diff --git a/dev/docker/hocuspocus/Dockerfile b/dev/docker/hocuspocus/Dockerfile new file mode 100644 index 00000000..be910740 --- /dev/null +++ b/dev/docker/hocuspocus/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine + +WORKDIR /app + +# `patch` isn't in node:alpine by default; needed for the hocuspocus patch. +RUN apk add --no-cache patch + +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund --loglevel=error + +# Apply the pre-built hocuspocus PR #1096 (beforeHandleAwareness) on top of +# the npm @hocuspocus/server@4.0.0 release. Removable once that hook ships in +# a release. See patches/hocuspocus-server-4.0.0.patch. +COPY patches/ ./patches/ +RUN cd node_modules/@hocuspocus/server && patch -p1 < /app/patches/hocuspocus-server-4.0.0.patch + +COPY server.js ./ + +CMD ["node", "server.js"] diff --git a/dev/docker/hocuspocus/package.json b/dev/docker/hocuspocus/package.json new file mode 100644 index 00000000..5515ad0f --- /dev/null +++ b/dev/docker/hocuspocus/package.json @@ -0,0 +1,13 @@ +{ + "name": "opencloud-hocuspocus-dev", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@hocuspocus/server": "^4.0.0", + "@hocuspocus/extension-sqlite": "^4.0.0" + } +} diff --git a/dev/docker/hocuspocus/patches/hocuspocus-server-4.0.0.patch b/dev/docker/hocuspocus/patches/hocuspocus-server-4.0.0.patch new file mode 100644 index 00000000..acb238e1 --- /dev/null +++ b/dev/docker/hocuspocus/patches/hocuspocus-server-4.0.0.patch @@ -0,0 +1,234 @@ +diff -urN package/dist/hocuspocus-server.esm.js server-new/package/dist/hocuspocus-server.esm.js +--- package/dist/hocuspocus-server.esm.js 1985-10-26 09:15:00.000000000 +0100 ++++ server-new/package/dist/hocuspocus-server.esm.js 2026-05-18 13:57:00.808448548 +0200 +@@ -193,9 +193,19 @@ + else if (connection) connection.send(message.toUint8Array()); + } + break; +- case MessageType.Awareness: +- applyAwarenessUpdate(document.awareness, message.readVarUint8Array(), connection ?? null); ++ case MessageType.Awareness: { ++ let update = message.readVarUint8Array(); ++ const origin = connection ? { ++ source: "connection", ++ connection ++ } : this.defaultTransactionOrigin ?? { source: "local" }; ++ const scratch = new Awareness(new Y.Doc()); ++ applyAwarenessUpdate(scratch, update, null); ++ await document.callbacks.beforeHandleAwareness(document, scratch.getStates(), origin); ++ update = encodeAwarenessUpdate(scratch, [...scratch.getStates().keys()]); ++ applyAwarenessUpdate(document.awareness, update, origin); + break; ++ } + case MessageType.QueryAwareness: + this.applyQueryAwarenessMessage(document, connection, reply); + break; +@@ -455,7 +465,8 @@ + super(yDocOptions); + this.callbacks = { + onUpdate: (document, origin, update) => {}, +- beforeBroadcastStateless: (document, stateless) => {} ++ beforeBroadcastStateless: (document, stateless) => {}, ++ beforeHandleAwareness: (document, states, transactionOrigin) => Promise.resolve() + }; + this.connections = /* @__PURE__ */ new Map(); + this.directConnectionsCount = 0; +@@ -499,6 +510,19 @@ + return this; + } + /** ++ * Set a callback that will be triggered before an inbound awareness update ++ * is applied to this document's awareness state. The callback receives the ++ * document, the decoded per-client states as a mutable `Map`, and the ++ * `TransactionOrigin` that will be forwarded to `applyAwarenessUpdate`. ++ * Use `isTransactionOrigin(origin)` to discriminate sources. Mutate the ++ * map in place (set/delete/field changes) to rewrite the update, or throw ++ * to reject it entirely. ++ */ ++ beforeHandleAwareness(callback) { ++ this.callbacks.beforeHandleAwareness = callback; ++ return this; ++ } ++ /** + * Register a connection and a set of clients on this document keyed by the + * underlying websocket connection + */ +@@ -558,15 +582,19 @@ + * Apply the given awareness update + */ + applyAwarenessUpdate(connection, update) { +- applyAwarenessUpdate(this.awareness, update, connection); ++ applyAwarenessUpdate(this.awareness, update, { ++ source: "connection", ++ connection ++ }); + return this; + } + /** + * Handle an awareness update and sync changes to clients + * @private + */ +- handleAwarenessUpdate({ added, updated, removed }, originConnection) { ++ handleAwarenessUpdate({ added, updated, removed }, origin) { + const changedClients = added.concat(updated, removed); ++ const originConnection = isTransactionOrigin(origin) && origin.source === "connection" ? origin.connection : null; + if (originConnection !== null) { + const entry = this.connections.get(originConnection); + if (entry) { +@@ -1003,6 +1031,7 @@ + onConnect: () => new Promise((r) => r(null)), + connected: () => new Promise((r) => r(null)), + beforeHandleMessage: () => new Promise((r) => r(null)), ++ beforeHandleAwareness: () => new Promise((r) => r()), + beforeSync: () => new Promise((r) => r(null)), + beforeBroadcastStateless: () => new Promise((r) => r(null)), + onStateless: () => new Promise((r) => r(null)), +@@ -1048,6 +1077,7 @@ + onLoadDocument: this.configuration.onLoadDocument, + afterLoadDocument: this.configuration.afterLoadDocument, + beforeHandleMessage: this.configuration.beforeHandleMessage, ++ beforeHandleAwareness: this.configuration.beforeHandleAwareness, + beforeBroadcastStateless: this.configuration.beforeBroadcastStateless, + beforeSync: this.configuration.beforeSync, + onStateless: this.configuration.onStateless, +@@ -1236,6 +1266,24 @@ + }; + this.hooks("beforeBroadcastStateless", hookPayload); + }); ++ document.beforeHandleAwareness((document, states, transactionOrigin) => { ++ const connection = isTransactionOrigin(transactionOrigin) && transactionOrigin.source === "connection" ? transactionOrigin.connection : void 0; ++ const request = connection?.request; ++ return this.hooks("beforeHandleAwareness", { ++ awareness: document.awareness, ++ clientsCount: document.getConnectionsCount(), ++ context: connection?.context, ++ document, ++ documentName: document.name, ++ instance: this, ++ requestHeaders: request?.headers ?? new Headers(), ++ requestParameters: request ? getParameters(request) : new URLSearchParams(), ++ socketId: connection?.socketId ?? "", ++ transactionOrigin, ++ connection, ++ states ++ }); ++ }); + document.awareness.on("update", (update, origin) => { + this.hooks("onAwarenessUpdate", { + document, +diff -urN package/dist/index.d.ts server-new/package/dist/index.d.ts +--- package/dist/index.d.ts 1985-10-26 09:15:00.000000000 +0100 ++++ server-new/package/dist/index.d.ts 2026-05-18 13:57:00.810243726 +0200 +@@ -13,6 +13,7 @@ + callbacks: { + onUpdate: (document: Document, origin: unknown, update: Uint8Array) => void; + beforeBroadcastStateless: (document: Document, stateless: string) => void; ++ beforeHandleAwareness: (document: Document, states: Map>, transactionOrigin: unknown) => Promise; + }; + connections: Map; +@@ -44,6 +45,16 @@ + */ + beforeBroadcastStateless(callback: (document: Document, stateless: string) => void): Document; + /** ++ * Set a callback that will be triggered before an inbound awareness update ++ * is applied to this document's awareness state. The callback receives the ++ * document, the decoded per-client states as a mutable `Map`, and the ++ * `TransactionOrigin` that will be forwarded to `applyAwarenessUpdate`. ++ * Use `isTransactionOrigin(origin)` to discriminate sources. Mutate the ++ * map in place (set/delete/field changes) to rewrite the update, or throw ++ * to reject it entirely. ++ */ ++ beforeHandleAwareness(callback: (document: Document, states: Map>, transactionOrigin: unknown) => Promise): Document; ++ /** + * Register a connection and a set of clients on this document keyed by the + * underlying websocket connection + */ +@@ -352,6 +363,18 @@ + onLoadDocument?(data: onLoadDocumentPayload): Promise; + afterLoadDocument?(data: afterLoadDocumentPayload): Promise; + beforeHandleMessage?(data: beforeHandleMessagePayload): Promise; ++ /** ++ * Fired before an inbound awareness update is applied to the document's ++ * awareness state. The hook receives the decoded per-client `states` as a ++ * mutable `Map` keyed by Yjs clientId. Mutate the map and the contained ++ * state objects in place to rewrite fields, drop peers (`states.delete`), ++ * or add synthetic ones (`states.set`); mutations are reflected in the ++ * broadcast. Throw to reject the update without applying anything. ++ * ++ * Multiple extensions chain naturally: each extension sees the map as ++ * mutated by previous extensions and can mutate it further. ++ */ ++ beforeHandleAwareness?(data: beforeHandleAwarenessPayload): Promise; + beforeSync?(data: beforeSyncPayload): Promise; + beforeBroadcastStateless?(data: beforeBroadcastStatelessPayload): Promise; + onStateless?(payload: onStatelessPayload): Promise; +@@ -365,7 +388,7 @@ + afterUnloadDocument?(data: afterUnloadDocumentPayload): Promise; + onDestroy?(data: onDestroyPayload): Promise; + } +-type HookName = "onConfigure" | "onListen" | "onUpgrade" | "onConnect" | "connected" | "onAuthenticate" | "onTokenSync" | "onCreateDocument" | "onLoadDocument" | "afterLoadDocument" | "beforeHandleMessage" | "beforeBroadcastStateless" | "beforeSync" | "onStateless" | "onChange" | "onStoreDocument" | "afterStoreDocument" | "onAwarenessUpdate" | "onRequest" | "onDisconnect" | "beforeUnloadDocument" | "afterUnloadDocument" | "onDestroy"; ++type HookName = "onConfigure" | "onListen" | "onUpgrade" | "onConnect" | "connected" | "onAuthenticate" | "onTokenSync" | "onCreateDocument" | "onLoadDocument" | "afterLoadDocument" | "beforeHandleMessage" | "beforeHandleAwareness" | "beforeBroadcastStateless" | "beforeSync" | "onStateless" | "onChange" | "onStoreDocument" | "afterStoreDocument" | "onAwarenessUpdate" | "onRequest" | "onDisconnect" | "beforeUnloadDocument" | "afterUnloadDocument" | "onDestroy"; + type HookPayloadByName = { + onConfigure: onConfigurePayload; + onListen: onListenPayload; +@@ -378,6 +401,7 @@ + onLoadDocument: onLoadDocumentPayload; + afterLoadDocument: afterLoadDocumentPayload; + beforeHandleMessage: beforeHandleMessagePayload; ++ beforeHandleAwareness: beforeHandleAwarenessPayload; + beforeBroadcastStateless: beforeBroadcastStatelessPayload; + beforeSync: beforeSyncPayload; + onStateless: onStatelessPayload; +@@ -540,6 +564,43 @@ + socketId: string; + connection: Connection; + } ++interface beforeHandleAwarenessPayload { ++ awareness: Awareness; ++ clientsCount: number; ++ /** ++ * Connection context populated by `onAuthenticate`. `undefined` when the ++ * update did not originate from a client connection (e.g. server-internal ++ * writes via `DirectConnection`). ++ */ ++ context: Context | undefined; ++ document: Document; ++ documentName: string; ++ instance: Hocuspocus; ++ requestHeaders: Headers; ++ requestParameters: URLSearchParams; ++ /** ++ * Per-client awareness states decoded from the inbound update, keyed by ++ * Yjs clientId. Mutate this map in place to rewrite the update: change ++ * fields on a state object, `states.delete(clientId)` to drop a peer, or ++ * `states.set(clientId, ...)` to add or replace one. The encoded update ++ * sent to peers reflects whatever the map looks like after every hook in ++ * the chain has run. ++ */ ++ states: Map>; ++ socketId: string; ++ /** ++ * The `TransactionOrigin` that will be passed to `applyAwarenessUpdate`. ++ * Use `isTransactionOrigin(origin)` to discriminate sources. Matches the ++ * `transactionOrigin` shape of `onAwarenessUpdatePayload`. ++ */ ++ transactionOrigin: unknown; ++ /** ++ * Convenience shortcut: `origin.connection` when `transactionOrigin` is a ++ * `ConnectionTransactionOrigin`, otherwise `undefined`. Matches the ++ * `connection?` shape of `onAwarenessUpdatePayload`. ++ */ ++ connection?: Connection; ++} + interface beforeSyncPayload { + clientsCount: number; + context: Context; +@@ -801,4 +862,4 @@ + executeNow: (id: string) => any; + }; + //#endregion +-export { AwarenessUpdate, Configuration, Connection, ConnectionConfiguration, ConnectionTransactionOrigin, DirectConnection, Document, Extension, Hocuspocus, HookName, HookPayloadByName, IncomingMessage, LocalTransactionOrigin, MessageReceiver, MessageType, OutgoingMessage, RedisTransactionOrigin, Server, ServerConfiguration, StatesArray, TransactionOrigin, WebSocketLike, afterLoadDocumentPayload, afterStoreDocumentPayload, afterUnloadDocumentPayload, beforeBroadcastStatelessPayload, beforeHandleMessagePayload, beforeSyncPayload, beforeUnloadDocumentPayload, connectedPayload, defaultConfiguration, defaultServerConfiguration, fetchPayload, isTransactionOrigin, onAuthenticatePayload, onAwarenessUpdatePayload, onChangePayload, onConfigurePayload, onConnectPayload, onCreateDocumentPayload, onDestroyPayload, onDisconnectPayload, onListenPayload, onLoadDocumentPayload, onRequestPayload, onStatelessPayload, onStoreDocumentPayload, onTokenSyncPayload, onUpgradePayload, shouldSkipStoreHooks, storePayload, useDebounce }; +\ No newline at end of file ++export { AwarenessUpdate, Configuration, Connection, ConnectionConfiguration, ConnectionTransactionOrigin, DirectConnection, Document, Extension, Hocuspocus, HookName, HookPayloadByName, IncomingMessage, LocalTransactionOrigin, MessageReceiver, MessageType, OutgoingMessage, RedisTransactionOrigin, Server, ServerConfiguration, StatesArray, TransactionOrigin, WebSocketLike, afterLoadDocumentPayload, afterStoreDocumentPayload, afterUnloadDocumentPayload, beforeBroadcastStatelessPayload, beforeHandleAwarenessPayload, beforeHandleMessagePayload, beforeSyncPayload, beforeUnloadDocumentPayload, connectedPayload, defaultConfiguration, defaultServerConfiguration, fetchPayload, isTransactionOrigin, onAuthenticatePayload, onAwarenessUpdatePayload, onChangePayload, onConfigurePayload, onConnectPayload, onCreateDocumentPayload, onDestroyPayload, onDisconnectPayload, onListenPayload, onLoadDocumentPayload, onRequestPayload, onStatelessPayload, onStoreDocumentPayload, onTokenSyncPayload, onUpgradePayload, shouldSkipStoreHooks, storePayload, useDebounce }; +\ No newline at end of file diff --git a/dev/docker/hocuspocus/server.js b/dev/docker/hocuspocus/server.js new file mode 100644 index 00000000..91ffa741 --- /dev/null +++ b/dev/docker/hocuspocus/server.js @@ -0,0 +1,247 @@ +import { Server } from '@hocuspocus/server' +import { SQLite } from '@hocuspocus/extension-sqlite' + +const port = parseInt(process.env.PORT ?? '1234', 10) +const dbPath = process.env.DB_PATH ?? '/var/lib/hocuspocus/state.db' +const opencloudUrl = (process.env.OPENCLOUD_URL ?? 'https://host.docker.internal:9200').replace(/\/$/, '') +const devFakeToken = process.env.DEV_FAKE_TOKEN ?? '' + +// Per-document first-seen app version. Acts as the authoritative gate for +// "everybody in this room must run the same client version". First connect +// for a documentName sets the baseline; subsequent connects with a different +// appVersion are rejected at authenticate-time. In-memory only; on restart +// the next connecter becomes the new baseline (acceptable for a stateless +// sidecar). Empty appVersion is tolerated for legacy/test clients. +const appVersionByDocument = new Map() + +function deterministicColor(seed) { + let hash = 0 + for (let i = 0; i < seed.length; i++) hash = seed.charCodeAt(i) + ((hash << 5) - hash) + return `hsl(${Math.abs(hash) % 360}, 70%, 50%)` +} + +async function validateTokenAgainstOpenCloud(token) { + const res = await fetch(`${opencloudUrl}/graph/v1.0/me`, { + headers: { Authorization: `Bearer ${token}` } + }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new Error(`graph /me returned ${res.status}: ${detail.slice(0, 200)}`) + } + return res.json() +} + +// Heuristic: a libregraph permission action implies write access when its +// trailing verb is create/update/delete/allTasks on driveItem properties. +const WRITE_ACTION = /\/(update|create|delete|allTasks)$/ + +// Splits OC's canonical composite id `$!` into +// the (driveId, itemId) pair the Graph endpoint expects: driveID = +// `$`, itemID = the FULL composite. +function parseDocumentId(documentName) { + const sep = documentName.indexOf('!') + if (sep <= 0 || sep === documentName.length - 1) { + throw new Error(`malformed documentName="${documentName}"`) + } + return { driveId: documentName.slice(0, sep), itemId: documentName } +} + +// Probes OC's Graph API for the user's effective access AND the file's +// current native etag. Returns `{ canWrite, etag }` on success; `null` when +// OC denies access entirely (401/403/404). +// +// Two parallel calls: +// - Graph /permissions for the effective action set (top-level +// @libre.graph.permissions.actions.allowedValues, which is the merged +// PermissionSet that backs WebDAV's oc:permissions). +// - WebDAV HEAD for the native eTag (Graph's /items endpoint is share-jail- +// only and 400s on personal drives; WebDAV works uniformly). +async function probeFileAccess(token, documentName) { + const { driveId, itemId } = parseDocumentId(documentName) + const permsUrl = + `${opencloudUrl}/graph/v1beta1/drives/${encodeURIComponent(driveId)}` + + `/items/${encodeURIComponent(itemId)}/permissions` + const davUrl = `${opencloudUrl}/remote.php/dav/spaces/${encodeURIComponent(itemId)}` + const headers = { Authorization: `Bearer ${token}` } + + const [permsRes, headRes] = await Promise.all([ + fetch(permsUrl, { headers }), + fetch(davUrl, { method: 'HEAD', headers }) + ]) + + if ([permsRes.status, headRes.status].some((s) => s === 401 || s === 403 || s === 404)) { + return null + } + if (!permsRes.ok) { + const detail = await permsRes.text().catch(() => '') + throw new Error(`graph permissions returned ${permsRes.status}: ${detail.slice(0, 200)}`) + } + if (!headRes.ok) { + const detail = await headRes.text().catch(() => '') + throw new Error(`webdav HEAD returned ${headRes.status}: ${detail.slice(0, 200)}`) + } + + const permsBody = await permsRes.json() + const allowed = Array.isArray(permsBody?.['@libre.graph.permissions.actions.allowedValues']) + ? permsBody['@libre.graph.permissions.actions.allowedValues'] + : [] + const canWrite = allowed.some((a) => WRITE_ACTION.test(a)) + + // WebDAV emits the strong validator under `ETag` (and sometimes `OC-ETag` + // for OC-specific extensions). Strip surrounding quotes for consistency + // with the etag the wrapper sees from `props.resource.etag`. + const rawEtag = headRes.headers.get('etag') || headRes.headers.get('oc-etag') || '' + const etag = rawEtag.replace(/^"(.*)"$/, '$1') + + return { canWrite, etag } +} + +const META_KEY = '_oc_meta' + +const server = new Server({ + port, + address: '0.0.0.0', + extensions: [new SQLite({ database: dbPath })], + + async onAuthenticate({ token, documentName, requestParameters }) { + if (!token) { + throw new Error('missing token') + } + + // App-version gate. First connect to a documentName sets the baseline, + // subsequent connects with a different appVersion are rejected so old + // clients can't poison the room. Empty client appVersion is permitted + // (back-compat for the integration test harness using a raw provider). + const clientAppVersion = requestParameters.get('appVersion') ?? '' + const baselineAppVersion = appVersionByDocument.get(documentName) + if (clientAppVersion && baselineAppVersion && clientAppVersion !== baselineAppVersion) { + throw new Error( + `app version mismatch for document="${documentName}": ` + + `client=${clientAppVersion} room=${baselineAppVersion}, please reload` + ) + } + if (clientAppVersion && !baselineAppVersion) { + appVersionByDocument.set(documentName, clientAppVersion) + } + + // Dev shortcut for integration tests: any token matching DEV_FAKE_TOKEN + // returns a synthetic identity. ACL check is skipped (tests use random + // documentNames that don't exist in OC). Disabled when DEV_FAKE_TOKEN is + // unset. Tests can pass `devEtag` to drive the stale-state detection + // path without touching real OC. + if (devFakeToken && token === devFakeToken) { + const id = 'dev-fake-user' + const nativeEtag = requestParameters.get('devEtag') ?? '' + console.log( + `[onAuthenticate] dev-fake document="${documentName}" nativeEtag="${nativeEtag}"` + ) + return { + nativeEtag, + user: { + id, + displayName: 'Dev Fake User', + color: deterministicColor(id) + } + } + } + + const me = await validateTokenAgainstOpenCloud(token) + const id = me.id ?? me.userPrincipalName ?? 'unknown' + + // ACL + native etag probe via Graph: enforces access AND captures the + // current native etag so onLoadDocument can detect a stale persisted + // Y.Doc snapshot (Hocuspocus persistence vs external file write). + const access = await probeFileAccess(token, documentName) + if (access === null) { + throw new Error(`access denied for document="${documentName}"`) + } + const readOnly = !access.canWrite + + console.log( + `[onAuthenticate] document="${documentName}" user="${me.displayName ?? id}" ` + + `id="${id}" readOnly=${readOnly} nativeEtag="${access.etag}"` + ) + return { + readOnly, + nativeEtag: access.etag, + clientAppVersion, + user: { + id, + displayName: me.displayName ?? me.userPrincipalName ?? id, + color: deterministicColor(id) + } + } + }, + + // Stale-state detection: SQLite extension has loaded the persisted Y.Doc + // by the time this runs (extension hooks run before the configuration + // hook). onLoadDocument only fires when a doc is loaded into memory for + // the first time after eviction — i.e. nobody else is in the room — so + // we can safely flag-and-rehydrate without racing live peers. + // + // Two staleness dimensions, both produce the same `_oc_meta.isStale = true` + // signal for the wrapper to act on: + // 1. etag drift: external file write happened between sessions + // (persisted etag != caller's native etag) + // 2. app-version drift: persisted Y.Doc was written by an older client + // version whose adapter layout the new client can no longer read + async onLoadDocument({ document, context }) { + const meta = document.getMap(META_KEY) + const persistedEtag = meta.get('etag') + const nativeEtag = context?.nativeEtag + const persistedAppVersion = meta.get('appVersion') + const clientAppVersion = context?.clientAppVersion + + const etagDrift = !!persistedEtag && !!nativeEtag && persistedEtag !== nativeEtag + const versionDrift = + !!persistedAppVersion && !!clientAppVersion && persistedAppVersion !== clientAppVersion + + if (!etagDrift && !versionDrift) return + + const reasons = [] + if (etagDrift) reasons.push(`etag(${persistedEtag}→${nativeEtag})`) + if (versionDrift) reasons.push(`appVersion(${persistedAppVersion}→${clientAppVersion})`) + console.log( + `[onLoadDocument] stale state document="${document.name}" ` + + `${reasons.join(' ')} → marked for rehydrate` + ) + document.transact(() => { + meta.set('isStale', true) + if (nativeEtag) meta.set('nativeEtag', nativeEtag) + }) + }, + + async onConnect({ documentName, requestHeaders }) { + console.log(`[onConnect] document="${documentName}" origin=${requestHeaders.origin ?? '-'}`) + }, + + async onDisconnect({ documentName, clientsCount }) { + console.log(`[onDisconnect] document="${documentName}" remaining=${clientsCount}`) + if (clientsCount === 0) { + // Forget the version baseline once the room empties out so a new + // deploy can start fresh without manual restart. + appVersionByDocument.delete(documentName) + } + }, + + // Anti-spoof identity stamp: before each inbound awareness update is + // applied, overwrite the `user` field on every state in the update with + // the authenticated identity from the connection's context. Provided by + // the patched @hocuspocus/server (see patches/). + async beforeHandleAwareness({ states, connection }) { + const user = connection?.context?.user + if (!user) return + const canonical = { + id: user.id, + name: user.displayName, + color: user.color + } + for (const state of states.values()) { + state.user = canonical + } + } +}) + +server.listen().then(() => { + console.log(`hocuspocus v4 listening on :${port}, db=${dbPath}, oc=${opencloudUrl}`) +}) diff --git a/dev/docker/opencloud.apps.yaml b/dev/docker/opencloud.apps.yaml index f5be72d4..00763413 100644 --- a/dev/docker/opencloud.apps.yaml +++ b/dev/docker/opencloud.apps.yaml @@ -1,6 +1,12 @@ importer: config: companionUrl: 'https://host.docker.internal:9200/companion' +codemirror: + config: + realtimeUrl: 'wss://host.docker.internal:9200/realtime' +tiptap: + config: + realtimeUrl: 'wss://host.docker.internal:9200/realtime' external-sites: config: defaultDashboard: 'Main Dashboard' diff --git a/docker-compose.yml b/docker-compose.yml index 815dead5..b00aeff9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,8 @@ services: - ./packages/web-app-calculator/dist:/web/apps/calculator - ./packages/web-app-arcade/dist:/web/apps/arcade - ./packages/web-app-cast/dist:/web/apps/cast + - ./packages/web-app-codemirror/dist:/web/apps/codemirror + - ./packages/web-app-tiptap/dist:/web/apps/tiptap - ./packages/web-app-draw-io/dist:/web/apps/draw-io - ./packages/web-app-external-sites/dist:/web/apps/external-sites - ./packages/web-app-importer/dist:/web/apps/importer @@ -79,6 +81,34 @@ services: traefik.http.routers.companion.entrypoints: opencloud traefik.http.services.companion.loadbalancer.server.port: 3020 + hocuspocus: + build: ./dev/docker/hocuspocus + extra_hosts: + - host.docker.internal:${DOCKER_HOST:-host-gateway} + environment: + PORT: '1234' + DB_PATH: /var/lib/hocuspocus/state.db + OPENCLOUD_URL: https://host.docker.internal:9200 + # Dev only: self-signed cert from OC's Traefik + NODE_TLS_REJECT_UNAUTHORIZED: '0' + # Dev only: allows the integration tests to bypass real OIDC tokens. + # Remove for prod. + DEV_FAKE_TOKEN: dev-integration-token + volumes: + - hocuspocus-data:/var/lib/hocuspocus + # Dev override: edit server.js without rebuild; remove for prod + - ./dev/docker/hocuspocus/server.js:/app/server.js:ro + labels: + traefik.enable: true + traefik.http.routers.hocuspocus.tls: true + traefik.http.routers.hocuspocus.rule: Host(`host.docker.internal`) && PathPrefix(`/realtime`) + traefik.http.routers.hocuspocus.entrypoints: opencloud + traefik.http.services.hocuspocus.loadbalancer.server.port: 1234 + traefik.http.middlewares.hocuspocus-strip.stripprefix.prefixes: /realtime + traefik.http.routers.hocuspocus.middlewares: hocuspocus-strip,cors + depends_on: + - traefik + tika-service: image: dadarek/wait-for-dependencies:0.2@sha256:b38e28108dee0aad90984e176adcc014dbf31b528602640f6c9a280cdc7ce2f0 depends_on: @@ -139,3 +169,4 @@ services: volumes: opencloud-config: uppy_companion_datadir: + hocuspocus-data: diff --git a/packages/web-app-codemirror/extension.d.ts b/packages/web-app-codemirror/extension.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/web-app-codemirror/extension.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/web-app-codemirror/l10n/translations.json b/packages/web-app-codemirror/l10n/translations.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/web-app-codemirror/l10n/translations.json @@ -0,0 +1 @@ +{} diff --git a/packages/web-app-codemirror/package.json b/packages/web-app-codemirror/package.json new file mode 100644 index 00000000..e2390acc --- /dev/null +++ b/packages/web-app-codemirror/package.json @@ -0,0 +1,36 @@ +{ + "name": "codemirror", + "version": "0.1.0", + "private": true, + "description": "OpenCloud Web collaborative CodeMirror editor", + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "build": "pnpm vite build", + "build:w": "pnpm vite build --watch --mode development", + "check:types": "vue-tsc --noEmit", + "test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest" + }, + "dependencies": { + "@codemirror/lang-markdown": "^6.3.0", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.34.0", + "@hocuspocus/provider": "^4.0.0", + "semver": "^7.8.0", + "y-codemirror.next": "^0.3.5", + "y-protocols": "^1.0.7", + "yjs": "^13.6.0" + }, + "devDependencies": { + "@opencloud-eu/web-client": "^7.0.0", + "@opencloud-eu/web-pkg": "^7.0.0", + "@types/semver": "^7.7.0", + "@types/ws": "^8.5.0", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/test-utils": "^2.4.0", + "happy-dom": "^20.0.0", + "vue": "^3.4.21", + "vue3-gettext": "^2.4.0", + "ws": "^8.18.0" + } +} diff --git a/packages/web-app-codemirror/src/App.vue b/packages/web-app-codemirror/src/App.vue new file mode 100644 index 00000000..f2d96fe5 --- /dev/null +++ b/packages/web-app-codemirror/src/App.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/web-app-codemirror/src/CodeMirrorEditor.vue b/packages/web-app-codemirror/src/CodeMirrorEditor.vue new file mode 100644 index 00000000..29eb2956 --- /dev/null +++ b/packages/web-app-codemirror/src/CodeMirrorEditor.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/packages/web-app-codemirror/src/CollaborativeWrapper.vue b/packages/web-app-codemirror/src/CollaborativeWrapper.vue new file mode 100644 index 00000000..725b5a73 --- /dev/null +++ b/packages/web-app-codemirror/src/CollaborativeWrapper.vue @@ -0,0 +1,460 @@ + + + diff --git a/packages/web-app-codemirror/src/adapters/codemirrorMarkdown.ts b/packages/web-app-codemirror/src/adapters/codemirrorMarkdown.ts new file mode 100644 index 00000000..b173cddb --- /dev/null +++ b/packages/web-app-codemirror/src/adapters/codemirrorMarkdown.ts @@ -0,0 +1,31 @@ +import type * as Y from 'yjs' +import type { CollaborativeAdapter } from '../types' + +const SHARED_TEXT_KEY = 'content' + +export const codemirrorMarkdownAdapter: CollaborativeAdapter = { + hydrate(ydoc: Y.Doc, content: string) { + const yText = ydoc.getText(SHARED_TEXT_KEY) + if (yText.length > 0) return + if (!content) return + ydoc.transact(() => { + yText.insert(0, content) + }, 'hydrate') + }, + + serialize(ydoc: Y.Doc): string { + return ydoc.getText(SHARED_TEXT_KEY).toString() + }, + + hasContent(ydoc: Y.Doc): boolean { + return ydoc.getText(SHARED_TEXT_KEY).length > 0 + }, + + reset(ydoc: Y.Doc) { + const yText = ydoc.getText(SHARED_TEXT_KEY) + if (yText.length === 0) return + ydoc.transact(() => { + yText.delete(0, yText.length) + }, 'reset') + } +} diff --git a/packages/web-app-codemirror/src/index.ts b/packages/web-app-codemirror/src/index.ts new file mode 100644 index 00000000..f6599862 --- /dev/null +++ b/packages/web-app-codemirror/src/index.ts @@ -0,0 +1,44 @@ +import { AppWrapperRoute, defineWebApplication } from '@opencloud-eu/web-pkg' +import { useGettext } from 'vue3-gettext' +import App from './App.vue' +import translations from '../l10n/translations.json' + +const applicationId = 'codemirror' + +export default defineWebApplication({ + setup() { + const { $gettext } = useGettext() + + const routes = [ + { + name: applicationId, + path: '/:driveAliasAndItem(.*)?', + component: AppWrapperRoute(App, { applicationId }), + meta: { + authContext: 'hybrid', + title: $gettext('CodeMirror'), + patchCleanPath: true + } + } + ] + + const appInfo = { + name: $gettext('CodeMirror'), + id: applicationId, + icon: 'file-text', + defaultExtension: 'md', + extensions: [ + { + extension: 'md', + routeName: applicationId + } + ] + } + + return { + appInfo, + routes, + translations + } + } +}) diff --git a/packages/web-app-codemirror/src/types.ts b/packages/web-app-codemirror/src/types.ts new file mode 100644 index 00000000..0d3fdb33 --- /dev/null +++ b/packages/web-app-codemirror/src/types.ts @@ -0,0 +1,43 @@ +import type * as Y from 'yjs' + +/** + * App-specific adapter between the native file format and the shared Y.Doc. + * The wrapper itself stays generic: it handles realtime sync, the etag loop, + * and lifecycle. Adapters describe how to move bytes in and out of the doc. + * + * Implementations must be deterministic — given the same Y.Doc state, + * `serialize` must always return the same content. + */ +export interface CollaborativeAdapter { + /** + * Populate an empty Y.Doc from the native file content. Called once per + * document by the elected hydrating client; other clients receive the + * resulting Y.Doc state through the realtime sync. + * + * Must be a no-op if the Y.Doc already has app data. + */ + hydrate(ydoc: Y.Doc, content: string): void | Promise + + /** + * Render the current Y.Doc state to the native file format for WebDAV PUT. + */ + serialize(ydoc: Y.Doc): string | Promise + + /** + * Returns true if the adapter has populated the doc with app data. + * Used to detect "doc is empty, needs hydration" without the wrapper + * knowing the adapter's shared-type layout. + */ + hasContent(ydoc: Y.Doc): boolean + + /** + * Wipe the adapter's shared content so `hasContent` returns false again. + * The wrapper calls this when the sidecar signals a stale persisted Y.Doc + * (external file write happened between sessions); the elected client + * then re-hydrates from the fresh native content. + * + * Optional — adapters that omit this won't recover from a stale-state + * signal in-place; the wrapper falls back to forcing a full reload. + */ + reset?(ydoc: Y.Doc): void +} diff --git a/packages/web-app-codemirror/tests/e2e/codemirror.spec.ts b/packages/web-app-codemirror/tests/e2e/codemirror.spec.ts new file mode 100644 index 00000000..17644500 --- /dev/null +++ b/packages/web-app-codemirror/tests/e2e/codemirror.spec.ts @@ -0,0 +1,185 @@ +import { test, expect, type Page } from '@playwright/test' +import { FilesAppBar } from '../../../../support/pages/filesAppBarActions' +import { FilesPage } from '../../../../support/pages/filesPage' +import { loginAsUser, logout } from '../../../../support/helpers/authHelper' +import { createRandomUser } from '../../../../support/helpers/api/apiHelper' +import { + createProjectSpace, + inviteUserToSpace, + uploadFileToSpace +} from '../../../../support/helpers/api/spaceHelper' + +let userPage: Page + +test.beforeEach(async ({ browser }) => { + const user = await createRandomUser() + userPage = (await loginAsUser(browser, user.username, user.password)).page +}) + +test.afterEach(async () => { + await logout(userPage) +}) + +// Locators specific to our CodeMirror app. +const cm = { + status: (p: Page) => p.locator('.oc-text-meta', { hasText: '—' }).first(), + content: (p: Page) => p.locator('.cm-content'), + awaitConnected: async (p: Page) => { + await expect(p.locator('.oc-text-meta', { hasText: 'connected' })).toBeVisible({ + timeout: 10_000 + }) + } +} + +async function openMdFile(page: Page, file: string) { + // Right-click the file -> "Open with..." -> "CodeMirror". + const filesPage = new FilesPage(page) + await filesPage.getResourceNameSelector(file).click({ button: 'right' }) + // Use the same locator the shared FilesPage helper uses to avoid strict-mode + // ambiguity with the role-labelled wrapper spans. + await page.locator('xpath=//*[contains(@class, "oc-drop")]//span[text()="Open with..."]').hover() + await page.getByRole('menuitem', { name: 'CodeMirror' }).click() +} + +test('open .md in CodeMirror, see initial content, server connects', async () => { + const bar = new FilesAppBar(userPage) + await bar.uploadFile('note-alpha.md') + + await openMdFile(userPage, 'note-alpha.md') + await expect(userPage).toHaveURL(/codemirror/) + await cm.awaitConnected(userPage) + await expect(cm.content(userPage)).toContainText('ALPHA-1') + await expect(cm.content(userPage)).not.toContainText('BETA-1') +}) + +test('switching files via direct navigation rebuilds the realtime provider with no cross-file leak', async () => { + // Upload two distinct .md files. + const bar = new FilesAppBar(userPage) + await bar.uploadFile('note-alpha.md') + await bar.uploadFile('note-beta.md') + + // Discover the app URL for ALPHA via the file browser. + await openMdFile(userPage, 'note-alpha.md') + await cm.awaitConnected(userPage) + await expect(cm.content(userPage)).toContainText('ALPHA-1') + const alphaUrl = userPage.url() + + // And likewise for BETA — go back, then open it via the file browser too. + await userPage.goBack() + await openMdFile(userPage, 'note-beta.md') + await cm.awaitConnected(userPage) + await expect(cm.content(userPage)).toContainText('BETA-1') + const betaUrl = userPage.url() + + expect(alphaUrl).not.toBe(betaUrl) + + // Now the actual lifecycle assertion: jump directly between the two URLs + // without going through the folder list. The same Vue component instance + // handles the route change — watchEffect must tear down the old provider/ + // Y.Doc and build a fresh one for the new file each time. + await userPage.goto(alphaUrl) + await cm.awaitConnected(userPage) + await expect(cm.content(userPage)).toContainText('ALPHA-1') + await expect(cm.content(userPage)).not.toContainText('BETA-1') + + await userPage.goto(betaUrl) + await cm.awaitConnected(userPage) + await expect(cm.content(userPage)).toContainText('BETA-1') + await expect(cm.content(userPage)).not.toContainText('ALPHA-1') + + await userPage.goto(alphaUrl) + await cm.awaitConnected(userPage) + await expect(cm.content(userPage)).toContainText('ALPHA-1') + await expect(cm.content(userPage)).not.toContainText('BETA-1') +}) + +// --------------------------------------------------------------------------- +// Multi-user collab: two distinct OC users joined to the same Project Space, +// editing the same .md file. Verifies that User A's caret position and the +// server-stamped identity reach User B through the awareness channel. +// --------------------------------------------------------------------------- +test.describe('multi-user collaboration via shared Project Space', () => { + // beforeEach in the outer scope runs for every test — opt out of it inside + // this describe so we can manage two browser contexts manually. + test.beforeEach(async () => { + /* override outer beforeEach: no auto user/login */ + }) + test.afterEach(async () => { + /* override outer afterEach */ + }) + + test("user A's caret is rendered in user B's editor with the server-stamped name", async ({ + browser + }) => { + // 1) Provision: project space + two users invited as Space Editors, + // one .md file uploaded into the space root. + const stamp = Date.now() + const space = await createProjectSpace(`collab-${stamp}`) + const alice = await createRandomUser() + const bob = await createRandomUser() + await inviteUserToSpace(space.id, alice.id) + await inviteUserToSpace(space.id, bob.id) + const { fileId } = await uploadFileToSpace( + space, + 'shared-note.md', + '# Shared Note\n\nLINE-A\nLINE-B\nLINE-C\nLINE-D\nLINE-E\n' + ) + + // 2) Both users log in via OIDC in their own browser contexts. + const aliceSession = await loginAsUser(browser, alice.username, alice.password) + const bobSession = await loginAsUser(browser, bob.username, bob.password) + const pageA = aliceSession.page + const pageB = bobSession.page + + try { + // 3) Open the file in CodeMirror via the fileId — AppWrapper resolves + // the drive context automatically, no folder navigation needed. + const fileUrl = `/codemirror/?fileId=${encodeURIComponent(fileId)}` + await Promise.all([pageA.goto(fileUrl), pageB.goto(fileUrl)]) + await Promise.all([cm.awaitConnected(pageA), cm.awaitConnected(pageB)]) + await expect(cm.content(pageA)).toContainText('LINE-C') + await expect(cm.content(pageB)).toContainText('LINE-C') + + // 4) Alice puts her caret on line 4 ("LINE-B") and goes to end-of-line. + await pageA.locator('.cm-line').nth(3).click() + await pageA.keyboard.press('End') + + // 5) Bob's editor must render Alice's remote caret on the same line, + // labelled with her *server-stamped* display name (not whatever + // Bob's awareness would have inferred). + const remoteCaretInB = pageB.locator('.cm-ySelectionCaret').first() + await expect(remoteCaretInB).toBeVisible({ timeout: 10_000 }) + + const remoteLabelInB = pageB.locator('.cm-ySelectionInfo').first() + await expect(remoteLabelInB).toHaveText(alice.username, { timeout: 5_000 }) + + // Position assertion: the caret sits inside the same line index. + const lineFourInB = pageB.locator('.cm-line').nth(3) + await expect(lineFourInB.locator('.cm-ySelectionCaret')).toHaveCount(1, { + timeout: 5_000 + }) + + // 6) Move Alice to line 6 ("LINE-D"); Bob's caret should follow, + // and the previous line must no longer carry it. + await pageA.locator('.cm-line').nth(5).click() + await pageA.keyboard.press('End') + + const lineSixInB = pageB.locator('.cm-line').nth(5) + await expect(lineSixInB.locator('.cm-ySelectionCaret')).toHaveCount(1, { + timeout: 5_000 + }) + await expect(lineFourInB.locator('.cm-ySelectionCaret')).toHaveCount(0, { + timeout: 5_000 + }) + + // 7) And a CRDT typing test for good measure: Alice types a marker, + // Bob sees it. + const marker = `MARK-${stamp}` + await pageA.keyboard.type(marker) + await expect(cm.content(pageB)).toContainText(marker, { timeout: 5_000 }) + } finally { + await logout(pageA) + await logout(pageB) + } + }) +}) diff --git a/packages/web-app-codemirror/tests/e2e/save-back.spec.ts b/packages/web-app-codemirror/tests/e2e/save-back.spec.ts new file mode 100644 index 00000000..b764e5f7 --- /dev/null +++ b/packages/web-app-codemirror/tests/e2e/save-back.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test' +import { loginCached, disposeSession } from '../../../../support/helpers/sessionCache' +import { + uploadFileAsAdmin, + fetchFileAsAdmin +} from '../../../../support/helpers/api/spaceHelper' + +// Save-back verification: type into the collab editor, trigger the host +// AppWrapper's save via Ctrl+S, then re-fetch the file through WebDAV +// (admin token, bypasses the collab layer) and assert the typed marker is +// persisted. Covers the round trip CRDT → update:currentContent emission +// → AppWrapper.save → WebDAV PUT → OC backend. +test.describe('save-back to native file', () => { + test('typing then Ctrl+S persists to OC', async ({ browser }) => { + const stamp = Date.now() + const filename = `save-back-${stamp}.md` + const initial = `initial content ${stamp}\n` + const file = await uploadFileAsAdmin(filename, initial) + + const adminSession = await loginCached(browser, 'admin', 'admin') + const page = adminSession.page + try { + await page.goto(`/codemirror/?fileId=${encodeURIComponent(file.itemId)}`) + await expect(page.locator('.oc-text-meta', { hasText: 'connected' })).toBeVisible({ + timeout: 10_000 + }) + await expect(page.locator('.cm-content')).toContainText(initial.trim()) + + // Click into editor and type a unique marker after the initial text. + const marker = `MARKER-${stamp}` + await page.locator('.cm-content').click() + await page.keyboard.press('End') + await page.keyboard.type(marker) + + // Give the wrapper's debounced serialize → emit a chance to fire so + // AppWrapper sees the dirty content before we trigger its save. + await page.waitForTimeout(500) + + // AppWrapper binds Ctrl+S to its own save() via useKeyboardActions. + await page.keyboard.press('Control+s') + + // Poll OC over WebDAV until the marker lands. AppWrapper doesn't + // expose a "saving done" signal we can latch onto, so we wait on + // the side effect. + await expect + .poll(async () => await fetchFileAsAdmin(file), { timeout: 10_000 }) + .toContain(marker) + + const persisted = await fetchFileAsAdmin(file) + expect(persisted).toContain(initial.trim()) + expect(persisted).toContain(marker) + } finally { + await disposeSession(page) + } + }) +}) diff --git a/packages/web-app-codemirror/tests/e2e/shared-file.spec.ts b/packages/web-app-codemirror/tests/e2e/shared-file.spec.ts new file mode 100644 index 00000000..ecb604d7 --- /dev/null +++ b/packages/web-app-codemirror/tests/e2e/shared-file.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, type Page } from '@playwright/test' +import { loginAsUser, logout } from '../../../../support/helpers/authHelper' +import { loginCached, disposeSession } from '../../../../support/helpers/sessionCache' +import { createRandomUser } from '../../../../support/helpers/api/apiHelper' +import { uploadFileAsAdmin, inviteUserToFile } from '../../../../support/helpers/api/spaceHelper' + +// Owner-vs-recipient collaboration: admin owns a file in their personal +// drive and shares it with a user. Both views of the file MUST end up on +// the same Hocuspocus document so edits cross-pollinate. +// +// The fix lives in CollaborativeWrapper.vue's `documentName` computed: +// prefer `resource.remoteItemId` over `resource.id`, because for a share +// the recipient's local view of the item carries a different id while +// `remoteItemId` points at the owner's canonical item id. +test.describe('owner+recipient collaboration on a shared file', () => { + const cm = { + content: (p: Page) => p.locator('.cm-content'), + awaitConnected: async (p: Page) => { + await expect(p.locator('.oc-text-meta', { hasText: 'connected' })).toBeVisible({ + timeout: 10_000 + }) + } + } + + test('admin edits a file, the recipient sees the edit live (and vice-versa)', async ({ + browser + }) => { + const stamp = Date.now() + const file = await uploadFileAsAdmin( + `cross-share-${stamp}.md`, + `# Shared with mary\n\nLINE-OWNER-${stamp}\n` + ) + + // Recipient: a fresh random user invited as File Editor. + const mary = await createRandomUser() + await inviteUserToFile(file, mary.id) + + // Owner opens the file by its canonical fileId. Admin's session is reused + // from a cached storageState — first call this run pays the UI-login cost, + // every subsequent test that touches admin gets a fresh context for free. + const adminSession = await loginCached(browser, 'admin', 'admin') + const ownerUrl = `/codemirror/?fileId=${encodeURIComponent(file.itemId)}` + await adminSession.page.goto(ownerUrl) + await cm.awaitConnected(adminSession.page) + await expect(cm.content(adminSession.page)).toContainText(`LINE-OWNER-${stamp}`) + + // Recipient is a freshly created user, so we still go through the UI + // login flow for her. State caching would help only if the same random + // user appeared in multiple tests, which is not the case here. + const marySession = await loginAsUser(browser, mary.username, mary.password) + await marySession.page.goto(ownerUrl) + await cm.awaitConnected(marySession.page) + await expect(cm.content(marySession.page)).toContainText(`LINE-OWNER-${stamp}`) + + try { + // Owner types — recipient must see it in real time. + const ownerMark = `OWNER-MARK-${stamp}` + await adminSession.page.locator('.cm-content').click() + await adminSession.page.keyboard.press('End') + await adminSession.page.keyboard.type(ownerMark) + await expect(cm.content(marySession.page)).toContainText(ownerMark, { timeout: 5_000 }) + + // Recipient types — owner must see it too. + const recipientMark = `RECIPIENT-MARK-${stamp}` + await marySession.page.locator('.cm-content').click() + await marySession.page.keyboard.press('End') + await marySession.page.keyboard.type(recipientMark) + await expect(cm.content(adminSession.page)).toContainText(recipientMark, { + timeout: 5_000 + }) + } finally { + // Admin's storageState stays cached for the next test; just close the + // context. Mary still goes through full logout to invalidate her token. + await disposeSession(adminSession.page) + await logout(marySession.page) + } + }) +}) diff --git a/packages/web-app-codemirror/tests/integration/realtime-sync.spec.ts b/packages/web-app-codemirror/tests/integration/realtime-sync.spec.ts new file mode 100644 index 00000000..fdcf7f2f --- /dev/null +++ b/packages/web-app-codemirror/tests/integration/realtime-sync.spec.ts @@ -0,0 +1,303 @@ +// Integration test: two Yjs/Hocuspocus clients sync over the dev realtime route. +// Requires the docker-compose stack to be running (OC + Traefik + hocuspocus). +// Default realtime URL: wss://host.docker.internal:9200/realtime +// +// Run: pnpm vitest run tests/integration/realtime-sync.spec.ts + +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import * as Y from 'yjs' +import { HocuspocusProvider } from '@hocuspocus/provider' +import WS from 'ws' + +const REALTIME_URL = process.env.REALTIME_URL ?? 'wss://host.docker.internal:9200/realtime' +const DEV_FAKE_TOKEN = process.env.DEV_FAKE_TOKEN ?? 'dev-integration-token' + +beforeAll(() => { + // dev cert is self-signed + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +}) + +interface Peer { + doc: Y.Doc + provider: HocuspocusProvider +} + +function makePeer( + documentName: string, + token = DEV_FAKE_TOKEN, + parameters: Record = {} +): Peer { + const doc = new Y.Doc() + // HocuspocusProvider has no first-class `parameters` option; it just opens + // a WebSocket against `url`. To get query params to the sidecar's + // requestParameters, we append them to the URL ourselves. + const qs = new URLSearchParams(parameters).toString() + const url = qs ? `${REALTIME_URL}?${qs}` : REALTIME_URL + const provider = new HocuspocusProvider({ + url, + name: documentName, + document: doc, + token, + // WebSocketPolyfill is missing from the TS type but accepted at runtime + // (Node has no global WebSocket; this routes through ws) + WebSocketPolyfill: WS, + connect: true + } as ConstructorParameters[0]) + return { doc, provider } +} + +function awaitSynced(peer: Peer, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + if (peer.provider.isSynced) return resolve() + const t = setTimeout(() => reject(new Error(`peer not synced within ${timeoutMs}ms`)), timeoutMs) + peer.provider.on('synced', () => { + clearTimeout(t) + resolve() + }) + }) +} + +function awaitText(doc: Y.Doc, expected: string, timeoutMs = 5000): Promise { + const yText = doc.getText('content') + return new Promise((resolve, reject) => { + if (yText.toString() === expected) return resolve(yText.toString()) + const t = setTimeout( + () => reject(new Error(`text mismatch within ${timeoutMs}ms — got "${yText.toString()}", want "${expected}"`)), + timeoutMs + ) + const handler = () => { + if (yText.toString() === expected) { + yText.unobserve(handler) + clearTimeout(t) + resolve(yText.toString()) + } + } + yText.observe(handler) + }) +} + +describe('realtime sync via Hocuspocus dev container', () => { + let peers: Peer[] = [] + + afterEach(() => { + peers.forEach((p) => { + p.provider.destroy() + p.doc.destroy() + }) + peers = [] + }) + + it('replicates a Y.Text write from peer A to peer B', async () => { + const docName = `test/sync-${Date.now()}-${Math.random().toString(36).slice(2)}` + + const peerA = makePeer(docName) + const peerB = makePeer(docName) + peers.push(peerA, peerB) + + await Promise.all([awaitSynced(peerA), awaitSynced(peerB)]) + + const message = 'hello from peer A' + peerA.doc.getText('content').insert(0, message) + + const observed = await awaitText(peerB.doc, message) + expect(observed).toBe(message) + }, 15_000) + + it('converges concurrent edits across two peers (CRDT property)', async () => { + const docName = `test/concurrent-${Date.now()}-${Math.random().toString(36).slice(2)}` + + const peerA = makePeer(docName) + const peerB = makePeer(docName) + peers.push(peerA, peerB) + + await Promise.all([awaitSynced(peerA), awaitSynced(peerB)]) + + // both peers prepend a token concurrently; one should win position 0 + peerA.doc.getText('content').insert(0, 'A') + peerB.doc.getText('content').insert(0, 'B') + + // give the sync layer time to converge + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const a = peerA.doc.getText('content').toString() + const b = peerB.doc.getText('content').toString() + + expect(a).toBe(b) // CRDT: both peers must agree + expect(new Set(a.split(''))).toEqual(new Set(['A', 'B'])) + }, 15_000) + + it('stamps server-side identity on awareness even when client never sets one', async () => { + const docName = `test/identity-${Date.now()}-${Math.random().toString(36).slice(2)}` + + const peerA = makePeer(docName) + const peerB = makePeer(docName) + peers.push(peerA, peerB) + + await Promise.all([awaitSynced(peerA), awaitSynced(peerB)]) + + // Peer A sets a non-user awareness field; never touches `user`. + peerA.provider.awareness?.setLocalStateField('cursor', { line: 0, ch: 5 }) + + // Peer B must see Peer A with a server-supplied `user` field. + const seen = await new Promise<{ id: string; name: string; color: string } | 'timeout'>( + (resolve) => { + const t = setTimeout(() => resolve('timeout'), 4000) + const aw = peerB.provider.awareness + if (!aw) return resolve('timeout') + const handler = () => { + for (const state of aw.getStates().values()) { + if (state?.user?.id) { + clearTimeout(t) + aw.off('update', handler) + resolve(state.user) + return + } + } + } + aw.on('update', handler) + handler() + } + ) + + expect(seen).not.toBe('timeout') + expect((seen as { id: string }).id).toBe('dev-fake-user') + expect((seen as { name: string }).name).toBe('Dev Fake User') + }, 10_000) + + it('overwrites client-supplied awareness identity with the authenticated one', async () => { + const docName = `test/spoof-${Date.now()}-${Math.random().toString(36).slice(2)}` + + const attacker = makePeer(docName) + const observer = makePeer(docName) + peers.push(attacker, observer) + + await Promise.all([awaitSynced(attacker), awaitSynced(observer)]) + + // Attacker tries to claim to be someone else. The server is supposed to + // ignore client-supplied `user` entirely and stamp its own. + attacker.provider.awareness?.setLocalStateField('user', { + id: 'spoofed-id', + name: 'Spoofed Admin', + color: 'hsl(0, 100%, 50%)' + }) + + // Allow the server's onAwarenessUpdate rewrite to propagate; the spoofed + // values may transit to the observer first, but the server-issued + // correction must arrive within a short window. + await new Promise((resolve) => setTimeout(resolve, 800)) + + const states = [...(observer.provider.awareness?.getStates().values() ?? [])] + const userStates = states.filter((s) => s?.user).map((s) => s.user) + expect(userStates.length).toBeGreaterThan(0) + for (const u of userStates) { + expect(u.id).toBe('dev-fake-user') + expect(u.name).toBe('Dev Fake User') + expect(u.name).not.toBe('Spoofed Admin') + } + }, 10_000) + + it('rejects connections with an invalid token', async () => { + const docName = `test/auth-${Date.now()}-${Math.random().toString(36).slice(2)}` + + const peer = makePeer(docName, 'definitely-not-a-valid-token') + peers.push(peer) + + const failure = await new Promise<{ reason: string } | 'timeout'>((resolve) => { + const t = setTimeout(() => resolve('timeout'), 4000) + peer.provider.on('authenticationFailed', (data: unknown) => { + clearTimeout(t) + resolve(data as { reason: string }) + }) + }) + + expect(failure).not.toBe('timeout') + expect(peer.provider.isAuthenticated).toBe(false) + expect(peer.provider.isSynced).toBe(false) + }, 10_000) + + it('persists state across reconnects (Hocuspocus SQLite extension)', async () => { + const docName = `test/persist-${Date.now()}-${Math.random().toString(36).slice(2)}` + const payload = 'persistent content' + + // first peer writes then disconnects + const writer = makePeer(docName) + await awaitSynced(writer) + writer.doc.getText('content').insert(0, payload) + // wait a beat so the server persists; Hocuspocus debounces writes + await new Promise((resolve) => setTimeout(resolve, 800)) + writer.provider.destroy() + writer.doc.destroy() + + // second peer (fresh) joins; should receive the same content + const reader = makePeer(docName) + peers.push(reader) + await awaitSynced(reader) + + expect(reader.doc.getText('content').toString()).toBe(payload) + }, 15_000) + + it('marks doc stale when persisted etag mismatches the connecting peer\'s native etag', async () => { + const docName = `test/stale-${Date.now()}-${Math.random().toString(36).slice(2)}` + + // First peer arrives saying the native etag is X1, writes content, and + // seeds `_oc_meta.etag = X1` so the persisted snapshot ties to X1. + const writer = makePeer(docName, DEV_FAKE_TOKEN, { devEtag: 'X1' }) + await awaitSynced(writer) + writer.doc.transact(() => { + writer.doc.getText('content').insert(0, 'old content') + writer.doc.getMap('_oc_meta').set('etag', 'X1') + }) + // wait for Hocuspocus to debounce-persist + await new Promise((resolve) => setTimeout(resolve, 800)) + writer.provider.destroy() + writer.doc.destroy() + + // Second peer arrives later and announces native etag X2 (external write + // happened between sessions). Sidecar's onLoadDocument should flag the + // doc as stale. + const reader = makePeer(docName, DEV_FAKE_TOKEN, { devEtag: 'X2' }) + peers.push(reader) + await awaitSynced(reader) + + // Give the staleness Y.Map update a moment to propagate to the reader. + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(reader.doc.getMap('_oc_meta').get('isStale')).toBe(true) + expect(reader.doc.getMap('_oc_meta').get('nativeEtag')).toBe('X2') + }, 15_000) + + it('rejects a second peer with a different appVersion than the room baseline', async () => { + const docName = `test/appver-${Date.now()}-${Math.random().toString(36).slice(2)}` + + // First peer sets the baseline. + const first = makePeer(docName, DEV_FAKE_TOKEN, { appVersion: 'v1' }) + peers.push(first) + await awaitSynced(first) + + // Second peer with a different appVersion must be rejected at + // authenticate-time. We listen for `authenticationFailed`. + const second = makePeer(docName, DEV_FAKE_TOKEN, { appVersion: 'v2' }) + const failure = await new Promise<{ reason: string } | 'timeout'>((resolve) => { + const t = setTimeout(() => resolve('timeout'), 4000) + second.provider.on('authenticationFailed', (data: unknown) => { + clearTimeout(t) + resolve(data as { reason: string }) + }) + }) + second.provider.destroy() + second.doc.destroy() + + expect(failure).not.toBe('timeout') + // Hocuspocus normalizes server-thrown auth errors to a generic + // 'permission-denied' reason; the human-readable message survives only + // in sidecar logs. + expect((failure as { reason: string }).reason).toBe('permission-denied') + expect(second.provider.isAuthenticated).toBe(false) + + // A third peer with the matching baseline still gets in. + const third = makePeer(docName, DEV_FAKE_TOKEN, { appVersion: 'v1' }) + peers.push(third) + await awaitSynced(third) + expect(third.provider.isAuthenticated).toBe(true) + }, 15_000) +}) diff --git a/packages/web-app-codemirror/tests/unit/CollaborativeWrapper.spec.ts b/packages/web-app-codemirror/tests/unit/CollaborativeWrapper.spec.ts new file mode 100644 index 00000000..02a71e73 --- /dev/null +++ b/packages/web-app-codemirror/tests/unit/CollaborativeWrapper.spec.ts @@ -0,0 +1,319 @@ +// Unit coverage for the CollaborativeWrapper that lives in this package +// and is reused by web-app-tiptap. The wrapper carries the non-trivial +// branching (collab vs local) and a handful of side effects (debounced +// emit, etag mirror, lifecycle teardown) that aren't exercised by the +// e2e suites unless we run them through the whole OC + sidecar stack. +// +// We mock HocuspocusProvider so the tests stay hermetic (no network) and +// useAuthStore so we don't need pinia. The CodeMirror markdown adapter is +// used as-is — it's pure, only touches Y.Text, and exercises the same +// hydrate/serialize contract a real editor would. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, nextTick } from 'vue' +import * as Y from 'yjs' +import { Awareness } from 'y-protocols/awareness' +import type { Resource } from '@opencloud-eu/web-client' + +import { codemirrorMarkdownAdapter } from '../../src/adapters/codemirrorMarkdown' +import CollaborativeWrapper from '../../src/CollaborativeWrapper.vue' + +// HocuspocusProvider mock — captures every constructed instance so tests +// can assert on URL / params / lifecycle without holding a real WebSocket. +// vi.hoisted is required so the providerInstances array is reachable from +// the hoisted vi.mock factory; defining a `class` outside and referencing +// it from the factory hits Vitest's "Cannot access before initialization" +// because vi.mock runs before the file body executes. +interface MockProvider { + url: string + name: string + document: Y.Doc + awareness: Awareness + destroy: ReturnType + disconnect: ReturnType + setAwarenessField: ReturnType + triggerSynced(): void + triggerAuthFailed(reason: string): void +} + +const { providerInstances } = vi.hoisted(() => { + return { providerInstances: [] as MockProvider[] } +}) + +vi.mock('@hocuspocus/provider', async () => { + const { Awareness: AwarenessImpl } = await import('y-protocols/awareness') + class MockHocuspocusProvider { + url: string + name: string + document: Y.Doc + awareness: Awareness + destroy = vi.fn() + disconnect = vi.fn() + setAwarenessField = vi.fn() + private _opts: any + constructor(opts: any) { + this.url = opts.url + this.name = opts.name + this.document = opts.document + this.awareness = new AwarenessImpl(opts.document) + this._opts = opts + providerInstances.push(this as MockProvider & MockHocuspocusProvider) + } + triggerSynced() { + this._opts.onSynced?.({ state: true }) + } + triggerAuthFailed(reason: string) { + this._opts.onAuthenticationFailed?.({ reason }) + } + } + return { HocuspocusProvider: MockHocuspocusProvider } +}) + +vi.mock('@opencloud-eu/web-pkg', () => ({ + useAuthStore: () => ({ accessToken: 'test-token' }) +})) + +const DummyEditor = defineComponent({ + name: 'DummyEditor', + props: ['ydoc', 'awareness', 'provider', 'isReadOnly'], + setup() { + return () => h('div', { class: 'dummy-editor' }) + } +}) + +function makeResource(overrides: Partial = {}): Resource { + return { + id: 'storage$space!item-1', + etag: 'etag-initial', + ...overrides + } as Resource +} + +function mountWrapper(overrides: Record = {}) { + return mount(CollaborativeWrapper, { + props: { + resource: makeResource(), + currentContent: '', + adapter: codemirrorMarkdownAdapter, + editor: DummyEditor, + appVersion: '1.2.3', + realtimeUrl: null, + ...overrides + } + }) +} + +beforeEach(() => { + providerInstances.length = 0 +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('CollaborativeWrapper — local mode (no realtimeUrl)', () => { + it('reports status "local" and does not construct a HocuspocusProvider', async () => { + const wrapper = mountWrapper({ currentContent: 'hello' }) + await flushPromises() + expect(wrapper.text()).toContain('local') + expect(providerInstances).toHaveLength(0) + }) + + it('hydrates the Y.Doc from currentContent (election degenerates to "we win")', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const wrapper = mountWrapper({ currentContent: 'hello local' }) + await flushPromises() + // Hydration is gated by a 150ms awareness-settle wait. + vi.advanceTimersByTime(200) + await flushPromises() + + const ydocAny = (wrapper.vm as unknown as { ydoc: Y.Doc | null }).ydoc + expect(ydocAny).toBeTruthy() + expect(ydocAny!.getText('content').toString()).toBe('hello local') + }) + + it('mounts the editor component with a real Awareness instance', async () => { + const wrapper = mountWrapper({ currentContent: 'x' }) + await flushPromises() + const editor = wrapper.findComponent(DummyEditor) + expect(editor.exists()).toBe(true) + expect(editor.props('awareness')).toBeInstanceOf(Awareness) + expect(editor.props('provider')).toBeNull() + }) +}) + +describe('CollaborativeWrapper — collab mode (realtimeUrl set)', () => { + it('constructs a HocuspocusProvider with the appVersion query param appended', async () => { + mountWrapper({ + realtimeUrl: 'wss://example.test/realtime', + appVersion: '2.3.4' + }) + await flushPromises() + expect(providerInstances).toHaveLength(1) + expect(providerInstances[0].url).toBe('wss://example.test/realtime?appVersion=2.3.4') + expect(providerInstances[0].setAwarenessField).toHaveBeenCalledWith('user', {}) + }) + + it('does not hydrate until onSynced fires (collab waits for the server)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const wrapper = mountWrapper({ + realtimeUrl: 'wss://example.test/realtime', + currentContent: 'should-only-land-after-sync' + }) + await flushPromises() + vi.advanceTimersByTime(500) + await flushPromises() + + const ydocAny = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydocAny.getText('content').toString()).toBe('') + + providerInstances[0].triggerSynced() + vi.advanceTimersByTime(200) + await flushPromises() + expect(ydocAny.getText('content').toString()).toBe('should-only-land-after-sync') + }) + + it('surfaces an auth failure as a lifecycle error and locks the editor read-only', async () => { + const wrapper = mountWrapper({ realtimeUrl: 'wss://example.test/realtime' }) + await flushPromises() + providerInstances[0].triggerAuthFailed('token expired') + await nextTick() + expect(wrapper.text()).toContain('token expired') + const editor = wrapper.findComponent(DummyEditor) + expect(editor.props('isReadOnly')).toBe(true) + }) +}) + +describe('CollaborativeWrapper — update:currentContent emission', () => { + it('emits debounced after a user-origin Y.Doc update', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const wrapper = mountWrapper({ currentContent: 'seed' }) + await flushPromises() + vi.advanceTimersByTime(200) + await flushPromises() + + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + // Clear any emit produced by hydration (hydration uses an internal + // origin so this should be a no-op, but we're isolating the + // post-hydrate signal explicitly). + ;(wrapper.emitted()['update:currentContent'] ?? []).length = 0 + + ydoc.getText('content').insert(4, ' edit') // no origin = user-typed + + // Nothing emitted within the debounce window yet. + vi.advanceTimersByTime(100) + await flushPromises() + expect(wrapper.emitted('update:currentContent') ?? []).toHaveLength(0) + + // 300ms after the last edit, the debounced serialize fires. + vi.advanceTimersByTime(300) + await flushPromises() + const emits = wrapper.emitted('update:currentContent') ?? [] + expect(emits.length).toBeGreaterThanOrEqual(1) + expect(emits[emits.length - 1][0]).toBe('seed edit') + }) + + it('does NOT emit for internal-origin transactions (hydrate / reset / recovery)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const wrapper = mountWrapper({ currentContent: 'seed' }) + await flushPromises() + vi.advanceTimersByTime(200) // let hydration run + await flushPromises() + vi.advanceTimersByTime(400) // past the debounce window + await flushPromises() + + // After hydration the wrapper may have emitted once with the + // post-hydrate serialization — that's a debounce artefact, not the + // internal-origin transaction itself. The contract we're verifying: + // a fresh internal-origin transact() does NOT schedule a NEW emit. + const before = (wrapper.emitted('update:currentContent') ?? []).length + + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + ydoc.transact(() => { + ydoc.getText('content').insert(0, 'internal-') + }, 'hydrate') + + vi.advanceTimersByTime(400) + await flushPromises() + const after = (wrapper.emitted('update:currentContent') ?? []).length + expect(after).toBe(before) + }) +}) + +describe('CollaborativeWrapper — etag mirror', () => { + it('writes a new resource.etag into _oc_meta.etag', async () => { + const wrapper = mountWrapper({ currentContent: 'x', resource: makeResource({ etag: 'a' }) }) + await flushPromises() + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + const meta = ydoc.getMap('_oc_meta') + + await wrapper.setProps({ resource: makeResource({ etag: 'b' }) }) + await flushPromises() + expect(meta.get('etag')).toBe('b') + expect(meta.get('lastSavedAt')).toBeTypeOf('number') + }) + + // Regression: `setProps({ resource })` with a new resource OBJECT whose + // `id` is unchanged must NOT tear down and rebuild the Y.Doc. The earlier + // implementation used `watchEffect((onCleanup) => { unref(documentName); ... })`, + // which Vue re-ran on every tracked prop access — including `props.resource` + // mutations from AppWrapper's post-save `resourcesStore.upsertResource`. + // Every save would have rebuilt the Y.Doc, losing in-flight peer edits. + // The current implementation gates rebuilds on a `sessionKey` computed + // (documentName + realtimeUrl), so an identity-preserving resource update + // is a no-op for the watch. + it('regression: does not rebuild Y.Doc when resource prop changes without identity change', async () => { + const wrapper = mountWrapper({ currentContent: 'x', resource: makeResource({ etag: 'a' }) }) + await flushPromises() + const ydocBefore = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydocBefore).toBeTruthy() + expect(ydocBefore.isDestroyed).toBe(false) + + // Same id, different etag — simulates AppWrapper bouncing `resource` after + // a successful save. + await wrapper.setProps({ resource: makeResource({ etag: 'b' }) }) + await flushPromises() + const ydocAfter = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydocAfter).toBe(ydocBefore) + expect(ydocBefore.isDestroyed).toBe(false) + }) + + it('does nothing when the etag is unchanged', async () => { + const wrapper = mountWrapper({ currentContent: 'x', resource: makeResource({ etag: 'a' }) }) + await flushPromises() + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + const meta = ydoc.getMap('_oc_meta') + // Initial etag may have been seeded by onProviderSynced. + const initialMeta = meta.get('etag') + + await wrapper.setProps({ resource: makeResource({ etag: 'a' }) }) + await flushPromises() + expect(meta.get('etag')).toBe(initialMeta) + }) +}) + +describe('CollaborativeWrapper — cleanup', () => { + it('destroys provider, awareness, and doc on unmount (collab mode)', async () => { + const wrapper = mountWrapper({ realtimeUrl: 'wss://example.test/realtime' }) + await flushPromises() + const prov = providerInstances[0] + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydoc.isDestroyed).toBe(false) + + wrapper.unmount() + expect(prov.destroy).toHaveBeenCalledOnce() + expect(ydoc.isDestroyed).toBe(true) + }) + + it('destroys awareness and doc on unmount (local mode)', async () => { + const wrapper = mountWrapper({ currentContent: 'x' }) + await flushPromises() + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydoc.isDestroyed).toBe(false) + + wrapper.unmount() + expect(ydoc.isDestroyed).toBe(true) + expect(providerInstances).toHaveLength(0) + }) +}) diff --git a/packages/web-app-codemirror/tsconfig.json b/packages/web-app-codemirror/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/web-app-codemirror/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/web-app-codemirror/vite.config.ts b/packages/web-app-codemirror/vite.config.ts new file mode 100644 index 00000000..62cf238f --- /dev/null +++ b/packages/web-app-codemirror/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@opencloud-eu/extension-sdk' + +export default defineConfig({ + name: 'codemirror', + server: { + port: 9740 + } +}) diff --git a/packages/web-app-codemirror/vitest.config.ts b/packages/web-app-codemirror/vitest.config.ts new file mode 100644 index 00000000..f7f55927 --- /dev/null +++ b/packages/web-app-codemirror/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { + // happy-dom is required for Vue component tests (window, DOM APIs); + // the integration suite under tests/integration/ runs against a real + // Hocuspocus server and only uses pure node APIs, so happy-dom is + // transparent for it. + environment: 'happy-dom', + include: ['tests/**/*.spec.ts'], + exclude: ['tests/e2e/**'], + testTimeout: 20_000 + } +}) diff --git a/packages/web-app-tiptap/extension.d.ts b/packages/web-app-tiptap/extension.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/web-app-tiptap/extension.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/web-app-tiptap/l10n/translations.json b/packages/web-app-tiptap/l10n/translations.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/web-app-tiptap/l10n/translations.json @@ -0,0 +1 @@ +{} diff --git a/packages/web-app-tiptap/package.json b/packages/web-app-tiptap/package.json new file mode 100644 index 00000000..62962235 --- /dev/null +++ b/packages/web-app-tiptap/package.json @@ -0,0 +1,32 @@ +{ + "name": "tiptap", + "version": "0.1.0", + "private": true, + "description": "OpenCloud Web collaborative Tiptap rich-text editor", + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "build": "pnpm vite build", + "build:w": "pnpm vite build --watch --mode development", + "check:types": "vue-tsc --noEmit" + }, + "dependencies": { + "@hocuspocus/provider": "^4.0.0", + "@tiptap/core": "^3.20.4", + "@tiptap/extension-collaboration": "^3.20.4", + "@tiptap/y-tiptap": "^3.0.0", + "@tiptap/extension-placeholder": "^3.20.4", + "@tiptap/markdown": "^3.20.4", + "@tiptap/pm": "^3.20.4", + "@tiptap/starter-kit": "^3.20.4", + "@tiptap/vue-3": "^3.20.4", + "y-protocols": "^1.0.7", + "yjs": "^13.6.0" + }, + "devDependencies": { + "@opencloud-eu/web-client": "^7.0.0", + "@opencloud-eu/web-pkg": "^7.0.0", + "vue": "^3.4.21", + "vue3-gettext": "^2.4.0" + } +} diff --git a/packages/web-app-tiptap/src/App.vue b/packages/web-app-tiptap/src/App.vue new file mode 100644 index 00000000..7747d15b --- /dev/null +++ b/packages/web-app-tiptap/src/App.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/web-app-tiptap/src/TiptapEditor.vue b/packages/web-app-tiptap/src/TiptapEditor.vue new file mode 100644 index 00000000..db6f668b --- /dev/null +++ b/packages/web-app-tiptap/src/TiptapEditor.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/packages/web-app-tiptap/src/adapters/tiptapMarkdown.ts b/packages/web-app-tiptap/src/adapters/tiptapMarkdown.ts new file mode 100644 index 00000000..47a37681 --- /dev/null +++ b/packages/web-app-tiptap/src/adapters/tiptapMarkdown.ts @@ -0,0 +1,65 @@ +import * as Y from 'yjs' +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { Collaboration } from '@tiptap/extension-collaboration' +import { Markdown } from '@tiptap/markdown' +import type { CollaborativeAdapter } from '../../../web-app-codemirror/src/types' + +// Tiptap binds Collaboration to a named Y.XmlFragment on the doc. We use +// the extension's default field name; the editor component must match. +const FRAGMENT = 'default' + +// Spin up a Tiptap editor with no visible DOM and the editor bound to the +// caller's Y.Doc. Loading the schema + Markdown extension is enough to do +// MD ↔ ProseMirror conversions; the editor never paints anywhere on screen. +// We attach to a detached
because @tiptap/core requires an element. +function makeHeadlessEditor(ydoc: Y.Doc): Editor { + const detached = document.createElement('div') + return new Editor({ + element: detached, + extensions: [ + StarterKit.configure({ + // Yjs Collaboration replaces history with its own undo manager. + history: false + }), + Markdown, + Collaboration.configure({ document: ydoc, field: FRAGMENT }) + ] + }) +} + +export const tiptapMarkdownAdapter: CollaborativeAdapter = { + hydrate(ydoc: Y.Doc, content: string) { + if (!content) return + const editor = makeHeadlessEditor(ydoc) + try { + // contentType: 'markdown' routes the input through @tiptap/markdown's + // parser. The Collaboration plugin propagates the resulting + // ProseMirror state into the bound Y.XmlFragment. + editor.commands.setContent(content, { contentType: 'markdown' }) + } finally { + editor.destroy() + } + }, + + serialize(ydoc: Y.Doc): string { + const editor = makeHeadlessEditor(ydoc) + try { + return editor.getMarkdown() + } finally { + editor.destroy() + } + }, + + hasContent(ydoc: Y.Doc): boolean { + return ydoc.getXmlFragment(FRAGMENT).length > 0 + }, + + reset(ydoc: Y.Doc) { + const frag = ydoc.getXmlFragment(FRAGMENT) + if (frag.length === 0) return + ydoc.transact(() => { + frag.delete(0, frag.length) + }, 'reset') + } +} diff --git a/packages/web-app-tiptap/src/index.ts b/packages/web-app-tiptap/src/index.ts new file mode 100644 index 00000000..c75ba8b6 --- /dev/null +++ b/packages/web-app-tiptap/src/index.ts @@ -0,0 +1,44 @@ +import { AppWrapperRoute, defineWebApplication } from '@opencloud-eu/web-pkg' +import { useGettext } from 'vue3-gettext' +import App from './App.vue' +import translations from '../l10n/translations.json' + +const applicationId = 'tiptap' + +export default defineWebApplication({ + setup() { + const { $gettext } = useGettext() + + const routes = [ + { + name: applicationId, + path: '/:driveAliasAndItem(.*)?', + component: AppWrapperRoute(App, { applicationId }), + meta: { + authContext: 'hybrid', + title: $gettext('Tiptap'), + patchCleanPath: true + } + } + ] + + const appInfo = { + name: $gettext('Tiptap'), + id: applicationId, + icon: 'file-paper', + defaultExtension: 'md', + extensions: [ + { + extension: 'md', + routeName: applicationId + } + ] + } + + return { + appInfo, + routes, + translations + } + } +}) diff --git a/packages/web-app-tiptap/tests/e2e/empty-file.spec.ts b/packages/web-app-tiptap/tests/e2e/empty-file.spec.ts new file mode 100644 index 00000000..71b0ab59 --- /dev/null +++ b/packages/web-app-tiptap/tests/e2e/empty-file.spec.ts @@ -0,0 +1,53 @@ +import { test, expect, type Page } from '@playwright/test' +import { FilesAppBar } from '../../../../support/pages/filesAppBarActions' +import { FilesPage } from '../../../../support/pages/filesPage' +import { loginAsUser, logout } from '../../../../support/helpers/authHelper' +import { createRandomUser } from '../../../../support/helpers/api/apiHelper' + +let userPage: Page + +test.beforeEach(async ({ browser }) => { + const user = await createRandomUser() + userPage = (await loginAsUser(browser, user.username, user.password)).page +}) + +test.afterEach(async () => { + await logout(userPage) +}) + +async function openMdInTiptap(page: Page, file: string) { + const filesPage = new FilesPage(page) + await filesPage.getResourceNameSelector(file).click({ button: 'right' }) + await page.locator('xpath=//*[contains(@class, "oc-drop")]//span[text()="Open with..."]').hover() + await page.getByRole('menuitem', { name: 'Tiptap' }).click() +} + +test('empty .md file opens cleanly and accepts input', async () => { + // capture console errors for diagnostic output + const consoleErrors: string[] = [] + userPage.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + }) + userPage.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`)) + + const bar = new FilesAppBar(userPage) + await bar.uploadFile('empty-note.md') + + await openMdInTiptap(userPage, 'empty-note.md') + await expect(userPage).toHaveURL(/tiptap/) + await expect(userPage.locator('.oc-text-meta', { hasText: 'connected' })).toBeVisible({ + timeout: 10_000 + }) + + const editor = userPage.locator('.ProseMirror') + await expect(editor).toBeVisible({ timeout: 5_000 }) + + // The editor must be editable and accept input. + await editor.click() + await userPage.keyboard.type('hello from empty') + + await expect(editor).toContainText('hello from empty', { timeout: 3_000 }) + + console.log('--- captured console errors ---') + for (const e of consoleErrors) console.log(e) +}) diff --git a/packages/web-app-tiptap/tests/e2e/tiptap.spec.ts b/packages/web-app-tiptap/tests/e2e/tiptap.spec.ts new file mode 100644 index 00000000..408e3730 --- /dev/null +++ b/packages/web-app-tiptap/tests/e2e/tiptap.spec.ts @@ -0,0 +1,142 @@ +import { test, expect, type Page } from '@playwright/test' +import { FilesAppBar } from '../../../../support/pages/filesAppBarActions' +import { FilesPage } from '../../../../support/pages/filesPage' +import { loginAsUser, logout } from '../../../../support/helpers/authHelper' +import { createRandomUser } from '../../../../support/helpers/api/apiHelper' +import { + createProjectSpace, + inviteUserToSpace, + uploadFileToSpace +} from '../../../../support/helpers/api/spaceHelper' + +let userPage: Page + +test.beforeEach(async ({ browser }) => { + const user = await createRandomUser() + userPage = (await loginAsUser(browser, user.username, user.password)).page +}) + +test.afterEach(async () => { + await logout(userPage) +}) + +// Locators specific to Tiptap rendering. Tiptap mounts on a ProseMirror-managed +// contenteditable; remote cursors come from `@tiptap/extension-collaboration-cursor`. +const tt = { + content: (p: Page) => p.locator('.ProseMirror').first(), + awaitConnected: async (p: Page) => { + await expect(p.locator('.oc-text-meta', { hasText: 'connected' })).toBeVisible({ + timeout: 10_000 + }) + }, + remoteCaret: (p: Page) => p.locator('.collaboration-cursor__caret').first(), + remoteLabel: (p: Page) => p.locator('.collaboration-cursor__label').first() +} + +async function openMdInTiptap(page: Page, file: string) { + const filesPage = new FilesPage(page) + await filesPage.getResourceNameSelector(file).click({ button: 'right' }) + await page.locator('xpath=//*[contains(@class, "oc-drop")]//span[text()="Open with..."]').hover() + await page.getByRole('menuitem', { name: 'Tiptap' }).click() +} + +test('open .md in Tiptap, Markdown is rendered as rich text, server connects', async () => { + const bar = new FilesAppBar(userPage) + await bar.uploadFile('rich-note.md') + + await openMdInTiptap(userPage, 'rich-note.md') + await expect(userPage).toHaveURL(/tiptap/) + await tt.awaitConnected(userPage) + + // Headings should be rendered as h1/h2 (not raw `#` characters). + await expect(tt.content(userPage).locator('h1')).toHaveText('Rich Note') + await expect(tt.content(userPage).locator('h2')).toHaveText('Section Two') + // Inline marks survive. + await expect(tt.content(userPage).locator('strong')).toHaveText('bold') + await expect(tt.content(userPage).locator('em')).toHaveText('italic') + await expect(tt.content(userPage).locator('code')).toHaveText('inline code') + // Lists survive. + await expect(tt.content(userPage).locator('ul li')).toHaveCount(3) + await expect(tt.content(userPage).locator('ol li')).toHaveCount(2) +}) + +test('switching files via direct navigation rebuilds the realtime provider (Tiptap)', async () => { + const bar = new FilesAppBar(userPage) + await bar.uploadFile('rich-note.md') + await bar.uploadFile('note-alpha.md') + + await openMdInTiptap(userPage, 'rich-note.md') + await tt.awaitConnected(userPage) + await expect(tt.content(userPage).locator('h1')).toHaveText('Rich Note') + const richUrl = userPage.url() + + await userPage.goBack() + await openMdInTiptap(userPage, 'note-alpha.md') + await tt.awaitConnected(userPage) + await expect(tt.content(userPage)).toContainText('Note Alpha') + const alphaUrl = userPage.url() + + expect(richUrl).not.toBe(alphaUrl) + + await userPage.goto(richUrl) + await tt.awaitConnected(userPage) + await expect(tt.content(userPage).locator('h1')).toHaveText('Rich Note') + + await userPage.goto(alphaUrl) + await tt.awaitConnected(userPage) + await expect(tt.content(userPage)).toContainText('Note Alpha') + await expect(tt.content(userPage)).not.toContainText('Rich Note') +}) + +test.describe('multi-user collaboration via shared Project Space (Tiptap)', () => { + test.beforeEach(async () => { + /* override outer beforeEach: no auto user/login */ + }) + test.afterEach(async () => { + /* override outer afterEach */ + }) + + test("user A's caret is rendered in user B's editor with the server-stamped name", async ({ + browser + }) => { + const stamp = Date.now() + const space = await createProjectSpace(`tiptap-${stamp}`) + const alice = await createRandomUser() + const bob = await createRandomUser() + await inviteUserToSpace(space.id, alice.id) + await inviteUserToSpace(space.id, bob.id) + const { fileId } = await uploadFileToSpace( + space, + 'team-note.md', + '# Team Note\n\nLine alpha.\nLine bravo.\nLine charlie.\nLine delta.\n' + ) + + const aliceSession = await loginAsUser(browser, alice.username, alice.password) + const bobSession = await loginAsUser(browser, bob.username, bob.password) + const pageA = aliceSession.page + const pageB = bobSession.page + + try { + const fileUrl = `/tiptap/?fileId=${encodeURIComponent(fileId)}` + await Promise.all([pageA.goto(fileUrl), pageB.goto(fileUrl)]) + await Promise.all([tt.awaitConnected(pageA), tt.awaitConnected(pageB)]) + await expect(tt.content(pageA).locator('h1')).toHaveText('Team Note') + await expect(tt.content(pageB).locator('h1')).toHaveText('Team Note') + + // Place Alice's caret somewhere in the body text. + await tt.content(pageA).click({ position: { x: 50, y: 50 } }) + await pageA.keyboard.press('End') + + await expect(tt.remoteCaret(pageB)).toBeVisible({ timeout: 10_000 }) + await expect(tt.remoteLabel(pageB)).toHaveText(alice.username, { timeout: 5_000 }) + + // CRDT typing test: Alice types, Bob receives. + const marker = `TIPTAP-MARK-${stamp}` + await pageA.keyboard.type(marker) + await expect(tt.content(pageB)).toContainText(marker, { timeout: 5_000 }) + } finally { + await logout(pageA) + await logout(pageB) + } + }) +}) diff --git a/packages/web-app-tiptap/tsconfig.json b/packages/web-app-tiptap/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/web-app-tiptap/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/web-app-tiptap/vite.config.ts b/packages/web-app-tiptap/vite.config.ts new file mode 100644 index 00000000..b2f364d7 --- /dev/null +++ b/packages/web-app-tiptap/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@opencloud-eu/extension-sdk' + +export default defineConfig({ + name: 'tiptap', + server: { + port: 9741 + }, + // y-prosemirror does `instanceof Y.Doc` checks against the Yjs constructor + // it imported; if the bundler ends up with two Yjs copies (one pulled in by + // @hocuspocus/provider, another by @tiptap/extension-collaboration via + // y-prosemirror), the check fails inside createDecorations with + // "Cannot read properties of undefined (reading 'doc')". Deduping forces a + // single Yjs instance across the bundle. + resolve: { + dedupe: ['yjs', 'y-prosemirror', 'y-protocols'] + } +}) diff --git a/playwright.config.ts b/playwright.config.ts index c14034dd..8f5d59f1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,6 +33,16 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + { + name: 'codemirror-chromium', + testDir: './packages/web-app-codemirror/tests/e2e', + use: { ...devices['Desktop Chrome'], browserName: 'chromium', ignoreHTTPSErrors: true } + }, + { + name: 'tiptap-chromium', + testDir: './packages/web-app-tiptap/tests/e2e', + use: { ...devices['Desktop Chrome'], browserName: 'chromium', ignoreHTTPSErrors: true } + }, { name: 'calculator-chromium', testDir: './packages/web-app-calculator/tests/e2e', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1786617b..e9ddbdb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,64 @@ importers: specifier: ^4.0.0-beta.1 version: 4.0.0-beta.1(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + packages/web-app-codemirror: + dependencies: + '@codemirror/lang-markdown': + specifier: ^6.3.0 + version: 6.5.0 + '@codemirror/state': + specifier: ^6.5.0 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.34.0 + version: 6.41.1 + '@hocuspocus/provider': + specifier: ^4.0.0 + version: 4.0.0(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + semver: + specifier: ^7.8.0 + version: 7.8.0 + y-codemirror.next: + specifier: ^0.3.5 + version: 0.3.5(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(yjs@13.6.30) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.30) + yjs: + specifier: ^13.6.0 + version: 13.6.30 + devDependencies: + '@opencloud-eu/web-client': + specifier: ^7.0.0 + version: 7.0.0 + '@opencloud-eu/web-pkg': + specifier: ^7.0.0 + version: 7.0.0(@floating-ui/dom@1.7.6)(@tiptap/extension-list@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@vue/compiler-sfc@3.5.33)(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + '@types/semver': + specifier: ^7.7.0 + version: 7.7.1 + '@types/ws': + specifier: ^8.5.0 + version: 8.18.1 + '@vitejs/plugin-vue': + specifier: ^6.0.0 + version: 6.0.6(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3)) + '@vue/test-utils': + specifier: ^2.4.0 + version: 2.4.9(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + happy-dom: + specifier: ^20.0.0 + version: 20.9.0 + vue: + specifier: ^3.4.21 + version: 3.5.33(typescript@6.0.3) + vue3-gettext: + specifier: ^2.4.0 + version: 2.4.0(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + ws: + specifier: ^8.18.0 + version: 8.20.0 + packages/web-app-draw-io: devDependencies: '@opencloud-eu/web-client': @@ -319,6 +377,55 @@ importers: specifier: ^4.0.0-beta.1 version: 4.0.0-beta.1(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + packages/web-app-tiptap: + dependencies: + '@hocuspocus/provider': + specifier: ^4.0.0 + version: 4.0.0(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/core': + specifier: ^3.20.4 + version: 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-collaboration': + specifier: ^3.20.4 + version: 3.23.4(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/extension-placeholder': + specifier: ^3.20.4 + version: 3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)) + '@tiptap/markdown': + specifier: ^3.20.4 + version: 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/pm': + specifier: ^3.20.4 + version: 3.23.1 + '@tiptap/starter-kit': + specifier: ^3.20.4 + version: 3.23.1 + '@tiptap/vue-3': + specifier: ^3.20.4 + version: 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.33(typescript@6.0.3)) + '@tiptap/y-tiptap': + specifier: ^3.0.0 + version: 3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.30) + yjs: + specifier: ^13.6.0 + version: 13.6.30 + devDependencies: + '@opencloud-eu/web-client': + specifier: ^7.0.0 + version: 7.0.0 + '@opencloud-eu/web-pkg': + specifier: ^7.0.0 + version: 7.0.0(@floating-ui/dom@1.7.6)(@tiptap/extension-list@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@vue/compiler-sfc@3.5.33)(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + vue: + specifier: ^3.4.21 + version: 3.5.33(typescript@6.0.3) + vue3-gettext: + specifier: ^2.4.0 + version: 2.4.0(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + packages/web-app-unzip: dependencies: '@zip.js/zip.js': @@ -343,6 +450,10 @@ importers: packages: + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} @@ -389,9 +500,21 @@ packages: '@codemirror/commands@6.10.3': resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + '@codemirror/language@6.12.3': resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} @@ -663,6 +786,15 @@ packages: resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} engines: {node: '>=6'} + '@hocuspocus/common@4.0.0': + resolution: {integrity: sha512-7BE8TsKBkdiOZO6tfm3ny6bIHPbxkIZb3hsYdVn/X5xbXI8n8w9pnE6pXgEMKQhJm6zsWsa9IDRJIp/c9u+DmA==} + + '@hocuspocus/provider@4.0.0': + resolution: {integrity: sha512-08gpeNZ6x2pmRD6m4XwRD52yQKnTl32a0HS9VSXZ5A1dIBVqxMz/x8Z06XbkKM2X8sp6vWEUCZCtzAGFSsofgg==} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -729,15 +861,30 @@ packages: '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + '@lezer/css@1.3.3': + resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==} + '@lezer/highlight@1.2.3': resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + '@lezer/json@1.0.3': resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} '@lezer/lr@1.4.10': resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + '@lezer/markdown@1.6.3': + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + + '@lifeomic/attempt@3.1.0': + resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==} + '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} @@ -1274,6 +1421,14 @@ packages: peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-collaboration@3.23.4': + resolution: {integrity: sha512-28TJFayxCk7J9TmHBG4+8lVAz6YgyjN0RqzZueVeimWxSEgnTDGlkfHx6Ho5tOuyLwDa6SMBhN/6Q0iUMdnwMQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + '@tiptap/y-tiptap': ^3.0.2 + yjs: ^13 + '@tiptap/extension-document@3.23.1': resolution: {integrity: sha512-NA5Rx59HRwG6Hb6LwLpC5lE7z6vCj6f90S7RNNsnE+CyiXNR/OhY2BcjuxiGnascHvsnsAbvxGU3ymKMDgvDVg==} peerDependencies: @@ -1354,6 +1509,11 @@ packages: peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-placeholder@3.23.4': + resolution: {integrity: sha512-yHtAZkFR9M2AQmCi555w4ns1BBCqwRyYDYMtd10DBvqPX7T3TmGerPdUfI6sLr74GxnZ5zHOnOYdwAbeG5JzNw==} + peerDependencies: + '@tiptap/extensions': 3.23.4 + '@tiptap/extension-strike@3.23.1': resolution: {integrity: sha512-+R5LG0ZW9SDZc4weA79uq6uUduVsCEph9tRcoQCRA82IVIiPYSTxTLew9odalmk/Mc7vdZvOK5jjtO5jUVw/rg==} peerDependencies: @@ -1396,6 +1556,12 @@ packages: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/extensions@3.23.4': + resolution: {integrity: sha512-SlGPXauW8iKWG7wwuwC/0y/smLImp0h6GBIGgNnTBgIP/ThXQnjLMSZH0mW/REO87dQxkku01V3ARRywi+juhg==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + '@tiptap/markdown@3.23.1': resolution: {integrity: sha512-xJNP8HPJhq5spCnr5I+TY7cnU4bdsKkxc28KxGHkFar054TsakBesii0XiahD+c7zODQ8gIlfr3EGzw41Hcz1g==} peerDependencies: @@ -1422,6 +1588,16 @@ packages: '@tiptap/pm': 3.23.1 vue: ^3.0.0 + '@tiptap/y-tiptap@3.0.3': + resolution: {integrity: sha512-8UvuV4lTisCE9cMTc/X8kRyTn9edUO7Kball0I6wb17VwZSjNDfh/YKtP4O5vcPawEzFHQIvZGq/k1h37kAf0w==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + '@tmcw/togeojson@7.1.2': resolution: {integrity: sha512-QKnFs9DAuqqBVj4d6c69tV1Dj2TspSBTqffivoN0YoBCVdP/JY1+WaYCJbzU49RkoU5NOSOJ3jtFHCdEUVh21A==} @@ -1470,18 +1646,34 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/glob@7.2.0': + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/minimatch@6.0.0': + resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} + deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. + '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/parse5@5.0.3': + resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} + '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} @@ -1817,6 +2009,10 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + array-back@6.2.3: resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} engines: {node: '>=12.17'} @@ -1866,6 +2062,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} @@ -1890,10 +2089,18 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1940,6 +2147,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + command-line-args@6.0.2: resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==} engines: {node: '>=12.20'} @@ -1956,6 +2167,9 @@ packages: complex.js@2.4.3: resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -1972,6 +2186,10 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -1992,6 +2210,9 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + css-selector-parser@1.4.1: + resolution: {integrity: sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2087,6 +2308,9 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2269,6 +2493,10 @@ packages: resolution: {integrity: sha512-WgZ+nKbELDa6N3i/9nrHeNznm+lY3z4YfhDDWgW+5P0pdmMj26bxaxU11ookgY3NyP9GC7HvZ9etp0jRFqGEeQ==} engines: {node: '>=8'} + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + find-replace@5.0.2: resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} engines: {node: '>=14'} @@ -2326,6 +2554,9 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2351,6 +2582,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + gettext-extractor@3.8.0: + resolution: {integrity: sha512-i/3mDQufQoJd2/EKm/B+VlaYrt3yGjVfLZu8DQpESKH29klNiW6z2S89FVCIEB85bDNgtGCeM/3A/yR1njr/Lw==} + engines: {node: '>=6'} + gl-matrix@3.4.4: resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} @@ -2369,6 +2604,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-modules@1.0.0: resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} engines: {node: '>=0.10.0'} @@ -2432,13 +2671,27 @@ packages: immutable@5.1.5: resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-buffer@1.1.6: resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} @@ -2485,6 +2738,9 @@ packages: peerDependencies: ws: '*' + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -2515,6 +2771,9 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsep@1.4.0: resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} engines: {node: '>= 10.16.0'} @@ -2527,6 +2786,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2576,6 +2838,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -2654,6 +2921,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} @@ -2767,6 +3037,9 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -2853,6 +3126,9 @@ packages: resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} engines: {node: '>=18'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2894,10 +3170,24 @@ packages: pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse-passwd@1.0.0: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + password-sheriff@2.0.0: resolution: {integrity: sha512-wt/vYZVdrROLi6LWBBsau8lM0V24KTvtzN62Iunh+C6dV+5q8Jn1HccOBO6dmm8+4IuM7plSUyD2ZV6ykSIj6g==} @@ -2912,6 +3202,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2930,6 +3224,10 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2978,6 +3276,9 @@ packages: pmtiles@4.4.1: resolution: {integrity: sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==} + pofile@1.0.11: + resolution: {integrity: sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==} + pofile@1.1.4: resolution: {integrity: sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g==} @@ -3092,6 +3393,10 @@ packages: resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} engines: {node: '>=0.10.0'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -3337,6 +3642,10 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -3441,11 +3750,20 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + typical@7.3.0: resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} engines: {node: '>=12.17'} @@ -3635,6 +3953,14 @@ packages: peerDependencies: typescript: '>=5.0.0' + vue3-gettext@2.4.0: + resolution: {integrity: sha512-xnCndk88nzC+EiUJpNKykUulvPhivM1AQuT6oa72PA4DUOFPjJvpy5hZ/GbxLUJiqvYJebMDMTWywCJwIw+biA==} + engines: {node: '>= 12.0.0'} + hasBin: true + peerDependencies: + '@vue/compiler-sfc': '>=3.0.0' + vue: '>=3.0.0' + vue3-gettext@4.0.0-beta.1: resolution: {integrity: sha512-1A46SmubgTMyy7i5hj8ay50NFl6/vzwoIVZPuGCin/X3a/NVCAs99G0EbcnfJiR7NZNTJgUjvBzppufC7Kq+4A==} engines: {node: '>= 20.19.0'} @@ -3698,6 +4024,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -3726,11 +4055,32 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + y-codemirror.next@0.3.5: + resolution: {integrity: sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==} + peerDependencies: + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + yjs: ^13.5.6 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true + yjs@13.6.30: + resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -3747,6 +4097,12 @@ packages: snapshots: + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.2 @@ -3799,11 +4155,51 @@ snapshots: '@codemirror/view': 6.41.1 '@lezer/common': 1.5.2 + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.5 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + '@codemirror/lang-json@6.0.2': dependencies: '@codemirror/language': 6.12.3 '@lezer/json': 1.0.3 + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.6.3 + '@codemirror/language@6.12.3': dependencies: '@codemirror/state': 6.6.0 @@ -4053,6 +4449,22 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.2.0 + '@hocuspocus/common@4.0.0': + dependencies: + lib0: 0.2.117 + + '@hocuspocus/provider@4.0.0(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)': + dependencies: + '@hocuspocus/common': 4.0.0 + '@lifeomic/attempt': 3.1.0 + lib0: 0.2.117 + ws: 8.20.0 + y-protocols: 1.0.7(yjs@13.6.30) + yjs: 13.6.30 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -4116,10 +4528,28 @@ snapshots: '@lezer/common@1.5.2': {} + '@lezer/css@1.3.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/highlight@1.2.3': dependencies: '@lezer/common': 1.5.2 + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/json@1.0.3': dependencies: '@lezer/common': 1.5.2 @@ -4130,6 +4560,13 @@ snapshots: dependencies: '@lezer/common': 1.5.2 + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lifeomic/attempt@3.1.0': {} + '@mapbox/jsonlint-lines-primitives@2.0.2': {} '@mapbox/point-geometry@1.1.0': {} @@ -4716,6 +5153,13 @@ snapshots: dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-collaboration@3.23.4(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': + dependencies: + '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/pm': 3.23.1 + '@tiptap/y-tiptap': 3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + yjs: 13.6.30 + '@tiptap/extension-document@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) @@ -4783,6 +5227,10 @@ snapshots: dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-placeholder@3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/extensions': 3.23.4(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/extension-strike@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) @@ -4817,6 +5265,11 @@ snapshots: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) '@tiptap/pm': 3.23.1 + '@tiptap/extensions@3.23.4(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)': + dependencies: + '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/pm': 3.23.1 + '@tiptap/markdown@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) @@ -4880,6 +5333,15 @@ snapshots: '@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) '@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)': + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + y-protocols: 1.0.7(yjs@13.6.30) + yjs: 13.6.30 + '@tmcw/togeojson@7.1.2': {} '@transloadit/prettier-bytes@0.3.5': {} @@ -4925,16 +5387,31 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/glob@7.2.0': + dependencies: + '@types/minimatch': 6.0.0 + '@types/node': 24.12.2 + '@types/har-format@1.2.16': {} '@types/json-schema@7.0.15': {} + '@types/minimatch@6.0.0': + dependencies: + minimatch: 10.2.5 + '@types/node@24.12.2': dependencies: undici-types: 7.16.0 + '@types/parse-json@4.0.2': {} + + '@types/parse5@5.0.3': {} + '@types/retry@0.12.2': {} + '@types/semver@7.7.1': {} + '@types/supercluster@7.1.3': dependencies: '@types/geojson': 7946.0.16 @@ -5385,6 +5862,8 @@ snapshots: aria-query@5.3.1: {} + array-back@3.1.0: {} + array-back@6.2.3: {} assertion-error@2.0.1: {} @@ -5431,6 +5910,11 @@ snapshots: boolbase@1.0.0: {} + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 @@ -5455,8 +5939,15 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} charenc@0.0.2: {} @@ -5497,6 +5988,13 @@ snapshots: dependencies: delayed-stream: 1.0.0 + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + command-line-args@6.0.2: dependencies: array-back: 6.2.3 @@ -5508,6 +6006,8 @@ snapshots: complex.js@2.4.3: {} + concat-map@0.0.1: {} + confbox@0.1.8: {} confbox@0.2.4: {} @@ -5523,6 +6023,14 @@ snapshots: dependencies: is-what: 5.5.0 + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + create-require@1.1.1: {} crelt@1.0.6: {} @@ -5544,6 +6052,8 @@ snapshots: crypt@0.0.2: {} + css-selector-parser@1.4.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -5610,6 +6120,10 @@ snapshots: entities@7.0.1: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5819,6 +6333,10 @@ snapshots: dependencies: find-file-up: 2.0.1 + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + find-replace@5.0.2: {} find-up@5.0.0: @@ -5870,6 +6388,8 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -5898,6 +6418,16 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + gettext-extractor@3.8.0: + dependencies: + '@types/glob': 7.2.0 + '@types/parse5': 5.0.3 + css-selector-parser: 1.4.1 + glob: 7.2.3 + parse5: 6.0.1 + pofile: 1.0.11 + typescript: 5.9.3 + gl-matrix@3.4.4: {} glob-parent@6.0.2: @@ -5922,6 +6452,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + global-modules@1.0.0: dependencies: global-prefix: 1.0.2 @@ -5982,10 +6521,24 @@ snapshots: immutable@5.1.5: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + ini@1.3.8: {} + is-arrayish@0.2.1: {} + is-buffer@1.1.6: {} is-core-module@2.16.1: @@ -6018,6 +6571,8 @@ snapshots: dependencies: ws: 8.18.0 + isomorphic.js@0.2.5: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -6046,12 +6601,16 @@ snapshots: js-cookie@3.0.5: {} + js-tokens@4.0.0: {} + jsep@1.4.0: {} jsesc@3.1.0: {} json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -6093,6 +6652,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.32.0: optional: true @@ -6144,6 +6707,8 @@ snapshots: lilconfig@3.1.3: {} + lines-and-columns@1.2.4: {} + linkifyjs@4.3.2: {} local-pkg@1.1.2: @@ -6270,6 +6835,10 @@ snapshots: dependencies: brace-expansion: 5.0.5 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.0 @@ -6342,6 +6911,10 @@ snapshots: dependencies: jwt-decode: 4.0.0 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6385,8 +6958,25 @@ snapshots: pako@2.1.0: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse-passwd@1.0.0: {} + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@6.0.1: {} + password-sheriff@2.0.0: {} path-browserify@1.0.1: {} @@ -6395,6 +6985,8 @@ snapshots: path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -6411,6 +7003,8 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 + path-type@4.0.0: {} + pathe@2.0.3: {} pbf@4.0.1: @@ -6456,6 +7050,8 @@ snapshots: dependencies: fflate: 0.8.2 + pofile@1.0.11: {} + pofile@1.1.4: {} postcss-selector-parser@7.1.1: @@ -6586,6 +7182,8 @@ snapshots: expand-tilde: 2.0.2 global-modules: 1.0.0 + resolve-from@4.0.0: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.1 @@ -6815,6 +7413,10 @@ snapshots: dependencies: copy-anything: 4.0.5 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -6924,8 +7526,12 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.9.3: {} + typescript@6.0.3: {} + typical@4.0.0: {} + typical@7.3.0: {} ufo@1.6.3: {} @@ -7106,6 +7712,20 @@ snapshots: '@vue/language-core': 3.2.7 typescript: 6.0.3 + vue3-gettext@2.4.0(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@vue/compiler-sfc': 3.5.33 + chalk: 4.1.2 + command-line-args: 5.2.1 + cosmiconfig: 7.1.0 + gettext-extractor: 3.8.0 + glob: 7.2.3 + parse5: 6.0.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + pofile: 1.1.4 + tslib: 2.8.1 + vue: 3.5.33(typescript@6.0.3) + vue3-gettext@4.0.0-beta.1(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)): dependencies: '@vue/compiler-sfc': 3.5.33 @@ -7182,14 +7802,34 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + ws@8.18.0: {} ws@8.20.0: {} xml-name-validator@4.0.0: {} + y-codemirror.next@0.3.5(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(yjs@13.6.30): + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + lib0: 0.2.117 + yjs: 13.6.30 + + y-protocols@1.0.7(yjs@13.6.30): + dependencies: + lib0: 0.2.117 + yjs: 13.6.30 + + yaml@1.10.3: {} + yaml@2.8.3: {} + yjs@13.6.30: + dependencies: + lib0: 0.2.117 + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/support/filesForUpload/empty-note.md b/support/filesForUpload/empty-note.md new file mode 100644 index 00000000..e69de29b diff --git a/support/filesForUpload/note-alpha.md b/support/filesForUpload/note-alpha.md new file mode 100644 index 00000000..4e31fe47 --- /dev/null +++ b/support/filesForUpload/note-alpha.md @@ -0,0 +1,6 @@ +# Note Alpha + +Initial content for file ALPHA. Lines below help spot mixing. +ALPHA-1 +ALPHA-2 +ALPHA-3 diff --git a/support/filesForUpload/note-beta.md b/support/filesForUpload/note-beta.md new file mode 100644 index 00000000..0f1af02b --- /dev/null +++ b/support/filesForUpload/note-beta.md @@ -0,0 +1,6 @@ +# Note Beta + +Initial content for file BETA. Lines below help spot mixing. +BETA-1 +BETA-2 +BETA-3 diff --git a/support/filesForUpload/rich-note.md b/support/filesForUpload/rich-note.md new file mode 100644 index 00000000..56a49544 --- /dev/null +++ b/support/filesForUpload/rich-note.md @@ -0,0 +1,14 @@ +# Rich Note + +A paragraph with **bold** and *italic* and `inline code`. + +## Section Two + +- bullet one +- bullet two +- bullet three + +1. ordered alpha +2. ordered beta + +> A blockquote that survives the round-trip. diff --git a/support/helpers/api/spaceHelper.ts b/support/helpers/api/spaceHelper.ts new file mode 100644 index 00000000..b1eda240 --- /dev/null +++ b/support/helpers/api/spaceHelper.ts @@ -0,0 +1,226 @@ +// Minimal Project Space helpers for the web-extensions e2e suite. +// +// The canonical reference for these patterns lives in the `opencloud-eu/web` +// repo (separate monorepo, not importable here): +// - tests/e2e/support/api/graph/spaces.ts — createSpace, getSpaceIdBySpaceName +// - tests/e2e/support/api/share/share.ts — full role-UUID table + share API +// - tests/e2e/support/api/davSpaces/spaces.ts — file ops within a space +// +// We deliberately re-implement a narrow subset rather than depending on web's +// Cucumber-coupled helpers (UsersEnvironment singletons, custom request +// wrappers). When extension-sdk eventually exposes shared e2e helpers, this +// file should be deleted in favour of those. +import { request } from '@playwright/test' +import config from '../../../playwright.config' +import { getAdminToken } from './getToken' + +const baseUrl = config.use.baseURL +const adminUsername = process.env.ADMIN_USERNAME ?? 'admin' +const adminPassword = process.env.ADMIN_PASSWORD ?? 'admin' + +// OC unified-role UUID for "Space Editor" — write access to all files in a +// Project Space. Verified against web/tests/e2e/support/api/share/share.ts +// `getPermissionsRoleIdByName('space editor')` and OC source +// `services/graph/pkg/unifiedrole/roles.go`. Other role UUIDs from there: +// space viewer a8d5fe5e-96e3-418d-825b-534dbdf22b99 +// space editor 58c63c02-1d89-4572-916a-870abc5a1b7d (used here) +// manager 312c0871-5ef7-4b3a-85b6-0e4074c64049 +// viewer b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5 (single file/folder) +// editor fb6c3e19-e378-47e5-b277-9732f9de6e21 (single file/folder) +// file editor 2d00ce52-1fc2-4dbc-8b95-a73b73395f5a (single file write) +const SPACE_EDITOR_ROLE_ID = '58c63c02-1d89-4572-916a-870abc5a1b7d' + +export interface ProjectSpace { + id: string + name: string + webDavUrl: string + rootItemId: string +} + +export async function createProjectSpace(name: string): Promise { + const adminToken = await getAdminToken(adminUsername, adminPassword) + const ctx = await request.newContext() + + const res = await ctx.post(`${baseUrl}/graph/v1.0/drives`, { + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'application/json' + }, + data: { name, driveType: 'project' } + }) + if (!res.ok()) { + throw new Error(`createProjectSpace failed: ${res.status()} - ${await res.text()}`) + } + const drive = await res.json() + return { + id: drive.id, + name: drive.name, + webDavUrl: drive.root.webDavUrl, + rootItemId: drive.root.id + } +} + +export async function inviteUserToSpace( + spaceId: string, + userId: string, + roleId: string = SPACE_EDITOR_ROLE_ID +) { + const adminToken = await getAdminToken(adminUsername, adminPassword) + const ctx = await request.newContext() + + // Endpoint and payload shape per opencloud-eu/libre-graph-api v1beta1. + // The recipient-type discriminator is a libregraph annotation key with + // dots (not dashes): `@libre.graph.recipient.type`. Valid values: "user", + // "group". User identifier field is `objectId` (UUID from /graph/v1.0/users). + const res = await ctx.post(`${baseUrl}/graph/v1beta1/drives/${spaceId}/root/invite`, { + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'application/json' + }, + data: { + recipients: [{ '@libre.graph.recipient.type': 'user', objectId: userId }], + roles: [roleId] + } + }) + if (!res.ok()) { + throw new Error( + `inviteUserToSpace(${userId} → ${spaceId}) failed: ${res.status()} - ${await res.text()}` + ) + } +} + +// File Editor unified-role-id (write access to a single item). See the +// SPACE_EDITOR_ROLE_ID comment near the top of this file for the canonical +// table. +const FILE_EDITOR_ROLE_ID = '2d00ce52-1fc2-4dbc-8b95-a73b73395f5a' + +export interface AdminFile { + driveId: string + itemId: string +} + +export async function uploadFileAsAdmin( + filename: string, + body: string | Buffer +): Promise { + const adminToken = await getAdminToken(adminUsername, adminPassword) + const ctx = await request.newContext() + + // Discover the admin's personal drive. + const drivesRes = await ctx.get(`${baseUrl}/graph/v1.0/me/drives`, { + headers: { Authorization: `Bearer ${adminToken}` } + }) + if (!drivesRes.ok()) { + throw new Error(`uploadFileAsAdmin.drives failed: ${drivesRes.status()}`) + } + const { value: drives } = (await drivesRes.json()) as { + value: Array<{ id: string; driveType: string; root: { id: string; webDavUrl: string } }> + } + const personal = drives.find((d) => d.driveType === 'personal') + if (!personal) throw new Error('admin has no personal drive') + + // PUT the file content via WebDAV — webDavUrl lives under `.root`. + const putUrl = `${personal.root.webDavUrl.replace(/\/$/, '')}/${encodeURIComponent(filename)}` + const putRes = await ctx.put(putUrl, { + headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'text/plain' }, + data: body + }) + if (!putRes.ok()) { + throw new Error(`uploadFileAsAdmin.put failed: ${putRes.status()} - ${await putRes.text()}`) + } + + // Resolve the file's item id by listing root children. + const childrenRes = await ctx.get( + `${baseUrl}/graph/v1.0/drives/${personal.id}/items/${personal.root.id}/children`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ) + const { value: children } = (await childrenRes.json()) as { + value: Array<{ id: string; name: string }> + } + const item = children.find((c) => c.name === filename) + if (!item) throw new Error(`uploadFileAsAdmin: ${filename} not in root`) + return { driveId: personal.id, itemId: item.id } +} + +// Fetches a file's current content via WebDAV using admin credentials. Used +// in tests that need to assert the server-side state after a collab session +// has written back. +export async function fetchFileAsAdmin(file: AdminFile): Promise { + const adminToken = await getAdminToken(adminUsername, adminPassword) + const ctx = await request.newContext() + const url = `${baseUrl}/remote.php/dav/spaces/${encodeURIComponent(file.itemId)}` + const res = await ctx.get(url, { headers: { Authorization: `Bearer ${adminToken}` } }) + if (!res.ok()) { + throw new Error(`fetchFileAsAdmin GET failed: ${res.status()} - ${await res.text()}`) + } + return res.text() +} + +export async function inviteUserToFile( + file: AdminFile, + recipientId: string, + roleId: string = FILE_EDITOR_ROLE_ID +) { + const adminToken = await getAdminToken(adminUsername, adminPassword) + const ctx = await request.newContext() + const res = await ctx.post( + `${baseUrl}/graph/v1beta1/drives/${file.driveId}/items/${file.itemId}/invite`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'application/json' + }, + data: { + recipients: [{ '@libre.graph.recipient.type': 'user', objectId: recipientId }], + roles: [roleId] + } + } + ) + if (!res.ok()) { + throw new Error(`inviteUserToFile failed: ${res.status()} - ${await res.text()}`) + } +} + +export async function uploadFileToSpace( + space: ProjectSpace, + filename: string, + body: string | Buffer +): Promise<{ fileId: string }> { + const adminToken = await getAdminToken(adminUsername, adminPassword) + const ctx = await request.newContext() + + const url = `${space.webDavUrl.replace(/\/$/, '')}/${encodeURIComponent(filename)}` + const putRes = await ctx.put(url, { + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'text/plain' + }, + data: body + }) + if (!putRes.ok()) { + throw new Error(`uploadFileToSpace.put failed: ${putRes.status()} - ${await putRes.text()}`) + } + + // Look up the file's libregraph item id (= the fileId we use as the + // Hocuspocus document name on the client side). List children of root + // and match by name — OC doesn't expose a path-based item GET. + const childrenRes = await ctx.get( + `${baseUrl}/graph/v1.0/drives/${space.id}/items/${space.rootItemId}/children`, + { + headers: { Authorization: `Bearer ${adminToken}` } + } + ) + if (!childrenRes.ok()) { + throw new Error( + `uploadFileToSpace.children failed: ${childrenRes.status()} - ${await childrenRes.text()}` + ) + } + const { value: children } = (await childrenRes.json()) as { value: Array<{ id: string; name: string }> } + const item = children.find((c) => c.name === filename) + if (!item) { + throw new Error( + `uploadFileToSpace: child "${filename}" not found among [${children.map((c) => c.name).join(', ')}]` + ) + } + return { fileId: item.id } +} diff --git a/support/helpers/sessionCache.ts b/support/helpers/sessionCache.ts new file mode 100644 index 00000000..366338dd --- /dev/null +++ b/support/helpers/sessionCache.ts @@ -0,0 +1,66 @@ +// Playwright-standard session reuse. The first test that asks for a logged-in +// `page` for a given user pays the full UI-login cost once and writes the +// resulting cookies + localStorage to a temp file. Every subsequent caller +// gets a fresh browser context bootstrapped from that file — no UI flow. +// +// Saves ~3-4s per test for users that already logged in this run. + +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdirSync, existsSync } from 'node:fs' +import type { Browser, Page } from '@playwright/test' +import { LoginPage } from '../pages/loginPage' + +const STATE_DIR = join(tmpdir(), 'oc-e2e-states') +if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true }) + +const inflight = new Map>() + +function stateFileFor(username: string) { + return join(STATE_DIR, `${username}.json`) +} + +async function captureState( + browser: Browser, + username: string, + password: string +): Promise { + const file = stateFileFor(username) + const ctx = await browser.newContext({ ignoreHTTPSErrors: true }) + const page = await ctx.newPage() + await page.goto('/') + await Promise.all([ + page.waitForResponse( + (r) => + r.url().endsWith('logon') && r.status() === 200 && r.request().method() === 'POST' + ), + new LoginPage(page).login(username, password) + ]) + await ctx.storageState({ path: file }) + await ctx.close() + return file +} + +// Returns a fresh Page already logged in as the given user. Internally caches +// the storage state on disk so repeat calls within the same test run skip +// the UI login altogether. +export async function loginCached( + browser: Browser, + username: string, + password: string +): Promise<{ page: Page }> { + let p = inflight.get(username) + if (!p) { + p = captureState(browser, username, password) + inflight.set(username, p) + } + const stateFile = await p + const ctx = await browser.newContext({ storageState: stateFile, ignoreHTTPSErrors: true }) + const page = await ctx.newPage() + await page.goto('/') + return { page } +} + +export async function disposeSession(page: Page) { + await page.context().close() +}