import * as Y from "yjs"; export interface Author { name: string; url?: string; affiliations: number[]; } export interface Affiliation { name: string; url?: string; } export interface LinkItem { label: string; url: string; } export interface FrontmatterData { title: string; subtitle: string; description: string; authors: Author[]; affiliations: Affiliation[]; published: string; template: "article" | "paper"; banner: string; doi: string; showPdf: boolean; tableOfContentsAutoCollapse: boolean; licence: string; pdfProOnly: boolean; seoThumbImage: string; links: LinkItem[]; } const SCALAR_DEFAULTS: Omit = { title: "", subtitle: "", description: "", published: "", template: "article", banner: "banner.html", doi: "", showPdf: true, tableOfContentsAutoCollapse: false, licence: "", pdfProOnly: false, seoThumbImage: "", }; type ScalarKey = keyof typeof SCALAR_DEFAULTS; type ArrayField = "authors" | "affiliations" | "links"; /** * Collaborative frontmatter store backed by Yjs. * * Scalar fields (title, doi, template...) live in a Y.Map for atomic updates. * Array fields (authors, affiliations, links) each get their own Y.Array * so concurrent insertions merge correctly instead of last-write-wins. */ export function createFrontmatterStore(ydoc: Y.Doc) { const ymap = ydoc.getMap("frontmatter"); const yAuthors = ydoc.getArray("frontmatter.authors"); const yAffiliations = ydoc.getArray("frontmatter.affiliations"); const yLinks = ydoc.getArray("frontmatter.links"); // --- Scalars --- function getScalar(key: K): FrontmatterData[K] { const val = ymap.get(key); return val !== undefined ? (val as FrontmatterData[K]) : (SCALAR_DEFAULTS[key] as FrontmatterData[K]); } function setScalar(key: K, value: FrontmatterData[K]) { ymap.set(key, value); } // --- Full snapshot --- function getAll(): FrontmatterData { const scalars: Record = {}; for (const key of Object.keys(SCALAR_DEFAULTS) as ScalarKey[]) { const val = ymap.get(key); scalars[key] = val !== undefined ? val : SCALAR_DEFAULTS[key]; } return { ...(scalars as Omit), authors: yAuthors.toArray(), affiliations: yAffiliations.toArray(), links: yLinks.toArray(), }; } function setAll(data: Partial) { ydoc.transact(() => { // `Object.entries` widens the value type to the full union of fields, so // we narrow manually per key before pushing into the typed Y.Array. if (data.authors !== undefined) { yAuthors.delete(0, yAuthors.length); yAuthors.push(data.authors); } if (data.affiliations !== undefined) { yAffiliations.delete(0, yAffiliations.length); yAffiliations.push(data.affiliations); } if (data.links !== undefined) { yLinks.delete(0, yLinks.length); yLinks.push(data.links); } for (const [key, value] of Object.entries(data)) { if (value === undefined) continue; if (key === "authors" || key === "affiliations" || key === "links") continue; ymap.set(key, value); } }); } // --- Typed getters/setters for convenience --- function get(key: K): FrontmatterData[K] { if (key === "authors") return yAuthors.toArray() as FrontmatterData[K]; if (key === "affiliations") return yAffiliations.toArray() as FrontmatterData[K]; if (key === "links") return yLinks.toArray() as FrontmatterData[K]; return getScalar(key as ScalarKey) as FrontmatterData[K]; } function set(key: K, value: FrontmatterData[K]) { if (key === "authors") { ydoc.transact(() => { yAuthors.delete(0, yAuthors.length); yAuthors.push(value as Author[]); }); } else if (key === "affiliations") { ydoc.transact(() => { yAffiliations.delete(0, yAffiliations.length); yAffiliations.push(value as Affiliation[]); }); } else if (key === "links") { ydoc.transact(() => { yLinks.delete(0, yLinks.length); yLinks.push(value as LinkItem[]); }); } else { setScalar(key as ScalarKey, value as FrontmatterData[ScalarKey]); } } // --- Authors (concurrent-safe) --- function addAuthor(author: Author) { yAuthors.push([author]); } function updateAuthor(index: number, author: Author) { if (index >= 0 && index < yAuthors.length) { ydoc.transact(() => { yAuthors.delete(index, 1); yAuthors.insert(index, [author]); }); } } function removeAuthor(index: number) { if (index >= 0 && index < yAuthors.length) { yAuthors.delete(index, 1); } } /** * Move an author from one position to another. `to` is the desired final * index in the resulting array. No-op when out of range or unchanged. * Authors don't reference each other, so no index remapping is needed. */ function moveAuthor(from: number, to: number) { const len = yAuthors.length; if (from === to || from < 0 || to < 0 || from >= len || to >= len) return; ydoc.transact(() => { const item = yAuthors.get(from); yAuthors.delete(from, 1); yAuthors.insert(to, [item]); }); } // --- Affiliations (concurrent-safe) --- function addAffiliation(affiliation: Affiliation): number { yAffiliations.push([affiliation]); return yAffiliations.length; // 1-based index } /** * Move an affiliation from one position to another. `to` is the desired * final index in the resulting array. * * Authors reference affiliations by their 1-based position, so reordering * the affiliation list would silently re-point every author's superscripts * unless we remap. We compute the old->new position mapping the move * implies and rewrite each author's `affiliations` accordingly, all inside * a single Yjs transaction so collaborators see one atomic update. */ function moveAffiliation(from: number, to: number) { const len = yAffiliations.length; if (from === to || from < 0 || to < 0 || from >= len || to >= len) return; ydoc.transact(() => { const aff = yAffiliations.get(from); yAffiliations.delete(from, 1); yAffiliations.insert(to, [aff]); // Replay the same move on the index sequence to derive old->new (1-based). const order = Array.from({ length: len }, (_, i) => i); const [moved] = order.splice(from, 1); order.splice(to, 0, moved); const oldToNew = new Map(); order.forEach((oldPos, newPos) => oldToNew.set(oldPos + 1, newPos + 1)); const authors = yAuthors.toArray(); yAuthors.delete(0, yAuthors.length); yAuthors.push( authors.map((a) => ({ ...a, affiliations: (a.affiliations || []).map((idx) => oldToNew.get(idx) ?? idx), })), ); }); } function updateAffiliation(index: number, affiliation: Affiliation) { if (index >= 0 && index < yAffiliations.length) { ydoc.transact(() => { yAffiliations.delete(index, 1); yAffiliations.insert(index, [affiliation]); }); } } /** * Remove an affiliation and reconcile every author's 1-based references: * drop the references to the deleted one, then shift higher indices down by * one so the remaining superscripts keep pointing at the right institution. */ function removeAffiliation(index: number) { const len = yAffiliations.length; if (index < 0 || index >= len) return; const removed = index + 1; // 1-based id used by authors ydoc.transact(() => { yAffiliations.delete(index, 1); const authors = yAuthors.toArray(); yAuthors.delete(0, yAuthors.length); yAuthors.push( authors.map((a) => ({ ...a, affiliations: (a.affiliations || []) .filter((idx) => idx !== removed) .map((idx) => (idx > removed ? idx - 1 : idx)), })), ); }); } // --- Observe all changes --- function observe(callback: () => void) { ymap.observe(callback); yAuthors.observe(callback); yAffiliations.observe(callback); yLinks.observe(callback); return () => { ymap.unobserve(callback); yAuthors.unobserve(callback); yAffiliations.unobserve(callback); yLinks.unobserve(callback); }; } return { get, set, getAll, setAll, addAuthor, updateAuthor, removeAuthor, moveAuthor, addAffiliation, moveAffiliation, updateAffiliation, removeAffiliation, observe, }; } export type FrontmatterStore = ReturnType;