Merge remote-tracking branch 'origin/develop' into staging

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2024-12-02 14:37:39 +07:00
267 changed files with 5408 additions and 4075 deletions
+2
View File
@@ -286,12 +286,14 @@
"MINIO_ENDPOINT": "localhost:9000",
"TRANSACTOR_URL": "ws://localhost:3333",
"MONGO_URL": "mongodb://localhost:27017",
"DB_URL": "mongodb://localhost:27017",
"ACCOUNTS_URL": "http://localhost:3000",
"TELEGRAM_DATABASE": "telegram-service",
"ELASTIC_URL": "http://localhost:9200",
"REKONI_URL": "http://localhost:4004",
"MODEL_VERSION": "0.6.287"
},
"runtimeVersion": "20",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"sourceMaps": true,
"outputCapture": "std",
+6 -15
View File
@@ -15,14 +15,13 @@ import documents, {
import core, {
AttachedData,
BackupClient,
CollaborativeDoc,
Client as CoreClient,
Data,
MeasureContext,
Ref,
TxOperations,
generateId,
makeCollaborativeDoc,
makeDocCollabId,
systemAccountEmail,
type Blob
} from '@hcengineering/core'
@@ -101,7 +100,7 @@ async function createDocument (
abstract: '',
effectiveDate: 0,
reviewInterval: DEFAULT_PERIODIC_REVIEW_INTERVAL,
content: makeCollaborativeDoc(generateId()),
content: null,
snapshots: 0,
plannedEffectiveDate: 0
}
@@ -115,11 +114,6 @@ async function createDocument (
console.log('Creating controlled doc from template')
const copyContent = async (source: CollaborativeDoc, target: CollaborativeDoc): Promise<void> => {
// intentionally left empty
// even though the template has some content, it won't be used
}
const { success } = await createControlledDocFromTemplate(
txops,
templateId,
@@ -128,8 +122,7 @@ async function createDocument (
space,
undefined,
undefined,
documents.class.ControlledDocument,
copyContent
documents.class.ControlledDocument
)
if (!success) {
throw new Error('Failed to create controlled document from template')
@@ -184,7 +177,7 @@ async function createTemplateIfNotExist (
approvers: [],
coAuthors: [],
changeControl: ccRecordId,
content: makeCollaborativeDoc(generateId()),
content: null,
snapshots: 0,
plannedEffectiveDate: 0
}
@@ -229,9 +222,7 @@ async function createSections (
console.log('Creating document content')
const collabId = doc.content
console.log(`Collab doc ID: ${collabId}`)
const collabId = makeDocCollabId(doc, 'content')
try {
let content: string = ''
@@ -245,7 +236,7 @@ async function createSections (
content += `<h1>${section.title}</h1>${section.content}`
}
await collaborator.updateContent(collabId, { content })
await collaborator.updateMarkup(collabId, content)
} finally {
// do nothing
}
+1 -1
View File
@@ -406,7 +406,7 @@ services:
restart: unless-stopped
links:
- elastic
- postgres
- cockroach
ports:
- 4702:4702
environment:
+24 -41
View File
@@ -14,18 +14,18 @@
//
import attachment, { type Attachment } from '@hcengineering/attachment'
import chunter, { type ChatMessage } from '@hcengineering/chunter'
import { yDocToBuffer } from '@hcengineering/collaboration'
import { type Person } from '@hcengineering/contact'
import core, {
type Account,
type AttachedData,
type Class,
type Blob as PlatformBlob,
type CollaborativeDoc,
type Data,
type Doc,
type DocumentQuery,
generateId,
makeCollaborativeDoc,
makeCollabId,
type Mixin,
type Ref,
SortingOrder,
@@ -42,7 +42,7 @@ import task, {
type TaskType,
type TaskTypeWithFactory
} from '@hcengineering/task'
import { jsonToMarkup, jsonToYDocNoSchema, parseMessageMarkdown } from '@hcengineering/text'
import { jsonToMarkup, parseMessageMarkdown } from '@hcengineering/text'
import tracker, {
type Issue,
type IssueParentInfo,
@@ -52,7 +52,7 @@ import tracker, {
TimeReportDayType
} from '@hcengineering/tracker'
import { type MarkdownPreprocessor, NoopMarkdownPreprocessor } from './preprocessor'
import { type FileUploader, type UploadResult } from './uploader'
import { type FileUploader } from './uploader'
export interface ImportWorkspace {
projectTypes?: ImportProjectType[]
@@ -281,14 +281,15 @@ export class WorkspaceImporter {
): Promise<Ref<Document>> {
const id = doc.id ?? generateId<Document>()
const content = await doc.descrProvider()
const collabId = await this.createCollaborativeContent(id, 'content', content, teamspaceId)
const collabId = makeCollabId(document.class.Document, id, 'content')
const contentId = await this.createCollaborativeContent(id, collabId, content, teamspaceId)
const lastRank = await getFirstRank(this.client, teamspaceId, parentId)
const rank = makeRank(lastRank, undefined)
const attachedData: Data<Document> = {
title: doc.title,
content: collabId,
content: contentId,
parent: parentId,
attachments: 0,
embeddings: 0,
@@ -391,7 +392,8 @@ export class WorkspaceImporter {
): Promise<{ id: Ref<Issue>, identifier: string }> {
const issueId = issue.id ?? generateId<Issue>()
const content = await issue.descrProvider()
const collabId = await this.createCollaborativeContent(issueId, 'description', content, project._id)
const collabId = makeCollabId(tracker.class.Issue, issueId, 'description')
const contentId = await this.createCollaborativeContent(issueId, collabId, content, project._id)
const { number, identifier } =
issue.number !== undefined
@@ -412,7 +414,7 @@ export class WorkspaceImporter {
const issueData: AttachedData<Issue> = {
title: issue.title,
description: collabId,
description: contentId,
assignee: issue.assignee ?? null,
component: null,
number,
@@ -532,14 +534,10 @@ export class WorkspaceImporter {
}
const file = new File([blob], attachment.title)
const attachmentId = await this.createAttachment(
attachment.id ?? generateId<Attachment>(),
file,
spaceId,
parentId,
parentClass
)
if (attachmentId === null) {
try {
await this.createAttachment(attachment.id ?? generateId<Attachment>(), file, spaceId, parentId, parentClass)
} catch {
console.warn('Failed to upload attachment file: ', attachment.title)
}
}
@@ -550,22 +548,9 @@ export class WorkspaceImporter {
spaceId: Ref<Space>,
parentId: Ref<Doc>,
parentClass: Ref<Class<Doc<Space>>>
): Promise<Ref<Attachment> | null> {
const response = await this.fileUploader.uploadFile(id, id, file)
if (response.status !== 200) {
return null
}
const responseText = await response.text()
if (responseText === undefined) {
return null
}
const uploadResult = JSON.parse(responseText) as UploadResult[]
if (!Array.isArray(uploadResult) || uploadResult.length === 0) {
return null
}
): Promise<Ref<Attachment>> {
const attachmentId = generateId<Attachment>()
const blobId = await this.fileUploader.uploadFile(id, file)
await this.client.addCollection(
attachment.class.Attachment,
spaceId,
@@ -573,7 +558,7 @@ export class WorkspaceImporter {
parentClass,
'attachments',
{
file: uploadResult[0].id,
file: blobId,
lastModified: Date.now(),
name: file.name,
size: file.size,
@@ -581,25 +566,23 @@ export class WorkspaceImporter {
},
id
)
return id
return attachmentId
}
// Collaborative content handling
private async createCollaborativeContent (
id: Ref<Doc>,
field: string,
collabId: CollaborativeDoc,
content: string,
spaceId: Ref<Space>
): Promise<CollaborativeDoc> {
): Promise<Ref<PlatformBlob>> {
const json = parseMessageMarkdown(content ?? '', 'image://')
const processedJson = this.preprocessor.process(json, id, spaceId)
const collabId = makeCollaborativeDoc(id, 'description')
const yDoc = jsonToYDocNoSchema(processedJson, field)
const buffer = yDocToBuffer(yDoc)
const markup = jsonToMarkup(processedJson)
const buffer = Buffer.from(markup)
await this.fileUploader.uploadCollaborativeDoc(id, collabId, buffer)
return collabId
return await this.fileUploader.uploadCollaborativeDoc(collabId, buffer)
}
async findIssueStatusByName (name: string): Promise<Ref<IssueStatus>> {
+16 -21
View File
@@ -13,25 +13,19 @@
// limitations under the License.
//
import {
concatLink,
type Ref,
type Blob as PlatformBlob,
type Doc,
type CollaborativeDoc,
collaborativeDocParse
concatLink,
makeCollabJsonId
} from '@hcengineering/core'
export interface FileUploader {
uploadFile: (id: Ref<Doc>, name: string, file: File, contentType?: string) => Promise<Response>
uploadCollaborativeDoc: (id: Ref<Doc>, collabId: CollaborativeDoc, data: Buffer) => Promise<Response>
uploadFile: (name: string, file: Blob) => Promise<Ref<PlatformBlob>>
uploadCollaborativeDoc: (collabId: CollaborativeDoc, data: Buffer) => Promise<Ref<PlatformBlob>>
getFileUrl: (id: string) => string
}
export interface UploadResult {
key: 'file'
id: Ref<PlatformBlob>
}
export class FrontFileUploader implements FileUploader {
constructor (
private readonly frontUrl: string,
@@ -41,31 +35,32 @@ export class FrontFileUploader implements FileUploader {
this.getFileUrl = this.getFileUrl.bind(this)
}
public async uploadFile (id: Ref<Doc>, name: string, file: File, contentType?: string): Promise<Response> {
public async uploadFile (name: string, file: Blob): Promise<Ref<PlatformBlob>> {
const form = new FormData()
form.append('file', file, name)
form.append('type', contentType ?? file.type)
form.append('size', file.size.toString())
form.append('name', file.name)
form.append('id', id)
form.append('data', new Blob([file]))
return await fetch(concatLink(this.frontUrl, '/files'), {
const res = await fetch(concatLink(this.frontUrl, '/files'), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.token
},
body: form
})
if (res.ok && res.status === 200) {
return name as Ref<PlatformBlob>
}
throw new Error('Failed to upload file')
}
public getFileUrl (id: string): string {
return concatLink(this.frontUrl, `/files/${this.workspaceId}/${id}?file=${id}&workspace=${this.workspaceId}`)
}
public async uploadCollaborativeDoc (id: Ref<Doc>, collabId: CollaborativeDoc, data: Buffer): Promise<Response> {
const file = new File([data], collabId)
const { documentId } = collaborativeDocParse(collabId)
return await this.uploadFile(id, documentId, file, 'application/ydoc')
public async uploadCollaborativeDoc (collabId: CollaborativeDoc, data: Buffer): Promise<Ref<PlatformBlob>> {
const blobId = makeCollabJsonId(collabId)
const blob = new Blob([data], { type: 'application/json' })
return await this.uploadFile(blobId, blob)
}
}
+12 -17
View File
@@ -12,21 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { yDocToBuffer } from '@hcengineering/collaboration'
import {
type AttachedData,
type Blob,
type Data,
type Doc,
generateId,
makeCollaborativeDoc,
type Ref,
type TxOperations
type TxOperations,
generateId,
makeCollabId
} from '@hcengineering/core'
import document, { type Document, getFirstRank, type Teamspace } from '@hcengineering/document'
import document, { type Document, type Teamspace, getFirstRank } from '@hcengineering/document'
import { makeRank } from '@hcengineering/rank'
import {
jsonToYDocNoSchema,
jsonToMarkup,
MarkupMarkType,
type MarkupNode,
MarkupNodeType,
@@ -325,8 +323,6 @@ async function createDBPageWithAttachments (
documentMetaMap?: Map<string, DocumentMetadata>
): Promise<void> {
const pageId = docMeta.id as Ref<Document>
const collabId = makeCollaborativeDoc(pageId, 'content')
const parentId = parentMeta !== undefined ? (parentMeta.id as Ref<Document>) : document.ids.NoParent
const lastRank = await getFirstRank(client, space, parentId)
@@ -334,7 +330,7 @@ async function createDBPageWithAttachments (
const object: Data<Document> = {
title: docMeta.name,
content: collabId,
content: null,
parent: parentId,
attachments: 0,
embeddings: 0,
@@ -409,7 +405,7 @@ async function importAttachment (
}
const file = new File([data], docMeta.name)
await fileUploader.uploadFile(docMeta.id as Ref<Doc>, docMeta.id, file)
await fileUploader.uploadFile(docMeta.id, file)
const attachedData: AttachedData<Attachment> = {
file: docMeta.id as Ref<Blob>,
@@ -444,13 +440,12 @@ async function importPageDocument (
if (documentMetaMap !== undefined) {
preProcessMarkdown(json, documentMetaMap, fileUploader)
}
const yDoc = jsonToYDocNoSchema(json, 'content')
const buffer = yDocToBuffer(yDoc)
const markup = jsonToMarkup(json)
const buffer = Buffer.from(markup)
const id = docMeta.id as Ref<Document>
const collabId = makeCollaborativeDoc(id, 'description')
await fileUploader.uploadCollaborativeDoc(id, collabId, buffer)
const collabId = makeCollabId(document.class.Document, id, 'content')
const blobId = await fileUploader.uploadCollaborativeDoc(collabId, buffer)
const parent = (parentMeta?.id as Ref<Document>) ?? document.ids.NoParent
@@ -459,7 +454,7 @@ async function importPageDocument (
const attachedData: Data<Document> = {
title: docMeta.name,
content: collabId,
content: blobId,
parent,
attachments: 0,
embeddings: 0,
+1 -1
View File
@@ -277,7 +277,7 @@ module.exports = [
plugins: [
new HtmlWebpackPlugin({
meta: {
viewport: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=1'
viewport: 'width=device-width, initial-scale=1.0'
}
}),
...(prod ? [new CompressionPlugin()] : []),
+1 -2
View File
@@ -21,7 +21,6 @@ import core, {
concatLink,
generateId,
getWorkspaceId,
makeCollaborativeDoc,
metricsToString,
newMetrics,
systemAccountEmail,
@@ -594,7 +593,7 @@ async function generateVacancy (client: TxOperations, members: Ref<PersonAccount
{
name: generateId().toString(),
number: 0,
fullDescription: makeCollaborativeDoc(_id, 'fullDescription'),
fullDescription: generateId(),
type: recruit.template.DefaultVacancy,
description: '',
private: false,
+6 -5
View File
@@ -16,7 +16,7 @@
import { getAccountDB, listWorkspacesRaw } from '@hcengineering/account'
import calendar from '@hcengineering/calendar'
import chunter, { type ChatMessage } from '@hcengineering/chunter'
import { loadCollaborativeDoc, saveCollaborativeDoc, yDocToBuffer } from '@hcengineering/collaboration'
import { loadCollabYdoc, saveCollabYdoc, yDocToBuffer } from '@hcengineering/collaboration'
import contact from '@hcengineering/contact'
import core, {
type ArrOf,
@@ -56,6 +56,7 @@ import core, {
generateId,
getObjectValue,
getWorkspaceId,
makeDocCollabId,
systemAccountEmail,
toIdMap,
updateAttribute
@@ -1201,8 +1202,8 @@ async function updateId (
const newMarkup = markup.replaceAll(doc._id, newId)
await update(h, db, contentDoc, { [attrName]: newMarkup })
} else if (attr.type._class === core.class.TypeCollaborativeDoc) {
const collaborativeDoc = (contentDoc as any)[attr.name] as CollaborativeDoc
await updateYDoc(ctx, collaborativeDoc, storage, workspaceId, contentDoc, newId, doc)
const collabId = makeDocCollabId(contentDoc, attr.name)
await updateYDoc(ctx, collabId, storage, workspaceId, contentDoc, newId, doc)
}
}
}
@@ -1250,7 +1251,7 @@ async function updateYDoc (
doc: RelatedDocument
): Promise<void> {
try {
const ydoc = await loadCollaborativeDoc(ctx, storage, workspaceId, _id)
const ydoc = await loadCollabYdoc(ctx, storage, workspaceId, _id)
if (ydoc === undefined) {
ctx.error('document content not found', { document: contentDoc._id })
return
@@ -1264,7 +1265,7 @@ async function updateYDoc (
})
if (updatedYDoc !== undefined) {
await saveCollaborativeDoc(ctx, storage, workspaceId, _id, updatedYDoc)
await saveCollabYdoc(ctx, storage, workspaceId, _id, updatedYDoc)
}
} catch {
// do nothing, the collaborative doc does not sem to exist yet
+2 -67
View File
@@ -58,13 +58,7 @@ import serverClientPlugin, {
listAccountWorkspaces,
updateBackupInfo
} from '@hcengineering/server-client'
import {
createBackupPipeline,
getConfig,
getServerPipeline,
registerServerPlugins,
registerStringLoaders
} from '@hcengineering/server-pipeline'
import { createBackupPipeline, getConfig } from '@hcengineering/server-pipeline'
import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token'
import toolPlugin, { FileModelLogger } from '@hcengineering/server-tool'
import { createWorkspace, upgradeWorkspace } from '@hcengineering/workspace-service'
@@ -89,8 +83,7 @@ import core, {
type Ref,
type Tx,
type Version,
type WorkspaceId,
type WorkspaceIdWithUrl
type WorkspaceId
} from '@hcengineering/core'
import { consoleModelLogger, type MigrateOperation } from '@hcengineering/model'
import contact from '@hcengineering/model-contact'
@@ -123,7 +116,6 @@ import {
} from './clean'
import { changeConfiguration } from './configuration'
import { moveAccountDbFromMongoToPG, moveFromMongoToPG, moveWorkspaceFromMongoToPG } from './db'
import { fixJsonMarkup, migrateMarkup } from './markup'
import { fixMixinForeignAttributes, showMixinForeignAttributes } from './mixin'
import { fixAccountEmails, renameAccount } from './renameAccount'
import { moveFiles, showLostFiles } from './storage'
@@ -1723,63 +1715,6 @@ export function devTool (
})
})
program
.command('fix-json-markup-mongo <workspace>')
.description('fixes double converted json markup')
.action(async (workspace: string) => {
const mongodbUri = getMongoDBUrl()
await withStorage(async (adapter) => {
const wsid = getWorkspaceId(workspace)
const endpoint = await getTransactorEndpoint(generateToken(systemAccountEmail, wsid), 'external')
await fixJsonMarkup(toolCtx, mongodbUri, adapter, wsid, endpoint)
})
})
program
.command('migrate-markup-mongo')
.description('migrates collaborative markup to storage')
.option('-w, --workspace <workspace>', 'Selected workspace only', '')
.option('-c, --concurrency <concurrency>', 'Number of documents being processed concurrently', '10')
.action(async (cmd: { workspace: string, concurrency: string }) => {
const { dbUrl, txes } = prepareTools()
const mongodbUri = getMongoDBUrl()
await withDatabase(dbUrl, async (db) => {
await withStorage(async (adapter) => {
const workspaces = await listWorkspacesPure(db)
const client = getMongoClient(mongodbUri)
const _client = await client.getClient()
let index = 0
try {
for (const workspace of workspaces) {
if (cmd.workspace !== '' && workspace.workspace !== cmd.workspace) {
continue
}
const wsId = getWorkspaceId(workspace.workspace)
console.log('processing workspace', workspace.workspace, index, workspaces.length)
const wsUrl: WorkspaceIdWithUrl = {
name: workspace.workspace,
workspaceName: workspace.workspaceName ?? '',
workspaceUrl: workspace.workspaceUrl ?? ''
}
registerServerPlugins()
registerStringLoaders()
const { pipeline } = await getServerPipeline(toolCtx, txes, dbUrl, wsUrl)
await migrateMarkup(toolCtx, adapter, wsId, _client, pipeline, parseInt(cmd.concurrency))
console.log('...done', workspace.workspace)
index++
}
} finally {
client.close()
}
})
})
})
program
.command('remove-duplicates-ids-mongo <workspaces>')
.description('remove duplicates ids for futue migration')
-207
View File
@@ -1,207 +0,0 @@
import { saveCollaborativeDoc } from '@hcengineering/collaboration'
import core, {
type AnyAttribute,
type Class,
type Client as CoreClient,
type Doc,
type Domain,
type Hierarchy,
type MeasureContext,
type Ref,
type WorkspaceId,
collaborativeDocParse,
makeCollaborativeDoc,
RateLimiter
} from '@hcengineering/core'
import { getMongoClient, getWorkspaceMongoDB } from '@hcengineering/mongo'
import { type Pipeline, type StorageAdapter } from '@hcengineering/server-core'
import { connect } from '@hcengineering/server-tool'
import { jsonToText, markupToYDoc } from '@hcengineering/text'
import { type Db, type FindCursor, type MongoClient } from 'mongodb'
export async function fixJsonMarkup (
ctx: MeasureContext,
mongoUrl: string,
storageAdapter: StorageAdapter,
workspaceId: WorkspaceId,
transactorUrl: string
): Promise<void> {
const connection = (await connect(transactorUrl, workspaceId, undefined, {
mode: 'backup'
})) as unknown as CoreClient
const hierarchy = connection.getHierarchy()
const client = getMongoClient(mongoUrl)
const _client = await client.getClient()
const db = getWorkspaceMongoDB(_client, workspaceId)
try {
const classes = hierarchy.getDescendants(core.class.Doc)
for (const _class of classes) {
const domain = hierarchy.findDomain(_class)
if (domain === undefined) continue
const attributes = hierarchy.getAllAttributes(_class)
const filtered = Array.from(attributes.values()).filter((attribute) => {
return hierarchy.isDerived(attribute.type._class, core.class.TypeMarkup)
})
if (filtered.length === 0) continue
await processFixJsonMarkupFor(ctx, domain, _class, filtered, workspaceId, db, storageAdapter)
}
} finally {
client.close()
await connection.close()
}
}
async function processFixJsonMarkupFor (
ctx: MeasureContext,
domain: Domain,
_class: Ref<Class<Doc>>,
attributes: AnyAttribute[],
workspaceId: WorkspaceId,
db: Db,
storageAdapter: StorageAdapter
): Promise<void> {
const collection = db.collection<Doc>(domain)
const docs = await collection.find({ _class }).toArray()
for (const doc of docs) {
const update: Record<string, any> = {}
const remove = []
for (const attribute of attributes) {
try {
const value = (doc as any)[attribute.name]
if (value != null) {
let res = value
while (true) {
try {
const json = JSON.parse(res)
const text = jsonToText(json)
JSON.parse(text)
res = text
} catch {
break
}
}
if (res !== value) {
update[attribute.name] = res
remove.push(makeCollaborativeDoc(doc._id, attribute.name))
}
}
} catch {}
}
if (Object.keys(update).length > 0) {
try {
await collection.updateOne({ _id: doc._id }, { $set: update })
} catch (err) {
console.error('failed to update document', doc._class, doc._id, err)
}
}
if (remove.length > 0) {
try {
await storageAdapter.remove(ctx, workspaceId, remove)
} catch (err) {
console.error('failed to remove objects from storage', doc._class, doc._id, remove, err)
}
}
}
}
export async function migrateMarkup (
ctx: MeasureContext,
storageAdapter: StorageAdapter,
workspaceId: WorkspaceId,
client: MongoClient,
pipeline: Pipeline,
concurrency: number
): Promise<void> {
const hierarchy = pipeline.context.hierarchy
const workspaceDb = client.db(workspaceId.name)
const classes = hierarchy.getDescendants(core.class.Doc)
for (const _class of classes) {
const domain = hierarchy.findDomain(_class)
if (domain === undefined) continue
const allAttributes = hierarchy.getAllAttributes(_class)
const attributes = Array.from(allAttributes.values()).filter((attribute) => {
return hierarchy.isDerived(attribute.type._class, 'core:class:TypeCollaborativeMarkup' as Ref<Class<Doc>>)
})
if (attributes.length === 0) continue
if (hierarchy.isMixin(_class) && attributes.every((p) => p.attributeOf !== _class)) continue
const collection = workspaceDb.collection(domain)
const filter = hierarchy.isMixin(_class) ? { [_class]: { $exists: true } } : { _class }
const iterator = collection.find<Doc>(filter)
try {
await processMigrateMarkupFor(ctx, hierarchy, storageAdapter, workspaceId, attributes, iterator, concurrency)
} finally {
await iterator.close()
}
}
}
async function processMigrateMarkupFor (
ctx: MeasureContext,
hierarchy: Hierarchy,
storageAdapter: StorageAdapter,
workspaceId: WorkspaceId,
attributes: AnyAttribute[],
iterator: FindCursor<Doc>,
concurrency: number
): Promise<void> {
const rateLimiter = new RateLimiter(concurrency)
let processed = 0
while (true) {
const doc = await iterator.next()
if (doc === null) break
const timestamp = Date.now()
const revisionId = `${timestamp}`
await rateLimiter.exec(async () => {
for (const attribute of attributes) {
const collaborativeDoc = makeCollaborativeDoc(doc._id, attribute.name, revisionId)
const { documentId } = collaborativeDocParse(collaborativeDoc)
const value = hierarchy.isMixin(attribute.attributeOf)
? ((doc as any)[attribute.attributeOf]?.[attribute.name] as string)
: ((doc as any)[attribute.name] as string)
if (value != null && value.startsWith('{')) {
const blob = await storageAdapter.stat(ctx, workspaceId, documentId)
// only for documents not in storage
if (blob === undefined) {
try {
const ydoc = markupToYDoc(value, attribute.name)
await saveCollaborativeDoc(ctx, storageAdapter, workspaceId, collaborativeDoc, ydoc)
} catch (err) {
console.error('failed to process document', doc._class, doc._id, err)
}
}
}
}
})
processed += 1
if (processed % 100 === 0) {
await rateLimiter.waitProcessing()
console.log('...processing', processed)
}
}
await rateLimiter.waitProcessing()
console.log('processed', processed)
}
+2 -2
View File
@@ -39,7 +39,7 @@ import {
IndexKind,
type Blob,
type Class,
type CollaborativeDoc,
type MarkupBlobRef,
type Domain,
type Ref,
type Timestamp
@@ -175,7 +175,7 @@ export class TMember extends TAttachedDoc implements Member {
export class TOrganization extends TContact implements Organization {
@Prop(TypeCollaborativeDoc(), core.string.Description)
@Index(IndexKind.FullText)
description!: CollaborativeDoc
description!: MarkupBlobRef | null
@Prop(Collection(contact.class.Member), contact.string.Members)
members!: number
+15 -10
View File
@@ -4,12 +4,12 @@
import attachment, { type Attachment } from '@hcengineering/attachment'
import {
clone,
loadCollaborativeDoc,
saveCollaborativeDoc,
YAbstractType,
YXmlElement,
YXmlText
YXmlText,
YAbstractType,
yXmlElementClone,
loadCollabYdoc,
saveCollabYdoc
} from '@hcengineering/collaboration'
import {
type ChangeControl,
@@ -26,7 +26,7 @@ import {
type Doc,
DOMAIN_TX,
generateId,
makeCollaborativeDoc,
makeDocCollabId,
MeasureMetricsContext,
type Ref,
SortingOrder,
@@ -144,7 +144,7 @@ async function createProductChangeControlTemplate (tx: TxOperations): Promise<vo
minor: 1,
state: DocumentState.Effective,
commentSequence: 0,
content: makeCollaborativeDoc(generateId())
content: null
},
ccCategory
)
@@ -295,7 +295,8 @@ async function migrateDocSections (client: MigrationClient): Promise<void> {
// Migrate sections headers + content
try {
const ydoc = await loadCollaborativeDoc(ctx, storage, client.workspaceId, document.content)
const collabId = makeDocCollabId(document, 'content')
const ydoc = await loadCollabYdoc(ctx, storage, client.workspaceId, collabId)
if (ydoc === undefined) {
// no content, ignore
continue
@@ -331,13 +332,17 @@ async function migrateDocSections (client: MigrationClient): Promise<void> {
...(sectionContent
.toArray()
.map((item) =>
item instanceof YAbstractType ? (item instanceof YXmlElement ? clone(item) : item.clone()) : item
item instanceof YAbstractType
? item instanceof YXmlElement
? yXmlElementClone(item)
: item.clone()
: item
) as any)
])
}
})
await saveCollaborativeDoc(ctx, storage, client.workspaceId, document.content, ydoc)
await saveCollabYdoc(ctx, storage, client.workspaceId, collabId, ydoc)
} catch (err) {
ctx.error('error collaborative document content migration', { error: err, document: document.title })
}
+5 -5
View File
@@ -48,17 +48,17 @@ import {
DateRangeMode,
IndexKind,
type Class,
type MarkupBlobRef,
type Doc,
type Domain,
type Ref,
type Timestamp,
type Type,
type CollectionSize,
type CollaborativeDoc,
type Role,
type TypedSpace,
type Account,
type RolesAssignment,
type Domain
type RolesAssignment
} from '@hcengineering/core'
import {
ArrOf,
@@ -259,7 +259,7 @@ export class TDocument extends TDoc implements Document {
state!: DocumentState
@Prop(TypeCollaborativeDoc(), documents.string.CollaborativeDocument)
content!: CollaborativeDoc
content!: MarkupBlobRef | null
@Prop(Collection(tags.class.TagReference), documents.string.Labels)
labels?: CollectionSize<TagReference>
@@ -425,7 +425,7 @@ export class TDocumentSnapshot extends TAttachedDoc implements DocumentSnapshot
@Prop(TypeCollaborativeDoc(), documents.string.CollaborativeDocument)
@Hidden()
content!: CollaborativeDoc
content!: MarkupBlobRef | null
@Prop(TypeDocumentState(), documents.string.Status)
state?: DocumentState
+2 -6
View File
@@ -28,7 +28,7 @@ import {
type Card,
type Class,
type ClassifierKind,
type CollaborativeDoc,
type MarkupBlobRef,
type Collection,
type Configuration,
type ConfigurationElement,
@@ -121,7 +121,7 @@ export class TCard extends TDoc implements Card {
title!: string
@Prop(TypeCollaborativeDoc(), core.string.Description)
description!: CollaborativeDoc | null
description!: MarkupBlobRef | null
@Prop(TypeString(), core.string.Id)
identifier?: string | undefined
@@ -373,10 +373,6 @@ export class TDomainIndexConfiguration extends TDoc implements DomainIndexConfig
@Model(core.class.TypeCollaborativeDoc, core.class.Type)
export class TTypeCollaborativeDoc extends TType {}
@UX(core.string.CollaborativeDocVersion)
@Model(core.class.TypeCollaborativeDocVersion, core.class.Type)
export class TTypeCollaborativeDocVersion extends TType {}
@UX(core.string.Rank)
@Model(core.class.TypeRank, core.class.Type)
export class TTypeRank extends TType {}
-2
View File
@@ -58,7 +58,6 @@ import {
TTypeBlob,
TTypeBoolean,
TTypeCollaborativeDoc,
TTypeCollaborativeDocVersion,
TTypeDate,
TTypeFileSize,
TTypeHyperlink,
@@ -125,7 +124,6 @@ export function createModel (builder: Builder): void {
TEnumOf,
TTypeMarkup,
TTypeCollaborativeDoc,
TTypeCollaborativeDocVersion,
TArrOf,
TRefTo,
TTypeDate,
+164 -23
View File
@@ -13,19 +13,21 @@
// limitations under the License.
//
import { saveCollaborativeDoc } from '@hcengineering/collaboration'
import { saveCollabJson, saveCollabYdoc, yDocFromBuffer } from '@hcengineering/collaboration'
import core, {
collaborativeDocParse,
coreId,
DOMAIN_MODEL_TX,
DOMAIN_SPACE,
DOMAIN_STATUS,
DOMAIN_TX,
generateId,
makeCollaborativeDoc,
makeDocCollabId,
makeCollabJsonId,
makeCollabYdocId,
MeasureMetricsContext,
RateLimiter,
type AnyAttribute,
type Blob,
type Class,
type Doc,
type Domain,
@@ -48,7 +50,6 @@ import {
type MigrationUpgradeClient
} from '@hcengineering/model'
import { type StorageAdapter } from '@hcengineering/storage'
import { markupToYDoc } from '@hcengineering/text'
async function migrateStatusesToModel (client: MigrationClient): Promise<void> {
// Move statuses to model:
@@ -176,7 +177,7 @@ async function migrateCollaborativeContentToStorage (client: MigrationClient): P
const iterator = await client.traverse(domain, query)
try {
console.log('processing', _class)
ctx.info('processing', { _class })
await processMigrateContentFor(ctx, domain, attributes, client, storageAdapter, iterator)
} finally {
await iterator.close()
@@ -204,9 +205,6 @@ async function processMigrateContentFor (
break
}
const timestamp = Date.now()
const revisionId = `${timestamp}`
const operations: { filter: MigrationDocumentQuery<Doc>, update: MigrateUpdate<Doc> }[] = []
for (const doc of docs) {
@@ -214,8 +212,6 @@ async function processMigrateContentFor (
const update: MigrateUpdate<Doc> = {}
for (const attribute of attributes) {
const collaborativeDoc = makeCollaborativeDoc(doc._id, attribute.name, revisionId)
const value = hierarchy.isMixin(attribute.attributeOf)
? ((doc as any)[attribute.attributeOf]?.[attribute.name] as string)
: ((doc as any)[attribute.name] as string)
@@ -224,22 +220,20 @@ async function processMigrateContentFor (
? `${attribute.attributeOf}.${attribute.name}`
: attribute.name
const collabId = makeDocCollabId(doc, attribute.name)
const blobId = makeCollabJsonId(collabId)
if (value != null && value.startsWith('{')) {
const { documentId } = collaborativeDocParse(collaborativeDoc)
const blob = await storageAdapter.stat(ctx, client.workspaceId, documentId)
// only for documents not in storage
if (blob === undefined) {
try {
const ydoc = markupToYDoc(value, attribute.name)
await saveCollaborativeDoc(ctx, storageAdapter, client.workspaceId, collaborativeDoc, ydoc)
} catch (err) {
console.error('failed to process document', doc._class, doc._id, err)
}
try {
const buffer = Buffer.from(value)
await storageAdapter.put(ctx, client.workspaceId, blobId, buffer, 'application/json', buffer.length)
} catch (err) {
ctx.error('failed to process document', { _class: doc._class, _id: doc._id, err })
}
update[attributeName] = collaborativeDoc
update[attributeName] = blobId
} else if (value == null || value === '') {
update[attributeName] = collaborativeDoc
update[attributeName] = null
}
}
@@ -256,10 +250,153 @@ async function processMigrateContentFor (
}
processed += docs.length
console.log('...processed', processed)
ctx.info('...processed', { count: processed })
}
}
async function migrateCollaborativeDocsToJson (client: MigrationClient): Promise<void> {
const ctx = new MeasureMetricsContext('migrateCollaborativeDocsToJson', {})
const storageAdapter = client.storageAdapter
const hierarchy = client.hierarchy
const classes = hierarchy.getDescendants(core.class.Doc)
for (const _class of classes) {
const domain = hierarchy.findDomain(_class)
if (domain === undefined) continue
const allAttributes = hierarchy.getAllAttributes(_class)
const attributes = Array.from(allAttributes.values()).filter((attribute) => {
return hierarchy.isDerived(attribute.type._class, core.class.TypeCollaborativeDoc)
})
if (attributes.length === 0) continue
if (hierarchy.isMixin(_class) && attributes.every((p) => p.attributeOf !== _class)) continue
const query = hierarchy.isMixin(_class) ? { [_class]: { $exists: true } } : { _class }
const iterator = await client.traverse(domain, query)
try {
ctx.info('processing', { _class })
await processMigrateJsonForDomain(ctx, domain, attributes, client, storageAdapter, iterator)
} finally {
await iterator.close()
}
}
}
async function processMigrateJsonForDomain (
ctx: MeasureContext,
domain: Domain,
attributes: AnyAttribute[],
client: MigrationClient,
storageAdapter: StorageAdapter,
iterator: MigrationIterator<Doc>
): Promise<void> {
const rateLimiter = new RateLimiter(10)
let processed = 0
while (true) {
const docs = await iterator.next(100)
if (docs === null || docs.length === 0) {
break
}
const operations: { filter: MigrationDocumentQuery<Doc>, update: MigrateUpdate<Doc> }[] = []
for (const doc of docs) {
await rateLimiter.exec(async () => {
const update = await processMigrateJsonForDoc(ctx, doc, attributes, client, storageAdapter)
if (Object.keys(update).length > 0) {
operations.push({ filter: { _id: doc._id }, update })
}
})
}
await rateLimiter.waitProcessing()
if (operations.length > 0) {
await client.bulk(domain, operations)
}
processed += docs.length
ctx.info('...processed', { count: processed })
}
}
async function processMigrateJsonForDoc (
ctx: MeasureContext,
doc: Doc,
attributes: AnyAttribute[],
client: MigrationClient,
storageAdapter: StorageAdapter
): Promise<MigrateUpdate<Doc>> {
const { hierarchy, workspaceId } = client
const update: MigrateUpdate<Doc> = {}
for (const attribute of attributes) {
const value = hierarchy.isMixin(attribute.attributeOf)
? ((doc as any)[attribute.attributeOf]?.[attribute.name] as string)
: ((doc as any)[attribute.name] as string)
if (value == null || value === '') {
continue
}
const attributeName = hierarchy.isMixin(attribute.attributeOf)
? `${attribute.attributeOf}.${attribute.name}`
: attribute.name
// Name of existing ydoc document
// original value here looks like '65b7f82f4d422b89d4cbdd6f:HEAD:0'
// where the first part is the blob id
const currentYdocId = value.split(':')[0] as Ref<Blob>
const collabId = makeDocCollabId(doc, attribute.name)
if (value.startsWith('{')) {
// For some reason we have documents that are already markups
const jsonId = await saveCollabJson(ctx, storageAdapter, workspaceId, collabId, value)
update[attributeName] = jsonId
continue
}
try {
const stat = await storageAdapter.stat(ctx, workspaceId, currentYdocId)
if (stat !== undefined) {
if (stat.contentType.includes('application/ydoc')) {
const buffer = await storageAdapter.read(ctx, workspaceId, currentYdocId)
const ydoc = yDocFromBuffer(Buffer.concat(buffer as any))
// If document id has changed, save it with new name to ensure we will be able to load it later
const ydocId = makeCollabYdocId(collabId)
if (ydocId !== currentYdocId) {
ctx.info('saving collaborative doc with new name', { collabId, ydocId, currentYdocId })
await saveCollabYdoc(ctx, storageAdapter, workspaceId, collabId, ydoc)
// do not bother with deletion so we can restore content is something goes wrong
// await storageAdapter.remove(ctx, client.workspaceId, [currentYdocId])
}
// Save document as JSON and save blob Id
const jsonId = await saveCollabJson(ctx, storageAdapter, workspaceId, collabId, ydoc)
update[attributeName] = jsonId
} else {
// it is not ydoc, do nothing
continue
}
} else {
// document is empty, unset
const unset = update.$unset ?? {}
update.$unset = { ...unset, [attribute.name]: 1 }
}
} catch (err) {
ctx.warn('failed to process collaborative doc', { workspaceId, collabId, currentYdocId, err })
}
}
return update
}
export const coreOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, coreId, [
@@ -351,6 +488,10 @@ export const coreOperation: MigrateOperation = {
DOMAIN_MODEL_TX
)
}
},
{
state: 'collaborative-docs-to-json',
func: migrateCollaborativeDocsToJson
}
])
},
+1
View File
@@ -28,6 +28,7 @@
},
"dependencies": {
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-activity": "^0.6.0",
"@hcengineering/model-chunter": "^0.6.0",
"@hcengineering/model-workbench": "^0.6.1",
"@hcengineering/model-attachment": "^0.6.0",
+11 -6
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2022, 2023 Hardcore Engineering Inc.
// Copyright © 2022, 2023, 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@@ -14,7 +14,7 @@
//
import activity from '@hcengineering/activity'
import type { Class, CollaborativeDoc, CollectionSize, Domain, Rank, Role, RolesAssignment } from '@hcengineering/core'
import type { Class, CollectionSize, MarkupBlobRef, Domain, Rank, Role, RolesAssignment } from '@hcengineering/core'
import { Account, AccountRole, IndexKind, Ref } from '@hcengineering/core'
import {
type Document,
@@ -32,8 +32,8 @@ import {
Mixin,
Model,
Prop,
ReadOnly,
TypeCollaborativeDoc,
TypeCollaborativeDocVersion,
TypeNumber,
TypeRef,
TypeString,
@@ -77,7 +77,7 @@ export class TDocument extends TDoc implements Document, Todoable {
title!: string
@Prop(TypeCollaborativeDoc(), document.string.Document)
content!: CollaborativeDoc
content!: MarkupBlobRef | null
@Prop(TypeRef(document.class.Document), document.string.ParentDocument)
parent!: Ref<Document>
@@ -137,8 +137,9 @@ export class TDocumentSnapshot extends TDoc implements DocumentSnapshot {
@Index(IndexKind.FullText)
title!: string
@Prop(TypeCollaborativeDocVersion(), document.string.Document)
content!: CollaborativeDoc
@Prop(TypeCollaborativeDoc(), document.string.Document)
@ReadOnly()
content!: MarkupBlobRef
@Prop(TypeRef(document.class.Document), document.string.ParentDocument)
parent!: Ref<Document>
@@ -312,6 +313,10 @@ function defineDocument (builder: Builder): void {
component: document.component.DocumentIcon
})
builder.mixin(document.class.Document, core.class.Class, view.mixin.AttributeEditor, {
inlineEditor: document.component.DocumentInlineEditor
})
// Actions
createAction(builder, {
+55 -20
View File
@@ -13,8 +13,18 @@
// limitations under the License.
//
import { DOMAIN_MODEL_TX, MeasureMetricsContext, SortingOrder, type CollaborativeDoc } from '@hcengineering/core'
import { type Document, type DocumentSnapshot, type Teamspace } from '@hcengineering/document'
import {
type Class,
type CollaborativeDoc,
type Doc,
type Ref,
DOMAIN_TX,
DOMAIN_MODEL_TX,
MeasureMetricsContext,
SortingOrder,
makeDocCollabId
} from '@hcengineering/core'
import { type DocumentSnapshot, type Document, type Teamspace } from '@hcengineering/document'
import {
migrateSpaceRanks,
tryMigrate,
@@ -24,11 +34,13 @@ import {
type MigrationDocumentQuery,
type MigrationUpgradeClient
} from '@hcengineering/model'
import { DOMAIN_ACTIVITY } from '@hcengineering/model-activity'
import core, { DOMAIN_SPACE } from '@hcengineering/model-core'
import { DOMAIN_NOTIFICATION } from '@hcengineering/notification'
import { type Asset } from '@hcengineering/platform'
import { makeRank } from '@hcengineering/rank'
import { loadCollaborativeDoc, saveCollaborativeDoc, yDocCopyXmlField } from '@hcengineering/collaboration'
import { loadCollabYdoc, saveCollabYdoc, yDocCopyXmlField } from '@hcengineering/collaboration'
import document, { documentId, DOMAIN_DOCUMENT } from './index'
async function migrateDocumentIcons (client: MigrationClient): Promise<void> {
@@ -197,23 +209,23 @@ async function renameFieldsRevert (client: MigrationClient): Promise<void> {
}
)
if (document.description.includes('%description:')) {
try {
const ydoc = await loadCollaborativeDoc(ctx, storage, client.workspaceId, document.description)
if (ydoc === undefined) {
continue
}
try {
const collabId = makeDocCollabId(document, 'content')
if (!ydoc.share.has('description') || ydoc.share.has('content')) {
continue
}
yDocCopyXmlField(ydoc, 'description', 'content')
await saveCollaborativeDoc(ctx, storage, client.workspaceId, document.description, ydoc)
} catch (err) {
ctx.error('error document content migration', { error: err, document: document.title })
const ydoc = await loadCollabYdoc(ctx, storage, client.workspaceId, collabId)
if (ydoc === undefined) {
continue
}
if (!ydoc.share.has('description') || ydoc.share.has('content')) {
continue
}
yDocCopyXmlField(ydoc, 'description', 'content')
await saveCollabYdoc(ctx, storage, client.workspaceId, collabId, ydoc)
} catch (err) {
ctx.error('error document content migration', { error: err, document: document.title })
}
}
@@ -246,7 +258,9 @@ async function restoreContentField (client: MigrationClient): Promise<void> {
for (const document of documents) {
try {
const ydoc = await loadCollaborativeDoc(ctx, storage, client.workspaceId, document.content)
const collabId = makeDocCollabId(document, 'content')
const ydoc = await loadCollabYdoc(ctx, storage, client.workspaceId, collabId)
if (ydoc === undefined) {
ctx.error('document content not found', { document: document.title })
continue
@@ -260,7 +274,7 @@ async function restoreContentField (client: MigrationClient): Promise<void> {
if (ydoc.share.has('')) {
yDocCopyXmlField(ydoc, '', 'content')
if (ydoc.share.has('content')) {
await saveCollaborativeDoc(ctx, storage, client.workspaceId, document.content, ydoc)
await saveCollabYdoc(ctx, storage, client.workspaceId, collabId, ydoc)
} else {
ctx.error('document content still not found', { document: document.title })
}
@@ -281,6 +295,23 @@ async function migrateRanks (client: MigrationClient): Promise<void> {
}
}
async function removeOldClasses (client: MigrationClient): Promise<void> {
const classes = [
'document:class:DocumentContent',
'document:class:DocumentSnapshot',
'document:class:DocumentVersion',
'document:class:DocumentRequest'
] as Ref<Class<Doc>>[]
for (const _class of classes) {
await client.deleteMany(DOMAIN_DOCUMENT, { _class })
await client.deleteMany(DOMAIN_ACTIVITY, { attachedToClass: _class })
await client.deleteMany(DOMAIN_ACTIVITY, { objectClass: _class })
await client.deleteMany(DOMAIN_NOTIFICATION, { attachedToClass: _class })
await client.deleteMany(DOMAIN_TX, { objectClass: _class })
await client.deleteMany(DOMAIN_TX, { 'tx.objectClass': _class })
}
}
export const documentOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, documentId, [
@@ -321,6 +352,10 @@ export const documentOperation: MigrateOperation = {
{
state: 'migrateRanks',
func: migrateRanks
},
{
state: 'removeOldClasses',
func: removeOldClasses
}
])
},
+1
View File
@@ -27,6 +27,7 @@ export default mergeIds(documentId, document, {
component: {
Documents: '' as AnyComponent,
DocumentPresenter: '' as AnyComponent,
DocumentInlineEditor: '' as AnyComponent,
NotificationDocumentPresenter: '' as AnyComponent,
TeamspaceSpacePresenter: '' as AnyComponent,
Move: '' as AnyComponent,
+1 -46
View File
@@ -13,14 +13,7 @@
// limitations under the License.
//
import {
AccountRole,
DOMAIN_MODEL_TX,
makeCollaborativeDoc,
TxOperations,
type Ref,
type Status
} from '@hcengineering/core'
import { AccountRole, DOMAIN_MODEL_TX, TxOperations, type Ref, type Status } from '@hcengineering/core'
import { leadId, type Lead } from '@hcengineering/lead'
import {
tryMigrate,
@@ -213,44 +206,6 @@ export const leadOperation: MigrateOperation = {
}
}
)
const it = await client.traverse(DOMAIN_CONTACT, {
_class: contact.class.Organization,
description: { $exists: false }
})
while (true) {
const docs = await it.next(50)
if (docs == null || docs.length === 0) {
break
}
await client.bulk(
DOMAIN_CONTACT,
docs.map((doc) => ({
filter: { _id: doc._id },
update: { $set: { description: makeCollaborativeDoc(doc._id, 'description') } }
}))
)
}
const it2 = await client.traverse(DOMAIN_CONTACT, { [lead.mixin.Customer + '.customerDescription']: null })
while (true) {
const docs = await it2.next(50)
if (docs == null || docs.length === 0) {
break
}
await client.bulk(
DOMAIN_CONTACT,
docs.map((doc) => ({
filter: { _id: doc._id },
update: {
$set: {
[lead.mixin.Customer + '.customerDescription']: makeCollaborativeDoc(
docs[0]._id,
'customerDescription'
)
}
}
}))
)
}
}
}
])
+2 -2
View File
@@ -17,7 +17,7 @@ import type { Employee } from '@hcengineering/contact'
import {
Account,
IndexKind,
type CollaborativeDoc,
type MarkupBlobRef,
type Role,
type RolesAssignment,
type Ref,
@@ -97,7 +97,7 @@ export class TCustomer extends TContact implements Customer {
@Prop(TypeCollaborativeDoc(), lead.string.Description)
@Index(IndexKind.FullText)
customerDescription!: CollaborativeDoc
customerDescription!: MarkupBlobRef | null
}
@Mixin(lead.mixin.DefaultFunnelTypeData, lead.class.Funnel)
+7 -11
View File
@@ -16,15 +16,15 @@
import contact, { type Employee, type Person } from '@hcengineering/contact'
import {
AccountRole,
type CollaborativeDoc,
type CollectionSize,
DateRangeMode,
type Doc,
type Domain,
DOMAIN_TRANSIENT,
IndexKind,
type MarkupBlobRef,
type Ref,
type Timestamp
type Timestamp,
DOMAIN_TRANSIENT,
DateRangeMode,
IndexKind
} from '@hcengineering/core'
import {
type DevicesPreference,
@@ -48,7 +48,6 @@ import {
type Builder,
Collection,
Collection as PropCollection,
Hidden,
Index,
Mixin,
Model,
@@ -91,7 +90,7 @@ export class TRoom extends TDoc implements Room {
@Prop(TypeCollaborativeDoc(), core.string.Description)
@Index(IndexKind.FullText)
description!: CollaborativeDoc
description!: MarkupBlobRef | null
type!: RoomType
@@ -203,16 +202,13 @@ export class TMeetingMinutes extends TAttachedDoc implements MeetingMinutes, Tod
@ReadOnly()
declare attachedTo: Ref<Doc>
@Hidden()
sid!: string
@Prop(TypeString(), view.string.Title)
@Index(IndexKind.FullText)
title!: string
@Prop(TypeCollaborativeDoc(), core.string.Description)
@Index(IndexKind.FullText)
description!: CollaborativeDoc
description!: MarkupBlobRef | null
@Prop(TypeAny(love.component.MeetingMinutesStatusPresenter, love.string.Status), love.string.Status, {
editor: love.component.MeetingMinutesStatusPresenter
+3 -16
View File
@@ -14,7 +14,7 @@
//
import contact from '@hcengineering/contact'
import { type Space, TxOperations, type Ref, makeCollaborativeDoc } from '@hcengineering/core'
import { type Space, TxOperations, type Ref } from '@hcengineering/core'
import drive from '@hcengineering/drive'
import {
MeetingStatus,
@@ -23,8 +23,7 @@ import {
createDefaultRooms,
isOffice,
loveId,
type Floor,
type Room
type Floor
} from '@hcengineering/love'
import {
createDefaultSpace,
@@ -90,7 +89,7 @@ async function createReception (client: MigrationUpgradeClient): Promise<void> {
language: 'en',
startWithTranscription: false,
startWithRecording: false,
description: makeCollaborativeDoc(love.ids.Reception, 'description')
description: null
},
love.ids.Reception
)
@@ -156,18 +155,6 @@ export const loveOperation: MigrateOperation = {
await client.move(DOMAIN_LOVE, { _class: love.class.MeetingMinutes }, DOMAIN_MEETING_MINUTES)
}
},
{
state: 'create-description-collaborative',
func: async (client) => {
const rooms = await client.find<Room>(DOMAIN_LOVE, { _class: { $in: [love.class.Room, love.class.Office] } })
for (const room of rooms) {
const description = room.description
if (description == null) {
await client.update(DOMAIN_LOVE, room, { description: makeCollaborativeDoc(room._id, 'description') })
}
}
}
},
{
state: 'default-meeting-minutes-status',
func: async (client) => {
+2 -2
View File
@@ -17,8 +17,8 @@ import type { Employee, Organization } from '@hcengineering/contact'
import {
Account,
IndexKind,
type CollaborativeDoc,
type Collection,
type MarkupBlobRef,
type Domain,
type Markup,
type Ref,
@@ -68,7 +68,7 @@ import recruit from './plugin'
export class TVacancy extends TProject implements Vacancy {
@Prop(TypeCollaborativeDoc(), recruit.string.FullDescription)
@Index(IndexKind.FullText)
fullDescription!: CollaborativeDoc
fullDescription!: MarkupBlobRef | null
@Prop(TypeCollection(attachment.class.Attachment), attachment.string.Attachments, {
shortLabel: attachment.string.Files
@@ -1,51 +0,0 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import { TDefaultProjectTypeData } from './types'
import testManagement from './plugin'
export function defineDefaultSpace (builder: Builder): void {
defineDefaultProject(builder)
}
function defineDefaultProject (builder: Builder): void {
builder.createModel(TDefaultProjectTypeData)
builder.createDoc(
core.class.SpaceTypeDescriptor,
core.space.Model,
{
name: testManagement.string.TestProject,
description: testManagement.string.FullDescription,
icon: testManagement.icon.TestProject,
baseClass: testManagement.class.TestProject,
availablePermissions: [
core.permission.UpdateSpace,
core.permission.ArchiveSpace,
core.permission.ForbidDeleteObject
]
},
testManagement.descriptors.ProjectType
)
builder.createDoc(core.class.SpaceType, core.space.Model, {
name: 'Default project type',
descriptor: testManagement.descriptors.ProjectType,
roles: 0,
targetClass: testManagement.mixin.DefaultProjectTypeData
})
}
+156 -51
View File
@@ -16,7 +16,7 @@
import activity from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import core from '@hcengineering/model-core'
import { SortingOrder } from '@hcengineering/core'
import { SortingOrder, type FindOptions } from '@hcengineering/core'
import { type Builder } from '@hcengineering/model'
import view, { createAction } from '@hcengineering/model-view'
@@ -25,7 +25,7 @@ import print from '@hcengineering/model-print'
import tracker from '@hcengineering/model-tracker'
import { type ViewOptionsModel } from '@hcengineering/view'
import { testManagementId } from '@hcengineering/test-management'
import { testManagementId, type TestResult } from '@hcengineering/test-management'
import {
DOMAIN_TEST_MANAGEMENT,
@@ -37,8 +37,8 @@ import {
TTestCase,
TDefaultProjectTypeData,
TTestRun,
TTypeTestRunResult,
TTestRunItem
TTypeTestRunStatus,
TTestResult
} from './types'
import testManagement from './plugin'
@@ -55,7 +55,6 @@ function defineApplication (builder: Builder): void {
icon: testManagement.icon.TestManagementApplication,
alias: testManagementId,
hidden: false,
locationResolver: testManagement.resolver.Location,
navigatorModel: {
spaces: [
{
@@ -74,9 +73,7 @@ function defineApplication (builder: Builder): void {
componentProps: {
_class: testManagement.class.TestCase,
icon: testManagement.icon.TestLibrary,
label: testManagement.string.TestLibrary,
createLabel: testManagement.string.CreateTestCase,
createComponent: testManagement.component.CreateTestCase
label: testManagement.string.TestLibrary
},
navigationModel: {
navigationComponent: view.component.FoldersBrowser,
@@ -85,6 +82,7 @@ function defineApplication (builder: Builder): void {
mainComponentLabel: testManagement.string.TestCases,
mainComponentIcon: testManagement.icon.TestCases,
createComponent: testManagement.component.CreateTestSuite,
mainHeaderComponent: testManagement.component.RunButton,
navigationComponentProps: {
_class: testManagement.class.TestSuite,
icon: testManagement.icon.TestSuites,
@@ -100,21 +98,34 @@ function defineApplication (builder: Builder): void {
},
syncWithLocationQuery: true
}
}
/* TODO: UBERF-8584
},
{
id: opt.testRunsId,
id: 'testRuns',
label: testManagement.string.TestRuns,
icon: testManagement.icon.TestRuns,
component: workbench.component.SpecialView,
componentProps: {
_class: testManagement.class.TestRun,
_class: testManagement.class.TestResult,
icon: testManagement.icon.TestRuns,
title: testManagement.string.TestRuns,
createLabel: testManagement.string.NewTestRun,
createComponent: testManagement.component.CreateTestRun
label: testManagement.string.TestRuns
},
navigationModel: {
navigationComponent: view.component.FoldersBrowser,
navigationComponentLabel: testManagement.string.TestRun,
navigationComponentIcon: testManagement.icon.TestRuns,
mainComponentLabel: testManagement.string.TestResults,
mainComponentIcon: testManagement.icon.TestResult,
navigationComponentProps: {
_class: testManagement.class.TestRun,
icon: testManagement.icon.TestRuns,
title: testManagement.string.TestSuites,
titleKey: 'name',
getFolderLink: testManagement.function.GetTestRunLink,
plainList: true
},
syncWithLocationQuery: true
}
} */
}
]
}
]
@@ -135,8 +146,8 @@ export function createModel (builder: Builder): void {
TTestCase,
TDefaultProjectTypeData,
TTestRun,
TTestRunItem,
TTypeTestRunResult
TTypeTestRunStatus,
TTestResult
)
builder.mixin(testManagement.class.TestProject, core.class.Class, activity.mixin.ActivityDoc, {})
@@ -204,7 +215,7 @@ function defineSpaceType (builder: Builder): void {
core.class.SpaceType,
core.space.Model,
{
name: 'Default project type',
name: 'Default Test Management',
descriptor: testManagement.descriptors.ProjectType,
roles: 0,
targetClass: testManagement.mixin.DefaultProjectTypeData
@@ -250,13 +261,7 @@ function defineTestSuite (builder: Builder): void {
// Actions
builder.mixin(testManagement.class.TestSuite, core.class.Class, view.mixin.IgnoreActions, {
actions: [
view.action.Open,
view.action.OpenInNewTab,
print.action.Print,
tracker.action.EditRelatedTargets,
tracker.action.NewRelatedIssue
]
actions: [print.action.Print, tracker.action.EditRelatedTargets]
})
createAction(
@@ -276,6 +281,24 @@ function defineTestSuite (builder: Builder): void {
},
testManagement.action.CreateChildTestSuite
)
createAction(
builder,
{
action: testManagement.actionImpl.RunSelectedTests,
label: testManagement.string.RunTestCases,
icon: testManagement.icon.Run,
category: testManagement.category.TestCase,
input: 'selection',
target: testManagement.class.TestCase,
context: {
mode: ['context'],
application: testManagement.app.TestManagement,
group: 'create'
}
},
testManagement.action.RunSelectedTests
)
}
function defineTestCase (builder: Builder): void {
@@ -306,6 +329,11 @@ function defineTestCase (builder: Builder): void {
presenter: testManagement.component.TestSuiteRefPresenter
})
builder.mixin(testManagement.class.TestCase, core.class.Class, view.mixin.ClassFilters, {
filters: ['priority', 'status'],
ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn']
})
builder.createDoc(
view.class.Viewlet,
core.space.Model,
@@ -350,11 +378,14 @@ function defineTestCase (builder: Builder): void {
hiddenKeys: ['title']
},
config: [
{ key: '', displayProps: { fixed: 'left', key: 'lead' } },
{ key: '', displayProps: { fixed: 'left' } },
{
key: 'status',
props: { kind: 'list', size: 'small', shouldShowName: false }
props: { kind: 'list', size: 'small', shouldShowName: false },
displayProps: { key: 'status', fixed: 'left' }
},
{ key: '', displayProps: { fixed: 'left', key: 'lead' } },
{ key: '', displayProps: { grow: true } },
{ key: 'modifiedOn', displayProps: { key: 'modified', fixed: 'right', dividerBefore: true } },
{
key: 'assignee',
@@ -366,21 +397,6 @@ function defineTestCase (builder: Builder): void {
},
testManagement.viewlet.ListTestCase
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: testManagement.class.TestCase,
descriptor: view.viewlet.Table,
config: ['', 'assignee', 'modifiedOn'],
configOptions: {
sortable: true
},
variant: 'short'
},
testManagement.viewlet.SuiteTestCases
)
}
function defineTestRun (builder: Builder): void {
@@ -391,10 +407,6 @@ function defineTestRun (builder: Builder): void {
components: { input: { component: chunter.component.ChatMessageInput } }
})
builder.mixin(testManagement.class.TestRun, core.class.Class, view.mixin.ObjectEditor, {
editor: testManagement.component.EditTestRun
})
builder.mixin(testManagement.class.TestRun, core.class.Class, view.mixin.ObjectPanel, {
component: testManagement.component.EditTestRun
})
@@ -403,18 +415,111 @@ function defineTestRun (builder: Builder): void {
presenter: testManagement.component.TestRunPresenter
})
builder.mixin(testManagement.class.TestRun, core.class.Class, view.mixin.ObjectIcon, {
component: testManagement.component.TestResultStatusPresenter
})
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectPresenter, {
presenter: testManagement.component.TestResultPresenter
})
builder.mixin(testManagement.class.TestResult, core.class.Class, activity.mixin.ActivityDoc, {})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: testManagement.class.TestResult,
components: { input: { component: chunter.component.ChatMessageInput } }
})
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectEditor, {
editor: testManagement.component.EditTestResult
})
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectEditorHeader, {
editor: testManagement.component.TestResultHeader
})
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectPanel, {
component: testManagement.component.EditTestResult
})
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectPanelFooter, {
editor: testManagement.component.TestResultFooter
})
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ClassFilters, {
filters: ['assignee', 'status', 'testSuite'],
ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn']
})
const viewOptions: ViewOptionsModel = {
groupBy: ['testSuite'],
orderBy: [
['status', SortingOrder.Ascending],
['modifiedOn', SortingOrder.Descending],
['createdOn', SortingOrder.Descending]
],
other: [
{
key: 'shouldShowAll',
type: 'toggle',
defaultValue: false,
actionTarget: 'category',
action: view.function.ShowEmptyGroups,
label: view.string.ShowEmptyGroups
}
]
}
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: testManagement.class.TestRun,
attachTo: testManagement.class.TestResult,
descriptor: view.viewlet.List,
configOptions: {
strict: true,
hiddenKeys: ['title', 'status', 'modifiedOn']
},
config: [
{ key: '', displayProps: { fixed: 'left' } },
{
key: 'status',
props: { kind: 'list', size: 'small', shouldShowName: false }
},
{
key: 'assignee',
props: { kind: 'list', shouldShowName: false, avatarSize: 'x-small' },
displayProps: { key: 'assignee', fixed: 'right' }
}
],
viewOptions,
/* eslint-disable @typescript-eslint/consistent-type-assertions */
options: {
lookup: {
testCase: testManagement.class.TestCase
}
} as FindOptions<TestResult>
},
testManagement.viewlet.TestResultList
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: testManagement.class.TestResult,
descriptor: view.viewlet.Table,
config: [''],
config: ['', 'testSuite', 'status', 'assignee'],
configOptions: {
strict: true
}
},
options: {
lookup: {
testCase: testManagement.class.TestCase
}
} as FindOptions<TestResult>
},
testManagement.viewlet.TableTestRun
testManagement.viewlet.TableTestResult
)
}
+9 -13
View File
@@ -15,23 +15,15 @@
import { testManagementId } from '@hcengineering/test-management'
import testManganement from '@hcengineering/test-management-resources/src/plugin'
import type { Doc, Ref } from '@hcengineering/core'
import type { Ref } from '@hcengineering/core'
import { mergeIds } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import type { Action, ActionCategory, ViewAction } from '@hcengineering/view'
import type { ActionCategory } from '@hcengineering/view'
export default mergeIds(testManagementId, testManganement, {
action: {
DeleteTestCase: '' as Ref<Action<Doc, any>>,
CreateChildTestSuite: '' as Ref<Action>,
EditTestSuite: '' as Ref<Action>
},
actionImpl: {
CreateChildTestSuite: '' as ViewAction,
EditTestSuite: '' as ViewAction
},
category: {
TestSuite: '' as Ref<ActionCategory>
TestSuite: '' as Ref<ActionCategory>,
TestCase: '' as Ref<ActionCategory>
},
component: {
CreateTestCase: '' as AnyComponent,
@@ -44,6 +36,10 @@ export default mergeIds(testManagementId, testManganement, {
CreateTestRun: '' as AnyComponent,
TestRunPresenter: '' as AnyComponent,
EditTestRun: '' as AnyComponent,
TestSuiteRefPresenter: '' as AnyComponent
TestSuiteRefPresenter: '' as AnyComponent,
RunButton: '' as AnyComponent,
TestResultPresenter: '' as AnyComponent,
EditTestResult: '' as AnyComponent,
TestResultFooter: '' as AnyComponent
}
})
+8
View File
@@ -53,4 +53,12 @@ export function definePresenters (builder: Builder): void {
builder.mixin(testManagement.class.TypeTestCaseStatus, core.class.Class, view.mixin.AttributePresenter, {
presenter: testManagement.component.TestCaseStatusPresenter
})
builder.mixin(testManagement.class.TypeTestRunStatus, core.class.Class, view.mixin.AttributePresenter, {
presenter: testManagement.component.TestResultStatusPresenter
})
builder.mixin(testManagement.class.TypeTestRunStatus, core.class.Class, view.mixin.AttributeEditor, {
inlineEditor: testManagement.component.TestResultStatusEditor
})
}
+53 -20
View File
@@ -22,8 +22,8 @@ import type {
TestCaseStatus,
TestProject,
TestRun,
TestRunResult,
TestRunItem
TestRunStatus,
TestResult
} from '@hcengineering/test-management'
import { type Attachment } from '@hcengineering/attachment'
import contact from '@hcengineering/contact'
@@ -40,7 +40,7 @@ import {
type Timestamp,
type Type,
type CollectionSize,
type CollaborativeDoc,
type MarkupBlobRef,
type Class
} from '@hcengineering/core'
import {
@@ -163,7 +163,7 @@ export class TTestCase extends TAttachedDoc implements TestCase {
@Prop(TypeCollaborativeDoc(), testManagement.string.FullDescription)
@Index(IndexKind.FullText)
description!: CollaborativeDoc
description!: MarkupBlobRef | null
@Prop(TypeTestCaseType(), testManagement.string.TestType)
@ReadOnly()
@@ -196,37 +196,70 @@ export class TTestRun extends TDoc implements TestRun {
@Prop(TypeCollaborativeDoc(), testManagement.string.FullDescription)
@Index(IndexKind.FullText)
description!: CollaborativeDoc
description!: MarkupBlobRef | null
@Prop(TypeDate(DateRangeMode.DATETIME), testManagement.string.DueDate)
dueDate?: Timestamp
@Prop(Collection(testManagement.class.TestRunItem), testManagement.string.TestRunItems, {
shortLabel: testManagement.string.TestRunItem
@Prop(Collection(testManagement.class.TestResult), testManagement.string.TestResult, {
shortLabel: testManagement.string.TestResult
})
items?: CollectionSize<TestRunItem>
results?: CollectionSize<TestResult>
}
/** @public */
export function TypeTestRunResult (): Type<TestRunResult> {
return { _class: testManagement.class.TypeTestRunResult, label: testManagement.string.TestRunResult }
export function TypeTestRunStatus (): Type<TestRunStatus> {
return { _class: testManagement.class.TypeTestRunStatus, label: testManagement.string.TestRunStatus }
}
@Model(testManagement.class.TypeTestRunResult, core.class.Type, DOMAIN_TEST_MANAGEMENT)
@UX(testManagement.string.TestRunResult)
export class TTypeTestRunResult extends TType {}
@Model(testManagement.class.TypeTestRunStatus, core.class.Type, DOMAIN_TEST_MANAGEMENT)
@UX(testManagement.string.TestRunStatus)
export class TTypeTestRunStatus extends TType {}
@Model(testManagement.class.TestRunItem, core.class.AttachedDoc, DOMAIN_TEST_MANAGEMENT)
@UX(testManagement.string.TestRunItem)
export class TTestRunItem extends TAttachedDoc implements TestRunItem {
@Prop(TypeRef(testManagement.class.TestRun), testManagement.string.TestRun)
testRun!: Ref<TestRun>
// TODO: Refactor to associations
@Model(testManagement.class.TestResult, core.class.AttachedDoc, DOMAIN_TEST_MANAGEMENT)
@UX(testManagement.string.TestResult)
export class TTestResult extends TAttachedDoc implements TestResult {
@Prop(TypeRef(testManagement.class.TestRun), core.string.AttachedTo)
@Index(IndexKind.Indexed)
declare attachedTo: Ref<TestRun>
@Prop(TypeRef(testManagement.class.TestRun), core.string.AttachedToClass)
@Index(IndexKind.Indexed)
@Hidden()
declare attachedToClass: Ref<Class<TestRun>>
@Prop(TypeRef(testManagement.class.TestProject), core.string.Space)
@Index(IndexKind.Indexed)
@Hidden()
declare space: Ref<TestProject>
@Prop(TypeString(), core.string.Collection)
@Hidden()
override collection: 'results' = 'results'
@Prop(TypeString(), testManagement.string.TestRunName)
@Index(IndexKind.FullText)
name!: string
@Prop(TypeCollaborativeDoc(), testManagement.string.FullDescription)
@Index(IndexKind.FullText)
description!: MarkupBlobRef | null
@Prop(TypeRef(testManagement.class.TestCase), testManagement.string.TestCase)
testCase!: Ref<TestCase>
@Prop(TypeTestRunResult(), testManagement.string.TestRunResult)
result?: TestRunResult
@Prop(TypeRef(testManagement.class.TestSuite), testManagement.string.TestSuite)
testSuite?: Ref<TestSuite>
@Prop(TypeTestRunStatus(), testManagement.string.TestRunStatus)
status?: TestRunStatus
@Prop(TypeRef(contact.mixin.Employee), testManagement.string.TestAssignee)
assignee?: Ref<Employee>
@Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })
attachments?: CollectionSize<Attachment>
@Prop(Collection(chunter.class.ChatMessage), chunter.string.Comments)
comments?: number
+2 -2
View File
@@ -19,7 +19,7 @@ import {
DOMAIN_MODEL,
DateRangeMode,
IndexKind,
type CollaborativeDoc,
type MarkupBlobRef,
type Domain,
type Markup,
type Ref,
@@ -185,7 +185,7 @@ export class TIssue extends TTask implements Issue {
@Prop(TypeCollaborativeDoc(), tracker.string.Description)
@Index(IndexKind.FullText)
description!: CollaborativeDoc
description!: MarkupBlobRef | null
@Prop(TypeRef(tracker.class.IssueStatus), tracker.string.Status, {
_id: tracker.attribute.IssueStatus,
-4
View File
@@ -514,10 +514,6 @@ export function createModel (builder: Builder): void {
presenter: view.component.MarkupDiffPresenter
})
builder.mixin(core.class.TypeCollaborativeDocVersion, core.class.Class, view.mixin.InlineAttributEditor, {
editor: view.component.CollaborativeDocEditor
})
classPresenter(builder, core.class.TypeBoolean, view.component.BooleanPresenter, view.component.BooleanEditor)
classPresenter(
builder,
+24 -12
View File
@@ -128,12 +128,12 @@ class PlatformClientImpl implements PlatformClient {
await this.connection.close()
}
private async processMarkup<T>(id: Ref<Doc>, data: WithMarkup<T>): Promise<T> {
private async processMarkup<T>(_class: Ref<Class<Doc>>, id: Ref<Doc>, data: WithMarkup<T>): Promise<T> {
const result: any = {}
for (const [key, value] of Object.entries(data)) {
if (value instanceof MarkupContent) {
result[key] = this.markup.uploadMarkup(id, key, value.content, value.kind)
result[key] = this.markup.uploadMarkup(_class, id, key, value.content, value.kind)
} else {
result[key] = value
}
@@ -151,7 +151,7 @@ class PlatformClientImpl implements PlatformClient {
id?: Ref<T>
): Promise<Ref<T>> {
id ??= generateId()
const data = await this.processMarkup<Data<T>>(id, attributes)
const data = await this.processMarkup<Data<T>>(_class, id, attributes)
return await this.client.createDoc(_class, space, data, id)
}
@@ -162,7 +162,7 @@ class PlatformClientImpl implements PlatformClient {
operations: WithMarkup<DocumentUpdate<T>>,
retrieve?: boolean
): Promise<TxResult> {
const update = await this.processMarkup<DocumentUpdate<T>>(objectId, operations)
const update = await this.processMarkup<DocumentUpdate<T>>(_class, objectId, operations)
return await this.client.updateDoc(_class, space, objectId, update, retrieve)
}
@@ -182,7 +182,7 @@ class PlatformClientImpl implements PlatformClient {
id?: Ref<P>
): Promise<Ref<P>> {
id ??= generateId()
const data = await this.processMarkup<AttachedData<P>>(id, attributes)
const data = await this.processMarkup<AttachedData<P>>(_class, id, attributes)
return await this.client.addCollection(_class, space, attachedTo, attachedToClass, collection, data, id)
}
@@ -196,7 +196,7 @@ class PlatformClientImpl implements PlatformClient {
operations: WithMarkup<DocumentUpdate<P>>,
retrieve?: boolean
): Promise<Ref<T>> {
const update = await this.processMarkup<DocumentUpdate<P>>(objectId, operations)
const update = await this.processMarkup<DocumentUpdate<P>>(_class, objectId, operations)
return await this.client.updateCollection(
_class,
space,
@@ -229,7 +229,7 @@ class PlatformClientImpl implements PlatformClient {
mixin: Ref<Mixin<M>>,
attributes: WithMarkup<MixinData<D, M>>
): Promise<TxResult> {
const data = await this.processMarkup<MixinData<D, M>>(objectId, attributes)
const data = await this.processMarkup<MixinData<D, M>>(objectClass, objectId, attributes)
return await this.client.createMixin(objectId, objectClass, objectSpace, mixin, data)
}
@@ -240,18 +240,30 @@ class PlatformClientImpl implements PlatformClient {
mixin: Ref<Mixin<M>>,
attributes: WithMarkup<MixinUpdate<D, M>>
): Promise<TxResult> {
const update = await this.processMarkup<MixinUpdate<D, M>>(objectId, attributes)
const update = await this.processMarkup<MixinUpdate<D, M>>(objectClass, objectId, attributes)
return await this.client.updateMixin(objectId, objectClass, objectSpace, mixin, update)
}
// Markup
async fetchMarkup (objectId: Ref<Doc>, objectAttr: string, markup: MarkupRef, format: MarkupFormat): Promise<string> {
return await this.markup.fetchMarkup(objectId, objectAttr, markup, format)
async fetchMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
markup: MarkupRef,
format: MarkupFormat
): Promise<string> {
return await this.markup.fetchMarkup(objectClass, objectId, objectAttr, markup, format)
}
async uploadMarkup (objectId: Ref<Doc>, objectAttr: string, markup: string, format: MarkupFormat): Promise<MarkupRef> {
return await this.markup.uploadMarkup(objectId, objectAttr, markup, format)
async uploadMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
markup: string,
format: MarkupFormat
): Promise<MarkupRef> {
return await this.markup.uploadMarkup(objectClass, objectId, objectAttr, markup, format)
}
// AsyncDisposable
+19 -8
View File
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { type Doc, Markup, type Ref, concatLink, makeCollaborativeDoc } from '@hcengineering/core'
import { type Class, type Doc, type Markup, type Ref, concatLink, makeCollabId } from '@hcengineering/core'
import { type CollaboratorClient, getClient } from '@hcengineering/collaborator-client'
import { parseMessageMarkdown, jsonToMarkup, markupToHTML, markupToMarkdown, htmlToMarkup } from '@hcengineering/text'
@@ -45,9 +45,15 @@ class MarkupOperationsImpl implements MarkupOperations {
this.collaborator = getClient({ name: workspace }, token, config.COLLABORATOR_URL)
}
async fetchMarkup (objectId: Ref<Doc>, objectAttr: string, doc: MarkupRef, format: MarkupFormat): Promise<string> {
const content = await this.collaborator.getContent(doc)
const markup = content[objectAttr] ?? ''
async fetchMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
doc: MarkupRef,
format: MarkupFormat
): Promise<string> {
const collabId = makeCollabId(objectClass, objectId, objectAttr)
const markup = await this.collaborator.getMarkup(collabId, doc)
switch (format) {
case 'markup':
@@ -61,7 +67,13 @@ class MarkupOperationsImpl implements MarkupOperations {
}
}
async uploadMarkup (objectId: Ref<Doc>, objectAttr: string, value: string, format: MarkupFormat): Promise<MarkupRef> {
async uploadMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
value: string,
format: MarkupFormat
): Promise<MarkupRef> {
let markup: Markup = ''
switch (format) {
@@ -78,8 +90,7 @@ class MarkupOperationsImpl implements MarkupOperations {
throw new Error('Unknown content format')
}
const doc = makeCollaborativeDoc(objectId, objectAttr)
await this.collaborator.updateContent(doc, { [objectAttr]: markup })
return doc
const collabId = makeCollabId(objectClass, objectId, objectAttr)
return await this.collaborator.createMarkup(collabId, markup)
}
}
+18 -4
View File
@@ -13,10 +13,10 @@
// limitations under the License.
//
import { type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core'
import { Class, type Blob, type Doc, type Ref } from '@hcengineering/core'
/** @public */
export type MarkupRef = CollaborativeDoc
export type MarkupRef = Ref<Blob>
/** @public */
export type MarkupFormat = 'markup' | 'html' | 'markdown'
@@ -45,21 +45,35 @@ export function markdown (content: string): MarkupContent {
export interface MarkupOperations {
/**
* Retrieves markup content for a specified document object
* * @param objectClass - Reference to the class of the document containing the markup
* @param objectId - Reference to the document containing the markup
* @param objectAttr - The attribute/field name where the markup is stored
* @param id - Unique reference identifying the specific markup content
* @param format - The format of the markup (e.g., HTML, Markdown, etc.)
* @returns Promise containing the markup content as a string
*/
fetchMarkup: (objectId: Ref<Doc>, objectAttr: string, id: MarkupRef, format: MarkupFormat) => Promise<string>
fetchMarkup: (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
id: MarkupRef,
format: MarkupFormat
) => Promise<string>
/**
* Saves markup content for a document object
* @param objectClass - Reference to the class of the document where markup should be stored
* @param objectId - Reference to the document where markup should be stored
* @param objectAttr - The attribute/field name where markup should be saved
* @param markup - The actual markup content to be uploaded
* @param format - The format of the provided markup (e.g., HTML, Markdown, etc.)
* @returns Promise containing a reference to the newly saved markup
*/
uploadMarkup: (objectId: Ref<Doc>, objectAttr: string, markup: string, format: MarkupFormat) => Promise<MarkupRef>
uploadMarkup: (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
markup: string,
format: MarkupFormat
) => Promise<MarkupRef>
}
@@ -13,26 +13,27 @@
// limitations under the License.
//
import { CollaborativeDoc } from '@hcengineering/core'
import { DocumentId } from '../types'
import { formatDocumentId, parseDocumentId } from '../utils'
import core, { CollaborativeDoc, Doc, Ref } from '@hcengineering/core'
import { encodeDocumentId, decodeDocumentId } from '../utils'
describe('utils', () => {
it('formatDocumentId', () => {
expect(formatDocumentId('ws1', 'doc1:HEAD:v1' as CollaborativeDoc)).toEqual('ws1://doc1:HEAD' as DocumentId)
expect(formatDocumentId('ws1', 'doc1:HEAD:v1#doc2:v2:v2' as CollaborativeDoc)).toEqual(
'ws1://doc1:HEAD/doc2:v2' as DocumentId
)
it('encodeDocumentId', () => {
const doc: CollaborativeDoc = {
objectClass: core.class.Card,
objectId: 'doc1' as Ref<Doc>,
objectAttr: 'description'
}
expect(encodeDocumentId('ws1', doc)).toEqual('ws1|core:class:Card|doc1|description')
})
describe('parseDocumentId', () => {
expect(parseDocumentId('ws1://doc1:HEAD' as DocumentId)).toEqual({
describe('decodeDocumentId', () => {
expect(decodeDocumentId('ws1|core:class:Card|doc1|description')).toEqual({
workspaceId: 'ws1',
collaborativeDoc: 'doc1:HEAD:HEAD' as CollaborativeDoc
})
expect(parseDocumentId('ws1://doc1:HEAD/doc2:v2' as DocumentId)).toEqual({
workspaceId: 'ws1',
collaborativeDoc: 'doc1:HEAD:HEAD#doc2:v2:v2' as CollaborativeDoc
documentId: {
objectClass: core.class.Card,
objectId: 'doc1' as Ref<Doc>,
objectAttr: 'description'
}
})
})
})
+56 -19
View File
@@ -13,18 +13,29 @@
// limitations under the License.
//
import { CollaborativeDoc, Markup, WorkspaceId, concatLink } from '@hcengineering/core'
import { formatDocumentId } from './utils'
import { Blob, CollaborativeDoc, Markup, MarkupBlobRef, Ref, WorkspaceId, concatLink } from '@hcengineering/core'
import { encodeDocumentId } from './utils'
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface GetContentRequest {}
export interface GetContentRequest {
source?: Ref<Blob>
}
/** @public */
export interface GetContentResponse {
content: Record<string, Markup>
}
/** @public */
export interface CreateContentRequest {
content: Record<string, Markup>
}
/** @public */
export interface CreateContentResponse {
content: Record<string, MarkupBlobRef>
}
/** @public */
export interface UpdateContentRequest {
content: Record<string, Markup>
@@ -36,8 +47,9 @@ export interface UpdateContentResponse {}
/** @public */
export interface CollaboratorClient {
getContent: (document: CollaborativeDoc) => Promise<Record<string, Markup>>
updateContent: (document: CollaborativeDoc, content: Record<string, Markup>) => Promise<void>
getMarkup: (document: CollaborativeDoc, source?: Ref<Blob> | null) => Promise<Markup>
createMarkup: (document: CollaborativeDoc, markup: Markup) => Promise<MarkupBlobRef>
updateMarkup: (document: CollaborativeDoc, markup: Markup) => Promise<void>
copyContent: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise<void>
}
@@ -54,11 +66,11 @@ class CollaboratorClientImpl implements CollaboratorClient {
private readonly collaboratorUrl: string
) {}
private async rpc (document: CollaborativeDoc, method: string, payload: any): Promise<any> {
private async rpc<P, R>(document: CollaborativeDoc, method: string, payload: P): Promise<R> {
const workspace = this.workspace.name
const documentId = formatDocumentId(workspace, document)
const documentId = encodeDocumentId(workspace, document)
const url = concatLink(this.collaboratorUrl, '/rpc')
const url = concatLink(this.collaboratorUrl, `/rpc/${encodeURIComponent(documentId)}`)
const res = await fetch(url, {
method: 'POST',
@@ -66,7 +78,7 @@ class CollaboratorClientImpl implements CollaboratorClient {
Authorization: 'Bearer ' + this.token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ method, documentId, payload })
body: JSON.stringify({ method, payload })
})
if (!res.ok) {
@@ -79,33 +91,58 @@ class CollaboratorClientImpl implements CollaboratorClient {
throw new Error(result.error)
}
return result
return result as R
}
async getContent (document: CollaborativeDoc): Promise<Record<string, Markup>> {
async getMarkup (document: CollaborativeDoc, source?: Ref<Blob> | null): Promise<Markup> {
const payload: GetContentRequest = {
source: source !== null ? source : undefined
}
const res = await retry(
3,
async () => {
return (await this.rpc(document, 'getContent', {})) as GetContentResponse
return await this.rpc<GetContentRequest, GetContentResponse>(document, 'getContent', payload)
},
50
)
return res.content ?? {}
return res.content[document.objectAttr] ?? ''
}
async updateContent (document: CollaborativeDoc, content: Record<string, Markup>): Promise<void> {
async createMarkup (document: CollaborativeDoc, markup: Markup): Promise<MarkupBlobRef> {
const content = {
[document.objectAttr]: markup
}
const res = await retry(
3,
async () => {
return await this.rpc<CreateContentRequest, CreateContentResponse>(document, 'createContent', { content })
},
50
)
return res.content[document.objectAttr]
}
async updateMarkup (document: CollaborativeDoc, markup: Markup): Promise<void> {
const content = {
[document.objectAttr]: markup
}
await retry(
3,
async () => {
await this.rpc(document, 'updateContent', { content })
await this.rpc<UpdateContentRequest, UpdateContentResponse>(document, 'updateContent', { content })
},
50
)
}
async copyContent (source: CollaborativeDoc, target: CollaborativeDoc): Promise<void> {
const content = await this.getContent(source)
await this.updateContent(target, content)
async copyContent (source: CollaborativeDoc, target: CollaborativeDoc, content?: Ref<Blob>): Promise<void> {
const markup = await this.getMarkup(source, content)
await this.updateMarkup(target, markup)
}
}
@@ -14,5 +14,4 @@
//
export * from './client'
export * from './types'
export * from './utils'
+13 -65
View File
@@ -13,78 +13,26 @@
// limitations under the License.
//
import {
Class,
CollaborativeDoc,
Doc,
Ref,
collaborativeDocChain,
collaborativeDocFormat,
collaborativeDocParse,
collaborativeDocUnchain
} from '@hcengineering/core'
import { DocumentId, PlatformDocumentId } from './types'
import { Class, CollaborativeDoc, Doc, Ref } from '@hcengineering/core'
/**
* Formats collaborative document as Hocuspocus document name.
*
* The document name is used for document identification on the server so should remain the same even
* when document is updated. Hence, we remove lastVersionId component from CollaborativeDoc.
*
* Example:
* workspace1://doc1:HEAD/doc2:v1
*
* @public
*/
export function formatDocumentId (workspaceId: string, collaborativeDoc: CollaborativeDoc): DocumentId {
const path = collaborativeDocUnchain(collaborativeDoc)
.map((p) => {
const { documentId, versionId } = collaborativeDocParse(p)
return `${documentId}:${versionId}`
})
.join('/')
return `${workspaceId}://${path}` as DocumentId
/** @public */
export function encodeDocumentId (workspaceId: string, documentId: CollaborativeDoc): string {
const { objectClass, objectId, objectAttr } = documentId
return [workspaceId, objectClass, objectId, objectAttr].join('|')
}
/** @public */
export function parseDocumentId (documentId: DocumentId): {
export function decodeDocumentId (documentId: string): {
workspaceId: string
collaborativeDoc: CollaborativeDoc
documentId: CollaborativeDoc
} {
const [workspaceId, path] = documentId.split('://')
const segments = path.split('/')
const collaborativeDocs = segments.map((p) => {
const [documentId, versionId] = p.split(':')
return collaborativeDocFormat({ documentId, versionId, lastVersionId: versionId })
})
const [workspaceId, objectClass, objectId, objectAttr] = documentId.split('|')
return {
workspaceId,
collaborativeDoc: collaborativeDocChain(...collaborativeDocs)
}
}
/** @public */
export function formatPlatformDocumentId (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string
): PlatformDocumentId {
return `${objectClass}/${objectId}/${objectAttr}` as PlatformDocumentId
}
/** @public */
export function parsePlatformDocumentId (platformDocumentId: PlatformDocumentId): {
objectClass: Ref<Class<Doc>>
objectId: Ref<Doc>
objectAttr: string
} {
const [objectClass, objectId, objectAttr] = platformDocumentId.split('/')
return {
objectClass: objectClass as Ref<Class<Doc>>,
objectId: objectId as Ref<Doc>,
objectAttr
documentId: {
objectClass: objectClass as Ref<Class<Doc>>,
objectId: objectId as Ref<Doc>,
objectAttr
}
}
}
+1 -1
View File
@@ -36,7 +36,7 @@
"Enum": "Enum",
"Members": "Members",
"Hyperlink": "URL",
"Collaborative": "Collaborative",
"MarkupBlobRef": "Collaborative",
"Object": "Object",
"System": "System",
"CreatedBy": "Created by",
+1 -1
View File
@@ -29,7 +29,7 @@
"Enum": "Enum.",
"Members": "Miembros",
"Hyperlink": "Enlace",
"Collaborative": "Colaborativo",
"MarkupBlobRef": "Colaborativo",
"Object": "Objeto",
"System": "Sistema",
"CreatedBy": "Creado por",
+1 -1
View File
@@ -36,7 +36,7 @@
"Enum": "Énumération",
"Members": "Membres",
"Hyperlink": "URL",
"Collaborative": "Collaboratif",
"MarkupBlobRef": "Collaboratif",
"Object": "Objet",
"System": "Système",
"CreatedBy": "Créé par",
+1 -1
View File
@@ -29,7 +29,7 @@
"Enum": "Enumeração",
"Members": "Membros",
"Hyperlink": "URL",
"Collaborative": "Colaborativo",
"MarkupBlobRef": "Colaborativo",
"Object": "Objeto",
"System": "Sistema",
"CreatedBy": "Criado por",
+1 -1
View File
@@ -36,7 +36,7 @@
"Enum": "Справочник",
"Members": "Участники",
"Hyperlink": "URL",
"Collaborative": "Коллаборативный",
"MarkupBlobRef": "Коллаборативный",
"Object": "Объект",
"System": "Система",
"CreatedBy": "Создан",
+1 -1
View File
@@ -36,7 +36,7 @@
"Enum": "枚举",
"Members": "成员",
"Hyperlink": "URL",
"Collaborative": "协作",
"MarkupBlobRef": "协作",
"Object": "对象",
"System": "系统",
"CreatedBy": "创建者",
@@ -1,204 +0,0 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
CollaborativeDoc,
collaborativeDocChain,
collaborativeDocUnchain,
collaborativeDocFormat,
collaborativeDocParse,
collaborativeDocFromCollaborativeDoc,
collaborativeDocFromLastVersion,
collaborativeDocWithVersion,
collaborativeDocWithLastVersion,
collaborativeDocWithSource
} from '../collaboration'
describe('collaborative-doc', () => {
describe('collaborativeDocChain', () => {
it('chains one collaborative doc', async () => {
expect(collaborativeDocChain('doc1:v1:v1' as CollaborativeDoc)).toEqual('doc1:v1:v1' as CollaborativeDoc)
})
it('chains multiple collaborative docs', async () => {
expect(collaborativeDocChain('doc1:v1:v1' as CollaborativeDoc, 'doc2:v2:v2' as CollaborativeDoc)).toEqual(
'doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc
)
})
it('chains multiple chained collaborative docs', async () => {
expect(
collaborativeDocChain('doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc, 'doc3:v3:v3' as CollaborativeDoc)
).toEqual('doc1:v1:v1#doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc)
})
})
describe('collaborativeDocUnchain', () => {
it('unchains one collaborative doc', async () => {
expect(collaborativeDocUnchain('doc1:v1:v1' as CollaborativeDoc)).toEqual(['doc1:v1:v1'] as CollaborativeDoc[])
})
it('unchains multiple collaborative docs', async () => {
expect(collaborativeDocUnchain('doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc)).toEqual([
'doc1:v1:v1',
'doc2:v2:v2'
] as CollaborativeDoc[])
})
})
describe('collaborativeDocParse', () => {
it('parses collaborative doc id', async () => {
expect(collaborativeDocParse('documentId' as CollaborativeDoc)).toEqual({
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: 'HEAD',
source: []
})
})
it('parses collaborative doc id with versionId', async () => {
expect(collaborativeDocParse('documentId:main' as CollaborativeDoc)).toEqual({
documentId: 'documentId',
versionId: 'main',
lastVersionId: 'main',
source: []
})
})
it('parses collaborative doc id with versionId and lastVersionId', async () => {
expect(collaborativeDocParse('documentId:HEAD:0' as CollaborativeDoc)).toEqual({
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: '0',
source: []
})
})
it('parses collaborative doc id with versionId, lastVersionId, and source', async () => {
expect(collaborativeDocParse('documentId:HEAD:0#documentId1:main#documentId2:HEAD' as CollaborativeDoc)).toEqual({
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: '0',
source: ['documentId1:main' as CollaborativeDoc, 'documentId2:HEAD' as CollaborativeDoc]
})
})
})
describe('collaborativeDocFormat', () => {
it('formats collaborative doc id', async () => {
expect(
collaborativeDocFormat({
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: '0'
})
).toEqual('documentId:HEAD:0')
})
it('formats collaborative doc id with sources', async () => {
expect(
collaborativeDocFormat({
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: '0',
source: ['documentId1:main' as CollaborativeDoc, 'documentId2:HEAD' as CollaborativeDoc]
})
).toEqual('documentId:HEAD:0#documentId1:main#documentId2:HEAD')
})
it('formats collaborative doc id with invalid characters', async () => {
expect(
collaborativeDocFormat({
documentId: 'doc:id',
versionId: 'version#id',
lastVersionId: 'last:version#id'
})
).toEqual('doc%id:version%id:last%version%id')
})
})
describe('collaborativeDocWithVersion', () => {
it('updates collaborative doc version id', async () => {
expect(collaborativeDocWithVersion('doc1:HEAD:HEAD' as CollaborativeDoc, 'v1')).toEqual('doc1:v1:v1')
expect(collaborativeDocWithVersion('doc1:HEAD:v1' as CollaborativeDoc, 'v2')).toEqual('doc1:v2:v2')
expect(collaborativeDocWithVersion('doc1:HEAD:v1#doc2:v1:v1' as CollaborativeDoc, 'v2')).toEqual(
'doc1:v2:v2#doc2:v1:v1'
)
})
})
describe('collaborativeDocWithLastVersion', () => {
it('updates collaborative doc version id', async () => {
expect(collaborativeDocWithLastVersion('doc1:HEAD:HEAD' as CollaborativeDoc, 'v1')).toEqual('doc1:HEAD:v1')
expect(collaborativeDocWithLastVersion('doc1:HEAD:v1' as CollaborativeDoc, 'v2')).toEqual('doc1:HEAD:v2')
expect(collaborativeDocWithLastVersion('doc1:HEAD:v1#doc2:v1:v1' as CollaborativeDoc, 'v2')).toEqual(
'doc1:HEAD:v2#doc2:v1:v1'
)
expect(collaborativeDocWithLastVersion('doc1:v1:v1' as CollaborativeDoc, 'v2')).toEqual(
// cannot update last version for non HEAD
'doc1:v1:v1'
)
})
})
describe('collaborativeDocWithSource', () => {
it('updates collaborative doc version id', async () => {
expect(
collaborativeDocWithSource('doc1:HEAD:HEAD' as CollaborativeDoc, 'doc2:v1:v1' as CollaborativeDoc)
).toEqual('doc1:HEAD:HEAD#doc2:v1:v1' as CollaborativeDoc)
expect(collaborativeDocWithSource('doc1:v1:v1' as CollaborativeDoc, 'doc2:v2:v2' as CollaborativeDoc)).toEqual(
'doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc
)
expect(
collaborativeDocWithSource('doc1:v1:v1' as CollaborativeDoc, 'doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc)
).toEqual('doc1:v1:v1#doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc)
expect(
collaborativeDocWithSource('doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc, 'doc3:v3:v3' as CollaborativeDoc)
).toEqual('doc1:v1:v1#doc3:v3:v3' as CollaborativeDoc)
})
})
describe('collaborativeDocFromLastVersion', () => {
it('returns valid collaborative doc id', async () => {
expect(collaborativeDocFromLastVersion('doc1:HEAD:HEAD#doc2:main:v2#doc3:main:v3' as CollaborativeDoc)).toEqual(
'doc1:HEAD:HEAD#doc2:main:v2#doc3:main:v3'
)
expect(collaborativeDocFromLastVersion('doc1:HEAD:v1#doc2:main:v2#doc3:main:v3' as CollaborativeDoc)).toEqual(
'doc1:v1:v1#doc2:main:v2#doc3:main:v3'
)
expect(collaborativeDocFromLastVersion('doc1:v1:v1#doc2:main:v2#doc3:main:v3' as CollaborativeDoc)).toEqual(
'doc1:v1:v1#doc2:main:v2#doc3:main:v3'
)
expect(collaborativeDocFromLastVersion('doc1:HEAD:v1' as CollaborativeDoc)).toEqual('doc1:v1:v1')
})
})
describe('collaborativeDocFromCollaborativeDoc', () => {
it('returns valid collaborative doc id', async () => {
expect(
collaborativeDocFromCollaborativeDoc(
'doc1:HEAD:HEAD' as CollaborativeDoc,
'doc2:HEAD:v2#doc3:v3:v3' as CollaborativeDoc
)
).toEqual('doc1:HEAD:HEAD#doc2:v2:v2#doc3:v3:v3')
expect(
collaborativeDocFromCollaborativeDoc(
'doc1:HEAD:HEAD' as CollaborativeDoc,
'doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc
)
).toEqual('doc1:HEAD:HEAD#doc2:v2:v2#doc3:v3:v3')
expect(
collaborativeDocFromCollaborativeDoc(
'doc1:HEAD:HEAD' as CollaborativeDoc,
'doc2:HEAD:HEAD#doc3:v3:v3' as CollaborativeDoc
)
).toEqual('doc1:HEAD:HEAD#doc2:HEAD:HEAD#doc3:v3:v3')
})
})
})
+8 -2
View File
@@ -15,7 +15,6 @@
//
import type { Asset, IntlString, Plugin } from '@hcengineering/platform'
import { CollaborativeDoc } from './collaboration'
import type { DocumentQuery } from './storage'
/**
@@ -55,6 +54,13 @@ export type CollectionSize<T> = T[]['length']
*/
export type Rank = string
/**
* @public
*
* Reference to blob containing snapshot of collaborative doc.
*/
export type MarkupBlobRef = Ref<Blob>
/**
* @public
*/
@@ -76,7 +82,7 @@ export interface Doc<S extends Space = Space> extends Obj {
export interface Card extends Doc {
title: string
description?: CollaborativeDoc | null
description?: MarkupBlobRef | null
identifier?: string
parent?: Ref<Card> | null
}
+23 -157
View File
@@ -13,175 +13,41 @@
// limitations under the License.
//
import { Doc, Ref } from './classes'
/**
* Identifier of the collaborative document holding collaborative content.
*
* Format:
* {documentId}:{versionId}:{lastVersionId}
* {documentId}:{versionId}
*
* Where:
* - documentId is an identifier of the document in storage
* - versionId is an identifier of the document version, HEAD for latest editable version
* - lastVersionId is an identifier of the latest available version
*
* The collaborative document may contain one or more such sections chained with # (hash):
* collaborativeDocId#collaborativeDocId#collaborativeDocId#...
*
* When collaborative document does not exist, it will be initialized from the first existing
* document in the list.
*
* @public
* */
export type CollaborativeDoc = string & { __collaborativeDoc: true }
import type { Class, MarkupBlobRef, Doc, Ref } from './classes'
/** @public */
export type CollaborativeDocVersion = string | typeof CollaborativeDocVersionHead
export interface CollaborativeDoc {
objectClass: Ref<Class<Doc>>
objectId: Ref<Doc>
objectAttr: string
}
/** @public */
export const CollaborativeDocVersionHead = 'HEAD'
/** @public */
export function makeCollaborativeDoc (
objectId: Ref<Doc>,
objectAttr?: string | undefined,
versionId?: string | undefined
export function makeCollabId<T extends Doc, U extends keyof T> (
objectClass: Ref<Class<T>>,
objectId: Ref<T>,
objectAttr: Extract<U, string> | string
): CollaborativeDoc {
const storageDocumentId = objectAttr !== undefined && objectAttr !== '' ? `${objectId}%${objectAttr}` : `${objectId}`
return collaborativeDocFormat({
documentId: storageDocumentId,
versionId: CollaborativeDocVersionHead,
lastVersionId: versionId ?? '0'
})
return { objectClass, objectId, objectAttr }
}
/** @public */
export interface CollaborativeDocData {
// Id of the document in object storage
documentId: string
// Id of the document version
// HEAD version represents the editable last document version
// Otherwise, it is a readonly version
versionId: CollaborativeDocVersion
// For HEAD versionId it is the latest available document version
// Otherwise, it is the same value as versionId
lastVersionId: string
source?: CollaborativeDoc[]
}
/**
* Merge several collaborative docs into single collaborative doc train.
*
* @public
*/
export function collaborativeDocChain (...docs: CollaborativeDoc[]): CollaborativeDoc {
return docs.join('#') as CollaborativeDoc
}
/**
* Split collaborative doc train into separate collaborative docs.
*
* @public
*/
export function collaborativeDocUnchain (doc: CollaborativeDoc): CollaborativeDoc[] {
return doc.split('#') as CollaborativeDoc[]
export function makeDocCollabId<T extends Doc, U extends keyof T> (
doc: T,
objectAttr: Extract<U, string> | string
): CollaborativeDoc {
return makeCollabId(doc._class, doc._id, objectAttr)
}
/** @public */
export function collaborativeDocParse (doc: CollaborativeDoc): CollaborativeDocData {
const [first, ...other] = collaborativeDocUnchain(doc)
const [documentId, versionId, lastVersionId] = first.split(':')
return {
documentId,
versionId: versionId ?? CollaborativeDocVersionHead,
lastVersionId: lastVersionId ?? versionId ?? CollaborativeDocVersionHead,
source: other
}
export function makeCollabYdocId (doc: CollaborativeDoc): MarkupBlobRef {
const { objectId, objectAttr } = doc
return `${objectId}%${objectAttr}` as MarkupBlobRef
}
const sanitize = (value: string): string => value.replace(/[:#]/g, '%')
/** @public */
export function collaborativeDocFormat ({
documentId,
versionId,
lastVersionId,
source
}: CollaborativeDocData): CollaborativeDoc {
const parts = [sanitize(documentId), sanitize(versionId), sanitize(lastVersionId)]
const collaborativeDoc = parts.join(':') as CollaborativeDoc
return collaborativeDocChain(collaborativeDoc, ...(source ?? []))
}
/**
* Updates versionId component in the collaborative document.
* Both versionId and lastVersionId will refer to the same collaborative document version.
*
* When versionId is not HEAD, the document will represent a readonly document version (snapshot).
*
* @public
*/
export function collaborativeDocWithVersion (collaborativeDoc: CollaborativeDoc, versionId: string): CollaborativeDoc {
const { documentId, source } = collaborativeDocParse(collaborativeDoc)
return collaborativeDocFormat({ documentId, versionId, lastVersionId: versionId, source })
}
/**
* Updates lastVersionId component in the collaborative document.
*
* When document versionId is HEAD, the function is no-op.
*
* @public
*/
export function collaborativeDocWithLastVersion (
collaborativeDoc: CollaborativeDoc,
lastVersionId: string
): CollaborativeDoc {
const { documentId, versionId, source } = collaborativeDocParse(collaborativeDoc)
return versionId === CollaborativeDocVersionHead
? collaborativeDocFormat({ documentId, versionId, lastVersionId, source })
: collaborativeDoc
}
/**
* Replaces source component in the collaborative document.
*
* @public
*/
export function collaborativeDocWithSource (
collaborativeDoc: CollaborativeDoc,
source: CollaborativeDoc
): CollaborativeDoc {
const { documentId, versionId, lastVersionId } = collaborativeDocParse(collaborativeDoc)
return collaborativeDocFormat({ documentId, versionId, lastVersionId, source: [source] })
}
/**
* Creates collaborative document that refers to the last version from the source collaborative document.
*
* @public
*/
export function collaborativeDocFromLastVersion (collaborativeDoc: CollaborativeDoc): CollaborativeDoc {
const { documentId, lastVersionId, source } = collaborativeDocParse(collaborativeDoc)
return collaborativeDocFormat({
documentId,
versionId: lastVersionId,
lastVersionId,
source
})
}
/**
* Creates collaborative document that refers to the last version from the source collaborative document.
*
* @public
*/
export function collaborativeDocFromCollaborativeDoc (
collaborativeDoc: CollaborativeDoc,
sourceCollaborativeDoc: CollaborativeDoc
): CollaborativeDoc {
return collaborativeDocWithSource(collaborativeDoc, collaborativeDocFromLastVersion(sourceCollaborativeDoc))
export function makeCollabJsonId (doc: CollaborativeDoc): MarkupBlobRef {
const timestamp = Date.now()
const { objectId, objectAttr } = doc
return [objectId, objectAttr, timestamp].join('-') as MarkupBlobRef
}
+3 -5
View File
@@ -23,6 +23,7 @@ import type {
Blob,
Card,
Class,
MarkupBlobRef,
Collection,
Configuration,
ConfigurationElement,
@@ -54,7 +55,6 @@ import type {
TypedSpace,
UserStatus
} from './classes'
import { CollaborativeDoc } from './collaboration'
import { Status, StatusCategory } from './status'
import type {
Tx,
@@ -120,8 +120,7 @@ export default plugin(coreId, {
TypeBoolean: '' as Ref<Class<Type<boolean>>>,
TypeTimestamp: '' as Ref<Class<Type<Timestamp>>>,
TypeDate: '' as Ref<Class<Type<Timestamp | Date>>>,
TypeCollaborativeDoc: '' as Ref<Class<Type<CollaborativeDoc>>>,
TypeCollaborativeDocVersion: '' as Ref<Class<Type<CollaborativeDoc>>>,
TypeCollaborativeDoc: '' as Ref<Class<Type<MarkupBlobRef>>>,
RefTo: '' as Ref<Class<RefTo<Doc>>>,
ArrOf: '' as Ref<Class<ArrOf<Doc>>>,
Enum: '' as Ref<Class<Enum>>,
@@ -185,9 +184,8 @@ export default plugin(coreId, {
String: '' as IntlString,
Record: '' as IntlString,
Markup: '' as IntlString,
Collaborative: '' as IntlString,
CollaborativeDoc: '' as IntlString,
CollaborativeDocVersion: '' as IntlString,
MarkupBlobRef: '' as IntlString,
Number: '' as IntlString,
Boolean: '' as IntlString,
Timestamp: '' as IntlString,
+4 -4
View File
@@ -114,7 +114,6 @@ export class MeasureMetricsContext implements MeasureContext {
this.logParams
)
result.id = this.id
result.onEnd = this.onEnd.bind(this)
result.contextData = this.contextData
return result
}
@@ -190,8 +189,6 @@ export class MeasureMetricsContext implements MeasureContext {
end (): void {
this.done()
}
async onEnd (ctx: MeasureContext): Promise<void> {}
}
/**
@@ -220,7 +217,10 @@ export function registerOperationLog (ctx: MeasureContext): { opLogMetrics?: Met
}
const op: OperationLog = { start: Date.now(), ops: [], end: -1 }
let opLogMetrics: Metrics | undefined
ctx.id = generateId()
if (ctx.id === undefined) {
ctx.id = 'op_' + generateId()
}
if (ctx.metrics !== undefined) {
if (ctx.metrics.opLog === undefined) {
ctx.metrics.opLog = {}
-2
View File
@@ -111,6 +111,4 @@ export interface MeasureContext<Q = any> {
// Mark current context as complete
// If no value is passed, time difference will be used.
end: (value?: number) => void
onEnd?: (ctx: MeasureContext) => Promise<void>
}
+2
View File
@@ -59,6 +59,8 @@ export interface SessionData {
branding: Branding | null
fulltextUpdates?: Map<Ref<DocIndexState>, DocIndexState>
asyncRequests?: (() => Promise<void>)[]
}
/**
+3 -10
View File
@@ -20,7 +20,7 @@ import core, {
Class,
Classifier,
ClassifierKind,
CollaborativeDoc,
MarkupBlobRef,
Data,
DateRangeMode,
Doc,
@@ -500,15 +500,8 @@ export function ArrOf<T extends PropertyType | Ref<Doc>> (type: Type<T>): TypeAr
/**
* @public
*/
export function TypeCollaborativeDoc (): Type<CollaborativeDoc> {
return { _class: core.class.TypeCollaborativeDoc, label: core.string.CollaborativeDoc }
}
/**
* @public
*/
export function TypeCollaborativeDocVersion (): Type<CollaborativeDoc> {
return { _class: core.class.TypeCollaborativeDocVersion, label: core.string.CollaborativeDocVersion }
export function TypeCollaborativeDoc (): Type<MarkupBlobRef> {
return { _class: core.class.TypeCollaborativeDoc, label: core.string.MarkupBlobRef }
}
/**
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "Typ obsahu",
"ContentTypeNotSupported": "Náhled není dostupný pro tento typ obsahu",
"StartDrawing": "Načmárejte",
"DrawingHistory": "Čmárání historie"
"DrawingHistory": "Čmárání historie",
"ColorAdd": "Přidejte předvolbu barev",
"ColorRemove": "Odstraňte předvolbu barev",
"ColorReset": "Resetovat předvolby barev"
},
"status": {
"FileTooLarge": "Soubor je příliš velký"
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "Content type",
"ContentTypeNotSupported": "Preview is not available for this content type",
"StartDrawing": "Scribble over",
"DrawingHistory": "Scribble history"
"DrawingHistory": "Scribble history",
"ColorAdd": "Add color preset",
"ColorRemove": "Remove color preset",
"ColorReset": "Reset color presets"
},
"status": {
"FileTooLarge": "File too large"
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "Tipo de contenido",
"ContentTypeNotSupported": "La vista previa no está disponible para este tipo de contenido",
"StartDrawing": "Garabatear encima",
"DrawingHistory": "Historia de garabatos"
"DrawingHistory": "Historia de garabatos",
"ColorAdd": "Añadir ajuste preestablecido de color",
"ColorRemove": "Eliminar el ajuste preestablecido de color",
"ColorReset": "Restablecer ajustes preestablecidos de color"
},
"status": {
"FileTooLarge": "Archivo demasiado grande"
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "Type de contenu",
"ContentTypeNotSupported": "L'aperçu n'est pas disponible pour ce type de contenu",
"StartDrawing": "Gribouiller dessus",
"DrawingHistory": "Histoire de gribouillage"
"DrawingHistory": "Histoire de gribouillage",
"ColorAdd": "Ajouter un préréglage de couleur",
"ColorRemove": "Supprimer le préréglage de couleur",
"ColorReset": "Réinitialiser les préréglages de couleur"
},
"status": {
"FileTooLarge": "Fichier trop volumineux"
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "Tipo di contenuto",
"ContentTypeNotSupported": "Anteprima non disponibile per questo tipo di contenuto",
"StartDrawing": "Scarabocchiare sopra",
"DrawingHistory": "Scarabocchiare la storia"
"DrawingHistory": "Scarabocchiare la storia",
"ColorAdd": "Aggiungi preimpostazione colore",
"ColorRemove": "Rimuovi preimpostazione colore",
"ColorReset": "Ripristina i preset colore"
},
"status": {
"FileTooLarge": "File troppo grande"
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "Tipo de conteúdo",
"ContentTypeNotSupported": "A visualização não está disponível para este tipo de conteúdo",
"StartDrawing": "Scarabocchiare sopra",
"DrawingHistory": "História de rabiscos"
"DrawingHistory": "História de rabiscos",
"ColorAdd": "Adicionar predefinição de cor",
"ColorRemove": "Remover predefinição de cor",
"ColorReset": "Repor predefinições de cores"
},
"status": {
"FileTooLarge": "Ficheiro demasiado grande"
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "Тип контента",
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента",
"StartDrawing": "Сделать набросок",
"DrawingHistory": "История набросков"
"DrawingHistory": "История набросков",
"ColorAdd": "Добавить предустановленный цвет",
"ColorRemove": "Удалить предустановленный цвет",
"ColorReset": "Сбросить предустановленные цвета"
},
"status": {
"FileTooLarge": "Файл слишком большой"
+4 -1
View File
@@ -35,7 +35,10 @@
"ContentType": "内容类型",
"ContentTypeNotSupported": "此內容類型無法預覽",
"StartDrawing": "随意涂鸦",
"DrawingHistory": "涂鸦的历史"
"DrawingHistory": "涂鸦的历史",
"ColorAdd": "添加颜色预设",
"ColorRemove": "删除颜色预设",
"ColorReset": "重置颜色预设"
},
"status": {
"FileTooLarge": "文件太大"
+16 -11
View File
@@ -14,13 +14,12 @@
//
import { type CollaboratorClient, getClient as getCollaborator } from '@hcengineering/collaborator-client'
import { type CollaborativeDoc, type Markup, getWorkspaceId } from '@hcengineering/core'
import { type Blob, type CollaborativeDoc, type Markup, type Ref, getWorkspaceId } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from './plugin'
/** @public */
export function getCollaboratorClient (): CollaboratorClient {
function getClient (): CollaboratorClient {
const workspaceId = getWorkspaceId(getMetadata(presentation.metadata.WorkspaceId) ?? '')
const token = getMetadata(presentation.metadata.Token) ?? ''
const collaboratorURL = getMetadata(presentation.metadata.CollaboratorUrl) ?? ''
@@ -29,19 +28,25 @@ export function getCollaboratorClient (): CollaboratorClient {
}
/** @public */
export async function getMarkup (collaborativeDoc: CollaborativeDoc): Promise<Record<string, Markup>> {
const client = getCollaboratorClient()
return await client.getContent(collaborativeDoc)
export async function getMarkup (doc: CollaborativeDoc, source: Ref<Blob> | null | undefined): Promise<Markup> {
const client = getClient()
return await client.getMarkup(doc, source)
}
/** @public */
export async function updateMarkup (collaborativeDoc: CollaborativeDoc, content: Record<string, Markup>): Promise<void> {
const client = getCollaboratorClient()
await client.updateContent(collaborativeDoc, content)
export async function createMarkup (doc: CollaborativeDoc, markup: Markup): Promise<Ref<Blob>> {
const client = getClient()
return await client.createMarkup(doc, markup)
}
/** @public */
export async function copyDocument (source: CollaborativeDoc, target: CollaborativeDoc): Promise<void> {
const client = getCollaboratorClient()
export async function updateMarkup (doc: CollaborativeDoc, markup: Markup): Promise<void> {
const client = getClient()
await client.updateMarkup(doc, markup)
}
/** @public */
export async function copyMarkup (source: CollaborativeDoc, target: CollaborativeDoc): Promise<void> {
const client = getClient()
await client.copyContent(source, target)
}
@@ -13,10 +13,11 @@
// limitations under the License.
-->
<script lang="ts">
import { Button, IconDelete, IconEdit, resizeObserver } from '@hcengineering/ui'
import { Analytics } from '@hcengineering/analytics'
import { resizeObserver } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { drawing, type DrawingData, type DrawingTool } from '../drawing'
import IconEraser from './icons/Eraser.svelte'
import { drawing, type DrawingCmd, type DrawingData, type DrawingTool } from '../drawing'
import DrawingBoardToolbar from './DrawingBoardToolbar.svelte'
export let active = false
export let readonly = true
@@ -25,10 +26,11 @@
export let drawings: DrawingData[]
export let createDrawing: (data: any) => Promise<void>
let drawingTool: DrawingTool = 'pen'
let penColor = 'blue'
const penColors = ['red', 'green', 'blue', 'white', 'black']
let drawingData: DrawingData | undefined
let tool: DrawingTool
let penColor: string
let penWidth: number
let eraserWidth: number
let commands: DrawingCmd[] | undefined
let board: HTMLDivElement
let toolbar: HTMLDivElement
let toolbarInside = false
@@ -51,42 +53,58 @@
if (readonly !== oldReadonly || drawings !== oldDrawings) {
if (drawings !== undefined) {
if (readonly) {
if (modified && drawingData !== undefined) {
createDrawing(drawingData).catch((error) => {
console.error('Failed to save drawing', error)
})
}
drawingData = drawings[0]
saveDrawing()
parseDrawing(drawings[0])
modified = false
} else {
if (drawingData === undefined) {
drawingData = {}
if (commands === undefined) {
commands = []
} else {
// Edit current content as a new drawing
drawingData = {
content: drawingData.content
}
commands = [...commands]
}
modified = false
}
} else {
drawingData = undefined
commands = undefined
}
oldDrawings = drawings
oldReadonly = readonly
}
}
onDestroy(() => {
if (modified && drawingData !== undefined) {
createDrawing(drawingData).catch((error) => {
function parseDrawing (data: DrawingData | undefined): void {
if (data?.content !== undefined && data?.content !== null) {
try {
commands = JSON.parse(data.content)
} catch (error: any) {
commands = []
Analytics.handleError(error)
console.error('Failed to parse drawing content', error)
}
} else {
commands = []
}
}
function saveDrawing (): void {
if (modified && commands !== undefined) {
const data: DrawingData = {
content: JSON.stringify(commands)
}
createDrawing(data).catch((error) => {
Analytics.handleError(error)
console.error('Failed to save drawing', error)
})
}
}
onDestroy(() => {
saveDrawing()
})
</script>
{#if active && drawingData !== undefined}
{#if active && commands !== undefined}
<div
{...$$restProps}
style:position="relative"
@@ -98,99 +116,32 @@
readonly,
imageWidth,
imageHeight,
drawingData,
drawingTool,
commands,
tool,
penColor,
changed: (content) => {
penWidth,
eraserWidth,
cmdAdded: () => {
modified = true
if (drawingData !== undefined) {
drawingData.content = content
}
}
}}
>
{#if !readonly}
<div class="toolbar" class:inside={toolbarInside} bind:this={toolbar}>
<Button
icon={IconDelete}
kind="icon"
on:click={() => {
drawingData = {}
modified = true
}}
/>
<div class="divider buttons-divider" />
<Button
icon={IconEdit}
kind="icon"
selected={drawingTool === 'pen'}
on:click={() => {
drawingTool = 'pen'
}}
/>
<Button
icon={IconEraser}
kind="icon"
selected={drawingTool === 'erase'}
on:click={() => {
drawingTool = 'erase'
}}
/>
<div class="divider buttons-divider" />
{#each penColors as color}
<Button
kind="icon"
selected={penColor === color}
on:click={() => {
penColor = color
}}
>
<div
slot="content"
class="colorIcon"
class:emphasized={color === 'white' || color === 'black'}
style:background={color}
/>
</Button>
{/each}
</div>
<DrawingBoardToolbar
placeInside={toolbarInside}
bind:toolbar
bind:tool
bind:penColor
bind:penWidth
bind:eraserWidth
on:clear={() => {
commands = []
modified = true
}}
/>
{/if}
<slot />
</div>
{:else}
<slot />
{/if}
<style lang="scss">
.toolbar {
position: absolute;
display: inline-flex;
align-items: center;
padding: 5px;
bottom: 100%;
&.inside {
left: 5px;
top: 5px;
bottom: unset;
background-color: var(--theme-navpanel-color);
border-radius: var(--small-BorderRadius);
z-index: 1;
}
}
.colorIcon {
width: 12px;
height: 12px;
border-radius: 50%;
margin: -3px;
&.emphasized {
box-shadow: 0px 0px 3px 0px var(--theme-button-contrast-enabled);
}
}
.divider {
margin: 0 5px;
}
</style>
@@ -0,0 +1,250 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import {
Button,
IconAdd,
IconDelete,
IconEdit,
IconMoreH,
IconRedo,
SelectPopup,
SelectPopupValueType,
eventToHTMLElement,
showPopup
} from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import IconEraser from './icons/Eraser.svelte'
import IconMove from './icons/Move.svelte'
import { DrawingTool } from '../drawing'
import presentation from '../plugin'
const dispatch = createEventDispatcher()
const maxColors = 8
const minColors = 0
const defaultColor = '#0000ff'
const defaultColors = ['#ff0000', '#00ff00', '#0000ff', '#ffffff', '#000000']
const storageKey = {
color: 'drawingBoard.color',
colors: 'drawingBoard.colors',
penWidth: 'drawingBoard.penWidth',
eraserWidth: 'drawingBoard.eraserWidth'
}
export let tool: DrawingTool = 'pen'
export let penColor: string
export let penWidth: number
export let eraserWidth: number
export let placeInside = false
export let showPanTool = false
export let toolbar: HTMLDivElement | undefined
let colorSelector: HTMLInputElement
let penColors: string[] = defaultColors
function showMenu (ev: MouseEvent): void {
const items: SelectPopupValueType[] = []
if (penColors.length < maxColors) {
items.push({
id: 'add-color',
label: presentation.string.ColorAdd,
icon: IconAdd
})
}
if (penColors.length > minColors) {
items.push({
id: 'remove-color',
label: presentation.string.ColorRemove,
icon: IconDelete
})
}
items.push({
id: 'reset-colors',
label: presentation.string.ColorReset,
icon: IconRedo
})
showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id) => {
switch (id) {
case 'add-color': {
if (colorSelector !== undefined) {
colorSelector.value = penColor
colorSelector.click()
}
break
}
case 'remove-color': {
penColors = penColors.filter((c: string) => c !== penColor)
localStorage.setItem(storageKey.colors, JSON.stringify(penColors))
selectColor(penColors[0])
break
}
case 'reset-colors': {
penColors = defaultColors
localStorage.removeItem(storageKey.colors)
selectColor(penColors[0])
break
}
case undefined: {
break
}
default: {
console.error('Unknown command id', id)
}
}
})
}
function addColorPreset (): void {
penColor = penColor.toLowerCase()
if (!penColors.includes(penColor)) {
penColors = [...penColors, penColor]
localStorage.setItem(storageKey.colors, JSON.stringify(penColors))
}
}
function selectColor (color: string): void {
penColor = color ?? defaultColor
localStorage.setItem(storageKey.color, penColor)
}
onMount(() => {
try {
const savedColors = localStorage.getItem(storageKey.colors)
penColors = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors
} catch {
penColors = defaultColors
}
penColor = (localStorage.getItem(storageKey.color) ?? penColor ?? defaultColor).toLowerCase()
if (!penColors.includes(penColor)) {
penColor = penColors[0] ?? defaultColor
}
penWidth = parseInt(localStorage.getItem(storageKey.penWidth) ?? '4')
eraserWidth = parseInt(localStorage.getItem(storageKey.eraserWidth) ?? '30')
})
function updatePenWidth (): void {
localStorage.setItem(storageKey.penWidth, penWidth.toString())
}
function updateEraserWidth (): void {
localStorage.setItem(storageKey.eraserWidth, eraserWidth.toString())
}
</script>
<div class="toolbar" class:inside={placeInside} bind:this={toolbar}>
<Button
icon={IconDelete}
kind="icon"
on:click={() => {
tool = 'pen'
dispatch('clear')
}}
/>
<div class="divider buttons-divider" />
<Button
icon={IconEdit}
kind="icon"
selected={tool === 'pen'}
on:click={() => {
tool = 'pen'
}}
/>
<Button
icon={IconEraser}
kind="icon"
selected={tool === 'erase'}
on:click={() => {
tool = 'erase'
}}
/>
{#if showPanTool}
<Button
icon={IconMove}
kind="icon"
selected={tool === 'pan'}
on:click={() => {
tool = 'pan'
}}
/>
{/if}
<div class="divider buttons-divider" />
{#if tool === 'pen'}
<input type="range" min={2} max={20} step={2} bind:value={penWidth} on:change={updatePenWidth} />
{:else}
<input type="range" min={10} max={100} step={10} bind:value={eraserWidth} on:change={updateEraserWidth} />
{/if}
<div class="divider buttons-divider" />
{#each penColors as color}
<Button
kind="icon"
selected={penColor === color}
on:click={() => {
tool = 'pen'
selectColor(color)
}}
>
<div slot="content" class="colorIcon" style:background={color} />
</Button>
{/each}
<div>
<input
type="color"
class="colorSelector"
bind:this={colorSelector}
bind:value={penColor}
on:change={addColorPreset}
/>
<Button kind="icon" icon={IconMoreH} on:click={showMenu} />
</div>
</div>
<style lang="scss">
.toolbar {
position: absolute;
display: inline-flex;
align-items: center;
padding: 0.25rem;
bottom: 100%;
&.inside {
left: 0.5rem;
top: 0.5rem;
bottom: unset;
background-color: var(--theme-popup-header);
border-radius: var(--small-BorderRadius);
border: 1px solid var(--theme-popup-divider);
box-shadow: 0.05rem 0.05rem 0.25rem rgba(0, 0, 0, 0.2);
z-index: 1;
}
}
.colorIcon {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
margin: -0.15rem;
box-shadow: 0px 0px 0.15rem 0px var(--theme-button-contrast-enabled);
}
.divider {
margin: 0 0.25rem;
}
.colorSelector {
position: absolute;
width: 0;
opacity: 0;
}
</style>
@@ -13,6 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import { SortingOrder, type Blob, type Ref } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { Button, Dialog, IconHistory, IconScribble, showPopup, tooltip } from '@hcengineering/ui'
@@ -63,6 +64,7 @@
})
.catch((error) => {
drawingLoading = false
Analytics.handleError(error)
console.error('Failed to load drawings for file', file, error)
})
}
@@ -19,6 +19,8 @@
export let position: 'start' | 'middle' | 'end' | undefined = undefined
export let selected = false
export let color = 'var(--theme-bg-color)'
export let noGap = false
export let fontColor: string | undefined = undefined
let clientWidth = 0
@@ -43,6 +45,7 @@
<div
class="asb-bar"
class:selected
class:noGap
class:cursor-pointer={!selected && !$$slots.default}
class:cursor-default={selected || $$slots.default}
{title}
@@ -91,7 +94,7 @@
{/if}
</svg>
<div class="asb-label__container" class:selected class:disabled={!$$slots.default}>
<div class="overflow-label">
<div class="overflow-label" style={fontColor ? `color: ${fontColor};` : ''}>
{#if $$slots.default}
<slot />
{:else}
@@ -0,0 +1,10 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.646.146a.5.5 0 01.708 0l2 2a.5.5 0 01-.708.708L8.5 1.707V5.5a.5.5 0 01-1 0V1.707L6.354 2.854a.5.5 0 11-.708-.708l2-2zM8 10a.5.5 0 01.5.5v3.793l1.146-1.147a.5.5 0 01.708.708l-2 2a.5.5 0 01-.708 0l-2-2a.5.5 0 01.708-.708L7.5 14.293V10.5A.5.5 0 018 10zM.146 8.354a.5.5 0 010-.708l2-2a.5.5 0 11.708.708L1.707 7.5H5.5a.5.5 0 010 1H1.707l1.147 1.146a.5.5 0 01-.708.708l-2-2zM10 8a.5.5 0 01.5-.5h3.793l-1.147-1.146a.5.5 0 01.708-.708l2 2a.5.5 0 010 .708l-2 2a.5.5 0 01-.708-.708L14.293 8.5H10.5A.5.5 0 0110 8z"
/>
</svg>
+114 -59
View File
@@ -19,15 +19,22 @@ export interface DrawingData {
export interface DrawingProps {
readonly: boolean
autoSize?: boolean
imageWidth?: number
imageHeight?: number
drawingData: DrawingData
drawingTool?: DrawingTool
commandCount?: number
commands: DrawingCmd[]
offset?: Point
tool?: DrawingTool
penColor?: string
changed?: (content: string) => void
penWidth?: number
eraserWidth?: number
defaultCursor?: string
cmdAdded?: (cmd: DrawingCmd) => void
panned?: (offset: Point) => void
}
interface DrawCmd {
export interface DrawingCmd {
lineWidth: number
erasing: boolean
penColor: string
@@ -52,6 +59,7 @@ class DrawState {
penWidth = 4
eraserWidth = 30
minLineLength = 6
center: Point = { x: 0, y: 0 }
offset: Point = { x: 0, y: 0 }
points: Point[] = []
scale: Point = { x: 1, y: 1 }
@@ -71,8 +79,8 @@ class DrawState {
addPoint = (mouseX: number, mouseY: number): void => {
this.points.push({
x: mouseX * this.scale.x - this.offset.x,
y: mouseY * this.scale.y - this.offset.y
x: mouseX * this.scale.x - this.offset.x - this.center.x,
y: mouseY * this.scale.y - this.offset.y - this.center.y
})
}
@@ -86,6 +94,8 @@ class DrawState {
this.addPoint(x, y)
}
const erasing = this.tool === 'erase'
this.ctx.save()
this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y)
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = this.penColor
@@ -97,10 +107,13 @@ class DrawState {
this.drawSmoothSegment(this.points, this.points.length - 1, lastPoint)
this.ctx.stroke()
}
this.ctx.restore()
})
}
drawCommand = (cmd: DrawCmd): void => {
drawCommand = (cmd: DrawingCmd): void => {
this.ctx.save()
this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y)
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = cmd.penColor
@@ -114,6 +127,7 @@ class DrawState {
}
this.ctx.stroke()
}
this.ctx.restore()
}
drawPoint = (p: Point, erasing: boolean): void => {
@@ -121,7 +135,7 @@ class DrawState {
if (!erasing) {
// Single point looks too small compared to a line of the same width
// So make it a bit biggers
r *= 1.5
r *= 1 / r + 1
}
this.ctx.lineWidth = 0
this.ctx.fillStyle = this.ctx.strokeStyle
@@ -152,15 +166,13 @@ class DrawState {
}
}
export function drawing (node: HTMLElement, props: DrawingProps): any {
if (
props.imageWidth === undefined ||
props.imageHeight === undefined ||
node.clientWidth === undefined ||
node.clientHeight === undefined
) {
export function drawing (
node: HTMLElement,
props: DrawingProps
): { canvas?: HTMLCanvasElement, update?: (props: DrawingProps) => void } {
if (props.autoSize !== true && (props.imageWidth === undefined || props.imageHeight === undefined)) {
console.error('Failed to create drawing: image size is not specified')
return
return {}
}
const canvas = document.createElement('canvas')
@@ -169,43 +181,62 @@ export function drawing (node: HTMLElement, props: DrawingProps): any {
canvas.style.top = '0'
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.width = props.imageWidth
canvas.height = props.imageHeight
canvas.width = props.imageWidth ?? 0
canvas.height = props.imageHeight ?? 0
node.appendChild(canvas)
const ctx = canvas.getContext('2d')
if (ctx === null) {
console.error('Failed to create drawing: unable to get 2d canvas context')
node.removeChild(canvas)
return
return {}
}
const canvasCursor = document.createElement('div')
canvasCursor.style.visibility = 'hidden'
canvasCursor.style.position = 'absolute'
canvasCursor.style.borderRadius = '50%'
canvasCursor.style.border = 'none'
canvasCursor.style.cursor = 'none'
canvasCursor.style.pointerEvents = 'none'
canvasCursor.style.left = '50%'
canvasCursor.style.top = '50%'
node.appendChild(canvasCursor)
let readonly = props.readonly ?? false
let prevPos: Point = { x: 0, y: 0 }
const draw = new DrawState(ctx)
draw.tool = props.drawingTool ?? 'pan'
draw.penColor = props.penColor ?? 'blue'
draw.tool = props.tool ?? draw.tool
draw.penColor = props.penColor ?? draw.penColor
draw.penWidth = props.penWidth ?? draw.penWidth
draw.eraserWidth = props.eraserWidth ?? draw.eraserWidth
updateCanvasCursor()
let commands: DrawCmd[] = []
let drawingData = props.drawingData
parseData()
let commands = props.commands
let commandCount = props.commandCount ?? 0
replayCommands({
offset: {
x: props.offset?.x ?? 0,
y: props.offset?.y ?? 0
}
})
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === canvas) {
draw.scale = {
x: canvas.width / entry.contentRect.width,
y: canvas.height / entry.contentRect.height
if (props.autoSize === true) {
draw.scale = { x: 1, y: 1 }
canvas.width = Math.floor(entry.contentRect.width)
canvas.height = Math.floor(entry.contentRect.height)
draw.center.x = canvas.width / 2
draw.center.y = canvas.height / 2
replayCommands({ offset: draw.offset })
} else {
draw.scale = {
x: canvas.width / entry.contentRect.width,
y: canvas.height / entry.contentRect.height
}
}
}
}
@@ -255,9 +286,18 @@ export function drawing (node: HTMLElement, props: DrawingProps): any {
}
}
// TODO: if (draw.tool === 'pan')
// Currently we only show drawing over attached images
// Their sizes are fixed and no pan required
if (draw.on && draw.tool === 'pan') {
requestAnimationFrame(() => {
replayCommands({
offset: {
x: draw.offset.x + x - prevPos.x,
y: draw.offset.y + y - prevPos.y
}
})
prevPos = { x, y }
props.panned?.(draw.offset)
})
}
}
canvas.onpointerup = (e) => {
@@ -288,30 +328,31 @@ export function drawing (node: HTMLElement, props: DrawingProps): any {
}
function storeCommand (): void {
if (draw.points.length > 1) {
if (draw.points.length > 0) {
const erasing = draw.tool === 'erase'
const cmd: DrawCmd = {
const cmd: DrawingCmd = {
lineWidth: (erasing ? draw.eraserWidth : draw.penWidth) * draw.lineScale(),
erasing,
penColor: draw.penColor,
points: draw.points
}
commands.push(cmd)
props.changed?.(JSON.stringify(commands))
props.cmdAdded?.(cmd)
}
}
function updateCanvasCursor (): void {
if (readonly) {
canvasCursor.style.visibility = 'hidden'
canvas.style.cursor = 'default'
canvas.style.cursor = props.defaultCursor ?? 'default'
} else if (draw.isDrawingTool()) {
canvas.style.cursor = 'none'
const erasing = draw.tool === 'erase'
const w = draw.cursorWidth()
canvasCursor.style.background = erasing ? 'none' : draw.penColor
canvasCursor.style.border = erasing ? '1px solid #333' : 'none'
canvasCursor.style.boxShadow = erasing ? '0px 0px 0px 1px #eee inset' : 'none'
canvasCursor.style.boxShadow = erasing
? '0px 0px 1px 1px white inset, 0px 0px 2px 1px black'
: '0px 0px 3px 0px var(--theme-button-contrast-enabled)'
canvasCursor.style.width = `${w}px`
canvasCursor.style.height = `${w}px`
} else if (draw.tool === 'pan') {
@@ -328,43 +369,54 @@ export function drawing (node: HTMLElement, props: DrawingProps): any {
draw.offset = { x: 0, y: 0 }
}
function replayCommands (): void {
draw.ctx.reset()
for (const cmd of commands) {
draw.drawCommand(cmd)
function replayCommands ({ offset, startIndex }: { offset?: Point, startIndex?: number } = {}): void {
if (startIndex === undefined || startIndex === 0) {
clearCanvas()
}
}
function parseData (): void {
clearCanvas()
if (drawingData.content !== undefined && drawingData.content !== null) {
try {
commands = JSON.parse(drawingData.content)
replayCommands()
} catch (error) {
commands = []
console.error('Failed to parse drawing content', error)
}
} else {
commands = []
if (offset !== undefined) {
draw.offset = offset
}
for (let i = startIndex ?? 0; i < commands.length; i++) {
draw.drawCommand(commands[i])
}
commandCount = commands.length
}
return {
canvas,
update (props: DrawingProps) {
if (drawingData !== props.drawingData) {
drawingData = props.drawingData
parseData()
let replay = false
let offset: Point | undefined
let startIndex: number | undefined
if (props.offset?.x !== draw.offset.x || props.offset?.y !== draw.offset.y) {
offset = props.offset
replay = true
}
if (commands !== props.commands) {
commands = props.commands
replay = true
} else if (props.commandCount !== undefined && props.commandCount !== commandCount) {
startIndex = commandCount
replay = true
}
let updateCursor = false
if (draw.tool !== props.drawingTool) {
draw.tool = props.drawingTool ?? 'pen'
if (draw.tool !== props.tool) {
draw.tool = props.tool ?? 'pen'
updateCursor = true
}
if (draw.penColor !== props.penColor) {
draw.penColor = props.penColor ?? 'blue'
updateCursor = true
}
if (draw.penWidth !== props.penWidth) {
draw.penWidth = props.penWidth ?? 5
updateCursor = true
}
if (draw.eraserWidth !== props.eraserWidth) {
draw.eraserWidth = props.eraserWidth ?? 5
updateCursor = true
}
if (props.readonly !== readonly) {
readonly = props.readonly ?? false
updateCursor = true
@@ -372,6 +424,9 @@ export function drawing (node: HTMLElement, props: DrawingProps): any {
if (updateCursor) {
updateCanvasCursor()
}
if (replay) {
replayCommands({ offset, startIndex })
}
}
}
}
+7 -6
View File
@@ -173,17 +173,18 @@ export function getFileUrl (file: string, filename?: string): string {
/**
* @public
*/
export async function uploadFile (file: File): Promise<Ref<PlatformBlob>> {
const id = generateFileId()
const params = getFileUploadParams(id, file)
export async function uploadFile (file: File, uuid?: Ref<PlatformBlob>): Promise<Ref<PlatformBlob>> {
uuid ??= generateFileId() as Ref<PlatformBlob>
const params = getFileUploadParams(uuid, file)
if (params.method === 'signed-url') {
await uploadFileWithSignedUrl(file, id, params.url)
await uploadFileWithSignedUrl(file, uuid, params.url)
} else {
await uploadFileWithFormData(file, id, params.url)
await uploadFileWithFormData(file, uuid, params.url)
}
return id as Ref<PlatformBlob>
return uuid
}
/**
+1
View File
@@ -49,6 +49,7 @@ export { default as LiteMessageViewer } from './components/LiteMessageViewer.sve
export { default as DownloadFileButton } from './components/DownloadFileButton.svelte'
export { default as FileTypeIcon } from './components/FileTypeIcon.svelte'
export { default as DrawingBoard } from './components/DrawingBoard.svelte'
export { default as DrawingBoardToolbar } from './components/DrawingBoardToolbar.svelte'
export { default } from './plugin'
export * from './types'
export * from './utils'
+4 -1
View File
@@ -123,7 +123,10 @@ export default plugin(presentationId, {
ContentType: '' as IntlString,
ContentTypeNotSupported: '' as IntlString,
StartDrawing: '' as IntlString,
DrawingHistory: '' as IntlString
DrawingHistory: '' as IntlString,
ColorAdd: '' as IntlString,
ColorRemove: '' as IntlString,
ColorReset: '' as IntlString
},
extension: {
FilePreviewExtension: '' as ComponentExtensionId,
+1 -1
View File
@@ -127,7 +127,7 @@ p:last-child { margin-block-end: 0; }
.p-inline {
overflow-wrap: break-word;
word-wrap: break-word;
hyphens: auto;
// hyphens: auto;
line-height: 150%;
&.contrast { color: var(--global-primary-TextColor); }
+27 -8
View File
@@ -141,20 +141,38 @@
}
.antiPanel-navigator {
position: relative;
background-color: var(--theme-navpanel-color);
&:not(.second) { background-color: var(--theme-navpanel-color); }
&:not(.right) {
min-width: 12.5rem;
max-width: 22.5rem;
width: 17.5rem;
}
&.right {
border-radius: var(--medium-BorderRadius);
}
}
&.right { border-radius: var(--medium-BorderRadius); }
&.second.float {
background-color: var(--theme-navpanel-color);
filter: drop-shadow(2px 0 5px rgba(0, 0, 0, .2));
z-index: 460;
&:not(.inner) {
position: fixed;
top: calc(var(--status-bar-height) + 3.5rem + 1px);
height: calc(100% - var(--status-bar-height) - 3.5rem - 2px);
border-left: 1px solid var(--theme-divider-color);
@media (max-width: 1024px) {
.antiPanel-navigator {
&.portrait { left: 0; }
&.landscape { left: var(--app-panel-width); }
}
&.inner {
position: absolute;
top: 3.5rem;
left: 0;
height: calc(100% - 3.5rem);
}
}
&.fly:not(.second) {
position: fixed;
background-color: var(--theme-navpanel-color);
z-index: 450;
@@ -176,6 +194,7 @@
}
}
}
@media (max-width: 480px) {
.mobile-theme {
.mobile-wrapper,
+4
View File
@@ -1493,6 +1493,10 @@
min-width: 0;
width: auto;
&.noGap {
flex: none;
}
&__back {
padding: 1px 0.5px;
height: calc(1.5rem + 2px);
+7
View File
@@ -326,6 +326,13 @@
height: calc(100vh - 4rem);
max-width: calc(100vw - 3rem);
max-height: calc(100vh - 4rem);
.mobile-theme & {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
}
.header {
+23 -23
View File
@@ -14,13 +14,13 @@
// limitations under the License.
-->
<script lang="ts">
import { getResource } from '@hcengineering/platform'
import { getResourceC } from '@hcengineering/platform'
import { afterUpdate, onMount } from 'svelte'
import { deviceOptionsStore as deviceInfo, resizeObserver } from '..'
import { closePanel, PanelProps, panelstore } from '../panelup'
import { fitPopupElement, popupstore } from '../popups'
import { deviceOptionsStore as deviceInfo, resizeObserver } from '..'
import type { AnySvelteComponent, PopupOptions, DeviceOptions } from '../types'
import type { AnySvelteComponent, DeviceOptions, PopupOptions } from '../types'
import Spinner from './Spinner.svelte'
export let contentPanel: HTMLElement | undefined
@@ -51,7 +51,7 @@
let keepSize: boolean = false
let props: PanelProps | undefined
function _close () {
function _close (): void {
closePanel()
}
@@ -59,7 +59,7 @@
if ($panelstore.panel.component === undefined) {
props = $panelstore.panel
} else {
getResource($panelstore.panel.component).then((r) => {
getResourceC($panelstore.panel.component, (r) => {
component = r
props = $panelstore.panel
})
@@ -69,14 +69,14 @@
props = undefined
}
function escapeClose () {
function escapeClose (): void {
// Check if there is popup visible, then ignore
if ($popupstore.length > 0) {
return
}
if (componentInstance?.canClose) {
if (!componentInstance.canClose()) {
if (componentInstance?.canClose !== undefined) {
if (!(componentInstance as { canClose: () => boolean }).canClose()) {
return
}
}
@@ -84,20 +84,20 @@
}
const fitPopup = (props: PanelProps, contentPanel: HTMLElement): void => {
if (modalHTML) {
if (modalHTML != null) {
const device: DeviceOptions = $deviceInfo
options = fitPopupElement(modalHTML, device, props.element, contentPanel)
}
}
function handleKeydown (ev: KeyboardEvent) {
function handleKeydown (ev: KeyboardEvent): void {
if (ev.key === 'Escape') {
escapeClose()
}
}
function _open (): void {
if (modalHTML && props) {
if (modalHTML != null && props != null) {
if (props.element === 'content') {
modalHTML.classList.add('bg')
} else {
@@ -107,28 +107,28 @@
}
const _update = (): void => {
if (props && contentPanel) {
if (props != null && contentPanel != null) {
fitPopup(props, contentPanel)
}
}
const checkResize = (el: Element) => {
if (props && contentPanel) fitPopup(props, contentPanel)
const checkResize = (): void => {
if (props != null && contentPanel != null) fitPopup(props, contentPanel)
}
onMount(() => {
if (props && contentPanel) fitPopup(props, contentPanel)
if (props != null && contentPanel != null) fitPopup(props, contentPanel)
})
afterUpdate(() => {
if (props && contentPanel) fitPopup(props, contentPanel)
if (props != null && contentPanel != null) fitPopup(props, contentPanel)
})
$: if (contentPanel !== oldPanel) {
oldPanel = contentPanel
keepSize = false
}
$: if (props && contentPanel !== undefined) {
$: if (props != null && contentPanel !== undefined) {
fitPopup(props, contentPanel)
if (!keepSize && props?.element === 'content') {
keepSize = true
@@ -138,26 +138,26 @@
}
export function fitPopupInstance (): void {
if (props && contentPanel) fitPopup(props, contentPanel)
if (props != null && contentPanel != null) fitPopup(props, contentPanel)
}
</script>
<svelte:window
on:resize={() => {
if (props && contentPanel) fitPopup(props, contentPanel)
if (props != null && contentPanel != null) fitPopup(props, contentPanel)
}}
on:keydown={(evt) => {
if (props) handleKeydown(evt)
if (props != null) handleKeydown(evt)
}}
on:beforeprint={() => {
if (props && contentPanel) fitPopup(props, contentPanel)
if (props != null && contentPanel != null) fitPopup(props, contentPanel)
}}
on:afterprint={() => {
if (props && contentPanel) fitPopup(props, contentPanel)
if (props != null && contentPanel != null) fitPopup(props, contentPanel)
}}
/>
{#if props}
{#if !component}
{#if !(component != null)}
<Spinner />
{:else}
<slot name="panel-header" />
@@ -41,6 +41,7 @@
export let shrink: boolean = false
export let divScroll: HTMLElement | undefined | null = undefined
export let divBox: HTMLElement | undefined | null = undefined
export let scrollSnap: boolean = false
export let checkForHeaders: boolean = false
export let stickedScrollBars: boolean = false
export let thinScrollBars: boolean = false
@@ -571,6 +572,8 @@
class="scroll relative flex-shrink flex-col"
style:flex-direction={scrollDirection === 'vertical-reverse' ? 'column-reverse' : 'column'}
class:disableOverscroll
class:scrollSnapX={scrollSnap && contentDirection === 'horizontal'}
class:scrollSnapY={scrollSnap && contentDirection === 'vertical'}
style:overflow-x={horizontal ? 'auto' : 'hidden'}
on:scroll={() => {
if (onScroll) {
@@ -879,6 +882,16 @@
&.disableOverscroll {
overscroll-behavior: none;
}
&.scrollSnapY {
scroll-snap-type: y mandatory;
}
&.scrollSnapX {
scroll-snap-type: x mandatory;
}
&.scrollSnapX,
&.scrollSnapY {
scroll-padding-inline: var(--spacing-1);
}
&::-webkit-scrollbar:vertical {
display: none;
width: 0;
@@ -13,7 +13,8 @@
// limitations under the License.
-->
<script lang="ts">
import type { IconSize } from '@hcengineering/ui'
import type { IconSize } from '../../types'
export let size: IconSize = 'small'
const fill: string = 'currentColor'
</script>
@@ -13,7 +13,8 @@
// limitations under the License.
-->
<script lang="ts">
import type { IconSize } from '@hcengineering/ui'
import type { IconSize } from '../../types'
export let size: IconSize = 'small'
const fill: string = 'currentColor'
</script>
@@ -140,7 +140,7 @@
updateDeviceSize()
$: secondRow = checkAdaptiveMatching($deviceInfo.size, 'xs')
$: asideFloat = $deviceInfo.navigator.float
$: asideFloat = $deviceInfo.aside.float
$: asideOpen = $deviceInfo.aside.visible
$: appsMini =
$deviceInfo.isMobile &&
+3 -1
View File
@@ -242,6 +242,8 @@ export { default as IconCheckmark } from './components/icons/Checkmark.svelte'
export { default as IconToDetails } from './components/icons/ToDetails.svelte'
export { default as IconHistory } from './components/icons/History.svelte'
export { default as IconScribble } from './components/icons/Scribble.svelte'
export { default as IconMenuOpen } from './components/icons/MenuOpen.svelte'
export { default as IconMenuClose } from './components/icons/MenuClose.svelte'
export { default as PanelInstance } from './components/PanelInstance.svelte'
export { default as Panel } from './components/Panel.svelte'
@@ -317,7 +319,7 @@ export const deviceOptionsStore = writable<DeviceOptions>({
isPortrait: false,
isMobile: false,
navigator: { visible: true, float: false, direction: 'vertical' },
aside: { visible: true },
aside: { visible: true, float: false },
fontSize: 0,
size: null,
sizes: { xs: false, sm: false, md: false, lg: false, xl: false, xxl: false },
+7 -4
View File
@@ -11,7 +11,9 @@ export interface PanelProps {
refit?: () => void
}
export const panelstore = writable<{ panel?: PanelProps | undefined }>({ panel: undefined })
export const panelstore = writable<{
panel?: PanelProps | undefined
}>({ panel: undefined })
let currentLocation: string | undefined
export function getPanelURI (component: AnyComponent, _id: string, _class: string, element?: PopupAlignment): string {
@@ -27,17 +29,18 @@ export function showPanel (
_id: string,
_class: string,
element?: PopupAlignment,
rightSection?: AnyComponent
rightSection?: AnyComponent,
doNavigate: boolean = true
): void {
openPanel(component, _id, _class, element, rightSection)
const loc = getLocation()
if (loc.fragment !== currentLocation) {
if (doNavigate && loc.fragment !== currentLocation) {
loc.fragment = currentLocation
navigate(loc)
}
}
export function openPanel (
function openPanel (
component: AnyComponent,
_id: string,
_class: string,
+1 -1
View File
@@ -381,7 +381,7 @@ export interface DeviceOptions {
isPortrait: boolean
isMobile: boolean
navigator: { visible: boolean, float: boolean, direction: 'vertical' | 'horizontal' }
aside: { visible: boolean }
aside: { visible: boolean, float: boolean }
fontSize: number
size: WidthType | null
sizes: Record<WidthType, boolean>
+1 -2
View File
@@ -56,7 +56,6 @@ export async function translate (text: Markup, lang: string): Promise<TranslateR
export async function connectMeeting (
roomId: Ref<Room>,
sid: string,
language: RoomLanguage,
options: Partial<ConnectMeetingRequest>
): Promise<void> {
@@ -68,7 +67,7 @@ export async function connectMeeting (
}
try {
const req: ConnectMeetingRequest = { roomId, roomSid: sid, transcription: options.transcription ?? false, language }
const req: ConnectMeetingRequest = { roomId, transcription: options.transcription ?? false, language }
await fetch(concatLink(url, 'love/connect'), {
method: 'POST',
headers: {
-2
View File
@@ -61,7 +61,6 @@ export interface TranslateResponse {
export interface ConnectMeetingRequest {
roomId: Ref<Room>
roomSid: string
language: RoomLanguage
transcription: boolean
}
@@ -74,7 +73,6 @@ export interface PostTranscriptRequest {
transcript: string
participant: Ref<Person>
roomName: string
final: boolean
}
export interface IdentityResponse {
@@ -27,7 +27,7 @@
</script>
{#if attachments.length}
<Scroller contentDirection={'horizontal'} horizontal gap={'gap-3'}>
<Scroller contentDirection={'horizontal'} horizontal gap={'gap-3'} scrollSnap>
{#each attachments as attachment}
<AttachmentPreview
value={attachment}
@@ -109,5 +109,6 @@
.content {
max-width: 20rem;
max-height: 20rem;
scroll-snap-align: start;
}
</style>
+3 -5
View File
@@ -183,12 +183,10 @@ export function showAttachmentPreviewPopup (value: WithLookup<Attachment>): Popu
content: data.content
})
const newDrawing = await client.findOne(attachment.class.Drawing, { _id: newId })
if (newDrawing !== undefined) {
return newDrawing
} else {
console.error('Unable to find just created drawing')
return data
if (newDrawing === undefined) {
throw new Error('Unable to find just created drawing')
}
return newDrawing
}
}
+2 -3
View File
@@ -8,8 +8,7 @@ import core, {
SortingOrder,
Status,
TxOperations,
generateId,
makeCollaborativeDoc
generateId
} from '@hcengineering/core'
import recruit, { Applicant, Vacancy } from '@hcengineering/recruit'
import task, { ProjectType, makeRank } from '@hcengineering/task'
@@ -41,7 +40,7 @@ export async function createVacancy (
{
name,
description: type.shortDescription ?? '',
fullDescription: makeCollaborativeDoc(id, 'fullDescription'),
fullDescription: null,
private: false,
archived: false,
company,
@@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Breadcrumbs, Header } from '@hcengineering/ui'
import { Breadcrumbs, Header, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { IntlString } from '@hcengineering/platform'
import { CalendarMode } from '../index'
@@ -43,6 +43,7 @@
adaptive="autoExtra"
doubleRowWidth={350}
closeOnEscape={false}
topIndent={$deviceInfo.navigator.float && !$deviceInfo.aside.float}
on:close
>
<div class="title">
@@ -170,6 +170,7 @@
class="antiPanel-navigator {$deviceInfo.navigator.direction === 'horizontal'
? 'portrait'
: 'landscape'} border-left"
class:fly={$deviceInfo.navigator.float}
>
<div class="antiPanel-wrap__content hulyNavPanel-container">
<ChatNavigator {object} {currentSpecial} on:select={handleChannelSelected} />
@@ -20,13 +20,13 @@
AttachedData,
fillDefaults,
generateId,
makeCollaborativeDoc,
makeCollabId,
Ref,
TxOperations,
WithLookup
} from '@hcengineering/core'
import { Card, getClient, InlineAttributeBar, updateMarkup } from '@hcengineering/presentation'
import { EmptyMarkup } from '@hcengineering/text'
import { Card, createMarkup, getClient, InlineAttributeBar } from '@hcengineering/presentation'
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
import { Button, createFocusManager, EditBox, FocusHandler, IconAttachment, IconInfo, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
@@ -46,7 +46,6 @@
const object: Organization = {
name: '',
description: makeCollaborativeDoc(id, 'description'),
attachments: 0
} as unknown as Organization
@@ -59,8 +58,11 @@
fillDefaults(hierarchy, object, contact.class.Organization)
async function createOrganization (): Promise<void> {
if (!isEmptyMarkup(description)) {
const target = makeCollabId(contact.class.Organization, id, 'description')
object.description = await createMarkup(target, description)
}
const op = client.apply()
await updateMarkup(object.description, { description })
await op.createDoc(contact.class.Organization, contact.space.Contacts, object, id)
await descriptionBox.createAttachments(id, op)
+2 -2
View File
@@ -18,13 +18,13 @@ import {
Account,
AttachedDoc,
Class,
CollaborativeDoc,
Doc,
Ref,
Space,
Timestamp,
UXObject,
type Blob,
type MarkupBlobRef,
type Data,
type WithLookup
} from '@hcengineering/core'
@@ -137,7 +137,7 @@ export interface Member extends AttachedDoc {
*/
export interface Organization extends Contact {
members: number
description: CollaborativeDoc
description: MarkupBlobRef | null
}
/**
@@ -17,17 +17,8 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Employee, PersonAccount } from '@hcengineering/contact'
import {
AttachedData,
Class,
generateId,
makeCollaborativeDoc,
getCurrentAccount,
Mixin,
Ref,
SortingOrder
} from '@hcengineering/core'
import { Card, copyDocument, createQuery, getClient } from '@hcengineering/presentation'
import { AttachedData, Class, generateId, getCurrentAccount, Mixin, Ref, SortingOrder } from '@hcengineering/core'
import { Card, createQuery, getClient } from '@hcengineering/presentation'
import { createFocusManager, EditBox, FocusHandler } from '@hcengineering/ui'
import { ObjectBox } from '@hcengineering/view-resources'
import {
@@ -36,10 +27,10 @@
type DocumentCategory,
type ChangeControl,
type DocumentSpace,
DocumentState,
createControlledDocFromTemplate
DocumentState
} from '@hcengineering/controlled-documents'
import { createControlledDocFromTemplate } from '../docutils'
import documents from '../plugin'
export let documentClass: Ref<Class<ControlledDocument>> = documents.class.ControlledDocument
@@ -78,7 +69,7 @@
approvers: [],
coAuthors: [],
changeControl: '' as Ref<ChangeControl>,
content: makeCollaborativeDoc(generateId())
content: null
}
let templateId: Ref<DocumentTemplate> | undefined = initTemplateId
@@ -91,17 +82,7 @@
return
}
await createControlledDocFromTemplate(
client,
templateId,
id,
object,
space,
undefined,
undefined,
documentClass,
copyDocument
)
await createControlledDocFromTemplate(client, templateId, id, object, space, undefined, undefined, documentClass)
dispatch('close', id)
}
@@ -109,7 +90,7 @@
const manager = createFocusManager()
$: if (templateId === undefined) {
client
void client
.findOne(
templateMixin,
{ _class: documentClass, _id: { $nin: excludedTemplates ?? [] } },
@@ -134,6 +115,7 @@
object.template = template._id
object.prefix = template.prefix
object.category = template.category
object.content = template.content
}
},
{
@@ -274,16 +274,18 @@
$: canShowSidebar = $editorMode !== 'comparing'
$: sideBar = canShowSidebar ? $availableRightPanelTabs : []
$: collaborativeDoc = $controlledDocument?.content
$: initialCollaborativeDoc = $controlledDocumentTemplate?.content
$: workspace = $resolvedLocationStore.path[1].toUpperCase()
$: docReference = getDocReference($controlledDocument)
$: templateReference = getDocReference($controlledDocumentTemplate)
$: attribute = {
key: 'content',
attr: client.getHierarchy().getAttribute(documents.class.ControlledDocument, 'content')
}
</script>
{#if $controlledDocument !== null && collaborativeDoc !== undefined}
{#if $controlledDocument !== null && attribute !== undefined}
<Panel
bind:innerWidth
isHeader={false}
@@ -430,13 +432,7 @@
<svelte:component this={DocumentTemplateFooter} slot="page-footer" {templateReference} />
<Collaboration
{collaborativeDoc}
{initialCollaborativeDoc}
objectClass={$controlledDocument._class}
objectId={$controlledDocument._id}
objectAttr="content"
>
<Collaboration object={$controlledDocument} {attribute}>
{#if $editorMode === 'comparing'}
<DocumentDiffViewer />
{:else}
@@ -18,13 +18,12 @@
import {
generateId,
getCurrentAccount,
makeCollaborativeDoc,
type AttachedData,
type Class,
type Data,
type Ref
} from '@hcengineering/core'
import { MessageBox, copyDocument, getClient } from '@hcengineering/presentation'
import { MessageBox, getClient } from '@hcengineering/presentation'
import {
AnySvelteComponent,
addNotification,
@@ -43,10 +42,10 @@
type DocumentTemplate,
DocumentState,
createChangeControl,
createControlledDocFromTemplate,
DEFAULT_PERIODIC_REVIEW_INTERVAL
} from '@hcengineering/controlled-documents'
import { createControlledDocFromTemplate } from '../../docutils'
import documents from '../../plugin'
import { getProjectDocumentLink } from '../../navigation'
import InfoStep from './steps/InfoStep.svelte'
@@ -122,7 +121,7 @@
state: DocumentState.Draft,
snapshots: 0,
changeControl: ccRecordId,
content: makeCollaborativeDoc(generateId()),
content: null,
requests: 0,
reviewers: [],
@@ -161,8 +160,7 @@
_space,
$locationStep.project,
$locationStep.parent,
documents.class.ControlledDocument,
copyDocument
documents.class.ControlledDocument
)
if (!success) {
@@ -32,8 +32,7 @@
type Ref,
type Mixin,
generateId,
getCurrentAccount,
makeCollaborativeDoc
getCurrentAccount
} from '@hcengineering/core'
import { MessageBox, getClient } from '@hcengineering/presentation'
import {
@@ -118,7 +117,7 @@
state: DocumentState.Draft,
snapshots: 0,
changeControl: ccRecordId,
content: makeCollaborativeDoc(generateId()),
content: null,
requests: 0,
reviewers: [],
@@ -1,7 +1,7 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte'
import { getClient } from '@hcengineering/presentation'
import { type Doc } from '@hcengineering/core'
import { makeDocCollabId, type Doc } from '@hcengineering/core'
import { CollaborationIds, type Ydoc } from '@hcengineering/text-editor'
import {
CollaborationDiffViewer,
@@ -89,9 +89,8 @@
}
loading = true
const data = createTiptapCollaborationData({
document: $compareTo.content
})
const compareToDoc = makeDocCollabId($compareTo, 'content')
const data = createTiptapCollaborationData(compareToDoc, $compareTo.content)
comparedYdoc = data.ydoc
comparedProvider = data.provider
void comparedProvider.loaded.then(() => (loading = false))
@@ -15,7 +15,7 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, tick } from 'svelte'
import { merge } from 'effector'
import { type CollaborativeDoc, type Ref, type Blob, generateId } from '@hcengineering/core'
import { type Ref, type Blob, generateId } from '@hcengineering/core'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
@@ -40,7 +40,6 @@
import {
$areDocumentCommentPopupsOpened as areDocumentCommentPopupsOpened,
$controlledDocument as controlledDocument,
$controlledDocumentTemplate as controlledDocumentTemplate,
$isEditable as isEditable,
$documentCommentHighlightedLocation as documentCommentHighlightedLocation,
$areDocumentCommentPopupsOpened as arePopupsOpened,
@@ -67,16 +66,6 @@
let editor: Editor
let title = $controlledDocument?.title ?? ''
let collaborativeDoc: CollaborativeDoc | undefined
$: if ($controlledDocument !== null) {
collaborativeDoc = $controlledDocument.content
}
let initialCollaborativeDoc: CollaborativeDoc | undefined
$: if ($controlledDocumentTemplate !== null) {
initialCollaborativeDoc = $controlledDocumentTemplate.content
}
$: isTemplate =
$controlledDocument != null && hierarchy.hasMixin($controlledDocument, documents.mixin.DocumentTemplate)
@@ -237,9 +226,14 @@
})
]
}
$: attribute = {
key: 'content',
attr: client.getHierarchy().getAttribute(documents.class.ControlledDocument, 'content')
}
</script>
{#if $controlledDocument && collaborativeDoc}
{#if $controlledDocument && attribute}
<DocumentPrintTitlePage />
{#if headings.length > 0}
@@ -270,14 +264,10 @@
</DocumentTitle>
<CollaboratorEditor
bind:this={textEditor}
objectId={$controlledDocument._id}
objectClass={$controlledDocument._class}
objectSpace={$controlledDocument.space}
{collaborativeDoc}
{initialCollaborativeDoc}
object={$controlledDocument}
{attribute}
{user}
readonly={!$isEditable}
field="content"
editorAttributes={{ style: 'padding: 0 2em; margin: 0 -2em;' }}
overflow="none"
canShowPopups={!$areDocumentCommentPopupsOpened}

Some files were not shown because too many files have changed in this diff Show More