From 449f08fd04fccf176c2bdbb79cb6d4c2147e9100 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 18 May 2026 13:28:06 +0200 Subject: [PATCH 1/5] feat: realtime collaboration PoC (codemirror + tiptap) Yjs/Hocuspocus-based collaborative editing for OpenCloud as a generic wrapper, validated with two apps over markdown (CodeMirror, Tiptap). - Hocuspocus sidecar: token validation via Graph /me, effective ACL via Graph /permissions allowedValues (same Reva PermissionSet that backs WebDAV oc:permissions, so owner/share/space-role are merged). Anti-spoof identity stamp on awareness updates. - CollaborativeWrapper: provider lifecycle, empty-user awareness bootstrap, etag save loop without soft-lock (412 -> HEAD probe -> retry), hydration election by lowest awareness clientId. - Adapter contract decouples wrapper from editor: hasContent, hydrate, serialize. CodeMirror and Tiptap apps each provide one small adapter against the same wrapper. - E2E Playwright suites per app, integration suite for two-peer Yjs sync via the sidecar. --- dev/docker/hocuspocus/Dockerfile | 19 + dev/docker/hocuspocus/package.json | 13 + .../patches/hocuspocus-server-4.0.0.patch | 234 +++ dev/docker/hocuspocus/server.js | 247 +++ docker-compose.yml | 31 + packages/web-app-codemirror/extension.d.ts | 1 + .../web-app-codemirror/l10n/translations.json | 1 + packages/web-app-codemirror/package.json | 32 + packages/web-app-codemirror/src/App.vue | 27 + .../src/CodeMirrorEditor.vue | 55 + .../src/CollaborativeWrapper.vue | 435 ++++ .../src/adapters/codemirrorMarkdown.ts | 31 + packages/web-app-codemirror/src/index.ts | 44 + packages/web-app-codemirror/src/types.ts | 43 + .../tests/e2e/codemirror.spec.ts | 185 ++ .../tests/e2e/save-back.spec.ts | 52 + .../tests/e2e/shared-file.spec.ts | 78 + .../tests/integration/realtime-sync.spec.ts | 303 +++ packages/web-app-codemirror/tsconfig.json | 3 + packages/web-app-codemirror/vite.config.ts | 8 + packages/web-app-codemirror/vitest.config.ts | 9 + packages/web-app-tiptap/extension.d.ts | 1 + .../web-app-tiptap/l10n/translations.json | 1 + packages/web-app-tiptap/package.json | 30 + packages/web-app-tiptap/src/App.vue | 30 + packages/web-app-tiptap/src/TiptapEditor.vue | 178 ++ .../src/adapters/tiptapMarkdown.ts | 72 + packages/web-app-tiptap/src/index.ts | 44 + .../tests/e2e/empty-file.spec.ts | 53 + .../web-app-tiptap/tests/e2e/tiptap.spec.ts | 142 ++ packages/web-app-tiptap/tsconfig.json | 3 + packages/web-app-tiptap/vite.config.ts | 8 + playwright.config.ts | 10 + pnpm-lock.yaml | 1839 ++++++++++++++++- support/filesForUpload/empty-note.md | 0 support/filesForUpload/note-alpha.md | 6 + support/filesForUpload/note-beta.md | 6 + support/filesForUpload/rich-note.md | 14 + support/helpers/api/spaceHelper.ts | 226 ++ support/helpers/sessionCache.ts | 66 + 40 files changed, 4579 insertions(+), 1 deletion(-) create mode 100644 dev/docker/hocuspocus/Dockerfile create mode 100644 dev/docker/hocuspocus/package.json create mode 100644 dev/docker/hocuspocus/patches/hocuspocus-server-4.0.0.patch create mode 100644 dev/docker/hocuspocus/server.js create mode 100644 packages/web-app-codemirror/extension.d.ts create mode 100644 packages/web-app-codemirror/l10n/translations.json create mode 100644 packages/web-app-codemirror/package.json create mode 100644 packages/web-app-codemirror/src/App.vue create mode 100644 packages/web-app-codemirror/src/CodeMirrorEditor.vue create mode 100644 packages/web-app-codemirror/src/CollaborativeWrapper.vue create mode 100644 packages/web-app-codemirror/src/adapters/codemirrorMarkdown.ts create mode 100644 packages/web-app-codemirror/src/index.ts create mode 100644 packages/web-app-codemirror/src/types.ts create mode 100644 packages/web-app-codemirror/tests/e2e/codemirror.spec.ts create mode 100644 packages/web-app-codemirror/tests/e2e/save-back.spec.ts create mode 100644 packages/web-app-codemirror/tests/e2e/shared-file.spec.ts create mode 100644 packages/web-app-codemirror/tests/integration/realtime-sync.spec.ts create mode 100644 packages/web-app-codemirror/tsconfig.json create mode 100644 packages/web-app-codemirror/vite.config.ts create mode 100644 packages/web-app-codemirror/vitest.config.ts create mode 100644 packages/web-app-tiptap/extension.d.ts create mode 100644 packages/web-app-tiptap/l10n/translations.json create mode 100644 packages/web-app-tiptap/package.json create mode 100644 packages/web-app-tiptap/src/App.vue create mode 100644 packages/web-app-tiptap/src/TiptapEditor.vue create mode 100644 packages/web-app-tiptap/src/adapters/tiptapMarkdown.ts create mode 100644 packages/web-app-tiptap/src/index.ts create mode 100644 packages/web-app-tiptap/tests/e2e/empty-file.spec.ts create mode 100644 packages/web-app-tiptap/tests/e2e/tiptap.spec.ts create mode 100644 packages/web-app-tiptap/tsconfig.json create mode 100644 packages/web-app-tiptap/vite.config.ts create mode 100644 support/filesForUpload/empty-note.md create mode 100644 support/filesForUpload/note-alpha.md create mode 100644 support/filesForUpload/note-beta.md create mode 100644 support/filesForUpload/rich-note.md create mode 100644 support/helpers/api/spaceHelper.ts create mode 100644 support/helpers/sessionCache.ts 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/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..3d7119de --- /dev/null +++ b/packages/web-app-codemirror/package.json @@ -0,0 +1,32 @@ +{ + "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", + "yjs": "^13.6.0" + }, + "devDependencies": { + "@opencloud-eu/web-client": "^3.0.0", + "@opencloud-eu/web-pkg": "^3.0.0", + "@types/semver": "^7.7.0", + "@types/ws": "^8.5.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..9967ab8b --- /dev/null +++ b/packages/web-app-codemirror/src/App.vue @@ -0,0 +1,27 @@ + + + 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..418bf45c --- /dev/null +++ b/packages/web-app-codemirror/src/CollaborativeWrapper.vue @@ -0,0 +1,435 @@ + + + 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..05a4f4b1 --- /dev/null +++ b/packages/web-app-codemirror/tests/e2e/save-back.spec.ts @@ -0,0 +1,52 @@ +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, click the explicit +// Save button in the wrapper top bar, 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 → wrapper save loop → +// WebDAV PUT → OC backend. +test.describe('save-back to native file', () => { + test('typing then clicking Save 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) + + // The save button enables once the doc is dirty. + const saveButton = page.getByTestId('collab-save') + await expect(saveButton).toBeEnabled({ timeout: 5_000 }) + await saveButton.click() + // After a successful save the button disables again because + // `hasUnsavedChanges` resets. + await expect(saveButton).toBeDisabled({ timeout: 5_000 }) + + // Read the file straight from OC via WebDAV and assert the marker + // landed on disk, not just in the live Y.Doc. + 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/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..48875296 --- /dev/null +++ b/packages/web-app-codemirror/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.spec.ts'], + 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..e76a260e --- /dev/null +++ b/packages/web-app-tiptap/package.json @@ -0,0 +1,30 @@ +{ + "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": "^2.10.0", + "@tiptap/extension-collaboration": "^2.10.0", + "@tiptap/extension-collaboration-cursor": "^2.10.0", + "@tiptap/extension-placeholder": "^2.10.0", + "@tiptap/starter-kit": "^2.10.0", + "@tiptap/vue-3": "^2.10.0", + "tiptap-markdown": "^0.8.10", + "yjs": "^13.6.0" + }, + "devDependencies": { + "@opencloud-eu/web-client": "^3.0.0", + "@opencloud-eu/web-pkg": "^3.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..0bda5e31 --- /dev/null +++ b/packages/web-app-tiptap/src/App.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/web-app-tiptap/src/TiptapEditor.vue b/packages/web-app-tiptap/src/TiptapEditor.vue new file mode 100644 index 00000000..78527fe0 --- /dev/null +++ b/packages/web-app-tiptap/src/TiptapEditor.vue @@ -0,0 +1,178 @@ + + + + + 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..860a3191 --- /dev/null +++ b/packages/web-app-tiptap/src/adapters/tiptapMarkdown.ts @@ -0,0 +1,72 @@ +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.configure({ + html: true, + tightLists: true, + linkify: true, + breaks: false, + transformPastedText: true + }), + Collaboration.configure({ document: ydoc, field: FRAGMENT }) + ] + }) +} + +export const tiptapMarkdownAdapter: CollaborativeAdapter = { + hydrate(ydoc: Y.Doc, content: string) { + if (!content) return + const editor = makeHeadlessEditor(ydoc) + try { + // setContent goes through tiptap-markdown's input handler when content + // is a Markdown string. The Collaboration plugin propagates the + // resulting ProseMirror state into the bound Y.XmlFragment. + editor.commands.setContent(content, false) + } finally { + editor.destroy() + } + }, + + serialize(ydoc: Y.Doc): string { + const editor = makeHeadlessEditor(ydoc) + try { + // tiptap-markdown attaches `getMarkdown()` on editor.storage.markdown. + return editor.storage.markdown.getMarkdown() as string + } 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..d04f8dd3 --- /dev/null +++ b/packages/web-app-tiptap/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@opencloud-eu/extension-sdk' + +export default defineConfig({ + name: 'tiptap', + server: { + port: 9741 + } +}) 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..925689ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,52 @@ 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) + yjs: + specifier: ^13.6.0 + version: 13.6.30 + devDependencies: + '@opencloud-eu/web-client': + specifier: ^3.0.0 + version: 3.2.0 + '@opencloud-eu/web-pkg': + specifier: ^3.0.0 + version: 3.2.0(@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 + 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 +365,49 @@ 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: ^2.10.0 + version: 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-collaboration': + specifier: ^2.10.0 + version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(y-prosemirror@1.3.7(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)) + '@tiptap/extension-collaboration-cursor': + specifier: ^2.10.0 + version: 2.26.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(y-prosemirror@1.3.7(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)) + '@tiptap/extension-placeholder': + specifier: ^2.10.0 + version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/starter-kit': + specifier: ^2.10.0 + version: 2.27.2 + '@tiptap/vue-3': + specifier: ^2.10.0 + version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.33(typescript@6.0.3)) + tiptap-markdown: + specifier: ^0.8.10 + version: 0.8.10(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + yjs: + specifier: ^13.6.0 + version: 13.6.30 + devDependencies: + '@opencloud-eu/web-client': + specifier: ^3.0.0 + version: 3.2.0 + '@opencloud-eu/web-pkg': + specifier: ^3.0.0 + version: 3.2.0(@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 +432,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,12 +482,78 @@ packages: '@codemirror/commands@6.10.3': resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + '@codemirror/language@6.12.3': resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + '@codemirror/lint@6.9.5': resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} @@ -663,6 +822,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 +897,57 @@ packages: '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + '@lezer/cpp@1.1.5': + resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==} + + '@lezer/css@1.3.3': + resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + '@lezer/highlight@1.2.3': resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@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==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.18': + resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + + '@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'} @@ -832,6 +1042,9 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@opencloud-eu/design-system@3.2.0': + resolution: {integrity: sha512-Wl+Q1QgC867K0c5WnJNnUxAGDBJMKc/XoaZ+Ic+BuT+hvwKJ/6/B/ZZx6SUQNcVHIZm5up1QstHKFAX5ux5SSw==} + '@opencloud-eu/design-system@7.0.0': resolution: {integrity: sha512-1lAECh3IOnWCEntblnCsKVBzOhJE2Au2FMMkK0D3/8XUakrki2C+BWYgq+bXwTbplvl7dFTfqDNYN3SLRdbLZw==} peerDependencies: @@ -856,9 +1069,15 @@ packages: '@opencloud-eu/tsconfig@7.0.0': resolution: {integrity: sha512-T0UJmzm0J40hYYwqc/+589outZfeHMMvDEujzkKdCjCUS4U619uL6deCS7KhGnpuNtIPR/Q4lF2vUFxJn+rJ+A==} + '@opencloud-eu/web-client@3.2.0': + resolution: {integrity: sha512-hjesabA/NUMS6dJd4OxYxIlsz8LIIqpUKoAKaMyhszgsUC8y9klsf6Uc6Oo6ddOg2JR4znj6vL+AenEz3ALcOw==} + '@opencloud-eu/web-client@7.0.0': resolution: {integrity: sha512-GmZO2kBf849gSB+wwZxi6zzHb4U2VCcl9nFG0EwDLA/BfX06K5gem8bNAxU9AZtOL7Yi9f3LXjsW+g8D/qEMUw==} + '@opencloud-eu/web-pkg@3.2.0': + resolution: {integrity: sha512-AqFoMs+LJfKg+VmbrMPiitIeFW9578Bv1x1BqIsPu8mCGfwMfkj4txekSdAKtNYnhnnyZdZK+EOzP5Dvm8vzGA==} + '@opencloud-eu/web-pkg@7.0.0': resolution: {integrity: sha512-Sbbzjb2A45rCBxELSksWsJW5eDOrO3C8gWNMszSENDl9Pe88v7bjl+A1uxxkv+L1ckoHdJcuuVGEem/Wlwn8+g==} @@ -974,10 +1193,16 @@ packages: engines: {node: '>=18'} hasBin: true + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@protomaps/basemaps@5.7.2': resolution: {integrity: sha512-K1Yk6bWdULulYg+R2QRVXx4NzJZan5YQhpejEG0c1/sXruJrfPIPZuakpf3jwAgVmjIRVQwAv+yRafDeN0aaUQ==} hasBin: true + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@replit/codemirror-indentation-markers@6.5.3': resolution: {integrity: sha512-hL5Sfvw3C1vgg7GolLe/uxX5T3tmgOA3ZzqlMv47zjU1ON51pzNWiVbS22oh6crYhtVhv8b3gdXwoYp++2ilHw==} peerDependencies: @@ -1099,26 +1324,50 @@ packages: resolution: {integrity: sha512-n0QRx0Ysx6mPfIydTkz7VP0FmwM+/EqMZiRqdsU3aTYsngE9GmEDV0OL1bAy6a8N/C1xf9vntkuAtj6N/8Z51w==} engines: {node: '>=18'} + '@sentry-internal/browser-utils@9.47.1': + resolution: {integrity: sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==} + engines: {node: '>=18'} + '@sentry-internal/feedback@10.49.0': resolution: {integrity: sha512-JNsUBGv0faCFE7MeZUH99Y9lU9qq3LBALbLxpE1x7ngNrQnVYRlcFgdqaD/btNBKr8awjYL8gmcSkHBWskGqLQ==} engines: {node: '>=18'} + '@sentry-internal/feedback@9.47.1': + resolution: {integrity: sha512-xJ4vKvIpAT8e+Sz80YrsNinPU0XV7jPxPjdZ4ex8R2mMvx7pM0gq8JiR/sIVmNiOE0WiUDr6VwLDE8j2APSRMA==} + engines: {node: '>=18'} + '@sentry-internal/replay-canvas@10.49.0': resolution: {integrity: sha512-7D/NrgH1Qwx5trDYaaTSSJmCb1yVQQLqFG4G/S9x2ltzl9876lSGJL8UeW8ReNQgF3CDAcwbmm/9aXaVSBUNZA==} engines: {node: '>=18'} + '@sentry-internal/replay-canvas@9.47.1': + resolution: {integrity: sha512-r9nve+l5+elGB9NXSN1+PUgJy790tXN1e8lZNH2ziveoU91jW4yYYt34mHZ30fU9tOz58OpaRMj3H3GJ/jYZVA==} + engines: {node: '>=18'} + '@sentry-internal/replay@10.49.0': resolution: {integrity: sha512-IEy4lwHVMiRE3JAcn+kFKjsTgalDOCSTf20SoFd+nkt6rN/k1RDyr4xpdfF//Kj3UdeTmbuibYjK5H/FLhhnGg==} engines: {node: '>=18'} + '@sentry-internal/replay@9.47.1': + resolution: {integrity: sha512-O9ZEfySpstGtX1f73m3NbdbS2utwPikaFt6sgp74RG4ZX4LlXe99VAjKR464xKECpYsLmj2bYpiK4opURF0pBA==} + engines: {node: '>=18'} + '@sentry/browser@10.49.0': resolution: {integrity: sha512-bGCHc+wK2Dx67YoSbmtlt04alqWfQ+dasD/GVipVOq50gvw/BBIDHTEWRJEjACl+LrvszeY54V+24p8z4IgysA==} engines: {node: '>=18'} + '@sentry/browser@9.47.1': + resolution: {integrity: sha512-at5JOLziw5QpVYytxTDU6xijdV6lDQ/Rxp/qXJaHXud3gIK4suv2cXW+tupJfwoUoHFCnDNfccjCmPmP0yRqiA==} + engines: {node: '>=18'} + '@sentry/core@10.49.0': resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==} engines: {node: '>=18'} + '@sentry/core@9.47.1': + resolution: {integrity: sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==} + engines: {node: '>=18'} + '@sentry/vue@10.49.0': resolution: {integrity: sha512-xxJ3Phh1Rgb3iIrWBJC4qepUVZL2XH+2eCpXWBAd8tvGSIWGSdP8RpwIj22pKsgDO/m8e1eoD43KwVWUX3AH5g==} engines: {node: '>=18'} @@ -1132,6 +1381,16 @@ packages: pinia: optional: true + '@sentry/vue@9.47.1': + resolution: {integrity: sha512-ZDBf7tAH9C2b46UMtdJWcGyYRc+XwUpt3gRwe2lgEVyVNE7rXFH38zkgZXr+5N/RQU/TdYmV55mf+IXsrNnosw==} + engines: {node: '>=18'} + peerDependencies: + pinia: 2.x || 3.x + vue: 2.x || 3.x + peerDependenciesMeta: + pinia: + optional: true + '@sphinxxxx/color-conversion@2.2.2': resolution: {integrity: sha512-XExJS3cLqgrmNBIP3bBw6+1oQ1ksGjFh0+oClDKFYpCCqx/hlqwWO5KO/S63fzUo67SxI9dMrF0y5T/Ey7h8Zw==} @@ -1237,53 +1496,120 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tiptap/core@2.27.2': + resolution: {integrity: sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==} + peerDependencies: + '@tiptap/pm': ^2.7.0 + '@tiptap/core@3.23.1': resolution: {integrity: sha512-8YvSGiJTeU5wPuGiYIIYgyiyaaT1CAx+kJL0bju0w871OvbJJj0T/ywhcmxGXW6pOal2T8X2xt9ZqE+vib0VJw==} peerDependencies: '@tiptap/pm': 3.23.1 + '@tiptap/extension-blockquote@2.27.2': + resolution: {integrity: sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-blockquote@3.23.1': resolution: {integrity: sha512-FdVZLZOkL06j3WLXOC2UeX7++Cj3qI2vfohruMJiz4vk1Q5UUH7G4+AykFzjzBJHrdEpkiRUkRpU1KZIWdbluw==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-bold@2.27.2': + resolution: {integrity: sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-bold@3.23.1': resolution: {integrity: sha512-EAYdNzyOjlQh2VBY1EhdxtiTjVMaOAD6P0ezms60dKRjd4oj/8grfXfUqwgo4NVdFb11Ks85vXoHuXJSylfR4A==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-bubble-menu@2.27.2': + resolution: {integrity: sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-bubble-menu@3.23.1': resolution: {integrity: sha512-1advMCpPkHD/3ucZhYmNau8B4tF0L6iRAFhUOglp5bBZDuq13+rYujh3cm4vFmjH9KqThzpcUDn+ZU2c+mTMyw==} peerDependencies: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/extension-bullet-list@2.27.2': + resolution: {integrity: sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-bullet-list@3.23.1': resolution: {integrity: sha512-owWnBBI4t+jqVDY0naDjhsAmrNGldh4czouef2K+mEf032B7uGsDVCwKp1qaX1JZesyYDfvXOaIwT22hNID2mw==} peerDependencies: '@tiptap/extension-list': 3.23.1 + '@tiptap/extension-code-block@2.27.2': + resolution: {integrity: sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-code-block@3.23.1': resolution: {integrity: sha512-BdJGqM57CsKgYrQUZz78vIG8Yn7EpsE2pA7iKn5tYoSXpYtt0IaU4qB1heH7lwWD/vVCAm0YQVD7/0F+0++yhA==} peerDependencies: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/extension-code@2.27.2': + resolution: {integrity: sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-code@3.23.1': resolution: {integrity: sha512-nGuhb4YghgTfkejwWHrD9GSpwcC5kkVmm2sN/UY4yceDw+PkyysYKJWZehRLTOC8GNgSAhq/EeQeq14Xwk6dyg==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-collaboration-cursor@2.26.2': + resolution: {integrity: sha512-FdRb27mZ5Kr18hN6cbfBj1e9F0DOoHB1Gv3IYeic+g4h1C9BjDVMN0+JRBQc+4lamNA8TsHO0oKWRwaPe4sSlA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + y-prosemirror: ^1.2.11 + + '@tiptap/extension-collaboration@2.27.2': + resolution: {integrity: sha512-Y61ItHxQ1uc/Ir27mBQRI/wY9JkOui194V+awNv+1YHeaKArTjC2cdSvNzj9+h8JIh5MyfvslSf8hBa7t7PzAg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + y-prosemirror: ^1.2.11 + + '@tiptap/extension-document@2.27.2': + resolution: {integrity: sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-document@3.23.1': resolution: {integrity: sha512-NA5Rx59HRwG6Hb6LwLpC5lE7z6vCj6f90S7RNNsnE+CyiXNR/OhY2BcjuxiGnascHvsnsAbvxGU3ymKMDgvDVg==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-dropcursor@2.27.2': + resolution: {integrity: sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-dropcursor@3.23.1': resolution: {integrity: sha512-WRN7e/h9m3uI5j9/+L6jcPhHbTL6aKxfFfQWZHNf5M8TqSL1P+/2h034td0XMj3n48i4fWyzjVUV9+sz6t2fDw==} peerDependencies: '@tiptap/extensions': 3.23.1 + '@tiptap/extension-floating-menu@2.27.2': + resolution: {integrity: sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-floating-menu@3.23.1': resolution: {integrity: sha512-XrYHpLn1DpLFSGTko9F9xgbNamL6fGpWkK4wqgwPVbg/SJwQCDO/9p5D3DtJTwD+xgw4sQ9as4O6rt6jx8JT+Q==} peerDependencies: @@ -1291,21 +1617,49 @@ packages: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/extension-gapcursor@2.27.2': + resolution: {integrity: sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-gapcursor@3.23.1': resolution: {integrity: sha512-E4hB0xquUpEXy7kboLBazrFyRCsN0j0fsTFR8udgQf5xetAVPhOexSTKuzOcU/n0kxsKJin7laYYEag/Fd2KNw==} peerDependencies: '@tiptap/extensions': 3.23.1 + '@tiptap/extension-hard-break@2.27.2': + resolution: {integrity: sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-hard-break@3.23.1': resolution: {integrity: sha512-XYkCKC5RVqMmmBk+nd22/6IDDx1OC54sdStH5VEHtfOrarriO0JztK8Mr0TijPPk9N4rKXsmndYZM2xyWZZytQ==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-heading@2.27.2': + resolution: {integrity: sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-heading@3.23.1': resolution: {integrity: sha512-1z9yCSp8fevgX3r/4kWXO3of0WFCQWfYjWfHANvoJ4JQTYBkARjXlj1tbk5rrAJBFDDfKRkUpZOurXKgGo+h+g==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-history@2.27.2': + resolution: {integrity: sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-horizontal-rule@2.27.2': + resolution: {integrity: sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-horizontal-rule@3.23.1': resolution: {integrity: sha512-30XUHXdEZxcz1FCWjz9HW2EEq06NQcAye6rXGnvHo6Y60iJ6MRsrX5byvceFNF9DTVtOIcUFBQ/psIiRcoi0KA==} peerDependencies: @@ -1317,6 +1671,11 @@ packages: peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-italic@2.27.2': + resolution: {integrity: sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-italic@3.23.1': resolution: {integrity: sha512-lZB9YCjoVNDoPMguya66nBvaS/2YpGN5iAcjAGx/JQkCAZeOAtl9+ALMzbWPKH6tQP6m98YtkY1T7RXr++T0bA==} peerDependencies: @@ -1328,6 +1687,11 @@ packages: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/extension-list-item@2.27.2': + resolution: {integrity: sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-list-item@3.23.1': resolution: {integrity: sha512-Fk/884un5OSLCFxe2TbOmfp3sLMB5b76CnMjaSrvgfiaZnsV2WlJZGPXxCAPbxNIATTykNlSBsVuMBO7we64Vg==} peerDependencies: @@ -1344,16 +1708,37 @@ packages: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/extension-ordered-list@2.27.2': + resolution: {integrity: sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-ordered-list@3.23.1': resolution: {integrity: sha512-3GG7YFhVJWw/HWmRxvMMUC296x7TPBQRLsH4ryEC1SMAmVJnbTIvetyvIcLqLEXGW7Rj41S7SO8qjOXVceSOTA==} peerDependencies: '@tiptap/extension-list': 3.23.1 + '@tiptap/extension-paragraph@2.27.2': + resolution: {integrity: sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-paragraph@3.23.1': resolution: {integrity: sha512-GC7b6yAjASl1q9sNkPmukZmVYMfxx03EEhpMMrLYJY9GBz82Ald927yYQsOqf2aKA/Rjo/aZMYCGtjXkGk6aBA==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-placeholder@2.27.2': + resolution: {integrity: sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-strike@2.27.2': + resolution: {integrity: sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-strike@3.23.1': resolution: {integrity: sha512-+R5LG0ZW9SDZc4weA79uq6uUduVsCEph9tRcoQCRA82IVIiPYSTxTLew9odalmk/Mc7vdZvOK5jjtO5jUVw/rg==} peerDependencies: @@ -1375,11 +1760,21 @@ packages: peerDependencies: '@tiptap/extension-list': 3.23.1 + '@tiptap/extension-text-style@2.27.2': + resolution: {integrity: sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-text-style@3.23.1': resolution: {integrity: sha512-q3GQQo+lBhrtNkqdbhYWnv/byG/RYAxVnNhYPQMubRzavGdXBU8NhpJ/47YYjPimG1sahzcs2aqy7amVd8ri/A==} peerDependencies: '@tiptap/core': 3.23.1 + '@tiptap/extension-text@2.27.2': + resolution: {integrity: sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-text@3.23.1': resolution: {integrity: sha512-k1Ki9bBV6mLz1mFP+Laqh1YHJ2MY0P8XzaMqpkgMndEBIJQ3XcpWQc5bfAlRnYcOI9ZXDbAgQ8CwgArxHmQWCQ==} peerDependencies: @@ -1402,9 +1797,15 @@ packages: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/pm@2.27.2': + resolution: {integrity: sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==} + '@tiptap/pm@3.23.1': resolution: {integrity: sha512-8G+TkNsUHHAAJYREpA6fw+Dw/m2Y3Go4/QMQM8RYepid+wTeE1wSv7sBA/CBrphhYmJSWeTyCPtgQIxnTJXMCA==} + '@tiptap/starter-kit@2.27.2': + resolution: {integrity: sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==} + '@tiptap/starter-kit@3.23.1': resolution: {integrity: sha512-CURePHQagBaZIDJrHH3of4Nmi0VYGpZ6yBlkdFxFHBxY9aeG2/h5kn+oHo8GbzkSFsRV+9olzRgDTOULVgs8pQ==} @@ -1414,6 +1815,13 @@ packages: '@tiptap/core': 3.23.1 '@tiptap/pm': 3.23.1 + '@tiptap/vue-3@2.27.2': + resolution: {integrity: sha512-NahnVLTAQsbLaNU9nGLdGCr88nAeQZJTejjBVQc3EzMdijmE46R44Rosj6O/pj3e7eLj1/gYvc+U/hIVbxMpoQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + vue: ^3.0.0 + '@tiptap/vue-3@3.23.1': resolution: {integrity: sha512-wTNAQxHGVZpeLsDLTuPIBN/lj22/EoYiSiGYw9VW+V+7HCQ1LTDjbW4QenEJrpyOlS4DwF1lXWATciOvg0ihXQ==} peerDependencies: @@ -1470,18 +1878,52 @@ 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/linkify-it@3.0.5': + resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@13.0.9': + resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@1.0.5': + resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@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==} @@ -1568,11 +2010,19 @@ packages: '@ucast/mongo@2.4.3': resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==} + '@uppy/companion-client@4.5.2': + resolution: {integrity: sha512-hfUsReHM5COhn+5d7CdZgZaG8BtDvtwj7vjXzg8qmgKI901mYUm/Zh420iOKT7eHiofKVTNoa7oijeGrqUEnyg==} + peerDependencies: + '@uppy/core': ^4.5.2 + '@uppy/companion-client@5.1.1': resolution: {integrity: sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==} peerDependencies: '@uppy/core': ^5.1.1 + '@uppy/core@4.5.3': + resolution: {integrity: sha512-52VLeBUY/j904h48lpPGykuWikkOOS4Lz/qkmalDiBQfNALb6iB1MOZs079IM3o/uMLYxzZRL80C3sKpkBUYcw==} + '@uppy/core@5.2.0': resolution: {integrity: sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw==} @@ -1581,6 +2031,11 @@ packages: peerDependencies: '@uppy/core': ^5.2.0 + '@uppy/drop-target@3.2.2': + resolution: {integrity: sha512-Y6wPDqmRE5BaOqKOkEfhURtN4qzCGshRn9nBC7jWfsmEhtXvxW6s25GPcuNHMyQIrn659aKLdi28bW7gvQoobg==} + peerDependencies: + '@uppy/core': ^4.5.2 + '@uppy/google-drive@5.1.0': resolution: {integrity: sha512-avspyvCfQgpSvry8xXRIazUv9RhwyubSeDsg9BQaNR1BRFSw5PWVuoX7tIUyoDYtzA8TsxcYvqJuSm4HZYkQcA==} peerDependencies: @@ -1596,6 +2051,9 @@ packages: peerDependencies: '@uppy/core': ^5.2.0 + '@uppy/store-default@4.3.2': + resolution: {integrity: sha512-dnY9R2o8fwmO1bF89D0b5jijD7DGED2qVST5hI/j18JreLWzLKH7u6HuNmOvzok8msrQ/qWzQd5Gx4LDQKhBbw==} + '@uppy/store-default@5.0.0': resolution: {integrity: sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw==} @@ -1604,11 +2062,19 @@ packages: peerDependencies: '@uppy/core': ^5.2.0 + '@uppy/tus@4.3.2': + resolution: {integrity: sha512-W9pXC/Xew6mM+XKbGafJI9flO3oQTFHxpd281SIy+hDFVTniAqW4VoNhcT15rDqlofQB+PufCXG1EJlX9pCIAw==} + peerDependencies: + '@uppy/core': ^4.5.2 + '@uppy/tus@5.1.1': resolution: {integrity: sha512-316kLQfO5H/uUJIMhBYhBrTpeN0Q+d6ykW3pomCvdTkFGCvg20rF3oH/owE3lf2UZZN7ZqBk+wHO0WlQePoklg==} peerDependencies: '@uppy/core': ^5.2.0 + '@uppy/utils@6.2.2': + resolution: {integrity: sha512-9mYJtbcngv2HOJIECkyfmdXTI5dW/ObCyvWP1Iti3E5bKtsa4sMmbx5Yh/tGCj8k/lBNhfvWyZuYnvnjmzNLSQ==} + '@uppy/utils@7.2.0': resolution: {integrity: sha512-6lC246qszMv6bTyl/+QyHwrudgeguWkA94ME1wHn+a6uRAvmtAEaUManIfGqTJfoKvWAiCJqdJPl5xRJjhAloQ==} @@ -1617,11 +2083,25 @@ packages: peerDependencies: '@uppy/core': ^5.2.0 + '@uppy/xhr-upload@4.4.2': + resolution: {integrity: sha512-CU66aVn4yghGklEkepCqFPulc6uygznApy2DpD+jCMLNB5q6yT1RPSrQUgRgXsYhpW1YhutZJWsrEnHEDS+Tcw==} + peerDependencies: + '@uppy/core': ^4.5.2 + '@uppy/xhr-upload@5.2.0': resolution: {integrity: sha512-3LV/X5Of6BINnKplP+CwUJ0a4/7cRFfzxwGyXnW+uCrNQHoo09dttcz3begWHejGvzenQHuUnMO3Fxyc71Pryg==} peerDependencies: '@uppy/core': ^5.2.0 + '@vavt/cm-extension@1.11.2': + resolution: {integrity: sha512-fgjXxZ+HvJz/mBXd1Cpo8PWd/a1kMD3RysM/4O16uyXG+pleBUdUoYcuM5hl4GfFt8CZu5QJMji+Gbl9wL0bmw==} + + '@vavt/copy2clipboard@1.0.3': + resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==} + + '@vavt/util@2.1.2': + resolution: {integrity: sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw==} + '@vitejs/plugin-basic-ssl@2.3.0': resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1694,6 +2174,9 @@ packages: '@vue/compiler-ssr@3.5.33': resolution: {integrity: sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==} + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + '@vue/devtools-api@7.7.9': resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} @@ -1742,14 +2225,27 @@ packages: '@vue/server-renderer': optional: true + '@vueuse/core@13.9.0': + resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==} + peerDependencies: + vue: ^3.5.0 + '@vueuse/core@14.2.1': resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} peerDependencies: vue: ^3.5.0 + '@vueuse/metadata@13.9.0': + resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==} + '@vueuse/metadata@14.2.1': resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + '@vueuse/shared@13.9.0': + resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==} + peerDependencies: + vue: ^3.5.0 + '@vueuse/shared@14.2.1': resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==} peerDependencies: @@ -1813,10 +2309,17 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.1: 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 +2369,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 +2396,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} @@ -1923,6 +2437,9 @@ packages: '@codemirror/state': ^6.2.1 '@codemirror/view': ^6.17.1 + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1940,6 +2457,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'} @@ -1953,9 +2474,15 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + 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 +2499,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==} @@ -1982,6 +2513,9 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cropperjs@1.6.2: + resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==} + cropperjs@2.1.1: resolution: {integrity: sha512-FDJMarkY+/SepYarPZsvkG2LmI2PElecciMFnvBiBIoKnFYua/scprC5qejCLLyuX2jEqJRS2njbAsHxfjtIXA==} @@ -1992,11 +2526,17 @@ 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'} hasBin: true + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2079,6 +2619,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -2087,6 +2631,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'} @@ -2233,6 +2780,10 @@ packages: fast-xml-builder@1.1.5: resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-parser@4.5.6: + resolution: {integrity: sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==} + hasBin: true + fast-xml-parser@5.7.1: resolution: {integrity: sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==} hasBin: true @@ -2269,6 +2820,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 +2881,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 +2909,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 +2931,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 +2998,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 +3065,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 +3098,13 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-generate-password@1.0.0: + resolution: {integrity: sha512-CHeBLSaph7lwGDvovQXvV66/u3dWTM2hIOWMrnSJ1i/2J1Pm76EyUlV6W7KLwKZSMgQi4U5hcTkbbyLm+XwtXQ==} + hasBin: true + + 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 +3117,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 +3169,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 +3252,12 @@ 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==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} @@ -2711,6 +3315,12 @@ packages: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} + lucide-vue-next@0.453.0: + resolution: {integrity: sha512-5zmv83vxAs9SVoe22veDBi8Dw0Fh2F+oTngWgKnKOkrZVbZjceXLQ3tescV2boB0zlaf9R2Sd9RuUP2766xvsQ==} + deprecated: Package deprecated. Please use @lucide/vue instead. + peerDependencies: + vue: '>=3.0.1' + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -2732,6 +3342,25 @@ packages: mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-it-image-figures@2.1.1: + resolution: {integrity: sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g==} + engines: {node: '>=12.0.0'} + peerDependencies: + markdown-it: '*' + + markdown-it-sub@2.0.0: + resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==} + + markdown-it-sup@2.0.0: + resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==} + + markdown-it-task-lists@2.1.1: + resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} + + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + marked@17.0.6: resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} engines: {node: '>= 20'} @@ -2746,9 +3375,20 @@ packages: engines: {node: '>= 18'} hasBin: true + md-editor-v3@5.8.5: + resolution: {integrity: sha512-NsqAmmAx/ykA1AcwxcHH4Hkn4VAPkqMX7Hd6Lv4FcwQoMQ70wWmJfs/mokyPGkqr4oYqqn8LRMBTqFNfoP0O0A==} + peerDependencies: + vue: ^3.5.3 + md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + medium-zoom@1.1.0: + resolution: {integrity: sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} @@ -2767,6 +3407,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 +3496,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 +3540,27 @@ 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@1.3.1: + resolution: {integrity: sha512-sD3NLKdjGZHQn1JPPGUeWZsh0DPRHlfReW4Y7g8V20+pgU2/rp/4JJM+Gm53FlakKgDCotVU/CL0imIShTvYhA==} + password-sheriff@2.0.0: resolution: {integrity: sha512-wt/vYZVdrROLi6LWBBsau8lM0V24KTvtzN62Iunh+C6dV+5q8Jn1HccOBO6dmm8+4IuM7plSUyD2ZV6ykSIj6g==} @@ -2912,6 +3575,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 +3597,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,9 +3649,21 @@ 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==} + portal-vue@3.0.0: + resolution: {integrity: sha512-9eprMxNURLx6ijbcgkWjYNcTWJYu/H8QF8nyAeBzOmk9lKCea01BW1hYBeLkgz+AestmPOvznAEOFmNuO4Adjw==} + engines: {node: '>=14.19'} + peerDependencies: + vue: ^3.0.4 + peerDependenciesMeta: + vue: + optional: true + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -3004,12 +3687,19 @@ packages: engines: {node: '>=14'} hasBin: true + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} prosemirror-changeset@2.4.1: resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} @@ -3022,12 +3712,24 @@ packages: prosemirror-history@1.5.0: resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.3.2: + resolution: {integrity: sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==} + prosemirror-model@1.25.4: resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -3037,6 +3739,13 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} @@ -3056,6 +3765,10 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3092,6 +3805,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==} @@ -3243,6 +3960,14 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + screenfull@6.0.2: + resolution: {integrity: sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw==} + engines: {node: ^14.13.1 || >=16.0.0} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -3324,6 +4049,9 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strnum@2.2.3: resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} @@ -3337,6 +4065,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'} @@ -3391,6 +4123,14 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + + tiptap-markdown@0.8.10: + resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==} + peerDependencies: + '@tiptap/core': ^2.0.3 + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -3441,15 +4181,27 @@ 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'} + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -3481,6 +4233,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@14.0.0: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true @@ -3609,6 +4365,11 @@ packages: peerDependencies: vue: ^3 + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + vue-router@5.0.6: resolution: {integrity: sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==} peerDependencies: @@ -3635,6 +4396,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 +4467,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'} @@ -3722,15 +4494,55 @@ packages: utf-8-validate: optional: true + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + + 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-prosemirror@1.3.7: + resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} + 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 + + 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 +4559,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 +4617,207 @@ snapshots: '@codemirror/view': 6.41.1 '@lezer/common': 1.5.2 + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/cpp': 1.1.5 + + '@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-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@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-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/java': 1.1.3 + + '@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-jinja@6.0.1': + 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/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@codemirror/lang-json@6.0.2': dependencies: '@codemirror/language': 6.12.3 '@lezer/json': 1.0.3 + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + 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/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@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/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.18 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.3 + '@codemirror/legacy-modes': 6.5.3 + '@codemirror/language@6.12.3': dependencies: '@codemirror/state': 6.6.0 @@ -3813,6 +4827,10 @@ snapshots: '@lezer/lr': 1.4.10 style-mod: 4.1.3 + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/lint@6.9.5': dependencies: '@codemirror/state': 6.6.0 @@ -4053,6 +5071,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 +5150,46 @@ snapshots: '@lezer/common@1.5.2': {} + '@lezer/cpp@1.1.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + 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/java@1.1.3': + 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 +5200,49 @@ snapshots: dependencies: '@lezer/common': 1.5.2 + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.18': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lifeomic/attempt@3.1.0': {} + '@mapbox/jsonlint-lines-primitives@2.0.2': {} '@mapbox/point-geometry@1.1.0': {} @@ -4272,6 +5385,27 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@opencloud-eu/design-system@3.2.0(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@emoji-mart/data': 1.2.1 + '@popperjs/core': 2.11.8 + deepmerge: 4.3.1 + emoji-mart: 5.6.0 + focus-trap: 7.8.0 + focus-trap-vue: 4.1.0(focus-trap@7.8.0)(vue@3.5.33(typescript@6.0.3)) + fuse.js: 7.3.0 + lodash-es: 4.18.1 + luxon: 3.7.2 + portal-vue: 3.0.0(vue@3.5.33(typescript@6.0.3)) + tippy.js: 6.3.7 + vue-inline-svg: 4.0.1(vue@3.5.33(typescript@6.0.3)) + vue-router: 4.6.4(vue@3.5.33(typescript@6.0.3)) + vue-select: 4.0.0-beta.6(vue@3.5.33(typescript@6.0.3)) + vue3-gettext: 2.4.0(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + transitivePeerDependencies: + - '@vue/compiler-sfc' + - vue + '@opencloud-eu/design-system@7.0.0(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': dependencies: '@emoji-mart/data': 1.2.1 @@ -4333,6 +5467,21 @@ snapshots: '@opencloud-eu/tsconfig@7.0.0': {} + '@opencloud-eu/web-client@3.2.0': + dependencies: + '@casl/ability': 6.8.1 + '@microsoft/fetch-event-source': 2.0.1 + axios: 1.15.1 + fast-xml-parser: 4.5.6 + lodash-es: 4.18.1 + luxon: 3.7.2 + uuid: 11.1.1 + webdav: 5.9.0 + xml-js: 1.6.11 + zod: 4.3.6 + transitivePeerDependencies: + - debug + '@opencloud-eu/web-client@7.0.0': dependencies: '@casl/ability': 6.8.1 @@ -4347,6 +5496,53 @@ snapshots: transitivePeerDependencies: - debug + '@opencloud-eu/web-pkg@3.2.0(@vue/compiler-sfc@3.5.33)(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@casl/ability': 6.8.1 + '@casl/vue': 2.2.6(@casl/ability@6.8.1)(vue@3.5.33(typescript@6.0.3)) + '@microsoft/fetch-event-source': 2.0.1 + '@opencloud-eu/design-system': 3.2.0(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + '@opencloud-eu/web-client': 3.2.0 + '@sentry/vue': 9.47.1(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + '@uppy/core': 4.5.3 + '@uppy/drop-target': 3.2.2(@uppy/core@4.5.3) + '@uppy/tus': 4.3.2(@uppy/core@4.5.3) + '@uppy/utils': 6.2.2 + '@uppy/xhr-upload': 4.4.2(@uppy/core@4.5.3) + '@vavt/cm-extension': 1.11.2 + '@vue/shared': 3.5.33 + '@vueuse/core': 13.9.0(vue@3.5.33(typescript@6.0.3)) + axios: 1.15.1 + cropperjs: 1.6.2 + deepmerge: 4.3.1 + dompurify: 3.4.0 + filesize: 11.0.16 + fuse.js: 7.3.0 + js-generate-password: 1.0.0 + lodash-es: 4.18.1 + luxon: 3.7.2 + mark.js: 8.11.1 + md-editor-v3: 5.8.5(vue@3.5.33(typescript@6.0.3)) + oidc-client-ts: 3.5.0 + p-queue: 8.1.1 + password-sheriff: 1.3.1 + pinia: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + portal-vue: 3.0.0(vue@3.5.33(typescript@6.0.3)) + prismjs: 1.30.0 + qs: 6.15.1 + screenfull: 6.0.2 + semver: 7.8.0 + uuid: 11.1.1 + vue-concurrency: 5.0.3(vue@3.5.33(typescript@6.0.3)) + vue-router: 4.6.4(vue@3.5.33(typescript@6.0.3)) + vue3-gettext: 2.4.0(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + zod: 4.3.6 + transitivePeerDependencies: + - '@vue/compiler-sfc' + - debug + - typescript + - vue + '@opencloud-eu/web-pkg@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))': dependencies: '@casl/ability': 6.8.1 @@ -4505,8 +5701,12 @@ snapshots: dependencies: playwright: 1.59.1 + '@popperjs/core@2.11.8': {} + '@protomaps/basemaps@5.7.2': {} + '@remirror/core-constants@3.0.0': {} + '@replit/codemirror-indentation-markers@6.5.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)': dependencies: '@codemirror/language': 6.12.3 @@ -4576,20 +5776,38 @@ snapshots: dependencies: '@sentry/core': 10.49.0 + '@sentry-internal/browser-utils@9.47.1': + dependencies: + '@sentry/core': 9.47.1 + '@sentry-internal/feedback@10.49.0': dependencies: '@sentry/core': 10.49.0 + '@sentry-internal/feedback@9.47.1': + dependencies: + '@sentry/core': 9.47.1 + '@sentry-internal/replay-canvas@10.49.0': dependencies: '@sentry-internal/replay': 10.49.0 '@sentry/core': 10.49.0 + '@sentry-internal/replay-canvas@9.47.1': + dependencies: + '@sentry-internal/replay': 9.47.1 + '@sentry/core': 9.47.1 + '@sentry-internal/replay@10.49.0': dependencies: '@sentry-internal/browser-utils': 10.49.0 '@sentry/core': 10.49.0 + '@sentry-internal/replay@9.47.1': + dependencies: + '@sentry-internal/browser-utils': 9.47.1 + '@sentry/core': 9.47.1 + '@sentry/browser@10.49.0': dependencies: '@sentry-internal/browser-utils': 10.49.0 @@ -4598,8 +5816,18 @@ snapshots: '@sentry-internal/replay-canvas': 10.49.0 '@sentry/core': 10.49.0 + '@sentry/browser@9.47.1': + dependencies: + '@sentry-internal/browser-utils': 9.47.1 + '@sentry-internal/feedback': 9.47.1 + '@sentry-internal/replay': 9.47.1 + '@sentry-internal/replay-canvas': 9.47.1 + '@sentry/core': 9.47.1 + '@sentry/core@10.49.0': {} + '@sentry/core@9.47.1': {} + '@sentry/vue@10.49.0(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': dependencies: '@sentry/browser': 10.49.0 @@ -4608,6 +5836,14 @@ snapshots: optionalDependencies: pinia: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + '@sentry/vue@9.47.1(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@sentry/browser': 9.47.1 + '@sentry/core': 9.47.1 + vue: 3.5.33(typescript@6.0.3) + optionalDependencies: + pinia: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + '@sphinxxxx/color-conversion@2.2.2': {} '@standard-schema/spec@1.1.0': {} @@ -4684,18 +5920,40 @@ snapshots: tailwindcss: 4.2.3 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) + '@tiptap/core@2.27.2(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/pm': 2.27.2 + + '@tiptap/core@2.27.2(@tiptap/pm@3.23.1)': + dependencies: + '@tiptap/pm': 3.23.1 + '@tiptap/core@3.23.1(@tiptap/pm@3.23.1)': dependencies: '@tiptap/pm': 3.23.1 + '@tiptap/extension-blockquote@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-blockquote@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-bold@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-bold@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-bubble-menu@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 3.23.1 + tippy.js: 6.3.7 + '@tiptap/extension-bubble-menu@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)': dependencies: '@floating-ui/dom': 1.7.6 @@ -4703,27 +5961,66 @@ snapshots: '@tiptap/pm': 3.23.1 optional: true + '@tiptap/extension-bullet-list@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-bullet-list@3.23.1(@tiptap/extension-list@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))': dependencies: '@tiptap/extension-list': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/extension-code-block@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 2.27.2 + '@tiptap/extension-code-block@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) '@tiptap/pm': 3.23.1 + '@tiptap/extension-code@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-code@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-collaboration-cursor@2.26.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(y-prosemirror@1.3.7(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: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + y-prosemirror: 1.3.7(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) + + '@tiptap/extension-collaboration@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(y-prosemirror@1.3.7(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: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 3.23.1 + y-prosemirror: 1.3.7(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) + + '@tiptap/extension-document@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@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) + '@tiptap/extension-dropcursor@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 2.27.2 + '@tiptap/extension-dropcursor@3.23.1(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))': dependencies: '@tiptap/extensions': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/extension-floating-menu@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 3.23.1 + tippy.js: 6.3.7 + '@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)': dependencies: '@floating-ui/dom': 1.7.6 @@ -4731,17 +6028,40 @@ snapshots: '@tiptap/pm': 3.23.1 optional: true + '@tiptap/extension-gapcursor@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 2.27.2 + '@tiptap/extension-gapcursor@3.23.1(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))': dependencies: '@tiptap/extensions': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/extension-hard-break@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-hard-break@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-heading@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-heading@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: - '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + + '@tiptap/extension-history@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 2.27.2 + + '@tiptap/extension-horizontal-rule@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 2.27.2 '@tiptap/extension-horizontal-rule@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)': dependencies: @@ -4752,6 +6072,10 @@ snapshots: dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-italic@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-italic@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) @@ -4762,6 +6086,10 @@ snapshots: '@tiptap/pm': 3.23.1 linkifyjs: 4.3.2 + '@tiptap/extension-list-item@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-list-item@3.23.1(@tiptap/extension-list@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))': dependencies: '@tiptap/extension-list': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) @@ -4775,14 +6103,31 @@ snapshots: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) '@tiptap/pm': 3.23.1 + '@tiptap/extension-ordered-list@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-ordered-list@3.23.1(@tiptap/extension-list@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))': dependencies: '@tiptap/extension-list': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/extension-paragraph@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-paragraph@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-placeholder@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/pm': 3.23.1 + + '@tiptap/extension-strike@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@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) @@ -4800,10 +6145,18 @@ snapshots: dependencies: '@tiptap/extension-list': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/extension-text-style@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-text-style@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) + '@tiptap/extension-text@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-text@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) @@ -4823,6 +6176,27 @@ snapshots: '@tiptap/pm': 3.23.1 marked: 17.0.6 + '@tiptap/pm@2.27.2': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.3.2 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + '@tiptap/pm@3.23.1': dependencies: prosemirror-changeset: 2.4.1 @@ -4838,6 +6212,30 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.8 + '@tiptap/starter-kit@2.27.2': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/extension-blockquote': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-bold': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-bullet-list': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-code-block': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2) + '@tiptap/extension-document': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-dropcursor': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2) + '@tiptap/extension-gapcursor': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2) + '@tiptap/extension-hard-break': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-heading': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-history': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2) + '@tiptap/extension-horizontal-rule': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@2.27.2) + '@tiptap/extension-italic': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-list-item': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-ordered-list': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-paragraph': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-strike': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/extension-text-style': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)) + '@tiptap/pm': 2.27.2 + '@tiptap/starter-kit@3.23.1': dependencies: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) @@ -4870,6 +6268,14 @@ snapshots: '@tiptap/core': 3.23.1(@tiptap/pm@3.23.1) '@tiptap/pm': 3.23.1 + '@tiptap/vue-3@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@tiptap/extension-bubble-menu': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/extension-floating-menu': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) + '@tiptap/pm': 3.23.1 + vue: 3.5.33(typescript@6.0.3) + '@tiptap/vue-3@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))': dependencies: '@floating-ui/dom': 1.7.6 @@ -4925,16 +6331,49 @@ 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/linkify-it@3.0.5': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@13.0.9': + dependencies: + '@types/linkify-it': 3.0.5 + '@types/mdurl': 1.0.5 + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@1.0.5': {} + + '@types/mdurl@2.0.0': {} + + '@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 @@ -5056,6 +6495,13 @@ snapshots: dependencies: '@ucast/core': 1.10.2 + '@uppy/companion-client@4.5.2(@uppy/core@4.5.3)': + dependencies: + '@uppy/core': 4.5.3 + '@uppy/utils': 6.2.2 + namespace-emitter: 2.0.1 + p-retry: 6.2.1 + '@uppy/companion-client@5.1.1(@uppy/core@5.2.0)': dependencies: '@uppy/core': 5.2.0 @@ -5063,6 +6509,17 @@ snapshots: namespace-emitter: 2.0.1 p-retry: 6.2.1 + '@uppy/core@4.5.3': + dependencies: + '@transloadit/prettier-bytes': 0.3.5 + '@uppy/store-default': 4.3.2 + '@uppy/utils': 6.2.2 + lodash: 4.18.1 + mime-match: 1.0.2 + namespace-emitter: 2.0.1 + nanoid: 5.1.7 + preact: 10.29.1 + '@uppy/core@5.2.0': dependencies: '@transloadit/prettier-bytes': 0.3.5 @@ -5087,6 +6544,11 @@ snapshots: preact: 10.29.1 shallow-equal: 3.1.0 + '@uppy/drop-target@3.2.2(@uppy/core@4.5.3)': + dependencies: + '@uppy/core': 4.5.3 + '@uppy/utils': 6.2.2 + '@uppy/google-drive@5.1.0(@uppy/core@5.2.0)': dependencies: '@uppy/companion-client': 5.1.1(@uppy/core@5.2.0) @@ -5113,6 +6575,8 @@ snapshots: p-queue: 8.1.1 preact: 10.29.1 + '@uppy/store-default@4.3.2': {} + '@uppy/store-default@5.0.0': {} '@uppy/thumbnail-generator@5.1.0(@uppy/core@5.2.0)': @@ -5121,6 +6585,13 @@ snapshots: '@uppy/utils': 7.2.0 exifr: 7.1.3 + '@uppy/tus@4.3.2(@uppy/core@4.5.3)': + dependencies: + '@uppy/companion-client': 4.5.2(@uppy/core@4.5.3) + '@uppy/core': 4.5.3 + '@uppy/utils': 6.2.2 + tus-js-client: 4.3.1 + '@uppy/tus@5.1.1(@uppy/core@5.2.0)': dependencies: '@uppy/companion-client': 5.1.1(@uppy/core@5.2.0) @@ -5128,6 +6599,11 @@ snapshots: '@uppy/utils': 7.2.0 tus-js-client: 4.3.1 + '@uppy/utils@6.2.2': + dependencies: + lodash: 4.18.1 + preact: 10.29.1 + '@uppy/utils@7.2.0': dependencies: lodash: 4.18.1 @@ -5141,12 +6617,24 @@ snapshots: '@uppy/utils': 7.2.0 preact: 10.29.1 + '@uppy/xhr-upload@4.4.2(@uppy/core@4.5.3)': + dependencies: + '@uppy/companion-client': 4.5.2(@uppy/core@4.5.3) + '@uppy/core': 4.5.3 + '@uppy/utils': 6.2.2 + '@uppy/xhr-upload@5.2.0(@uppy/core@5.2.0)': dependencies: '@uppy/companion-client': 5.1.1(@uppy/core@5.2.0) '@uppy/core': 5.2.0 '@uppy/utils': 7.2.0 + '@vavt/cm-extension@1.11.2': {} + + '@vavt/copy2clipboard@1.0.3': {} + + '@vavt/util@2.1.2': {} + '@vitejs/plugin-basic-ssl@2.3.0(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))': dependencies: 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) @@ -5250,6 +6738,8 @@ snapshots: '@vue/compiler-dom': 3.5.33 '@vue/shared': 3.5.33 + '@vue/devtools-api@6.6.4': {} + '@vue/devtools-api@7.7.9': dependencies: '@vue/devtools-kit': 7.7.9 @@ -5324,6 +6814,13 @@ snapshots: optionalDependencies: '@vue/server-renderer': 3.5.33(vue@3.5.33(typescript@6.0.3)) + '@vueuse/core@13.9.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 13.9.0 + '@vueuse/shared': 13.9.0(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + '@vueuse/core@14.2.1(vue@3.5.33(typescript@6.0.3))': dependencies: '@types/web-bluetooth': 0.0.21 @@ -5331,8 +6828,14 @@ snapshots: '@vueuse/shared': 14.2.1(vue@3.5.33(typescript@6.0.3)) vue: 3.5.33(typescript@6.0.3) + '@vueuse/metadata@13.9.0': {} + '@vueuse/metadata@14.2.1': {} + '@vueuse/shared@13.9.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + '@vueuse/shared@14.2.1(vue@3.5.33(typescript@6.0.3))': dependencies: vue: 3.5.33(typescript@6.0.3) @@ -5383,8 +6886,12 @@ snapshots: arg@4.1.3: {} + argparse@2.0.1: {} + aria-query@5.3.1: {} + array-back@3.1.0: {} + array-back@6.2.3: {} assertion-error@2.0.1: {} @@ -5431,6 +6938,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 +6967,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: {} @@ -5480,6 +6999,16 @@ snapshots: '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.1 + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5497,6 +7026,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 @@ -5506,8 +7042,12 @@ snapshots: commander@10.0.1: {} + commander@2.20.3: {} + complex.js@2.4.3: {} + concat-map@0.0.1: {} + confbox@0.1.8: {} confbox@0.2.4: {} @@ -5523,6 +7063,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: {} @@ -5531,6 +7079,8 @@ snapshots: dependencies: luxon: 3.7.2 + cropperjs@1.6.2: {} + cropperjs@2.1.1: dependencies: '@cropper/elements': 2.1.1 @@ -5544,8 +7094,12 @@ snapshots: crypt@0.0.2: {} + css-selector-parser@1.4.1: {} + cssesc@3.0.0: {} + cssfilter@0.0.10: {} + csstype@3.2.3: {} custom-error-instance@2.1.1: {} @@ -5606,10 +7160,16 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + entities@4.5.0: {} + entities@6.0.1: {} 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: {} @@ -5787,6 +7347,10 @@ snapshots: dependencies: path-expression-matcher: 1.5.0 + fast-xml-parser@4.5.6: + dependencies: + strnum: 1.1.2 + fast-xml-parser@5.7.1: dependencies: '@nodable/entities': 2.1.0 @@ -5819,6 +7383,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 +7438,8 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -5898,6 +7468,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 +7502,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 +7571,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 +7621,8 @@ snapshots: dependencies: ws: 8.18.0 + isomorphic.js@0.2.5: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -6046,12 +7651,18 @@ snapshots: js-cookie@3.0.5: {} + js-generate-password@1.0.0: {} + + 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 +7704,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 +7759,12 @@ snapshots: lilconfig@3.1.3: {} + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + linkifyjs@4.3.2: {} local-pkg@1.1.2: @@ -6196,6 +7817,10 @@ snapshots: lru-cache@11.2.7: {} + lucide-vue-next@0.453.0(vue@3.5.33(typescript@6.0.3)): + dependencies: + vue: 3.5.33(typescript@6.0.3) + luxon@3.7.2: {} magic-string-ast@1.0.3: @@ -6232,6 +7857,25 @@ snapshots: mark.js@8.11.1: {} + markdown-it-image-figures@2.1.1(markdown-it@14.1.1): + dependencies: + markdown-it: 14.1.1 + + markdown-it-sub@2.0.0: {} + + markdown-it-sup@2.0.0: {} + + markdown-it-task-lists@2.1.1: {} + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + marked@17.0.6: {} math-intrinsics@1.1.0: {} @@ -6248,12 +7892,41 @@ snapshots: tiny-emitter: 2.1.0 typed-function: 4.2.2 + md-editor-v3@5.8.5(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/language': 6.12.3 + '@codemirror/language-data': 6.5.2 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/highlight': 1.2.3 + '@types/markdown-it': 14.1.2 + '@vavt/copy2clipboard': 1.0.3 + '@vavt/util': 2.1.2 + codemirror: 6.0.2 + lru-cache: 11.2.7 + lucide-vue-next: 0.453.0(vue@3.5.33(typescript@6.0.3)) + markdown-it: 14.1.1 + markdown-it-image-figures: 2.1.1(markdown-it@14.1.1) + markdown-it-sub: 2.0.0 + markdown-it-sup: 2.0.0 + medium-zoom: 1.1.0 + vue: 3.5.33(typescript@6.0.3) + xss: 1.0.15 + md5@2.3.0: dependencies: charenc: 0.0.2 crypt: 0.0.2 is-buffer: 1.1.6 + mdurl@2.0.0: {} + + medium-zoom@1.1.0: {} + memoize-one@6.0.0: {} mime-db@1.52.0: {} @@ -6270,6 +7943,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 +8019,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 +8066,27 @@ 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@1.3.1: {} + password-sheriff@2.0.0: {} path-browserify@1.0.1: {} @@ -6395,6 +8095,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 +8113,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,8 +8160,14 @@ snapshots: dependencies: fflate: 0.8.2 + pofile@1.0.11: {} + pofile@1.1.4: {} + portal-vue@3.0.0(vue@3.5.33(typescript@6.0.3)): + optionalDependencies: + vue: 3.5.33(typescript@6.0.3) + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -6477,6 +8187,8 @@ snapshots: prettier@3.8.3: {} + prismjs@1.30.0: {} + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -6487,6 +8199,10 @@ snapshots: dependencies: prosemirror-transform: 1.12.0 + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-commands@1.7.1: dependencies: prosemirror-model: 1.25.4 @@ -6513,15 +8229,37 @@ snapshots: prosemirror-view: 1.41.8 rope-sequence: 1.3.4 + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-keymap@1.2.3: dependencies: prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.1 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.3.2: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + prosemirror-model@1.25.4: dependencies: orderedmap: 2.1.1 + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-schema-list@1.5.1: dependencies: prosemirror-model: 1.25.4 @@ -6542,6 +8280,14 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.8 + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + prosemirror-transform@1.12.0: dependencies: prosemirror-model: 1.25.4 @@ -6560,6 +8306,8 @@ snapshots: proxy-from-env@2.1.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} qs@6.15.1: @@ -6586,6 +8334,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 @@ -6725,6 +8475,10 @@ snapshots: '@parcel/watcher': 2.5.6 optional: true + sax@1.6.0: {} + + screenfull@6.0.2: {} + scule@1.3.0: {} seedrandom@3.0.5: {} @@ -6803,6 +8557,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strnum@1.1.2: {} + strnum@2.2.3: {} style-mod@4.1.3: {} @@ -6815,6 +8571,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 @@ -6869,6 +8629,18 @@ snapshots: tinyrainbow@3.1.0: {} + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + + tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@3.23.1)): + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@3.23.1) + '@types/markdown-it': 13.0.9 + markdown-it: 14.1.1 + markdown-it-task-lists: 2.1.1 + prosemirror-markdown: 1.13.4 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -6924,10 +8696,16 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.9.3: {} + typescript@6.0.3: {} + typical@4.0.0: {} + typical@7.3.0: {} + uc.micro@2.1.0: {} + ufo@1.6.3: {} undici-types@7.16.0: {} @@ -6958,6 +8736,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.1: {} + uuid@14.0.0: {} v8-compile-cache-lib@3.0.1: {} @@ -7072,6 +8852,11 @@ snapshots: dependencies: vue: 3.5.33(typescript@6.0.3) + vue-router@4.6.4(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.33(typescript@6.0.3) + vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)): dependencies: '@babel/generator': 7.29.1 @@ -7106,6 +8891,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 +8981,52 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + ws@8.18.0: {} ws@8.20.0: {} + xml-js@1.6.11: + dependencies: + sax: 1.6.0 + xml-name-validator@4.0.0: {} + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + + 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-prosemirror@1.3.7(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 + + 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() +} From f398bdda219d7d1463d3995968758ac5fbc1dcf5 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 19 May 2026 17:39:37 +0200 Subject: [PATCH 2/5] fix(realtime-collab): align tiptap on v3, fix MF + cursor wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoC e2e + integration suites now pass green (codemirror 5/5, tiptap 4/4, integration 8/8) against OC 6.2.0. - web-pkg / web-client: ^3.0.0 → ^7.0.0 in both packages (mismatched major rejected by Module Federation as "Shared module must be provided by host"; all other apps in the repo were already on ^7) - drop unused `storeToRefs` import (pinia named export is not federated in dev builds and broke the codemirror vite build); read serverUrl directly off the store inside the computed instead - tiptap deps: ^2.10 → ^3.20.4 to align with what web-pkg's editor already pulls in transitively (avoids parallel v2+v3 in the store and the tiptap-markdown → @tiptap/pm/model resolution failure) - replace community `tiptap-markdown` with the official `@tiptap/markdown` (same package web's editor uses); adapter switches to `setContent(md, { contentType: 'markdown' })` + `editor.getMarkdown()` - drop `@tiptap/extension-collaboration-cursor@3.0.0`: it still imports `yCursorPlugin` from upstream `y-prosemirror`, while `@tiptap/extension-collaboration@3` has migrated to Tiptap's own `@tiptap/y-tiptap` fork — the two ship distinct `ySyncPluginKey` instances, so the cursor extension cannot find the sync state and throws "Cannot read properties of undefined (reading 'doc')" on init. A 12-line in-place Extension wires `@tiptap/y-tiptap`'s `yCursorPlugin` directly with a `cursorBuilder` that emits the same `.collaboration-cursor__caret` / `__label` DOM the consumer CSS and the e2e test expect. - vite.config: dedupe `yjs` / `y-prosemirror` / `y-protocols` in the tiptap bundle so duplicated instances don't break `instanceof` checks - CollaborativeWrapper: pass `provider` through to the editor component alongside `awareness` (the cursor wiring needs the provider for the underlying awareness; codemirror ignores the extra prop) --- packages/web-app-codemirror/package.json | 4 +- .../src/CollaborativeWrapper.vue | 5 +- packages/web-app-tiptap/package.json | 19 +- packages/web-app-tiptap/src/TiptapEditor.vue | 55 +- .../src/adapters/tiptapMarkdown.ts | 21 +- packages/web-app-tiptap/vite.config.ts | 9 + pnpm-lock.yaml | 1384 +---------------- 7 files changed, 158 insertions(+), 1339 deletions(-) diff --git a/packages/web-app-codemirror/package.json b/packages/web-app-codemirror/package.json index 3d7119de..53fc5ffa 100644 --- a/packages/web-app-codemirror/package.json +++ b/packages/web-app-codemirror/package.json @@ -21,8 +21,8 @@ "yjs": "^13.6.0" }, "devDependencies": { - "@opencloud-eu/web-client": "^3.0.0", - "@opencloud-eu/web-pkg": "^3.0.0", + "@opencloud-eu/web-client": "^7.0.0", + "@opencloud-eu/web-pkg": "^7.0.0", "@types/semver": "^7.7.0", "@types/ws": "^8.5.0", "vue": "^3.4.21", diff --git a/packages/web-app-codemirror/src/CollaborativeWrapper.vue b/packages/web-app-codemirror/src/CollaborativeWrapper.vue index 418bf45c..b3c0a29a 100644 --- a/packages/web-app-codemirror/src/CollaborativeWrapper.vue +++ b/packages/web-app-codemirror/src/CollaborativeWrapper.vue @@ -1,6 +1,5 @@ diff --git a/packages/web-app-codemirror/src/CollaborativeWrapper.vue b/packages/web-app-codemirror/src/CollaborativeWrapper.vue index b3c0a29a..c5c640e4 100644 --- a/packages/web-app-codemirror/src/CollaborativeWrapper.vue +++ b/packages/web-app-codemirror/src/CollaborativeWrapper.vue @@ -1,9 +1,18 @@ diff --git a/packages/web-app-codemirror/src/CollaborativeWrapper.vue b/packages/web-app-codemirror/src/CollaborativeWrapper.vue index c5c640e4..fdc26a82 100644 --- a/packages/web-app-codemirror/src/CollaborativeWrapper.vue +++ b/packages/web-app-codemirror/src/CollaborativeWrapper.vue @@ -10,9 +10,10 @@ import { type PropType } from 'vue' import * as Y from 'yjs' +import { Awareness } from 'y-protocols/awareness' import { HocuspocusProvider } from '@hocuspocus/provider' import { Resource } from '@opencloud-eu/web-client' -import { useAuthStore, useConfigStore } from '@opencloud-eu/web-pkg' +import { useAuthStore } from '@opencloud-eu/web-pkg' import semverCompare from 'semver/functions/compare' import semverValid from 'semver/functions/valid' import type { CollaborativeAdapter } from './types' @@ -27,7 +28,13 @@ const props = defineProps({ // its own package.json, baked in at build time by Vite. Used to detect // schema mismatch between peers in the same Y.Doc room. The wrapper // itself stays agnostic of where the version comes from. - appVersion: { type: String, required: true } + appVersion: { type: String, required: true }, + // Realtime sync URL (wss://.../realtime). When null/undefined we run + // in local-only mode: still a Y.Doc + Awareness pair (so editor bindings + // stay on a single codepath) but no Hocuspocus provider, no cross-peer + // sync, no stale-state probe. Hydration runs immediately from + // `currentContent` instead of waiting for `onSynced`. + realtimeUrl: { type: String as PropType, required: false, default: null } }) // The hosting AppWrapper drives isEditor / isDirty / autoSave / Ctrl+S / @@ -53,12 +60,12 @@ function compareVersion(a: string, b: string): number { return a === b ? 0 : Number.NaN } -const configStore = useConfigStore() const authStore = useAuthStore() const ydoc = shallowRef(null) const provider = shallowRef(null) -const status = shallowRef<'connecting' | 'connected' | 'disconnected'>('connecting') +const awareness = shallowRef(null) +const status = shallowRef<'connecting' | 'connected' | 'disconnected' | 'local'>('connecting') // Set when the sidecar told us the persisted state is stale and we either // recovered locally or need the user to reload, or when realtime auth failed. const lifecycleError = shallowRef(null) @@ -89,29 +96,26 @@ const documentName = computed(() => { return r.remoteItemId ?? r.id }) -const realtimeBaseUrl = computed(() => { - const base = configStore.serverUrl.replace(/\/$/, '') - return base.replace(/^http/, 'ws') + '/realtime' -}) - const effectiveReadOnly = computed(() => props.isReadOnly || isLockedForReload.value) // --------------------------------------------------------------------------- -// Provider lifecycle — rebuilt whenever the file identity changes. +// Y.Doc + (optional) provider lifecycle — rebuilt whenever the file identity +// changes. Two modes, gated solely by `props.realtimeUrl`: +// - collab : Hocuspocus provider connects, awareness comes from the +// provider, hydration waits for onSynced. +// - local : standalone Awareness instance, no network, hydration runs +// immediately. The downstream editor sees an awareness object +// just like in collab-mode — the only behavioural diff for +// consumers is that no peers will ever appear. // --------------------------------------------------------------------------- watchEffect((onCleanup) => { const name = unref(documentName) - const wsUrl = unref(realtimeBaseUrl) - if (!name || !wsUrl) return + if (!name) return // Reset per-file state. lifecycleError.value = null isLockedForReload.value = false - // HocuspocusProvider has no `parameters` option; we get query params to - // the sidecar's requestParameters by appending them to the URL ourselves. - const wsUrlWithParams = `${wsUrl}?appVersion=${encodeURIComponent(APP_VERSION)}` - const doc = new Y.Doc() // Debounced serialize → emit. We hand AppWrapper the same string an @@ -149,37 +153,61 @@ watchEffect((onCleanup) => { } doc.on('update', onDocUpdate) - const prov = new HocuspocusProvider({ - url: wsUrlWithParams, - name, - document: doc, - token: () => authStore.accessToken, - onStatus({ status: s }) { - status.value = s as typeof status.value - }, - onAuthenticationFailed({ reason }) { - console.error('[collab] realtime auth failed:', reason) - // Surface as lifecycle error so the user sees the reason rather than a - // silent disconnect. Server uses this for app-version rejection too. - lifecycleError.value = new Error(reason || 'authentication failed') - isLockedForReload.value = true - }, - onSynced() { - void onProviderSynced(doc, prov) - } - }) + let prov: HocuspocusProvider | null = null + let aw: Awareness + + if (props.realtimeUrl) { + // ---------- Collab mode ---------- + // HocuspocusProvider has no `parameters` option; we get query params to + // the sidecar's requestParameters by appending them to the URL ourselves. + const wsUrlWithParams = `${props.realtimeUrl}?appVersion=${encodeURIComponent(APP_VERSION)}` + prov = new HocuspocusProvider({ + url: wsUrlWithParams, + name, + document: doc, + token: () => authStore.accessToken, + onStatus({ status: s }) { + status.value = s as typeof status.value + }, + onAuthenticationFailed({ reason }) { + console.error('[collab] realtime auth failed:', reason) + // Surface as lifecycle error so the user sees the reason rather than a + // silent disconnect. Server uses this for app-version rejection too. + lifecycleError.value = new Error(reason || 'authentication failed') + isLockedForReload.value = true + }, + onSynced() { + void onProviderSynced(doc, prov, prov!.awareness!) + } + }) - // Empty-user bootstrap: creates an awareness entry under our Y.Doc.clientID - // as soon as the provider connects, so peers see us before the editor - // binding emits its first cursor update. The server's beforeHandleAwareness - // hook overwrites this with the authenticated identity. Lurkers that never - // touch `user` stay invisible (matches the hook's "only stamp when present" - // rule). - prov.setAwarenessField('user', {}) + // Empty-user bootstrap: creates an awareness entry under our Y.Doc.clientID + // as soon as the provider connects, so peers see us before the editor + // binding emits its first cursor update. The server's beforeHandleAwareness + // hook overwrites this with the authenticated identity. Lurkers that never + // touch `user` stay invisible (matches the hook's "only stamp when present" + // rule). + prov.setAwarenessField('user', {}) + aw = prov.awareness! + } else { + // ---------- Local mode ---------- + // Standalone Awareness so the editor bindings still see a non-null + // awareness instance (CodeMirror's yCollab, Tiptap's cursor plugin + // when registered). Nobody else will ever join, which is the point. + aw = new Awareness(doc) + status.value = 'local' + // No `onSynced` to wait for — hand off to the same hydration entrypoint + // immediately. Without a sidecar the app-version handshake and + // stale-state probe are no-ops (the doc is freshly minted and there's + // no persisted state to compare against), but we still run through the + // function so future shared-handler additions keep both modes aligned. + void onProviderSynced(doc, null, aw) + } // _oc_meta is the parallel channel for stale/version coordination. The // editor binding never sees it because adapters bind to their own shared - // types (e.g. Y.Text 'content' for CodeMirror). + // types (e.g. Y.Text 'content' for CodeMirror). In local mode no one ever + // sets isStale / bumps appVersion, so the observer is dormant but harmless. const meta = doc.getMap(META_KEY) const metaObserver = (event: Y.YMapEvent) => { // App version mismatch surfaced after-the-fact (e.g. a newer peer joined @@ -207,21 +235,24 @@ watchEffect((onCleanup) => { // run a client-side rehydrate (election prevents all peers from doing // it at once). if (event.keysChanged.has('isStale') && meta.get('isStale') === true) { - void recoverFromStaleState(doc, prov) + void recoverFromStaleState(doc, prov, aw) } } meta.observe(metaObserver) ydoc.value = doc provider.value = prov + awareness.value = aw onCleanup(() => { if (serializeTimer !== undefined) window.clearTimeout(serializeTimer) meta.unobserve(metaObserver) doc.off('update', onDocUpdate) - prov.destroy() + prov?.destroy() + aw.destroy() doc.destroy() if (provider.value === prov) provider.value = null + if (awareness.value === aw) awareness.value = null if (ydoc.value === doc) ydoc.value = null }) }) @@ -230,7 +261,9 @@ watchEffect((onCleanup) => { // `resourcesStore.upsertResource(putFileContentsResponse)`, which bubbles // the new etag back into this prop. Mirror it into `_oc_meta.etag` so the // sidecar's stale-state probe (on the next room load after eviction) and -// any future peer-aware logic see the current authoritative tag. +// any future peer-aware logic see the current authoritative tag. In local +// mode no sidecar reads `_oc_meta`, but the mirror is cheap and keeps the +// two modes symmetrical. watch( () => props.resource.etag, (newEtag) => { @@ -245,12 +278,12 @@ watch( } ) -function lockForReload(prov: HocuspocusProvider, message: string) { +function lockForReload(prov: HocuspocusProvider | null, message: string) { if (isLockedForReload.value) return isLockedForReload.value = true lifecycleError.value = new Error(message) try { - prov.disconnect() + prov?.disconnect() } catch { // disconnect can throw if already torn down; ignore. } @@ -259,9 +292,14 @@ function lockForReload(prov: HocuspocusProvider, message: string) { // --------------------------------------------------------------------------- // Hydration — elected client seeds Y.Doc from native content. Lowest // awareness clientId wins to avoid double-hydration when two peers see an -// empty doc simultaneously. +// empty doc simultaneously. In local mode there are no peers, so the +// election degenerates to "we win unconditionally" — which is what we want. // --------------------------------------------------------------------------- -async function onProviderSynced(doc: Y.Doc, prov: HocuspocusProvider) { +async function onProviderSynced( + doc: Y.Doc, + prov: HocuspocusProvider | null, + awarenessInstance: Awareness +) { const meta = doc.getMap(META_KEY) // If the sidecar already flagged the doc as stale (etag or app-version @@ -310,12 +348,14 @@ async function onProviderSynced(doc: Y.Doc, prov: HocuspocusProvider) { if (effectiveReadOnly.value) return // never seed from a read-only view // Let other clients announce themselves via awareness before electing. + // In local mode nobody else exists, but the 150ms wait costs nothing + // and keeps the codepath identical. await new Promise((resolve) => setTimeout(resolve, 150)) if (props.adapter.hasContent(doc)) return // someone beat us const myId = doc.clientID - const peers = Array.from(prov.awareness?.getStates().keys() ?? []) + const peers = Array.from(awarenessInstance.getStates().keys()) const lowest = peers.length ? Math.min(myId, ...peers) : myId if (myId !== lowest) return @@ -328,9 +368,15 @@ async function onProviderSynced(doc: Y.Doc, prov: HocuspocusProvider) { // elected client wipes adapter content, clears the staleness flag, and // re-hydrates from `props.currentContent` (which the parent route component // re-fetched at app-open time, so it reflects the new native content). -// Other peers see the wipe + hydrate as ordinary CRDT updates. +// Other peers see the wipe + hydrate as ordinary CRDT updates. Unreachable +// in local mode (no sidecar ever sets isStale), but coded provider-tolerant +// so the two modes share one implementation. // --------------------------------------------------------------------------- -async function recoverFromStaleState(doc: Y.Doc, prov: HocuspocusProvider) { +async function recoverFromStaleState( + doc: Y.Doc, + prov: HocuspocusProvider | null, + awarenessInstance: Awareness +) { const meta = doc.getMap(META_KEY) if (effectiveReadOnly.value) return if (typeof props.adapter.reset !== 'function') { @@ -347,7 +393,7 @@ async function recoverFromStaleState(doc: Y.Doc, prov: HocuspocusProvider) { if (meta.get('isStale') !== true) return // someone else handled it const myId = doc.clientID - const peers = Array.from(prov.awareness?.getStates().keys() ?? []) + const peers = Array.from(awarenessInstance.getStates().keys()) const lowest = peers.length ? Math.min(myId, ...peers) : myId if (myId !== lowest) return @@ -386,9 +432,9 @@ async function recoverFromStaleState(doc: Y.Doc, prov: HocuspocusProvider) {
diff --git a/packages/web-app-tiptap/src/TiptapEditor.vue b/packages/web-app-tiptap/src/TiptapEditor.vue index d79d90e5..db6f668b 100644 --- a/packages/web-app-tiptap/src/TiptapEditor.vue +++ b/packages/web-app-tiptap/src/TiptapEditor.vue @@ -48,10 +48,26 @@ function makeCursorExtension(awareness: Awareness) { const props = defineProps({ ydoc: { type: Object as PropType, required: true }, awareness: { type: Object as PropType, required: true }, - provider: { type: Object as PropType, required: true }, + // Optional: present only in collab-mode (when the wrapper has a + // HocuspocusProvider). In local-mode the wrapper passes null and we + // skip cursor wiring entirely — there are no peers to render. + provider: { + type: Object as PropType, + required: false, + default: null + }, isReadOnly: { type: Boolean, default: false } }) +const extensions = [ + StarterKit.configure({ history: false }), + Markdown, + Collaboration.configure({ document: props.ydoc, field: 'default' }) +] +if (props.provider) { + extensions.push(makeCursorExtension(props.awareness)) +} + const editor = useEditor({ editable: !props.isReadOnly, // enableContentCheck flags schema mismatches that would otherwise silently @@ -64,12 +80,7 @@ const editor = useEditor({ onContentError({ disableCollaboration }) { disableCollaboration() }, - extensions: [ - StarterKit.configure({ history: false }), - Markdown, - Collaboration.configure({ document: props.ydoc, field: 'default' }), - makeCursorExtension(props.awareness) - ] + extensions }) onBeforeUnmount(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 642783cf..a589f7df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: 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 @@ -394,6 +397,9 @@ importers: '@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 From ac85423acaeda45b42f66e4331cc92c58fc8c6df Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 19 May 2026 20:24:24 +0200 Subject: [PATCH 5/5] fix(realtime-collab): gate Y.Doc lifecycle on a session key, add unit suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper's `watchEffect((onCleanup) => { ... })` re-ran whenever any tracked prop changed inside the body — including `props.resource` mutations from AppWrapper's post-save `resourcesStore.upsertResource`. Each save would tear down and rebuild the Y.Doc, dropping any in-flight peer edits. The shared-file e2e didn't catch it because the two peers' saves are far apart in test time and the wrapper re-hydrates cleanly from `currentContent`. Replace with `watch(sessionKey, ..., { immediate: true })` where sessionKey = `${documentName}::${realtimeUrl ?? 'local'}`. Vue's computed equality check guarantees an identity-preserving resource update (same id, different etag) leaves the watch dormant — the Y.Doc and provider survive. Adds the first wrapper unit suite (vitest + happy-dom + @vue/test-utils), 13 specs covering both modes (local + collab), the debounced `update:currentContent` emit, the `_oc_meta.etag` mirror, the cleanup contract, and an explicit regression test for the rebuild bug above. - `HocuspocusProvider` is mocked via a class returned from `vi.hoisted` so `new HocuspocusProvider(...)` inside the wrapper finds a real constructable, and the test can fish out the instance to trigger `onSynced` / `onAuthenticationFailed` manually - `useAuthStore` is mocked to a plain `{ accessToken }` object so the wrapper doesn't need a real pinia in the test - The CodeMirror markdown adapter is reused as-is — pure, Y.Text-only, matches the contract a real adapter would honour --- packages/web-app-codemirror/package.json | 3 + .../src/CollaborativeWrapper.vue | 35 +- .../tests/unit/CollaborativeWrapper.spec.ts | 319 ++++++++++++++++++ packages/web-app-codemirror/vitest.config.ts | 9 +- pnpm-lock.yaml | 9 + 5 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 packages/web-app-codemirror/tests/unit/CollaborativeWrapper.spec.ts diff --git a/packages/web-app-codemirror/package.json b/packages/web-app-codemirror/package.json index 88e3d5de..e2390acc 100644 --- a/packages/web-app-codemirror/package.json +++ b/packages/web-app-codemirror/package.json @@ -26,6 +26,9 @@ "@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/CollaborativeWrapper.vue b/packages/web-app-codemirror/src/CollaborativeWrapper.vue index fdc26a82..725b5a73 100644 --- a/packages/web-app-codemirror/src/CollaborativeWrapper.vue +++ b/packages/web-app-codemirror/src/CollaborativeWrapper.vue @@ -5,7 +5,6 @@ import { shallowRef, unref, watch, - watchEffect, type Component, type PropType } from 'vue' @@ -108,15 +107,31 @@ const effectiveReadOnly = computed(() => props.isReadOnly || isLockedForReload.v // just like in collab-mode — the only behavioural diff for // consumers is that no peers will ever appear. // --------------------------------------------------------------------------- -watchEffect((onCleanup) => { - const name = unref(documentName) - if (!name) return +// Use an explicit session key (name + realtime URL) instead of letting +// `watchEffect` track every prop access inside the body. Vue's watchEffect +// re-runs whenever any of its reactive deps fire — including unrelated +// `props.resource` mutations from AppWrapper's post-save `upsertResource`, +// which would tear down the Y.Doc on every save and lose peer edits. +const sessionKey = computed(() => { + const name = documentName.value + if (!name) return null + return `${name}::${props.realtimeUrl ?? 'local'}` +}) + +watch( + sessionKey, + (key, _oldKey, onCleanup) => { + if (!key) return + const name = unref(documentName) + if (!name) return - // Reset per-file state. - lifecycleError.value = null - isLockedForReload.value = false + // Reset per-file state. + lifecycleError.value = null + isLockedForReload.value = false - const doc = new Y.Doc() + const doc = new Y.Doc() + // (the body below was the original `watchEffect` callback; indentation + // intentionally kept at the prior level to minimise diff churn.) // Debounced serialize → emit. We hand AppWrapper the same string an // out-of-band PUT would write; AppWrapper diffs it against its @@ -255,7 +270,9 @@ watchEffect((onCleanup) => { if (awareness.value === aw) awareness.value = null if (ydoc.value === doc) ydoc.value = null }) -}) + }, + { immediate: true } +) // AppWrapper updates `props.resource` after each of its own saves via // `resourcesStore.upsertResource(putFileContentsResponse)`, which bubbles 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/vitest.config.ts b/packages/web-app-codemirror/vitest.config.ts index 48875296..f7f55927 100644 --- a/packages/web-app-codemirror/vitest.config.ts +++ b/packages/web-app-codemirror/vitest.config.ts @@ -1,9 +1,16 @@ import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' export default defineConfig({ + plugins: [vue()], test: { - environment: 'node', + // 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/pnpm-lock.yaml b/pnpm-lock.yaml index a589f7df..e9ddbdb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,15 @@ importers: '@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)