Cloud collaborator refactoring (#6424)

This commit is contained in:
Alexander Onnikov 2024-08-29 15:10:37 +07:00 committed by GitHub
parent 42b19c39c7
commit 4ff8a4559f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 762 additions and 998 deletions

View File

@ -1865,6 +1865,9 @@ dependencies:
y-protocols:
specifier: ^1.0.6
version: 1.0.6(yjs@13.6.12)
y-websocket:
specifier: ^2.0.4
version: 2.0.4(yjs@13.6.12)
yjs:
specifier: ^13.5.52
version: 13.6.12
@ -10213,6 +10216,32 @@ packages:
event-target-shim: 5.0.1
dev: false
/abstract-leveldown@6.2.3:
resolution: {integrity: sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
buffer: 5.7.1
immediate: 3.3.0
level-concat-iterator: 2.0.1
level-supports: 1.0.1
xtend: 4.0.2
dev: false
optional: true
/abstract-leveldown@6.3.0:
resolution: {integrity: sha512-TU5nlYgta8YrBMNpc9FwQzRbiXsj49gsALsXadbGHt9CROPzX5fB0rWDR5mtdpOOKa5XqRFpbj1QroPAoPzVjQ==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
buffer: 5.7.1
immediate: 3.3.0
level-concat-iterator: 2.0.1
level-supports: 1.0.1
xtend: 4.0.2
dev: false
optional: true
/accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@ -12519,6 +12548,16 @@ packages:
engines: {node: '>=10'}
dev: false
/deferred-leveldown@5.3.0:
resolution: {integrity: sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
abstract-leveldown: 6.2.3
inherits: 2.0.4
dev: false
optional: true
/define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@ -13201,6 +13240,18 @@ packages:
engines: {node: '>= 0.8'}
dev: false
/encoding-down@6.3.0:
resolution: {integrity: sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
abstract-leveldown: 6.3.0
inherits: 2.0.4
level-codec: 9.0.2
level-errors: 2.0.1
dev: false
optional: true
/end-of-stream@1.4.4:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
dependencies:
@ -13251,6 +13302,15 @@ packages:
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
dev: false
/errno@0.1.8:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true
requiresBuild: true
dependencies:
prr: 1.0.1
dev: false
optional: true
/error-callsites@2.0.4:
resolution: {integrity: sha512-V877Ch4FC4FN178fDK1fsrHN4I1YQIBdtjKrHh3BUHMnh3SMvwUVrqkaOgDpUuevgSNna0RBq6Ox9SGlxYrigA==}
engines: {node: '>=6.x'}
@ -15879,6 +15939,12 @@ packages:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
dev: false
/immediate@3.3.0:
resolution: {integrity: sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==}
requiresBuild: true
dev: false
optional: true
/immutable@4.3.5:
resolution: {integrity: sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==}
dev: false
@ -17417,6 +17483,107 @@ packages:
webpack: 5.90.3(esbuild@0.20.1)(webpack-cli@5.1.4)
dev: false
/level-codec@9.0.2:
resolution: {integrity: sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
buffer: 5.7.1
dev: false
optional: true
/level-concat-iterator@2.0.1:
resolution: {integrity: sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==}
engines: {node: '>=6'}
requiresBuild: true
dev: false
optional: true
/level-errors@2.0.1:
resolution: {integrity: sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
errno: 0.1.8
dev: false
optional: true
/level-iterator-stream@4.0.2:
resolution: {integrity: sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
inherits: 2.0.4
readable-stream: 3.6.2
xtend: 4.0.2
dev: false
optional: true
/level-js@5.0.2:
resolution: {integrity: sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==}
requiresBuild: true
dependencies:
abstract-leveldown: 6.2.3
buffer: 5.7.1
inherits: 2.0.4
ltgt: 2.2.1
dev: false
optional: true
/level-packager@5.1.1:
resolution: {integrity: sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
encoding-down: 6.3.0
levelup: 4.4.0
dev: false
optional: true
/level-supports@1.0.1:
resolution: {integrity: sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
xtend: 4.0.2
dev: false
optional: true
/level@6.0.1:
resolution: {integrity: sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==}
engines: {node: '>=8.6.0'}
requiresBuild: true
dependencies:
level-js: 5.0.2
level-packager: 5.1.1
leveldown: 5.6.0
dev: false
optional: true
/leveldown@5.6.0:
resolution: {integrity: sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==}
engines: {node: '>=8.6.0'}
requiresBuild: true
dependencies:
abstract-leveldown: 6.2.3
napi-macros: 2.0.0
node-gyp-build: 4.1.1
dev: false
optional: true
/levelup@4.4.0:
resolution: {integrity: sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==}
engines: {node: '>=6'}
requiresBuild: true
dependencies:
deferred-leveldown: 5.3.0
level-errors: 2.0.1
level-iterator-stream: 4.0.2
level-supports: 1.0.1
xtend: 4.0.2
dev: false
optional: true
/leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@ -17778,6 +17945,12 @@ packages:
engines: {node: '>=12'}
dev: false
/ltgt@2.2.1:
resolution: {integrity: sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==}
requiresBuild: true
dev: false
optional: true
/lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@ -18347,6 +18520,12 @@ packages:
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
dev: false
/napi-macros@2.0.0:
resolution: {integrity: sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==}
requiresBuild: true
dev: false
optional: true
/natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
dev: false
@ -18449,6 +18628,13 @@ packages:
dev: false
optional: true
/node-gyp-build@4.1.1:
resolution: {integrity: sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==}
hasBin: true
requiresBuild: true
dev: false
optional: true
/node-gyp-build@4.8.0:
resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==}
hasBin: true
@ -19889,6 +20075,12 @@ packages:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
dev: false
/prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
requiresBuild: true
dev: false
optional: true
/pseudomap@1.0.2:
resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==}
dev: false
@ -23791,6 +23983,22 @@ packages:
yjs: 13.6.12
dev: false
/y-websocket@2.0.4(yjs@13.6.12):
resolution: {integrity: sha512-UbrkOU4GPNFFTDlJYAxAmzZhia8EPxHkngZ6qjrxgIYCN3gI2l+zzLzA9p4LQJ0IswzpioeIgmzekWe7HoBBjg==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
hasBin: true
peerDependencies:
yjs: ^13.5.6
dependencies:
lib0: 0.2.89
lodash.debounce: 4.0.8
y-protocols: 1.0.6(yjs@13.6.12)
yjs: 13.6.12
optionalDependencies:
ws: 6.2.2
y-leveldb: 0.1.2(yjs@13.6.12)
dev: false
/y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
dev: false
@ -34026,6 +34234,7 @@ packages:
y-indexeddb: 9.0.12(yjs@13.6.12)
y-prosemirror: 1.2.2(prosemirror-model@1.19.4)(y-protocols@1.0.6)(yjs@13.6.12)
y-protocols: 1.0.6(yjs@13.6.12)
y-websocket: 2.0.4(yjs@13.6.12)
yjs: 13.6.12
transitivePeerDependencies:
- '@babel/core'

View File

@ -206,7 +206,7 @@ export async function configurePlatform (): Promise<void> {
setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG))
setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL)
setMetadata(textEditor.metadata.CollaboratorUrl, config.COLLABORATOR_URL)
setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR ?? '')
setMetadata(github.metadata.GithubApplication, config.GITHUB_APP ?? '')
setMetadata(github.metadata.GithubClientID, config.GITHUB_CLIENTID ?? '')

View File

@ -5,6 +5,7 @@ import { ScreenSource } from '@hcengineering/love'
*/
export interface Config {
ACCOUNTS_URL: string
COLLABORATOR?: string
COLLABORATOR_URL: string
FRONT_URL: string
FILES_URL: string

View File

@ -8,6 +8,7 @@ import { HtmlConversionBackend } from './convert/convert'
export interface Config {
doc: string
token: string
collaborator?: string
collaboratorURL: string
uploadURL: string
workspaceId: WorkspaceId

View File

@ -50,6 +50,8 @@ export function docImportTool (): void {
process.exit(1)
}
const collaborator = process.env.COLLABORATOR
const uploadUrl = process.env.UPLOAD_URL ?? '/files'
const mongodbUri = process.env.MONGO_URL
@ -105,6 +107,7 @@ export function docImportTool (): void {
uploadURL: uploadUrl,
storageAdapter,
collaboratorURL: collaboratorUrl,
collaborator,
token: generateToken(systemAccountEmail, workspaceId)
}

View File

@ -124,6 +124,7 @@ export interface Config {
MODEL_VERSION: string
VERSION: string
COLLABORATOR_URL: string
COLLABORATOR?: string
REKONI_URL: string
TELEGRAM_URL: string
GMAIL_URL: string
@ -290,7 +291,7 @@ export async function configurePlatform() {
setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL)
setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG))
setMetadata(textEditor.metadata.CollaboratorUrl, config.COLLABORATOR_URL ?? 'ws://localhost:3078')
setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR)
if (config.MODEL_VERSION != null) {
console.log('Minimal Model version requirement', config.MODEL_VERSION)

View File

@ -19,22 +19,18 @@ import { formatDocumentId, parseDocumentId } from '../utils'
describe('utils', () => {
it('formatDocumentId', () => {
expect(formatDocumentId('minio', 'ws1', 'doc1:HEAD:v1' as CollaborativeDoc)).toEqual(
'minio://ws1/doc1:HEAD' as DocumentId
)
expect(formatDocumentId('minio', 'ws1', 'doc1:HEAD:v1#doc2:v2:v2' as CollaborativeDoc)).toEqual(
'minio://ws1/doc1:HEAD/doc2:v2' as DocumentId
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
)
})
describe('parseDocumentId', () => {
expect(parseDocumentId('minio://ws1/doc1:HEAD' as DocumentId)).toEqual({
storage: 'minio',
expect(parseDocumentId('ws1://doc1:HEAD' as DocumentId)).toEqual({
workspaceUrl: 'ws1',
collaborativeDoc: 'doc1:HEAD:HEAD' as CollaborativeDoc
})
expect(parseDocumentId('minio://ws1/doc1:HEAD/doc2:v2' as DocumentId)).toEqual({
storage: 'minio',
expect(parseDocumentId('ws1://doc1:HEAD/doc2:v2' as DocumentId)).toEqual({
workspaceUrl: 'ws1',
collaborativeDoc: 'doc1:HEAD:HEAD#doc2:v2:v2' as CollaborativeDoc
})

View File

@ -13,31 +13,12 @@
// limitations under the License.
//
import {
Account,
CollaborativeDoc,
Markup,
Ref,
Timestamp,
WorkspaceId,
collaborativeDocWithLastVersion,
collaborativeDocWithVersion,
concatLink
} from '@hcengineering/core'
import { DocumentId } from './types'
import { formatMinioDocumentId } from './utils'
import { CollaborativeDoc, Markup, WorkspaceId, concatLink } from '@hcengineering/core'
import { formatDocumentId } from './utils'
/** @public */
export interface DocumentSnapshotParams {
createdBy: Ref<Account>
versionId: string
versionName?: string
}
/** @public */
export interface GetContentRequest {
documentId: DocumentId
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface GetContentRequest {}
/** @public */
export interface GetContentResponse {
@ -46,81 +27,18 @@ export interface GetContentResponse {
/** @public */
export interface UpdateContentRequest {
documentId: DocumentId
content: Record<string, Markup>
snapshot?: DocumentSnapshotParams
}
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface UpdateContentResponse {}
/** @public */
export interface CopyContentRequest {
documentId: DocumentId
sourceField: string
targetField: string
snapshot?: DocumentSnapshotParams
}
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface CopyContentResponse {}
/** @public */
export interface BranchDocumentRequest {
sourceDocumentId: DocumentId
targetDocumentId: DocumentId
}
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface BranchDocumentResponse {}
/** @public */
export interface RemoveDocumentRequest {
documentId: DocumentId
}
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface RemoveDocumentResponse {}
/** @public */
export interface TakeSnapshotRequest {
documentId: DocumentId
snapshot: DocumentSnapshotParams
}
/** @public */
export interface TakeSnapshotResponse {
versionId: string
name: string
createdBy: Ref<Account>
createdOn: Timestamp
}
/** @public */
export interface CollaboratorClient {
// field operations
getContent: (collaborativeDoc: CollaborativeDoc) => Promise<Record<string, Markup>>
updateContent: (
document: CollaborativeDoc,
content: Record<string, Markup>,
snapshot?: DocumentSnapshotParams
) => Promise<CollaborativeDoc>
copyContent: (
document: CollaborativeDoc,
sourceField: string,
targetField: string,
snapshot?: DocumentSnapshotParams
) => Promise<CollaborativeDoc>
// document operations
branch: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise<void>
remove: (collaborativeDoc: CollaborativeDoc) => Promise<void>
snapshot: (collaborativeDoc: CollaborativeDoc, params: DocumentSnapshotParams) => Promise<CollaborativeDoc>
getContent: (document: CollaborativeDoc) => Promise<Record<string, Markup>>
updateContent: (document: CollaborativeDoc, content: Record<string, Markup>) => Promise<void>
copyContent: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise<void>
}
/** @public */
@ -136,7 +54,10 @@ class CollaboratorClientImpl implements CollaboratorClient {
private readonly collaboratorUrl: string
) {}
private async rpc (method: string, payload: any): Promise<any> {
private async rpc (document: CollaborativeDoc, method: string, payload: any): Promise<any> {
const workspace = this.workspace.name
const documentId = formatDocumentId(workspace, document)
const url = concatLink(this.collaboratorUrl, '/rpc')
const res = await fetch(url, {
@ -145,7 +66,7 @@ class CollaboratorClientImpl implements CollaboratorClient {
Authorization: 'Bearer ' + this.token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ method, payload })
body: JSON.stringify({ method, documentId, payload })
})
const result = await res.json()
@ -158,70 +79,16 @@ class CollaboratorClientImpl implements CollaboratorClient {
}
async getContent (document: CollaborativeDoc): Promise<Record<string, Markup>> {
const workspace = this.workspace.name
const documentId = formatMinioDocumentId(workspace, document)
const payload: GetContentRequest = { documentId }
const res = (await this.rpc('getContent', payload)) as GetContentResponse
const res = (await this.rpc(document, 'getContent', {})) as GetContentResponse
return res.content ?? {}
}
async updateContent (
document: CollaborativeDoc,
content: Record<string, Markup>,
snapshot?: DocumentSnapshotParams
): Promise<CollaborativeDoc> {
const workspace = this.workspace.name
const documentId = formatMinioDocumentId(workspace, document)
const payload: UpdateContentRequest = { documentId, content, snapshot }
await this.rpc('updateContent', payload)
return snapshot !== undefined ? collaborativeDocWithLastVersion(document, snapshot.versionId) : document
async updateContent (document: CollaborativeDoc, content: Record<string, Markup>): Promise<void> {
await this.rpc(document, 'updateContent', { content })
}
async copyContent (
document: CollaborativeDoc,
sourceField: string,
targetField: string,
snapshot?: DocumentSnapshotParams
): Promise<CollaborativeDoc> {
const workspace = this.workspace.name
const documentId = formatMinioDocumentId(workspace, document)
const payload: CopyContentRequest = { documentId, sourceField, targetField, snapshot }
await this.rpc('copyContent', payload)
return snapshot !== undefined ? collaborativeDocWithLastVersion(document, snapshot.versionId) : document
}
async branch (source: CollaborativeDoc, target: CollaborativeDoc): Promise<void> {
const workspace = this.workspace.name
const sourceDocumentId = formatMinioDocumentId(workspace, source)
const targetDocumentId = formatMinioDocumentId(workspace, target)
const payload: BranchDocumentRequest = { sourceDocumentId, targetDocumentId }
await this.rpc('branchDocument', payload)
}
async remove (document: CollaborativeDoc): Promise<void> {
const workspace = this.workspace.name
const documentId = formatMinioDocumentId(workspace, document)
const payload: RemoveDocumentRequest = { documentId }
await this.rpc('removeDocument', payload)
}
async snapshot (document: CollaborativeDoc, snapshot: DocumentSnapshotParams): Promise<CollaborativeDoc> {
const workspace = this.workspace.name
const documentId = formatMinioDocumentId(workspace, document)
const payload: TakeSnapshotRequest = { documentId, snapshot }
const res = (await this.rpc('takeSnapshot', payload)) as TakeSnapshotResponse
return collaborativeDocWithVersion(document, res.versionId)
async copyContent (source: CollaborativeDoc, target: CollaborativeDoc): Promise<void> {
const content = await this.getContent(source)
await this.updateContent(target, content)
}
}

View File

@ -25,11 +25,6 @@ import {
} from '@hcengineering/core'
import { DocumentId, PlatformDocumentId } from './types'
/** @public */
export function formatMinioDocumentId (workspaceUrl: string, collaborativeDoc: CollaborativeDoc): DocumentId {
return formatDocumentId('minio', workspaceUrl, collaborativeDoc)
}
/**
* Formats collaborative document as Hocuspocus document name.
*
@ -37,15 +32,11 @@ export function formatMinioDocumentId (workspaceUrl: string, collaborativeDoc: C
* when document is updated. Hence, we remove lastVersionId component from CollaborativeDoc.
*
* Example:
* minio://workspace1/doc1:HEAD/doc2:v1
* workspace1://doc1:HEAD/doc2:v1
*
* @public
*/
export function formatDocumentId (
storage: string,
workspaceUrl: string,
collaborativeDoc: CollaborativeDoc
): DocumentId {
export function formatDocumentId (workspaceUrl: string, collaborativeDoc: CollaborativeDoc): DocumentId {
const path = collaborativeDocUnchain(collaborativeDoc)
.map((p) => {
const { documentId, versionId } = collaborativeDocParse(p)
@ -53,25 +44,23 @@ export function formatDocumentId (
})
.join('/')
return `${storage}://${workspaceUrl}/${path}` as DocumentId
return `${workspaceUrl}://${path}` as DocumentId
}
/** @public */
export function parseDocumentId (documentId: DocumentId): {
storage: string
workspaceUrl: string
collaborativeDoc: CollaborativeDoc
} {
const [storage, path] = documentId.split('://')
const [workspaceUrl, ...rest] = path.split('/')
const [workspaceUrl, path] = documentId.split('://')
const segments = path.split('/')
const collaborativeDocs = rest.map((p) => {
const collaborativeDocs = segments.map((p) => {
const [documentId, versionId] = p.split(':')
return collaborativeDocFormat({ documentId, versionId, lastVersionId: versionId })
})
return {
storage,
workspaceUrl,
collaborativeDoc: collaborativeDocChain(...collaborativeDocs)
}

View File

@ -57,37 +57,35 @@ describe('collaborative-doc', () => {
describe('collaborativeDocParse', () => {
it('parses collaborative doc id', async () => {
expect(collaborativeDocParse('minioDocumentId' as CollaborativeDoc)).toEqual({
documentId: 'minioDocumentId',
expect(collaborativeDocParse('documentId' as CollaborativeDoc)).toEqual({
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: 'HEAD',
source: []
})
})
it('parses collaborative doc id with versionId', async () => {
expect(collaborativeDocParse('minioDocumentId:main' as CollaborativeDoc)).toEqual({
documentId: 'minioDocumentId',
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('minioDocumentId:HEAD:0' as CollaborativeDoc)).toEqual({
documentId: 'minioDocumentId',
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('minioDocumentId:HEAD:0#minioDocumentId1:main#minioDocumentId2:HEAD' as CollaborativeDoc)
).toEqual({
documentId: 'minioDocumentId',
expect(collaborativeDocParse('documentId:HEAD:0#documentId1:main#documentId2:HEAD' as CollaborativeDoc)).toEqual({
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: '0',
source: ['minioDocumentId1:main' as CollaborativeDoc, 'minioDocumentId2:HEAD' as CollaborativeDoc]
source: ['documentId1:main' as CollaborativeDoc, 'documentId2:HEAD' as CollaborativeDoc]
})
})
})
@ -96,21 +94,21 @@ describe('collaborative-doc', () => {
it('formats collaborative doc id', async () => {
expect(
collaborativeDocFormat({
documentId: 'minioDocumentId',
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: '0'
})
).toEqual('minioDocumentId:HEAD:0')
).toEqual('documentId:HEAD:0')
})
it('formats collaborative doc id with sources', async () => {
expect(
collaborativeDocFormat({
documentId: 'minioDocumentId',
documentId: 'documentId',
versionId: 'HEAD',
lastVersionId: '0',
source: ['minioDocumentId1:main' as CollaborativeDoc, 'minioDocumentId2:HEAD' as CollaborativeDoc]
source: ['documentId1:main' as CollaborativeDoc, 'documentId2:HEAD' as CollaborativeDoc]
})
).toEqual('minioDocumentId:HEAD:0#minioDocumentId1:main#minioDocumentId2:HEAD')
).toEqual('documentId:HEAD:0#documentId1:main#documentId2:HEAD')
})
it('formats collaborative doc id with invalid characters', async () => {
expect(

View File

@ -19,11 +19,11 @@ import { Doc, Ref } from './classes'
* Identifier of the collaborative document holding collaborative content.
*
* Format:
* {minioDocumentId}:{versionId}:{lastVersionId}
* {minioDocumentId}:{versionId}
* {documentId}:{versionId}:{lastVersionId}
* {documentId}:{versionId}
*
* Where:
* - minioDocumentId is an identifier of the document in Minio
* - 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
*

View File

@ -13,20 +13,15 @@
// limitations under the License.
//
import {
type CollaboratorClient,
getClient as getCollaborator,
type DocumentSnapshotParams
} from '@hcengineering/collaborator-client'
import { type CollaborativeDoc, type Markup, getCurrentAccount, getWorkspaceId } from '@hcengineering/core'
import { type CollaboratorClient, getClient as getCollaborator } from '@hcengineering/collaborator-client'
import { type CollaborativeDoc, type Markup, getWorkspaceId } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import { getCurrentLocation } from '@hcengineering/ui'
import presentation from './plugin'
/** @public */
export function getCollaboratorClient (): CollaboratorClient {
const workspaceId = getWorkspaceId(getCurrentLocation().path[1] ?? '')
const workspaceId = getWorkspaceId(getMetadata(presentation.metadata.Workspace) ?? '')
const token = getMetadata(presentation.metadata.Token) ?? ''
const collaboratorURL = getMetadata(presentation.metadata.CollaboratorUrl) ?? ''
@ -45,27 +40,8 @@ export async function updateMarkup (collaborativeDoc: CollaborativeDoc, content:
await client.updateContent(collaborativeDoc, content)
}
/** @public */
export async function copyDocumentContent (
collaborativeDoc: CollaborativeDoc,
sourceField: string,
targetField: string
): Promise<void> {
const client = getCollaboratorClient()
await client.copyContent(collaborativeDoc, sourceField, targetField)
}
/** @public */
export async function copyDocument (source: CollaborativeDoc, target: CollaborativeDoc): Promise<void> {
const client = getCollaboratorClient()
await client.branch(source, target)
}
/** @public */
export async function takeSnapshot (collaborativeDoc: CollaborativeDoc, versionName: string): Promise<CollaborativeDoc> {
const client = getCollaboratorClient()
const createdBy = getCurrentAccount()._id
const snapshot: DocumentSnapshotParams = { createdBy, versionId: `${Date.now()}`, versionName }
return await client.snapshot(collaborativeDoc, snapshot)
await client.copyContent(source, target)
}

View File

@ -0,0 +1,63 @@
//
// 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 { Markup } from '@hcengineering/core'
import { markupToYDoc, markupToYDocNoSchema, yDocToMarkup } from '../ydoc'
describe('ydoc', () => {
const markups: Markup[] = [
// just text
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}',
// just text with bold mark
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello world"}]}]}',
// separate paragraphs with bold mark
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello"}]},{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}',
// mixed text and text with bold mark
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}',
// mixed text with italic and text with bold and italic marks
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}},{"type":"italic","attrs":{}}],"text":"world"}]}]}',
// text with link and italic marks
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","text":"hello world","marks":[{"type":"link","attrs":{"href":"http://example.com","target":"_blank","rel":"noopener noreferrer","class":"cursor-pointer"}},{"type":"italic","attrs":{}}]}]}]}',
// an image
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"image","attrs":{"src":"http://example.com/image.jpg","alt":"image"}}]}]}',
// a table with formatting inside
'{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"1"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"2"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"codeBlock","content":[{"type":"text","text":"3"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"4"}]}]}]}]}]}'
]
describe.each(markups)('markupToYDoc', (markup) => {
it('converts markup to ydoc and back', () => {
const ydoc = markupToYDoc(markup, 'test')
const back = yDocToMarkup(ydoc, 'test')
expect(JSON.parse(back)).toEqual(JSON.parse(markup))
})
})
describe.each(markups)('markupToYDocNoSchema', (markup) => {
it('converts markup to ydoc', () => {
const ydoc1 = markupToYDoc(markup, 'test')
const ydoc2 = markupToYDocNoSchema(markup, 'test')
expect(ydoc2.getXmlFragment('test').toJSON()).toEqual(ydoc1.getXmlFragment('test').toJSON())
})
it('converts markup to ydoc and back', () => {
const ydoc = markupToYDocNoSchema(markup, 'test')
const back = yDocToMarkup(ydoc, 'test')
expect(JSON.parse(back)).toEqual(JSON.parse(markup))
})
})
})

View File

@ -17,19 +17,83 @@ import { Markup } from '@hcengineering/core'
import { Extensions, getSchema } from '@tiptap/core'
import { Node, Schema } from 'prosemirror-model'
import { prosemirrorJSONToYDoc, prosemirrorToYDoc, yDocToProsemirrorJSON } from 'y-prosemirror'
import { Doc as YDoc, applyUpdate, encodeStateAsUpdate } from 'yjs'
import { Doc as YDoc, applyUpdate, encodeStateAsUpdate, XmlElement as YXmlElement, XmlText as YXmlText } from 'yjs'
import { defaultExtensions } from './extensions'
import { MarkupNode } from './markup/model'
import { jsonToMarkup, markupToPmNode } from './markup/utils'
import { jsonToMarkup, markupToJSON, markupToPmNode } from './markup/utils'
const defaultSchema = getSchema(defaultExtensions)
/**
* @public
*/
export function markupToYDoc (markup: Markup, field: string): YDoc {
const node = markupToPmNode(markup)
export function markupToYDoc (markup: Markup, field: string, schema?: Schema, extensions?: Extensions): YDoc {
const node = markupToPmNode(markup, schema, extensions)
return prosemirrorToYDoc(node, field)
}
/**
* Convert markup to Y.Doc without using ProseMirror schema
*
* @public
*/
export function markupToYDocNoSchema (markup: Markup, field: string): YDoc {
return jsonToYDocNoSchema(markupToJSON(markup), field)
}
/**
* Convert ProseMirror JSON to Y.Doc without using ProseMirror schema
*
* @public
*/
export function jsonToYDocNoSchema (json: MarkupNode, field: string): YDoc {
const nodes = json.type === 'doc' ? json.content ?? [] : [json]
const content = nodes.map(nodeToYXmlElement)
const ydoc = new YDoc()
const fragment = ydoc.getXmlFragment(field)
fragment.push(content)
return ydoc
}
/**
* Convert ProseMirror JSON Node representation to YXmlElement
* */
function nodeToYXmlElement (node: MarkupNode): YXmlElement | YXmlText {
const elem = node.type === 'text' ? new YXmlText() : new YXmlElement(node.type)
if (elem instanceof YXmlElement) {
if (node.content !== undefined && node.content.length > 0) {
const content = node.content.map(nodeToYXmlElement)
elem.push(content)
}
} else {
// https://github.com/yjs/y-prosemirror/blob/master/src/plugins/sync-plugin.js#L777
const attributes: Record<string, any> = {}
if (node.marks !== undefined) {
node.marks.forEach((mark) => {
attributes[mark.type] = mark.attrs ?? {}
})
}
elem.applyDelta([
{
insert: node.text ?? '',
attributes
}
])
}
if (node.attrs !== undefined) {
Object.entries(node.attrs).forEach(([key, value]) => {
elem.setAttribute(key, value)
})
}
return elem
}
/**
* @public
*/
@ -38,43 +102,6 @@ export function yDocToMarkup (ydoc: YDoc, field: string): Markup {
return jsonToMarkup(json as MarkupNode)
}
/**
* Get ProseMirror node from Y.Doc content
*
* @public
*/
export function yDocContentToNode (
content: ArrayBuffer,
field?: string,
schema?: Schema,
extensions?: Extensions
): Node {
const ydoc = new YDoc()
const uint8arr = new Uint8Array(content)
applyUpdate(ydoc, uint8arr)
return yDocToNode(ydoc, field, schema, extensions)
}
const defaultSchema = getSchema(defaultExtensions)
/**
* Get ProseMirror node from Y.Doc
*
* @public
*/
export function yDocToNode (ydoc: YDoc, field?: string, schema?: Schema, extensions?: Extensions): Node {
schema ??= getSchema(extensions ?? defaultExtensions)
try {
const body = yDocToProsemirrorJSON(ydoc, field)
return schema.nodeFromJSON(body)
} catch (err: any) {
console.error(err)
return schema.node(schema.topNodeType)
}
}
/**
* Get ProseMirror nodes from Y.Doc content
*
@ -117,7 +144,7 @@ export function updateYDocContent (
schema ??= extensions === undefined ? defaultSchema : getSchema(extensions ?? defaultExtensions)
try {
const ydoc = new YDoc()
const ydoc = new YDoc({ gc: false })
const res = new YDoc({ gc: false })
const uint8arr = new Uint8Array(content)
applyUpdate(ydoc, uint8arr)
@ -135,20 +162,3 @@ export function updateYDocContent (
console.error(err)
}
}
/**
* Create Y.Doc
*
* @public
*/
export function YDocFromContent (content: MarkupNode, field: string, schema?: Schema, extensions?: Extensions): YDoc {
schema ??= extensions === undefined ? defaultSchema : getSchema(extensions ?? defaultExtensions)
const res = new YDoc({ gc: false })
const yDoc = prosemirrorJSONToYDoc(schema, content, field)
const update = encodeStateAsUpdate(yDoc)
applyUpdate(res, update)
return res
}

View File

@ -16,7 +16,7 @@
import { Class, Doc, Ref } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
import { getResource } from '@hcengineering/platform'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Collaboration } from '@hcengineering/text-editor-resources'
import {
@ -218,15 +218,19 @@
const project = await getLatestProjectId($controlledDocument.space)
if (project !== undefined) {
const id = await createNewDraftForControlledDoc(
client,
$controlledDocument,
$controlledDocument.space,
version,
project
)
const loc = getProjectDocumentLink(id, project)
navigate(loc)
try {
const id = await createNewDraftForControlledDoc(
client,
$controlledDocument,
$controlledDocument.space,
version,
project
)
const loc = getProjectDocumentLink(id, project)
navigate(loc)
} catch (err) {
await setPlatformStatus(unknownError(err))
}
} else {
console.warn('No document project found for space', $controlledDocument.space)
}
@ -237,7 +241,11 @@
async function onEditDocument (): Promise<void> {
if ($controlledDocument != null && $canCreateNewSnapshot && $isProjectEditable) {
await createDocumentSnapshotAndEdit(client, $controlledDocument)
try {
await createDocumentSnapshotAndEdit(client, $controlledDocument)
} catch (err) {
await setPlatformStatus(unknownError(err))
}
} else {
console.warn('Unexpected document state', $documentState)
}

View File

@ -5,10 +5,9 @@
import { CollaborationIds, type Ydoc } from '@hcengineering/text-editor'
import {
CollaborationDiffViewer,
Provider,
StringDiffViewer,
TiptapCollabProvider,
createTiptapCollaborationData,
formatCollaborativeDocumentId
createTiptapCollaborationData
} from '@hcengineering/text-editor-resources'
import { Dropdown, Label, ListItem, Loading, Scroller, themeStore } from '@hcengineering/ui'
import documents, {
@ -25,7 +24,7 @@
$documentComparisonVersions as documentComparisonVersions,
comparisonRequested
} from '../../stores/editors/document'
import { COLLABORATOR_URL, TOKEN, getTranslatedControlledDocStates, getTranslatedDocumentStates } from '../../utils'
import { getTranslatedControlledDocStates, getTranslatedDocumentStates } from '../../utils'
import DocumentTitle from './DocumentTitle.svelte'
const client = getClient()
@ -33,7 +32,7 @@
const ydoc = getContext<Ydoc>(CollaborationIds.Doc)
let comparedYdoc: Ydoc | undefined = undefined
let comparedProvider: TiptapCollabProvider | undefined = undefined
let comparedProvider: Provider | undefined = undefined
let loading = true
const handleSelect = (event: CustomEvent<ListItem>) => {
@ -86,23 +85,20 @@
}))
$: if ($compareTo) {
if (comparedProvider) {
comparedProvider.disconnect()
comparedProvider.destroy()
}
loading = true
const collaborativeDoc = $compareTo.content
const data = createTiptapCollaborationData({
collaboratorURL: COLLABORATOR_URL,
token: TOKEN,
documentId: formatCollaborativeDocumentId(collaborativeDoc)
document: $compareTo.content
})
comparedYdoc = data.ydoc
comparedProvider = data.provider
comparedProvider.loaded.then(() => (loading = false))
void comparedProvider.loaded.then(() => (loading = false))
}
onDestroy(() => {
comparedProvider?.destroy()
void comparedProvider?.destroy()
})
</script>

View File

@ -25,7 +25,7 @@ import {
type MixinData
} from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { copyDocument, takeSnapshot } from '@hcengineering/presentation'
import { copyDocument } from '@hcengineering/presentation'
import { themeStore } from '@hcengineering/ui'
import documents, {
type ControlledDocument,
@ -55,6 +55,18 @@ export async function createNewDraftForControlledDoc (
newDraftDocId = newDraftDocId ?? generateId()
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<ChangeControl>()
const newCCSpec: Data<ChangeControl> = {
@ -66,14 +78,6 @@ export async function createNewDraftForControlledDoc (
await createChangeControl(client, newCCId, newCCSpec, document.space)
const collaborativeDoc = getCollaborativeDocForDocument(
`DOC-${document.prefix}`,
document.seqNumber,
document.major,
document.minor,
true
)
// TODO: copy labels?
const docSpec: AttachedData<ControlledDocument> = {
...(document.template != null ? { template: document.template } : {}),
@ -133,10 +137,6 @@ export async function createNewDraftForControlledDoc (
})
}
if (document.content !== undefined) {
await copyDocument(document.content, collaborativeDoc)
}
const documentTraining = getDocumentTraining(hierarchy, document)
if (documentTraining !== undefined) {
const newDraftDoc = await client.findOne(document._class, { _id: newDraftDocId })
@ -158,10 +158,19 @@ export async function createNewDraftForControlledDoc (
}
export async function createDocumentSnapshotAndEdit (client: TxOperations, document: ControlledDocument): Promise<void> {
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}`
const snapshot = await takeSnapshot(document.content, name)
const newSnapshotId = generateId<ControlledDocumentSnapshot>()
const op = client.apply(document._id)
@ -176,7 +185,7 @@ export async function createDocumentSnapshotAndEdit (client: TxOperations, docum
name,
state: document.state,
controlledState: document.controlledState,
content: snapshot
content: collaborativeDoc
},
newSnapshotId
)

View File

@ -27,11 +27,10 @@ import core, {
getCurrentAccount,
checkPermission
} from '@hcengineering/core'
import { type IntlString, getMetadata, translate } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import { type IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { type Person, type Employee, type PersonAccount } from '@hcengineering/contact'
import request, { RequestStatus } from '@hcengineering/request'
import textEditor from '@hcengineering/text-editor'
import { isEmptyMarkup } from '@hcengineering/text'
import { showPopup, getUserTimezone, type Location } from '@hcengineering/ui'
import { type KeyFilter } from '@hcengineering/view'
@ -62,9 +61,6 @@ import { wizardOpened } from './stores/wizards/create-document'
export type TranslatedDocumentStates = Readonly<Record<DocumentState, string>>
export const TOKEN = getMetadata(presentation.metadata.Token) ?? ''
export const COLLABORATOR_URL = getMetadata(textEditor.metadata.CollaboratorUrl) ?? ''
export const isDocumentCommentAttachedTo = (
value: DocumentComment | null | undefined,
location: { nodeId?: string | null }

View File

@ -16,7 +16,7 @@
-->
<script lang="ts">
import { Document } from '@hcengineering/document'
import { Card, getClient, takeSnapshot } from '@hcengineering/presentation'
import { Card, getClient } from '@hcengineering/presentation'
import { EditBox } from '@hcengineering/ui'
import document from '../plugin'
@ -27,19 +27,19 @@
let name = ''
async function create (): Promise<void> {
const snapshot = await takeSnapshot(doc.content, name)
await client.addCollection(
document.class.DocumentSnapshot,
doc.space,
doc._id,
document.class.Document,
'snapshots',
{
name,
content: snapshot
}
)
// TODO implement me
// const snapshot = await takeSnapshot(doc.content, name)
// await client.addCollection(
// document.class.DocumentSnapshot,
// doc.space,
// doc._id,
// document.class.Document,
// 'snapshots',
// {
// name,
// content: snapshot
// }
// )
}
</script>

View File

@ -72,6 +72,7 @@
"prosemirror-codemark": "^0.4.2",
"y-protocols": "^1.0.6",
"y-prosemirror": "^1.2.1",
"y-websocket": "^2.0.4",
"yjs": "^13.5.52",
"fast-equals": "^5.0.1",
"rfc6902": "^5.0.1",

View File

@ -16,14 +16,12 @@
-->
<script lang="ts">
import { type Class, type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core'
import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { onDestroy, setContext } from 'svelte'
import textEditor, { CollaborationIds } from '@hcengineering/text-editor'
import { CollaborationIds } from '@hcengineering/text-editor'
import { TiptapCollabProvider, createTiptapCollaborationData } from '../provider/tiptap'
import { formatCollaborativeDocumentId, formatPlatformDocumentId } from '../provider/utils'
import { createTiptapCollaborationData } from '../provider/utils'
import { Provider } from '../provider/types'
import { formatDocumentId } from '@hcengineering/collaborator-client'
export let collaborativeDoc: CollaborativeDoc
export let initialCollaborativeDoc: CollaborativeDoc | undefined = undefined
@ -32,50 +30,34 @@
export let objectId: Ref<Doc> | undefined = undefined
export let objectAttr: string | undefined = undefined
const token = getMetadata(presentation.metadata.Token) ?? ''
const collaboratorURL = getMetadata(textEditor.metadata.CollaboratorUrl) ?? ''
let provider: Provider | undefined
let initialContentId: DocumentId | undefined
let platformDocumentId: PlatformDocumentId | undefined
// while editing, collaborative doc may change, hence we need to
// build stable key to ensure we don't do unnecessary updates
$: documentId = formatDocumentId('', collaborativeDoc)
$: documentId = formatCollaborativeDocumentId(collaborativeDoc)
$: if (initialCollaborativeDoc !== undefined) {
initialContentId = formatCollaborativeDocumentId(initialCollaborativeDoc)
}
$: if (objectClass !== undefined && objectId !== undefined && objectAttr !== undefined) {
platformDocumentId = formatPlatformDocumentId(objectClass, objectId, objectAttr)
}
let _documentId: DocumentId | undefined
let provider: TiptapCollabProvider | undefined
let _documentId: string | undefined
$: if (_documentId !== documentId) {
_documentId = documentId
if (provider !== undefined) {
provider.disconnect()
void provider.destroy()
}
const data = createTiptapCollaborationData({
documentId,
initialContentId,
platformDocumentId,
collaboratorURL,
token
document: collaborativeDoc,
initialDocument: initialCollaborativeDoc,
objectClass,
objectId,
objectAttr
})
provider = data.provider
setContext(CollaborationIds.Doc, data.ydoc)
setContext(CollaborationIds.Provider, provider)
provider.on('status', (event: any) => {
console.log('Collaboration:', documentId, event.status) // logs "connected" or "disconnected"
})
provider.on('synced', (event: any) => {
console.log('Collaboration:', event) // logs "connected" or "disconnected"
})
}
onDestroy(() => {
provider?.destroy()
void provider?.destroy()
})
</script>

View File

@ -16,29 +16,32 @@
-->
<script lang="ts">
import type { AwarenessState, AwarenessStateMap } from '@hcengineering/text-editor'
import { AnySvelteComponent, Button, DelayedCaller } from '@hcengineering/ui'
import { onMount } from 'svelte'
import { Editor } from '@tiptap/core'
import { onMount } from 'svelte'
import { createRelativePositionFromJSON } from 'yjs'
import { relativePositionToAbsolutePosition, ySyncPluginKey } from 'y-prosemirror'
import { Provider } from '../provider/types'
import { TiptapCollabProvider } from '../provider/tiptap'
import { AwarenessChangeEvent, CollaborationUserState } from '@hcengineering/text-editor'
export let provider: TiptapCollabProvider
export let provider: Provider
export let editor: Editor
export let component: AnySvelteComponent
let states: CollaborationUserState[] = []
let states: AwarenessState[] = []
const debounce = new DelayedCaller(100)
const onAwarenessChange = (event: AwarenessChangeEvent): void => {
const onAwarenessChange = (): void => {
debounce.call(() => {
states = event.states.filter((p) => p.user != null).filter((p) => p.clientId !== provider.awareness?.clientID)
const map: AwarenessStateMap = provider.awareness?.states ?? new Map()
const entries: Array<[number, AwarenessState]> = Array.from(map.entries())
states = entries
.filter(([clientId, state]) => clientId !== provider.awareness?.clientID && state.user != null)
.map(([_, state]) => state)
})
}
function goToCursor (state: CollaborationUserState): void {
function goToCursor (state: AwarenessState): void {
const cursor = state.cursor
if (cursor?.head != null) {
try {
@ -60,8 +63,8 @@
}
onMount(() => {
provider.on('awarenessUpdate', onAwarenessChange)
return () => provider.off('awarenessUpdate', onAwarenessChange)
provider.awareness?.on('update', onAwarenessChange)
return () => provider.awareness?.off('update', onAwarenessChange)
})
</script>

View File

@ -15,10 +15,9 @@
//
-->
<script lang="ts">
import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client'
import { type Space, type Class, type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core'
import { IntlString, getMetadata, translate } from '@hcengineering/platform'
import presentation, { getFileUrl, getImageSize } from '@hcengineering/presentation'
import { IntlString, translate } from '@hcengineering/platform'
import { getFileUrl, getImageSize } from '@hcengineering/presentation'
import { markupToJSON } from '@hcengineering/text'
import {
AnySvelteComponent,
@ -43,9 +42,8 @@
import { deleteAttachment } from '../command/deleteAttachment'
import { textEditorCommandHandler } from '../commands'
import { EditorKitOptions, getEditorKit } from '../../src/kits/editor-kit'
import { IndexeddbProvider } from '../provider/indexeddb'
import { TiptapCollabProvider } from '../provider/tiptap'
import { formatCollaborativeDocumentId, formatPlatformDocumentId } from '../provider/utils'
import { Provider } from '../provider/types'
import { createLocalProvider, createRemoteProvider } from '../provider/utils'
import textEditor, {
CollaborationIds,
CollaborationUser,
@ -103,37 +101,19 @@
const dispatch = createEventDispatcher()
const token = getMetadata(presentation.metadata.Token) ?? ''
const collaboratorURL = getMetadata(textEditor.metadata.CollaboratorUrl) ?? ''
const documentId = formatCollaborativeDocumentId(collaborativeDoc)
let initialContentId: DocumentId | undefined
if (initialCollaborativeDoc !== undefined) {
initialContentId = formatCollaborativeDocumentId(collaborativeDoc)
}
let platformDocumentId: PlatformDocumentId | undefined
if (objectClass !== undefined && objectId !== undefined && objectAttr !== undefined) {
platformDocumentId = formatPlatformDocumentId(objectClass, objectId, objectAttr)
}
const ydoc = getContext<YDoc>(CollaborationIds.Doc) ?? new YDoc()
const contextProvider = getContext<TiptapCollabProvider>(CollaborationIds.Provider)
const contextProvider = getContext<Provider>(CollaborationIds.Provider)
const localProvider = new IndexeddbProvider(collaborativeDoc, ydoc)
const localProvider = createLocalProvider(ydoc, collaborativeDoc)
const remoteProvider: TiptapCollabProvider =
const remoteProvider =
contextProvider ??
new TiptapCollabProvider({
url: collaboratorURL,
name: documentId,
document: ydoc,
token,
parameters: {
initialContentId,
platformDocumentId
}
createRemoteProvider(ydoc, {
document: collaborativeDoc,
initialDocument: initialCollaborativeDoc,
objectClass,
objectId,
objectAttr
})
let localSynced = false
@ -478,7 +458,7 @@
} catch (err: any) {}
}
if (contextProvider === undefined) {
remoteProvider.destroy()
void remoteProvider.destroy()
}
void localProvider.destroy()
})

View File

@ -73,12 +73,8 @@ export { InlineToolbarExtension, type InlineStyleToolbarOptions } from './compon
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
export { ImageUploadExtension, type ImageUploadOptions } from './components/extension/imageUploadExt'
export * from './command/deleteAttachment'
export {
TiptapCollabProvider,
type TiptapCollabProviderConfiguration,
createTiptapCollaborationData
} from './provider/tiptap'
export { formatCollaborativeDocumentId, formatPlatformDocumentId } from './provider/utils'
export { createTiptapCollaborationData } from './provider/utils'
export { type Provider } from './provider/types'
export default async (): Promise<Resources> => ({
function: {

View File

@ -0,0 +1,44 @@
//
// 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 as YDoc } from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { type Provider } from './types'
export interface DatalakeCollabProviderParameters {
url: string
name: string
token: string
document: YDoc
}
export class CloudCollabProvider extends WebsocketProvider implements Provider {
readonly loaded: Promise<void>
constructor (params: DatalakeCollabProviderParameters) {
const { document, url, name } = params
super(url, encodeURIComponent(name), document)
this.loaded = new Promise((resolve) => {
this.on('synced', resolve)
})
}
destroy (): void {
this.disconnect()
}
}

View File

@ -14,23 +14,23 @@
//
import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client'
import { HocuspocusProvider, type HocuspocusProviderConfiguration } from '@hocuspocus/provider'
import { Doc as Ydoc } from 'yjs'
import { type Provider } from './types'
export type TiptapCollabProviderConfiguration = HocuspocusProviderConfiguration &
export type HocuspocusCollabProviderConfiguration = HocuspocusProviderConfiguration &
Required<Pick<HocuspocusProviderConfiguration, 'token'>> &
Omit<HocuspocusProviderConfiguration, 'parameters'> & {
parameters: TiptapCollabProviderURLParameters
parameters: HocuspocusCollabProviderURLParameters
}
export interface TiptapCollabProviderURLParameters {
export interface HocuspocusCollabProviderURLParameters {
initialContentId?: DocumentId
platformDocumentId?: PlatformDocumentId
}
export class TiptapCollabProvider extends HocuspocusProvider {
loaded: Promise<void>
export class HocuspocusCollabProvider extends HocuspocusProvider implements Provider {
readonly loaded: Promise<void>
constructor (configuration: TiptapCollabProviderConfiguration) {
constructor (configuration: HocuspocusCollabProviderConfiguration) {
const parameters: Record<string, any> = {}
const initialContentId = configuration.parameters?.initialContentId
@ -59,26 +59,3 @@ export class TiptapCollabProvider extends HocuspocusProvider {
this.configuration.websocketProvider.disconnect()
}
}
export const createTiptapCollaborationData = (params: {
documentId: string
initialContentId?: DocumentId
platformDocumentId?: PlatformDocumentId
collaboratorURL: string
token: string
}): { provider: TiptapCollabProvider, ydoc: Ydoc } => {
const ydoc: Ydoc = new Ydoc()
return {
ydoc,
provider: new TiptapCollabProvider({
url: params.collaboratorURL,
name: params.documentId,
document: ydoc,
token: params.token,
parameters: {
initialContentId: params.initialContentId,
platformDocumentId: params.platformDocumentId
}
})
}
}

View File

@ -12,16 +12,23 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { collaborativeDocParse, type CollaborativeDoc } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { type Doc as YDoc } from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
import { type Awareness } from 'y-protocols/awareness'
export class IndexeddbProvider extends IndexeddbPersistence {
loaded: Promise<void>
import { type Provider } from './types'
constructor (collaborativeDoc: CollaborativeDoc, doc: YDoc) {
const { documentId, versionId } = collaborativeDocParse(collaborativeDoc)
const name = `${documentId}/${versionId}`
export class IndexeddbProvider extends IndexeddbPersistence implements Provider {
readonly loaded: Promise<void>
readonly awareness: Awareness | null = null
constructor (documentId: string, doc: YDoc) {
const workspaceId: string = getMetadata(presentation.metadata.Workspace) ?? ''
const name = `${workspaceId}/${documentId}`
super(name, doc)
@ -29,4 +36,8 @@ export class IndexeddbProvider extends IndexeddbPersistence {
this.on('synced', resolve)
})
}
async destroy (): Promise<void> {
await super.destroy()
}
}

View File

@ -0,0 +1,23 @@
//
// Copyright © 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
// 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 Awareness } from 'y-protocols/awareness'
export interface Provider {
loaded: Promise<void>
awareness: Awareness | null
destroy: () => void | Promise<void>
}

View File

@ -17,18 +17,22 @@ import { type Ref, type CollaborativeDoc, type Doc, type Class } from '@hcengine
import {
type DocumentId,
type PlatformDocumentId,
formatMinioDocumentId,
formatDocumentId,
formatPlatformDocumentId as origFormatPlatformDocumentId
} from '@hcengineering/collaborator-client'
import { getCurrentLocation } from '@hcengineering/ui'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import textEditor from '@hcengineering/text-editor'
import { Doc as Ydoc } from 'yjs'
function getWorkspace (): string {
return getCurrentLocation().path[1] ?? ''
}
import { CloudCollabProvider } from './cloud'
import { HocuspocusCollabProvider } from './hocuspocus'
import { IndexeddbProvider } from './indexeddb'
import { type Provider } from './types'
export function formatCollaborativeDocumentId (collaborativeDoc: CollaborativeDoc): DocumentId {
const workspace = getWorkspace()
return formatMinioDocumentId(workspace, collaborativeDoc)
const workspace = getMetadata(presentation.metadata.Workspace) ?? ''
return formatDocumentId(workspace, collaborativeDoc)
}
export function formatPlatformDocumentId (
@ -38,3 +42,66 @@ export function formatPlatformDocumentId (
): PlatformDocumentId {
return origFormatPlatformDocumentId(objectClass, objectId, objectAttr)
}
export function createLocalProvider (ydoc: Ydoc, document: CollaborativeDoc): Provider {
const documentId = formatCollaborativeDocumentId(document)
return new IndexeddbProvider(documentId, ydoc)
}
export function createRemoteProvider (
ydoc: Ydoc,
params: {
document: CollaborativeDoc
initialDocument?: CollaborativeDoc
objectClass?: Ref<Class<Doc>>
objectId?: Ref<Doc>
objectAttr?: string
}
): 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
return collaborator === 'cloud'
? new CloudCollabProvider({
url: collaboratorUrl,
name: documentId,
document: ydoc,
token
})
: new HocuspocusCollabProvider({
url: collaboratorUrl,
name: documentId,
document: ydoc,
token,
parameters: {
initialContentId,
platformDocumentId
}
})
}
export const createTiptapCollaborationData = (params: {
document: CollaborativeDoc
initialDocument?: CollaborativeDoc
objectClass?: Ref<Class<Doc>>
objectId?: Ref<Doc>
objectAttr?: string
}): { provider: Provider, ydoc: Ydoc } => {
const ydoc: Ydoc = new Ydoc()
return {
ydoc,
provider: createRemoteProvider(ydoc, params)
}
}

View File

@ -17,7 +17,7 @@
import { type Class, type Ref } from '@hcengineering/core'
import { type Asset, type IntlString, type Metadata, type Plugin, plugin } from '@hcengineering/platform'
import { type TextEditorExtensionFactory, type RefInputActionItem, TextEditorAction } from './types'
import { type TextEditorExtensionFactory, type RefInputActionItem, TextEditorAction, CollaboratorType } from './types'
/**
* @public
@ -31,7 +31,7 @@ export default plugin(textEditorId, {
TextEditorAction: '' as Ref<Class<TextEditorAction>>
},
metadata: {
CollaboratorUrl: '' as Metadata<string>
Collaborator: '' as Metadata<CollaboratorType>
},
string: {
TableOfContents: '' as IntlString,

View File

@ -6,6 +6,12 @@ import { type AnyExtension, type Content, type Editor, type SingleCommands } fro
import { type ParseOptions } from '@tiptap/pm/model'
export type { AnyExtension, Editor } from '@tiptap/core'
/**
* @public
*/
export type CollaboratorType = 'local' | 'cloud'
/**
* @public
*/
@ -122,8 +128,7 @@ export interface CollaborationUser {
}
/** @public */
export interface CollaborationUserState {
clientId: number
export interface AwarenessState {
user: CollaborationUser
cursor?: {
anchor: RelativePosition
@ -133,9 +138,10 @@ export interface CollaborationUserState {
}
/** @public */
export interface AwarenessChangeEvent {
states: CollaborationUserState[]
}
export type AwarenessClientId = number
/** @public */
export type AwarenessStateMap = Map<AwarenessClientId, AwarenessState>
export type TextEditorMode = 'full' | 'compact'
export type ExtensionCreator = (mode: TextEditorMode, ctx: any) => AnyExtension

View File

@ -19,11 +19,11 @@ import { collaborativeHistoryDocId, isEditableDoc, isEditableDocVersion } from '
describe('collaborative-doc', () => {
describe('collaborativeHistoryDocId', () => {
it('returns valid history doc id', async () => {
expect(collaborativeHistoryDocId('minioDocumentId')).toEqual('minioDocumentId#history')
expect(collaborativeHistoryDocId('documentId')).toEqual('documentId#history')
})
it('returns valid history doc id for history doc id', async () => {
expect(collaborativeHistoryDocId('minioDocumentId#history')).toEqual('minioDocumentId#history')
expect(collaborativeHistoryDocId('documentId#history')).toEqual('documentId#history')
})
})

View File

@ -25,9 +25,7 @@ import {
import { Doc as YDoc } from 'yjs'
import { StorageAdapter } from '@hcengineering/server-core'
import { yDocBranch } from '../history/branch'
import { YDocVersion } from '../history/history'
import { createYdocSnapshot, restoreYdocSnapshot } from '../history/snapshot'
import { restoreYdocSnapshot } from '../history/snapshot'
import { yDocFromStorage, yDocToStorage } from './storage'
/** @public */
@ -151,69 +149,6 @@ export async function removeCollaborativeDoc (
})
}
/** @public */
export async function copyCollaborativeDoc (
storageAdapter: StorageAdapter,
workspace: WorkspaceId,
source: CollaborativeDoc,
target: CollaborativeDoc,
ctx: MeasureContext
): Promise<YDoc | undefined> {
const { documentId: sourceDocumentId } = collaborativeDocParse(source)
const { documentId: targetDocumentId, versionId: targetVersionId } = collaborativeDocParse(target)
if (sourceDocumentId === targetDocumentId) {
// no need to copy into itself
return
}
await ctx.with('copyCollaborativeDoc', {}, async (ctx) => {
const ySource = await ctx.with('loadCollaborativeDocVersion', {}, async (ctx) => {
return await loadCollaborativeDoc(storageAdapter, workspace, source, ctx)
})
if (ySource === undefined) {
return
}
const yTarget = await ctx.with('yDocBranch', {}, () => {
return yDocBranch(ySource)
})
await ctx.with('saveCollaborativeDocVersion', {}, async (ctx) => {
await saveCollaborativeDocVersion(storageAdapter, workspace, targetDocumentId, targetVersionId, yTarget, ctx)
})
})
}
/** @public */
export async function takeCollaborativeDocSnapshot (
storageAdapter: StorageAdapter,
workspace: WorkspaceId,
collaborativeDoc: CollaborativeDoc,
ydoc: YDoc,
version: YDocVersion,
ctx: MeasureContext
): Promise<void> {
const { documentId } = collaborativeDocParse(collaborativeDoc)
const historyDocumentId = collaborativeHistoryDocId(documentId)
await ctx.with('takeCollaborativeDocSnapshot', {}, async (ctx) => {
const yHistory =
(await ctx.with('yDocFromStorage', { type: 'history' }, async (ctx) => {
return await yDocFromStorage(ctx, storageAdapter, workspace, historyDocumentId, new YDoc({ gc: false }))
})) ?? new YDoc()
await ctx.with('createYdocSnapshot', {}, async () => {
createYdocSnapshot(ydoc, yHistory, version)
})
await ctx.with('yDocToStorage', { type: 'history' }, async (ctx) => {
await yDocToStorage(ctx, storageAdapter, workspace, historyDocumentId, yHistory)
})
})
}
/** @public */
export function isEditableDoc (id: CollaborativeDoc): boolean {
const { versionId } = collaborativeDocParse(id)

View File

@ -24,11 +24,11 @@ export async function yDocFromStorage (
ctx: MeasureContext,
storageAdapter: StorageAdapter,
workspace: WorkspaceId,
minioDocumentId: string,
documentId: string,
ydoc?: YDoc
): Promise<YDoc | undefined> {
// stat the object to ensure it exists, because read will throw an error in this case
const blob = await storageAdapter.stat(ctx, workspace, minioDocumentId)
const blob = await storageAdapter.stat(ctx, workspace, documentId)
if (blob === undefined) {
return undefined
}
@ -37,7 +37,7 @@ export async function yDocFromStorage (
// it is either already gc-ed, or gc not needed and it is disabled
ydoc ??= new YDoc({ gc: false })
const buffer = await storageAdapter.read(ctx, workspace, minioDocumentId)
const buffer = await storageAdapter.read(ctx, workspace, documentId)
return yDocFromBuffer(Buffer.concat(buffer), ydoc)
}
@ -46,9 +46,9 @@ export async function yDocToStorage (
ctx: MeasureContext,
storageAdapter: StorageAdapter,
workspace: WorkspaceId,
minioDocumentId: string,
documentId: string,
ydoc: YDoc
): Promise<void> {
const buffer = yDocToBuffer(ydoc)
await storageAdapter.put(ctx, workspace, minioDocumentId, buffer, 'application/ydoc', buffer.length)
await storageAdapter.put(ctx, workspace, documentId, buffer, 'application/ydoc', buffer.length)
}

View File

@ -26,15 +26,15 @@ import {
onLoadDocumentPayload,
onStoreDocumentPayload
} from '@hocuspocus/server'
import { Transformer } from '@hocuspocus/transformer'
import { Doc as YDoc } from 'yjs'
import { Context, withContext } from '../context'
import { CollabStorageAdapter } from '../storage/adapter'
import { TransformerFactory } from '../types'
export interface StorageConfiguration {
ctx: MeasureContext
adapter: CollabStorageAdapter
transformerFactory: TransformerFactory
transformer: Transformer
}
export class StorageExtension implements Extension {
@ -60,11 +60,8 @@ export class StorageExtension implements Extension {
}
async afterLoadDocument ({ context, documentName, document }: withContext<afterLoadDocumentPayload>): Promise<any> {
const { workspaceId } = context
// remember the markup for the document
const transformer = this.configuration.transformerFactory(workspaceId)
this.markups.set(documentName, transformer.fromYdoc(document))
this.markups.set(documentName, this.configuration.transformer.fromYdoc(document))
}
async onStoreDocument ({ context, documentName, document }: withContext<onStoreDocumentPayload>): Promise<void> {
@ -127,13 +124,10 @@ export class StorageExtension implements Extension {
private async storeDocument (documentName: string, document: Document, context: Context): Promise<void> {
const { ctx, adapter } = this.configuration
const { workspaceId } = context
try {
const transformer = this.configuration.transformerFactory(workspaceId)
const prevMarkup = this.markups.get(documentName) ?? {}
const currMarkup = transformer.fromYdoc(document)
const currMarkup = this.configuration.transformer.fromYdoc(document)
await ctx.with('save-document', {}, async (ctx) => {
await adapter.saveDocument(ctx, documentName as DocumentId, document, context, {

View File

@ -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 { yDocBranchWithGC } from '@hcengineering/collaboration'
import { type BranchDocumentRequest, type BranchDocumentResponse } from '@hcengineering/collaborator-client'
import { MeasureContext } from '@hcengineering/core'
import { applyUpdate, encodeStateAsUpdate } from 'yjs'
import { Context } from '../../context'
import { RpcMethodParams } from '../rpc'
export async function branchDocument (
ctx: MeasureContext,
context: Context,
payload: BranchDocumentRequest,
params: RpcMethodParams
): Promise<BranchDocumentResponse> {
const { sourceDocumentId, targetDocumentId } = payload
const { hocuspocus } = params
const sourceConnection = await ctx.with('connect', { type: 'source' }, async () => {
return await hocuspocus.openDirectConnection(sourceDocumentId, context)
})
const targetConnection = await ctx.with('connect', { type: 'target' }, async () => {
return await hocuspocus.openDirectConnection(targetDocumentId, context)
})
try {
let update = new Uint8Array()
await sourceConnection.transact((document) => {
const copy = yDocBranchWithGC(document)
update = encodeStateAsUpdate(copy)
})
await targetConnection.transact((document) => {
applyUpdate(document, update)
})
} finally {
await sourceConnection.disconnect()
await targetConnection.disconnect()
}
return {}
}

View File

@ -1,64 +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 { YDocVersion, takeCollaborativeDocSnapshot, yDocCopyXmlField } from '@hcengineering/collaboration'
import { parseDocumentId, type CopyContentRequest, type CopyContentResponse } from '@hcengineering/collaborator-client'
import { MeasureContext } from '@hcengineering/core'
import { Doc as YDoc } from 'yjs'
import { Context } from '../../context'
import { RpcMethodParams } from '../rpc'
export async function copyContent (
ctx: MeasureContext,
context: Context,
payload: CopyContentRequest,
params: RpcMethodParams
): Promise<CopyContentResponse> {
const { documentId, sourceField, targetField, snapshot } = payload
const { hocuspocus, storageAdapter } = params
const { workspaceId } = context
const connection = await ctx.with('connect', {}, async () => {
return await hocuspocus.openDirectConnection(documentId, context)
})
try {
await ctx.with('copy', {}, async () => {
await connection.transact((document) => {
yDocCopyXmlField(document, sourceField, targetField)
})
})
if (snapshot !== undefined && snapshot.versionId !== 'HEAD') {
const ydoc = connection.document ?? new YDoc()
const { collaborativeDoc } = parseDocumentId(documentId)
const version: YDocVersion = {
versionId: snapshot.versionId,
name: snapshot.versionName ?? snapshot.versionId,
createdBy: snapshot.createdBy,
createdOn: Date.now()
}
await ctx.with('snapshot', {}, async () => {
await takeCollaborativeDocSnapshot(storageAdapter, workspaceId, collaborativeDoc, ydoc, version, ctx)
})
}
} finally {
await connection.disconnect()
}
return {}
}

View File

@ -21,10 +21,10 @@ import { RpcMethodParams } from '../rpc'
export async function getContent (
ctx: MeasureContext,
context: Context,
documentId: string,
payload: GetContentRequest,
params: RpcMethodParams
): Promise<GetContentResponse> {
const { documentId } = payload
const { hocuspocus, transformer } = params
const connection = await ctx.with('connect', {}, async () => {

View File

@ -14,18 +14,10 @@
//
import { getContent } from './getContent'
import { copyContent } from './copyContent'
import { updateContent } from './updateContent'
import { branchDocument } from './branchDocument'
import { removeDocument } from './removeDocument'
import { takeSnapshot } from './takeSnapshot'
import { RpcMethod } from '../rpc'
export const methods: Record<string, RpcMethod> = {
getContent,
copyContent,
updateContent,
branchDocument,
removeDocument,
takeSnapshot
updateContent
}

View File

@ -1,53 +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 { collaborativeHistoryDocId } from '@hcengineering/collaboration'
import {
parseDocumentId,
type RemoveDocumentRequest,
type RemoveDocumentResponse
} from '@hcengineering/collaborator-client'
import { MeasureContext, collaborativeDocParse } from '@hcengineering/core'
import { Context } from '../../context'
import { RpcMethodParams } from '../rpc'
export async function removeDocument (
ctx: MeasureContext,
context: Context,
payload: RemoveDocumentRequest,
params: RpcMethodParams
): Promise<RemoveDocumentResponse> {
const { documentId } = payload
const { hocuspocus, storageAdapter } = params
const { workspaceId } = context
const document = hocuspocus.documents.get(documentId)
if (document !== undefined) {
hocuspocus.closeConnections(documentId)
hocuspocus.unloadDocument(document)
}
const { collaborativeDoc } = parseDocumentId(documentId)
const { documentId: contentDocumentId } = collaborativeDocParse(collaborativeDoc)
const historyDocumentId = collaborativeHistoryDocId(contentDocumentId)
try {
await storageAdapter.remove(ctx, workspaceId, [contentDocumentId, historyDocumentId])
} catch (err) {
ctx.error('failed to remove document', { documentId, error: err })
}
return {}
}

View File

@ -1,65 +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 { YDocVersion, takeCollaborativeDocSnapshot } from '@hcengineering/collaboration'
import {
parseDocumentId,
type TakeSnapshotRequest,
type TakeSnapshotResponse
} from '@hcengineering/collaborator-client'
import { CollaborativeDocVersionHead, MeasureContext, collaborativeDocParse } from '@hcengineering/core'
import { Doc as YDoc } from 'yjs'
import { Context } from '../../context'
import { RpcMethodParams } from '../rpc'
export async function takeSnapshot (
ctx: MeasureContext,
context: Context,
payload: TakeSnapshotRequest,
params: RpcMethodParams
): Promise<TakeSnapshotResponse> {
const { documentId, snapshot } = payload
const { hocuspocus, storageAdapter } = params
const { workspaceId } = context
const version: YDocVersion = {
versionId: snapshot.versionId,
name: snapshot.versionName ?? snapshot.versionId,
createdBy: snapshot.createdBy,
createdOn: Date.now()
}
const { collaborativeDoc } = parseDocumentId(documentId)
const { versionId } = collaborativeDocParse(collaborativeDoc)
if (versionId !== CollaborativeDocVersionHead) {
throw new Error('invalid document version')
}
const connection = await ctx.with('connect', {}, async () => {
return await hocuspocus.openDirectConnection(documentId, context)
})
try {
const ydoc = connection.document ?? new YDoc()
await ctx.with('snapshot', {}, async () => {
await takeCollaborativeDocSnapshot(storageAdapter, workspaceId, collaborativeDoc, ydoc, version, ctx)
})
return { ...version }
} finally {
await connection.disconnect()
}
}

View File

@ -14,25 +14,20 @@
//
import { MeasureContext } from '@hcengineering/core'
import {
parseDocumentId,
type UpdateContentRequest,
type UpdateContentResponse
} from '@hcengineering/collaborator-client'
import { YDocVersion, takeCollaborativeDocSnapshot } from '@hcengineering/collaboration'
import { Doc as YDoc, applyUpdate, encodeStateAsUpdate } from 'yjs'
import { type UpdateContentRequest, type UpdateContentResponse } from '@hcengineering/collaborator-client'
import { applyUpdate, encodeStateAsUpdate } from 'yjs'
import { Context } from '../../context'
import { RpcMethodParams } from '../rpc'
export async function updateContent (
ctx: MeasureContext,
context: Context,
documentId: string,
payload: UpdateContentRequest,
params: RpcMethodParams
): Promise<UpdateContentResponse> {
const { documentId, content, snapshot } = payload
const { hocuspocus, transformer, storageAdapter } = params
const { workspaceId } = context
const { content } = payload
const { hocuspocus, transformer } = params
const updates = await ctx.with('transform', {}, () => {
const updates: Record<string, Uint8Array> = {}
@ -41,6 +36,7 @@ export async function updateContent (
const ydoc = transformer.toYdoc(markup, field)
updates[field] = encodeStateAsUpdate(ydoc)
})
return updates
})
@ -60,22 +56,6 @@ export async function updateContent (
})
})
})
if (snapshot !== undefined && snapshot.versionId !== 'HEAD') {
const ydoc = connection.document ?? new YDoc()
const { collaborativeDoc } = parseDocumentId(documentId)
const version: YDocVersion = {
versionId: snapshot.versionId,
name: snapshot.versionName ?? snapshot.versionId,
createdBy: snapshot.createdBy,
createdOn: Date.now()
}
await ctx.with('snapshot', {}, async () => {
await takeCollaborativeDocSnapshot(storageAdapter, workspaceId, collaborativeDoc, ydoc, version, ctx)
})
}
} finally {
await connection.disconnect()
}

View File

@ -21,6 +21,7 @@ import { Context } from '../context'
export interface RpcRequest {
method: string
documentId: string
payload: object
}
@ -33,6 +34,7 @@ export type RpcResponse = object | RpcErrorResponse
export type RpcMethod = (
ctx: MeasureContext,
context: Context,
documentId: string,
payload: any,
params: RpcMethodParams
) => Promise<RpcResponse>

View File

@ -17,7 +17,6 @@ import { Analytics } from '@hcengineering/analytics'
import { MeasureContext, generateId, metricsAggregate } from '@hcengineering/core'
import type { StorageAdapter } from '@hcengineering/server-core'
import { Token, decodeToken } from '@hcengineering/server-token'
import { ServerKit } from '@hcengineering/text'
import { Hocuspocus } from '@hocuspocus/server'
import bp from 'body-parser'
import cors from 'cors'
@ -33,7 +32,6 @@ import { simpleClientFactory } from './platform'
import { RpcErrorResponse, RpcRequest, RpcResponse, methods } from './rpc'
import { PlatformStorageAdapter } from './storage/platform'
import { MarkupTransformer } from './transformers/markup'
import { TransformerFactory } from './types'
/**
* @public
@ -53,20 +51,7 @@ export async function start (ctx: MeasureContext, config: Config, storageAdapter
app.use(bp.json())
const extensionsCtx = ctx.newChild('extensions', {})
const transformerFactory: TransformerFactory = (workspaceId) => {
const extensions = [
ServerKit.configure({
image: {
getBlobRef: async (fileId, name, size) => {
const src = await storageAdapter.getUrl(ctx, workspaceId, fileId)
return { src, srcset: '' }
}
}
})
]
return new MarkupTransformer(extensions)
}
const transformer = new MarkupTransformer()
const hocuspocus = new Hocuspocus({
address: '0.0.0.0',
@ -110,7 +95,7 @@ export async function start (ctx: MeasureContext, config: Config, storageAdapter
new StorageExtension({
ctx: extensionsCtx.newChild('storage', {}),
adapter: new PlatformStorageAdapter(storageAdapter),
transformerFactory
transformer
})
]
})
@ -159,29 +144,39 @@ export async function start (ctx: MeasureContext, config: Config, storageAdapter
}
const request = req.body as RpcRequest
const documentId = request.documentId
if (documentId === undefined || documentId === '') {
const response: RpcErrorResponse = {
error: 'Missing documentId'
}
res.status(400).send(response)
return
}
const method = methods[request.method]
if (method === undefined) {
const response: RpcErrorResponse = {
error: 'Unknown method'
}
res.status(400).send(response)
} else {
const token = decodeToken(authHeader.split(' ')[1])
const context = getContext(token)
rpcCtx.info('rpc', { method: request.method, connectionId: context.connectionId, mode: token.extra?.mode ?? '' })
await rpcCtx.with('/rpc', { method: request.method }, async (ctx) => {
try {
const transformer = transformerFactory(token.workspace)
const response: RpcResponse = await rpcCtx.with(request.method, {}, async (ctx) => {
return await method(ctx, context, request.payload, { hocuspocus, storageAdapter, transformer })
})
res.status(200).send(response)
} catch (err: any) {
res.status(500).send({ error: err.message })
}
})
return
}
const token = decodeToken(authHeader.split(' ')[1])
const context = getContext(token)
rpcCtx.info('rpc', { method: request.method, connectionId: context.connectionId, mode: token.extra?.mode ?? '' })
await rpcCtx.with('/rpc', { method: request.method }, async (ctx) => {
try {
const response: RpcResponse = await rpcCtx.with(request.method, {}, async (ctx) => {
return await method(ctx, context, documentId, request.payload, { hocuspocus, storageAdapter, transformer })
})
res.status(200).send(response)
} catch (err: any) {
res.status(500).send({ error: err.message })
}
})
})
const wss = new WebSocketServer({

View File

@ -14,12 +14,7 @@
//
import activity, { DocUpdateMessage } from '@hcengineering/activity'
import {
YDocVersion,
loadCollaborativeDoc,
saveCollaborativeDoc,
takeCollaborativeDocSnapshot
} from '@hcengineering/collaboration'
import { loadCollaborativeDoc, saveCollaborativeDoc } from '@hcengineering/collaboration'
import {
DocumentId,
PlatformDocumentId,
@ -98,14 +93,6 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
})
try {
let snapshot: YDocVersion | undefined
try {
ctx.info('take document snapshot', { documentId })
snapshot = await this.takeSnapshot(ctx, client, documentId, document, context)
} catch (err) {
ctx.error('failed to take document snapshot', { documentId, error: err })
}
try {
ctx.info('save document content', { documentId })
await this.saveDocumentToStorage(ctx, documentId, document, context)
@ -120,7 +107,7 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
if (platformDocumentId !== undefined) {
ctx.info('save document content to platform', { documentId, platformDocumentId })
await ctx.with('save-to-platform', {}, async (ctx) => {
await this.saveDocumentToPlatform(ctx, client, documentId, platformDocumentId, snapshot, markup)
await this.saveDocumentToPlatform(ctx, client, documentId, platformDocumentId, markup)
})
}
} finally {
@ -157,39 +144,11 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
})
}
async takeSnapshot (
ctx: MeasureContext,
client: Omit<TxOperations, 'close'>,
documentId: DocumentId,
document: YDoc,
context: Context
): Promise<YDocVersion | undefined> {
const { collaborativeDoc } = parseDocumentId(documentId)
const { workspaceId } = context
const timestamp = Date.now()
const yDocVersion: YDocVersion = {
versionId: `${timestamp}`,
name: 'Automatic snapshot',
createdBy: client.user,
createdOn: timestamp
}
await ctx.with('take-snapshot', {}, async (ctx) => {
await takeCollaborativeDocSnapshot(this.storage, workspaceId, collaborativeDoc, document, yDocVersion, ctx)
})
return yDocVersion
}
async saveDocumentToPlatform (
ctx: MeasureContext,
client: Omit<TxOperations, 'close'>,
documentName: string,
platformDocumentId: PlatformDocumentId,
snapshot: YDocVersion | undefined,
markup: {
prev: Record<string, string>
curr: Record<string, string>
@ -219,8 +178,7 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
}
const collaborativeDoc = (current as any)[objectAttr] as CollaborativeDoc
const newCollaborativeDoc =
snapshot !== undefined ? collaborativeDocWithLastVersion(collaborativeDoc, snapshot.versionId) : collaborativeDoc
const newCollaborativeDoc = collaborativeDocWithLastVersion(collaborativeDoc, `${Date.now()}`)
await ctx.with('update', {}, async () => {
await client.diffUpdate(current, { [objectAttr]: newCollaborativeDoc })

View File

@ -1,41 +0,0 @@
//
// Copyright © 2023 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 { TiptapTransformer, Transformer } from '@hocuspocus/transformer'
import { Extensions } from '@tiptap/core'
import { generateHTML, generateJSON } from '@tiptap/html'
import { Doc } from 'yjs'
export class HtmlTransformer implements Transformer {
transformer: Transformer
constructor (private readonly extensions: Extensions) {
this.transformer = TiptapTransformer.extensions(extensions)
}
fromYdoc (document: Doc, fieldName?: string | string[] | undefined): any {
const json = this.transformer.fromYdoc(document, fieldName)
return generateHTML(json, this.extensions)
}
toYdoc (document: any, fieldName: string): Doc {
if (typeof document === 'string' && document !== '') {
const json = generateJSON(document, this.extensions)
return this.transformer.toYdoc(json, fieldName)
}
return new Doc()
}
}

View File

@ -13,22 +13,14 @@
// limitations under the License.
//
import { jsonToMarkup, markupToJSON } from '@hcengineering/text'
import { TiptapTransformer, Transformer } from '@hocuspocus/transformer'
import { Extensions } from '@tiptap/core'
import { markupToYDocNoSchema, yDocToMarkup } from '@hcengineering/text'
import { Transformer } from '@hocuspocus/transformer'
import { Doc } from 'yjs'
export class MarkupTransformer implements Transformer {
transformer: Transformer
constructor (private readonly extensions: Extensions) {
this.transformer = TiptapTransformer.extensions(extensions)
}
fromYdoc (document: Doc, fieldName?: string | string[] | undefined): any {
const json = this.transformer.fromYdoc(document, fieldName)
if (typeof fieldName === 'string') {
return jsonToMarkup(json)
return yDocToMarkup(document, fieldName)
}
if (fieldName === undefined || fieldName.length === 0) {
@ -37,7 +29,7 @@ export class MarkupTransformer implements Transformer {
const data: Record<string, string> = {}
fieldName?.forEach((field) => {
data[field] = jsonToMarkup(json[field])
data[field] = yDocToMarkup(document, field)
})
return data
@ -45,8 +37,7 @@ export class MarkupTransformer implements Transformer {
toYdoc (document: any, fieldName: string): Doc {
if (typeof document === 'string' && document !== '') {
const json = markupToJSON(document)
return this.transformer.toYdoc(json, fieldName)
return markupToYDocNoSchema(document, fieldName)
}
return new Doc()

View File

@ -13,8 +13,7 @@
// limitations under the License.
//
import type { Class, Doc, Domain, Ref, WorkspaceId } from '@hcengineering/core'
import { Transformer } from '@hocuspocus/transformer'
import type { Class, Doc, Domain, Ref } from '@hcengineering/core'
/** @public */
export interface DocumentId {
@ -30,6 +29,3 @@ export interface PlatformDocumentId {
objectId: Ref<Doc>
objectAttr: string
}
/** @public */
export type TransformerFactory = (workspaceId: WorkspaceId) => Transformer

View File

@ -247,6 +247,7 @@ export function start (
telegramUrl: string
gmailUrl: string
calendarUrl: string
collaborator?: string
collaboratorUrl: string
brandingUrl?: string
previewConfig: string
@ -297,6 +298,7 @@ export function start (
TELEGRAM_URL: config.telegramUrl,
GMAIL_URL: config.gmailUrl,
CALENDAR_URL: config.calendarUrl,
COLLABORATOR: config.collaborator,
COLLABORATOR_URL: config.collaboratorUrl,
BRANDING_URL: config.brandingUrl,
PREVIEW_CONFIG: config.previewConfig,

View File

@ -81,6 +81,8 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
process.exit(1)
}
const collaborator = process.env.COLLABORATOR
const modelVersion = process.env.MODEL_VERSION
if (modelVersion === undefined) {
console.error('please provide model version requirement')
@ -129,6 +131,7 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
rekoniUrl,
calendarUrl,
collaboratorUrl,
collaborator,
brandingUrl,
previewConfig,
pushPublicKey

View File

@ -16,7 +16,7 @@ import core, {
import { ModelLogger } from '@hcengineering/model'
import { makeRank } from '@hcengineering/rank'
import { AggregatorStorageAdapter } from '@hcengineering/server-core'
import { parseMessageMarkdown, YDocFromContent } from '@hcengineering/text'
import { jsonToYDocNoSchema, parseMessageMarkdown } from '@hcengineering/text'
import { v4 as uuid } from 'uuid'
const fieldRegexp = /\${\S+?}/
@ -266,10 +266,11 @@ export class WorkspaceInitializer {
}
private async createCollab (data: string, field: string, _id: Ref<Doc>): Promise<string> {
const json = parseMessageMarkdown(data ?? '', this.imageUrl)
const id = `${_id}%${field}`
const collabId = `${id}:HEAD:0` as CollaborativeDoc
const yDoc = YDocFromContent(json, field)
const json = parseMessageMarkdown(data ?? '', this.imageUrl)
const yDoc = jsonToYDocNoSchema(json, field)
await saveCollaborativeDoc(this.storageAdapter, this.wsUrl, collabId, yDoc, this.ctx)
return collabId

View File

@ -372,16 +372,7 @@ export abstract class IssueSyncManagerBase {
!areEqualMarkups(update.description, syncData.current?.description ?? '')
) {
try {
const versionId = `${Date.now()}`
issueData.description = await this.collaborator.updateContent(
doc.description,
{ description: update.description },
{
versionId,
versionName: versionId,
createdBy: account
}
)
await this.collaborator.updateContent(doc.description, { description: update.description })
} catch (err: any) {
Analytics.handleError(err)
this.ctx.error(err)
@ -926,17 +917,8 @@ export abstract class IssueSyncManagerBase {
workspace: this.provider.getWorkspaceId().name
})
try {
const versionId = `${Date.now()}`
issueData.description = update.description
update.description = await this.collaborator.updateContent(
existingIssue.description,
{ description: update.description },
{
versionId,
versionName: versionId,
createdBy: account
}
)
await this.collaborator.updateContent(existingIssue.description, { description: update.description })
} catch (err: any) {
Analytics.handleError(err)
this.ctx.error('error during description update', err)