import { docToHtml, docToMarkdown, docToPlainText, editorSchema, extractOutline, OutlineEntry, extractWikilinkSlugs, } from '@dorfteich/shared'; import { Node } from 'prosemirror-model'; import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; import * as Y from 'yjs'; /** * The Yjs XmlFragment name the editor binds to (TipTap's collaboration * extension defaults to "default", #25) — api, web, and collab (#35) must * all agree on this or Yjs states become unreadable across them. */ const FRAGMENT_NAME = 'default'; /** Thrown for state bytes that are not a well-formed Yjs update for this schema. */ export class InvalidPageStateError extends Error {} function docFromState(state: Uint8Array): Node { const ydoc = new Y.Doc(); try { Y.applyUpdate(ydoc, state); return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema); } catch (error) { throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state'); } finally { ydoc.destroy(); } } /** * Encode a ProseMirror document as the initial Yjs state a page is created * with. Used both for a fresh empty page and for importing a converted * document (#63) as a page's starting content — the editor binds to the same * {@link FRAGMENT_NAME}, so an opening client sees exactly this document. */ export function docToState(doc: Node): Uint8Array { const ydoc = new Y.Doc(); try { const fragment = ydoc.getXmlFragment(FRAGMENT_NAME); prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); // Copy into a plain ArrayBuffer-backed view — yjs's own return type is // the wider `Uint8Array`, which Prisma's Bytes input // (`Uint8Array`) does not accept directly. return new Uint8Array(Y.encodeStateAsUpdate(ydoc)); } finally { ydoc.destroy(); } } /** A fresh Yjs state containing a single empty paragraph. */ export function emptyPageState(): Uint8Array { return docToState(editorSchema.node('doc', null, [editorSchema.node('paragraph')])); } export interface DerivedPageContent { plainText: string; markdown: string; html: string; outline: OutlineEntry[]; /** fileIds of every `image` node currently embedded in the document * (issue #31) — `PagesService.saveState` uses this to keep * `Attachment.pageId` pointed at whichever page's content actually * embeds the file, which is what the trash-purge job uses to find a * purged page's files. */ imageFileIds: string[]; /** Outgoing wikilink target slugs (issue #117): pages created through the * api (imports, phantom-create) seed their `page_links` rows from this — * collab, the content writer, rewrites them on every later save. */ wikilinkSlugs: string[]; } function imageFileIdsOf(doc: Node): string[] { const ids: string[] = []; doc.descendants((node) => { if (node.type.name === 'image' && typeof node.attrs.fileId === 'string') { ids.push(node.attrs.fileId); } }); return ids; } /** * Decodes a page's Yjs state into the derived representations stored in * `page_content_cache` (issue #23). The collab server (#35) will decode * the same way and call the same shared derivation functions (#24). */ export function deriveContent(state: Uint8Array): DerivedPageContent { const doc = docFromState(state); return { plainText: docToPlainText(doc), markdown: docToMarkdown(doc), html: docToHtml(doc), outline: extractOutline(doc), imageFileIds: imageFileIdsOf(doc), wikilinkSlugs: extractWikilinkSlugs(doc), }; }