mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-28 18:20:42 +00:00
* 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.
401 lines
15 KiB
TypeScript
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;
|
|
};
|