openwriter 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BJMpYpj1.css +1 -0
- package/dist/client/assets/index-DgUPw-v5.js +214 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/package.json +1 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +8 -0
- package/dist/plugins/github/dist/blog-tools.js +792 -0
- package/dist/plugins/github/dist/git-sync.d.ts +36 -0
- package/dist/plugins/github/dist/git-sync.js +276 -0
- package/dist/plugins/github/dist/helpers.d.ts +84 -0
- package/dist/plugins/github/dist/helpers.js +62 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +102 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/server/documents.js +119 -2
- package/dist/server/index.js +31 -11
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +251 -86
- package/dist/server/peek-outline.js +87 -17
- package/dist/server/pending-metadata.js +65 -0
- package/dist/server/pending-overlay.js +151 -2
- package/dist/server/plugin-manager.js +18 -3
- package/dist/server/state.js +126 -39
- package/dist/server/ws.js +85 -26
- package/package.json +1 -1
- package/skill/SKILL.md +60 -22
- package/dist/client/assets/index-AWIKUHJ_.css +0 -1
- package/dist/client/assets/index-DmHLFNTs.js +0 -212
|
@@ -273,41 +273,108 @@ export function countWords(nodes) {
|
|
|
273
273
|
return 0;
|
|
274
274
|
return text.split(/\s+/).length;
|
|
275
275
|
}
|
|
276
|
-
/**
|
|
277
|
-
* doesn't exceed `maxWords
|
|
278
|
-
* already fits. Always includes at least one top-level node so callers
|
|
279
|
-
* never receive an empty body.
|
|
276
|
+
/** Read a region of a doc, truncated at a top-level node boundary so the
|
|
277
|
+
* returned content doesn't exceed `maxWords` (unless `force` is set).
|
|
280
278
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
279
|
+
* Three modes:
|
|
280
|
+
* - **Default** (no slice, no force) — first N words of the doc, capped at
|
|
281
|
+
* `maxWords`. read_pad's original contract: doc opening + continuation hint.
|
|
282
|
+
* - **Slice** (`{ from, to }`) — words in the percentile range. Snaps to top-level
|
|
283
|
+
* node boundaries so blocks aren't split mid-sentence. Still subject to
|
|
284
|
+
* `maxWords` unless `force` is set; an agent walking 10% chunks of a large
|
|
285
|
+
* doc gets predictable per-call cost.
|
|
286
|
+
* - **Force** — bypass the cap. Returns the full requested region (whole doc
|
|
287
|
+
* if no slice, the whole slice if sliced). Use for full-doc audits and
|
|
288
|
+
* rewrites where the agent has explicitly accepted the cost.
|
|
286
289
|
*
|
|
287
290
|
* Node-boundary truncation (never splits a top-level block) keeps the
|
|
288
291
|
* returned slice structurally valid markdown — list items, blockquotes,
|
|
289
292
|
* and code blocks stay intact. */
|
|
290
|
-
export function truncateRead(doc,
|
|
293
|
+
export function truncateRead(doc, options = {}) {
|
|
294
|
+
const maxWords = options.maxWords ?? 2000;
|
|
295
|
+
const force = !!options.force;
|
|
296
|
+
const sliceIn = options.slice ?? null;
|
|
297
|
+
// Clamp/validate slice — clamp to [0,1], silently fix swapped or degenerate inputs.
|
|
298
|
+
let slice = null;
|
|
299
|
+
if (sliceIn) {
|
|
300
|
+
const from = Math.max(0, Math.min(1, sliceIn.from));
|
|
301
|
+
const to = Math.max(0, Math.min(1, sliceIn.to));
|
|
302
|
+
slice = from < to ? { from, to } : { from: 0, to: 1 };
|
|
303
|
+
}
|
|
291
304
|
const content = doc.content || [];
|
|
292
305
|
const totalWords = countWords(content);
|
|
293
306
|
const lastTopId = (n) => n?.attrs?.id ?? null;
|
|
294
|
-
|
|
307
|
+
// Empty doc — nothing to slice or truncate.
|
|
308
|
+
if (content.length === 0) {
|
|
295
309
|
return {
|
|
296
310
|
doc,
|
|
297
311
|
truncated: false,
|
|
312
|
+
totalWords: 0,
|
|
313
|
+
returnedWords: 0,
|
|
314
|
+
firstNodeId: null,
|
|
315
|
+
lastNodeId: null,
|
|
316
|
+
remaining: 0,
|
|
317
|
+
slice,
|
|
318
|
+
forced: force,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// Step 1: pick the candidate node range based on slice (or all nodes if no slice).
|
|
322
|
+
// Word-position-based: walk top-level nodes, snap to boundaries that contain
|
|
323
|
+
// the slice's word range. A node is included if any of its words fall inside
|
|
324
|
+
// [fromWord, toWord) — rounds outward, agent always gets whole blocks.
|
|
325
|
+
let candidateStart = 0;
|
|
326
|
+
let candidateEnd = content.length;
|
|
327
|
+
let candidateTotalWords = totalWords;
|
|
328
|
+
if (slice) {
|
|
329
|
+
const fromWord = Math.floor(slice.from * totalWords);
|
|
330
|
+
const toWord = Math.ceil(slice.to * totalWords);
|
|
331
|
+
let cum = 0;
|
|
332
|
+
let startIdx = -1;
|
|
333
|
+
let endIdx = content.length;
|
|
334
|
+
for (let i = 0; i < content.length; i++) {
|
|
335
|
+
const w = countWords([content[i]]);
|
|
336
|
+
const nodeStart = cum;
|
|
337
|
+
const nodeEnd = cum + w;
|
|
338
|
+
// Include node if its word span overlaps [fromWord, toWord).
|
|
339
|
+
if (startIdx === -1 && nodeEnd > fromWord)
|
|
340
|
+
startIdx = i;
|
|
341
|
+
if (startIdx !== -1 && nodeStart >= toWord) {
|
|
342
|
+
endIdx = i;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
cum += w;
|
|
346
|
+
}
|
|
347
|
+
if (startIdx === -1)
|
|
348
|
+
startIdx = content.length - 1;
|
|
349
|
+
candidateStart = startIdx;
|
|
350
|
+
candidateEnd = endIdx;
|
|
351
|
+
candidateTotalWords = countWords(content.slice(candidateStart, candidateEnd));
|
|
352
|
+
}
|
|
353
|
+
// Step 2: apply maxWords cap to the candidate range (unless forced).
|
|
354
|
+
const candidate = content.slice(candidateStart, candidateEnd);
|
|
355
|
+
if (force || candidateTotalWords <= maxWords) {
|
|
356
|
+
const firstNodeId = lastTopId(candidate[0]);
|
|
357
|
+
const lastNodeId = lastTopId(candidate[candidate.length - 1]);
|
|
358
|
+
return {
|
|
359
|
+
doc: { ...doc, content: candidate },
|
|
360
|
+
truncated: false,
|
|
298
361
|
totalWords,
|
|
299
|
-
returnedWords:
|
|
300
|
-
|
|
362
|
+
returnedWords: candidateTotalWords,
|
|
363
|
+
firstNodeId,
|
|
364
|
+
lastNodeId,
|
|
301
365
|
remaining: 0,
|
|
366
|
+
slice,
|
|
367
|
+
forced: force,
|
|
302
368
|
};
|
|
303
369
|
}
|
|
370
|
+
// Step 3: candidate exceeds cap — truncate from the start of the candidate
|
|
371
|
+
// range, including whole top-level blocks until adding the next would exceed
|
|
372
|
+
// the cap. Always include at least one node.
|
|
304
373
|
const included = [];
|
|
305
374
|
let words = 0;
|
|
306
375
|
let lastNodeId = null;
|
|
307
|
-
for (const n of
|
|
376
|
+
for (const n of candidate) {
|
|
308
377
|
const w = countWords([n]);
|
|
309
|
-
// Always include at least one node — even if it alone exceeds the cap,
|
|
310
|
-
// an empty body would be a worse failure mode than a slightly oversize one.
|
|
311
378
|
if (included.length > 0 && words + w > maxWords)
|
|
312
379
|
break;
|
|
313
380
|
included.push(n);
|
|
@@ -320,8 +387,11 @@ export function truncateRead(doc, maxWords) {
|
|
|
320
387
|
truncated: true,
|
|
321
388
|
totalWords,
|
|
322
389
|
returnedWords: words,
|
|
390
|
+
firstNodeId: lastTopId(included[0]),
|
|
323
391
|
lastNodeId,
|
|
324
|
-
remaining:
|
|
392
|
+
remaining: candidateTotalWords - words,
|
|
393
|
+
slice,
|
|
394
|
+
forced: false,
|
|
325
395
|
};
|
|
326
396
|
}
|
|
327
397
|
/** Find blocks whose text matches the query inside one doc. Returns up to
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending metadata — sibling of pending-overlay.ts for frontmatter-level
|
|
3
|
+
* staging. Where the overlay holds proposed BLOCK changes (insert / rewrite
|
|
4
|
+
* / delete on TipTap nodes), this module holds proposed METADATA changes —
|
|
5
|
+
* for now just the document title.
|
|
6
|
+
*
|
|
7
|
+
* Architectural model:
|
|
8
|
+
* - Pending metadata lives in the SAME sidecar file as the block overlay
|
|
9
|
+
* (_pending/{docId}.json), under a top-level `metadata:` key. The
|
|
10
|
+
* pending-overlay module owns the `entries:` key; this module owns the
|
|
11
|
+
* `metadata:` key. Each preserves the other's slot when it writes.
|
|
12
|
+
* - The canonical disk .md file's frontmatter is NEVER modified by a
|
|
13
|
+
* pending stage — the title only changes on disk after the user accepts.
|
|
14
|
+
* Reject discards the proposal; nothing on disk moves.
|
|
15
|
+
* - The active document's pending metadata is mirrored into state.ts as
|
|
16
|
+
* `state.pendingMetadata` so WS broadcasts and getters expose it without
|
|
17
|
+
* a disk read on every poll.
|
|
18
|
+
*
|
|
19
|
+
* Why a separate module from pending-overlay.ts:
|
|
20
|
+
* - The overlay model is keyed by nodeId and assumes a TipTap tree. Title
|
|
21
|
+
* is not in the tree — it's a YAML frontmatter field. Forcing it into a
|
|
22
|
+
* fake "node" would corrupt the overlay invariants (nodeId stability,
|
|
23
|
+
* splitMergedDoc tree-walk, applyOverlayPure idempotency).
|
|
24
|
+
* - Future metadata-staging (tags, status, custom frontmatter fields) lands
|
|
25
|
+
* here without further churn to the overlay code path.
|
|
26
|
+
*
|
|
27
|
+
* Scope (phase 1): document title only. tag_doc / untag_doc / set_metadata
|
|
28
|
+
* for non-title fields / mark_enriched still write hot. Workspace and
|
|
29
|
+
* container renames also still write hot — they live in workspace manifests,
|
|
30
|
+
* not per-doc sidecars, and need a separate decision.
|
|
31
|
+
*
|
|
32
|
+
* adr: adr/pending-overlay-model.md
|
|
33
|
+
*/
|
|
34
|
+
import { readSidecarRaw, writeSidecarRaw } from './pending-overlay.js';
|
|
35
|
+
import { logger } from './logger.js';
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// SIDECAR I/O
|
|
38
|
+
// ============================================================================
|
|
39
|
+
/** Read the pending-metadata slot from the per-doc sidecar. Returns null if
|
|
40
|
+
* the sidecar is missing or has no metadata slot. */
|
|
41
|
+
export function loadPendingMetadata(docId) {
|
|
42
|
+
if (!docId)
|
|
43
|
+
return null;
|
|
44
|
+
const raw = readSidecarRaw(docId);
|
|
45
|
+
if (!raw?.metadata || Object.keys(raw.metadata).length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
return raw.metadata;
|
|
48
|
+
}
|
|
49
|
+
/** Persist the pending-metadata slot. Preserves the sidecar's existing
|
|
50
|
+
* `entries:` slot. Passing null (or an empty object) clears the metadata
|
|
51
|
+
* and may delete the sidecar entirely if entries are also empty. */
|
|
52
|
+
export function savePendingMetadata(docId, meta) {
|
|
53
|
+
if (!docId)
|
|
54
|
+
return;
|
|
55
|
+
const raw = readSidecarRaw(docId);
|
|
56
|
+
const entries = Array.isArray(raw?.entries) ? raw.entries : [];
|
|
57
|
+
const cleaned = meta && Object.keys(meta).length > 0 ? meta : null;
|
|
58
|
+
writeSidecarRaw(docId, { entries, metadata: cleaned || undefined });
|
|
59
|
+
if (cleaned?.title) {
|
|
60
|
+
logger.info('overlay', 'meta-stage', `docId=${docId} field=title from="${cleaned.title.from}" to="${cleaned.title.to}"`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
logger.info('overlay', 'meta-clear', `docId=${docId}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync, rmSync } from 'fs';
|
|
35
35
|
import { join } from 'path';
|
|
36
|
-
import { getDataDir, atomicWriteFileSync } from './helpers.js';
|
|
36
|
+
import { getDataDir, atomicWriteFileSync, resolveDocPath } from './helpers.js';
|
|
37
|
+
import { markdownToTiptap } from './markdown-parse.js';
|
|
37
38
|
// ============================================================================
|
|
38
39
|
// DIAGNOSTIC HELPERS — node-text preview + entry summary for log readability
|
|
39
40
|
// ============================================================================
|
|
@@ -107,6 +108,52 @@ export function loadOverlay(docId) {
|
|
|
107
108
|
return [];
|
|
108
109
|
}
|
|
109
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Read the entire sidecar JSON object (raw). Used by pending-metadata.ts so
|
|
113
|
+
* it can read the `metadata:` slot without re-implementing file I/O. Returns
|
|
114
|
+
* null when the sidecar is missing or unreadable.
|
|
115
|
+
*/
|
|
116
|
+
export function readSidecarRaw(docId) {
|
|
117
|
+
if (!docId)
|
|
118
|
+
return null;
|
|
119
|
+
const path = getSidecarPath(docId);
|
|
120
|
+
if (!existsSync(path))
|
|
121
|
+
return null;
|
|
122
|
+
try {
|
|
123
|
+
const raw = readFileSync(path, 'utf-8');
|
|
124
|
+
return JSON.parse(raw);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Atomically write the sidecar with the full JSON object. Both pending-overlay
|
|
132
|
+
* (entries) and pending-metadata (metadata slot) share this single file, so
|
|
133
|
+
* each must preserve the other's slot on every write. This is the low-level
|
|
134
|
+
* primitive both use.
|
|
135
|
+
*
|
|
136
|
+
* If the resulting object has no entries AND no metadata, the sidecar is
|
|
137
|
+
* deleted (absence = "nothing pending for this doc").
|
|
138
|
+
*/
|
|
139
|
+
export function writeSidecarRaw(docId, payload) {
|
|
140
|
+
if (!docId)
|
|
141
|
+
return;
|
|
142
|
+
const hasEntries = Array.isArray(payload.entries) && payload.entries.length > 0;
|
|
143
|
+
const hasMeta = !!payload.metadata && Object.keys(payload.metadata).length > 0;
|
|
144
|
+
if (!hasEntries && !hasMeta) {
|
|
145
|
+
deleteOverlay(docId);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
ensurePendingDir();
|
|
149
|
+
const path = getSidecarPath(docId);
|
|
150
|
+
const out = { version: 1 };
|
|
151
|
+
if (hasEntries)
|
|
152
|
+
out.entries = payload.entries;
|
|
153
|
+
if (hasMeta)
|
|
154
|
+
out.metadata = payload.metadata;
|
|
155
|
+
atomicWriteFileSync(path, JSON.stringify(out, null, 2));
|
|
156
|
+
}
|
|
110
157
|
export function saveOverlay(docId, entries) {
|
|
111
158
|
if (!docId)
|
|
112
159
|
return;
|
|
@@ -156,7 +203,22 @@ export function saveOverlay(docId, entries) {
|
|
|
156
203
|
if (!newById.has(e.nodeId))
|
|
157
204
|
changes.push(`-${entrySummary(e)}`);
|
|
158
205
|
}
|
|
206
|
+
// Preserve the sidecar's `metadata` slot (used by pending-metadata.ts) across
|
|
207
|
+
// entries-only writes. Without this read-modify-write, saving entries would
|
|
208
|
+
// clobber any pending title-rename staged for the same doc.
|
|
209
|
+
// adr: adr/pending-overlay-model.md
|
|
210
|
+
const prevRaw = readSidecarRaw(docId);
|
|
211
|
+
const prevMetadata = prevRaw?.metadata && Object.keys(prevRaw.metadata).length > 0 ? prevRaw.metadata : null;
|
|
159
212
|
if (dedupedEntries.length === 0) {
|
|
213
|
+
if (prevMetadata) {
|
|
214
|
+
// Entries empty but metadata still pending — keep the sidecar alive with
|
|
215
|
+
// metadata only.
|
|
216
|
+
writeSidecarRaw(docId, { metadata: prevMetadata });
|
|
217
|
+
if (prevEntries.length > 0) {
|
|
218
|
+
diagLog(`[Overlay] SAVE docId=${docId} entries=0 (was ${prevEntries.length}) metadata preserved`);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
160
222
|
if (prevEntries.length > 0) {
|
|
161
223
|
diagLog(`[Overlay] SAVE docId=${docId} → DELETE (was ${prevEntries.length} entries)`);
|
|
162
224
|
}
|
|
@@ -165,7 +227,10 @@ export function saveOverlay(docId, entries) {
|
|
|
165
227
|
}
|
|
166
228
|
ensurePendingDir();
|
|
167
229
|
const path = getSidecarPath(docId);
|
|
168
|
-
|
|
230
|
+
const out = { version: 1, entries: dedupedEntries };
|
|
231
|
+
if (prevMetadata)
|
|
232
|
+
out.metadata = prevMetadata;
|
|
233
|
+
atomicWriteFileSync(path, JSON.stringify(out, null, 2));
|
|
169
234
|
if (changes.length > 0) {
|
|
170
235
|
diagLog(`[Overlay] SAVE docId=${docId} entries=${dedupedEntries.length} changes=[${changes.join(' | ')}]`);
|
|
171
236
|
}
|
|
@@ -558,6 +623,24 @@ export function applyOverlay(canonical, entries) {
|
|
|
558
623
|
* adr: adr/pending-overlay-model.md
|
|
559
624
|
*/
|
|
560
625
|
export function applyOverlayPure(canonical, entries) {
|
|
626
|
+
// Strip the parser-fallback / createDocumentFile-stub empty paragraph
|
|
627
|
+
// before merging. markdownToTiptap mints a placeholder empty paragraph
|
|
628
|
+
// whenever the disk body has no block content (TipTap requires at least
|
|
629
|
+
// one node), and createDocumentFile mints the same shape for fresh empty
|
|
630
|
+
// docs. Both surface as a trailing empty `[p:...]` in the merged view
|
|
631
|
+
// whenever the doc's real content lives only in the overlay. We strip
|
|
632
|
+
// ONLY when canonical is exactly that single empty-paragraph shape AND
|
|
633
|
+
// no overlay entry anchors to it — so steady-state docs with real
|
|
634
|
+
// canonical content (or where an overlay entry references the empty
|
|
635
|
+
// paragraph deliberately, like a pending-delete on a user-emptied node)
|
|
636
|
+
// are untouched. adr: adr/pending-overlay-model.md
|
|
637
|
+
if (entries.length > 0 && isFallbackEmptyCanonical(canonical)) {
|
|
638
|
+
const fallbackId = canonical.content[0]?.attrs?.id;
|
|
639
|
+
const referenced = entries.some((e) => e.nodeId === fallbackId || e.afterNodeId === fallbackId || e.parentNodeId === fallbackId);
|
|
640
|
+
if (!referenced) {
|
|
641
|
+
canonical = { ...canonical, content: [] };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
561
644
|
const merged = canonical ? JSON.parse(JSON.stringify(canonical)) : { type: 'doc', content: [] };
|
|
562
645
|
if (entries.length === 0)
|
|
563
646
|
return merged;
|
|
@@ -860,3 +943,69 @@ export function migrateLegacyPending(doc, legacyPending) {
|
|
|
860
943
|
walk(doc?.content || [], null);
|
|
861
944
|
return entries;
|
|
862
945
|
}
|
|
946
|
+
// ============================================================================
|
|
947
|
+
// MERGED-VIEW DOC LOADER
|
|
948
|
+
// ============================================================================
|
|
949
|
+
/**
|
|
950
|
+
* Load a doc from disk WITH its sidecar overlay applied — returns the
|
|
951
|
+
* user-visible merged view. This is the canonical reader for any code path
|
|
952
|
+
* that surfaces a non-active doc to the user (MCP read tools, browser HTTP
|
|
953
|
+
* fetches, anything that says "give me the doc").
|
|
954
|
+
*
|
|
955
|
+
* Why this exists. fb666e6 (May 2026) split persistence into two surfaces:
|
|
956
|
+
* the .md body holds canonical content, the per-docId sidecar at
|
|
957
|
+
* `_pending/{docId}.json` holds the pending overlay. Writes were updated
|
|
958
|
+
* to handle both halves symmetrically; reads weren't. The bare
|
|
959
|
+
* `markdownToTiptap` returns canonical-only — anyone calling it directly
|
|
960
|
+
* and treating the result as "the doc" silently drops the user's pending
|
|
961
|
+
* content. This function closes that asymmetry: there is one entry point
|
|
962
|
+
* for "load the doc," and it always materializes the full merged view.
|
|
963
|
+
*
|
|
964
|
+
* Callers that explicitly want canonical-only (the save-time matcher, the
|
|
965
|
+
* on-disk identity persistence path, sync-check roundtripping) continue to
|
|
966
|
+
* call `markdownToTiptap` directly. Those callsites are internal to the
|
|
967
|
+
* persistence layer and deliberately operate on the pre-overlay shape.
|
|
968
|
+
*
|
|
969
|
+
* Throws if the file doesn't exist (matches resolveDocTarget's existing
|
|
970
|
+
* behavior; callers that want soft-failure should check existsSync first).
|
|
971
|
+
*
|
|
972
|
+
* adr: adr/pending-overlay-model.md
|
|
973
|
+
*/
|
|
974
|
+
export function loadDocFromDisk(filename) {
|
|
975
|
+
const targetPath = resolveDocPath(filename);
|
|
976
|
+
if (!existsSync(targetPath)) {
|
|
977
|
+
throw new Error(`Document file not found: ${filename}`);
|
|
978
|
+
}
|
|
979
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
980
|
+
const parsed = markdownToTiptap(raw);
|
|
981
|
+
const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string')
|
|
982
|
+
? parsed.metadata.docId
|
|
983
|
+
: '';
|
|
984
|
+
let document = parsed.document;
|
|
985
|
+
if (docId) {
|
|
986
|
+
const overlayEntries = loadOverlay(docId);
|
|
987
|
+
if (overlayEntries.length > 0) {
|
|
988
|
+
// applyOverlayPure handles the parser-fallback / stub-empty strip
|
|
989
|
+
// itself — see the comment block at its top for why.
|
|
990
|
+
document = applyOverlayPure(parsed.document, overlayEntries);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
document,
|
|
995
|
+
title: parsed.title,
|
|
996
|
+
metadata: parsed.metadata,
|
|
997
|
+
docId,
|
|
998
|
+
rawFrontmatter: parsed.rawFrontmatter,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/** True when a parsed doc's canonical body is `[{ paragraph with no content }]`
|
|
1002
|
+
* — the markdownToTiptap fallback shape that signals "disk body had no
|
|
1003
|
+
* block-level content." Genuine user empty paragraphs in a doc with other
|
|
1004
|
+
* real content don't trip this (length check is strict); only the parser
|
|
1005
|
+
* fallback's single-node case matches. */
|
|
1006
|
+
function isFallbackEmptyCanonical(canonical) {
|
|
1007
|
+
if (!canonical?.content || canonical.content.length !== 1)
|
|
1008
|
+
return false;
|
|
1009
|
+
const node = canonical.content[0];
|
|
1010
|
+
return node?.type === 'paragraph' && (!node.content || node.content.length === 0);
|
|
1011
|
+
}
|
|
@@ -146,12 +146,27 @@ export class PluginManager {
|
|
|
146
146
|
}
|
|
147
147
|
return resolved;
|
|
148
148
|
}
|
|
149
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Persist enabled/config state to ~/.openwriter/config.json.
|
|
151
|
+
*
|
|
152
|
+
* IMPORTANT: plugins can store arbitrary nested data on their own slot
|
|
153
|
+
* (e.g. the github plugin stores `blogSites: [...]`). This writer
|
|
154
|
+
* preserves any such keys by merging into the existing on-disk slot
|
|
155
|
+
* rather than rebuilding the slot from scratch. Without this preserve
|
|
156
|
+
* step, every plugin enable/disable/config edit would silently drop
|
|
157
|
+
* blogSites and any other plugin-owned data.
|
|
158
|
+
*
|
|
159
|
+
* adr: adr/plugin-slot-nested-data.md
|
|
160
|
+
*/
|
|
150
161
|
savePluginState() {
|
|
151
|
-
const
|
|
162
|
+
const current = readConfig();
|
|
163
|
+
const existing = (current.plugins || {});
|
|
164
|
+
const pluginsState = { ...existing };
|
|
152
165
|
for (const [name, managed] of this.plugins) {
|
|
166
|
+
const prior = (existing[name] || {});
|
|
153
167
|
pluginsState[name] = {
|
|
154
|
-
|
|
168
|
+
...prior, // preserve blogSites + any other plugin-owned data
|
|
169
|
+
enabled: managed.enabled, // overwrite managed fields
|
|
155
170
|
config: managed.config,
|
|
156
171
|
};
|
|
157
172
|
}
|