super-productivity/packages/plugin-dev/doc-mode/src/doc-transform.ts
Johannes Millan 2580ea6eb1
perf(plugins): skip no-op doc-mode saves + rename to doc-mode (#7825)
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality

Closes #7815.

flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.

The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.

Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.

Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).

* refactor(plugins): apply doc-mode no-op-save review findings (#7815)

Multi-review follow-ups for #7815:

- Tighten serializeContextDoc determinism test: previously asserted
  equality between a doc and its `{...doc}` spread, which preserves V8
  insertion order — the test would pass even against a future encoder
  that randomised iteration over Map-backed nodes (the real failure
  mode for the byte-compare). Now asserts repeated-call determinism on
  the same input directly, plus type+non-empty shape so a future return-
  type change (e.g. Uint8Array) breaks here, not in the editor.

- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
  lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
  AFTER `await`. Both are correct (the sync path is fire-and-forget and
  the iframe can be torn down mid-call, so a post-dispatch stamp would
  silently drop on teardown) but the divergence wasn't called out in
  the inline comments.

* refactor(plugins): rename document-mode to doc-mode

Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.

Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
  plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry

Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).

The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.

* refactor(plugins): apply doc-mode rename review findings

Multi-review follow-ups for the rename in 3443bcacd0:

- editor.ts:1920: missed runtime log string ('Document mode:' →
  'doc-mode:') — leaked into the dev console with inconsistent brand
  because the rename sweep matched on the kebab `document-mode` or the
  PascalCase `Document Mode`, not the bare-space `Document mode`.
- editor.ts + background.ts header JSDoc: `Document-Mode editor` /
  `Document-Mode background script` → `Doc-Mode …`. Cosmetic but the
  only remaining branded references in the source.
- scripts/build.js: esbuild iife `globalName: 'DocumentModePlugin'` →
  `'DocModePlugin'`. Internal global, no consumers, but worth keeping
  consistent with the new id.
- src/app/plugins/plugin.service.ts: add a header comment to
  BUNDLED_PLUGIN_PATHS noting that pluginIds become entityId prefixes in
  IDB / op-log / sync wire and must be treated as permanent for any
  plugin that has ever shipped. Records the rule we relied on being
  absent for this rename.

DOM-document references (`installDocumentDragHandlers`,
`Document-status banner`, `Document-level pointer handlers`) left
unchanged — those refer to the browser document object, not the brand.
Historical plan docs in `docs/plans/2026-05-2[123]-*.md` also left
unchanged per the rename commit's intent.
2026-05-27 17:30:38 +02:00

401 lines
15 KiB
TypeScript

/**
* 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<string, unknown>;
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<string, PMNode[]>();
const leadingBlocks: (PMNode | PMText)[] = [];
const blocksAfter = new Map<string, (PMNode | PMText)[]>();
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<string>();
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<string>();
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<Task>,
ctx: ActiveWorkContext,
): Set<string> => {
const ids = new Set<string>();
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;
};