/** * Pure document-transform helpers for Doc Mode. * * Everything here operates on plain ProseMirror-JSON (`PMNode`) and a task * lookup function — no TipTap editor, no DOM, no module-global cache. That * keeps the load-time pipeline (`prepareStoredDoc`) and the context filter * (`isInContext`) unit-testable in plain Node; see `doc-transform.spec.ts`. * * The editor (`ui/editor.ts`) owns the live `taskCache` Map and passes * `(id) => taskCache.get(id)` as the `TaskLookup` argument. */ import type { ActiveWorkContext, Task } from '@super-productivity/plugin-api'; /** Resolves a task id to the host's current task, or `undefined` if unknown. */ export type TaskLookup = (taskId: string) => Task | undefined; export type PMText = { type: 'text'; text: string }; export type PMNode = { type?: string; attrs?: Record; content?: (PMNode | PMText)[]; text?: string; }; /* -------------------------------------------------------------------------- */ /* Node builders */ /* -------------------------------------------------------------------------- */ /** * Build a single chip node. Task title is pulled from the lookup so the * `taskRef` / `subTaskRef` carries inline content, not just an id — a chip * inserted without content reads back as an empty title and would be written * back to the host as a title erasure (see `reconcileTitlesFromDoc`). */ export const taskNodeJSON = ( taskId: string, variant: 'taskRef' | 'subTaskRef', getTask: TaskLookup, ): PMNode => { const task = getTask(taskId); const title = task?.title || ''; return { type: variant, attrs: { taskId, isDone: !!task?.isDone }, content: title ? [{ type: 'text', text: title }] : [], }; }; /** A parent chip followed by one `subTaskRef` per host subtask. */ export const taskRefWithSubtasksJSON = ( taskId: string, getTask: TaskLookup, ): PMNode[] => { const task = getTask(taskId); const out: PMNode[] = [taskNodeJSON(taskId, 'taskRef', getTask)]; for (const subId of task?.subTaskIds ?? []) { out.push(taskNodeJSON(subId, 'subTaskRef', getTask)); } return out; }; /** Build a fresh doc for a context that has no stored doc yet. */ export const buildSeedDoc = (ctx: ActiveWorkContext, getTask: TaskLookup): PMNode => ({ type: 'doc', content: [ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: ctx.title }], }, ...ctx.taskIds.flatMap((id) => taskRefWithSubtasksJSON(id, getTask)), { type: 'paragraph' }, ], }); /* -------------------------------------------------------------------------- */ /* Stored-doc pipeline */ /* -------------------------------------------------------------------------- */ /** * Older docs stored taskRef as an atom node (no `content` array). Walk the * stored JSON and populate content from the task lookup so the new * content-bearing schema can load them. Idempotent — nodes that already have * content are left alone. */ export const migrateStoredDoc = (raw: unknown, getTask: TaskLookup): unknown => { const visit = (node: PMNode | PMText | undefined): PMNode | PMText | undefined => { if (!node || typeof node !== 'object') return node; if ('text' in node) return node; if (node.type === 'taskRef' || node.type === 'subTaskRef') { const taskId = (node.attrs?.taskId as string) || ''; const task = getTask(taskId); const hasContent = Array.isArray(node.content) && node.content.length > 0; return { ...node, attrs: { taskId, isDone: (node.attrs?.isDone as boolean) ?? !!task?.isDone, }, content: hasContent ? node.content : task?.title ? [{ type: 'text', text: task.title }] : [], }; } if (Array.isArray(node.content)) { return { ...node, content: node.content .map(visit) .filter((n): n is PMNode | PMText => n !== undefined), }; } return node; }; return visit(raw as PMNode); }; /** * Strip redundant chip data from a doc before it is serialized to storage. * * Each `taskRef` / `subTaskRef` chip carries inline title text (`content`) * and an `isDone` attr, but on load both are re-derived from the host task * cache: `migrateStoredDoc` backfills missing `content` and `refreshChipContentFromCache` * overwrites both unconditionally. Persisting them is therefore dead weight in * the synced payload — and the title is the byte-heavy, variable-length part. * This collapses every chip to a bare identity atom `{ type, attrs: { taskId } }`. * * Pure: returns new objects, never mutates `doc`. It runs only on the * serialized copy (`editor.getJSON()` returns a fresh object); the live * editor document keeps its inline content, so title write-back * (`reconcileTitlesFromDoc`, which reads the live doc) is unaffected. */ export const stripChipContent = (doc: unknown): unknown => { const visit = (node: PMNode | PMText | undefined): PMNode | PMText | undefined => { if (!node || typeof node !== 'object') return node; if ('text' in node) return node; if (node.type === 'taskRef' || node.type === 'subTaskRef') { return { type: node.type, attrs: { taskId: (node.attrs?.taskId as string) || '' }, }; } if (Array.isArray(node.content)) { return { ...node, content: node.content .map(visit) .filter((n): n is PMNode | PMText => n !== undefined), }; } return node; }; return visit(doc as PMNode); }; /** A paragraph with no inline content — the editor's structural landing line. */ const isEmptyParagraph = (n: PMNode | PMText): boolean => { const node = n as PMNode; return ( node.type === 'paragraph' && (!Array.isArray(node.content) || node.content.length === 0) ); }; /** * Rebuild the doc against the current context. * * - Top-level chip ORDER comes from `ctx.taskIds` (the host's canonical * order for the view): TODAY re-sorts daily, and reorders done in the * regular task view must win. Stale chips (not in `ctx`) are dropped, new * ones appended; duplicate parent groups and subtask rows are de-duped. * - Non-chip blocks (paragraphs, headings, lists, quotes, dividers) keep * their POSITION: each is anchored to the chip group it follows in the * stored doc and re-emitted directly after that group; blocks before the * first chip stay at the top. This is what stops text the user typed * *between* two tasks from collapsing to the bottom of the doc. * * A block whose anchor chip has left the context loses its anchor and is * appended after the last chip (kept, not dropped). */ export const reconcileTopLevelTaskRefs = ( doc: unknown, ctx: ActiveWorkContext, getTask: TaskLookup, ): unknown => { const root = doc as PMNode; if (!root || root.type !== 'doc' || !Array.isArray(root.content)) return doc; const src = root.content as (PMNode | PMText)[]; // Trailing empty paragraphs are the editor's structural landing line, not // content. Ignore them here and re-add exactly one at the end, so they // cannot pile up mid-doc when chips get reordered. let end = src.length; while (end > 0 && isEmptyParagraph(src[end - 1])) end--; // Pass 1: index chip groups by parent taskId (de-duping subtask rows), and // anchor every non-chip block to the taskId of the chip group before it // (`null` = before any chip). const storedGroups = new Map(); const leadingBlocks: (PMNode | PMText)[] = []; const blocksAfter = new Map(); let anchor: string | null = null; let i = 0; while (i < end) { const node = src[i] as PMNode; if (node.type === 'taskRef') { const taskId = (node.attrs?.taskId as string) || ''; const group: PMNode[] = [node]; const seenSubs = new Set(); let j = i + 1; while (j < end && (src[j] as PMNode).type === 'subTaskRef') { const subId = ((src[j] as PMNode).attrs?.taskId as string) || ''; if (subId && !seenSubs.has(subId)) { seenSubs.add(subId); group.push(src[j] as PMNode); } j++; } // First stored group for a parent wins; later duplicates are dropped. if (taskId && !storedGroups.has(taskId)) storedGroups.set(taskId, group); if (taskId) anchor = taskId; i = j; } else if (node.type === 'subTaskRef') { // Orphan subtask (no preceding parent) — drop it. i++; } else { // Any non-chip block — keep it anchored to the preceding chip group. if (anchor === null) { leadingBlocks.push(node); } else { const arr = blocksAfter.get(anchor); if (arr) arr.push(node); else blocksAfter.set(anchor, [node]); } i++; } } // Pass 2: rebuild in ctx order, re-emitting each group's anchored blocks // straight after it. const out: (PMNode | PMText)[] = [...leadingBlocks]; const ctxIds = new Set(ctx.taskIds); for (const id of ctx.taskIds) { const group = storedGroups.get(id) ?? taskRefWithSubtasksJSON(id, getTask); for (const n of group) out.push(n); const after = blocksAfter.get(id); if (after) for (const n of after) out.push(n); } // Blocks whose anchor chip is no longer in the context — keep, don't drop. for (const [taskId, blocks] of blocksAfter) { if (!ctxIds.has(taskId)) for (const n of blocks) out.push(n); } // Exactly one trailing empty paragraph so the cursor always has a home. out.push({ type: 'paragraph' }); return { ...root, content: out }; }; /** * Walk the top-level content and insert any subTaskRefs from the host that * aren't already present right after their parent taskRef. Idempotent — * existing subtask blocks are preserved. */ export const ensureSubtasksInJSON = (doc: unknown, getTask: TaskLookup): unknown => { const root = doc as PMNode; if (!root || root.type !== 'doc' || !Array.isArray(root.content)) return doc; const src = root.content as (PMNode | PMText)[]; const out: (PMNode | PMText)[] = []; let i = 0; while (i < src.length) { const node = src[i]; out.push(node); if ((node as PMNode).type === 'taskRef') { const parentId = ((node as PMNode).attrs?.taskId as string) || ''; const parent = getTask(parentId); const existing = new Set(); let j = i + 1; while (j < src.length && (src[j] as PMNode).type === 'subTaskRef') { out.push(src[j]); existing.add(((src[j] as PMNode).attrs?.taskId as string) || ''); j++; } if (parent?.subTaskIds) { for (const subId of parent.subTaskIds) { if (!subId || existing.has(subId)) continue; // Skip subs not known to the host yet — they would render as empty // "ghost" rows. They show up live via onAnyTaskUpdate instead. if (!getTask(subId)) continue; out.push(taskNodeJSON(subId, 'subTaskRef', getTask)); } } i = j; } else { i++; } } return { ...root, content: out }; }; /** * Final pass before loading: replace each chip's inline title text and * `isDone` attr with the current values from the task lookup. Without this, * stored chip nodes keep the title they had at the last save and look stale * after any external edit (host UI in another view, sync from server, app * reload) — there is no other refresh path during context load. * * Trade-off: if the user was typing in a chip and switched contexts before * the 600 ms write-back fired, those typed characters are overwritten by the * looked-up title on return. The pending timer is already cleared in * setActiveContext, so the host never saw that typing anyway — this just * makes the doc match the host. */ export const refreshChipContentFromCache = ( doc: unknown, getTask: TaskLookup, ): unknown => { const root = doc as PMNode; if (!root || root.type !== 'doc' || !Array.isArray(root.content)) return doc; const refreshed = (root.content as (PMNode | PMText)[]).map((n) => { const node = n as PMNode; if (node.type !== 'taskRef' && node.type !== 'subTaskRef') return node; const taskId = (node.attrs?.taskId as string) || ''; if (!taskId) return node; const task = getTask(taskId); if (!task) return node; const title = task.title || ''; return { ...node, attrs: { ...(node.attrs ?? {}), taskId, isDone: !!task.isDone }, content: title ? [{ type: 'text', text: title }] : [], }; }); return { ...root, content: refreshed }; }; /** * Pipeline applied to a stored doc before loading it into TipTap. Order * matters: schema migration first (canonicalises old taskRef shapes), then * top-level reconciliation against the current ctx (drops stale chips, * appends new ones), then subtask backfill (inserts host subtasks under each * kept parent), and finally a content refresh so every chip's title matches * the current host state rather than the stored snapshot. */ export const prepareStoredDoc = ( raw: unknown, ctx: ActiveWorkContext, getTask: TaskLookup, ): unknown => refreshChipContentFromCache( ensureSubtasksInJSON( reconcileTopLevelTaskRefs(migrateStoredDoc(raw, getTask), ctx, getTask), getTask, ), getTask, ); /* -------------------------------------------------------------------------- */ /* Context membership */ /* -------------------------------------------------------------------------- */ /** * Does `task` belong to the given work context? Matches the host's * view-level filter: * - PROJECT: task.projectId equals ctx.id * - TODAY: task has the TODAY tag OR a dueDay OR a dueWithTime * - TAG: task.tagIds contains ctx.id * * Subtasks (task.parentId set) are *not* surfaced at the top level — they * follow their parent. */ export const isInContext = (task: Task, ctx: ActiveWorkContext): boolean => { if (task.parentId) return false; if (ctx.type === 'PROJECT') return task.projectId === ctx.id; if (ctx.id === 'TODAY') { return !!task.tagIds?.includes('TODAY') || !!task.dueDay || !!task.dueWithTime; } if (ctx.type === 'TAG') return !!task.tagIds?.includes(ctx.id); return false; }; /** * Set of task ids that the doc for `ctx` should currently contain: every * in-context top-level task plus the ids of its subtasks. `onAnyTaskUpdate` * diffs successive snapshots to detect a task (or subtask) transitioning * into the context and append a fresh chip for it. */ export const snapshotInContextTaskIds = ( tasks: Iterable, ctx: ActiveWorkContext, ): Set => { const ids = new Set(); for (const t of tasks) { if (!isInContext(t, ctx)) continue; ids.add(t.id); for (const subId of t.subTaskIds ?? []) ids.add(subId); } return ids; };