diff --git a/.vscode/launch.json b/.vscode/launch.json index 47bd54f286..cb08a80418 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", diff --git a/dev/doc-import-tool/src/import.ts b/dev/doc-import-tool/src/import.ts index f8c414d409..c76a8d0b9c 100644 --- a/dev/doc-import-tool/src/import.ts +++ b/dev/doc-import-tool/src/import.ts @@ -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 => { - // 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 += `

${section.title}

${section.content}` } - await collaborator.updateContent(collabId, { content }) + await collaborator.updateMarkup(collabId, content) } finally { // do nothing } diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index c83b0f3c48..d92ff909c7 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -406,7 +406,7 @@ services: restart: unless-stopped links: - elastic - - postgres + - cockroach ports: - 4702:4702 environment: diff --git a/dev/import-tool/src/importer/importer.ts b/dev/import-tool/src/importer/importer.ts index 13d2363f08..93f285ee70 100644 --- a/dev/import-tool/src/importer/importer.ts +++ b/dev/import-tool/src/importer/importer.ts @@ -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> { const id = doc.id ?? generateId() 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 = { title: doc.title, - content: collabId, + content: contentId, parent: parentId, attachments: 0, embeddings: 0, @@ -391,7 +392,8 @@ export class WorkspaceImporter { ): Promise<{ id: Ref, identifier: string }> { const issueId = issue.id ?? generateId() 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 = { 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(), - file, - spaceId, - parentId, - parentClass - ) - if (attachmentId === null) { + + try { + await this.createAttachment(attachment.id ?? generateId(), file, spaceId, parentId, parentClass) + } catch { console.warn('Failed to upload attachment file: ', attachment.title) } } @@ -550,22 +548,9 @@ export class WorkspaceImporter { spaceId: Ref, parentId: Ref, parentClass: Ref>> - ): Promise | 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> { + const attachmentId = generateId() + 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, - field: string, + collabId: CollaborativeDoc, content: string, spaceId: Ref - ): Promise { + ): Promise> { 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> { diff --git a/dev/import-tool/src/importer/uploader.ts b/dev/import-tool/src/importer/uploader.ts index 64269f1176..d2ae4fb7d9 100644 --- a/dev/import-tool/src/importer/uploader.ts +++ b/dev/import-tool/src/importer/uploader.ts @@ -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, name: string, file: File, contentType?: string) => Promise - uploadCollaborativeDoc: (id: Ref, collabId: CollaborativeDoc, data: Buffer) => Promise + uploadFile: (name: string, file: Blob) => Promise> + uploadCollaborativeDoc: (collabId: CollaborativeDoc, data: Buffer) => Promise> getFileUrl: (id: string) => string } -export interface UploadResult { - key: 'file' - id: Ref -} - 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, name: string, file: File, contentType?: string): Promise { + public async uploadFile (name: string, file: Blob): Promise> { 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 + } + + 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, collabId: CollaborativeDoc, data: Buffer): Promise { - 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> { + const blobId = makeCollabJsonId(collabId) + const blob = new Blob([data], { type: 'application/json' }) + return await this.uploadFile(blobId, blob) } } diff --git a/dev/import-tool/src/notion/notion.ts b/dev/import-tool/src/notion/notion.ts index 7af6e37082..aff260f759 100644 --- a/dev/import-tool/src/notion/notion.ts +++ b/dev/import-tool/src/notion/notion.ts @@ -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 ): Promise { const pageId = docMeta.id as Ref - const collabId = makeCollaborativeDoc(pageId, 'content') - const parentId = parentMeta !== undefined ? (parentMeta.id as Ref) : document.ids.NoParent const lastRank = await getFirstRank(client, space, parentId) @@ -334,7 +330,7 @@ async function createDBPageWithAttachments ( const object: Data = { 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, docMeta.id, file) + await fileUploader.uploadFile(docMeta.id, file) const attachedData: AttachedData = { file: docMeta.id as Ref, @@ -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 - 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.ids.NoParent @@ -459,7 +454,7 @@ async function importPageDocument ( const attachedData: Data = { title: docMeta.name, - content: collabId, + content: blobId, parent, attachments: 0, embeddings: 0, diff --git a/dev/prod/webpack.config.js b/dev/prod/webpack.config.js index c9d9529061..25774344d0 100644 --- a/dev/prod/webpack.config.js +++ b/dev/prod/webpack.config.js @@ -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()] : []), diff --git a/dev/tool/src/benchmark.ts b/dev/tool/src/benchmark.ts index fcae5fd39f..7143746c0e 100644 --- a/dev/tool/src/benchmark.ts +++ b/dev/tool/src/benchmark.ts @@ -21,7 +21,6 @@ import core, { concatLink, generateId, getWorkspaceId, - makeCollaborativeDoc, metricsToString, newMetrics, systemAccountEmail, @@ -594,7 +593,7 @@ async function generateVacancy (client: TxOperations, members: Ref { 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 diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index d49d0380ad..829c9eca48 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -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 ') - .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 ', 'Selected workspace only', '') - .option('-c, --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 ') .description('remove duplicates ids for futue migration') diff --git a/dev/tool/src/markup.ts b/dev/tool/src/markup.ts deleted file mode 100644 index 649d78e959..0000000000 --- a/dev/tool/src/markup.ts +++ /dev/null @@ -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 { - 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>, - attributes: AnyAttribute[], - workspaceId: WorkspaceId, - db: Db, - storageAdapter: StorageAdapter -): Promise { - const collection = db.collection(domain) - const docs = await collection.find({ _class }).toArray() - for (const doc of docs) { - const update: Record = {} - 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 { - 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>) - }) - - 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(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, - concurrency: number -): Promise { - 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) -} diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index e7a597a31a..6bcaa00a87 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -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 diff --git a/models/controlled-documents/src/migration.ts b/models/controlled-documents/src/migration.ts index a609ba304f..c199e79fb0 100644 --- a/models/controlled-documents/src/migration.ts +++ b/models/controlled-documents/src/migration.ts @@ -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 { // 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 { ...(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 }) } diff --git a/models/controlled-documents/src/types.ts b/models/controlled-documents/src/types.ts index 6fbe902438..4f8dc62038 100644 --- a/models/controlled-documents/src/types.ts +++ b/models/controlled-documents/src/types.ts @@ -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 @@ -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 diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 60512e0cf2..2907480e95 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -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 {} diff --git a/models/core/src/index.ts b/models/core/src/index.ts index 26445f9f9d..d741be243c 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -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, diff --git a/models/core/src/migration.ts b/models/core/src/migration.ts index 36a96bafd0..e5dfc1e3bc 100644 --- a/models/core/src/migration.ts +++ b/models/core/src/migration.ts @@ -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 { // 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, update: MigrateUpdate }[] = [] for (const doc of docs) { @@ -214,8 +212,6 @@ async function processMigrateContentFor ( const update: MigrateUpdate = {} 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 { + 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 +): Promise { + 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, update: MigrateUpdate }[] = [] + + 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> { + const { hierarchy, workspaceId } = client + + const update: MigrateUpdate = {} + + 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 + 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 { await tryMigrate(client, coreId, [ @@ -351,6 +488,10 @@ export const coreOperation: MigrateOperation = { DOMAIN_MODEL_TX ) } + }, + { + state: 'collaborative-docs-to-json', + func: migrateCollaborativeDocsToJson } ]) }, diff --git a/models/document/package.json b/models/document/package.json index 55c1b687ad..f671066983 100644 --- a/models/document/package.json +++ b/models/document/package.json @@ -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", diff --git a/models/document/src/index.ts b/models/document/src/index.ts index cacdf4c235..1e17d90d36 100644 --- a/models/document/src/index.ts +++ b/models/document/src/index.ts @@ -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 @@ -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 @@ -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, { diff --git a/models/document/src/migration.ts b/models/document/src/migration.ts index e05caea6f9..6bc4efb4a4 100644 --- a/models/document/src/migration.ts +++ b/models/document/src/migration.ts @@ -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 { @@ -197,23 +209,23 @@ async function renameFieldsRevert (client: MigrationClient): Promise { } ) - 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 { 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 { 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 { } } +async function removeOldClasses (client: MigrationClient): Promise { + const classes = [ + 'document:class:DocumentContent', + 'document:class:DocumentSnapshot', + 'document:class:DocumentVersion', + 'document:class:DocumentRequest' + ] as Ref>[] + + 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 { await tryMigrate(client, documentId, [ @@ -321,6 +352,10 @@ export const documentOperation: MigrateOperation = { { state: 'migrateRanks', func: migrateRanks + }, + { + state: 'removeOldClasses', + func: removeOldClasses } ]) }, diff --git a/models/document/src/plugin.ts b/models/document/src/plugin.ts index 883cfc54b8..8f22ca8384 100644 --- a/models/document/src/plugin.ts +++ b/models/document/src/plugin.ts @@ -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, diff --git a/models/lead/src/migration.ts b/models/lead/src/migration.ts index 7f77fd49da..df581ebabf 100644 --- a/models/lead/src/migration.ts +++ b/models/lead/src/migration.ts @@ -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' - ) - } - } - })) - ) - } } } ]) diff --git a/models/lead/src/types.ts b/models/lead/src/types.ts index 7a32aeebbf..25b807ec0e 100644 --- a/models/lead/src/types.ts +++ b/models/lead/src/types.ts @@ -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) diff --git a/models/love/src/index.ts b/models/love/src/index.ts index 0467e74658..b25c90400b 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -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 - @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 diff --git a/models/love/src/migration.ts b/models/love/src/migration.ts index be65d9912d..db983c7aa4 100644 --- a/models/love/src/migration.ts +++ b/models/love/src/migration.ts @@ -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 { 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(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) => { diff --git a/models/recruit/src/types.ts b/models/recruit/src/types.ts index c6d390be15..8d1c097744 100644 --- a/models/recruit/src/types.ts +++ b/models/recruit/src/types.ts @@ -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 diff --git a/models/test-management/src/defaultTypes.ts b/models/test-management/src/defaultTypes.ts deleted file mode 100644 index 3495aff78a..0000000000 --- a/models/test-management/src/defaultTypes.ts +++ /dev/null @@ -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 - }) -} diff --git a/models/test-management/src/index.ts b/models/test-management/src/index.ts index 43b9e4d93e..f461dd558d 100644 --- a/models/test-management/src/index.ts +++ b/models/test-management/src/index.ts @@ -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 + }, + 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 }, - testManagement.viewlet.TableTestRun + testManagement.viewlet.TableTestResult ) } diff --git a/models/test-management/src/plugin.ts b/models/test-management/src/plugin.ts index abf37a268f..0bf91802e5 100644 --- a/models/test-management/src/plugin.ts +++ b/models/test-management/src/plugin.ts @@ -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>, - CreateChildTestSuite: '' as Ref, - EditTestSuite: '' as Ref - }, - actionImpl: { - CreateChildTestSuite: '' as ViewAction, - EditTestSuite: '' as ViewAction - }, category: { - TestSuite: '' as Ref + TestSuite: '' as Ref, + TestCase: '' as Ref }, 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 } }) diff --git a/models/test-management/src/presenters.ts b/models/test-management/src/presenters.ts index 0b45b6df50..8d68c5f7c9 100644 --- a/models/test-management/src/presenters.ts +++ b/models/test-management/src/presenters.ts @@ -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 + }) } diff --git a/models/test-management/src/types.ts b/models/test-management/src/types.ts index 1fca881944..fc1ffcd1ef 100644 --- a/models/test-management/src/types.ts +++ b/models/test-management/src/types.ts @@ -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 + results?: CollectionSize } /** @public */ -export function TypeTestRunResult (): Type { - return { _class: testManagement.class.TypeTestRunResult, label: testManagement.string.TestRunResult } +export function TypeTestRunStatus (): Type { + 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 +// 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 + + @Prop(TypeRef(testManagement.class.TestRun), core.string.AttachedToClass) + @Index(IndexKind.Indexed) + @Hidden() + declare attachedToClass: Ref> + + @Prop(TypeRef(testManagement.class.TestProject), core.string.Space) + @Index(IndexKind.Indexed) + @Hidden() + declare space: Ref + + @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 - @Prop(TypeTestRunResult(), testManagement.string.TestRunResult) - result?: TestRunResult + @Prop(TypeRef(testManagement.class.TestSuite), testManagement.string.TestSuite) + testSuite?: Ref + + @Prop(TypeTestRunStatus(), testManagement.string.TestRunStatus) + status?: TestRunStatus + + @Prop(TypeRef(contact.mixin.Employee), testManagement.string.TestAssignee) + assignee?: Ref + + @Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files }) + attachments?: CollectionSize @Prop(Collection(chunter.class.ChatMessage), chunter.string.Comments) comments?: number diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index 70cadb279e..92d8ba2d13 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -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, diff --git a/models/view/src/index.ts b/models/view/src/index.ts index 7d21f3db1c..968100c190 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -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, diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index a39c0bc8df..5ad7fe45b9 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -128,12 +128,12 @@ class PlatformClientImpl implements PlatformClient { await this.connection.close() } - private async processMarkup(id: Ref, data: WithMarkup): Promise { + private async processMarkup(_class: Ref>, id: Ref, data: WithMarkup): Promise { 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 ): Promise> { id ??= generateId() - const data = await this.processMarkup>(id, attributes) + const data = await this.processMarkup>(_class, id, attributes) return await this.client.createDoc(_class, space, data, id) } @@ -162,7 +162,7 @@ class PlatformClientImpl implements PlatformClient { operations: WithMarkup>, retrieve?: boolean ): Promise { - const update = await this.processMarkup>(objectId, operations) + const update = await this.processMarkup>(_class, objectId, operations) return await this.client.updateDoc(_class, space, objectId, update, retrieve) } @@ -182,7 +182,7 @@ class PlatformClientImpl implements PlatformClient { id?: Ref

): Promise> { id ??= generateId() - const data = await this.processMarkup>(id, attributes) + const data = await this.processMarkup>(_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>, retrieve?: boolean ): Promise> { - const update = await this.processMarkup>(objectId, operations) + const update = await this.processMarkup>(_class, objectId, operations) return await this.client.updateCollection( _class, space, @@ -229,7 +229,7 @@ class PlatformClientImpl implements PlatformClient { mixin: Ref>, attributes: WithMarkup> ): Promise { - const data = await this.processMarkup>(objectId, attributes) + const data = await this.processMarkup>(objectClass, objectId, attributes) return await this.client.createMixin(objectId, objectClass, objectSpace, mixin, data) } @@ -240,18 +240,30 @@ class PlatformClientImpl implements PlatformClient { mixin: Ref>, attributes: WithMarkup> ): Promise { - const update = await this.processMarkup>(objectId, attributes) + const update = await this.processMarkup>(objectClass, objectId, attributes) return await this.client.updateMixin(objectId, objectClass, objectSpace, mixin, update) } // Markup - async fetchMarkup (objectId: Ref, objectAttr: string, markup: MarkupRef, format: MarkupFormat): Promise { - return await this.markup.fetchMarkup(objectId, objectAttr, markup, format) + async fetchMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: MarkupRef, + format: MarkupFormat + ): Promise { + return await this.markup.fetchMarkup(objectClass, objectId, objectAttr, markup, format) } - async uploadMarkup (objectId: Ref, objectAttr: string, markup: string, format: MarkupFormat): Promise { - return await this.markup.uploadMarkup(objectId, objectAttr, markup, format) + async uploadMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: string, + format: MarkupFormat + ): Promise { + return await this.markup.uploadMarkup(objectClass, objectId, objectAttr, markup, format) } // AsyncDisposable diff --git a/packages/api-client/src/markup/client.ts b/packages/api-client/src/markup/client.ts index 7bc255899a..180e6fdfdf 100644 --- a/packages/api-client/src/markup/client.ts +++ b/packages/api-client/src/markup/client.ts @@ -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, objectAttr: string, doc: MarkupRef, format: MarkupFormat): Promise { - const content = await this.collaborator.getContent(doc) - const markup = content[objectAttr] ?? '' + async fetchMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + doc: MarkupRef, + format: MarkupFormat + ): Promise { + 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, objectAttr: string, value: string, format: MarkupFormat): Promise { + async uploadMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + value: string, + format: MarkupFormat + ): Promise { 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) } } diff --git a/packages/api-client/src/markup/types.ts b/packages/api-client/src/markup/types.ts index a641f9fd06..a4f6765689 100644 --- a/packages/api-client/src/markup/types.ts +++ b/packages/api-client/src/markup/types.ts @@ -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 /** @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, objectAttr: string, id: MarkupRef, format: MarkupFormat) => Promise + fetchMarkup: ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + id: MarkupRef, + format: MarkupFormat + ) => Promise /** * 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, objectAttr: string, markup: string, format: MarkupFormat) => Promise + uploadMarkup: ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: string, + format: MarkupFormat + ) => Promise } diff --git a/packages/collaborator-client/src/__tests__/utils.test.ts b/packages/collaborator-client/src/__tests__/utils.test.ts index 00b407d3d6..992e39c2bd 100644 --- a/packages/collaborator-client/src/__tests__/utils.test.ts +++ b/packages/collaborator-client/src/__tests__/utils.test.ts @@ -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, + 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, + objectAttr: 'description' + } }) }) }) diff --git a/packages/collaborator-client/src/client.ts b/packages/collaborator-client/src/client.ts index c7ffa716a4..b7d34156a4 100644 --- a/packages/collaborator-client/src/client.ts +++ b/packages/collaborator-client/src/client.ts @@ -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 +} /** @public */ export interface GetContentResponse { content: Record } +/** @public */ +export interface CreateContentRequest { + content: Record +} + +/** @public */ +export interface CreateContentResponse { + content: Record +} + /** @public */ export interface UpdateContentRequest { content: Record @@ -36,8 +47,9 @@ export interface UpdateContentResponse {} /** @public */ export interface CollaboratorClient { - getContent: (document: CollaborativeDoc) => Promise> - updateContent: (document: CollaborativeDoc, content: Record) => Promise + getMarkup: (document: CollaborativeDoc, source?: Ref | null) => Promise + createMarkup: (document: CollaborativeDoc, markup: Markup) => Promise + updateMarkup: (document: CollaborativeDoc, markup: Markup) => Promise copyContent: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise } @@ -54,11 +66,11 @@ class CollaboratorClientImpl implements CollaboratorClient { private readonly collaboratorUrl: string ) {} - private async rpc (document: CollaborativeDoc, method: string, payload: any): Promise { + private async rpc(document: CollaborativeDoc, method: string, payload: P): Promise { 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> { + async getMarkup (document: CollaborativeDoc, source?: Ref | null): Promise { + 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(document, 'getContent', payload) }, 50 ) - return res.content ?? {} + + return res.content[document.objectAttr] ?? '' } - async updateContent (document: CollaborativeDoc, content: Record): Promise { + async createMarkup (document: CollaborativeDoc, markup: Markup): Promise { + const content = { + [document.objectAttr]: markup + } + + const res = await retry( + 3, + async () => { + return await this.rpc(document, 'createContent', { content }) + }, + 50 + ) + + return res.content[document.objectAttr] + } + + async updateMarkup (document: CollaborativeDoc, markup: Markup): Promise { + const content = { + [document.objectAttr]: markup + } + await retry( 3, async () => { - await this.rpc(document, 'updateContent', { content }) + await this.rpc(document, 'updateContent', { content }) }, 50 ) } - async copyContent (source: CollaborativeDoc, target: CollaborativeDoc): Promise { - const content = await this.getContent(source) - await this.updateContent(target, content) + async copyContent (source: CollaborativeDoc, target: CollaborativeDoc, content?: Ref): Promise { + const markup = await this.getMarkup(source, content) + await this.updateMarkup(target, markup) } } diff --git a/packages/collaborator-client/src/index.ts b/packages/collaborator-client/src/index.ts index b487cf8270..70baf83124 100644 --- a/packages/collaborator-client/src/index.ts +++ b/packages/collaborator-client/src/index.ts @@ -14,5 +14,4 @@ // export * from './client' -export * from './types' export * from './utils' diff --git a/packages/collaborator-client/src/utils.ts b/packages/collaborator-client/src/utils.ts index 216744b62e..f6225ba0aa 100644 --- a/packages/collaborator-client/src/utils.ts +++ b/packages/collaborator-client/src/utils.ts @@ -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>, - objectId: Ref, - objectAttr: string -): PlatformDocumentId { - return `${objectClass}/${objectId}/${objectAttr}` as PlatformDocumentId -} - -/** @public */ -export function parsePlatformDocumentId (platformDocumentId: PlatformDocumentId): { - objectClass: Ref> - objectId: Ref - objectAttr: string -} { - const [objectClass, objectId, objectAttr] = platformDocumentId.split('/') - return { - objectClass: objectClass as Ref>, - objectId: objectId as Ref, - objectAttr + documentId: { + objectClass: objectClass as Ref>, + objectId: objectId as Ref, + objectAttr + } } } diff --git a/packages/core/lang/en.json b/packages/core/lang/en.json index fa7f241872..f0a11de8cd 100644 --- a/packages/core/lang/en.json +++ b/packages/core/lang/en.json @@ -36,7 +36,7 @@ "Enum": "Enum", "Members": "Members", "Hyperlink": "URL", - "Collaborative": "Collaborative", + "MarkupBlobRef": "Collaborative", "Object": "Object", "System": "System", "CreatedBy": "Created by", diff --git a/packages/core/lang/es.json b/packages/core/lang/es.json index 563325862c..67acda9558 100644 --- a/packages/core/lang/es.json +++ b/packages/core/lang/es.json @@ -29,7 +29,7 @@ "Enum": "Enum.", "Members": "Miembros", "Hyperlink": "Enlace", - "Collaborative": "Colaborativo", + "MarkupBlobRef": "Colaborativo", "Object": "Objeto", "System": "Sistema", "CreatedBy": "Creado por", diff --git a/packages/core/lang/fr.json b/packages/core/lang/fr.json index 0e33492919..9fea9a9952 100644 --- a/packages/core/lang/fr.json +++ b/packages/core/lang/fr.json @@ -36,7 +36,7 @@ "Enum": "Énumération", "Members": "Membres", "Hyperlink": "URL", - "Collaborative": "Collaboratif", + "MarkupBlobRef": "Collaboratif", "Object": "Objet", "System": "Système", "CreatedBy": "Créé par", diff --git a/packages/core/lang/pt.json b/packages/core/lang/pt.json index 171e524cb6..d654320cf1 100644 --- a/packages/core/lang/pt.json +++ b/packages/core/lang/pt.json @@ -29,7 +29,7 @@ "Enum": "Enumeração", "Members": "Membros", "Hyperlink": "URL", - "Collaborative": "Colaborativo", + "MarkupBlobRef": "Colaborativo", "Object": "Objeto", "System": "Sistema", "CreatedBy": "Criado por", diff --git a/packages/core/lang/ru.json b/packages/core/lang/ru.json index c31dd56013..b887a212fa 100644 --- a/packages/core/lang/ru.json +++ b/packages/core/lang/ru.json @@ -36,7 +36,7 @@ "Enum": "Справочник", "Members": "Участники", "Hyperlink": "URL", - "Collaborative": "Коллаборативный", + "MarkupBlobRef": "Коллаборативный", "Object": "Объект", "System": "Система", "CreatedBy": "Создан", diff --git a/packages/core/lang/zh.json b/packages/core/lang/zh.json index d7370b90d2..11a95ea773 100644 --- a/packages/core/lang/zh.json +++ b/packages/core/lang/zh.json @@ -36,7 +36,7 @@ "Enum": "枚举", "Members": "成员", "Hyperlink": "URL", - "Collaborative": "协作", + "MarkupBlobRef": "协作", "Object": "对象", "System": "系统", "CreatedBy": "创建者", diff --git a/packages/core/src/__tests__/collaboration.test.ts b/packages/core/src/__tests__/collaboration.test.ts deleted file mode 100644 index ffc376a30a..0000000000 --- a/packages/core/src/__tests__/collaboration.test.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index 739ecebc11..e3f8043059 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -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[]['length'] */ export type Rank = string +/** + * @public + * + * Reference to blob containing snapshot of collaborative doc. + */ +export type MarkupBlobRef = Ref + /** * @public */ @@ -76,7 +82,7 @@ export interface Doc extends Obj { export interface Card extends Doc { title: string - description?: CollaborativeDoc | null + description?: MarkupBlobRef | null identifier?: string parent?: Ref | null } diff --git a/packages/core/src/collaboration.ts b/packages/core/src/collaboration.ts index 77177ba966..1042ef561c 100644 --- a/packages/core/src/collaboration.ts +++ b/packages/core/src/collaboration.ts @@ -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> + objectId: Ref + objectAttr: string +} /** @public */ -export const CollaborativeDocVersionHead = 'HEAD' - -/** @public */ -export function makeCollaborativeDoc ( - objectId: Ref, - objectAttr?: string | undefined, - versionId?: string | undefined +export function makeCollabId ( + objectClass: Ref>, + objectId: Ref, + objectAttr: Extract | 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 ( + doc: T, + objectAttr: Extract | 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 } diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 4ed7db7dff..398c937502 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -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>>, TypeTimestamp: '' as Ref>>, TypeDate: '' as Ref>>, - TypeCollaborativeDoc: '' as Ref>>, - TypeCollaborativeDocVersion: '' as Ref>>, + TypeCollaborativeDoc: '' as Ref>>, RefTo: '' as Ref>>, ArrOf: '' as Ref>>, Enum: '' as Ref>, @@ -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, diff --git a/packages/core/src/measurements/context.ts b/packages/core/src/measurements/context.ts index a8d8dfa19f..21d63b7199 100644 --- a/packages/core/src/measurements/context.ts +++ b/packages/core/src/measurements/context.ts @@ -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 {} } /** @@ -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 = {} diff --git a/packages/core/src/measurements/types.ts b/packages/core/src/measurements/types.ts index 7ea9339a54..85c7cceed3 100644 --- a/packages/core/src/measurements/types.ts +++ b/packages/core/src/measurements/types.ts @@ -111,6 +111,4 @@ export interface MeasureContext { // Mark current context as complete // If no value is passed, time difference will be used. end: (value?: number) => void - - onEnd?: (ctx: MeasureContext) => Promise } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index ed3a1589d4..d0956761a5 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -59,6 +59,8 @@ export interface SessionData { branding: Branding | null fulltextUpdates?: Map, DocIndexState> + + asyncRequests?: (() => Promise)[] } /** diff --git a/packages/model/src/dsl.ts b/packages/model/src/dsl.ts index 430aeaea72..5741db1fb9 100644 --- a/packages/model/src/dsl.ts +++ b/packages/model/src/dsl.ts @@ -20,7 +20,7 @@ import core, { Class, Classifier, ClassifierKind, - CollaborativeDoc, + MarkupBlobRef, Data, DateRangeMode, Doc, @@ -500,15 +500,8 @@ export function ArrOf> (type: Type): TypeAr /** * @public */ -export function TypeCollaborativeDoc (): Type { - return { _class: core.class.TypeCollaborativeDoc, label: core.string.CollaborativeDoc } -} - -/** - * @public - */ -export function TypeCollaborativeDocVersion (): Type { - return { _class: core.class.TypeCollaborativeDocVersion, label: core.string.CollaborativeDocVersion } +export function TypeCollaborativeDoc (): Type { + return { _class: core.class.TypeCollaborativeDoc, label: core.string.MarkupBlobRef } } /** diff --git a/packages/presentation/lang/cs.json b/packages/presentation/lang/cs.json index 9c8abdbdd9..6ef6157f72 100644 --- a/packages/presentation/lang/cs.json +++ b/packages/presentation/lang/cs.json @@ -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ý" diff --git a/packages/presentation/lang/en.json b/packages/presentation/lang/en.json index 0d31fff8f0..bbabdb8a04 100644 --- a/packages/presentation/lang/en.json +++ b/packages/presentation/lang/en.json @@ -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" diff --git a/packages/presentation/lang/es.json b/packages/presentation/lang/es.json index 14d938443e..df0c0cc963 100644 --- a/packages/presentation/lang/es.json +++ b/packages/presentation/lang/es.json @@ -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" diff --git a/packages/presentation/lang/fr.json b/packages/presentation/lang/fr.json index c8ddbc4c9c..e96be737ba 100644 --- a/packages/presentation/lang/fr.json +++ b/packages/presentation/lang/fr.json @@ -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" diff --git a/packages/presentation/lang/it.json b/packages/presentation/lang/it.json index 213bff86bb..03862b59f6 100644 --- a/packages/presentation/lang/it.json +++ b/packages/presentation/lang/it.json @@ -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" diff --git a/packages/presentation/lang/pt.json b/packages/presentation/lang/pt.json index 8b8fe39dd2..dbdfe3a3ac 100644 --- a/packages/presentation/lang/pt.json +++ b/packages/presentation/lang/pt.json @@ -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" diff --git a/packages/presentation/lang/ru.json b/packages/presentation/lang/ru.json index 5d58cfa38d..a015b6cc59 100644 --- a/packages/presentation/lang/ru.json +++ b/packages/presentation/lang/ru.json @@ -35,7 +35,10 @@ "ContentType": "Тип контента", "ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента", "StartDrawing": "Сделать набросок", - "DrawingHistory": "История набросков" + "DrawingHistory": "История набросков", + "ColorAdd": "Добавить предустановленный цвет", + "ColorRemove": "Удалить предустановленный цвет", + "ColorReset": "Сбросить предустановленные цвета" }, "status": { "FileTooLarge": "Файл слишком большой" diff --git a/packages/presentation/lang/zh.json b/packages/presentation/lang/zh.json index e1dd4686ce..5944241999 100644 --- a/packages/presentation/lang/zh.json +++ b/packages/presentation/lang/zh.json @@ -35,7 +35,10 @@ "ContentType": "内容类型", "ContentTypeNotSupported": "此內容類型無法預覽", "StartDrawing": "随意涂鸦", - "DrawingHistory": "涂鸦的历史" + "DrawingHistory": "涂鸦的历史", + "ColorAdd": "添加颜色预设", + "ColorRemove": "删除颜色预设", + "ColorReset": "重置颜色预设" }, "status": { "FileTooLarge": "文件太大" diff --git a/packages/presentation/src/collaborator.ts b/packages/presentation/src/collaborator.ts index 712a90d0b0..8b3ad682e2 100644 --- a/packages/presentation/src/collaborator.ts +++ b/packages/presentation/src/collaborator.ts @@ -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> { - const client = getCollaboratorClient() - return await client.getContent(collaborativeDoc) +export async function getMarkup (doc: CollaborativeDoc, source: Ref | null | undefined): Promise { + const client = getClient() + return await client.getMarkup(doc, source) } /** @public */ -export async function updateMarkup (collaborativeDoc: CollaborativeDoc, content: Record): Promise { - const client = getCollaboratorClient() - await client.updateContent(collaborativeDoc, content) +export async function createMarkup (doc: CollaborativeDoc, markup: Markup): Promise> { + const client = getClient() + return await client.createMarkup(doc, markup) } /** @public */ -export async function copyDocument (source: CollaborativeDoc, target: CollaborativeDoc): Promise { - const client = getCollaboratorClient() +export async function updateMarkup (doc: CollaborativeDoc, markup: Markup): Promise { + const client = getClient() + await client.updateMarkup(doc, markup) +} + +/** @public */ +export async function copyMarkup (source: CollaborativeDoc, target: CollaborativeDoc): Promise { + const client = getClient() await client.copyContent(source, target) } diff --git a/packages/presentation/src/components/DrawingBoard.svelte b/packages/presentation/src/components/DrawingBoard.svelte index 046e5585b0..afbae21946 100644 --- a/packages/presentation/src/components/DrawingBoard.svelte +++ b/packages/presentation/src/components/DrawingBoard.svelte @@ -13,10 +13,11 @@ // limitations under the License. --> -{#if active && drawingData !== undefined} +{#if active && commands !== undefined}

{ + penWidth, + eraserWidth, + cmdAdded: () => { modified = true - if (drawingData !== undefined) { - drawingData.content = content - } } }} > {#if !readonly} -
- - {/each} -
+ { + commands = [] + modified = true + }} + /> {/if}
{:else} {/if} - - diff --git a/packages/presentation/src/components/DrawingBoardToolbar.svelte b/packages/presentation/src/components/DrawingBoardToolbar.svelte new file mode 100644 index 0000000000..1d5f793615 --- /dev/null +++ b/packages/presentation/src/components/DrawingBoardToolbar.svelte @@ -0,0 +1,250 @@ + + + +
+ + {/each} +
+ +
+
+ + diff --git a/packages/presentation/src/components/FilePreviewPopup.svelte b/packages/presentation/src/components/FilePreviewPopup.svelte index 29015db6c7..de3028c17c 100644 --- a/packages/presentation/src/components/FilePreviewPopup.svelte +++ b/packages/presentation/src/components/FilePreviewPopup.svelte @@ -13,6 +13,7 @@ // limitations under the License. --> + + + + diff --git a/packages/presentation/src/drawing.ts b/packages/presentation/src/drawing.ts index 0332c3e3c5..cc9a97b402 100644 --- a/packages/presentation/src/drawing.ts +++ b/packages/presentation/src/drawing.ts @@ -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 }) + } } } } diff --git a/packages/presentation/src/file.ts b/packages/presentation/src/file.ts index 5d1fa55756..a49b011d4a 100644 --- a/packages/presentation/src/file.ts +++ b/packages/presentation/src/file.ts @@ -173,17 +173,18 @@ export function getFileUrl (file: string, filename?: string): string { /** * @public */ -export async function uploadFile (file: File): Promise> { - const id = generateFileId() - const params = getFileUploadParams(id, file) +export async function uploadFile (file: File, uuid?: Ref): Promise> { + uuid ??= generateFileId() as Ref + + 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 + return uuid } /** diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index 547d178bf0..62a913052d 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -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' diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 2033fc4067..84c592159e 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -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, diff --git a/packages/theme/styles/_layouts.scss b/packages/theme/styles/_layouts.scss index afdc52456e..794ef8d2d6 100644 --- a/packages/theme/styles/_layouts.scss +++ b/packages/theme/styles/_layouts.scss @@ -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); } diff --git a/packages/theme/styles/common.scss b/packages/theme/styles/common.scss index 6ea42526e3..edaa02262a 100644 --- a/packages/theme/styles/common.scss +++ b/packages/theme/styles/common.scss @@ -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, diff --git a/packages/theme/styles/components.scss b/packages/theme/styles/components.scss index 4ea43b9f7f..924821fcda 100644 --- a/packages/theme/styles/components.scss +++ b/packages/theme/styles/components.scss @@ -1493,6 +1493,10 @@ min-width: 0; width: auto; + &.noGap { + flex: none; + } + &__back { padding: 1px 0.5px; height: calc(1.5rem + 2px); diff --git a/packages/theme/styles/dialogs.scss b/packages/theme/styles/dialogs.scss index c18964c47a..d88ee44067 100644 --- a/packages/theme/styles/dialogs.scss +++ b/packages/theme/styles/dialogs.scss @@ -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 { diff --git a/packages/ui/src/components/PanelInstance.svelte b/packages/ui/src/components/PanelInstance.svelte index c05d85bd37..6aeefe1204 100644 --- a/packages/ui/src/components/PanelInstance.svelte +++ b/packages/ui/src/components/PanelInstance.svelte @@ -14,13 +14,13 @@ // limitations under the License. --> { - 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)} {:else} diff --git a/packages/ui/src/components/Scroller.svelte b/packages/ui/src/components/Scroller.svelte index 681ea58b32..571d452def 100644 --- a/packages/ui/src/components/Scroller.svelte +++ b/packages/ui/src/components/Scroller.svelte @@ -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; diff --git a/plugins/time-resources/src/components/icons/MenuClose.svelte b/packages/ui/src/components/icons/MenuClose.svelte similarity index 96% rename from plugins/time-resources/src/components/icons/MenuClose.svelte rename to packages/ui/src/components/icons/MenuClose.svelte index 63908e9ba6..6cc2f44167 100644 --- a/plugins/time-resources/src/components/icons/MenuClose.svelte +++ b/packages/ui/src/components/icons/MenuClose.svelte @@ -13,7 +13,8 @@ // limitations under the License. --> diff --git a/plugins/time-resources/src/components/icons/MenuOpen.svelte b/packages/ui/src/components/icons/MenuOpen.svelte similarity index 95% rename from plugins/time-resources/src/components/icons/MenuOpen.svelte rename to packages/ui/src/components/icons/MenuOpen.svelte index c100775793..89e81f4091 100644 --- a/plugins/time-resources/src/components/icons/MenuOpen.svelte +++ b/packages/ui/src/components/icons/MenuOpen.svelte @@ -13,7 +13,8 @@ // limitations under the License. --> diff --git a/packages/ui/src/components/internal/Root.svelte b/packages/ui/src/components/internal/Root.svelte index 49858d6374..ccdfc62486 100644 --- a/packages/ui/src/components/internal/Root.svelte +++ b/packages/ui/src/components/internal/Root.svelte @@ -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 && diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ec8c8b97ab..cf2253c42b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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({ 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 }, diff --git a/packages/ui/src/panelup.ts b/packages/ui/src/panelup.ts index d1434a8c8b..4b06c3d655 100644 --- a/packages/ui/src/panelup.ts +++ b/packages/ui/src/panelup.ts @@ -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, diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 6293ffc0a7..77121dea8d 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -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 diff --git a/plugins/ai-bot-resources/src/requests.ts b/plugins/ai-bot-resources/src/requests.ts index 9e44c86184..3bc8387761 100644 --- a/plugins/ai-bot-resources/src/requests.ts +++ b/plugins/ai-bot-resources/src/requests.ts @@ -56,7 +56,6 @@ export async function translate (text: Markup, lang: string): Promise, - sid: string, language: RoomLanguage, options: Partial ): Promise { @@ -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: { diff --git a/plugins/ai-bot/src/rest.ts b/plugins/ai-bot/src/rest.ts index d6a71da8a7..226ccafe3c 100644 --- a/plugins/ai-bot/src/rest.ts +++ b/plugins/ai-bot/src/rest.ts @@ -61,7 +61,6 @@ export interface TranslateResponse { export interface ConnectMeetingRequest { roomId: Ref - roomSid: string language: RoomLanguage transcription: boolean } @@ -74,7 +73,6 @@ export interface PostTranscriptRequest { transcript: string participant: Ref roomName: string - final: boolean } export interface IdentityResponse { diff --git a/plugins/attachment-resources/src/components/AttachmentList.svelte b/plugins/attachment-resources/src/components/AttachmentList.svelte index d8a42581e8..c71622a2a4 100644 --- a/plugins/attachment-resources/src/components/AttachmentList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentList.svelte @@ -27,7 +27,7 @@ {#if attachments.length} - + {#each attachments as attachment} diff --git a/plugins/attachment-resources/src/utils.ts b/plugins/attachment-resources/src/utils.ts index 51feeba355..d48476e0ac 100644 --- a/plugins/attachment-resources/src/utils.ts +++ b/plugins/attachment-resources/src/utils.ts @@ -183,12 +183,10 @@ export function showAttachmentPreviewPopup (value: WithLookup): 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 } } diff --git a/plugins/bitrix/src/hr.ts b/plugins/bitrix/src/hr.ts index 1ed9d9faa7..f36c7605dc 100644 --- a/plugins/bitrix/src/hr.ts +++ b/plugins/bitrix/src/hr.ts @@ -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, diff --git a/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte b/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte index da02fe2841..637d4ead5f 100644 --- a/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte +++ b/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> -{#if $controlledDocument !== null && collaborativeDoc !== undefined} +{#if $controlledDocument !== null && attribute !== undefined} - + {#if $editorMode === 'comparing'} {:else} diff --git a/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte b/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte index eebd20c597..ae43692bd9 100644 --- a/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte +++ b/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte @@ -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) { diff --git a/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte b/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte index 32b89c7aa8..1c7a2d6560 100644 --- a/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte +++ b/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte @@ -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: [], diff --git a/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte b/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte index 802da8264e..af125cfc9f 100644 --- a/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte +++ b/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte @@ -1,7 +1,7 @@ -{#if $controlledDocument && collaborativeDoc} +{#if $controlledDocument && attribute} {#if headings.length > 0} @@ -270,14 +264,10 @@ | undefined, + documentId: Ref, + spec: AttachedData, + space: Ref, + project: Ref | undefined, + parent: Ref | undefined, + docClass: Ref> = documents.class.ControlledDocument +): Promise<{ seqNumber: number, success: boolean }> { + const result = await controlledDocFromTemplate(client, templateId, documentId, spec, space, project, parent, docClass) + + if (result.success && templateId !== undefined) { + const source = makeCollabId(documents.mixin.DocumentTemplate, templateId, 'content') + const target = makeCollabId(docClass, documentId, 'content') + try { + await copyMarkup(source, target) + } catch (err) { + await setPlatformStatus(unknownError(err)) + return { ...result, success: false } + } + } + + return result +} + export async function createNewDraftForControlledDoc ( client: TxOperations, document: ControlledDocument, @@ -65,18 +94,6 @@ export async function createNewDraftForControlledDoc ( ops.notMatch(documents.class.Document, notMatchQuery) - const collaborativeDoc = getCollaborativeDocForDocument( - `DOC-${document.prefix}`, - document.seqNumber, - document.major, - document.minor, - true - ) - - if (document.content !== undefined) { - await copyDocument(document.content, collaborativeDoc) - } - // Create new change control for new version const newCCId = generateId() const newCCSpec: Data = { @@ -111,7 +128,7 @@ export async function createNewDraftForControlledDoc ( labels: 0, state: DocumentState.Draft, plannedEffectiveDate: 0, - content: collaborativeDoc + content: document.content } const meta = await client.findOne(documents.class.ProjectMeta, { @@ -170,16 +187,6 @@ export async function createNewDraftForControlledDoc ( } export async function createDocumentSnapshotAndEdit (client: TxOperations, document: ControlledDocument): Promise { - const collaborativeDoc = getCollaborativeDocForDocument( - `DOC-${document.prefix}`, - document.seqNumber, - document.major, - document.minor, - true - ) - - await copyDocument(document.content, collaborativeDoc) - const language = get(themeStore).language const namePrefix = await translate(documents.string.DraftRevision, {}, language) const name = `${namePrefix} ${(document.snapshots ?? 0) + 1}` @@ -197,7 +204,7 @@ export async function createDocumentSnapshotAndEdit (client: TxOperations, docum name, state: document.state, controlledState: document.controlledState, - content: collaborativeDoc + content: document.content }, newSnapshotId ) @@ -205,6 +212,10 @@ export async function createDocumentSnapshotAndEdit (client: TxOperations, docum await op.commit() await client.update(document, { controlledState: undefined }) + + const source = makeDocCollabId(document, 'content') + const target = makeCollabId(documents.class.ControlledDocumentSnapshot, newSnapshotId, 'content') + await copyMarkup(source, target) } export function getDocumentTrainingClass (hierarchy: Hierarchy): Class { diff --git a/plugins/controlled-documents/src/docutils.ts b/plugins/controlled-documents/src/docutils.ts index 29e2815475..1e7afaad44 100644 --- a/plugins/controlled-documents/src/docutils.ts +++ b/plugins/controlled-documents/src/docutils.ts @@ -14,17 +14,7 @@ // import { type Employee } from '@hcengineering/contact' -import { - type AttachedData, - type Class, - type CollaborativeDoc, - type Doc, - type Ref, - type TxOperations, - Mixin, - generateId, - makeCollaborativeDoc -} from '@hcengineering/core' +import { type AttachedData, type Class, type Ref, type TxOperations, Blob, Mixin } from '@hcengineering/core' import { type Document, type DocumentTemplate, @@ -40,7 +30,6 @@ import { import documents from './plugin' import { TEMPLATE_PREFIX } from './utils' -import { setPlatformStatus, unknownError } from '@hcengineering/platform' async function getParentPath (client: TxOperations, parent: Ref): Promise>> { const parentDocObj = await client.findOne(documents.class.ProjectDocument, { @@ -72,8 +61,7 @@ export async function createControlledDocFromTemplate ( space: Ref, project: Ref | undefined, parent: Ref | undefined, - docClass: Ref> = documents.class.ControlledDocument, - copyContent: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise = async () => {} + docClass: Ref> = documents.class.ControlledDocument ): Promise<{ seqNumber: number, success: boolean }> { if (templateId == null) { return { seqNumber: -1, success: false } @@ -101,8 +89,6 @@ export async function createControlledDocFromTemplate ( const seqNumber = template.sequence + 1 const prefix = template.docPrefix - const _copyContent = (doc: CollaborativeDoc): Promise => copyContent(template.content, doc) - return await createControlledDoc( client, templateId, @@ -114,7 +100,7 @@ export async function createControlledDocFromTemplate ( seqNumber, path, docClass, - _copyContent + template.content ) } @@ -129,12 +115,10 @@ async function createControlledDoc ( seqNumber: number, path: Ref[] = [], docClass: Ref> = documents.class.ControlledDocument, - copyContent: (doc: CollaborativeDoc) => Promise + content: Ref | null ): Promise<{ seqNumber: number, success: boolean }> { const projectId = project ?? documents.ids.NoProject - const collaborativeDoc = getCollaborativeDocForDocument(`DOC-${prefix}`, seqNumber, 0, 1) - const ops = client.apply() ops.notMatch(documents.class.Document, { @@ -184,22 +168,12 @@ async function createControlledDoc ( seqNumber, prefix, state: DocumentState.Draft, - content: collaborativeDoc + content }, documentId ) const success = await ops.commit() - - if (success.result) { - try { - await copyContent(collaborativeDoc) - } catch (err) { - await setPlatformStatus(unknownError(err)) - return { seqNumber, success: false } - } - } - return { seqNumber, success: success.result } } @@ -228,7 +202,6 @@ export async function createDocumentTemplate ( true ) const seqNumber = (incResult as any).object.sequence as number - const collaborativeDocId = getCollaborativeDocForDocument('TPL-DOC', seqNumber, 0, 1) const code = spec.code === '' ? `${TEMPLATE_PREFIX}-${seqNumber}` : spec.code let path: Array> = [] @@ -292,7 +265,7 @@ export async function createDocumentTemplate ( prefix: TEMPLATE_PREFIX, author, owner: author, - content: collaborativeDocId + content: null }, templateId ) @@ -306,19 +279,3 @@ export async function createDocumentTemplate ( return { seqNumber, success: success.result } } - -export function getCollaborativeDocForDocument ( - prefix: string, - seqNumber: number, - major: number, - minor: number, - next: boolean = false -): CollaborativeDoc { - if (prefix.endsWith('-')) { - prefix = prefix.substring(0, prefix.length - 1) - } - - return makeCollaborativeDoc( - (`${prefix}-${seqNumber}-${major}.${minor}${next ? '.next' : ''}-` + generateId()) as Ref - ) -} diff --git a/plugins/controlled-documents/src/types.ts b/plugins/controlled-documents/src/types.ts index 31119266a6..4ec4a756ac 100644 --- a/plugins/controlled-documents/src/types.ts +++ b/plugins/controlled-documents/src/types.ts @@ -5,10 +5,10 @@ import { Attachment } from '@hcengineering/attachment' import { ChatMessage } from '@hcengineering/chunter' import { Employee } from '@hcengineering/contact' import { - CollaborativeDoc, type AttachedDoc, type Class, type CollectionSize, + type MarkupBlobRef, type Doc, type Markup, type Ref, @@ -122,7 +122,7 @@ export interface Document extends Doc { author?: Ref // Employee who created/released the document owner?: Ref // Employee responsible for working on the document state: DocumentState - content: CollaborativeDoc + content: MarkupBlobRef | null labels?: CollectionSize // A collection of attached tags(labels) abstract?: string commentSequence: number // Used to enumerate the comments across revisions of the working copy of the document @@ -138,7 +138,7 @@ export interface Document extends Doc { */ export interface DocumentSnapshot extends AttachedDoc { name?: string - content: CollaborativeDoc + content: MarkupBlobRef | null state?: DocumentState } diff --git a/plugins/document-resources/src/components/DocumentEditor.svelte b/plugins/document-resources/src/components/DocumentEditor.svelte index afc26cc971..547023115e 100644 --- a/plugins/document-resources/src/components/DocumentEditor.svelte +++ b/plugins/document-resources/src/components/DocumentEditor.svelte @@ -16,8 +16,9 @@ --> + + + { + onChange(e.detail) + }} + _class={document.class.Document} + label={document.string.Document} + searchField={'title'} + docQuery={{}} + {readonly} + {kind} + {size} + {justify} + {width} +/> diff --git a/plugins/document-resources/src/index.ts b/plugins/document-resources/src/index.ts index b46ea7c610..2db4b0a18c 100644 --- a/plugins/document-resources/src/index.ts +++ b/plugins/document-resources/src/index.ts @@ -32,6 +32,7 @@ import { openDoc } from '@hcengineering/view-resources' import CreateDocument from './components/CreateDocument.svelte' import DocumentIcon from './components/DocumentIcon.svelte' import DocumentItem from './components/DocumentItem.svelte' +import DocumentInlineEditor from './components/DocumentInlineEditor.svelte' import DocumentPresenter from './components/DocumentPresenter.svelte' import DocumentSearchIcon from './components/DocumentSearchIcon.svelte' import DocumentToDoPresenter from './components/DocumentToDoPresenter.svelte' @@ -167,6 +168,7 @@ export default async (): Promise => ({ component: { CreateDocument, CreateTeamspace, + DocumentInlineEditor, DocumentPresenter, Documents, EditDoc, diff --git a/plugins/document-resources/src/utils.ts b/plugins/document-resources/src/utils.ts index f19ab76201..814d522d94 100644 --- a/plugins/document-resources/src/utils.ts +++ b/plugins/document-resources/src/utils.ts @@ -19,8 +19,7 @@ import { type QuerySelector, type Ref, SortingOrder, - type TxOperations, - makeCollaborativeDoc + type TxOperations } from '@hcengineering/core' import { type Document, type Teamspace, documentId, getFirstRank } from '@hcengineering/document' import { getMetadata, translate } from '@hcengineering/platform' @@ -79,7 +78,7 @@ export async function createEmptyDocument ( const object: Data = { title, - content: makeCollaborativeDoc(id, 'content'), + content: null, attachments: 0, embeddings: 0, labels: 0, diff --git a/plugins/document/src/types.ts b/plugins/document/src/types.ts index 8b650bcc49..767afdbb45 100644 --- a/plugins/document/src/types.ts +++ b/plugins/document/src/types.ts @@ -14,7 +14,7 @@ // import { Attachment } from '@hcengineering/attachment' -import { Account, Class, CollaborativeDoc, Doc, Rank, Ref, TypedSpace } from '@hcengineering/core' +import { Account, Class, MarkupBlobRef, Doc, Rank, Ref, TypedSpace } from '@hcengineering/core' import { Preference } from '@hcengineering/preference' import { IconProps } from '@hcengineering/view' @@ -24,7 +24,7 @@ export interface Teamspace extends TypedSpace, IconProps {} /** @public */ export interface Document extends Doc, IconProps { title: string - content: CollaborativeDoc + content: MarkupBlobRef | null parent: Ref space: Ref @@ -43,7 +43,7 @@ export interface Document extends Doc, IconProps { /** @public */ export interface DocumentSnapshot extends Doc { title: string - content: CollaborativeDoc + content: MarkupBlobRef parent: Ref } diff --git a/plugins/guest-resources/src/components/Guest.svelte b/plugins/guest-resources/src/components/Guest.svelte index a026073cd5..5893cf5879 100644 --- a/plugins/guest-resources/src/components/Guest.svelte +++ b/plugins/guest-resources/src/components/Guest.svelte @@ -33,7 +33,7 @@ getCurrentLocation, getLocation, navigate, - openPanel, + showPanel, defineSeparators, setResolvedLocation, deviceOptionsStore as deviceInfo @@ -207,12 +207,13 @@ provider, focus: doc }) - openPanel( + showPanel( props[0] as AnyComponent, _id, _class, (props[3] ?? undefined) as PopupAlignment, - (props[4] ?? undefined) as AnyComponent + (props[4] ?? undefined) as AnyComponent, + false ) } else { closePanel(false) diff --git a/plugins/lead-resources/src/components/CreateCustomer.svelte b/plugins/lead-resources/src/components/CreateCustomer.svelte index b218fc30be..f2ef95ccdd 100644 --- a/plugins/lead-resources/src/components/CreateCustomer.svelte +++ b/plugins/lead-resources/src/components/CreateCustomer.svelte @@ -25,12 +25,12 @@ Ref, WithLookup, generateId, - makeCollaborativeDoc + makeCollabId } from '@hcengineering/core' import { Customer, LeadEvents } from '@hcengineering/lead' - import { Card, getClient, InlineAttributeBar, updateMarkup } from '@hcengineering/presentation' + import { Card, createMarkup, getClient, InlineAttributeBar } from '@hcengineering/presentation' import { StyledTextBox } from '@hcengineering/text-editor-resources' - import { EmptyMarkup } from '@hcengineering/text' + import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' import { Button, createFocusManager, @@ -85,14 +85,17 @@ } if (client.getHierarchy().isDerived(targetClass._id, contact.class.Organization)) { - ;(candidate as Organization).description = makeCollaborativeDoc(customerId, 'description') + ;(candidate as Organization).description = null } const candidateData: MixinData = { - customerDescription: makeCollaborativeDoc(customerId, 'customerDescription') + customerDescription: null } - await updateMarkup(candidateData.customerDescription, { customerDescription: description }) + if (!isEmptyMarkup(description)) { + const collabId = makeCollabId(lead.mixin.Customer, customerId, 'customerDescription') + candidateData.customerDescription = await createMarkup(collabId, description) + } const id = await client.createDoc(targetClass._id, contact.space.Contacts, { ...candidate, ...object }, customerId) await client.createMixin( diff --git a/plugins/lead/src/index.ts b/plugins/lead/src/index.ts index d9fc1f0fee..f30be8eb82 100644 --- a/plugins/lead/src/index.ts +++ b/plugins/lead/src/index.ts @@ -15,7 +15,7 @@ // import type { Contact } from '@hcengineering/contact' -import type { Attribute, Class, CollaborativeDoc, Doc, Markup, Ref, Status, Timestamp } from '@hcengineering/core' +import type { Attribute, Class, MarkupBlobRef, Doc, Markup, Ref, Status, Timestamp } from '@hcengineering/core' import { Mixin } from '@hcengineering/core' import type { Asset, IntlString, Plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' @@ -36,7 +36,7 @@ export interface Funnel extends Project { export interface Customer extends Contact { leads?: number - customerDescription: CollaborativeDoc + customerDescription: MarkupBlobRef | null } /** diff --git a/plugins/love-resources/src/components/AddRoomPopup.svelte b/plugins/love-resources/src/components/AddRoomPopup.svelte index 4d61e0b1fd..719eacc0c3 100644 --- a/plugins/love-resources/src/components/AddRoomPopup.svelte +++ b/plugins/love-resources/src/components/AddRoomPopup.svelte @@ -1,5 +1,5 @@
diff --git a/plugins/test-management-assets/assets/icons.svg b/plugins/test-management-assets/assets/icons.svg index 6fb417124a..7a4cde62a4 100644 --- a/plugins/test-management-assets/assets/icons.svg +++ b/plugins/test-management-assets/assets/icons.svg @@ -3,12 +3,11 @@ - - + + - - - + + @@ -52,4 +51,17 @@ + + + + + + + + + + + + + diff --git a/plugins/test-management-assets/lang/cs.json b/plugins/test-management-assets/lang/cs.json index 78ab73bfd9..4cc8c2d40d 100644 --- a/plugins/test-management-assets/lang/cs.json +++ b/plugins/test-management-assets/lang/cs.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Název sady", "DescriptionPlaceholder": "Popis (volitelné)", "TestRuns": "Testovací běhy", - "NewTestRun": "Nový testovací běh", "TestRun": "Testovací běh", "TestNamePlaceholder": "Název testovacího případu", "ChooseIcon": "Vyberte ikonu", @@ -61,6 +60,23 @@ "TestRunNamePlaceholder": "Název testovacího běhu", "SelectTestSuites": "Vyberte testovací sady", "SelectTestCases": "Vyberte testovací případy", - "TestLibrary": "Testovací knihovna" + "TestLibrary": "Testovací knihovna", + "TestResult": "Výsledek", + "TestRunResult": "Výsledek", + "TestRunStatus": "Stav", + "StatusNonTested": "Neprovedeno", + "StatusBlocked": "Blokováno", + "StatusPassed": "Prošlo", + "StatusFailed": "Neprošlo", + "SelectTestCase": "Vyberte testovací případ", + "Save": "Uložit", + "SaveAndNext": "Uložit a další", + "DonePercent": "% Hotovo", + "TestResults": "Výsledky", + "TestRunName": "Název", + "DueDate": "Termín", + "RunTestCases": "Spustit", + "TestCaseDescription": "Popis testovacího případu", + "TestResultAttributes": "Výsledek" } -} \ No newline at end of file +} diff --git a/plugins/test-management-assets/lang/en.json b/plugins/test-management-assets/lang/en.json index 135650b31c..11a3feb1a3 100644 --- a/plugins/test-management-assets/lang/en.json +++ b/plugins/test-management-assets/lang/en.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Suite name", "DescriptionPlaceholder": "Description (optional)", "TestRuns": "Test runs", - "NewTestRun": "New test run", "TestRun": "Test run", "TestNamePlaceholder": "Test case title", "ChooseIcon": "Choose icon", @@ -57,10 +56,27 @@ "AssignedTo": "Assigneed to", "PreviousAssigned": "Previously assigned", "NoTestCases": "There are no test cases in this test suite", - "CreateTestRun": "Create test run", + "CreateTestRun": "New test run", "TestRunNamePlaceholder": "Test run name", "SelectTestSuites": "Select test suites", "SelectTestCases": "Select test cases", - "TestLibrary": "Test library" + "TestLibrary": "Test library", + "TestResult": "Result", + "TestRunResult": "Result", + "TestRunStatus": "Status", + "StatusNonTested": "Untested", + "StatusBlocked": "Blocked", + "StatusPassed": "Passed", + "StatusFailed": "Failed", + "SelectTestCase": "Select test case", + "Save": "Save", + "SaveAndNext": "Save and next", + "DonePercent": "% Done", + "TestResults": "Results", + "TestRunName": "Name", + "DueDate": "Due date", + "RunTestCases": "Run", + "TestCaseDescription": "Test case description", + "TestResultAttributes": "Result" } } diff --git a/plugins/test-management-assets/lang/es.json b/plugins/test-management-assets/lang/es.json new file mode 100644 index 0000000000..0bbdac565e --- /dev/null +++ b/plugins/test-management-assets/lang/es.json @@ -0,0 +1,82 @@ +{ + "string": { + "ConfigLabel": "Gestión de Pruebas", + "ConfigDescription": "Extensión para gestionar casos de prueba", + "TestCaseType": "Tipo", + "TestCasePriority": "Prioridad", + "TestCaseStatus": "Estado", + "TestSuite": "Suite de Pruebas", + "SuiteName": "Nombre", + "SuiteDescription": "Descripción", + "Suite": "Suite", + "TestName": "Nombre", + "TestDescription": "Descripción", + "TestType": "Tipo", + "TestPriority": "Prioridad", + "TestStatus": "Estado", + "TestEstimatedTime": "Tiempo estimado", + "TestPreconditions": "Precondiciones", + "TestSteps": "Pasos", + "TestAssignee": "Asignado a", + "TestCase": "Caso de prueba", + "TestProject": "Proyecto", + "TestManagementApplication": "Gestión de Pruebas", + "AllTestCases": "Todos los casos de prueba", + "AllProjects": "Todos los proyectos", + "Projects": "Proyectos", + "CreateProject": "Crear proyecto", + "EditProject": "Editar proyecto", + "TestCases": "Casos de prueba", + "TestManagementDescription": "Extensión para gestionar casos de prueba", + "CreateTestCase": "Nuevo caso de prueba", + "FullDescription": "Descripción completa", + "ProjectName": "Nombre del proyecto", + "ProjectType": "Tipo de proyecto", + "Members": "Miembros", + "RoleLabel": "Rol", + "ProjectMembers": "Miembros del proyecto", + "TestSuites": "Suites de pruebas", + "CreateTestSuite": "Crear suite de pruebas", + "NamePlaceholder": "Nombre de la suite", + "DescriptionPlaceholder": "Descripción (opcional)", + "TestRuns": "Ejecuciones de prueba", + "TestRun": "Ejecución de prueba", + "TestNamePlaceholder": "Título del caso de prueba", + "ChooseIcon": "Elegir ícono", + "NoTestSuite": "No hay suite de pruebas", + "StatusDraft": "Borrador", + "StatusReview": "Listo para revisión", + "StatusReviewComments": "Necesita corregir comentarios de revisión", + "StatusApproved": "Aprobado", + "StatusRejected": "Rechazado", + "SetStatus": "Establecer estado", + "Assignee": "Asignado a", + "Unassigned": "No asignado", + "AssignTo": "Asignar a", + "AssignedTo": "Asignado a", + "PreviousAssigned": "Asignado previamente", + "NoTestCases": "No hay casos de prueba en esta suite de pruebas", + "CreateTestRun": "Nueva ejecución de prueba", + "TestRunNamePlaceholder": "Nombre de la ejecución de prueba", + "SelectTestSuites": "Seleccionar suites de prueba", + "SelectTestCases": "Seleccionar casos de prueba", + "TestLibrary": "Biblioteca de pruebas", + "TestResult": "Resultado", + "TestRunResult": "Resultado", + "TestRunStatus": "Estado", + "StatusNonTested": "No probado", + "StatusBlocked": "Bloqueado", + "StatusPassed": "Aprobado", + "StatusFailed": "Fallido", + "SelectTestCase": "Seleccionar caso de prueba", + "Save": "Guardar", + "SaveAndNext": "Guardar y siguiente", + "DonePercent": "% Completado", + "TestResults": "Resultados", + "TestRunName": "Nombre", + "DueDate": "Fecha de vencimiento", + "RunTestCases": "Ejecutar", + "TestCaseDescription": "Descripción del caso de prueba", + "TestResultAttributes": "Resultado" + } +} diff --git a/plugins/test-management-assets/lang/fr.json b/plugins/test-management-assets/lang/fr.json index c2f3353047..3fdb3a50fb 100644 --- a/plugins/test-management-assets/lang/fr.json +++ b/plugins/test-management-assets/lang/fr.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Nom de la suite", "DescriptionPlaceholder": "Description (facultatif)", "TestRuns": "Exécutions de tests", - "NewTestRun": "Nouveau test", "TestRun": "Exécution d'essai", "TestNamePlaceholder": "Titre du scénario de test", "ChooseIcon": "Choisir l'icône", @@ -61,6 +60,23 @@ "TestRunNamePlaceholder": "Nom de l'exécution du test", "SelectTestSuites": "Sélectionnez les suites de tests", "SelectTestCases": "Sélectionnez des cas de test", - "TestLibrary": "Bibliothèque de tests" + "TestLibrary": "Bibliothèque de tests", + "TestResult": "Résultat", + "TestRunResult": "Résultat", + "TestRunStatus": "Statut", + "StatusNonTested": "Non testé", + "StatusBlocked": "Bloqué", + "StatusPassed": "Réussi", + "StatusFailed": "Échoué", + "SelectTestCase": "Sélectionnez le cas de test", + "Save": "Sauvegarder", + "SaveAndNext": "Sauvegarder et suivant", + "DonePercent": "% Fait", + "TestResults": "Résultats", + "TestRunName": "Nom", + "DueDate": "Date d'échéance", + "RunTestCases": "Exécuter", + "TestCaseDescription": "Description du cas de test", + "TestResultAttributes": "Résultat" } } diff --git a/plugins/test-management-assets/lang/it.json b/plugins/test-management-assets/lang/it.json new file mode 100644 index 0000000000..8751272028 --- /dev/null +++ b/plugins/test-management-assets/lang/it.json @@ -0,0 +1,82 @@ +{ + "string": { + "ConfigLabel": "Gestione Test", + "ConfigDescription": "Estensione per gestire i casi di test", + "TestCaseType": "Tipo", + "TestCasePriority": "Priorità", + "TestCaseStatus": "Stato", + "TestSuite": "Suite di Test", + "SuiteName": "Nome", + "SuiteDescription": "Descrizione", + "Suite": "Suite", + "TestName": "Nome", + "TestDescription": "Descrizione", + "TestType": "Tipo", + "TestPriority": "Priorità", + "TestStatus": "Stato", + "TestEstimatedTime": "Tempo stimato", + "TestPreconditions": "Precondizioni", + "TestSteps": "Passaggi", + "TestAssignee": "Assegnatario", + "TestCase": "Caso di test", + "TestProject": "Progetto", + "TestManagementApplication": "Gestione Test", + "AllTestCases": "Tutti i casi di test", + "AllProjects": "Tutti i progetti", + "Projects": "Progetti", + "CreateProject": "Crea progetto", + "EditProject": "Modifica progetto", + "TestCases": "Casi di test", + "TestManagementDescription": "Estensione per gestire i casi di test", + "CreateTestCase": "Nuovo caso di test", + "FullDescription": "Descrizione completa", + "ProjectName": "Nome", + "ProjectType": "Tipo", + "Members": "Membri", + "RoleLabel": "Ruolo", + "ProjectMembers": "Membri del progetto", + "TestSuites": "Suite di test", + "CreateTestSuite": "Crea suite di test", + "NamePlaceholder": "Nome della suite", + "DescriptionPlaceholder": "Descrizione (opzionale)", + "TestRuns": "Esecuzioni di test", + "TestRun": "Esecuzione di test", + "TestNamePlaceholder": "Titolo del caso di test", + "ChooseIcon": "Scegli icona", + "NoTestSuite": "Nessuna suite di test", + "StatusDraft": "Bozza", + "StatusReview": "Pronto per la revisione", + "StatusReviewComments": "Necessita di correggere i commenti della revisione", + "StatusApproved": "Approvato", + "StatusRejected": "Respinto", + "SetStatus": "Imposta stato", + "Assignee": "Assegnatario", + "Unassigned": "Non assegnato", + "AssignTo": "Assegna a", + "AssignedTo": "Assegnato a", + "PreviousAssigned": "Assegnato in precedenza", + "NoTestCases": "Non ci sono casi di test in questa suite di test", + "CreateTestRun": "Nuova esecuzione di test", + "TestRunNamePlaceholder": "Nome dell'esecuzione di test", + "SelectTestSuites": "Seleziona suite di test", + "SelectTestCases": "Seleziona casi di test", + "TestLibrary": "Libreria di test", + "TestResult": "Risultato", + "TestRunResult": "Risultato", + "TestRunStatus": "Stato", + "StatusNonTested": "Non testato", + "StatusBlocked": "Bloccato", + "StatusPassed": "Superato", + "StatusFailed": "Fallito", + "SelectTestCase": "Seleziona caso di test", + "Save": "Salva", + "SaveAndNext": "Salva e successivo", + "DonePercent": "% Completato", + "TestResults": "Risultati", + "TestRunName": "Nome", + "DueDate": "Data di scadenza", + "RunTestCases": "Esegui", + "TestCaseDescription": "Descrizione del caso di test", + "TestResultAttributes": "Risultato" + } +} diff --git a/plugins/test-management-assets/lang/pt.json b/plugins/test-management-assets/lang/pt.json new file mode 100644 index 0000000000..5f34b98868 --- /dev/null +++ b/plugins/test-management-assets/lang/pt.json @@ -0,0 +1,82 @@ +{ + "string": { + "ConfigLabel": "Gestão de Testes", + "ConfigDescription": "Extensão para gerenciar casos de teste", + "TestCaseType": "Tipo", + "TestCasePriority": "Prioridade", + "TestCaseStatus": "Status", + "TestSuite": "Suite de Testes", + "SuiteName": "Nome", + "SuiteDescription": "Descrição", + "Suite": "Suite", + "TestName": "Nome", + "TestDescription": "Descrição", + "TestType": "Tipo", + "TestPriority": "Prioridade", + "TestStatus": "Status", + "TestEstimatedTime": "Tempo estimado", + "TestPreconditions": "Precondições", + "TestSteps": "Passos", + "TestAssignee": "Responsável", + "TestCase": "Caso de teste", + "TestProject": "Projeto", + "TestManagementApplication": "Gestão de Testes", + "AllTestCases": "Todos os casos de teste", + "AllProjects": "Todos os projetos", + "Projects": "Projetos", + "CreateProject": "Criar projeto", + "EditProject": "Editar projeto", + "TestCases": "Casos de teste", + "TestManagementDescription": "Extensão para gerenciar casos de teste", + "CreateTestCase": "Novo caso de teste", + "FullDescription": "Descrição completa", + "ProjectName": "Nome", + "ProjectType": "Tipo", + "Members": "Membros", + "RoleLabel": "Função", + "ProjectMembers": "Membros do projeto", + "TestSuites": "Suites de teste", + "CreateTestSuite": "Criar suite de teste", + "NamePlaceholder": "Nome da suite", + "DescriptionPlaceholder": "Descrição (opcional)", + "TestRuns": "Execuções de teste", + "TestRun": "Execução de teste", + "TestNamePlaceholder": "Título do caso de teste", + "ChooseIcon": "Escolher ícone", + "NoTestSuite": "Nenhuma suite de teste", + "StatusDraft": "Rascunho", + "StatusReview": "Pronto para revisão", + "StatusReviewComments": "Precisa corrigir comentários da revisão", + "StatusApproved": "Aprovado", + "StatusRejected": "Rejeitado", + "SetStatus": "Definir status", + "Assignee": "Responsável", + "Unassigned": "Não atribuído", + "AssignTo": "Atribuir a", + "AssignedTo": "Atribuído a", + "PreviousAssigned": "Atribuído anteriormente", + "NoTestCases": "Não há casos de teste nesta suite de teste", + "CreateTestRun": "Nova execução de teste", + "TestRunNamePlaceholder": "Nome da execução de teste", + "SelectTestSuites": "Selecionar suites de teste", + "SelectTestCases": "Selecionar casos de teste", + "TestLibrary": "Biblioteca de testes", + "TestResult": "Resultado", + "TestRunResult": "Resultado", + "TestRunStatus": "Status", + "StatusNonTested": "Não testado", + "StatusBlocked": "Bloqueado", + "StatusPassed": "Aprovado", + "StatusFailed": "Falhou", + "SelectTestCase": "Selecionar caso de teste", + "Save": "Salvar", + "SaveAndNext": "Salvar e próximo", + "DonePercent": "% Concluído", + "TestResults": "Resultados", + "TestRunName": "Nome", + "DueDate": "Data de vencimento", + "RunTestCases": "Executar", + "TestCaseDescription": "Descrição do caso de teste", + "TestResultAttributes": "Resultado" + } +} diff --git a/plugins/test-management-assets/lang/ru.json b/plugins/test-management-assets/lang/ru.json index 19a45e0d6c..9917ba97ca 100644 --- a/plugins/test-management-assets/lang/ru.json +++ b/plugins/test-management-assets/lang/ru.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Имя тестового набора", "DescriptionPlaceholder": "Описание (опционально)", "TestRuns": "Выполнение тестов", - "NewTestRun": "Новый тест план", "TestRun": "Тест план", "TestNamePlaceholder": "Имя тест кейса", "ChooseIcon": "Выберите иконку", @@ -61,6 +60,23 @@ "TestRunNamePlaceholder": "Название тест плана", "SelectTestSuites": "Выбрать наборы тестов", "SelectTestCases": "Выбрать тест-кейсы", - "TestLibrary": "Библиотека тестов" + "TestLibrary": "Библиотека тестов", + "TestResult": "Результат", + "TestRunResult": "Результат", + "TestRunStatus": "Статус", + "StatusNonTested": "Не протестировано", + "StatusBlocked": "Заблокировано", + "StatusPassed": "Пройдено", + "StatusFailed": "Не пройдено", + "SelectTestCase": "Выбрать тест-кейс", + "Save": "Сохранить", + "SaveAndNext": "Сохранить и перейти к следующему", + "DonePercent": "% Выполнено", + "TestResults": "Результаты", + "TestRunName": "Название", + "DueDate": "Выполнить до", + "RunTestCases": "Выполнить", + "TestCaseDescription": "Описание тест-кейса", + "TestResultAttributes": "Результат" } } diff --git a/plugins/test-management-assets/lang/zh.json b/plugins/test-management-assets/lang/zh.json index c7da64156d..4d1558a7e6 100644 --- a/plugins/test-management-assets/lang/zh.json +++ b/plugins/test-management-assets/lang/zh.json @@ -40,7 +40,6 @@ "NamePlaceholder": "套房名稱", "DescriptionPlaceholder": "說明(可選)", "TestRuns": "試運行", - "NewTestRun": "新試運行", "TestRun": "試運行", "TestNamePlaceholder": "測試用例標題", "ChooseIcon": "選擇圖示", @@ -61,6 +60,23 @@ "TestRunNamePlaceholder": "測試運行名稱", "SelectTestSuites": "選擇測試套件", "SelectTestCases": "選擇測試用例", - "TestLibrary": "測試庫" + "TestLibrary": "測試庫", + "TestResult": "結果", + "TestRunResult": "結果", + "TestRunStatus": "狀態", + "StatusNonTested": "未測試", + "StatusBlocked": "被阻止", + "StatusPassed": "通過", + "StatusFailed": "失敗", + "SelectTestCase": "選擇測試用例", + "Save": "保存", + "SaveAndNext": "保存並繼續", + "DonePercent": "%完成", + "TestResults": "結果", + "TestRunName": "姓名", + "DueDate": "到期日", + "RunTestCases": "运行", + "TestCaseDescription": "測試用例描述", + "TestResultAttributes": "結果" } } diff --git a/plugins/test-management-assets/src/index.ts b/plugins/test-management-assets/src/index.ts index 8a50befa21..d88f48d5e0 100644 --- a/plugins/test-management-assets/src/index.ts +++ b/plugins/test-management-assets/src/index.ts @@ -36,5 +36,11 @@ loadMetadata(testManagement.icon, { StatusReviewComments: `${icons}#status-review-comments`, StatusApproved: `${icons}#status-approved`, StatusRejected: `${icons}#status-canceled`, - TestLibrary: `${icons}#test-library` + TestLibrary: `${icons}#test-library`, + TestResult: `${icons}#testResult`, + StatusNonTested: `${icons}#status-draft`, + StatusBlocked: `${icons}#status-review-comments`, + StatusPassed: `${icons}#status-approved`, + StatusFailed: `${icons}#status-canceled`, + Run: `${icons}#run` }) diff --git a/plugins/test-management-resources/src/components/test-case/AssigneeEditor.svelte b/plugins/test-management-resources/src/components/test-case/AssigneeEditor.svelte deleted file mode 100644 index ea405cd046..0000000000 --- a/plugins/test-management-resources/src/components/test-case/AssigneeEditor.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - - -{#if _object} - {#if isAction} - { - const result = evt.detail - if (result === null) { - handleAssigneeChanged(null) - } else if (result !== undefined && result._id !== value) { - value = result._id - handleAssigneeChanged(result._id) - } - }} - /> - {:else} - handleAssigneeChanged(detail)} - /> - {/if} -{/if} diff --git a/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte b/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte index aee61920a7..2a250b33fb 100644 --- a/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte +++ b/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte @@ -17,14 +17,13 @@ import { Attachment } from '@hcengineering/attachment' import { AttachmentPresenter, AttachmentStyledBox } from '@hcengineering/attachment-resources' import { TestCase, TestProject, TestSuite, TestCaseStatus } from '@hcengineering/test-management' - import core, { fillDefaults, generateId, makeCollaborativeDoc, Ref, TxOperations, Data } from '@hcengineering/core' + import core, { fillDefaults, generateId, makeCollabId, Ref, TxOperations, Data } from '@hcengineering/core' import { ObjectBox } from '@hcengineering/view-resources' - import { Card, SpaceSelector, getClient, updateMarkup } from '@hcengineering/presentation' - import { EmptyMarkup } from '@hcengineering/text' + import { Card, SpaceSelector, createMarkup, getClient } from '@hcengineering/presentation' + import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' import { Button, createFocusManager, EditBox, FocusHandler, IconAttachment, getLocation } from '@hcengineering/ui' import StatusEditor from './StatusEditor.svelte' - import AssigneeEditor from './AssigneeEditor.svelte' import ProjectPresenter from '../project/ProjectPresenter.svelte' import testManagement from '../../plugin' @@ -43,7 +42,7 @@ const object: Data = { name: '', - description: makeCollaborativeDoc(id, 'description'), + description: null, status: TestCaseStatus.Draft, assignee: null, attachments: 0, @@ -62,7 +61,10 @@ async function createTestCase (): Promise { const op = client.apply() - await updateMarkup(object.description, { description }) + if (!isEmptyMarkup(description)) { + const target = makeCollabId(testManagement.class.TestCase, id, 'description') + object.description = await createMarkup(target, description) + } await op.addCollection( testManagement.class.TestCase, _space, @@ -164,12 +166,6 @@ /> - (object.assignee = detail)} - /> diff --git a/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte b/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte index 9e21e28c4e..dec99743de 100644 --- a/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte +++ b/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte @@ -13,13 +13,16 @@ // limitations under the License. --> + + diff --git a/plugins/test-management-resources/src/components/test-result/EditTestResult.svelte b/plugins/test-management-resources/src/components/test-result/EditTestResult.svelte new file mode 100644 index 0000000000..41020b475f --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/EditTestResult.svelte @@ -0,0 +1,105 @@ + + + +{#if object} + + dispatch('close')} + > +
+
+ +
+ + + + + + + + + + +{/if} diff --git a/packages/collaborator-client/src/types.ts b/plugins/test-management-resources/src/components/test-result/RightHeader.svelte similarity index 75% rename from packages/collaborator-client/src/types.ts rename to plugins/test-management-resources/src/components/test-result/RightHeader.svelte index 9169a5eadc..0429820bd8 100644 --- a/packages/collaborator-client/src/types.ts +++ b/plugins/test-management-resources/src/components/test-result/RightHeader.svelte @@ -1,4 +1,4 @@ -// + -/** @public */ -export type DocumentId = string & { __documentId: true } +
+ +
-/** @public */ -export type PlatformDocumentId = string & { __platformDocId: true } + diff --git a/plugins/test-management-resources/src/components/test-result/TestResultFooter.svelte b/plugins/test-management-resources/src/components/test-result/TestResultFooter.svelte new file mode 100644 index 0000000000..2554f5ed7d --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultFooter.svelte @@ -0,0 +1,31 @@ + + + + +
+
diff --git a/plugins/test-management-resources/src/components/test-result/TestResultHeader.svelte b/plugins/test-management-resources/src/components/test-result/TestResultHeader.svelte new file mode 100644 index 0000000000..668c1dde60 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultHeader.svelte @@ -0,0 +1,25 @@ + + + +
+
+ +
diff --git a/plugins/test-management-resources/src/components/test-result/TestResultPresenter.svelte b/plugins/test-management-resources/src/components/test-result/TestResultPresenter.svelte new file mode 100644 index 0000000000..f2f5005c15 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultPresenter.svelte @@ -0,0 +1,55 @@ + + + + +{#if value} + {#if inline} + + {:else} + +
+ {#if shouldShowAvatar} +
+ +
+ {/if} + + {title} + +
+
+ {/if} +{/if} diff --git a/plugins/test-management-resources/src/components/test-result/TestResultStatusEditor.svelte b/plugins/test-management-resources/src/components/test-result/TestResultStatusEditor.svelte new file mode 100644 index 0000000000..dea4e87317 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultStatusEditor.svelte @@ -0,0 +1,106 @@ + + + +{#if kind === 'list'} + +{:else if kind === 'list-header'} +
+ {#if shouldShowAvatar} + + {/if} + +
+{:else} +
{#if testCases > 0} @@ -67,7 +60,7 @@ @@ -78,7 +71,7 @@ - + diff --git a/plugins/test-management-resources/src/components/test-run/TestRunAside.svelte b/plugins/test-management-resources/src/components/test-run/TestRunAside.svelte new file mode 100644 index 0000000000..823f876197 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/TestRunAside.svelte @@ -0,0 +1,32 @@ + + + + + +
+ +
+ diff --git a/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte b/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte index 5157718452..9b38f419d5 100644 --- a/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte +++ b/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte @@ -13,6 +13,28 @@ // limitations under the License. --> -
+{#if value} + {#if inline} + + {:else} + +
+ + {value.name} + +
+
+ {/if} +{/if} diff --git a/plugins/test-management-resources/src/components/test-run/TestRunResult.svelte b/plugins/test-management-resources/src/components/test-run/TestRunResult.svelte new file mode 100644 index 0000000000..c43e70d539 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/TestRunResult.svelte @@ -0,0 +1,64 @@ + + + + + + + + + + diff --git a/plugins/test-management-resources/src/components/test-run/TestRunStats.svelte b/plugins/test-management-resources/src/components/test-run/TestRunStats.svelte new file mode 100644 index 0000000000..ff10978881 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/TestRunStats.svelte @@ -0,0 +1,55 @@ + + + +
+ + {#if !isLoading && stats !== undefined} + + {:else} + + {/if} + + + {#if !isLoading} +
+
+ +
+ {stats?.done ?? 0} +
+ {:else} + + {/if} +
diff --git a/plugins/test-management-resources/src/components/test-run/ViewContainer.svelte b/plugins/test-management-resources/src/components/test-run/ViewContainer.svelte new file mode 100644 index 0000000000..2dbeed8373 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/ViewContainer.svelte @@ -0,0 +1,25 @@ + + + +
+ +
+ diff --git a/server/collaborator/src/types.ts b/plugins/test-management-resources/src/components/test-run/store/testRunStore.ts similarity index 66% rename from server/collaborator/src/types.ts rename to plugins/test-management-resources/src/components/test-run/store/testRunStore.ts index 6171defa96..9f04f26ed4 100644 --- a/server/collaborator/src/types.ts +++ b/plugins/test-management-resources/src/components/test-run/store/testRunStore.ts @@ -13,19 +13,10 @@ // limitations under the License. // -import type { Class, Doc, Domain, Ref } from '@hcengineering/core' +import type { TestCase, TestResult } from '@hcengineering/test-management' +import { type Ref } from '@hcengineering/core' +import { type Writable, writable } from 'svelte/store' -/** @public */ -export interface DocumentId { - workspaceId: string - documentId: string - versionId: string -} +export const currentTestCase: Writable | undefined> = writable(undefined) -/** @public */ -export interface PlatformDocumentId { - objectDomain: Domain - objectClass: Ref> - objectId: Ref - objectAttr: string -} +export const selectedTestRun: Writable = writable(undefined) diff --git a/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte b/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte index 293f23a8d2..13241c1b91 100644 --- a/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte +++ b/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte @@ -18,10 +18,9 @@ import { TestSuite } from '@hcengineering/test-management' import { StyledTextArea } from '@hcengineering/text-editor-resources' import { Panel } from '@hcengineering/panel' - import { EditBox } from '@hcengineering/ui' + import { EditBox, Breadcrumb } from '@hcengineering/ui' import { createEventDispatcher, onMount } from 'svelte' - import TestCasesList from './TestCasesList.svelte' import testManagement from '../../plugin' export let _id: Ref @@ -61,14 +60,17 @@ dispatch('close')} > + + + +
- -
- -
{/if} diff --git a/plugins/test-management-resources/src/index.ts b/plugins/test-management-resources/src/index.ts index 3776daf5b4..7ee0a6ff9b 100644 --- a/plugins/test-management-resources/src/index.ts +++ b/plugins/test-management-resources/src/index.ts @@ -28,9 +28,17 @@ import CreateTestRun from './components/test-run/CreateTestRun.svelte' import TestCaseStatusPresenter from './components/test-case/TestCaseStatusPresenter.svelte' import EditTestRun from './components/test-run/EditTestRun.svelte' import TestRunPresenter from './components/test-run/TestRunPresenter.svelte' +import RunButton from './components/test-case/RunButton.svelte' +import TestResultStatusPresenter from './components/test-result/TestResultStatusPresenter.svelte' +import TestResultStatusEditor from './components/test-result/TestResultStatusEditor.svelte' +import TestRunResult from './components/test-run/TestRunResult.svelte' +import TestResultPresenter from './components/test-result/TestResultPresenter.svelte' +import EditTestResult from './components/test-result/EditTestResult.svelte' +import TestResultHeader from './components/test-result/TestResultHeader.svelte' +import TestResultFooter from './components/test-result/TestResultFooter.svelte' -import { CreateChildTestSuiteAction, EditTestSuiteAction } from './utils' -import { resolveLocation, getTestSuiteLink } from './navigation' +import { CreateChildTestSuiteAction, EditTestSuiteAction, RunSelectedTestsAction } from './utils' +import { resolveLocation, getAttachedObjectLink } from './navigation' export default async (): Promise => ({ component: { @@ -47,16 +55,26 @@ export default async (): Promise => ({ TestCaseStatusPresenter, EditTestRun, TestRunPresenter, - TestSuiteRefPresenter + TestSuiteRefPresenter, + RunButton, + TestResultStatusPresenter, + TestResultStatusEditor, + TestRunResult, + TestResultPresenter, + EditTestResult, + TestResultHeader, + TestResultFooter }, function: { - GetTestSuiteLink: getTestSuiteLink + GetTestSuiteLink: getAttachedObjectLink, + GetTestRunLink: getAttachedObjectLink }, resolver: { Location: resolveLocation }, actionImpl: { CreateChildTestSuite: CreateChildTestSuiteAction, - EditTestSuite: EditTestSuiteAction + EditTestSuite: EditTestSuiteAction, + RunSelectedTests: RunSelectedTestsAction } }) diff --git a/plugins/test-management-resources/src/navigation.ts b/plugins/test-management-resources/src/navigation.ts index 775011ce07..00ba0f82b0 100644 --- a/plugins/test-management-resources/src/navigation.ts +++ b/plugins/test-management-resources/src/navigation.ts @@ -61,14 +61,9 @@ async function generateProjectLocation ( } } -export function getTestSuiteLink (testSuite: Ref): Location { +export function getAttachedObjectLink (parentDoc: Ref): Location { const loc = getCurrentResolvedLocation() - loc.query = - testSuite === undefined - ? undefined - : { - [SUITE_KEY]: testSuite - } + loc.query = parentDoc === undefined ? undefined : { attachedTo: parentDoc } return loc } @@ -84,6 +79,18 @@ export function getTestSuiteIdFromLocation (): Ref { return (location?.query?.[SUITE_KEY] as Ref) ?? testManagement.ids.NoParent } +export function getTestRunsLink (space: Ref, parentDoc: Ref): Location { + const loc = getCurrentResolvedLocation() + loc.path.length = 5 + loc.path[2] = testManagementId + loc.path[3] = space + loc.path[4] = 'testRuns' + loc.fragment = undefined + loc.query = parentDoc === undefined ? undefined : { attachedTo: parentDoc } + + return loc +} + export async function resolveLocation (loc: Location): Promise { if (loc.path[2] !== testManagementId) { return undefined diff --git a/plugins/test-management-resources/src/testRunUtils.ts b/plugins/test-management-resources/src/testRunUtils.ts new file mode 100644 index 0000000000..4552190e95 --- /dev/null +++ b/plugins/test-management-resources/src/testRunUtils.ts @@ -0,0 +1,65 @@ +// +// 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 Ref } from '@hcengineering/core' +import { getClient } from '@hcengineering/presentation' +import testManagement, { type TestRun, type TestCase, TestRunStatus } from '@hcengineering/test-management' + +export async function getTestCases (objectId: Ref): Promise { + if (objectId === undefined) { + return [] + } + const client = getClient() + const testResults = await client.findAll(testManagement.class.TestResult, { attachedTo: objectId }) + const testCaseIds = testResults.map((testResult) => testResult.testCase) + return await client.findAll(testManagement.class.TestCase, { _id: { $in: testCaseIds } }) +} + +export interface TestRunStats { + readonly done: number + readonly untested: number + readonly blocked: number + readonly completed: number + readonly failed: number +} + +async function getTestResultCount (objectId: Ref, status: TestRunStatus): Promise { + const client = getClient() + const testResults = await client.findAll( + testManagement.class.TestResult, + { + attachedTo: objectId, + status + }, + { limit: 0, total: true } + ) + return testResults.total > 0 ? testResults.total : 0 +} + +export async function getTestRunStats (objectId: Ref): Promise { + const untested = await getTestResultCount(objectId, TestRunStatus.Untested) + const blocked = await getTestResultCount(objectId, TestRunStatus.Blocked) + const completed = await getTestResultCount(objectId, TestRunStatus.Passed) + const failed = await getTestResultCount(objectId, TestRunStatus.Failed) + const total = untested + blocked + completed + failed + + const done = total > 0 ? ((total - untested) * 100) / total : 0 + return { + done, + untested, + blocked, + completed, + failed + } +} diff --git a/plugins/test-management-resources/src/types.ts b/plugins/test-management-resources/src/types.ts index 2b2ffc954d..40f555855a 100644 --- a/plugins/test-management-resources/src/types.ts +++ b/plugins/test-management-resources/src/types.ts @@ -14,7 +14,7 @@ // import { type Asset, type IntlString } from '@hcengineering/platform' -import testManagement, { TestCaseStatus } from '@hcengineering/test-management' +import testManagement, { TestCaseStatus, TestRunStatus } from '@hcengineering/test-management' /** @public */ export const defaultTestCaseStatuses = [ @@ -39,3 +39,31 @@ export const testCaseStatusAssets: Record = { + [TestRunStatus.Untested]: { + icon: testManagement.icon.StatusNonTested, + label: testManagement.string.StatusNonTested + }, + [TestRunStatus.Blocked]: { + icon: testManagement.icon.StatusBlocked, + label: testManagement.string.StatusBlocked + }, + [TestRunStatus.Passed]: { + icon: testManagement.icon.StatusPassed, + label: testManagement.string.StatusPassed + }, + [TestRunStatus.Failed]: { + icon: testManagement.icon.StatusFailed, + label: testManagement.string.StatusFailed + } +} diff --git a/plugins/test-management-resources/src/utils.ts b/plugins/test-management-resources/src/utils.ts index 823692ebf8..6b4b81c2f5 100644 --- a/plugins/test-management-resources/src/utils.ts +++ b/plugins/test-management-resources/src/utils.ts @@ -13,9 +13,7 @@ // limitations under the License. // -import { type Contact } from '@hcengineering/contact' -import core, { type Doc, type Ref, type TxCreateDoc, type TxUpdateDoc } from '@hcengineering/core' -import { getClient } from '@hcengineering/presentation' +import type { Doc, DocumentQuery, Ref } from '@hcengineering/core' import { showPopup } from '@hcengineering/ui' import { type TestProject, type TestCase, type TestSuite } from '@hcengineering/test-management' @@ -23,34 +21,7 @@ import CreateTestSuiteComponent from './components/test-suite/CreateTestSuite.sv import EditTestSuiteComponent from './components/test-suite/EditTestSuite.svelte' import CreateTestCase from './components/test-case/CreateTestCase.svelte' import CreateProject from './components/project/CreateProject.svelte' - -export async function getPreviousAssignees (objectId: Ref | undefined): Promise>> { - if (objectId === undefined) { - return [] - } - const client = getClient() - const createTx = ( - await client.findAll>(core.class.TxCreateDoc, { - objectId: objectId as Ref - }) - )[0] - const updateTxes = await client.findAll>( - core.class.TxUpdateDoc, - { objectId: objectId as Ref, 'operations.assignee': { $exists: true } }, - { sort: { modifiedOn: -1 } } - ) - const set = new Set>() - const createAssignee = createTx?.attributes?.assignee - for (const tx of updateTxes) { - const assignee = tx.operations.assignee - if (assignee == null) continue - set.add(assignee) - } - if (createAssignee != null) { - set.add(createAssignee) - } - return Array.from(set) -} +import CreateTestRun from './components/test-run/CreateTestRun.svelte' export async function showCreateTestSuitePopup ( space: Ref | undefined, @@ -71,6 +42,14 @@ export async function showCreateProjectPopup (): Promise { showPopup(CreateProject, {}, 'top') } +export async function showCreateTestRunPopup (options: { + testCases?: TestCase[] + query?: DocumentQuery + space: Ref +}): Promise { + showPopup(CreateTestRun, options, 'top') +} + export async function CreateChildTestSuiteAction (doc: TestSuite): Promise { await showCreateTestSuitePopup(doc.space, doc._id) } @@ -78,3 +57,13 @@ export async function CreateChildTestSuiteAction (doc: TestSuite): Promise export async function EditTestSuiteAction (doc: TestSuite): Promise { await showEditTestSuitePopup(doc._id) } + +export async function RunSelectedTestsAction (docs: TestCase[] | TestCase): Promise { + const testCases = Array.isArray(docs) ? docs : [docs] + if (testCases?.length > 0) { + const space = testCases[0].space + await showCreateTestRunPopup({ testCases, space }) + } else { + console.error('No test cases selected') + } +} diff --git a/plugins/test-management/src/analytics.ts b/plugins/test-management/src/analytics.ts new file mode 100644 index 0000000000..b92682a729 --- /dev/null +++ b/plugins/test-management/src/analytics.ts @@ -0,0 +1,26 @@ +// +// 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. +// + +export enum TestManagementEvents { + TestCaseCreated = 'testManagement.TestCaseCreated', + TestCaseEdited = 'testManagement.TestCaseEdited', + TestCaseDeleted = 'testManagement.TestCaseDeleted', + TestSuiteCreated = 'testManagement.TestSuiteCreated', + TestSuiteEdited = 'testManagement.TestSuiteEdited', + TestSuiteDeleted = 'testManagement.TestSuiteDeleted', + TestRunCreated = 'testManagement.TestRunCreated', + TestRunEdited = 'testManagement.TestRunEdited', + TestRunDeleted = 'testManagement.TestRunDeleted' +} diff --git a/plugins/test-management/src/index.ts b/plugins/test-management/src/index.ts index 90cd70a0a9..e19ee4f581 100644 --- a/plugins/test-management/src/index.ts +++ b/plugins/test-management/src/index.ts @@ -16,6 +16,7 @@ import { testManagementId, testManagementPlugin } from './plugin' export * from './types' +export * from './analytics' export { testManagementId } export default testManagementPlugin diff --git a/plugins/test-management/src/plugin.ts b/plugins/test-management/src/plugin.ts index e5104633ed..67b06126b9 100644 --- a/plugins/test-management/src/plugin.ts +++ b/plugins/test-management/src/plugin.ts @@ -28,7 +28,7 @@ import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platfor import { plugin } from '@hcengineering/platform' import { type AnyComponent, type Location, type ResolvedLocation } from '@hcengineering/ui' -import { ActionCategory, Viewlet } from '@hcengineering/view' +import { Action, ActionCategory, ViewAction, Viewlet } from '@hcengineering/view' import { TestSuite, TestCase, @@ -37,8 +37,8 @@ import { TestCasePriority, TestCaseStatus, TestRun, - TestRunItem, - TestRunResult + TestRunStatus, + TestResult } from './types' /** @public */ @@ -49,6 +49,17 @@ export const testManagementPlugin = plugin(testManagementId, { app: { TestManagement: '' as Ref }, + action: { + DeleteTestCase: '' as Ref>, + CreateChildTestSuite: '' as Ref, + EditTestSuite: '' as Ref, + RunSelectedTests: '' as Ref + }, + actionImpl: { + CreateChildTestSuite: '' as ViewAction, + EditTestSuite: '' as ViewAction, + RunSelectedTests: '' as ViewAction + }, icon: { TestManagement: '' as Asset, TestManagementVersion: '' as Asset, @@ -68,7 +79,13 @@ export const testManagementPlugin = plugin(testManagementId, { StatusApproved: '' as Asset, StatusRejected: '' as Asset, Document: '' as Asset, - TestLibrary: '' as Asset + TestLibrary: '' as Asset, + TestResult: '' as Asset, + StatusNonTested: '' as Asset, + StatusBlocked: '' as Asset, + StatusPassed: '' as Asset, + StatusFailed: '' as Asset, + Run: '' as Asset }, class: { TestCase: '' as Ref>, @@ -78,8 +95,8 @@ export const testManagementPlugin = plugin(testManagementId, { TypeTestCasePriority: '' as Ref>>, TypeTestCaseStatus: '' as Ref>>, TestRun: '' as Ref>, - TestRunItem: '' as Ref>, - TypeTestRunResult: '' as Ref>> + TypeTestRunStatus: '' as Ref>>, + TestResult: '' as Ref> }, descriptors: { ProjectType: '' as Ref @@ -131,7 +148,6 @@ export const testManagementPlugin = plugin(testManagementId, { NamePlaceholder: '' as IntlString, DescriptionPlaceholder: '' as IntlString, TestRuns: '' as IntlString, - NewTestRun: '' as IntlString, TestRun: '' as IntlString, TestNamePlaceholder: '' as IntlString, ChooseIcon: '' as IntlString, @@ -150,14 +166,26 @@ export const testManagementPlugin = plugin(testManagementId, { TestRunName: '' as IntlString, NoTestCases: '' as IntlString, DueDate: '' as IntlString, - TestRunItems: '' as IntlString, - TestRunResult: '' as IntlString, - TestRunItem: '' as IntlString, + TestRunStatus: '' as IntlString, TestRunNamePlaceholder: '' as IntlString, + TestRunResult: '' as IntlString, SelectTestSuites: '' as IntlString, SelectTestCases: '' as IntlString, CreateTestRun: '' as IntlString, - TestLibrary: '' as IntlString + TestLibrary: '' as IntlString, + TestResult: '' as IntlString, + StatusNonTested: '' as IntlString, + StatusBlocked: '' as IntlString, + StatusPassed: '' as IntlString, + StatusFailed: '' as IntlString, + SelectTestCase: '' as IntlString, + Save: '' as IntlString, + SaveAndNext: '' as IntlString, + DonePercent: '' as IntlString, + TestResults: '' as IntlString, + RunTestCases: '' as IntlString, + TestCaseDescription: '' as IntlString, + TestResultAttributes: '' as IntlString }, category: { TestManagement: '' as Ref @@ -171,7 +199,12 @@ export const testManagementPlugin = plugin(testManagementId, { PriorityIconPresenter: '' as AnyComponent, TestCaseStatusPresenter: '' as AnyComponent, TestSuites: '' as AnyComponent, - CreateTestSuite: '' as AnyComponent + CreateTestSuite: '' as AnyComponent, + TestRunFromSelection: '' as AnyComponent, + TestResultStatusPresenter: '' as AnyComponent, + TestResultStatusEditor: '' as AnyComponent, + TestRunResult: '' as AnyComponent, + TestResultHeader: '' as AnyComponent }, ids: { NoParent: '' as Ref, @@ -193,9 +226,9 @@ export const testManagementPlugin = plugin(testManagementId, { viewlet: { TableTestCase: '' as Ref, TableTestSuites: '' as Ref, - TableTestRun: '' as Ref, - SuiteTestCases: '' as Ref, - ListTestCase: '' as Ref + ListTestCase: '' as Ref, + TestResultList: '' as Ref, + TableTestResult: '' as Ref }, testCaseTypeStatus: { Draft: '' as Ref, @@ -207,7 +240,8 @@ export const testManagementPlugin = plugin(testManagementId, { TestCase: '' as Ref }, function: { - GetTestSuiteLink: '' as Resource<(doc: Ref) => Location> + GetTestSuiteLink: '' as Resource<(doc: Ref) => Location>, + GetTestRunLink: '' as Resource<(doc: Ref) => Location> }, resolver: { Location: '' as Resource<(loc: Location) => Promise> diff --git a/plugins/test-management/src/types.ts b/plugins/test-management/src/types.ts index 1e07df32cb..f7e19e523f 100644 --- a/plugins/test-management/src/types.ts +++ b/plugins/test-management/src/types.ts @@ -21,7 +21,7 @@ import { type Ref, type Markup, TypedSpace, - CollaborativeDoc, + MarkupBlobRef, AttachedDoc, Timestamp } from '@hcengineering/core' @@ -89,7 +89,7 @@ export interface TestSuite extends Doc { /** @public */ export interface TestCase extends AttachedDoc { name: string - description: CollaborativeDoc + description: MarkupBlobRef | null type: TestCaseType priority: TestCasePriority status: TestCaseStatus @@ -101,22 +101,28 @@ export interface TestCase extends AttachedDoc + results?: CollectionSize } /** @public */ -export enum TestRunResult { - Passed, +export enum TestRunStatus { + Untested, Blocked, + Passed, Failed } +// TODO: Refactor to associations /** @public */ -export interface TestRunItem extends AttachedDoc { - testRun: Ref +export interface TestResult extends AttachedDoc { + name: string testCase: Ref - result?: TestRunResult + testSuite?: Ref + status?: TestRunStatus + description: MarkupBlobRef | null + assignee?: Ref + attachments?: CollectionSize comments?: number } diff --git a/plugins/text-editor-assets/lang/cs.json b/plugins/text-editor-assets/lang/cs.json index 56cafd8935..fc482580e8 100644 --- a/plugins/text-editor-assets/lang/cs.json +++ b/plugins/text-editor-assets/lang/cs.json @@ -59,6 +59,7 @@ "Unset": "Zrušit nastavení", "Image": "Obrázek", "SeparatorLine": "Oddělovací čára", - "TodoList": "Akční položka" + "TodoList": "Akční položka", + "DrawingBoard": "Kreslení" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/en.json b/plugins/text-editor-assets/lang/en.json index cbc870e082..2c63a5982d 100644 --- a/plugins/text-editor-assets/lang/en.json +++ b/plugins/text-editor-assets/lang/en.json @@ -59,6 +59,7 @@ "Unset": "Unset", "Image": "Image", "SeparatorLine": "Separator line", - "TodoList": "Action item" + "TodoList": "Action item", + "DrawingBoard": "Drawing board" } } diff --git a/plugins/text-editor-assets/lang/es.json b/plugins/text-editor-assets/lang/es.json index d10e9c46cb..dd2f5d37b7 100644 --- a/plugins/text-editor-assets/lang/es.json +++ b/plugins/text-editor-assets/lang/es.json @@ -57,6 +57,7 @@ "Unset": "Anular", "Image": "Imagen", "SeparatorLine": "Línea de separación", - "TodoList": "Tarea pendiente" + "TodoList": "Tarea pendiente", + "DrawingBoard": "Tablero de dibujos" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/fr.json b/plugins/text-editor-assets/lang/fr.json index 7193686f05..3ae6e1d7f2 100644 --- a/plugins/text-editor-assets/lang/fr.json +++ b/plugins/text-editor-assets/lang/fr.json @@ -57,6 +57,7 @@ "Unset": "Non défini", "Image": "Image", "SeparatorLine": "Ligne de séparation", - "TodoList": "À faire" + "TodoList": "À faire", + "DrawingBoard": "Tableau de dessin" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/pt.json b/plugins/text-editor-assets/lang/pt.json index b6f0b47125..d043b750a3 100644 --- a/plugins/text-editor-assets/lang/pt.json +++ b/plugins/text-editor-assets/lang/pt.json @@ -57,6 +57,7 @@ "Unset": "Anular", "Image": "Imagem", "SeparatorLine": "linha separadora", - "TodoList": "Tarefa pendente" + "TodoList": "Tarefa pendente", + "DrawingBoard": "Quadro de desenho" } } diff --git a/plugins/text-editor-assets/lang/ru.json b/plugins/text-editor-assets/lang/ru.json index f197e3abe5..41aaf9e4c2 100644 --- a/plugins/text-editor-assets/lang/ru.json +++ b/plugins/text-editor-assets/lang/ru.json @@ -59,6 +59,7 @@ "Unset": "Убрать", "Image": "Изображение", "SeparatorLine": "Разделительная линия", - "TodoList": "Действие" + "TodoList": "Действие", + "DrawingBoard": "Доска" } } diff --git a/plugins/text-editor-assets/lang/zh.json b/plugins/text-editor-assets/lang/zh.json index c3def4da63..f2bbe1938e 100644 --- a/plugins/text-editor-assets/lang/zh.json +++ b/plugins/text-editor-assets/lang/zh.json @@ -59,6 +59,7 @@ "Unset": "未设置", "Image": "图片", "SeparatorLine": "分隔线", - "TodoList": "待办" + "TodoList": "待办", + "DrawingBoard": "画板" } } diff --git a/plugins/text-editor-resources/src/components/Collaboration.svelte b/plugins/text-editor-resources/src/components/Collaboration.svelte index ac9e2b4647..80c5d9637f 100644 --- a/plugins/text-editor-resources/src/components/Collaboration.svelte +++ b/plugins/text-editor-resources/src/components/Collaboration.svelte @@ -15,26 +15,32 @@ // --> -{#key _documentId} +{#key documentId} {/key} diff --git a/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte b/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte index 18cd027ba9..3742b98e3a 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte @@ -15,7 +15,7 @@ -{#if collaborativeDoc != null} - -{/if} + diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index aaa4a66110..004afa615d 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -16,13 +16,21 @@ --> + +{#if savedCmds !== undefined && savedProps !== undefined} + + { + dispatch('close') + }} + > +
{ + savedCmds.push([cmd]) + }, + panned: (offset) => { + savedProps.set('offset', offset) + } + }} + > + + + + + { + savedCmds.delete(0, savedCmds.length) + savedProps.set('offset', { x: 0, y: 0 }) + }} + /> +
+
+{/if} diff --git a/plugins/text-editor-resources/src/components/extension/drawingBoard.ts b/plugins/text-editor-resources/src/components/extension/drawingBoard.ts new file mode 100644 index 0000000000..c1476599cb --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/drawingBoard.ts @@ -0,0 +1,276 @@ +// +// 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 DrawingCmd, type DrawingProps, drawing } from '@hcengineering/presentation' +import { showPopup } from '@hcengineering/ui' +import { type Editor, mergeAttributes, Node } from '@tiptap/core' +import { NodeSelection } from '@tiptap/pm/state' +import type { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs' +import DrawingBoardPopup from '../DrawingBoardPopup.svelte' + +const defaultHeight = 500 +const maxHeight = 1000 +const minHeight = 100 + +export interface DrawingBoardOptions { + ydoc?: YDoc +} + +const scribbleSvg = ` + +` +const chevronSvg = ` + +` + +interface SavedBoard { + commands: YArray + props: YMap +} + +function getSavedBoard (ydoc: YDoc | undefined, id: string): SavedBoard { + const board = ydoc?.getMap(`drawing-board-${id}`) + const commands = board?.get('commands') as YArray + const props = board?.get('props') as YMap + return { commands, props } +} + +function showBoardPopup (board: SavedBoard, editor: Editor): void { + if (board.commands !== undefined && board.props !== undefined) { + showPopup( + DrawingBoardPopup, + { + savedCmds: board.commands, + savedProps: board.props + }, + 'centered', + () => { + editor.commands.focus() + } + ) + } +} + +declare module '@tiptap/core' { + interface Commands { + drawingBoard: { + showDrawingBoardPopup: () => ReturnType + } + } +} + +export const DrawingBoardExtension = Node.create({ + name: 'drawingBoard', + + group: 'block', + + renderHTML ({ HTMLAttributes }) { + return ['div', mergeAttributes({ 'data-type': this.name }, HTMLAttributes)] + }, + + addOptions () { + return { + ydoc: undefined + } + }, + + addAttributes () { + return { + id: { + renderHTML: (attrs) => ({ 'data-id': attrs.id }) + }, + height: { + renderHTML: (attrs) => ({ 'data-height': attrs.height }) + } + } + }, + + addCommands () { + return { + showDrawingBoardPopup: + () => + ({ state }) => { + if (state.selection instanceof NodeSelection) { + const node = state.selection.node + if (node?.type.name === this.name) { + const board = getSavedBoard(this.options.ydoc, node.attrs.id) + showBoardPopup(board, this.editor) + return true + } + } + return false + } + } + }, + + addKeyboardShortcuts () { + return { + Enter: () => this.editor.commands.showDrawingBoardPopup() + } + }, + + addNodeView () { + return ({ node, getPos }) => { + const board = getSavedBoard(this.options.ydoc, node.attrs.id) + if (board.commands === undefined || board.props === undefined) { + return {} + } + + const normalBorder = '1px solid var(--theme-navpanel-border)' + const selectedBorder = '1px solid var(--theme-editbox-focus-border)' + + const dom = document.createElement('div') + dom.id = node.attrs.id + dom.contentEditable = 'false' + dom.style.width = '100%' + dom.style.position = 'relative' + dom.style.height = `${node.attrs.height ?? defaultHeight}px` + dom.style.border = normalBorder + dom.style.borderRadius = 'var(--small-BorderRadius)' + dom.style.backgroundColor = 'var(--theme-navpanel-color)' + dom.ondblclick = () => { + showBoardPopup(board, this.editor) + } + + const drawingProps: DrawingProps = { + readonly: true, + autoSize: true, + commands: board.commands.toArray(), + commandCount: board.commands.length, + offset: board.props.get('offset') + } + + const { canvas, update: updateDrawing } = drawing(dom, drawingProps) + if (canvas === undefined || updateDrawing === undefined) { + return {} + } + dom.appendChild(canvas) + + const listenSavedCommands = (): void => { + let update = false + if (board.commands.length === 0) { + update = true + drawingProps.commands = [] + } else if (board.commands.length > drawingProps.commands.length) { + update = true + for (let i = drawingProps.commands.length; i < board.commands.length; i++) { + drawingProps.commands.push(board.commands.get(i)) + } + } + if (update) { + drawingProps.commandCount = board.commands.length + updateDrawing(drawingProps) + } + } + const listenSavedProps = (): void => { + drawingProps.offset = board.props.get('offset') + updateDrawing(drawingProps) + } + board.commands.observe(listenSavedCommands) + board.props.observe(listenSavedProps) + + const button = document.createElement('div') + button.style.visibility = 'hidden' + button.style.cursor = 'pointer' + button.style.position = 'absolute' + button.style.top = '0.3rem' + button.style.right = '0.3rem' + button.style.width = '2.2rem' + button.style.height = '2.2rem' + button.style.color = 'var(--primary-button-color)' + button.style.backgroundColor = 'var(--primary-button-default)' + button.style.borderRadius = 'var(--extra-small-BorderRadius)' + button.style.border = normalBorder + button.style.display = 'flex' + button.style.alignItems = 'center' + button.style.justifyContent = 'center' + button.innerHTML = scribbleSvg + button.onclick = () => { + showBoardPopup(board, this.editor) + } + dom.appendChild(button) + + const resizer = document.createElement('div') + resizer.style.position = 'absolute' + resizer.style.bottom = '0' + resizer.style.left = 'calc(50% - 4rem)' + resizer.style.width = '8rem' + resizer.style.height = '0.6rem' + resizer.style.cursor = 'row-resize' + resizer.style.color = 'var(--global-on-accent-TextColor)' + resizer.style.backgroundColor = 'var(--global-accent-IconColor)' + resizer.style.border = selectedBorder + resizer.style.display = 'flex' + resizer.style.alignItems = 'center' + resizer.style.justifyContent = 'center' + resizer.style.visibility = 'hidden' + resizer.style.borderTopLeftRadius = 'var(--small-BorderRadius)' + resizer.style.borderTopRightRadius = 'var(--small-BorderRadius)' + resizer.style.borderBottom = 'none' + resizer.style.opacity = '0.5' + resizer.innerHTML = chevronSvg + resizer.onpointerenter = () => { + resizer.style.opacity = '1' + } + resizer.onpointerleave = () => { + resizer.style.opacity = '0.5' + } + resizer.onpointerdown = (e: PointerEvent): void => { + e.preventDefault() + resizer.setPointerCapture(e.pointerId) + const { offsetHeight } = dom + const startY = e.clientY - offsetHeight + let height = node.attrs.height ?? defaultHeight + const onPointerMove = (e: PointerEvent): void => { + e.preventDefault() + height = Math.max(minHeight, e.clientY - startY) + height = Math.min(maxHeight, height) + dom.style.height = `${height}px` + } + const onPointerUp = (e: PointerEvent): void => { + e.preventDefault() + resizer.releasePointerCapture(e.pointerId) + if (typeof getPos === 'function') { + const tr = this.editor.state.tr.setNodeMarkup(getPos(), undefined, { ...node.attrs, height }) + this.editor.view.dispatch(tr) + } + dom.removeEventListener('pointermove', onPointerMove) + dom.removeEventListener('pointerup', onPointerUp) + } + dom.addEventListener('pointermove', onPointerMove) + dom.addEventListener('pointerup', onPointerUp) + } + dom.appendChild(resizer) + + return { + dom, + selectNode: () => { + dom.style.border = selectedBorder + button.style.visibility = 'visible' + resizer.style.visibility = 'visible' + }, + deselectNode: () => { + dom.style.border = normalBorder + button.style.visibility = 'hidden' + resizer.style.visibility = 'hidden' + }, + destroy: () => { + board.commands.unobserve(listenSavedCommands) + board.props.unobserve(listenSavedProps) + } + } + } + } +}) diff --git a/plugins/text-editor-resources/src/components/extensions.ts b/plugins/text-editor-resources/src/components/extensions.ts index 8eada21dbf..c69ab8ef56 100644 --- a/plugins/text-editor-resources/src/components/extensions.ts +++ b/plugins/text-editor-resources/src/components/extensions.ts @@ -16,6 +16,7 @@ import view from '@hcengineering/view' import { type Editor, type Range } from '@tiptap/core' import textEditor from '@hcengineering/text-editor' +import { IconScribble } from '@hcengineering/ui' import { type CompletionOptions } from '../Completion' import MentionList from './MentionList.svelte' @@ -147,7 +148,8 @@ export function inlineCommandsConfig ( { id: 'table', label: textEditor.string.Table, icon: view.icon.Table2 }, { id: 'code-block', label: textEditor.string.CodeBlock, icon: view.icon.CodeBlock }, { id: 'separator-line', label: textEditor.string.SeparatorLine, icon: view.icon.SeparatorLine }, - { id: 'todo-list', label: textEditor.string.TodoList, icon: view.icon.TodoList } + { id: 'todo-list', label: textEditor.string.TodoList, icon: view.icon.TodoList }, + { id: 'drawing-board', label: textEditor.string.DrawingBoard, icon: IconScribble as any } ].filter(({ id }) => !excludedCommands.includes(id as InlineCommandId)) }, command: ({ editor, range, props }: { editor: Editor, range: Range, props: any }) => { diff --git a/plugins/text-editor-resources/src/provider/cloud.ts b/plugins/text-editor-resources/src/provider/cloud.ts index 4754dafc07..76136f1590 100644 --- a/plugins/text-editor-resources/src/provider/cloud.ts +++ b/plugins/text-editor-resources/src/provider/cloud.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // +import { type Blob, type Ref } from '@hcengineering/core' import { type Doc as YDoc } from 'yjs' import { WebsocketProvider } from 'y-websocket' @@ -23,15 +24,16 @@ export interface DatalakeCollabProviderParameters { token: string document: YDoc + content: Ref | null } export class CloudCollabProvider extends WebsocketProvider implements Provider { readonly loaded: Promise - constructor (params: DatalakeCollabProviderParameters) { - const { document, url, name } = params + constructor ({ document, url, name, content }: DatalakeCollabProviderParameters) { + const params = content != null ? { content } : undefined - super(url, encodeURIComponent(name), document) + super(url, encodeURIComponent(name), document, { params }) this.loaded = new Promise((resolve) => { this.on('synced', resolve) diff --git a/plugins/text-editor-resources/src/provider/hocuspocus.ts b/plugins/text-editor-resources/src/provider/hocuspocus.ts index 72ce9ee446..4e711eef72 100644 --- a/plugins/text-editor-resources/src/provider/hocuspocus.ts +++ b/plugins/text-editor-resources/src/provider/hocuspocus.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client' +import { type Blob, type Ref } from '@hcengineering/core' import { HocuspocusProvider, type HocuspocusProviderConfiguration } from '@hocuspocus/provider' import { type Provider } from './types' @@ -23,8 +23,7 @@ Omit & { } export interface HocuspocusCollabProviderURLParameters { - initialContentId?: DocumentId - platformDocumentId?: PlatformDocumentId + content: Ref | null } export class HocuspocusCollabProvider extends HocuspocusProvider implements Provider { @@ -33,14 +32,9 @@ export class HocuspocusCollabProvider extends HocuspocusProvider implements Prov constructor (configuration: HocuspocusCollabProviderConfiguration) { const parameters: Record = {} - const initialContentId = configuration.parameters?.initialContentId - if (initialContentId !== undefined && initialContentId !== '') { - parameters.initialContentId = initialContentId - } - - const platformDocumentId = configuration.parameters?.platformDocumentId - if (platformDocumentId !== undefined && platformDocumentId !== '') { - parameters.platformDocumentId = platformDocumentId + const content = configuration.parameters?.content + if (content !== null && content !== '') { + parameters.content = content } const hocuspocusConfig: HocuspocusProviderConfiguration = { diff --git a/plugins/text-editor-resources/src/provider/utils.ts b/plugins/text-editor-resources/src/provider/utils.ts index 9222c59a82..f075289587 100644 --- a/plugins/text-editor-resources/src/provider/utils.ts +++ b/plugins/text-editor-resources/src/provider/utils.ts @@ -13,13 +13,8 @@ // limitations under the License. // -import { type Ref, type CollaborativeDoc, type Doc, type Class, generateId } from '@hcengineering/core' -import { - type DocumentId, - type PlatformDocumentId, - formatDocumentId, - formatPlatformDocumentId as origFormatPlatformDocumentId -} from '@hcengineering/collaborator-client' +import { type Blob, type CollaborativeDoc, type Ref, generateId } from '@hcengineering/core' +import { encodeDocumentId } from '@hcengineering/collaborator-client' import { getMetadata } from '@hcengineering/platform' import presentation from '@hcengineering/presentation' import textEditor from '@hcengineering/text-editor' @@ -30,54 +25,30 @@ import { HocuspocusCollabProvider } from './hocuspocus' import { IndexeddbProvider } from './indexeddb' import { type Provider } from './types' -export function formatCollaborativeDocumentId (collaborativeDoc: CollaborativeDoc): DocumentId { +function getDocumentId (doc: CollaborativeDoc): string { const workspace = getMetadata(presentation.metadata.WorkspaceId) ?? '' - return formatDocumentId(workspace, collaborativeDoc) + return encodeDocumentId(workspace, doc) } -export function formatPlatformDocumentId ( - objectClass: Ref>, - objectId: Ref, - objectAttr: string -): PlatformDocumentId { - return origFormatPlatformDocumentId(objectClass, objectId, objectAttr) -} - -export function createLocalProvider (ydoc: Ydoc, document: CollaborativeDoc): Provider { - const documentId = formatCollaborativeDocumentId(document) +export function createLocalProvider (ydoc: Ydoc, doc: CollaborativeDoc): Provider { + const documentId = getDocumentId(doc) return new IndexeddbProvider(documentId, ydoc) } -export function createRemoteProvider ( - ydoc: Ydoc, - params: { - document: CollaborativeDoc - initialDocument?: CollaborativeDoc - objectClass?: Ref> - objectId?: Ref - objectAttr?: string - } -): Provider { +export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content: Ref | null): Provider { const collaborator = getMetadata(textEditor.metadata.Collaborator) const token = getMetadata(presentation.metadata.Token) ?? '' const collaboratorUrl = getMetadata(presentation.metadata.CollaboratorUrl) ?? '' - const documentId = formatCollaborativeDocumentId(params.document) - const initialContentId = - params.initialDocument !== undefined ? formatCollaborativeDocumentId(params.initialDocument) : undefined - - const { objectClass, objectId, objectAttr } = params - const platformDocumentId = - objectClass !== undefined && objectId !== undefined && objectAttr !== undefined - ? formatPlatformDocumentId(objectClass, objectId, objectAttr) - : undefined + const documentId = getDocumentId(doc) return collaborator === 'cloud' ? new CloudCollabProvider({ url: collaboratorUrl, name: documentId, document: ydoc, + content, token }) : new HocuspocusCollabProvider({ @@ -85,23 +56,17 @@ export function createRemoteProvider ( name: documentId, document: ydoc, token, - parameters: { - initialContentId, - platformDocumentId - } + parameters: { content } }) } -export const createTiptapCollaborationData = (params: { - document: CollaborativeDoc - initialDocument?: CollaborativeDoc - objectClass?: Ref> - objectId?: Ref - objectAttr?: string -}): { provider: Provider, ydoc: Ydoc } => { +export const createTiptapCollaborationData = ( + doc: CollaborativeDoc, + content: Ref | null +): { provider: Provider, ydoc: Ydoc } => { const ydoc: Ydoc = new Ydoc({ guid: generateId() }) return { ydoc, - provider: createRemoteProvider(ydoc, params) + provider: createRemoteProvider(ydoc, doc, content) } } diff --git a/plugins/text-editor/src/plugin.ts b/plugins/text-editor/src/plugin.ts index 66fc794368..93e52d3bcf 100644 --- a/plugins/text-editor/src/plugin.ts +++ b/plugins/text-editor/src/plugin.ts @@ -93,7 +93,8 @@ export default plugin(textEditorId, { Unset: '' as IntlString, Image: '' as IntlString, SeparatorLine: '' as IntlString, - TodoList: '' as IntlString + TodoList: '' as IntlString, + DrawingBoard: '' as IntlString }, icon: { Header1: '' as Asset, diff --git a/plugins/time-resources/src/components/ToDos.svelte b/plugins/time-resources/src/components/ToDos.svelte index 81dde73f4f..74d9bb2387 100644 --- a/plugins/time-resources/src/components/ToDos.svelte +++ b/plugins/time-resources/src/components/ToDos.svelte @@ -29,6 +29,8 @@ Header, ButtonIcon, Label, + IconMenuOpen, + IconMenuClose, deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { getCurrentAccount, toIdMap, SortingOrder } from '@hcengineering/core' @@ -39,8 +41,6 @@ import { getNearest } from '../utils' import CreateToDo from './CreateToDo.svelte' import ToDoGroup from './ToDoGroup.svelte' - import MenuClose from './icons/MenuClose.svelte' - import MenuOpen from './icons/MenuOpen.svelte' import time from '../plugin' export let mode: ToDosMode @@ -292,7 +292,7 @@
@@ -209,7 +210,7 @@ float={$deviceInfo.navigator.float ? 'navigator' : true} index={0} disabledWhen={['panel-aside']} - color={'var(--theme-navpanel-border)'} + color={'var(--theme-divider-color)'} />
diff --git a/plugins/time-resources/src/components/team/TeamNavigator.svelte b/plugins/time-resources/src/components/team/TeamNavigator.svelte index 01f406a5bb..b5ef38cd86 100644 --- a/plugins/time-resources/src/components/team/TeamNavigator.svelte +++ b/plugins/time-resources/src/components/team/TeamNavigator.svelte @@ -16,13 +16,11 @@ import { Ref, getCurrentAccount } from '@hcengineering/core' import { createQuery } from '@hcengineering/presentation' import task, { Project } from '@hcengineering/task' - import { Label, Separator, Scroller, NavItem } from '@hcengineering/ui' + import { Label, Separator, Scroller, NavItem, deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { ObjectPresenter, TreeNode } from '@hcengineering/view-resources' import { NavFooter, NavHeader } from '@hcengineering/workbench-resources' import time from '../../plugin' - export let navFloat: boolean = false - export let appsDirection: 'vertical' | 'horizontal' = 'horizontal' export let selected: Ref | undefined = (localStorage.getItem('team_last_mode') as Ref) ?? undefined let projects: Project[] = [] @@ -41,7 +39,10 @@ $: selectedItem = projects.find((pr) => pr._id === selected) -
+
@@ -89,9 +90,9 @@
diff --git a/plugins/tracker-resources/src/components/CreateIssue.svelte b/plugins/tracker-resources/src/components/CreateIssue.svelte index 441b7fc067..292c78aba9 100644 --- a/plugins/tracker-resources/src/components/CreateIssue.svelte +++ b/plugins/tracker-resources/src/components/CreateIssue.svelte @@ -27,7 +27,8 @@ SortingOrder, fillDefaults, generateId, - makeCollaborativeDoc, + makeCollabId, + makeDocCollabId, toIdMap } from '@hcengineering/core' import { getResource, translate } from '@hcengineering/platform' @@ -41,15 +42,15 @@ MessageBox, MultipleDraftController, SpaceSelector, + createMarkup, createQuery, getClient, - getMarkup, - updateMarkup + getMarkup } from '@hcengineering/presentation' import tags, { TagElement, TagReference } from '@hcengineering/tags' import { TaskType, makeRank } from '@hcengineering/task' import { TaskKindSelector } from '@hcengineering/task-resources' - import { EmptyMarkup } from '@hcengineering/text' + import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' import { Component as ComponentType, Issue, @@ -202,8 +203,8 @@ parentIssue: originalIssue.parents[0]?.parentId, title: `${originalIssue.title} (copy)` } - void getMarkup(originalIssue.description).then((res) => { - object.description = res.description + void getMarkup(makeDocCollabId(originalIssue, 'description'), originalIssue.description).then((res) => { + object.description = res }) void client.findAll(tags.class.TagReference, { attachedTo: originalIssue._id }).then((p) => { object.labels = p @@ -476,7 +477,7 @@ const value: DocData = { title: getTitle(object.title), - description: makeCollaborativeDoc(_id, 'description'), + description: null, assignee: object.assignee, component: object.component, milestone: object.milestone, @@ -509,7 +510,10 @@ identifier } - await updateMarkup(value.description, { description: object.description }) + if (!isEmptyMarkup(object.description)) { + const collabId = makeCollabId(tracker.class.Issue, _id, 'description') + value.description = await createMarkup(collabId, object.description) + } await docCreateManager.commit(operations, _id, currentProject, value, 'pre') diff --git a/plugins/tracker-resources/src/components/SubIssues.svelte b/plugins/tracker-resources/src/components/SubIssues.svelte index db9569b58e..4b2cb8cd5e 100644 --- a/plugins/tracker-resources/src/components/SubIssues.svelte +++ b/plugins/tracker-resources/src/components/SubIssues.svelte @@ -14,10 +14,11 @@ --> {#if issue} @@ -92,11 +96,11 @@
- {#if issue.description} + {#if descriptionKey}
limit} style:max-height={`${limit}px`}>
(cHeight = element.clientHeight)}> {#key issue._id} - + {/key}
diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index fd1f78ec9d..086587f69e 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -51,7 +51,6 @@ import ProjectComponents from './components/components/ProjectComponents.svelte' import CreateIssue from './components/CreateIssue.svelte' import EditRelatedTargets from './components/EditRelatedTargets.svelte' import EditRelatedTargetsPopup from './components/EditRelatedTargetsPopup.svelte' -import Inbox from './components/inbox/Inbox.svelte' import AssigneeEditor from './components/issues/AssigneeEditor.svelte' import DueDatePresenter from './components/issues/DueDatePresenter.svelte' import EditIssue from './components/issues/edit/EditIssue.svelte' @@ -582,7 +581,6 @@ export default async (): Promise => ({ component: { NopeComponent, Issues, - Inbox, MyIssues, Components, IssuePresenter, diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index b7be6e83be..09330b2801 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -305,7 +305,6 @@ export default mergeIds(trackerId, tracker, { }, component: { NopeComponent: '' as AnyComponent, - Inbox: '' as AnyComponent, MyIssues: '' as AnyComponent, Views: '' as AnyComponent, Issues: '' as AnyComponent, diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index 3ba06ba655..bec155e700 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -18,7 +18,7 @@ import { AttachedDoc, Attribute, Class, - CollaborativeDoc, + MarkupBlobRef, CollectionSize, Data, Doc, @@ -183,7 +183,7 @@ export interface Milestone extends Doc { export interface Issue extends Task { attachedTo: Ref title: string - description: CollaborativeDoc + description: MarkupBlobRef | null status: Ref priority: IssuePriority diff --git a/plugins/view-resources/src/__tests__/folderUtils.test.ts b/plugins/view-resources/src/__tests__/folderUtils.test.ts new file mode 100644 index 0000000000..1ce0c18082 --- /dev/null +++ b/plugins/view-resources/src/__tests__/folderUtils.test.ts @@ -0,0 +1,183 @@ +// 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 Doc } from '@hcengineering/core' + +import { FoldersManager, emptyFoldersState, type FoldersState } from '../components/folders/store/folderUtils' + +describe('foldersManager', () => { + test('folders tree', () => { + const docs: Doc[] = [ + { + name: 'Child 1', + description: '', + parent: '673ef344595d5369f68ced98', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732178763949, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732178763949, + docUpdateMessages: 1, + _id: '673ef34b595d5369f68ceda2', + space: '673434ed74993781b4195e02' + }, + { + name: 'Child 2', + description: '', + parent: '673ef344595d5369f68ced98', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732178770252, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732178770252, + docUpdateMessages: 1, + _id: '673ef352595d5369f68cedac', + space: '673434ed74993781b4195e02' + }, + { + _id: '673434f674993781b4195e1b', + name: 'New suite', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731495620083, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731474678014, + docUpdateMessages: 2, + testCases: 1, + space: '673434ed74993781b4195e02' + }, + { + name: 'Parent', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732178756247, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732178756247, + docUpdateMessages: 1, + _id: '673ef344595d5369f68ced98', + space: '673434ed74993781b4195e02' + }, + { + _id: '6734863403ae9eca5b01e4fd', + name: 'test', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731495529218, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731495476678, + docUpdateMessages: 4, + testCases: 3, + space: '673434ed74993781b4195e02' + } + ] as any + const foldersManager = new FoldersManager('name', 'parent', 'testManagement:ids:NoParent' as any, false, () => {}) + const foldersState = foldersManager.getNestedStructure(docs) + + expect(foldersState.folders.length).toBe(3) + expect(foldersState.folderById.size).toBe(5) + expect(foldersState.descendants.get('673ef344595d5369f68ced98' as any)?.length).toBe(2) + }) + + test('plain list', () => { + const docs: Doc[] = [ + { + _id: '6739541d04aed0096599e03a', + name: '3 items', + description: '6739541704aed0096599e032%description:HEAD:0', + _class: 'testManagement:class:TestRun', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731810333365, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731810333307, + docUpdateMessages: 4, + testCases: 3, + space: '673434ed74993781b4195e02' + }, + { + name: 'Test run 1', + description: '673ef510595d5369f68cee0a%description:HEAD:0', + _class: 'testManagement:class:TestRun', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732179220778, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732179220778, + testCases: 2, + docUpdateMessages: 3, + _id: '673ef514595d5369f68cee12', + space: '673434ed74993781b4195e02' + }, + { + name: 'Test run 2', + description: '673ef516595d5369f68cee18%description:HEAD:0', + _class: 'testManagement:class:TestRun', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732179227645, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732179227611, + testCases: 2, + docUpdateMessages: 3, + _id: '673ef51b595d5369f68cee20', + space: '673434ed74993781b4195e02' + } + ] as any + const foldersManager = new FoldersManager('name', 'parent', 'noParent' as any, true, () => {}) + const foldersState = foldersManager.getPlainList(docs) + + expect(foldersState.folders.length).toBe(3) + expect(foldersState.folderById.size).toBe(3) + expect(foldersState.descendants.size).toBe(0) + }) + + test('test state update', () => { + const docs: Doc[] = [ + { + _id: '673434f674993781b4195e1b', + name: 'New suite', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731495620083, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731474678014, + docUpdateMessages: 2, + testCases: 1, + space: '673434ed74993781b4195e02' + } + ] as any + let folderState = emptyFoldersState() + + expect(folderState.folders.length).toBe(0) + expect(folderState.descendants.size).toBe(0) + expect(folderState.folderById.size).toBe(0) + + const foldersManager = new FoldersManager( + 'name', + 'parent', + 'testManagement:ids:NoParent' as any, + false, + (state: FoldersState) => { + folderState = state + } + ) + foldersManager.setFolders(docs) + + expect(folderState.folders.length).toBe(1) + expect(folderState.folderById.size).toBe(1) + }) +}) diff --git a/plugins/view-resources/src/components/ObjectBox.svelte b/plugins/view-resources/src/components/ObjectBox.svelte index 47ab463546..158de7dbfc 100644 --- a/plugins/view-resources/src/components/ObjectBox.svelte +++ b/plugins/view-resources/src/components/ObjectBox.svelte @@ -27,13 +27,13 @@ LabelAndProps, getEventPositionElement, getFocusManager, - showPanel, showPopup } from '@hcengineering/ui' import view from '@hcengineering/view' import { createEventDispatcher } from 'svelte' import ObjectBoxPopup from './ObjectBoxPopup.svelte' import ObjectPresenter from './ObjectPresenter.svelte' + import { openDoc } from '../utils' export let _class: Ref> export let excluded: Array> | undefined = undefined @@ -181,7 +181,7 @@ size={'small'} action={() => { if (selected) { - showPanel(view.component.EditDoc, selected._id, Hierarchy.mixinOrClass(selected), 'content') + void openDoc(client.getHierarchy(), selected) } }} /> diff --git a/plugins/view-resources/src/components/FolderTreeLevel.svelte b/plugins/view-resources/src/components/folders/FolderTreeLevel.svelte similarity index 99% rename from plugins/view-resources/src/components/FolderTreeLevel.svelte rename to plugins/view-resources/src/components/folders/FolderTreeLevel.svelte index d8eb2bff9e..96f0ada0e7 100644 --- a/plugins/view-resources/src/components/FolderTreeLevel.svelte +++ b/plugins/view-resources/src/components/folders/FolderTreeLevel.svelte @@ -19,7 +19,7 @@ import { Action, IconEdit } from '@hcengineering/ui' import { getResource } from '@hcengineering/platform' - import { TreeItem, getActions as getContributedActions } from '../index' + import { TreeItem, getActions as getContributedActions } from '../../index' export let folders: Ref[] export let folderById: Map, Doc> diff --git a/plugins/view-resources/src/components/FoldersBrowser.svelte b/plugins/view-resources/src/components/folders/FoldersBrowser.svelte similarity index 53% rename from plugins/view-resources/src/components/FoldersBrowser.svelte rename to plugins/view-resources/src/components/folders/FoldersBrowser.svelte index 182a8cbc0b..0995e1f6fc 100644 --- a/plugins/view-resources/src/components/FoldersBrowser.svelte +++ b/plugins/view-resources/src/components/folders/FoldersBrowser.svelte @@ -16,13 +16,13 @@ - getRootActions()} - {forciblyСollapsed} - on:click={handleAllItemsSelected} - > + {#if noParentId !== undefined} + getRootActions()} + {forciblyСollapsed} + on:click={handleAllItemsSelected} + > + { + await handleFolderSelected(ev.detail) + }} + /> + + {#if (selected || forciblyСollapsed) && visibleItem !== undefined} + {@const folder = visibleItem} + await getFolderActions(folder)} + shouldTooltip + forciblyСollapsed + /> + {/if} + + + {:else} { - handleFolderSelected(ev.detail) + on:selected={async (ev) => { + await handleFolderSelected(ev.detail) }} /> - - {#if (selected || forciblyСollapsed) && visibleItem} - {@const folder = visibleItem} - await getFolderActions(folder)} - shouldTooltip - forciblyСollapsed - /> - {/if} - - + {/if} diff --git a/plugins/view-resources/src/components/folders/store/folderStore.ts b/plugins/view-resources/src/components/folders/store/folderStore.ts new file mode 100644 index 0000000000..511be91c6d --- /dev/null +++ b/plugins/view-resources/src/components/folders/store/folderStore.ts @@ -0,0 +1,38 @@ +// 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 Doc, type Ref } from '@hcengineering/core' +import { writable, type Writable } from 'svelte/store' + +import { type FoldersState, FoldersManager, emptyFoldersState } from './folderUtils' + +export { type FoldersState, emptyFoldersState } + +export const FoldersStore: Writable = writable(emptyFoldersState()) + +export const SelectedFolderStore: Writable | undefined> = writable(undefined) + +export function setSelectedFolder (_id: Ref | undefined): void { + SelectedFolderStore.set(_id) +} + +export function getFoldersManager ( + titleKey: string, + parentKey: string, + noParentId: Ref, + plainList: boolean +): FoldersManager { + return new FoldersManager(titleKey, parentKey, noParentId, plainList, (state: FoldersState) => { + FoldersStore.set(state) + }) +} diff --git a/plugins/view-resources/src/stores/folderStore.ts b/plugins/view-resources/src/components/folders/store/folderUtils.ts similarity index 55% rename from plugins/view-resources/src/stores/folderStore.ts rename to plugins/view-resources/src/components/folders/store/folderUtils.ts index 5de7347c60..fea2297e23 100644 --- a/plugins/view-resources/src/stores/folderStore.ts +++ b/plugins/view-resources/src/components/folders/store/folderUtils.ts @@ -12,41 +12,40 @@ // limitations under the License. import { type Doc, type Ref } from '@hcengineering/core' -import { writable, type Writable } from 'svelte/store' -export class FoldersState { - folders: Array> - folderById: Map, Doc> - descendants: Map, Doc[]> - - constructor (folders: Array>, folderById: Map, Doc>, descendants: Map, Doc[]>) { - this.folders = folders - this.folderById = folderById - this.descendants = descendants - } - - static empty (): FoldersState { - return new FoldersState([], new Map, Doc>(), new Map, Doc[]>()) - } +export interface FoldersState { + readonly folders: Array> + readonly folderById: Map, Doc> + readonly descendants: Map, Doc[]> } -export const FoldersStore: Writable = writable(FoldersState.empty()) - -export const SelectedFolderStore: Writable | undefined> = writable(undefined) - -export function setSelectedFolder (_id: Ref | undefined): void { - SelectedFolderStore.set(_id) +export function emptyFoldersState (): FoldersState { + return { + folders: [], + folderById: new Map, Doc>(), + descendants: new Map, Doc[]>() + } } export class FoldersManager { titleKey: string parentKey: string noParentId: Ref + plainList: boolean + updateStore: (state: FoldersState) => void - constructor (titleKey: string, parentKey: string, noParentId: Ref) { + constructor ( + titleKey: string, + parentKey: string, + noParentId: Ref, + plainList: boolean, + updateStore: (state: FoldersState) => void + ) { this.titleKey = titleKey this.parentKey = parentKey this.noParentId = noParentId + this.plainList = plainList + this.updateStore = updateStore } public getTitle (doc: Doc): string { @@ -64,16 +63,17 @@ export class FoldersManager { } public setFolders (result: Doc[]): void { + const newState: FoldersState = this.plainList ? this.getPlainList(result) : this.getNestedStructure(result) + this.updateStore(newState) + } + + public getNestedStructure (result: Doc[]): FoldersState { let folders: Array> = [] const folderById: Map, Doc> = new Map, Doc>() const descendants: Map, Doc[]> = new Map, Doc[]>() for (const doc of result) { - const mappedDoc = { - title: this.getTitle(doc), - parent: this.getParent(doc), - ...doc - } + const mappedDoc = this.mapDoc(doc) const current = descendants.get(this.getParent(mappedDoc)) ?? [] current.push(mappedDoc) descendants.set(this.getParent(mappedDoc), current) @@ -81,6 +81,31 @@ export class FoldersManager { } folders = this.getDescendants(descendants, this.noParentId) - FoldersStore.set(new FoldersState(folders, folderById, descendants)) + return { folders, folderById, descendants } + } + + public getPlainList (result: Doc[]): FoldersState { + const folderById: Map, Doc> = new Map, Doc>() + + for (const doc of result) { + const mappedDoc = this.mapDoc(doc) + folderById.set(mappedDoc._id, mappedDoc) + } + const folders: Array> = result.map((doc) => doc._id) + + return { + folders, + folderById, + descendants: new Map, Doc[]>() + } + } + + private mapDoc (doc: Doc): Doc { + const mappedDoc = { + title: this.getTitle(doc), + parent: this.getParent(doc), + ...doc + } + return mappedDoc } } diff --git a/plugins/view-resources/src/components/list/ListItem.svelte b/plugins/view-resources/src/components/list/ListItem.svelte index c3f162fb57..fb4a0705f3 100644 --- a/plugins/view-resources/src/components/list/ListItem.svelte +++ b/plugins/view-resources/src/components/list/ListItem.svelte @@ -80,6 +80,8 @@ } $: mobile = $deviceInfo.isMobile + $: needCompact = + model.filter((m) => m.displayProps?.optional || m.displayProps?.compression || m.displayProps?.suffix).length > 0 onMount(() => { dispatch('on-mount') @@ -191,7 +193,7 @@ {/if} {/if} {/each} - {#if compactMode} + {#if compactMode && needCompact}
diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index bb099c3cc9..ddf8b5ce41 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -101,7 +101,7 @@ import ImageViewer from './components/viewer/ImageViewer.svelte' import VideoViewer from './components/viewer/VideoViewer.svelte' import PDFViewer from './components/viewer/PDFViewer.svelte' import TextViewer from './components/viewer/TextViewer.svelte' -import FoldersBrowser from './components/FoldersBrowser.svelte' +import FoldersBrowser from './components/folders/FoldersBrowser.svelte' import { blobImageMetadata, blobVideoMetadata } from './blob' @@ -167,6 +167,8 @@ export { default as List } from './components/list/List.svelte' export { default as NavLink } from './components/navigator/NavLink.svelte' export { default as StatusPresenter } from './components/status/StatusPresenter.svelte' export { default as StatusRefPresenter } from './components/status/StatusRefPresenter.svelte' +export { default as FoldersBrowser } from './components/folders/FoldersBrowser.svelte' +export { default as ListView } from './components/list/ListView.svelte' export * from './filter' export * from './middleware' @@ -189,6 +191,7 @@ export { } from './utils' export * from './viewOptions' export { + ArrayEditor, BooleanEditor, BooleanPresenter, ClassAttributeBar, diff --git a/plugins/workbench-resources/src/components/ComponentNavigator.svelte b/plugins/workbench-resources/src/components/ComponentNavigator.svelte index bb381a7930..84d5d3c7b8 100644 --- a/plugins/workbench-resources/src/components/ComponentNavigator.svelte +++ b/plugins/workbench-resources/src/components/ComponentNavigator.svelte @@ -22,6 +22,9 @@ Breadcrumb, Component, IconAdd, + IconMenuOpen, + IconMenuClose, + ButtonIcon, Header, Separator, showPopup, @@ -29,7 +32,8 @@ resolvedLocationStore, deviceOptionsStore as deviceInfo, defineSeparators, - workbenchSeparators + twoPanelsSeparators, + resizeObserver } from '@hcengineering/ui' import { Doc, DocumentQuery, Ref, Space, mergeQueries } from '@hcengineering/core' import { IntlString, Asset } from '@hcengineering/platform' @@ -43,11 +47,15 @@ export let createComponentProps: Record = {} export let mainComponentLabel: IntlString export let mainComponentIcon: Asset | undefined = undefined + export let mainHeaderComponent: AnyComponent | undefined = undefined export let query: DocumentQuery = {} export let syncWithLocationQuery: boolean = true export let mainComponent: AnyComponent | AnySvelteComponent export let mainComponentProps = {} + const FLOAT_LIMIT = 760 + let container: HTMLDivElement + let locationQuery: DocumentQuery = {} let resultQuery: DocumentQuery = {} let spaceQuery: DocumentQuery = {} @@ -67,24 +75,60 @@ if (createComponent === undefined) return showPopup(createComponent, { ...createComponentProps, space }, 'top') } - defineSeparators('parentsNavigator', workbenchSeparators) + + defineSeparators('parentsNavigator', twoPanelsSeparators) + + const getVisibleNavigator = (): boolean => !(localStorage.getItem('componentNavigator') === 'hidden') + let visibleNavigator: boolean = getVisibleNavigator() + let floatNavigator: boolean = false + + const toggleNavigator = (): void => { + visibleNavigator = !visibleNavigator + if (visibleNavigator) { + if (!floatNavigator && container.getBoundingClientRect().width < FLOAT_LIMIT) { + floatNavigator = true + } + localStorage.removeItem('componentNavigator') + } else localStorage.setItem('componentNavigator', 'hidden') + } -
- {#if $deviceInfo.navigator.visible} +
{ + if (visibleNavigator && !floatNavigator && element.clientWidth < FLOAT_LIMIT) { + floatNavigator = true + visibleNavigator = false + } else if (floatNavigator && element.clientWidth >= FLOAT_LIMIT) { + floatNavigator = false + visibleNavigator = getVisibleNavigator() + } + }} +> + {#if visibleNavigator} + {#if floatNavigator} + + +
+ {/if}
- + {#if createComponent}
- - + {/if}
- + + + + + + + {#if mainHeaderComponent} + + {/if} +
+ + diff --git a/plugins/workbench-resources/src/components/Workbench.svelte b/plugins/workbench-resources/src/components/Workbench.svelte index 1ea3c97685..26a51ac531 100644 --- a/plugins/workbench-resources/src/components/Workbench.svelte +++ b/plugins/workbench-resources/src/components/Workbench.svelte @@ -63,7 +63,7 @@ locationToUrl, mainSeparators, navigate, - openPanel, + showPanel, PanelInstance, Popup, PopupAlignment, @@ -77,7 +77,8 @@ setResolvedLocation, showPopup, TooltipInstance, - workbenchSeparators + workbenchSeparators, + resizeObserver } from '@hcengineering/ui' import view from '@hcengineering/view' import { @@ -400,7 +401,6 @@ const prevTabLoc = prevTab ? getTabLocation(prevTab) : undefined if (prevTabLoc === undefined || prevTabLoc.path[2] !== loc.path[2]) { clear(1) - clear(2) } } prevTabIdStore.set($tabIdStore) @@ -528,12 +528,13 @@ provider, focus: doc }) - openPanel( + showPanel( props[0] as AnyComponent, _id, _class, (props[3] ?? undefined) as PopupAlignment, - (props[4] ?? undefined) as AnyComponent + (props[4] ?? undefined) as AnyComponent, + false ) } else { accessDeniedStore.set(true) @@ -631,21 +632,31 @@ let aside: HTMLElement let cover: HTMLElement + let workbenchWidth: number = $deviceInfo.docWidth - $deviceInfo.navigator.float = !($deviceInfo.docWidth < 1024) - $: if ($deviceInfo.docWidth <= 1024 && !$deviceInfo.navigator.float) { - $deviceInfo.navigator.visible = false - $deviceInfo.navigator.float = true + $deviceInfo.navigator.float = !(workbenchWidth < 1024) + const checkWorkbenchWidth = (): void => { + if (workbenchWidth <= 1024 && !$deviceInfo.navigator.float && $deviceInfo.navigator.visible) { + $deviceInfo.navigator.visible = false + $deviceInfo.navigator.float = true + } else if (workbenchWidth > 1024 && $deviceInfo.navigator.float) { + if (getMetadata(workbench.metadata.NavigationExpandedDefault) === undefined) { + $deviceInfo.navigator.float = false + $deviceInfo.navigator.visible = true + } + } + } + $: if ($deviceInfo.docWidth <= 1024 && !$deviceInfo.aside.float) { $deviceInfo.aside.visible = false - } else if ($deviceInfo.docWidth > 1024 && $deviceInfo.navigator.float) { + $deviceInfo.aside.float = true + } else if ($deviceInfo.docWidth > 1024 && $deviceInfo.aside.float) { if (getMetadata(workbench.metadata.NavigationExpandedDefault) === undefined) { - $deviceInfo.navigator.float = false - $deviceInfo.navigator.visible = true + $deviceInfo.aside.float = false $deviceInfo.aside.visible = true } } const checkOnHide = (): void => { - if ($deviceInfo.navigator.visible && $deviceInfo.docWidth <= 1024) $deviceInfo.navigator.visible = false + if ($deviceInfo.navigator.visible && workbenchWidth <= 1024) $deviceInfo.navigator.visible = false } let oldNavVisible: boolean = $deviceInfo.navigator.visible let oldASideVisible: boolean = $deviceInfo.aside.visible @@ -889,7 +900,14 @@ application: currentApplication?._id }} /> -
+
{ + workbenchWidth = element.clientWidth + checkWorkbenchWidth() + }} + > {#if mainNavigator} @@ -900,6 +918,7 @@ class="antiPanel-navigator no-print {$deviceInfo.navigator.direction === 'horizontal' ? 'portrait' : 'landscape'} border-left" + class:fly={$deviceInfo.navigator.float} >
{#if currentApplication} @@ -996,7 +1015,7 @@
{/if}
- {#if !$deviceInfo.navigator.float} + {#if !$deviceInfo.aside.float} {#if $sidebarStore.variant === SidebarVariant.EXPANDED} {/if} @@ -1004,8 +1023,7 @@ {/if}
-
- {#if $deviceInfo.navigator.float} + {#if $deviceInfo.aside.float}
diff --git a/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte b/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte index 4a03ced07f..8860b6b0cf 100644 --- a/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte +++ b/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte @@ -65,7 +65,7 @@ id="sidebar" class="antiPanel-application vertical sidebar-container" class:mini - class:float={$deviceInfo.navigator.float} + class:float={$deviceInfo.aside.float} > {#if mini} diff --git a/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte b/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte index fd4f44e38c..9cefa62449 100644 --- a/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte +++ b/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte @@ -22,7 +22,8 @@ Location, Header, Breadcrumbs, - getCurrentLocation + getCurrentLocation, + deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { onDestroy, onMount } from 'svelte' @@ -108,6 +109,7 @@ hideDescription={true} adaptive="disabled" closeOnEscape={false} + topIndent={$deviceInfo.navigator.float && !$deviceInfo.aside.float} on:close={() => { if (widget !== undefined) { closeWidget(widget._id) diff --git a/plugins/workbench-resources/src/sidebar.ts b/plugins/workbench-resources/src/sidebar.ts index 5a7900ab35..b916b132c9 100644 --- a/plugins/workbench-resources/src/sidebar.ts +++ b/plugins/workbench-resources/src/sidebar.ts @@ -256,7 +256,9 @@ export function createWidgetTab (widget: Widget, tab: WidgetTab, newTab = false) variant: SidebarVariant.EXPANDED }) const devInfo = get(deviceInfo) - if (devInfo.navigator.float && !devInfo.aside.visible) deviceInfo.set({ ...devInfo, aside: { visible: true } }) + if (devInfo.aside.float && !devInfo.aside.visible) { + deviceInfo.set({ ...devInfo, aside: { visible: true, float: true } }) + } } export function pinWidgetTab (widget: Widget, tabId: string): void { @@ -337,7 +339,9 @@ export function minimizeSidebar (closedByUser = false): void { sidebarStore.set({ ...state, ...widgetsState, widget: undefined, variant: SidebarVariant.MINI }) const devInfo = get(deviceInfo) - if (devInfo.navigator.float && devInfo.aside.visible) deviceInfo.set({ ...devInfo, aside: { visible: false } }) + if (devInfo.navigator.float && devInfo.aside.visible) { + deviceInfo.set({ ...devInfo, aside: { visible: false, float: true } }) + } } export function updateTabData (widget: Ref, tabId: string, data: Record): void { diff --git a/pods/fulltext/src/server.ts b/pods/fulltext/src/server.ts index 97587f6bbe..e3b4dbe6d9 100644 --- a/pods/fulltext/src/server.ts +++ b/pods/fulltext/src/server.ts @@ -144,9 +144,6 @@ class WorkspaceIndexer { await helper.checkDomain(ctx, DOMAIN_DOC_INDEX_STATE, 10000, dhelper) } } - }, - async (ctx: MeasureContext) => { - await result.pipeline.context.adapterManager?.closeContext?.(ctx) } ) await result.fulltext.startIndexing(() => { diff --git a/server-plugins/activity-resources/src/references.ts b/server-plugins/activity-resources/src/references.ts index 3c4d12fa45..f97426cb8a 100644 --- a/server-plugins/activity-resources/src/references.ts +++ b/server-plugins/activity-resources/src/references.ts @@ -14,12 +14,12 @@ // import activity, { ActivityMessage, ActivityReference, UserMentionInfo } from '@hcengineering/activity' -import { loadCollaborativeDoc, yDocToBuffer } from '@hcengineering/collaboration' +import { loadCollabJson } from '@hcengineering/collaboration' import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact' import core, { Account, + Blob, Class, - CollaborativeDoc, Data, Doc, generateId, @@ -416,21 +416,17 @@ async function getCreateReferencesTxes ( refs.push(...attrReferences) } else if (attr.type._class === core.class.TypeCollaborativeDoc) { - const collaborativeDoc = (createdDoc as any)[attr.name] as CollaborativeDoc - try { - const ydoc = await loadCollaborativeDoc(ctx, storage, control.workspace, collaborativeDoc) - if (ydoc !== undefined) { - const attrReferences = getReferencesData( - srcDocId, - srcDocClass, - attachedDocId, - attachedDocClass, - yDocToBuffer(ydoc) - ) - refs.push(...attrReferences) + const blobId = (createdDoc as any)[attr.name] as Ref + if (blobId != null) { + try { + const markup = await loadCollabJson(ctx, storage, control.workspace, blobId) + if (markup !== undefined) { + const attrReferences = getReferencesData(srcDocId, srcDocClass, attachedDocId, attachedDocClass, markup) + refs.push(...attrReferences) + } + } catch { + // do nothing, the collaborative doc does not sem to exist yet } - } catch { - // do nothing, the collaborative doc does not sem to exist yet } } } @@ -469,17 +465,13 @@ async function getUpdateReferencesTxes ( } else if (attr.type._class === core.class.TypeCollaborativeDoc) { hasReferenceAttrs = true try { - const collaborativeDoc = (updatedDoc as any)[attr.name] as CollaborativeDoc - const ydoc = await loadCollaborativeDoc(ctx, storage, control.workspace, collaborativeDoc) - if (ydoc !== undefined) { - const attrReferences = getReferencesData( - srcDocId, - srcDocClass, - attachedDocId, - attachedDocClass, - yDocToBuffer(ydoc) - ) - references.push(...attrReferences) + const blobId = (updatedDoc as any)[attr.name] as Ref + if (blobId != null) { + const markup = await loadCollabJson(ctx, storage, control.workspace, blobId) + if (markup !== undefined) { + const attrReferences = getReferencesData(srcDocId, srcDocClass, attachedDocId, attachedDocClass, markup) + references.push(...attrReferences) + } } } catch { // do nothing, the collaborative doc does not sem to exist yet diff --git a/server-plugins/collaboration-resources/src/index.ts b/server-plugins/collaboration-resources/src/index.ts index bcf19cbfdd..18cc1c7ce0 100644 --- a/server-plugins/collaboration-resources/src/index.ts +++ b/server-plugins/collaboration-resources/src/index.ts @@ -13,9 +13,9 @@ // limitations under the License. // -import { removeCollaborativeDoc } from '@hcengineering/collaboration' import type { CollaborativeDoc, Doc, Tx, TxRemoveDoc } from '@hcengineering/core' -import core from '@hcengineering/core' +import core, { makeCollabId } from '@hcengineering/core' +import { removeCollabYdoc } from '@hcengineering/collaboration' import { type TriggerControl } from '@hcengineering/server-core' /** @@ -39,17 +39,16 @@ export async function OnDelete ( const attributes = hierarchy.getAllAttributes(rmTx.objectClass) for (const attribute of attributes.values()) { if (hierarchy.isDerived(attribute.type._class, core.class.TypeCollaborativeDoc)) { - const value = (doc as any)[attribute.name] as CollaborativeDoc - if (value !== undefined) { - toDelete.push(value) - } + toDelete.push(makeCollabId(doc._class, doc._id, attribute.name)) } } // TODO This is not accurate way to delete collaborative document // Even though we are deleting it here, the document can be currently in use by someone else // and when editing session ends, the collborator service will recreate the document again - await removeCollaborativeDoc(storageAdapter, workspace, toDelete, ctx) + if (toDelete.length > 0) { + await removeCollabYdoc(storageAdapter, workspace, toDelete, ctx) + } } return [] } diff --git a/server/collaboration/package.json b/server/collaboration/package.json index e2a6c20abf..b591ce67a3 100644 --- a/server/collaboration/package.json +++ b/server/collaboration/package.json @@ -40,6 +40,7 @@ "dependencies": { "@hcengineering/core": "^0.6.32", "@hcengineering/server-core": "^0.6.1", + "@hcengineering/text": "^0.6.5", "base64-js": "^1.5.1", "yjs": "^13.6.19" } diff --git a/server/collaboration/src/history/__tests__/branch.test.ts b/server/collaboration/src/history/__tests__/branch.test.ts deleted file mode 100644 index ee92d9bb1a..0000000000 --- a/server/collaboration/src/history/__tests__/branch.test.ts +++ /dev/null @@ -1,133 +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 { Doc as YDoc, encodeStateAsUpdate, encodeStateVector } from 'yjs' - -import { yDocBranch, yDocBranchWithGC } from '../branch' -import { generateId } from '@hcengineering/core' - -describe('branch', () => { - describe('yDocBranch', () => { - it('branches document without gc', async () => { - const source = new YDoc({ guid: generateId(), gc: false }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranch(source) - - expect(target.gc).toBeFalsy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates the same as source regardless of gc - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).toEqual(sourceUpdate) - }) - - it('branches document state with gc', async () => { - const source = new YDoc({ guid: generateId(), gc: true }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranch(source) - - expect(target.gc).toBeTruthy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates the same as source regardless of gc - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).toEqual(sourceUpdate) - }) - }) - - describe('yDocBranchWithGC', () => { - it('branches document state without gc', async () => { - const source = new YDoc({ guid: generateId(), gc: false }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranchWithGC(source) - - expect(target.gc).toBeFalsy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates different because source is not gc-ed - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).not.toEqual(sourceUpdate) - }) - - it('branches document state with gc', async () => { - const source = new YDoc({ guid: generateId(), gc: true }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranchWithGC(source) - - expect(target.gc).toBeTruthy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates the same because source is gc-ed - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).toEqual(sourceUpdate) - }) - }) - - function applyGarbageCollectableChanges (ydoc: YDoc): void { - const sourceData = ydoc.getArray('data') - sourceData.insert(0, ['a']) - sourceData.insert(1, [1, 2]) - sourceData.delete(0, 1) - sourceData.insert(2, [3]) - } -}) diff --git a/server/collaboration/src/history/__tests__/history.test.ts b/server/collaboration/src/history/__tests__/history.test.ts deleted file mode 100644 index 957b048e78..0000000000 --- a/server/collaboration/src/history/__tests__/history.test.ts +++ /dev/null @@ -1,159 +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 { Account, Ref, generateId } from '@hcengineering/core' -import { Doc as YDoc, encodeStateAsUpdate } from 'yjs' - -import { YDocVersion, addVersion, deleteVersion, getVersion, getVersionData, listVersions } from '../history' - -const HISTORY = 'history' -const UPDATES = 'updates' - -describe('history', () => { - let ydoc: YDoc - - beforeEach(() => { - ydoc = new YDoc({ guid: generateId() }) - }) - - it('addVersion should append new version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, version, update) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(1) - expect(updates.size).toEqual(1) - - expect(history.get(0)).toEqual(version) - expect(updates.get(versionId)).toBeDefined() - }) - - it('addVersion should raise an error when a version already exists', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, version, update) - expect(() => { - addVersion(ydoc, version, update) - }).toThrow() - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(1) - expect(updates.size).toEqual(1) - - expect(history.get(0)).toEqual(version) - expect(updates.get(versionId)).toBeDefined() - }) - - it('getVersion should get existing version data', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - addVersion(ydoc, version, update) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(3) - expect(updates.size).toEqual(3) - - expect(getVersion(ydoc, versionId)).toEqual(version) - }) - - it('getVersion should return undefined for unknown version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - addVersion(ydoc, version, encodeStateAsUpdate(ydoc)) - - expect(getVersion(ydoc, generateId())).toBeUndefined() - }) - - it('listVersions should return existing versions', async () => { - const version1 = yDocVersion(generateId()) - const version2 = yDocVersion(generateId()) - - addVersion(ydoc, version1, encodeStateAsUpdate(ydoc)) - addVersion(ydoc, version2, encodeStateAsUpdate(ydoc)) - - expect(listVersions(ydoc)).toEqual(expect.arrayContaining([version1, version2])) - }) - - it('listVersions should return empty list when no versions', async () => { - expect(listVersions(ydoc)).toEqual([]) - }) - - it('getVersionData should get existing version data', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, version, update) - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(3) - expect(updates.size).toEqual(3) - - expect(getVersionData(ydoc, versionId)).toEqual(update) - }) - - it('getVersionData should return undefined for unknown version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - addVersion(ydoc, version, encodeStateAsUpdate(ydoc)) - - expect(getVersionData(ydoc, generateId())).toBeUndefined() - }) - - it('deleteVersion should delete existing version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - addVersion(ydoc, version, encodeStateAsUpdate(ydoc)) - - deleteVersion(ydoc, versionId) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(0) - expect(updates.size).toEqual(0) - - expect(getVersion(ydoc, versionId)).toEqual(undefined) - expect(getVersionData(ydoc, versionId)).toEqual(undefined) - }) -}) - -function yDocVersion (versionId: string): YDocVersion { - return { - versionId, - name: versionId, - createdBy: 'unit test' as Ref, - createdOn: Date.now() - } -} diff --git a/server/collaboration/src/history/__tests__/snapshot.test.ts b/server/collaboration/src/history/__tests__/snapshot.test.ts deleted file mode 100644 index 6c36917352..0000000000 --- a/server/collaboration/src/history/__tests__/snapshot.test.ts +++ /dev/null @@ -1,109 +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 { Account, Ref, generateId } from '@hcengineering/core' -import { Doc as YDoc } from 'yjs' - -import { YDocVersion } from '../history' -import { createYdocSnapshot, restoreYdocSnapshot } from '../snapshot' - -const HISTORY = 'history' -const UPDATES = 'updates' - -describe('snapshot', () => { - let yContent: YDoc - let yHistory: YDoc - - beforeEach(() => { - yContent = new YDoc({ guid: generateId(), gc: false }) - yHistory = new YDoc({ guid: generateId() }) - }) - - it('createYdocSnapshot appends new version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - createYdocSnapshot(yContent, yHistory, version) - - const history = yHistory.getArray(HISTORY) - const updates = yHistory.getMap(UPDATES) - - expect(history.length).toEqual(1) - expect(updates.size).toEqual(1) - - expect(history.get(0)).toEqual(version) - expect(updates.get(versionId)).toBeDefined() - }) - - it('restoreYdocSnapshot restores existing version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - const data = yContent.getArray('data') - data.insert(0, [1, 2, 3]) - expect(data.toArray()).toEqual(expect.arrayContaining([1, 2, 3])) - - createYdocSnapshot(yContent, yHistory, version) - - data.delete(1, 1) - expect(data.toArray()).toEqual(expect.arrayContaining([1, 3])) - - const yRestore = restoreYdocSnapshot(yContent, yHistory, versionId) - - // assert the restored doc has not been changed - expect(yRestore).toBeDefined() - expect(yRestore?.getArray('data').toArray()).toEqual(expect.arrayContaining([1, 2, 3])) - - // assert the original doc has not been changed - expect(yContent.getArray('data').toArray()).toEqual(expect.arrayContaining([1, 3])) - }) - - it('restoreYdocSnapshot throws an error when gc is enabled', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - yContent = new YDoc({ guid: generateId(), gc: true }) - createYdocSnapshot(yContent, yHistory, version) - expect(() => restoreYdocSnapshot(yContent, yHistory, versionId)).toThrow() - }) - - it('restoreYdocSnapshot does not restore version that does not exist', async () => { - const versionId = generateId() - - const yRestore = restoreYdocSnapshot(yContent, yHistory, versionId) - expect(yRestore).toBeUndefined() - }) - - it('restoreYdocSnapshot restored document has gc enabled', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - createYdocSnapshot(yContent, yHistory, version) - const yRestore = restoreYdocSnapshot(yContent, yHistory, versionId) - - // so far we don't care whether gc is enabled or not in the restore - // but we need to ensure we understand that it is enabled - expect(yRestore?.gc).toEqual(true) - }) -}) - -function yDocVersion (versionId: string): YDocVersion { - return { - versionId, - name: versionId, - createdBy: 'unit test' as Ref, - createdOn: Date.now() - } -} diff --git a/server/collaboration/src/history/branch.ts b/server/collaboration/src/history/branch.ts deleted file mode 100644 index 275cf4a31d..0000000000 --- a/server/collaboration/src/history/branch.ts +++ /dev/null @@ -1,54 +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 { generateId } from '@hcengineering/core' -import { Doc as YDoc, applyUpdate, encodeStateAsUpdate } from 'yjs' - -/** - * Branch (copy) document content as is. - * - * If the source document has gc parameter enabled, then garbage - * collection will be performed. The result document will have the same - * gc parameter value as the source document. - * - * @public - * */ -export function yDocBranch (source: YDoc): YDoc { - const target = new YDoc({ guid: generateId(), gc: source.gc }) - - const update = encodeStateAsUpdate(source) - applyUpdate(target, update) - - return target -} - -/** - * Branch (copy) document content with garbage collecting while applying update. - * - * Garbage collection will be performed regardless of the gc parameter - * in the source document. The result document will have the same gc - * parameter value as the source document. - * - * @public - * */ -export function yDocBranchWithGC (source: YDoc): YDoc { - const target = new YDoc({ guid: generateId(), gc: source.gc }) - - const gc = new YDoc({ guid: generateId(), gc: true }) - applyUpdate(gc, encodeStateAsUpdate(source)) - applyUpdate(target, encodeStateAsUpdate(gc)) - - return target -} diff --git a/server/collaboration/src/history/history.ts b/server/collaboration/src/history/history.ts deleted file mode 100644 index 7feaebb3ed..0000000000 --- a/server/collaboration/src/history/history.ts +++ /dev/null @@ -1,110 +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 { Account, Ref, Timestamp } from '@hcengineering/core' -import { fromByteArray, toByteArray } from 'base64-js' -import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs' - -/** - * This module provides utils for document version storage based on YDoc - * - * At the top level the history document contains two fields: - * 1. history - * An array containing version ids in creation order - * 2. updates - * A map containing version data keyed by version id - * - * { - * "history": [ - * { "versionId": "version1", ... }, - * { "versionId": "version2", ... }, - * ... - * ], - * "updates": { - * "version1": ... version data as ydoc update base64 encoded ..., - * "version2": ... version data as ydoc update base64 encoded ..., - * ... - * } - * } - */ - -/** @public */ -export interface YDocVersion { - versionId: string - name: string - - createdBy: Ref - createdOn: Timestamp -} - -const HISTORY = 'history' -const UPDATES = 'updates' - -function getHistory (ydoc: YDoc): YArray { - return ydoc.getArray(HISTORY) -} - -function getUpdates (ydoc: YDoc): YMap { - return ydoc.getMap(UPDATES) -} - -/** @public */ -export function addVersion (ydoc: YDoc, version: YDocVersion, update: Uint8Array): void { - const history = getHistory(ydoc) - const updates = getUpdates(ydoc) - - const { versionId } = version - - if (updates.has(versionId)) { - throw Error('history item already exists') - } - - ydoc.transact((tr) => { - history.push([version]) - updates.set(versionId, fromByteArray(update)) - }) -} - -/** @public */ -export function getVersion (ydoc: YDoc, versionId: string): YDocVersion | undefined { - const history = getHistory(ydoc) - return history.toArray().find((p) => p.versionId === versionId) -} - -/** @public */ -export function listVersions (ydoc: YDoc): YDocVersion[] { - return getHistory(ydoc).toArray() -} - -/** @public */ -export function getVersionData (ydoc: YDoc, versionId: string): Uint8Array | undefined { - const updates = getUpdates(ydoc) - const update = updates.get(versionId) - return update !== undefined ? toByteArray(update) : undefined -} - -/** @public */ -export function deleteVersion (ydoc: YDoc, versionId: string): void { - const history = getHistory(ydoc) - const updates = getUpdates(ydoc) - - ydoc.transact((tr) => { - const index = history.toArray().findIndex((p) => p.versionId === versionId) - if (index !== -1) { - history.delete(index, 1) - } - updates.delete(versionId) - }) -} diff --git a/server/collaboration/src/history/snapshot.ts b/server/collaboration/src/history/snapshot.ts deleted file mode 100644 index ac0d273af3..0000000000 --- a/server/collaboration/src/history/snapshot.ts +++ /dev/null @@ -1,36 +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 { Doc as YDoc } from 'yjs' -import * as Y from 'yjs' - -import { YDocVersion, addVersion, getVersionData } from './history' - -/** @public */ -export function createYdocSnapshot (yContent: YDoc, yHistory: YDoc, version: YDocVersion): void { - const snapshot = Y.snapshot(yContent) - const update = Y.encodeSnapshot(snapshot) - - addVersion(yHistory, version, update) -} - -/** @public */ -export function restoreYdocSnapshot (yContent: YDoc, yHistory: YDoc, versionId: string): YDoc | undefined { - const update = getVersionData(yHistory, versionId) - if (update !== undefined) { - const snapshot = Y.decodeSnapshot(update) - return Y.createDocFromSnapshot(yContent, snapshot) - } -} diff --git a/server/collaboration/src/index.ts b/server/collaboration/src/index.ts index 223512db04..88cac91502 100644 --- a/server/collaboration/src/index.ts +++ b/server/collaboration/src/index.ts @@ -13,9 +13,5 @@ // limitations under the License. // -export * from './history/branch' -export * from './history/history' -export * from './history/snapshot' -export * from './utils/collaborative-doc' -export * from './utils/storage' -export * from './utils/ydoc' +export * from './storage' +export * from './ydoc' diff --git a/server/collaboration/src/storage.ts b/server/collaboration/src/storage.ts new file mode 100644 index 0000000000..2d195ef3c8 --- /dev/null +++ b/server/collaboration/src/storage.ts @@ -0,0 +1,127 @@ +// +// 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 Blob, + type CollaborativeDoc, + type Ref, + type WorkspaceId, + Markup, + MeasureContext, + generateId, + makeCollabJsonId, + makeCollabYdocId +} from '@hcengineering/core' +import { StorageAdapter } from '@hcengineering/server-core' +import { yDocToMarkup } from '@hcengineering/text' +import { Doc as YDoc } from 'yjs' + +import { yDocFromBuffer, yDocToBuffer } from './ydoc' + +/** @public */ +export async function loadCollabYdoc ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + doc: CollaborativeDoc +): Promise { + const blobId = makeCollabYdocId(doc) + + const blob = await storageAdapter.stat(ctx, workspace, blobId) + if (blob === undefined) { + return undefined + } + + if (!blob.contentType.includes('application/ydoc')) { + ctx.error('invalid content type', { contentType: blob.contentType }) + return undefined + } + + // no need to apply gc because we load existing document + // it is either already gc-ed, or gc not needed and it is disabled + const ydoc = new YDoc({ guid: generateId(), gc: false }) + + const buffer = await storageAdapter.read(ctx, workspace, blobId) + return yDocFromBuffer(Buffer.concat(buffer as any), ydoc) +} + +/** @public */ +export async function saveCollabYdoc ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + doc: CollaborativeDoc, + ydoc: YDoc +): Promise> { + const blobId = makeCollabYdocId(doc) + + const buffer = yDocToBuffer(ydoc) + await storageAdapter.put(ctx, workspace, blobId, buffer, 'application/ydoc', buffer.length) + + return blobId +} + +/** @public */ +export async function removeCollabYdoc ( + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + collaborativeDocs: CollaborativeDoc[], + ctx: MeasureContext +): Promise { + const toRemove: string[] = collaborativeDocs.map(makeCollabYdocId) + if (toRemove.length > 0) { + await ctx.with('remove', {}, async () => { + await storageAdapter.remove(ctx, workspace, toRemove) + }) + } +} + +/** @public */ +export async function loadCollabJson ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + blobId: Ref +): Promise { + const blob = await storageAdapter.stat(ctx, workspace, blobId) + if (blob === undefined) { + return undefined + } + + if (!blob.contentType.includes('application/json')) { + ctx.error('invalid content type', { contentType: blob.contentType }) + return undefined + } + + const buffer = await storageAdapter.read(ctx, workspace, blobId) + return Buffer.concat(buffer as any).toString() +} + +/** @public */ +export async function saveCollabJson ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + doc: CollaborativeDoc, + content: Markup | YDoc +): Promise> { + const blobId = makeCollabJsonId(doc) + + const markup = typeof content === 'string' ? content : yDocToMarkup(content, doc.objectAttr) + const buffer = Buffer.from(markup) + await storageAdapter.put(ctx, workspace, blobId, buffer, 'application/json', buffer.length) + + return blobId +} diff --git a/server/collaboration/src/utils/__tests__/collaborative-doc.test.ts b/server/collaboration/src/utils/__tests__/collaborative-doc.test.ts deleted file mode 100644 index 87e8e542c7..0000000000 --- a/server/collaboration/src/utils/__tests__/collaborative-doc.test.ts +++ /dev/null @@ -1,61 +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 { collaborativeDocFormat } from '@hcengineering/core' -import { collaborativeHistoryDocId, isEditableDoc, isEditableDocVersion } from '../collaborative-doc' - -describe('collaborative-doc', () => { - describe('collaborativeHistoryDocId', () => { - it('returns valid history doc id', async () => { - expect(collaborativeHistoryDocId('documentId')).toEqual('documentId#history') - }) - - it('returns valid history doc id for history doc id', async () => { - expect(collaborativeHistoryDocId('documentId#history')).toEqual('documentId#history') - }) - }) - - describe('isEditableDoc', () => { - it('returns true for HEAD version', async () => { - const doc = collaborativeDocFormat({ - documentId: 'example', - versionId: 'HEAD', - lastVersionId: '0' - }) - expect(isEditableDoc(doc)).toBeTruthy() - }) - - it('returns false for other versions', async () => { - const doc = collaborativeDocFormat({ - documentId: 'example', - versionId: 'main', - lastVersionId: '0' - }) - expect(isEditableDoc(doc)).toBeFalsy() - }) - }) - - describe('isEditableDocVersion', () => { - it('returns true for HEAD version', async () => { - expect(isEditableDocVersion('HEAD')).toBeTruthy() - }) - - it('returns false for other versions', async () => { - expect(isEditableDocVersion('')).toBeFalsy() - expect(isEditableDocVersion('main')).toBeFalsy() - expect(isEditableDocVersion('head')).toBeFalsy() - }) - }) -}) diff --git a/server/collaboration/src/utils/__tests__/ydoc.test.ts b/server/collaboration/src/utils/__tests__/ydoc.test.ts deleted file mode 100644 index 6dc0acaeb4..0000000000 --- a/server/collaboration/src/utils/__tests__/ydoc.test.ts +++ /dev/null @@ -1,87 +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 { Doc as YDoc, XmlElement as YXmlElement, XmlText as YXmlText, encodeStateVector } from 'yjs' - -import { clone, yDocCopyXmlField, yDocFromBuffer, yDocToBuffer } from '../ydoc' -import { generateId } from '@hcengineering/core' - -describe('ydoc', () => { - it('yDocFromBuffer converts ydoc to a buffer', async () => { - const ydoc = new YDoc({ guid: generateId() }) - const buffer = yDocToBuffer(ydoc) - - expect(buffer).toBeDefined() - }) - - it('yDocFromBuffer converts buffer to a ydoc', async () => { - const source = new YDoc({ guid: generateId() }) - source.getArray('data').insert(0, [1, 2]) - - const buffer = yDocToBuffer(source) - - const target = yDocFromBuffer(buffer, new YDoc({ guid: generateId() })) - expect(target).toBeDefined() - expect(encodeStateVector(target)).toEqual(encodeStateVector(source)) - }) - - describe('yDocCopyXmlField', () => { - it('copies into new field', async () => { - const ydoc = new YDoc() - - const source = ydoc.getXmlFragment('source') - source.insertAfter(null, [new YXmlElement('p'), new YXmlText('foo'), new YXmlElement('p')]) - expect(ydoc.share.has('target')).toBeFalsy() - - yDocCopyXmlField(ydoc, 'source', 'target') - const target = ydoc.getXmlFragment('target') - - expect(ydoc.share.has('target')).toBeTruthy() - expect(target.toJSON()).toEqual(source.toJSON()) - }) - - it('copies into existing field', async () => { - const ydoc = new YDoc() - - const source = ydoc.getXmlFragment('source') - const target = ydoc.getXmlFragment('target') - - source.insertAfter(null, [new YXmlElement('p'), new YXmlText('foo'), new YXmlElement('p')]) - target.insertAfter(null, [new YXmlText('bar')]) - expect(target.toJSON()).not.toEqual(source.toJSON()) - - yDocCopyXmlField(ydoc, 'source', 'target') - expect(ydoc.share.has('target')).toBeTruthy() - expect(target.toJSON()).toEqual(source.toJSON()) - }) - }) - - it('clones YXmlElement', () => { - const ydoc = new YDoc() - const source = ydoc.getXmlElement('source') - const target = ydoc.getXmlFragment('target') - - const src = new YXmlElement('paragraph') - src.setAttribute('class', 'text') - src.setAttribute('size', 1024 as any) - src.insert(0, [new YXmlText('foo')]) - source.insert(0, [src]) - - const dst = clone(src) - target.insert(0, [dst]) - - expect(src.toJSON()).toEqual(dst.toJSON()) - }) -}) diff --git a/server/collaboration/src/utils/collaborative-doc.ts b/server/collaboration/src/utils/collaborative-doc.ts deleted file mode 100644 index f6d6f9cd29..0000000000 --- a/server/collaboration/src/utils/collaborative-doc.ts +++ /dev/null @@ -1,168 +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, - CollaborativeDocVersion, - CollaborativeDocVersionHead, - MeasureContext, - WorkspaceId, - collaborativeDocParse, - collaborativeDocUnchain, - generateId -} from '@hcengineering/core' -import { Doc as YDoc } from 'yjs' - -import { StorageAdapter } from '@hcengineering/server-core' -import { restoreYdocSnapshot } from '../history/snapshot' -import { yDocFromStorage, yDocToStorage } from './storage' - -/** @public */ -export function collaborativeHistoryDocId (id: string): string { - const suffix = '#history' - return id.endsWith(suffix) ? id : id + suffix -} - -async function loadCollaborativeDocVersion ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - versionId: string -): Promise { - const yContent = await ctx.with('yDocFromStorage', { type: 'content' }, (ctx) => { - return yDocFromStorage(ctx, storageAdapter, workspace, documentId, new YDoc({ guid: generateId(), gc: false })) - }) - - // the document does not exist - if (yContent === undefined) { - return undefined - } - - if (versionId === 'HEAD') { - return yContent - } - - const historyDocumentId = collaborativeHistoryDocId(documentId) - const yHistory = await ctx.with('yDocFromStorage', { type: 'history' }, (ctx) => { - return yDocFromStorage(ctx, storageAdapter, workspace, historyDocumentId, new YDoc({ guid: generateId() })) - }) - - // the history document does not exist - if (yHistory === undefined) { - return undefined - } - - return await ctx.with('restoreYdocSnapshot', {}, () => { - return restoreYdocSnapshot(yContent, yHistory, versionId) - }) -} - -/** @public */ -export async function loadCollaborativeDoc ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - collaborativeDoc: CollaborativeDoc -): Promise { - const sources = collaborativeDocUnchain(collaborativeDoc) - - return await ctx.with('loadCollaborativeDoc', { type: 'content' }, async (ctx) => { - for (const source of sources) { - const { documentId, versionId } = collaborativeDocParse(source) - - const ydoc = await loadCollaborativeDocVersion(ctx, storageAdapter, workspace, documentId, versionId) - - if (ydoc !== undefined) { - return ydoc - } - } - return undefined - }) -} - -/** @public */ -export async function saveCollaborativeDoc ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - collaborativeDoc: CollaborativeDoc, - ydoc: YDoc -): Promise { - const { documentId, versionId } = collaborativeDocParse(collaborativeDoc) - await saveCollaborativeDocVersion(ctx, storageAdapter, workspace, documentId, versionId, ydoc) -} - -/** @public */ -export async function saveCollaborativeDocVersion ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - versionId: CollaborativeDocVersion, - ydoc: YDoc -): Promise { - await ctx.with('saveCollaborativeDoc', {}, async (ctx) => { - if (versionId === 'HEAD') { - await ctx.with('yDocToStorage', {}, () => yDocToStorage(ctx, storageAdapter, workspace, documentId, ydoc)) - } else { - console.warn('Cannot save non HEAD document version', documentId, versionId) - } - }) -} - -/** @public */ -export async function removeCollaborativeDoc ( - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - collaborativeDocs: CollaborativeDoc[], - ctx: MeasureContext -): Promise { - await ctx.with('removeollaborativeDoc', {}, async (ctx) => { - const toRemove: string[] = [] - for (const collaborativeDoc of collaborativeDocs) { - const { documentId, versionId } = collaborativeDocParse(collaborativeDoc) - if (versionId === CollaborativeDocVersionHead) { - toRemove.push(documentId, collaborativeHistoryDocId(documentId)) - } else { - console.warn('Cannot remove non HEAD document version', documentId, versionId) - } - } - if (toRemove.length > 0) { - await ctx.with('remove', {}, () => storageAdapter.remove(ctx, workspace, toRemove)) - } - }) -} - -/** @public */ -export function isEditableDoc (id: CollaborativeDoc): boolean { - const { versionId } = collaborativeDocParse(id) - return isEditableDocVersion(versionId) -} - -/** @public */ -export function isReadonlyDoc (id: CollaborativeDoc): boolean { - return !isEditableDoc(id) -} - -/** @public */ -export function isEditableDocVersion (version: CollaborativeDocVersion): boolean { - return version === CollaborativeDocVersionHead -} - -/** @public */ -export function isReadonlyDocVersion (version: CollaborativeDocVersion): boolean { - return !isEditableDocVersion(version) -} diff --git a/server/collaboration/src/utils/storage.ts b/server/collaboration/src/utils/storage.ts deleted file mode 100644 index be2cb7976d..0000000000 --- a/server/collaboration/src/utils/storage.ts +++ /dev/null @@ -1,57 +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 { generateId, MeasureContext, WorkspaceId } from '@hcengineering/core' -import { StorageAdapter } from '@hcengineering/server-core' -import { Doc as YDoc } from 'yjs' - -import { yDocFromBuffer, yDocToBuffer } from './ydoc' - -/** @public */ -export async function yDocFromStorage ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - ydoc?: YDoc -): Promise { - // stat the object to ensure it exists, because read will throw an error in this case - try { - const buffer = await storageAdapter.read(ctx, workspace, documentId) - - // no need to apply gc because we load existing document - // it is either already gc-ed, or gc not needed and it is disabled - ydoc ??= new YDoc({ guid: generateId(), gc: false }) - - return yDocFromBuffer(Buffer.concat(buffer as any), ydoc) - } catch (err: any) { - if (err.code === 'NoSuchKey') { - return undefined - } - throw err - } -} - -/** @public */ -export async function yDocToStorage ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - ydoc: YDoc -): Promise { - const buffer = yDocToBuffer(ydoc) - await storageAdapter.put(ctx, workspace, documentId, buffer, 'application/ydoc', buffer.length) -} diff --git a/server/collaboration/src/utils/ydoc.ts b/server/collaboration/src/ydoc.ts similarity index 88% rename from server/collaboration/src/utils/ydoc.ts rename to server/collaboration/src/ydoc.ts index c6824fb4fb..b20cd907e8 100644 --- a/server/collaboration/src/utils/ydoc.ts +++ b/server/collaboration/src/ydoc.ts @@ -13,6 +13,7 @@ // limitations under the License. // +import { generateId } from '@hcengineering/core' import { AbstractType as YAbstractType, Doc as YDoc, @@ -24,7 +25,8 @@ import { export { XmlElement as YXmlElement, XmlText as YXmlText, AbstractType as YAbstractType } from 'yjs' /** @public */ -export function yDocFromBuffer (buffer: Buffer, ydoc: YDoc): YDoc { +export function yDocFromBuffer (buffer: Buffer, ydoc?: YDoc): YDoc { + ydoc ??= new YDoc({ guid: generateId(), gc: false }) try { const uint8arr = new Uint8Array(buffer) applyUpdate(ydoc, uint8arr) @@ -59,7 +61,7 @@ export function yDocCopyXmlField (ydoc: YDoc, source: string, target: string): v * @param src YXmlElement * @returns YXmlElement */ -export function clone (src: YXmlElement): YXmlElement { +export function yXmlElementClone (src: YXmlElement): YXmlElement { const el = new YXmlElement(src.nodeName) const attrs = src.getAttributes() @@ -72,7 +74,7 @@ export function clone (src: YXmlElement): YXmlElement { src .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 ) diff --git a/server/collaborator/src/context.ts b/server/collaborator/src/context.ts index b0de5441ef..c1775f9ba4 100644 --- a/server/collaborator/src/context.ts +++ b/server/collaborator/src/context.ts @@ -13,8 +13,7 @@ // limitations under the License. // -import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client' -import { WorkspaceId, generateId } from '@hcengineering/core' +import { Blob, Ref, WorkspaceId, generateId } from '@hcengineering/core' import { decodeToken } from '@hcengineering/server-token' import { onAuthenticatePayload } from '@hocuspocus/server' import { ClientFactory, simpleClientFactory } from './platform' @@ -24,8 +23,7 @@ export interface Context { workspaceId: WorkspaceId clientFactory: ClientFactory - initialContentId?: DocumentId - platformDocumentId?: PlatformDocumentId + content?: Ref } interface WithContext { @@ -42,14 +40,12 @@ export function buildContext (data: onAuthenticatePayload): Context { const connectionId = context.connectionId ?? generateId() const decodedToken = decodeToken(data.token) - const initialContentId = (data.requestParameters.get('initialContentId') as DocumentId) ?? undefined - const platformDocumentId = (data.requestParameters.get('platformDocumentId') as PlatformDocumentId) ?? undefined + const content = (data.requestParameters.get('content') as Ref) ?? undefined return { connectionId, workspaceId: decodedToken.workspace, clientFactory: simpleClientFactory(decodedToken), - initialContentId, - platformDocumentId + content } } diff --git a/server/collaborator/src/extensions/authentication.ts b/server/collaborator/src/extensions/authentication.ts index 2d7081133b..757b0d4422 100644 --- a/server/collaborator/src/extensions/authentication.ts +++ b/server/collaborator/src/extensions/authentication.ts @@ -13,8 +13,7 @@ // limitations under the License. // -import { DocumentId, parseDocumentId } from '@hcengineering/collaborator-client' -import { isReadonlyDoc } from '@hcengineering/collaboration' +import { decodeDocumentId } from '@hcengineering/collaborator-client' import { MeasureContext } from '@hcengineering/core' import { decodeToken } from '@hcengineering/server-token' import { Extension, onAuthenticatePayload } from '@hocuspocus/server' @@ -35,7 +34,7 @@ export class AuthenticationExtension implements Extension { async onAuthenticate (data: onAuthenticatePayload): Promise { const ctx = this.configuration.ctx - const { workspaceId, collaborativeDoc } = parseDocumentId(data.documentName as DocumentId) + const { workspaceId } = decodeDocumentId(data.documentName) return await ctx.with('authenticate', { workspaceId }, async () => { const token = decodeToken(data.token) @@ -50,8 +49,6 @@ export class AuthenticationExtension implements Extension { throw new Error('documentName must include workspace id') } - data.connection.readOnly = isReadonlyDoc(collaborativeDoc) - return buildContext(data) }) } diff --git a/server/collaborator/src/extensions/storage.ts b/server/collaborator/src/extensions/storage.ts index db49e51148..279e4568b3 100644 --- a/server/collaborator/src/extensions/storage.ts +++ b/server/collaborator/src/extensions/storage.ts @@ -13,7 +13,6 @@ // limitations under the License. // -import { DocumentId } from '@hcengineering/collaborator-client' import { type Markup, MeasureContext } from '@hcengineering/core' import { Document, @@ -130,7 +129,7 @@ export class StorageExtension implements Extension { try { return await ctx.with('load-document', {}, (ctx) => { - return adapter.loadDocument(ctx, documentName as DocumentId, context) + return adapter.loadDocument(ctx, documentName, context) }) } catch (err) { ctx.error('failed to load document', { documentName, error: err }) @@ -146,7 +145,7 @@ export class StorageExtension implements Extension { const currMarkup = this.configuration.transformer.fromYdoc(document) await ctx.with('save-document', {}, (ctx) => - adapter.saveDocument(ctx, documentName as DocumentId, document, context, { + adapter.saveDocument(ctx, documentName, document, context, { prev: prevMarkup, curr: currMarkup }) diff --git a/server/collaborator/src/rpc/methods/createContent.ts b/server/collaborator/src/rpc/methods/createContent.ts new file mode 100644 index 0000000000..97ad6e4ce7 --- /dev/null +++ b/server/collaborator/src/rpc/methods/createContent.ts @@ -0,0 +1,49 @@ +// +// 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 CreateContentRequest, + type CreateContentResponse, + decodeDocumentId +} from '@hcengineering/collaborator-client' +import { saveCollabJson } from '@hcengineering/collaboration' +import { type Blob, type Ref, MeasureContext } from '@hcengineering/core' +import { Context } from '../../context' +import { RpcMethodParams } from '../rpc' + +export async function createContent ( + ctx: MeasureContext, + context: Context, + documentName: string, + payload: CreateContentRequest, + params: RpcMethodParams +): Promise { + const { content } = payload + const { hocuspocus, storageAdapter } = params + + if (hocuspocus.documents.has(documentName) || hocuspocus.loadingDocuments.has(documentName)) { + throw new Error(`Document ${documentName} already exists`) + } + + const { documentId, workspaceId } = decodeDocumentId(documentName) + + const result: Record> = {} + for (const [field, markup] of Object.entries(content)) { + const blob = await saveCollabJson(ctx, storageAdapter, { name: workspaceId }, documentId, markup) + result[field] = blob + } + + return { content: result } +} diff --git a/server/collaborator/src/rpc/methods/getContent.ts b/server/collaborator/src/rpc/methods/getContent.ts index 90aeb92cdf..8899141834 100644 --- a/server/collaborator/src/rpc/methods/getContent.ts +++ b/server/collaborator/src/rpc/methods/getContent.ts @@ -21,14 +21,17 @@ import { RpcMethodParams } from '../rpc' export async function getContent ( ctx: MeasureContext, context: Context, - documentId: string, + documentName: string, payload: GetContentRequest, params: RpcMethodParams ): Promise { const { hocuspocus, transformer } = params + const { source } = payload + + context = { ...context, content: source } const connection = await ctx.with('connect', {}, () => { - return hocuspocus.openDirectConnection(documentId, context) + return hocuspocus.openDirectConnection(documentName, context) }) try { diff --git a/server/collaborator/src/rpc/methods/index.ts b/server/collaborator/src/rpc/methods/index.ts index 6c69a0a2f3..2a01f16280 100644 --- a/server/collaborator/src/rpc/methods/index.ts +++ b/server/collaborator/src/rpc/methods/index.ts @@ -14,10 +14,12 @@ // import { getContent } from './getContent' +import { createContent } from './createContent' import { updateContent } from './updateContent' import { RpcMethod } from '../rpc' export const methods: Record = { getContent, + createContent, updateContent } diff --git a/server/collaborator/src/rpc/methods/updateContent.ts b/server/collaborator/src/rpc/methods/updateContent.ts index 1334f0265c..87dc350e78 100644 --- a/server/collaborator/src/rpc/methods/updateContent.ts +++ b/server/collaborator/src/rpc/methods/updateContent.ts @@ -22,7 +22,7 @@ import { RpcMethodParams } from '../rpc' export async function updateContent ( ctx: MeasureContext, context: Context, - documentId: string, + documentName: string, payload: UpdateContentRequest, params: RpcMethodParams ): Promise { @@ -41,7 +41,7 @@ export async function updateContent ( }) const connection = await ctx.with('connect', {}, () => { - return hocuspocus.openDirectConnection(documentId, context) + return hocuspocus.openDirectConnection(documentName, context) }) try { diff --git a/server/collaborator/src/server.ts b/server/collaborator/src/server.ts index 8e7422ebc0..e3afeefbc2 100644 --- a/server/collaborator/src/server.ts +++ b/server/collaborator/src/server.ts @@ -136,24 +136,24 @@ export async function start (ctx: MeasureContext, config: Config, storageAdapter }) // eslint-disable-next-line @typescript-eslint/no-misused-promises - app.post('/rpc', async (req, res) => { + app.post('/rpc/:id', async (req, res) => { const authHeader = req.headers.authorization if (authHeader === undefined) { res.status(403).send({ error: 'Unauthorized' }) return } - const request = req.body as RpcRequest - - const documentId = request.documentId + const documentId = req.params.id if (documentId === undefined || documentId === '') { const response: RpcErrorResponse = { - error: 'Missing documentId' + error: 'Missing document id' } res.status(400).send(response) return } + const request = req.body as RpcRequest + const method = methods[request.method] if (method === undefined) { const response: RpcErrorResponse = { diff --git a/server/collaborator/src/storage/adapter.ts b/server/collaborator/src/storage/adapter.ts index 04573e7296..8b5fb44a7d 100644 --- a/server/collaborator/src/storage/adapter.ts +++ b/server/collaborator/src/storage/adapter.ts @@ -13,16 +13,15 @@ // limitations under the License. // -import { DocumentId } from '@hcengineering/collaborator-client' import { MeasureContext } from '@hcengineering/core' import { Doc as YDoc } from 'yjs' import { Context } from '../context' export interface CollabStorageAdapter { - loadDocument: (ctx: MeasureContext, documentId: DocumentId, context: Context) => Promise + loadDocument: (ctx: MeasureContext, documentId: string, context: Context) => Promise saveDocument: ( ctx: MeasureContext, - documentId: DocumentId, + documentId: string, document: YDoc, context: Context, markup: { diff --git a/server/collaborator/src/storage/platform.ts b/server/collaborator/src/storage/platform.ts index 3179ae5e5d..d9d7884479 100644 --- a/server/collaborator/src/storage/platform.ts +++ b/server/collaborator/src/storage/platform.ts @@ -14,61 +14,63 @@ // import activity, { DocUpdateMessage } from '@hcengineering/activity' -import { loadCollaborativeDoc, saveCollaborativeDoc } from '@hcengineering/collaboration' -import { - DocumentId, - PlatformDocumentId, - parseDocumentId, - parsePlatformDocumentId -} from '@hcengineering/collaborator-client' -import core, { - AttachedData, - CollaborativeDoc, - MeasureContext, - TxOperations, - collaborativeDocWithLastVersion -} from '@hcengineering/core' +import { loadCollabJson, loadCollabYdoc, saveCollabJson, saveCollabYdoc } from '@hcengineering/collaboration' +import { decodeDocumentId } from '@hcengineering/collaborator-client' +import core, { AttachedData, MeasureContext, TxOperations } from '@hcengineering/core' import { StorageAdapter } from '@hcengineering/server-core' +import { markupToYDocNoSchema, areEqualMarkups } from '@hcengineering/text' import { Doc as YDoc } from 'yjs' import { Context } from '../context' -import { areEqualMarkups } from '@hcengineering/text' import { CollabStorageAdapter } from './adapter' export class PlatformStorageAdapter implements CollabStorageAdapter { constructor (private readonly storage: StorageAdapter) {} - async loadDocument (ctx: MeasureContext, documentId: DocumentId, context: Context): Promise { + async loadDocument (ctx: MeasureContext, documentName: string, context: Context): Promise { + const { content, workspaceId } = context + const { documentId } = decodeDocumentId(documentName) + // try to load document content try { - ctx.info('load document content', { documentId }) - const ydoc = await this.loadDocumentFromStorage(ctx, documentId, context) + ctx.info('load document content', { documentName }) + + const ydoc = await ctx.with('loadCollabYdoc', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return loadCollabYdoc(ctx, this.storage, context.workspaceId, documentId) + }) + }) if (ydoc !== undefined) { return ydoc } } catch (err) { - ctx.error('failed to load document content', { documentId, error: err }) + ctx.error('failed to load document content', { documentName, error: err }) throw err } // then try to load from inital content - const { initialContentId } = context - if (initialContentId !== undefined && initialContentId.length > 0) { + if (content !== undefined) { try { - ctx.info('load document initial content', { documentId, initialContentId }) - const ydoc = await this.loadDocumentFromStorage(ctx, initialContentId, context) + ctx.info('load document initial content', { documentName, content }) + + const markup = await ctx.with('loadCollabJson', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return loadCollabJson(ctx, this.storage, workspaceId, content) + }) + }) + if (markup !== undefined) { + const ydoc = markupToYDocNoSchema(markup, documentId.objectAttr) + + // if document was loaded from the initial content or storage we need to save + // it to ensure the next time we load it from the ydoc document + await saveCollabYdoc(ctx, this.storage, workspaceId, documentId, ydoc) - // if document was loaded from the initial content or storage we need to save - // it to ensure the next time we load it from the ydoc document - if (ydoc !== undefined) { - ctx.info('save document content', { documentId, initialContentId }) - await this.saveDocumentToStorage(ctx, documentId, ydoc, context) return ydoc } } catch (err) { - ctx.error('failed to load initial document content', { documentId, initialContentId, error: err }) + ctx.error('failed to load initial document content', { documentName, content, error: err }) throw err } } @@ -79,7 +81,7 @@ export class PlatformStorageAdapter implements CollabStorageAdapter { async saveDocument ( ctx: MeasureContext, - documentId: DocumentId, + documentName: string, document: YDoc, context: Context, markup: { @@ -88,72 +90,45 @@ export class PlatformStorageAdapter implements CollabStorageAdapter { } ): Promise { const { clientFactory } = context + const { documentId } = decodeDocumentId(documentName) const client = await ctx.with('connect', {}, () => clientFactory()) try { try { - ctx.info('save document content', { documentId }) - await this.saveDocumentToStorage(ctx, documentId, document, context) + ctx.info('save document ydoc content', { documentName }) + await ctx.with('saveCollabYdoc', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return saveCollabYdoc(ctx, this.storage, context.workspaceId, documentId, document) + }) + }) } catch (err) { - ctx.error('failed to save document', { documentId, error: err }) + ctx.error('failed to save document ydoc content', { documentName, error: err }) // raise an error if failed to save document to storage // this will prevent document from being unloaded from memory throw err } - const { platformDocumentId } = context - if (platformDocumentId !== undefined) { - ctx.info('save document content to platform', { documentId, platformDocumentId }) - await ctx.with('save-to-platform', {}, (ctx) => - this.saveDocumentToPlatform(ctx, client, documentId, platformDocumentId, markup) - ) - } + ctx.info('save document content to platform', { documentName }) + await ctx.with('save-to-platform', {}, (ctx) => { + return this.saveDocumentToPlatform(ctx, client, documentName, markup) + }) } finally { await client.close() } } - async loadDocumentFromStorage ( - ctx: MeasureContext, - documentId: DocumentId, - context: Context - ): Promise { - const { collaborativeDoc } = parseDocumentId(documentId) - - return await ctx.with('load-document', {}, (ctx) => - withRetry(ctx, 5, async () => { - return await loadCollaborativeDoc(ctx, this.storage, context.workspaceId, collaborativeDoc) - }) - ) - } - - async saveDocumentToStorage ( - ctx: MeasureContext, - documentId: DocumentId, - document: YDoc, - context: Context - ): Promise { - const { collaborativeDoc } = parseDocumentId(documentId) - - await ctx.with('save-document', {}, (ctx) => - withRetry(ctx, 5, async () => { - await saveCollaborativeDoc(ctx, this.storage, context.workspaceId, collaborativeDoc, document) - }) - ) - } - async saveDocumentToPlatform ( ctx: MeasureContext, client: Omit, documentName: string, - platformDocumentId: PlatformDocumentId, markup: { prev: Record curr: Record } ): Promise { - const { objectClass, objectId, objectAttr } = parsePlatformDocumentId(platformDocumentId) + const { documentId, workspaceId } = decodeDocumentId(documentName) + const { objectAttr, objectClass, objectId } = documentId const currMarkup = markup.curr[objectAttr] const prevMarkup = markup.prev[objectAttr] @@ -184,10 +159,13 @@ export class PlatformStorageAdapter implements CollabStorageAdapter { return } - const collaborativeDoc = (current as any)[objectAttr] as CollaborativeDoc - const newCollaborativeDoc = collaborativeDocWithLastVersion(collaborativeDoc, `${Date.now()}`) + const blobId = await ctx.with('saveCollabJson', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return saveCollabJson(ctx, this.storage, { name: workspaceId }, documentId, markup.curr[objectAttr]) + }) + }) - await ctx.with('update', {}, () => client.diffUpdate(current, { [objectAttr]: newCollaborativeDoc })) + await ctx.with('update', {}, () => client.diffUpdate(current, { [objectAttr]: blobId })) await ctx.with('activity', {}, () => { const data: AttachedData = { diff --git a/server/core/src/adapter.ts b/server/core/src/adapter.ts index 25f53f4e86..175d73c736 100644 --- a/server/core/src/adapter.ts +++ b/server/core/src/adapter.ts @@ -69,8 +69,7 @@ export interface DbAdapter extends LowLevelStorage { helper?: () => DomainHelperOperations - closeContext?: (ctx: MeasureContext) => Promise - + reserveContext?: (id: string) => () => void close: () => Promise findAll: ( ctx: MeasureContext, diff --git a/server/core/src/dbAdapterManager.ts b/server/core/src/dbAdapterManager.ts index d6bba2e5ca..09d5c6dbd8 100644 --- a/server/core/src/dbAdapterManager.ts +++ b/server/core/src/dbAdapterManager.ts @@ -42,16 +42,22 @@ export class DbAdapterManagerImpl implements DBAdapterManager { private readonly adapters: Map ) {} - async closeContext (ctx: MeasureContext): Promise { + reserveContext (id: string): () => void { + const ops: (() => void)[] = [] for (const adapter of this.adapters.values()) { try { - if (adapter.closeContext !== undefined) { - await adapter.closeContext(ctx) + if (adapter.reserveContext !== undefined) { + ops.push(adapter.reserveContext(id)) } } catch (err: any) { Analytics.handleError(err) } } + return () => { + for (const op of ops) { + op() + } + } } getDefaultAdapter (): DbAdapter { diff --git a/server/core/src/triggers.ts b/server/core/src/triggers.ts index d4be68e058..311f55cd7a 100644 --- a/server/core/src/triggers.ts +++ b/server/core/src/triggers.ts @@ -17,7 +17,6 @@ import core, { TxFactory, TxProcessor, - generateId, groupByArray, matchQuery, type Class, @@ -145,13 +144,11 @@ export class Triggers { trigger.resource, {}, async (ctx) => { - if (mode === 'async') { - ctx.id = generateId() - } - const tresult = await this.applyTrigger(ctx, ctrl, matches, { trigger }) - result.push(...tresult) - if (ctx.onEnd !== undefined && mode === 'async') { - await ctx.onEnd(ctx) + try { + const tresult = await this.applyTrigger(ctx, ctrl, matches, { trigger }) + result.push(...tresult) + } catch (err: any) { + ctx.error('error during async processing', { err }) } }, { count: matches.length } diff --git a/server/core/src/types.ts b/server/core/src/types.ts index d6b4b2461f..afb621593b 100644 --- a/server/core/src/types.ts +++ b/server/core/src/types.ts @@ -152,7 +152,7 @@ export interface DBAdapterManager { initAdapters: (ctx: MeasureContext) => Promise - closeContext: (ctx: MeasureContext) => Promise + reserveContext: (id: string) => () => void domainHelper?: DomainHelper } diff --git a/server/indexer/src/indexer/indexer.ts b/server/indexer/src/indexer/indexer.ts index cb576d29f3..e3bc52c23a 100644 --- a/server/indexer/src/indexer/indexer.ts +++ b/server/indexer/src/indexer/indexer.ts @@ -21,7 +21,6 @@ import core, { type AttachedDoc, type Blob, type Class, - type CollaborativeDoc, DOMAIN_DOC_INDEX_STATE, DOMAIN_MIGRATION, type Doc, @@ -41,7 +40,6 @@ import core, { TxFactory, type WithLookup, type WorkspaceIdWithUrl, - collaborativeDocParse, coreId, docKey, generateId, @@ -63,7 +61,7 @@ import type { StorageAdapter } from '@hcengineering/server-core' import { RateLimiter, SessionDataImpl } from '@hcengineering/server-core' -import { jsonToText, markupToJSON, pmNodeToText, yDocContentToNodes } from '@hcengineering/text' +import { jsonToText, markupToJSON, markupToText } from '@hcengineering/text' import { findSearchPresenter, updateDocWithPresenter } from '../mapper' import { type FullTextPipeline } from './types' import { createIndexedDoc, createStateDoc, getContent } from './utils' @@ -129,8 +127,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { readonly storageAdapter: StorageAdapter, readonly contentAdapter: ContentTextAdapter, readonly broadcastUpdate: (ctx: MeasureContext, classes: Ref>[]) => void, - readonly checkIndexes: () => Promise, - readonly closeContext: (ctx: MeasureContext) => Promise + readonly checkIndexes: () => Promise ) { this.contexts = new Map(model.findAllSync(core.class.FullTextSearchContext, {}).map((it) => [it.toClass, it])) } @@ -408,7 +405,6 @@ export class FullTextIndexPipeline implements FullTextPipeline { await rateLimiter.exec(async () => { let st = Date.now() - ctx.id = generateId() let groupBy = await this.storage.groupBy(ctx, DOMAIN_DOC_INDEX_STATE, 'objectClass', { needIndex: true }) const total = Array.from(groupBy.values()).reduce((a, b) => a + b, 0) while (true) { @@ -526,7 +522,6 @@ export class FullTextIndexPipeline implements FullTextPipeline { this.metrics.error('error during index', { error: err }) } } - await this.closeContext(ctx) }) return { classUpdate: Array.from(_classUpdate.values()), processed } } @@ -780,14 +775,12 @@ export class FullTextIndexPipeline implements FullTextPipeline { v: { value: any, attr: AnyAttribute }, indexedDoc: IndexedDoc ): Promise { - const collaborativeDoc = v.value as CollaborativeDoc - if (collaborativeDoc !== undefined && collaborativeDoc !== '') { - const { documentId } = collaborativeDocParse(collaborativeDoc) - + const value = v.value as Ref + if (value !== undefined && value !== '') { try { - const readable = await this.storageAdapter?.read(ctx, this.workspace, documentId) - const nodes = yDocContentToNodes(Buffer.concat(readable as any)) - let textContent = nodes.map(pmNodeToText).join('\n') + const readable = await this.storageAdapter?.read(ctx, this.workspace, value) + const markup = Buffer.concat(readable as any).toString() + let textContent = markupToText(markup) textContent = textContent .split(/ +|\t+|\f+/) .filter((it) => it) @@ -798,7 +791,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { indexedDoc.fulltextSummary += '\n' + textContent } catch (err: any) { Analytics.handleError(err) - ctx.error('failed to handle blob', { _id: documentId, workspace: this.workspace.name }) + ctx.error('failed to handle blob', { _id: value, workspace: this.workspace.name }) } } } diff --git a/server/middleware/src/connectionMgr.ts b/server/middleware/src/connectionMgr.ts deleted file mode 100644 index 6abb509ba5..0000000000 --- a/server/middleware/src/connectionMgr.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { generateId, type MeasureContext, type Tx } from '@hcengineering/core' -import { - BaseMiddleware, - type Middleware, - type PipelineContext, - type TxMiddlewareResult -} from '@hcengineering/server-core' - -/** - * Will support apply tx - * @public - */ -export class ConnectionMgrMiddleware extends BaseMiddleware implements Middleware { - static async create ( - ctx: MeasureContext, - context: PipelineContext, - next?: Middleware - ): Promise { - return new ConnectionMgrMiddleware(context, next) - } - - async tx (ctx: MeasureContext, tx: Tx[]): Promise { - if (ctx.id === undefined) { - ctx.id = generateId() - } - ctx.onEnd = async (_ctx: MeasureContext) => { - await this.context.adapterManager?.closeContext?.(_ctx) - } - const result = await this.provideTx(ctx, tx) - if (ctx.onEnd !== undefined) { - await ctx.onEnd(ctx) - } - return result - } -} diff --git a/server/middleware/src/index.ts b/server/middleware/src/index.ts index 908b412a29..f34b8d2f2d 100644 --- a/server/middleware/src/index.ts +++ b/server/middleware/src/index.ts @@ -16,7 +16,6 @@ export * from './applyTx' export * from './broadcast' export * from './configuration' -export * from './connectionMgr' export * from './contextName' export * from './dbAdapter' export * from './dbAdapterHelper' diff --git a/server/middleware/src/triggers.ts b/server/middleware/src/triggers.ts index 7e107eb04f..83067623a9 100644 --- a/server/middleware/src/triggers.ts +++ b/server/middleware/src/triggers.ts @@ -33,7 +33,6 @@ import core, { type TxRemoveDoc, type TxUpdateDoc, addOperation, - generateId, toFindResult, withContext } from '@hcengineering/core' @@ -170,15 +169,18 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware { await this.processDerivedTxes(ctx, derived) } - const asyncProcess = this.processAsyncTriggers(ctx, triggerControl, findAll, txes, triggers) - // In case of async context, we execute both async and sync triggers as sync + const performSync = (ctx as MeasureContext).contextData.isAsyncContext ?? false - if ((ctx as MeasureContext).contextData.isAsyncContext ?? false) { - await asyncProcess + if (performSync) { + await this.processAsyncTriggers(ctx, triggerControl, findAll, txes, triggers) } else { - asyncProcess.catch((err) => { - ctx.error('error during processing async triggers', { err }) - }) + ctx.contextData.asyncRequests = [ + ...(ctx.contextData.asyncRequests ?? []), + async () => { + // In case of async context, we execute both async and sync triggers as sync + await this.processAsyncTriggers(ctx, triggerControl, findAll, txes, triggers) + } + ] } } @@ -224,10 +226,6 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware { this.context.modelDb ) ctx.contextData = asyncContextData - - if ((ctx as MeasureContext).contextData.isAsyncContext ?? false) { - ctx.id = 'async_tr' + generateId() - } const aresult = await this.triggers.apply( ctx, txes, @@ -247,9 +245,6 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware { await this.context.head?.handleBroadcast(ctx) }) } - if (ctx.onEnd !== undefined) { - await ctx.onEnd(ctx) - } } private async processDerivedTxes (ctx: MeasureContext, derived: Tx[]): Promise { diff --git a/server/postgres/src/storage.ts b/server/postgres/src/storage.ts index 8f4c5fdfc0..12dad481b9 100644 --- a/server/postgres/src/storage.ts +++ b/server/postgres/src/storage.ts @@ -27,6 +27,7 @@ import core, { DOMAIN_TX, type FindOptions, type FindResult, + generateId, groupByArray, type Hierarchy, isOperator, @@ -50,6 +51,7 @@ import core, { type TxRemoveDoc, type TxResult, type TxUpdateDoc, + withContext, type WithLookup, type WorkspaceId } from '@hcengineering/core' @@ -107,90 +109,231 @@ async function * createCursorGenerator ( } } -abstract class PostgresAdapterBase implements DbAdapter { - protected readonly _helper: DBCollectionHelper - protected readonly tableFields = new Map() - protected readonly connections = new Map>() +class ConnectionInfo { + // It should preserve at least one available connection in pool, other connection should be closed + available: postgres.ReservedSql[] = [] - protected readonly retryTxn = async ( - client: postgres.ReservedSql, - fn: (client: postgres.ReservedSql) => Promise - ): Promise => { - const backoffInterval = 100 // millis - const maxTries = 5 - let tries = 0 + released: boolean = false - while (true) { - await client.unsafe('BEGIN;') - tries++ + constructor ( + readonly mgrId: string, + readonly connectionId: string, + protected readonly client: postgres.Sql, + readonly managed: boolean + ) {} - try { - const result = await fn(client) - await client.unsafe('COMMIT;') - return result - } catch (err: any) { - await client.unsafe('ROLLBACK;') + async withReserve ( + reserveOrPool: boolean, + action: (reservedClient: postgres.ReservedSql | postgres.Sql) => Promise + ): Promise { + let reserved: postgres.ReservedSql | undefined - if (err.code !== '40001' || tries === maxTries) { - throw err - } else { - console.log('Transaction failed. Retrying.') - console.log(err.message) - await new Promise((resolve) => setTimeout(resolve, tries * backoffInterval)) + // Check if we have at least one available connection and reserve one more if required. + if (this.available.length === 0) { + if (reserveOrPool) { + reserved = await this.client.reserve() + } + } else { + reserved = this.available.shift() as postgres.ReservedSql + } + + try { + // Use reserved or pool + return await action(reserved ?? this.client) + } catch (err: any) { + console.error(err) + throw err + } finally { + if (this.released) { + reserved?.release() + } else { + // after use we put into available + if (reserved !== undefined) { + this.available.push(reserved) + } + + if (this.available.length > 1) { + // We need to release any >= 1 + const toRelease = this.available.splice(1, this.available.length - 1) + + for (const r of toRelease) { + r.release() + } } } } } + release (): void { + for (const c of this.available) { + c.release() + } + this.available = [] + } +} + +const connections = new Map() + +class ConnectionMgr { + constructor ( + protected readonly client: postgres.Sql, + readonly mgrId: string + ) {} + + async write ( + id: string | undefined, + fn: (client: postgres.Sql | postgres.ReservedSql) => Promise + ): Promise { + const backoffInterval = 25 // millis + const maxTries = 5 + let tries = 0 + + const realId = id ?? generateId() + + const connection = this.getConnection(realId, false) + + try { + while (true) { + const retry: boolean | Error = await connection.withReserve(true, async (client) => { + tries++ + try { + await client.unsafe('BEGIN;') + await fn(client) + await client.unsafe('COMMIT;') + return true + } catch (err: any) { + await client.unsafe('ROLLBACK;') + + if (err.code !== '40001' || tries === maxTries) { + return err + } else { + console.log('Transaction failed. Retrying.') + console.log(err.message) + return false + } + } + }) + if (retry === true) { + break + } + if (retry instanceof Error) { + // Pass it to exit + throw retry + } + // Retry for a timeout + await new Promise((resolve) => setTimeout(resolve, backoffInterval)) + } + } finally { + if (!connection.managed) { + // We need to relase in case it temporaty connection was used + connection.release() + } + } + } + + async read (id: string | undefined, fn: (client: postgres.Sql | postgres.ReservedSql) => Promise): Promise { + const backoffInterval = 25 // millis + const maxTries = 5 + let tries = 0 + + const realId = id ?? generateId() + // Will reuse reserved if had and use new one if not + const connection = this.getConnection(realId, false) + + try { + while (true) { + const retry: false | { result: any } | Error = await connection.withReserve(false, async (client) => { + tries++ + try { + return { result: await fn(client) } + } catch (err: any) { + if (err.code !== '40001' || tries === maxTries) { + return err + } else { + console.log('Read Transaction failed. Retrying.') + console.log(err.message) + return false + } + } + }) + if (retry instanceof Error) { + // Pass it to exit + throw retry + } + if (retry === false) { + // Retry for a timeout + await new Promise((resolve) => setTimeout(resolve, backoffInterval)) + continue + } + return retry.result + } + } finally { + if (!connection.managed) { + // We need to relase in case it temporaty connection was used + connection.release() + } + } + } + + release (id: string): void { + const conn = connections.get(id) + if (conn !== undefined) { + conn.released = true + connections.delete(id) // We need to delete first + conn.release() + } else { + console.log('wrne') + } + } + + close (): void { + for (const [k, conn] of Array.from(connections.entries()).filter( + ([, it]: [string, ConnectionInfo]) => it.mgrId === this.mgrId + )) { + connections.delete(k) + conn.release() + } + } + + getConnection (id: string, managed: boolean = true): ConnectionInfo { + let conn = connections.get(id) + if (conn === undefined) { + conn = new ConnectionInfo(this.mgrId, id, this.client, managed) + } + if (managed) { + connections.set(id, conn) + } + return conn + } +} + +abstract class PostgresAdapterBase implements DbAdapter { + protected readonly _helper: DBCollectionHelper + protected readonly tableFields = new Map() + + mgr: ConnectionMgr + constructor ( protected readonly client: postgres.Sql, protected readonly refClient: PostgresClientReference, protected readonly workspaceId: WorkspaceId, protected readonly hierarchy: Hierarchy, - protected readonly modelDb: ModelDb + protected readonly modelDb: ModelDb, + readonly mgrId: string ) { this._helper = new DBCollectionHelper(this.client, this.workspaceId) + this.mgr = new ConnectionMgr(client, mgrId) } - protected async withConnection( - ctx: MeasureContext, - operation: (client: postgres.ReservedSql) => Promise - ): Promise { - const connection = await this.getConnection(ctx) - if (connection !== undefined) { - return await operation(connection) - } else { - const client = await this.client.reserve() - try { - return await operation(client) - } finally { - client.release() - } + reserveContext (id: string): () => void { + const conn = this.mgr.getConnection(id, true) + return () => { + conn.released = true + conn.release() + connections.delete(id) // We need to delete first } } - async closeContext (ctx: MeasureContext): Promise { - if (ctx.id === undefined) return - const conn = this.connections.get(ctx.id) - if (conn !== undefined) { - if (conn instanceof Promise) { - ;(await conn).release() - } else { - conn.release() - } - this.connections.delete(ctx.id) - } - } - - protected async getConnection (ctx: MeasureContext): Promise { - if (ctx.id === undefined) return - const conn = this.connections.get(ctx.id) - if (conn !== undefined) return await conn - const client = this.client.reserve() - this.connections.set(ctx.id, client) - return await client - } - async traverse( _domain: Domain, query: DocumentQuery, @@ -236,13 +379,7 @@ abstract class PostgresAdapterBase implements DbAdapter { abstract init (): Promise async close (): Promise { - for (const c of this.connections.values()) { - if (c instanceof Promise) { - ;(await c).release() - } else { - c.release() - } - } + this.mgr.close() this.refClient.close() } @@ -258,8 +395,8 @@ abstract class PostgresAdapterBase implements DbAdapter { sqlChunks.push(`LIMIT ${options.limit}`) } const finalSql: string = [select, ...sqlChunks].join(' ') - const result = await this.client.unsafe(finalSql) - return result.map((p) => parseDocWithProjection(p as any, domain, options?.projection)) + const result: DBDoc[] = await this.mgr.read(undefined, (client) => client.unsafe(finalSql)) + return result.map((p) => parseDocWithProjection(p, domain, options?.projection)) } buildRawOrder(domain: string, sort: SortingQuery): string { @@ -305,66 +442,61 @@ abstract class PostgresAdapterBase implements DbAdapter { if ((operations as any)['%hash%'] === undefined) { ;(operations as any)['%hash%'] = null } + const domainFields = new Set(getDocFieldsByDomains(domain)) if (isOps) { - const conn = await this.client.reserve() - try { - await this.retryTxn(conn, async (client) => { - const res = await client.unsafe( - `SELECT * FROM ${translateDomain(domain)} WHERE ${translatedQuery} FOR UPDATE` - ) - const schema = getSchema(domain) - const docs = res.map((p) => parseDoc(p as any, schema)) - const domainFields = new Set(getDocFieldsByDomains(domain)) - for (const doc of docs) { - if (doc === undefined) continue - const prevAttachedTo = (doc as any).attachedTo - TxProcessor.applyUpdate(doc, operations) - const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields) - const params: any[] = [doc._id, this.workspaceId.name] - let paramsIndex = params.length + 1 - const updates: string[] = [] - const { extractedFields, remainingData } = parseUpdate(domain, operations) - const newAttachedTo = (doc as any).attachedTo - if (Object.keys(extractedFields).length > 0) { - for (const key in extractedFields) { - const val = (extractedFields as any)[key] - if (key === 'attachedTo' && val === prevAttachedTo) continue - updates.push(`"${key}" = $${paramsIndex++}`) - params.push(val) - } - } else if (prevAttachedTo !== undefined && prevAttachedTo !== newAttachedTo) { - updates.push(`"attachedTo" = $${paramsIndex++}`) - params.push(newAttachedTo) + await this.mgr.write(undefined, async (client) => { + const res = await client.unsafe(`SELECT * FROM ${translateDomain(domain)} WHERE ${translatedQuery} FOR UPDATE`) + const schema = getSchema(domain) + const docs = res.map((p) => parseDoc(p as any, schema)) + for (const doc of docs) { + if (doc === undefined) continue + const prevAttachedTo = (doc as any).attachedTo + TxProcessor.applyUpdate(doc, operations) + ;(doc as any)['%hash%'] = null + const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields) + const params: any[] = [doc._id, this.workspaceId.name] + let paramsIndex = params.length + 1 + const updates: string[] = [] + const { extractedFields, remainingData } = parseUpdate(operations, domainFields) + const newAttachedTo = (doc as any).attachedTo + if (Object.keys(extractedFields).length > 0) { + for (const key in extractedFields) { + const val = (extractedFields as any)[key] + if (key === 'attachedTo' && val === prevAttachedTo) continue + updates.push(`"${key}" = $${paramsIndex++}`) + params.push(val) } - - if (Object.keys(remainingData).length > 0) { - updates.push(`data = $${paramsIndex++}`) - params.push(converted.data) - } - await client.unsafe( - `UPDATE ${translateDomain(domain)} SET ${updates.join(', ')} WHERE _id = $1 AND "workspaceId" = $2`, - params - ) + } else if (prevAttachedTo !== undefined && prevAttachedTo !== newAttachedTo) { + updates.push(`"attachedTo" = $${paramsIndex++}`) + params.push(newAttachedTo) } - }) - } finally { - conn.release() - } + + if (Object.keys(remainingData).length > 0) { + updates.push(`data = $${paramsIndex++}`) + params.push(converted.data) + } + await client.unsafe( + `UPDATE ${translateDomain(domain)} SET ${updates.join(', ')} WHERE _id = $1 AND "workspaceId" = $2`, + params + ) + } + }) } else { - await this.rawUpdateDoc(domain, query, operations) + await this.rawUpdateDoc(domain, query, operations, domainFields) } } private async rawUpdateDoc( domain: Domain, query: DocumentQuery, - operations: DocumentUpdate + operations: DocumentUpdate, + domainFields: Set ): Promise { const translatedQuery = this.buildRawQuery(domain, query) const updates: string[] = [] const params: any[] = [] let paramsIndex = params.length + 1 - const { extractedFields, remainingData } = parseUpdate(domain, operations) + const { extractedFields, remainingData } = parseUpdate(operations, domainFields) const { space, attachedTo, ...ops } = operations as any for (const key in extractedFields) { updates.push(`"${key}" = $${paramsIndex++}`) @@ -382,32 +514,19 @@ abstract class PostgresAdapterBase implements DbAdapter { if (dataUpdated) { updates.push(`data = ${from}`) } - const conn = await this.client.reserve() - try { - await this.retryTxn(conn, async (client) => { - await client.unsafe( - `UPDATE ${translateDomain(domain)} SET ${updates.join(', ')} WHERE ${translatedQuery}`, - params - ) - }) - } catch (err: any) { - console.error(err, { domain, params, updates }) - throw err - } finally { - conn.release() - } + await this.mgr.write(undefined, async (client) => { + await client.unsafe( + `UPDATE ${translateDomain(domain)} SET ${updates.join(', ')} WHERE ${translatedQuery}`, + params + ) + }) } async rawDeleteMany(domain: Domain, query: DocumentQuery): Promise { const translatedQuery = this.buildRawQuery(domain, query) - const conn = await this.client.reserve() - try { - await this.retryTxn(conn, async (client) => { - await client.unsafe(`DELETE FROM ${translateDomain(domain)} WHERE ${translatedQuery}`) - }) - } finally { - conn.release() - } + await this.mgr.write(undefined, async (client) => { + await client.unsafe(`DELETE FROM ${translateDomain(domain)} WHERE ${translatedQuery}`) + }) } findAll( @@ -447,34 +566,37 @@ abstract class PostgresAdapterBase implements DbAdapter { sqlChunks.push(this.buildJoinString(joins)) } sqlChunks.push(`WHERE ${this.buildQuery(_class, domain, query, joins, options)}`) - const connection = (await this.getConnection(ctx)) ?? this.client - let total = options?.total === true ? 0 : -1 - if (options?.total === true) { - const totalReq = `SELECT COUNT(${domain}._id) as count FROM ${domain}` - const totalSql = [totalReq, ...sqlChunks].join(' ') - const totalResult = await connection.unsafe(totalSql) - const parsed = Number.parseInt(totalResult[0].count) - total = Number.isNaN(parsed) ? 0 : parsed - } - if (options?.sort !== undefined) { - sqlChunks.push(this.buildOrder(_class, domain, options.sort, joins)) - } - if (options?.limit !== undefined) { - sqlChunks.push(`LIMIT ${options.limit}`) - } + const findId = ctx.id ?? generateId() - const finalSql: string = [select, ...sqlChunks].join(' ') - const result = await connection.unsafe(finalSql) - if (options?.lookup === undefined && options?.domainLookup === undefined) { - return toFindResult( - result.map((p) => parseDocWithProjection(p as any, domain, options?.projection)), - total - ) - } else { - const res = this.parseLookup(result, joins, options?.projection, domain) - return toFindResult(res, total) - } + return (await this.mgr.read(findId, async (connection) => { + let total = options?.total === true ? 0 : -1 + if (options?.total === true) { + const totalReq = `SELECT COUNT(${domain}._id) as count FROM ${domain}` + const totalSql = [totalReq, ...sqlChunks].join(' ') + const totalResult = await connection.unsafe(totalSql) + const parsed = Number.parseInt(totalResult[0].count) + total = Number.isNaN(parsed) ? 0 : parsed + } + if (options?.sort !== undefined) { + sqlChunks.push(this.buildOrder(_class, domain, options.sort, joins)) + } + if (options?.limit !== undefined) { + sqlChunks.push(`LIMIT ${options.limit}`) + } + + const finalSql: string = [select, ...sqlChunks].join(' ') + const result = await connection.unsafe(finalSql) + if (options?.lookup === undefined && options?.domainLookup === undefined) { + return toFindResult( + result.map((p) => parseDocWithProjection(p as any, domain, options?.projection)), + total + ) + } else { + const res = this.parseLookup(result, joins, options?.projection, domain) + return toFindResult(res, total) + } + })) as FindResult } catch (err) { ctx.error('Error in findAll', { err }) throw err @@ -1131,34 +1253,35 @@ abstract class PostgresAdapterBase implements DbAdapter { const tdomain = translateDomain(domain) const schema = getSchema(domain) + const findId = generateId() + const flush = async (flush = false): Promise => { if (bulkUpdate.size > 1000 || flush) { if (bulkUpdate.size > 0) { const entries = Array.from(bulkUpdate.entries()) bulkUpdate.clear() - const updateClient = await this.client.reserve() try { while (entries.length > 0) { const part = entries.splice(0, 200) const data: string[] = part.flat() const indexes = part.map((val, idx) => `($${2 * idx + 1}::text, $${2 * idx + 2}::text)`).join(', ') await ctx.with('bulk-write-find', {}, () => { - return this.retryTxn(updateClient, (client) => - client.unsafe( - ` + return this.mgr.write( + findId, + async (client) => + await client.unsafe( + ` UPDATE ${tdomain} SET "%hash%" = update_data.hash FROM (values ${indexes}) AS update_data(_id, hash) WHERE ${tdomain}."workspaceId" = '${this.workspaceId.name}' AND ${tdomain}."_id" = update_data._id `, - data - ) + data + ) ) }) } } catch (err: any) { ctx.error('failed to update hash', { err }) - } finally { - updateClient.release() } } } @@ -1182,10 +1305,10 @@ abstract class PostgresAdapterBase implements DbAdapter { } if (recheck === true) { - await this.retryTxn( - client, - (client) => - client`UPDATE ${client(tdomain)} SET "%hash%" = NULL WHERE "workspaceId" = ${this.workspaceId.name} AND "%hash%" IS NOT NULL` + await this.mgr.write( + findId, + async (client) => + await client`UPDATE ${client(tdomain)} SET "%hash%" = NULL WHERE "workspaceId" = ${this.workspaceId.name} AND "%hash%" IS NOT NULL` ) } @@ -1207,10 +1330,10 @@ abstract class PostgresAdapterBase implements DbAdapter { if (pos === -1) { await bulk.return([]) // We need to close generator docs = { done: true, value: undefined } - await this.retryTxn( - client, - (client) => - client`UPDATE ${client(tdomain)} SET "%hash%" = NULL WHERE "workspaceId" = ${this.workspaceId.name} AND "%hash%" IS NOT NULL` + await this.mgr.write( + findId, + async (client) => + await client`UPDATE ${client(tdomain)} SET "%hash%" = NULL WHERE "workspaceId" = ${this.workspaceId.name} AND "%hash%" IS NOT NULL` ) break } @@ -1247,14 +1370,12 @@ abstract class PostgresAdapterBase implements DbAdapter { if (docs.length === 0) { return [] } - const client = await this.client.reserve() - try { + + return await this.mgr.read('', async (client) => { const res = await client`SELECT * FROM ${client(translateDomain(domain))} WHERE _id = ANY(${docs}) AND "workspaceId" = ${this.workspaceId.name}` return res.map((p) => parseDocWithProjection(p as any, domain)) - } finally { - client.release() - } + }) }) } @@ -1271,7 +1392,6 @@ abstract class PostgresAdapterBase implements DbAdapter { const insertStr = insertFields.join(', ') const onConflictStr = onConflict.join(', ') - const client = await this.client.reserve() try { const domainFields = new Set(getDocFieldsByDomains(domain)) const toUpload = [...docs] @@ -1305,40 +1425,35 @@ abstract class PostgresAdapterBase implements DbAdapter { } const vals = vars.join(',') - await this.retryTxn(client, (client) => - client.unsafe( - `INSERT INTO ${tdomain} ("workspaceId", ${insertStr}) VALUES ${vals} + await this.mgr.write( + ctx.id, + async (client) => + await client.unsafe( + `INSERT INTO ${tdomain} ("workspaceId", ${insertStr}) VALUES ${vals} ON CONFLICT ("workspaceId", _id) DO UPDATE SET ${onConflictStr};`, - values - ) + values + ) ) } } catch (err: any) { ctx.error('failed to upload', { err }) throw err - } finally { - client.release() } }) } async clean (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { - const updateClient = await this.client.reserve() - try { - const tdomain = translateDomain(domain) - const toClean = [...docs] - while (toClean.length > 0) { - const part = toClean.splice(0, 200) - await ctx.with('clean', {}, () => { - return this.retryTxn( - updateClient, - (client) => - client`DELETE FROM ${client(tdomain)} WHERE _id = ANY(${part}) AND "workspaceId" = ${this.workspaceId.name}` - ) - }) - } - } finally { - updateClient.release() + const tdomain = translateDomain(domain) + const toClean = [...docs] + while (toClean.length > 0) { + const part = toClean.splice(0, 200) + await ctx.with('clean', {}, () => { + return this.mgr.write( + ctx.id, + (client) => + client`DELETE FROM ${client(tdomain)} WHERE _id = ANY(${part}) AND "workspaceId" = ${this.workspaceId.name}` + ) + }) } } @@ -1350,12 +1465,13 @@ abstract class PostgresAdapterBase implements DbAdapter { ): Promise> { const key = isDataField(domain, field) ? `data ->> '${field}'` : `"${field}"` return ctx.with('groupBy', { domain }, async (ctx) => { - const connection = (await this.getConnection(ctx)) ?? this.client try { - const result = await connection.unsafe( - `SELECT DISTINCT ${key} as ${field}, Count(*) AS count FROM ${translateDomain(domain)} WHERE ${this.buildRawQuery(domain, query ?? {})} GROUP BY ${key}` - ) - return new Map(result.map((r) => [r[field.toLocaleLowerCase()], parseInt(r.count)])) + return await this.mgr.read(ctx.id ?? generateId(), async (connection) => { + const result = await connection.unsafe( + `SELECT DISTINCT ${key} as ${field}, Count(*) AS count FROM ${translateDomain(domain)} WHERE ${this.buildRawQuery(domain, query ?? {})} GROUP BY ${key}` + ) + return new Map(result.map((r) => [r[field.toLocaleLowerCase()], parseInt(r.count)])) + }) } catch (err) { ctx.error('Error while grouping by', { domain, field }) throw err @@ -1365,74 +1481,85 @@ abstract class PostgresAdapterBase implements DbAdapter { update (ctx: MeasureContext, domain: Domain, operations: Map, DocumentUpdate>): Promise { const ids = Array.from(operations.keys()) - return this.withConnection(ctx, (client) => { - return this.retryTxn(client, async (client) => { - try { - const res = - await client`SELECT * FROM ${client(translateDomain(domain))} WHERE _id = ANY(${ids}) AND "workspaceId" = ${this.workspaceId.name} FOR UPDATE` - const schema = getSchema(domain) - const docs = res.map((p) => parseDoc(p as any, schema)) - const map = new Map(docs.map((d) => [d._id, d])) - const domainFields = new Set(getDocFieldsByDomains(domain)) - for (const [_id, ops] of operations) { - const doc = map.get(_id) - if (doc === undefined) continue - const op = { ...ops } - if ((op as any)['%hash%'] === undefined) { - ;(op as any)['%hash%'] = null - } - TxProcessor.applyUpdate(doc, op) - const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields) - - const columns: string[] = [] - const { extractedFields, remainingData } = parseUpdate(domain, op) - for (const key in extractedFields) { - columns.push(key) - } - if (Object.keys(remainingData).length > 0) { - columns.push('data') - } - columns.push('modifiedBy') - columns.push('modifiedOn') - await client`UPDATE ${client(translateDomain(domain))} SET ${client( - converted, - columns - )} WHERE _id = ${doc._id} AND "workspaceId" = ${this.workspaceId.name}` + return this.mgr.write(ctx.id, async (client) => { + try { + const res: DBDoc[] = + await client`SELECT * FROM ${client(translateDomain(domain))} WHERE _id = ANY(${ids}) AND "workspaceId" = ${this.workspaceId.name} FOR UPDATE` + const schema = getSchema(domain) + const docs = res.map((p) => parseDoc(p, schema)) + const map = new Map(docs.map((d) => [d._id, d])) + const domainFields = new Set(getDocFieldsByDomains(domain)) + for (const [_id, ops] of operations) { + const doc = map.get(_id) + if (doc === undefined) continue + const op = { ...ops } + if ((op as any)['%hash%'] === undefined) { + ;(op as any)['%hash%'] = null } - } catch (err) { - ctx.error('Error while updating', { domain, operations, err }) - throw err + TxProcessor.applyUpdate(doc, op) + const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields) + + const columns: string[] = [] + const { extractedFields, remainingData } = parseUpdate(op, domainFields) + for (const key in extractedFields) { + columns.push(key) + } + if (Object.keys(remainingData).length > 0) { + columns.push('data') + } + columns.push('modifiedBy') + columns.push('modifiedOn') + await client`UPDATE ${client(translateDomain(domain))} SET ${client( + converted, + columns + )} WHERE _id = ${doc._id} AND "workspaceId" = ${this.workspaceId.name}` } - }) + } catch (err) { + ctx.error('Error while updating', { domain, operations, err }) + throw err + } }) } + @withContext('insert') async insert (ctx: MeasureContext, domain: string, docs: Doc[]): Promise { - const fields = getDocFieldsByDomains(domain) + const schema = getSchema(domain) + const fields = Object.keys(schema) const filedsWithData = [...fields, 'data'] const columns: string[] = ['workspaceId'] for (const field of filedsWithData) { columns.push(field) } - await this.withConnection(ctx, async (connection) => { - const domainFields = new Set(getDocFieldsByDomains(domain)) - while (docs.length > 0) { - const part = docs.splice(0, 500) - const values: DBDoc[] = [] - for (let i = 0; i < part.length; i++) { - const doc = part[i] - const d = convertDoc(domain, doc, this.workspaceId.name, domainFields) - values.push(d) - } - await this.retryTxn(connection, async (client) => { - await client`INSERT INTO ${client(translateDomain(domain))} ${client(values, columns)}` - }) + const domainFields = new Set(fields) + while (docs.length > 0) { + const part = docs.splice(0, 500) + const values: DBDoc[] = [] + for (let i = 0; i < part.length; i++) { + const doc = part[i] + const d = convertDoc(domain, doc, this.workspaceId.name, domainFields) + values.push(d) } - }) + await this.mgr.write(ctx.id, async (client) => { + try { + await client`INSERT INTO ${client(translateDomain(domain))} ${client(values, columns)}` + } catch (err: any) { + console.error('inserting error', err) + } + }) + } return {} } } +interface OperationBulk { + add: Doc[] + updates: TxUpdateDoc[] + + removes: TxRemoveDoc[] + + mixins: TxMixin[] +} + class PostgresAdapter extends PostgresAdapterBase { async init (domains?: string[], excludeDomains?: string[]): Promise { let resultDomains = domains ?? this.hierarchy.domains() @@ -1443,17 +1570,20 @@ class PostgresAdapter extends PostgresAdapterBase { this._helper.domains = new Set(resultDomains as Domain[]) } - private async process (ctx: MeasureContext, tx: Tx): Promise { + private process (ops: OperationBulk, tx: Tx): void { switch (tx._class) { case core.class.TxCreateDoc: - return await this.txCreateDoc(ctx, tx as TxCreateDoc) + ops.add.push(TxProcessor.createDoc2Doc(tx as TxCreateDoc)) + break case core.class.TxUpdateDoc: - return await this.txUpdateDoc(ctx, tx as TxUpdateDoc) + ops.updates.push(tx as TxUpdateDoc) + break case core.class.TxRemoveDoc: - await this.txRemoveDoc(ctx, tx as TxRemoveDoc) + ops.removes.push(tx as TxRemoveDoc) break case core.class.TxMixin: - return await this.txMixin(ctx, tx as TxMixin) + ops.mixins.push(tx as TxMixin) + break case core.class.TxApplyIf: return undefined default: @@ -1462,26 +1592,24 @@ class PostgresAdapter extends PostgresAdapterBase { } } - private async txMixin (ctx: MeasureContext, tx: TxMixin): Promise { + private async txMixin (ctx: MeasureContext, tx: TxMixin, domainFields: Set): Promise { await ctx.with('tx-mixin', { _class: tx.objectClass, mixin: tx.mixin }, async (ctx) => { - await this.withConnection(ctx, async (connection) => { - await this.retryTxn(connection, async (client) => { - const doc = await this.findDoc(ctx, client, tx.objectClass, tx.objectId, true) - if (doc === undefined) return - TxProcessor.updateMixin4Doc(doc, tx) - ;(doc as any)['%hash%'] = null - const domain = this.hierarchy.getDomain(tx.objectClass) - const converted = convertDoc(domain, doc, this.workspaceId.name) - const { extractedFields } = parseUpdate(domain, tx.attributes as Partial) - const columns = new Set() - for (const key in extractedFields) { - columns.add(key) - } - columns.add('modifiedBy') - columns.add('modifiedOn') - columns.add('data') - await client`UPDATE ${client(translateDomain(domain))} SET ${client(converted, Array.from(columns))} WHERE _id = ${tx.objectId} AND "workspaceId" = ${this.workspaceId.name}` - }) + await this.mgr.write(ctx.id, async (client) => { + const doc = await this.findDoc(ctx, client, tx.objectClass, tx.objectId, true) + if (doc === undefined) return + TxProcessor.updateMixin4Doc(doc, tx) + ;(doc as any)['%hash%'] = null + const domain = this.hierarchy.getDomain(tx.objectClass) + const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields) + const { extractedFields } = parseUpdate(tx.attributes as Partial, domainFields) + const columns = new Set() + for (const key in extractedFields) { + columns.add(key) + } + columns.add('modifiedBy') + columns.add('modifiedOn') + columns.add('data') + await client`UPDATE ${client(translateDomain(domain))} SET ${client(converted, Array.from(columns))} WHERE _id = ${tx.objectId} AND "workspaceId" = ${this.workspaceId.name}` }) }) return {} @@ -1501,25 +1629,52 @@ class PostgresAdapter extends PostgresAdapterBase { if (domain === undefined) { continue } + const ops: OperationBulk = { + add: [], + mixins: [], + removes: [], + updates: [] + } for (const tx of txs) { - const res = await this.process(ctx, tx) - if (res !== undefined) { + this.process(ops, tx) + } + + const domainFields = new Set(getDocFieldsByDomains(domain)) + if (ops.add.length > 0) { + const res = await this.insert(ctx, domain, ops.add) + if (Object.keys(res).length > 0) { result.push(res) } } + // TODO: Optimize updates + if (ops.updates.length > 0) { + for (const upd of ops.updates) { + const res = await this.txUpdateDoc(ctx, upd, domainFields) + if (Object.keys(res).length > 0) { + result.push(res) + } + } + } + // TODO: Optimize mixins + for (const mix of ops.mixins) { + const res = await this.txMixin(ctx, mix, domainFields) + if (Object.keys(res).length > 0) { + result.push(res) + } + } + if (ops.removes.length > 0) { + await this.clean( + ctx, + domain, + ops.removes.map((it) => it.objectId) + ) + } } return result } - protected async txCreateDoc (ctx: MeasureContext, tx: TxCreateDoc): Promise { - const doc = TxProcessor.createDoc2Doc(tx) - return await ctx.with('create-doc', { _class: doc._class }, (_ctx) => { - return this.insert(_ctx, this.hierarchy.getDomain(doc._class), [doc]) - }) - } - - protected txUpdateDoc (ctx: MeasureContext, tx: TxUpdateDoc): Promise { + protected txUpdateDoc (ctx: MeasureContext, tx: TxUpdateDoc, domainFields: Set): Promise { return ctx.with('tx-update-doc', { _class: tx.objectClass }, (_ctx) => { if (isOperator(tx.operations)) { let doc: Doc | undefined @@ -1528,45 +1683,48 @@ class PostgresAdapter extends PostgresAdapterBase { 'update with operations', { operations: JSON.stringify(Object.keys(tx.operations)) }, async (ctx) => { - return await this.withConnection(ctx, async (connection) => { - await this.retryTxn(connection, async (client) => { - doc = await this.findDoc(ctx, client, tx.objectClass, tx.objectId, true) - if (doc === undefined) return {} - ops.modifiedBy = tx.modifiedBy - ops.modifiedOn = tx.modifiedOn - TxProcessor.applyUpdate(doc, ops) - const domain = this.hierarchy.getDomain(tx.objectClass) - const converted = convertDoc(domain, doc, this.workspaceId.name) - const columns: string[] = [] - const { extractedFields, remainingData } = parseUpdate(domain, ops) - for (const key in extractedFields) { - columns.push(key) - } - if (Object.keys(remainingData).length > 0) { - columns.push('data') - } - await client`UPDATE ${client(translateDomain(domain))} SET ${client(converted, columns)} WHERE _id = ${tx.objectId} AND "workspaceId" = ${this.workspaceId.name}` - }) - if (tx.retrieve === true && doc !== undefined) { - return { object: doc } + await this.mgr.write(ctx.id, async (client) => { + doc = await this.findDoc(ctx, client, tx.objectClass, tx.objectId, true) + if (doc === undefined) return {} + ops.modifiedBy = tx.modifiedBy + ops.modifiedOn = tx.modifiedOn + TxProcessor.applyUpdate(doc, ops) + ;(doc as any)['%hash%'] = null + const domain = this.hierarchy.getDomain(tx.objectClass) + const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields) + const columns: string[] = [] + const { extractedFields, remainingData } = parseUpdate(ops, domainFields) + for (const key in extractedFields) { + columns.push(key) } - return {} + if (Object.keys(remainingData).length > 0) { + columns.push('data') + } + await client`UPDATE ${client(translateDomain(domain))} SET ${client(converted, columns)} WHERE _id = ${tx.objectId} AND "workspaceId" = ${this.workspaceId.name}` }) + if (tx.retrieve === true && doc !== undefined) { + return { object: doc } + } + return {} } ) } else { - return this.updateDoc(_ctx, tx, tx.retrieve ?? false) + return this.updateDoc(_ctx, tx, tx.retrieve ?? false, domainFields) } }) } - private updateDoc(ctx: MeasureContext, tx: TxUpdateDoc, retrieve: boolean): Promise { + private updateDoc( + ctx: MeasureContext, + tx: TxUpdateDoc, + retrieve: boolean, + domainFields: Set + ): Promise { return ctx.with('update jsonb_set', {}, async (_ctx) => { const updates: string[] = ['"modifiedBy" = $1', '"modifiedOn" = $2'] const params: any[] = [tx.modifiedBy, tx.modifiedOn, tx.objectId, this.workspaceId.name] let paramsIndex = params.length + 1 - const domain = this.hierarchy.getDomain(tx.objectClass) - const { extractedFields, remainingData } = parseUpdate(domain, tx.operations) + const { extractedFields, remainingData } = parseUpdate(tx.operations, domainFields) const { space, attachedTo, ...ops } = tx.operations as any if (ops['%hash%'] === undefined) { ops['%hash%'] = null @@ -1587,29 +1745,29 @@ class PostgresAdapter extends PostgresAdapterBase { if (dataUpdated) { updates.push(`data = ${from}`) } - await this.withConnection(ctx, async (connection) => { - try { - await this.retryTxn(connection, (client) => - client.unsafe( - `UPDATE ${translateDomain(this.hierarchy.getDomain(tx.objectClass))} SET ${updates.join(', ')} WHERE _id = $3 AND "workspaceId" = $4`, - params - ) + try { + await this.mgr.write(ctx.id, async (client) => { + await client.unsafe( + `UPDATE ${translateDomain(this.hierarchy.getDomain(tx.objectClass))} SET ${updates.join(', ')} WHERE _id = $3 AND "workspaceId" = $4`, + params ) - if (retrieve) { - const object = await this.findDoc(_ctx, connection, tx.objectClass, tx.objectId) + }) + if (retrieve) { + return await this.mgr.read(ctx.id, async (client) => { + const object = await this.findDoc(_ctx, client, tx.objectClass, tx.objectId) return { object } - } - } catch (err) { - console.error(err, { tx, params, updates }) + }) } - }) + } catch (err) { + console.error(err, { tx, params, updates }) + } return {} }) } private findDoc ( ctx: MeasureContext, - client: postgres.Sql | postgres.ReservedSql, + client: postgres.ReservedSql | postgres.Sql, _class: Ref>, _id: Ref, forUpdate: boolean = false @@ -1617,27 +1775,13 @@ class PostgresAdapter extends PostgresAdapterBase { const domain = this.hierarchy.getDomain(_class) return ctx.with('find-doc', { _class }, async () => { const res = - await client`SELECT * FROM ${this.client(translateDomain(domain))} WHERE _id = ${_id} AND "workspaceId" = ${this.workspaceId.name} ${ + await client`SELECT * FROM ${client(translateDomain(domain))} WHERE _id = ${_id} AND "workspaceId" = ${this.workspaceId.name} ${ forUpdate ? client` FOR UPDATE` : client`` }` - const dbDoc = res[0] - return dbDoc !== undefined ? parseDoc(dbDoc as any, getSchema(domain)) : undefined + const dbDoc = res[0] as any + return dbDoc !== undefined ? parseDoc(dbDoc, getSchema(domain)) : undefined }) } - - protected async txRemoveDoc (ctx: MeasureContext, tx: TxRemoveDoc): Promise { - await ctx.with('tx-remove-doc', { _class: tx.objectClass }, async (_ctx) => { - const domain = translateDomain(this.hierarchy.getDomain(tx.objectClass)) - await this.withConnection(_ctx, async (connection) => { - await this.retryTxn( - connection, - (client) => - client`DELETE FROM ${client(domain)} WHERE _id = ${tx.objectId} AND "workspaceId" = ${this.workspaceId.name}` - ) - }) - }) - return {} - } } class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter { @@ -1674,10 +1818,13 @@ class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter { } async getModel (ctx: MeasureContext): Promise { - const res = await this - .client`SELECT * FROM ${this.client(translateDomain(DOMAIN_MODEL_TX))} WHERE "workspaceId" = ${this.workspaceId.name} ORDER BY _id ASC, "modifiedOn" ASC` + const res: DBDoc[] = await this.mgr.read( + undefined, + (client) => + client`SELECT * FROM ${client(translateDomain(DOMAIN_MODEL_TX))} WHERE "workspaceId" = ${this.workspaceId.name} ORDER BY _id ASC, "modifiedOn" ASC` + ) - const model = res.map((p) => parseDoc(p as any, getSchema(DOMAIN_MODEL_TX))) + const model = res.map((p) => parseDoc(p, getSchema(DOMAIN_MODEL_TX))) // We need to put all core.account.System transactions first const systemTx: Tx[] = [] const userTx: Tx[] = [] @@ -1698,7 +1845,14 @@ export async function createPostgresAdapter ( ): Promise { const client = getDBClient(url) const connection = await client.getClient() - const adapter = new PostgresAdapter(connection, client, workspaceId, hierarchy, modelDb) + const adapter = new PostgresAdapter( + connection, + client, + workspaceId, + hierarchy, + modelDb, + 'default-' + workspaceId.name + ) return adapter } @@ -1714,7 +1868,7 @@ export async function createPostgresTxAdapter ( ): Promise { const client = getDBClient(url) const connection = await client.getClient() - const adapter = new PostgresTxAdapter(connection, client, workspaceId, hierarchy, modelDb) + const adapter = new PostgresTxAdapter(connection, client, workspaceId, hierarchy, modelDb, 'tx' + workspaceId.name) await adapter.init() return adapter } diff --git a/server/postgres/src/utils.ts b/server/postgres/src/utils.ts index f4a18f14e9..60e821de15 100644 --- a/server/postgres/src/utils.ts +++ b/server/postgres/src/utils.ts @@ -272,8 +272,9 @@ export function convertDoc ( createdBy: doc.createdBy, modifiedBy: doc.modifiedBy, modifiedOn: doc.modifiedOn, - createdOn: doc.createdOn, - _class: doc._class + createdOn: doc.createdOn ?? doc.modifiedOn, + _class: doc._class, + '%hash%': (doc as any)['%hash%'] ?? null } const remainingData: Partial = {} @@ -326,8 +327,8 @@ export function inferType (val: any): string { } export function parseUpdate ( - domain: string, - ops: DocumentUpdate | MixinUpdate + ops: DocumentUpdate | MixinUpdate, + fields: Set ): { extractedFields: Partial remainingData: Partial @@ -339,14 +340,14 @@ export function parseUpdate ( const val = (ops as any)[key] if (key.startsWith('$')) { for (const k in val) { - if (getDocFieldsByDomains(domain).includes(k)) { + if (fields.has(k)) { ;(extractedFields as any)[k] = val[key] } else { ;(remainingData as any)[k] = val[key] } } } else { - if (getDocFieldsByDomains(domain).includes(key)) { + if (fields.has(key)) { ;(extractedFields as any)[key] = val } else { ;(remainingData as any)[key] = val diff --git a/server/server-pipeline/src/pipeline.ts b/server/server-pipeline/src/pipeline.ts index 8fbdfd2c85..747b1dc40d 100644 --- a/server/server-pipeline/src/pipeline.ts +++ b/server/server-pipeline/src/pipeline.ts @@ -17,7 +17,6 @@ import { ApplyTxMiddleware, BroadcastMiddleware, ConfigurationMiddleware, - ConnectionMgrMiddleware, ContextNameMiddleware, DBAdapterInitMiddleware, DBAdapterMiddleware, @@ -114,7 +113,6 @@ export function createServerPipeline ( SpacePermissionsMiddleware.create, ConfigurationMiddleware.create, ContextNameMiddleware.create, - ConnectionMgrMiddleware.create, MarkDerivedEntryMiddleware.create, ApplyTxMiddleware.create, // Extract apply TxMiddleware.create, // Store tx into transaction domain diff --git a/server/server/src/client.ts b/server/server/src/client.ts index 74886bcd93..d4de360a1e 100644 --- a/server/server/src/client.ts +++ b/server/server/src/client.ts @@ -15,6 +15,7 @@ import core, { AccountRole, + generateId, TxFactory, TxProcessor, type Account, @@ -29,6 +30,7 @@ import core, { type Ref, type SearchOptions, type SearchQuery, + type SessionData, type Timestamp, type Tx, type TxCUD, @@ -49,6 +51,8 @@ import { import { type Token } from '@hcengineering/server-token' import { handleSend } from './utils' +const useReserveContext = (process.env.USE_RESERVE_CTX ?? 'true') === 'true' + /** * @public */ @@ -192,13 +196,35 @@ export class ClientSession implements Session { this.current.tx++ this.includeSessionContext(ctx.ctx) - const result = await this._pipeline.tx(ctx.ctx, [tx]) + let cid = 'client_' + generateId() + ctx.ctx.id = cid + let onEnd = useReserveContext ? this._pipeline.context.adapterManager?.reserveContext?.(cid) : undefined + try { + const result = await this._pipeline.tx(ctx.ctx, [tx]) - // Send result immideately - await ctx.sendResponse(result) + // Send result immideately + await ctx.sendResponse(result) - // We need to broadcast all collected transactions - await this._pipeline.handleBroadcast(ctx.ctx) + // We need to broadcast all collected transactions + await this._pipeline.handleBroadcast(ctx.ctx) + } finally { + onEnd?.() + } + + // ok we could perform async requests if any + const asyncs = (ctx.ctx.contextData as SessionData).asyncRequests ?? [] + if (asyncs.length > 0) { + cid = 'client_async_' + generateId() + ctx.ctx.id = cid + onEnd = useReserveContext ? this._pipeline.context.adapterManager?.reserveContext?.(cid) : undefined + try { + for (const r of (ctx.ctx.contextData as SessionData).asyncRequests ?? []) { + await r() + } + } finally { + onEnd?.() + } + } } broadcast (ctx: MeasureContext, socket: ConnectionSocket, tx: Tx[]): void { diff --git a/server/tool/src/initializer.ts b/server/tool/src/initializer.ts index e2782444aa..250115c633 100644 --- a/server/tool/src/initializer.ts +++ b/server/tool/src/initializer.ts @@ -1,11 +1,11 @@ -import { saveCollaborativeDoc } from '@hcengineering/collaboration' +import { saveCollabJson } from '@hcengineering/collaboration' import core, { AttachedDoc, Class, - CollaborativeDoc, Data, Doc, generateId, + makeCollabId, MeasureContext, Mixin, Ref, @@ -16,7 +16,7 @@ import core, { import { ModelLogger } from '@hcengineering/model' import { makeRank } from '@hcengineering/rank' import type { StorageAdapter } from '@hcengineering/server-core' -import { jsonToYDocNoSchema, parseMessageMarkdown } from '@hcengineering/text' +import { jsonToMarkup, parseMessageMarkdown } from '@hcengineering/text' import { v4 as uuid } from 'uuid' const fieldRegexp = /\${\S+?}/ @@ -199,7 +199,7 @@ export class WorkspaceInitializer { if (step.collabFields !== undefined) { for (const field of step.collabFields) { if ((data as any)[field] !== undefined) { - const res = await this.createCollab((data as any)[field], field, _id) + const res = await this.createCollab((data as any)[field], step._class, _id, field) ;(data as any)[field] = res } } @@ -265,15 +265,18 @@ export class WorkspaceInitializer { return data } - private async createCollab (data: string, field: string, _id: Ref): Promise { - const id = `${_id}%${field}` - const collabId = `${id}:HEAD:0` as CollaborativeDoc + private async createCollab ( + data: string, + objectClass: Ref>, + objectId: Ref, + objectAttr: string + ): Promise { + const doc = makeCollabId(objectClass, objectId, objectAttr) const json = parseMessageMarkdown(data ?? '', this.imageUrl) - const yDoc = jsonToYDocNoSchema(json, field) + const markup = jsonToMarkup(json) - await saveCollaborativeDoc(this.ctx, this.storageAdapter, this.wsUrl, collabId, yDoc) - return collabId + return await saveCollabJson(this.ctx, this.storageAdapter, this.wsUrl, doc, markup) } private async fillProps | Props>( diff --git a/services/ai-bot/love-agent/esbuild.config.js b/services/ai-bot/love-agent/esbuild.config.js index da1b7778ab..bd1cd8d505 100644 --- a/services/ai-bot/love-agent/esbuild.config.js +++ b/services/ai-bot/love-agent/esbuild.config.js @@ -1,7 +1,7 @@ import esbuild from 'esbuild'; await esbuild.build({ - entryPoints: ['src/index.ts', 'src/start.ts', 'src/config.ts', 'src/agent.ts', 'src/stt.ts', 'src/type.ts'], + entryPoints: ['src/**/*.ts'], platform: 'node', bundle: false, minify: false, diff --git a/services/ai-bot/love-agent/package.json b/services/ai-bot/love-agent/package.json index 8e681df53e..a0e1704677 100644 --- a/services/ai-bot/love-agent/package.json +++ b/services/ai-bot/love-agent/package.json @@ -35,8 +35,8 @@ }, "dependencies": { "@deepgram/sdk": "^3.9.0", - "@livekit/agents": "^0.4.1", - "@livekit/rtc-node": "^0.11.1", + "@livekit/agents": "^0.4.6", + "@livekit/rtc-node": "^0.12.1", "dotenv": "^16.4.5" } } \ No newline at end of file diff --git a/services/ai-bot/love-agent/pnpm-lock.yaml b/services/ai-bot/love-agent/pnpm-lock.yaml index 26f981af21..22b867f125 100644 --- a/services/ai-bot/love-agent/pnpm-lock.yaml +++ b/services/ai-bot/love-agent/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: ^3.9.0 version: 3.9.0 '@livekit/agents': - specifier: ^0.4.1 - version: 0.4.1 + specifier: ^0.4.6 + version: 0.4.6(@livekit/rtc-node@0.12.1) '@livekit/rtc-node': - specifier: ^0.11.1 - version: 0.11.1 + specifier: ^0.12.1 + version: 0.12.1 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -243,8 +243,10 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@livekit/agents@0.4.1': - resolution: {integrity: sha512-zZnd19CWvm1i6PKzAgUw6gLdZOJ/QKzbIVSLNAZPQphCEEg3cPoe3fEf9XE7o9+n6e5sZ6FfmSUh/c7jxWmujw==} + '@livekit/agents@0.4.6': + resolution: {integrity: sha512-SVA6tW3o1ZVdQ0fGWwie+mMB4jkcNZzI0iYrOb0L9lDs9vW4C/4YlVtp8bOQSCxisG8HsCy+beVKbMTd0Mx9Fg==} + peerDependencies: + '@livekit/rtc-node': ^0.11.1 '@livekit/mutex@1.1.0': resolution: {integrity: sha512-XRLG+z/0uoyDioupjUiskjI06Y51U/IXVPJn7qJ+R3J75XX01irYVBM9MpxeJahpVoe9QhU4moIEolX+HO9U9g==} @@ -252,38 +254,38 @@ packages: '@livekit/protocol@1.27.1': resolution: {integrity: sha512-ISEp7uWdV82mtCR1eyHFTzdRZTVbe2+ZztjmjiMPzR/KPrI1Ma/u5kLh87NNuY3Rn8wv1VlEvGHHsFjQ+dKVUw==} - '@livekit/rtc-node-darwin-arm64@0.11.1': - resolution: {integrity: sha512-M+Ui87H06ae19GGI7r937dS6hI84MBBTQAkkNlL7qd+pvdCAk25u0FYa8r4SOElKJ0VR3AbzeDoXTihLgpvjMg==} + '@livekit/rtc-node-darwin-arm64@0.12.1': + resolution: {integrity: sha512-GF+QnRp7yBK4AyeG3eZiuAzbQOH4+bkLSyTgb5A1CzGEca2XD5CSgKUeGtIgIglq0XmYzN6bvl260xUWPl0WCQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@livekit/rtc-node-darwin-x64@0.11.1': - resolution: {integrity: sha512-7G92fyuK2p+jdTH2cUJTNeAmtknTGsXuy0xbI727V7VzQvHFDXULCExRlgwn4t9TxvNlIjUpiltiQ6RCSai6zw==} + '@livekit/rtc-node-darwin-x64@0.12.1': + resolution: {integrity: sha512-uORO+/W5jvCfuFcmzQ4q5WCgJX5EW7eUxgfUHSaIuzcwgxBxG4yagWDl9WkK6MY9V1j6fhAiNlJ3hPl8cYGUjA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@livekit/rtc-node-linux-arm64-gnu@0.11.1': - resolution: {integrity: sha512-vqZN9+87Pvxit7auYVW69M+GvUPnf+EwipIJ92GgCJA3Ir1Tcceu5ud5/Ic+0FzSoV0cotVVlQNm74F0tQvyCg==} + '@livekit/rtc-node-linux-arm64-gnu@0.12.1': + resolution: {integrity: sha512-8LIHq0nSwCjUMVPNfrohupH2ynhEFcYmidYa1KmHLkVlW+gyXBfy+QE7voAbXhTSbLZLDX58N5KGCQa61LSVAA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@livekit/rtc-node-linux-x64-gnu@0.11.1': - resolution: {integrity: sha512-smHZUMfgILQh6/eoauYNe/VlKwQCp4/4jWxiIADHY+mtDtVSvQ9zB6y4GP8FrpohRwFWesKCUpvPBypU0Icrng==} + '@livekit/rtc-node-linux-x64-gnu@0.12.1': + resolution: {integrity: sha512-8x5IKzcTmwGyy0I0CU5vzealCl7c0Fp4KMBakq/uUOavbh/Rs1nAsjT8flrXC09F4njXD1E9uYN6CMTN9uZeKw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@livekit/rtc-node-win32-x64-msvc@0.11.1': - resolution: {integrity: sha512-bTWVtb+UiRPFjiuhrqq40gt5vs5mMPTa1e+kd2jGQPTOlKZPLArQ0WgFcep2TAy1zmcpOgfeM1XRPVFhZl7G1A==} + '@livekit/rtc-node-win32-x64-msvc@0.12.1': + resolution: {integrity: sha512-zPbvUnpHdDlkEW3AN8wIqa3Wwae26MV/gQmqmP+3AjZRog8hMZy9Fe48lJgZR6VobjFjw99wSgIytYZCdIOUqQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@livekit/rtc-node@0.11.1': - resolution: {integrity: sha512-EFw+giPll12fcXATZpN2zKkE3umYJAdHvfjW+Yu0aBjwfxbUBXu8rz6le2CzDNvGmRwR888DSZXFZfYikwZgiw==} + '@livekit/rtc-node@0.12.1': + resolution: {integrity: sha512-VNRobdI/CmZ8mVUV105uqFOH7BMDmK8VsNq4GrpWAdXKnQMVKoAjLmvFU2LVQMKP4qE9BG3aTi59BGYt/PCLiA==} engines: {node: '>= 18'} '@livekit/typed-emitter@3.0.0': @@ -1556,11 +1558,11 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@livekit/agents@0.4.1': + '@livekit/agents@0.4.6(@livekit/rtc-node@0.12.1)': dependencies: '@livekit/mutex': 1.1.0 '@livekit/protocol': 1.27.1 - '@livekit/rtc-node': 0.11.1 + '@livekit/rtc-node': 0.12.1 '@livekit/typed-emitter': 3.0.0 commander: 12.1.0 livekit-server-sdk: 2.8.1 @@ -1578,32 +1580,32 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.0 - '@livekit/rtc-node-darwin-arm64@0.11.1': + '@livekit/rtc-node-darwin-arm64@0.12.1': optional: true - '@livekit/rtc-node-darwin-x64@0.11.1': + '@livekit/rtc-node-darwin-x64@0.12.1': optional: true - '@livekit/rtc-node-linux-arm64-gnu@0.11.1': + '@livekit/rtc-node-linux-arm64-gnu@0.12.1': optional: true - '@livekit/rtc-node-linux-x64-gnu@0.11.1': + '@livekit/rtc-node-linux-x64-gnu@0.12.1': optional: true - '@livekit/rtc-node-win32-x64-msvc@0.11.1': + '@livekit/rtc-node-win32-x64-msvc@0.12.1': optional: true - '@livekit/rtc-node@0.11.1': + '@livekit/rtc-node@0.12.1': dependencies: '@bufbuild/protobuf': 2.2.2 '@livekit/mutex': 1.1.0 '@livekit/typed-emitter': 3.0.0 optionalDependencies: - '@livekit/rtc-node-darwin-arm64': 0.11.1 - '@livekit/rtc-node-darwin-x64': 0.11.1 - '@livekit/rtc-node-linux-arm64-gnu': 0.11.1 - '@livekit/rtc-node-linux-x64-gnu': 0.11.1 - '@livekit/rtc-node-win32-x64-msvc': 0.11.1 + '@livekit/rtc-node-darwin-arm64': 0.12.1 + '@livekit/rtc-node-darwin-x64': 0.12.1 + '@livekit/rtc-node-linux-arm64-gnu': 0.12.1 + '@livekit/rtc-node-linux-x64-gnu': 0.12.1 + '@livekit/rtc-node-win32-x64-msvc': 0.12.1 '@livekit/typed-emitter@3.0.0': {} diff --git a/services/ai-bot/love-agent/src/agent.ts b/services/ai-bot/love-agent/src/agent.ts index e442c2bddb..b5192b694b 100644 --- a/services/ai-bot/love-agent/src/agent.ts +++ b/services/ai-bot/love-agent/src/agent.ts @@ -17,7 +17,7 @@ import { cli, defineAgent, type JobContext, JobRequest, WorkerOptions } from '@l import { fileURLToPath } from 'node:url' import { RemoteParticipant, RemoteTrack, RemoteTrackPublication, RoomEvent, TrackKind } from '@livekit/rtc-node' -import { STT } from './stt.js' +import { STT } from './deepgram/stt.js' import { Metadata, TranscriptionStatus } from './type.js' import config from './config.js' diff --git a/services/ai-bot/love-agent/src/stt.ts b/services/ai-bot/love-agent/src/deepgram/stt.ts similarity index 95% rename from services/ai-bot/love-agent/src/stt.ts rename to services/ai-bot/love-agent/src/deepgram/stt.ts index 02ee5aae86..576a48644d 100644 --- a/services/ai-bot/love-agent/src/stt.ts +++ b/services/ai-bot/love-agent/src/deepgram/stt.ts @@ -24,7 +24,7 @@ import { SOCKET_STATES } from '@deepgram/sdk' -import config from './config.js' +import config from '../config.js' const KEEP_ALIVE_INTERVAL = 10 * 1000 @@ -165,9 +165,9 @@ export class STT { } if (data.speech_final === true) { - void this.sendToPlatform(transcript, sid, true) + void this.sendToPlatform(transcript, sid) } else if (data.is_final === true) { - void this.sendToPlatform(transcript, sid, false) + void this.sendToPlatform(transcript, sid) } }) @@ -199,12 +199,11 @@ export class STT { } } - async sendToPlatform (transcript: string, sid: string, isFinal = false): Promise { + async sendToPlatform (transcript: string, sid: string): Promise { const request = { transcript, participant: this.participantBySid.get(sid)?.identity, - roomName: this.name, - final: isFinal + roomName: this.name } try { diff --git a/services/ai-bot/pod-ai-bot/src/controller.ts b/services/ai-bot/pod-ai-bot/src/controller.ts index 90b2a0b73c..a8f4ecf310 100644 --- a/services/ai-bot/pod-ai-bot/src/controller.ts +++ b/services/ai-bot/pod-ai-bot/src/controller.ts @@ -308,6 +308,6 @@ export class AIControl { const wsClient = await this.getWorkspaceClient(workspace) if (wsClient === undefined) return - await wsClient.processLoveTranscript(request.transcript, request.participant, roomId as Ref, request.final) + await wsClient.processLoveTranscript(request.transcript, request.participant, roomId as Ref) } } diff --git a/services/ai-bot/pod-ai-bot/src/workspace/love.ts b/services/ai-bot/pod-ai-bot/src/workspace/love.ts index 78c414ab79..8ec68e30d8 100644 --- a/services/ai-bot/pod-ai-bot/src/workspace/love.ts +++ b/services/ai-bot/pod-ai-bot/src/workspace/love.ts @@ -1,3 +1,18 @@ +// +// 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 { ConnectMeetingRequest } from '@hcengineering/ai-bot' import chunter from '@hcengineering/chunter' import { Person } from '@hcengineering/contact' @@ -27,7 +42,6 @@ import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text' import config from '../config' export class LoveController { - private readonly roomSidById = new Map, string>() private readonly connectedRooms = new Set>() private participantsInfo: ParticipantInfo[] = [] @@ -44,7 +58,7 @@ export class LoveController { void this.initData() setInterval(() => { void this.checkConnection() - }, 5000) + }, 10 * 1000) } getIdentity (): { identity: Ref, name: string } { @@ -119,12 +133,10 @@ export class LoveController { if (room === undefined) { this.ctx.error('Room not found', request) - this.roomSidById.delete(request.roomId) this.connectedRooms.delete(request.roomId) return } - this.roomSidById.set(request.roomId, request.roomSid) this.connectedRooms.add(request.roomId) this.ctx.info('Connecting', { room: room.name, roomId: room._id }) @@ -155,11 +167,10 @@ export class LoveController { await stopTranscription(this.token, getTokenRoomName(this.workspace, room.name, room._id), room.name) } - this.roomSidById.delete(roomId) this.connectedRooms.delete(roomId) } - async processTranscript (text: string, person: Ref, roomId: Ref, final: boolean): Promise { + async processTranscript (text: string, person: Ref, roomId: Ref): Promise { const room = await this.getRoom(roomId) const participant = await this.getRoomParticipant(roomId, person) @@ -167,14 +178,8 @@ export class LoveController { return } - const sid = this.roomSidById.get(roomId) - - if (sid === undefined) { - return - } - const personAccount = this.client.getModel().getAccountByPersonId(participant.person)[0] - const doc = await this.getMeetingMinutes(room, sid) + const doc = await this.getMeetingMinutes(room) if (doc === undefined) return const op = this.client.apply(undefined, undefined, true) @@ -210,13 +215,10 @@ export class LoveController { ) } - async getMeetingMinutes (room: Room, sid: string): Promise { - if (sid === '') return undefined - + async getMeetingMinutes (room: Room): Promise { const doc = - this.meetingMinutes.find( - (m) => m.sid === sid && m.attachedTo === room._id && m.status === MeetingStatus.Active - ) ?? (await this.client.findOne(love.class.MeetingMinutes, { sid, room: room._id, status: MeetingStatus.Active })) + this.meetingMinutes.find((m) => m.attachedTo === room._id && m.status === MeetingStatus.Active) ?? + (await this.client.findOne(love.class.MeetingMinutes, { attachedTo: room._id, status: MeetingStatus.Active })) if (doc === undefined) { return undefined diff --git a/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts index ee8fe6ee61..78f7bf21d0 100644 --- a/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts +++ b/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts @@ -709,7 +709,7 @@ export class WorkspaceClient { await this.love.disconnect(request.roomId) } - async processLoveTranscript (text: string, participant: Ref, room: Ref, final: boolean): Promise { + async processLoveTranscript (text: string, participant: Ref, room: Ref): Promise { // Just wait initialization await this.opClient @@ -718,7 +718,7 @@ export class WorkspaceClient { return } - await this.love.processTranscript(text, participant, room, final) + await this.love.processTranscript(text, participant, room) } async getLoveIdentity (): Promise { diff --git a/services/github/pod-github/src/sync/issueBase.ts b/services/github/pod-github/src/sync/issueBase.ts index 08ba502e24..c767ad9a9c 100644 --- a/services/github/pod-github/src/sync/issueBase.ts +++ b/services/github/pod-github/src/sync/issueBase.ts @@ -14,7 +14,6 @@ import core, { Account, AttachedDoc, Class, - CollaborativeDoc, Doc, DocumentUpdate, Markup, @@ -22,7 +21,8 @@ import core, { Ref, Space, Status, - TxOperations + TxOperations, + makeDocCollabId } from '@hcengineering/core' import github, { DocSyncInfo, @@ -74,8 +74,8 @@ import { /** * @public */ -export type WithMarkup = { - [P in keyof T]: T[P] extends CollaborativeDoc ? Markup : T[P] +export type WithMarkup = Omit & { + description: Markup } /** @@ -259,7 +259,7 @@ export abstract class IssueSyncManagerBase { } let needProjectRefresh = false - const update: DocumentUpdate & Record = {} + const update: DocumentUpdate> & Record = {} let structure = integration.projectStructure.get(target.target._id) @@ -372,7 +372,8 @@ export abstract class IssueSyncManagerBase { !areEqualMarkups(update.description, syncData.current?.description ?? '') ) { try { - await this.collaborator.updateContent(doc.description, { description: update.description }) + const collabId = makeDocCollabId(doc, 'description') + await this.collaborator.updateMarkup(collabId, update.description) } catch (err: any) { Analytics.handleError(err) this.ctx.error(err) @@ -772,7 +773,7 @@ export abstract class IssueSyncManagerBase { } if (update.description !== undefined) { - if (areEqualMarkups(update.description, existingIssue.description)) { + if (update.description === existingIssue.description) { delete update.description } } @@ -924,8 +925,10 @@ export abstract class IssueSyncManagerBase { workspace: this.provider.getWorkspaceId().name }) try { - issueData.description = update.description - await this.collaborator.updateContent(existingIssue.description, { description: update.description }) + const description = update.description as Markup + issueData.description = description + const collabId = makeDocCollabId(existingIssue, 'description') + await this.collaborator.updateMarkup(collabId, description) } catch (err: any) { Analytics.handleError(err) this.ctx.error('error during description update', err) diff --git a/services/github/pod-github/src/sync/issues.ts b/services/github/pod-github/src/sync/issues.ts index bc389a98a3..a2bc87b74c 100644 --- a/services/github/pod-github/src/sync/issues.ts +++ b/services/github/pod-github/src/sync/issues.ts @@ -19,7 +19,9 @@ import core, { TxOperations, cutObjectArray, generateId, - makeCollaborativeDoc + makeDocCollabId, + makeCollabJsonId, + makeCollabId } from '@hcengineering/core' import github, { DocSyncInfo, @@ -239,7 +241,7 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan case 'assigned': case 'unassigned': { const assignees = await this.getAssigneesI(event.issue) - const update: DocumentUpdate = { + const update: IssueUpdate = { assignee: assignees?.[0]?.person ?? null } await this.handleUpdate(externalData as IssueExternalData, derivedClient, update, account, prj, false) @@ -255,7 +257,7 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan } const type = await this.provider.getTaskTypeOf(prj.type, tracker.class.Issue) const statuses = await this.provider.getStatuses(type?._id) - const update: DocumentUpdate = { + const update: IssueUpdate = { status: ( await guessStatus( { @@ -380,8 +382,8 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan } const description = await this.ctx.withLog('query collaborative description', {}, async () => { - const content = await this.collaborator.getContent((existing as Issue).description) - return content.description ?? '' + const collabId = makeDocCollabId(existing, 'description') + return await this.collaborator.getMarkup(collabId, (existing as Issue).description) }) this.ctx.info('create github issue', { @@ -624,8 +626,8 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan 'query collaborative description', {}, async () => { - const content = await this.collaborator.getContent((existing as Issue).description) - return content.description ?? '' + const collabId = makeDocCollabId(existing, 'description') + return await this.collaborator.getMarkup(collabId, (existing as Issue).description) }, { url: issueExternal.url } ) @@ -908,9 +910,12 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan const { description, ...update } = issueData + const collabId = makeCollabId(tracker.class.Issue, issueId, 'description') + const contentId = makeCollabJsonId(collabId) + const value: AttachedData = { ...update, - description: makeCollaborativeDoc(issueId, 'description'), + description: contentId, kind: taskType, component: null, milestone: null, @@ -930,7 +935,7 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan identifier: `${prj.identifier}-${number}` } - await this.collaborator.updateContent(value.description, { description }) + await this.collaborator.updateMarkup(collabId, description) await this.client.addCollection( tracker.class.Issue, diff --git a/services/github/pod-github/src/sync/pullrequests.ts b/services/github/pod-github/src/sync/pullrequests.ts index 731dc79403..33b8b77749 100644 --- a/services/github/pod-github/src/sync/pullrequests.ts +++ b/services/github/pod-github/src/sync/pullrequests.ts @@ -15,7 +15,8 @@ import core, { WithLookup, cutObjectArray, generateId, - makeCollaborativeDoc + makeCollabId, + makeDocCollabId } from '@hcengineering/core' import github, { DocSyncInfo, @@ -217,12 +218,12 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS break } case 'review_requested': { - const update: DocumentUpdate = {} + const update: GithubPullRequestUpdate = {} await this.handleUpdate(externalData, derivedClient, update, account, prj, true) break } case 'review_request_removed': { - const update: DocumentUpdate = {} + const update: GithubPullRequestUpdate = {} await this.handleUpdate(externalData, derivedClient, update, account, prj, true) break } @@ -234,7 +235,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS case 'assigned': case 'unassigned': { const assignees = await this.getAssignees(externalData) - const update: DocumentUpdate = { + const update: GithubPullRequestUpdate = { assignee: assignees?.[0]?.person ?? null } await this.handleUpdate(externalData, derivedClient, update, account, prj, true) @@ -247,7 +248,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS const isMerged = event.pull_request?.merged_at !== null - const update: DocumentUpdate = { + const update: GithubPullRequestUpdate = { draft: externalData.isDraft, head: externalData.headRef, base: externalData.baseRef, @@ -594,8 +595,8 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS 'query collaborative pull request description', {}, async () => { - const content = await this.collaborator.getContent((existing as any).description) - return content.description + const collabId = makeDocCollabId(existing, 'description') + return await this.collaborator.getMarkup(collabId, (existing as GithubPullRequest).description) }, { url: pullRequestExternal.url } ) @@ -998,7 +999,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS async performIssueFieldsUpdate ( info: DocSyncInfo, - existing: Issue, + existing: WithMarkup, platformUpdate: DocumentUpdate, issueData: Pick, 'title' | 'description' | 'assignee' | 'status' | 'remainingTime' | 'component'>, container: ContainerFocus, @@ -1180,7 +1181,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS const number = project.sequence const value: AttachedData = { ...data, - description: makeCollaborativeDoc(prId, 'description'), + description: null, kind: taskType, component: null, milestone: null, @@ -1203,7 +1204,8 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS reviews: 0 } - await this.collaborator.updateContent(value.description, { description }) + const collabId = makeCollabId(github.class.GithubPullRequest, prId, 'description') + await this.collaborator.updateMarkup(collabId, description) await client.addCollection( github.class.GithubPullRequest, diff --git a/services/love/src/main.ts b/services/love/src/main.ts index 67d17603c9..a2d36dca80 100644 --- a/services/love/src/main.ts +++ b/services/love/src/main.ts @@ -86,7 +86,7 @@ export const main = async (): Promise => { const filename = stripPrefix(prefix, res.filename) const storedBlob = await storageAdapter.stat(ctx, data.workspaceId, filename) if (storedBlob !== undefined) { - const client = await WorkspaceClient.create(data.workspace) + const client = await WorkspaceClient.create(data.workspace, ctx) await client.saveFile(filename, data.name, storedBlob, data.meetingMinutes) await client.close() } @@ -137,6 +137,7 @@ export const main = async (): Promise => { const name = `${room}_${dateStr}.mp4` const id = await startRecord(storageConfig, egressClient, roomClient, roomName, workspace) dataByUUID.set(id, { name, workspace: workspace.name, workspaceId: workspace, meetingMinutes }) + ctx.info('Start recording', { workspace: workspace.name, roomName, meetingMinutes }) res.send() } catch (e) { console.error(e) diff --git a/services/love/src/workspaceClient.ts b/services/love/src/workspaceClient.ts index f8293d7735..4daf1bc42b 100644 --- a/services/love/src/workspaceClient.ts +++ b/services/love/src/workspaceClient.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import core, { Client, Ref, TxOperations, type Blob, Data } from '@hcengineering/core' +import core, { Client, Ref, TxOperations, type Blob, Data, MeasureContext } from '@hcengineering/core' import drive, { createFile } from '@hcengineering/drive' import love, { MeetingMinutes } from '@hcengineering/love' import { generateToken } from '@hcengineering/server-token' @@ -23,10 +23,13 @@ import config from './config' export class WorkspaceClient { private client!: TxOperations - private constructor (private readonly workspace: string) {} + private constructor ( + private readonly workspace: string, + private readonly ctx: MeasureContext + ) {} - static async create (workspace: string): Promise { - const instance = new WorkspaceClient(workspace) + static async create (workspace: string, ctx: MeasureContext): Promise { + const instance = new WorkspaceClient(workspace, ctx) await instance.initClient(workspace) return instance } @@ -43,6 +46,7 @@ export class WorkspaceClient { } async saveFile (uuid: string, name: string, blob: Blob, meetingMinutes?: Ref): Promise { + this.ctx.info('Save recording', { workspace: this.workspace, meetingMinutes }) const current = await this.client.findOne(drive.class.Drive, { _id: love.space.Drive }) if (current === undefined) { await this.client.createDoc( @@ -83,7 +87,10 @@ export class WorkspaceClient { if (ref === undefined) return const meeting = await this.client.findOne(love.class.MeetingMinutes, { _id: ref }) - if (meeting === undefined) return + if (meeting === undefined) { + this.ctx.error('Meeting not found', { _id: ref }) + return + } await this.client.addCollection( attachment.class.Attachment, diff --git a/tests/sanity/tests/chat/chat.spec.ts b/tests/sanity/tests/chat/chat.spec.ts index 20f97bb20c..d65cf51242 100644 --- a/tests/sanity/tests/chat/chat.spec.ts +++ b/tests/sanity/tests/chat/chat.spec.ts @@ -548,7 +548,7 @@ test.describe('Channel tests', () => { }) await test.step('Open general in sidebar too', async () => { - await channelPage.makeActionWithChannelInMenu('general', 'Open in sidebar') + await channelPage.makeActionWithChannelInMenu('general', 'Open in sidebar', true) await sidebarPage.checkIfSidebarHasVerticalTab(true, data.channelName) await sidebarPage.checkIfSidebarHasVerticalTab(true, 'general') await sidebarPage.checkIfChatSidebarTabIsOpen(true, 'general') @@ -606,6 +606,7 @@ test.describe('Channel tests', () => { }) await test.step('User go to another chat and Sidebar with tread disappears', async () => { + await channelPage.appsMenuButton().click() await channelPage.clickChannel('random') await sidebarPage.checkIfSidebarIsOpen(false) }) diff --git a/tests/sanity/tests/model/channel-page.ts b/tests/sanity/tests/model/channel-page.ts index ff7bd1d5f2..13a87c3f20 100644 --- a/tests/sanity/tests/model/channel-page.ts +++ b/tests/sanity/tests/model/channel-page.ts @@ -302,7 +302,12 @@ export class ChannelPage extends CommonPage { await expect(this.buttonBreadcrumb(channel)).toBeVisible() } - async makeActionWithChannelInMenu (channelName: string, action: string): Promise { + async makeActionWithChannelInMenu ( + channelName: string, + action: string, + openNavigator: boolean = false + ): Promise { + if (openNavigator) await this.appsMenuButton().click() await this.channelContainers().filter({ hasText: channelName }).hover() await this.channelContainers().filter({ hasText: channelName }).locator('.hulyNavItem-actions').click() await this.selectFromDropdown(this.page, action) diff --git a/tests/sanity/tests/model/common-page.ts b/tests/sanity/tests/model/common-page.ts index b5044b51b8..07b3bc95a7 100644 --- a/tests/sanity/tests/model/common-page.ts +++ b/tests/sanity/tests/model/common-page.ts @@ -91,6 +91,7 @@ export class CommonPage { submitButton = (): Locator => this.page.locator('div.date-popup-container button[type="submit"]') buttonBreadcrumb = (hasText?: string): Locator => this.page.locator('button.hulyBreadcrumb-container', { hasText }) + appsMenuButton = (): Locator => this.page.locator('[id="app-workbench\\:string\\:ShowMenu"]') async selectMenuItem (page: Page, name: string, fullWordFilter: boolean = false): Promise { if (name !== 'first') {