Neuer Notification-Typ mentioned; abgeleitete Tabelle page_mentions (Migration), vom Collab-Persist transaktional neu geschrieben — der Diff gegen den Vorzustand wird als pg_notify (page_mentions_changed) emittiert, nur NEU Erwähnte lösen aus (kein Spam bei Folge-Saves). Der api-Listener (erweitert um den zweiten Kanal) ruft NotificationsService.fanoutMentions: Zustellung nur nach canAccessPage-Recheck, die Autoren (pending contributors) benachrich- tigen sich nie selbst; Payload wie gehabt mit Actor-Namen. API-seitig erzeugte Seiten seeden page_mentions aus deriveContent. Glocken-Text de+en; DB-Test (Leser ja / Outsider nein / Autor nein); kompletter Loop live verifiziert (Tippen → Persist → NOTIFY → Notification). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
129 lines
4.7 KiB
TypeScript
129 lines
4.7 KiB
TypeScript
import {
|
|
docToHtml,
|
|
docToMarkdown,
|
|
docToPlainText,
|
|
editorSchema,
|
|
extractOutline,
|
|
OutlineEntry,
|
|
extractMentionUserIds,
|
|
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 {}
|
|
|
|
/**
|
|
* Decode a page's full current document from its base state plus the
|
|
* `page_updates` log (issue #154) — the persisted base alone lags behind
|
|
* the live document until the next compaction merges the log back.
|
|
*/
|
|
export function docFromStateAndUpdates(state: Uint8Array, updates: Uint8Array[]): Node {
|
|
const ydoc = new Y.Doc();
|
|
try {
|
|
Y.applyUpdate(ydoc, state);
|
|
for (const update of updates) Y.applyUpdate(ydoc, update);
|
|
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
|
|
} catch (error) {
|
|
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
|
|
} finally {
|
|
ydoc.destroy();
|
|
}
|
|
}
|
|
|
|
/** Decode a page's stored Yjs state back into its ProseMirror document —
|
|
* also the entry point for read-time task extraction (issue #154). */
|
|
export 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<ArrayBuffer> {
|
|
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<ArrayBufferLike>`, which Prisma's Bytes input
|
|
// (`Uint8Array<ArrayBuffer>`) 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<ArrayBuffer> {
|
|
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[];
|
|
/** Resolved user ids of every `@mention` (issue #151) — api-created pages
|
|
* seed their `page_mentions` rows from this; collab rewrites on save. */
|
|
mentionUserIds: 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),
|
|
mentionUserIds: extractMentionUserIds(doc),
|
|
};
|
|
}
|