openwriter 0.12.1 → 0.14.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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-CNmzNvB_.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-CRImKlcp.css">
13
+ <script type="module" crossorigin src="/assets/index-BxI3DazW.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-OV13QtgQ.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Backlinks engine: keeps each doc's frontmatter `backlinks` field in sync
3
+ * with the forward links pointing at it from other docs.
4
+ *
5
+ * Design:
6
+ * - Forward links in prose = source of truth (link mark with `doc:` href).
7
+ * - Backlinks frontmatter = derived projection, eventually consistent.
8
+ * - Incremental on save: when a doc's forward links change, only the
9
+ * affected target docs get their backlinks refreshed.
10
+ * - Full rebuild via /api/rebuild-backlinks (idempotent rescue path).
11
+ *
12
+ * Frontmatter schema (lean — anchor text + refs only, no snippet/context):
13
+ * backlinks:
14
+ * - text: "the territorial imperative"
15
+ * from_doc: a3f2c1d4 # source docId
16
+ * from_node: f6c3830d # source nodeId where link mark lives
17
+ * to_node: 1a2b3c4d # optional: target nodeId being linked to
18
+ */
19
+ import { readFileSync, existsSync, readdirSync } from 'fs';
20
+ import { join } from 'path';
21
+ import matter from 'gray-matter';
22
+ import { getDataDir, atomicWriteFileSync, resolveDocPath, isExternalDoc } from './helpers.js';
23
+ import { filenameByDocId } from './documents.js';
24
+ import { markdownToTiptap } from './markdown-parse.js';
25
+ const HEX8 = /^[a-f0-9]{8}$/;
26
+ const ANCHOR_TEXT_MAX = 80; // truncate long anchor text in backlinks frontmatter
27
+ /** Parse the post-`doc:` portion of an href into {docId, nodeId}. Mirrors src/editor/link-href.ts. */
28
+ function parseDocHref(href) {
29
+ if (!href.startsWith('doc:'))
30
+ return { docId: null, nodeId: null };
31
+ let body = href.slice(4);
32
+ // Strip query string (?q=...) — only needed for client-side scroll
33
+ const qIdx = body.indexOf('?q=');
34
+ if (qIdx >= 0)
35
+ body = body.slice(0, qIdx);
36
+ // Split fragment
37
+ let target = body, nodeId = null;
38
+ const hashIdx = body.indexOf('#');
39
+ if (hashIdx >= 0) {
40
+ const frag = body.slice(hashIdx + 1);
41
+ nodeId = HEX8.test(frag) ? frag : null;
42
+ target = body.slice(0, hashIdx);
43
+ }
44
+ return {
45
+ docId: HEX8.test(target) ? target : null,
46
+ nodeId,
47
+ };
48
+ }
49
+ function truncate(s, n) {
50
+ if (s.length <= n)
51
+ return s;
52
+ return s.slice(0, n - 1) + '…';
53
+ }
54
+ /**
55
+ * Walk a TipTap doc, return every `doc:` link found.
56
+ * Each entry includes the enclosing block's nodeId (from_node) and the
57
+ * anchor text (the text wrapped by the link mark).
58
+ */
59
+ export function extractForwardLinks(doc, sourceDocId) {
60
+ const links = [];
61
+ function walkBlock(block) {
62
+ if (!block)
63
+ return;
64
+ const blockId = block.attrs?.id;
65
+ if (Array.isArray(block.content)) {
66
+ // Inline pass: collect contiguous text runs with same link mark
67
+ // (a mark may span multiple text nodes if other marks toggle inside)
68
+ let currentHref = null;
69
+ let currentText = [];
70
+ const flush = () => {
71
+ if (currentHref && currentText.length > 0 && blockId) {
72
+ const parsed = parseDocHref(currentHref);
73
+ if (parsed.docId) {
74
+ links.push({
75
+ text: truncate(currentText.join(''), ANCHOR_TEXT_MAX),
76
+ from_doc: sourceDocId,
77
+ from_node: blockId,
78
+ to_doc: parsed.docId,
79
+ ...(parsed.nodeId ? { to_node: parsed.nodeId } : {}),
80
+ });
81
+ }
82
+ }
83
+ currentText = [];
84
+ };
85
+ for (const child of block.content) {
86
+ if (child.type === 'text') {
87
+ const linkMark = (child.marks || []).find((m) => m.type === 'link');
88
+ const href = linkMark?.attrs?.href || null;
89
+ if (href !== currentHref) {
90
+ flush();
91
+ currentHref = href;
92
+ }
93
+ if (href && child.text)
94
+ currentText.push(child.text);
95
+ }
96
+ else {
97
+ flush();
98
+ currentHref = null;
99
+ // Recurse into nested block-level content (e.g., listItem -> paragraph)
100
+ walkBlock(child);
101
+ }
102
+ }
103
+ flush();
104
+ // Also recurse for blocks whose content is sub-blocks (list, blockquote)
105
+ for (const child of block.content) {
106
+ if (child.type && child.type !== 'text' && Array.isArray(child.content)) {
107
+ // Already handled above via flush() recursion guard — skip duplicate
108
+ // (We need to recurse into blocks that contain block-level content,
109
+ // but not the text children we already iterated.)
110
+ }
111
+ }
112
+ }
113
+ }
114
+ if (doc?.content) {
115
+ for (const node of doc.content)
116
+ walkBlock(node);
117
+ }
118
+ return links;
119
+ }
120
+ /**
121
+ * Read a doc's frontmatter from disk and parse it.
122
+ * Returns null if the file doesn't exist or can't be parsed.
123
+ */
124
+ function readFrontmatter(filename) {
125
+ try {
126
+ const filePath = resolveDocPath(filename);
127
+ if (!existsSync(filePath))
128
+ return null;
129
+ const raw = readFileSync(filePath, 'utf-8');
130
+ const parsed = matter(raw);
131
+ return { data: parsed.data, content: parsed.content, rawMatter: parsed.matter };
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ /**
138
+ * Write a doc's file with updated frontmatter (preserves body verbatim).
139
+ * Only touches the frontmatter — does NOT re-serialize the body, which would
140
+ * lose nodeIds and reformat. This is safe to call on non-active docs.
141
+ */
142
+ function writeFrontmatter(filename, newData) {
143
+ const filePath = resolveDocPath(filename);
144
+ const raw = readFileSync(filePath, 'utf-8');
145
+ const parsed = matter(raw);
146
+ // Clean up: drop null/undefined fields
147
+ for (const key of Object.keys(newData)) {
148
+ if (newData[key] === undefined || newData[key] === null)
149
+ delete newData[key];
150
+ else if (Array.isArray(newData[key]) && newData[key].length === 0)
151
+ delete newData[key];
152
+ }
153
+ // Match the project convention: JSON-encoded YAML frontmatter
154
+ const newFrontmatter = `---\n${JSON.stringify(newData)}\n---\n\n${parsed.content.trimStart()}`;
155
+ // Avoid no-op writes
156
+ if (newFrontmatter === raw)
157
+ return;
158
+ atomicWriteFileSync(filePath, newFrontmatter);
159
+ }
160
+ /** Convert ForwardLinks targeting a given doc into Backlink entries for its frontmatter. */
161
+ function toBacklinks(targetDocId, allLinks) {
162
+ return allLinks
163
+ .filter((l) => l.to_doc === targetDocId)
164
+ .map((l) => {
165
+ const entry = {
166
+ text: l.text,
167
+ from_doc: l.from_doc,
168
+ from_node: l.from_node,
169
+ };
170
+ if (l.to_node)
171
+ entry.to_node = l.to_node;
172
+ return entry;
173
+ });
174
+ }
175
+ /**
176
+ * Incremental update: source doc's forward links changed from oldLinks to newLinks.
177
+ * Update each affected target doc's backlinks frontmatter.
178
+ *
179
+ * If `currentDocMetadata` is provided, it's the live in-memory metadata for the
180
+ * source doc (the active doc). The caller is responsible for persisting it.
181
+ * For OTHER target docs we touch their files directly.
182
+ */
183
+ export function updateBacklinksForSource(sourceDocId, newLinks, oldLinks) {
184
+ const oldTargets = new Set(oldLinks.map((l) => l.to_doc));
185
+ const newTargets = new Set(newLinks.map((l) => l.to_doc));
186
+ const affected = new Set([...oldTargets, ...newTargets]);
187
+ const touched = [];
188
+ for (const targetDocId of affected) {
189
+ if (targetDocId === sourceDocId)
190
+ continue; // Skip self-links (rare; if any, handled by caller)
191
+ const targetFilename = filenameByDocId(targetDocId);
192
+ if (!targetFilename) {
193
+ // Target doc not found anywhere — broken link, source-side surface added in a follow-up
194
+ continue;
195
+ }
196
+ const fm = readFrontmatter(targetFilename);
197
+ if (!fm)
198
+ continue;
199
+ // Pull all existing backlinks, drop ones from this source, then add new
200
+ const existing = Array.isArray(fm.data.backlinks) ? fm.data.backlinks : [];
201
+ const kept = existing.filter((b) => b.from_doc !== sourceDocId);
202
+ const fromThisSource = newLinks
203
+ .filter((l) => l.to_doc === targetDocId)
204
+ .map((l) => {
205
+ const entry = {
206
+ text: l.text,
207
+ from_doc: l.from_doc,
208
+ from_node: l.from_node,
209
+ };
210
+ if (l.to_node)
211
+ entry.to_node = l.to_node;
212
+ return entry;
213
+ });
214
+ const updated = [...kept, ...fromThisSource];
215
+ // Stable ordering for diff-friendliness: by from_doc, from_node
216
+ updated.sort((a, b) => {
217
+ if (a.from_doc !== b.from_doc)
218
+ return a.from_doc < b.from_doc ? -1 : 1;
219
+ return a.from_node < b.from_node ? -1 : 1;
220
+ });
221
+ const newData = { ...fm.data };
222
+ if (updated.length > 0)
223
+ newData.backlinks = updated;
224
+ else
225
+ delete newData.backlinks;
226
+ try {
227
+ writeFrontmatter(targetFilename, newData);
228
+ touched.push(targetDocId);
229
+ }
230
+ catch {
231
+ // Best-effort — skip on error
232
+ }
233
+ }
234
+ return { touched };
235
+ }
236
+ /**
237
+ * Read all docs in the data dir, return their parsed frontmatter + tiptap doc.
238
+ * Used by full rebuild.
239
+ */
240
+ function loadAllDocsForRebuild() {
241
+ const out = [];
242
+ let files = [];
243
+ try {
244
+ files = readdirSync(getDataDir()).filter((f) => f.endsWith('.md'));
245
+ }
246
+ catch {
247
+ return out;
248
+ }
249
+ for (const f of files) {
250
+ try {
251
+ const raw = readFileSync(join(getDataDir(), f), 'utf-8');
252
+ const parsed = markdownToTiptap(raw);
253
+ const docId = parsed.metadata?.docId;
254
+ if (!docId)
255
+ continue;
256
+ out.push({ docId, filename: f, doc: parsed.document });
257
+ }
258
+ catch {
259
+ // skip unreadable
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+ /**
265
+ * Full rebuild: scan all docs, compute backlinks for each from scratch,
266
+ * write updated frontmatter to docs whose backlinks changed.
267
+ * Idempotent. Run via /api/rebuild-backlinks.
268
+ */
269
+ export function rebuildAllBacklinks() {
270
+ const allDocs = loadAllDocsForRebuild();
271
+ // Collect every forward link in the workspace
272
+ const allLinks = [];
273
+ for (const d of allDocs) {
274
+ allLinks.push(...extractForwardLinks(d.doc, d.docId));
275
+ }
276
+ // For each doc, compute its inbound = backlinks, write if changed
277
+ let updated = 0;
278
+ for (const d of allDocs) {
279
+ const newBacklinks = toBacklinks(d.docId, allLinks);
280
+ newBacklinks.sort((a, b) => {
281
+ if (a.from_doc !== b.from_doc)
282
+ return a.from_doc < b.from_doc ? -1 : 1;
283
+ return a.from_node < b.from_node ? -1 : 1;
284
+ });
285
+ const fm = readFrontmatter(d.filename);
286
+ if (!fm)
287
+ continue;
288
+ const existing = Array.isArray(fm.data.backlinks) ? fm.data.backlinks : [];
289
+ if (JSON.stringify(existing) === JSON.stringify(newBacklinks))
290
+ continue;
291
+ const newData = { ...fm.data };
292
+ if (newBacklinks.length > 0)
293
+ newData.backlinks = newBacklinks;
294
+ else
295
+ delete newData.backlinks;
296
+ try {
297
+ writeFrontmatter(d.filename, newData);
298
+ updated++;
299
+ }
300
+ catch {
301
+ // skip
302
+ }
303
+ }
304
+ return { scanned: allDocs.length, updated };
305
+ }
306
+ /**
307
+ * Read previously-saved markdown from disk for a given source filename
308
+ * and extract its forward links. Used by the save hook to compute the
309
+ * "old" link set before the new write lands.
310
+ */
311
+ export function extractForwardLinksFromDisk(filename, sourceDocId) {
312
+ try {
313
+ const filePath = resolveDocPath(filename);
314
+ if (isExternalDoc(filename) || !existsSync(filePath))
315
+ return [];
316
+ const raw = readFileSync(filePath, 'utf-8');
317
+ const parsed = markdownToTiptap(raw);
318
+ return extractForwardLinks(parsed.document, sourceDocId);
319
+ }
320
+ catch {
321
+ return [];
322
+ }
323
+ }
@@ -7,7 +7,7 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSy
7
7
  import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
- import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
10
+ import { tiptapToMarkdownChecked, markdownToTiptap } from './markdown.js';
11
11
  import { parseMarkdownContent } from './compact.js';
12
12
  import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, } from './state.js';
13
13
  import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
@@ -122,7 +122,7 @@ export function listDocuments() {
122
122
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
123
123
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
124
124
  ...(data.variantType ? { variantType: data.variantType } : {}),
125
- ...(data.autoAccept === true ? { autoAccept: true } : {}),
125
+ ...(typeof data.autoAccept === 'boolean' ? { autoAccept: data.autoAccept } : {}),
126
126
  };
127
127
  }
128
128
  catch {
@@ -163,7 +163,7 @@ export function listDocuments() {
163
163
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
164
164
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
165
165
  ...(data.variantType ? { variantType: data.variantType } : {}),
166
- ...(data.autoAccept === true ? { autoAccept: true } : {}),
166
+ ...(typeof data.autoAccept === 'boolean' ? { autoAccept: data.autoAccept } : {}),
167
167
  });
168
168
  }
169
169
  catch { /* skip unreadable external files */ }
@@ -276,7 +276,7 @@ export function archiveDocument(filename) {
276
276
  const next = remaining[0];
277
277
  const raw = readFileSync(next.path, 'utf-8');
278
278
  const parsed = markdownToTiptap(raw);
279
- setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata);
279
+ setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata, undefined);
280
280
  return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: next.name } };
281
281
  }
282
282
  }
@@ -471,7 +471,7 @@ export function createDocument(title, content, path) {
471
471
  const metadata = { title: docTitle, docId: generateNodeId() };
472
472
  setActiveDocument(newDoc, docTitle, filePath, isTemp, undefined, metadata);
473
473
  // Write doc to disk
474
- const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
474
+ const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
475
475
  ensureDataDir();
476
476
  atomicWriteFileSync(filePath, markdown);
477
477
  // Prepend to doc order so new docs appear at top and stay put after edits
@@ -519,7 +519,7 @@ export function createDocumentFile(title, path, extraMeta) {
519
519
  }
520
520
  const newDoc = { type: 'doc', content: [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }] };
521
521
  const metadata = { title: docTitle, docId: generateNodeId(), agentCreated: true, ...extraMeta };
522
- const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
522
+ const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
523
523
  ensureDataDir();
524
524
  atomicWriteFileSync(filePath, markdown);
525
525
  // Prepend to doc order so new docs appear at top and stay put after edits
@@ -557,7 +557,7 @@ export async function deleteDocument(filename) {
557
557
  const next = remaining[0];
558
558
  const raw = readFileSync(next.path, 'utf-8');
559
559
  const parsed = markdownToTiptap(raw);
560
- setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata);
560
+ setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata, undefined);
561
561
  return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: next.name } };
562
562
  }
563
563
  }
@@ -574,7 +574,7 @@ export function reloadDocument() {
574
574
  const raw = readFileSync(filePath, 'utf-8');
575
575
  const parsed = markdownToTiptap(raw);
576
576
  const mtime = new Date(statSync(filePath).mtimeMs);
577
- setActiveDocument(parsed.document, parsed.title, filePath, filename.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
577
+ setActiveDocument(parsed.document, parsed.title, filePath, filename.startsWith(TEMP_PREFIX), mtime, parsed.metadata, undefined);
578
578
  return { document: getDocument(), title: getTitle(), filename };
579
579
  }
580
580
  export function updateDocumentTitle(filename, newTitle) {
@@ -586,7 +586,7 @@ export function updateDocumentTitle(filename, newTitle) {
586
586
  const raw = readFileSync(filePath, 'utf-8');
587
587
  const parsed = markdownToTiptap(raw);
588
588
  const metadata = { ...parsed.metadata, title: newTitle };
589
- const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
589
+ const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
590
590
  atomicWriteFileSync(filePath, markdown);
591
591
  // Update state if this is the active document
592
592
  const baseName = filePath.split(/[/\\]/).pop() || '';
@@ -654,7 +654,7 @@ export function duplicateDocument(filename) {
654
654
  }
655
655
  const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
656
656
  setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
657
- const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
657
+ const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
658
658
  ensureDataDir();
659
659
  atomicWriteFileSync(filePath, markdown);
660
660
  const newFilename = filePath.split(/[/\\]/).pop();
@@ -788,7 +788,7 @@ function resolveDocFile(filePath, action) {
788
788
  if (count === 0)
789
789
  return 0;
790
790
  // Re-serialize — pending attrs are cleared so pending key will be removed from frontmatter
791
- const newRaw = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
791
+ const { markdown: newRaw } = tiptapToMarkdownChecked(doc, parsed.title, parsed.metadata);
792
792
  atomicWriteFileSync(filePath, newRaw);
793
793
  return count;
794
794
  }
@@ -34,7 +34,7 @@ import { createTaskRouter } from './task-routes.js';
34
34
  import { platformFetch, isAuthenticated } from './connections.js';
35
35
  import { PluginManager } from './plugin-manager.js';
36
36
  import { checkForUpdate, getUpdateInfo, getCurrentVersion } from './update-check.js';
37
- import { addMark, getMarks, resolveMarks } from './marks.js';
37
+ import { addMark, getMarks, resolveMarks, editMark } from './marks.js';
38
38
  const __filename = fileURLToPath(import.meta.url);
39
39
  const __dirname = dirname(__filename);
40
40
  export async function startHttpServer(options = {}) {
@@ -167,12 +167,9 @@ export async function startHttpServer(options = {}) {
167
167
  if (isActiveDoc) {
168
168
  if (enabled) {
169
169
  stripPendingAttrs(); // accept any pending changes
170
- setMetadata({ autoAccept: true });
171
- }
172
- else {
173
- const meta = getMetadata();
174
- delete meta.autoAccept;
175
170
  }
171
+ // Explicit boolean (not delete) — false overrides workspace inheritance.
172
+ setMetadata({ autoAccept: enabled });
176
173
  save();
177
174
  updatePendingCacheForActiveDoc();
178
175
  broadcastMetadataChanged(getMetadata());
@@ -191,6 +188,55 @@ export async function startHttpServer(options = {}) {
191
188
  res.status(500).json({ error: err.message });
192
189
  }
193
190
  });
191
+ // Toggle auto-accept on a workspace or container. Inherits to every doc inside.
192
+ // Body: { wsFile, containerId?, enabled }. Omit containerId to target the
193
+ // whole workspace; pass it to target a specific container.
194
+ app.post('/api/auto-accept/inherit', async (req, res) => {
195
+ try {
196
+ const wsFile = req.body?.wsFile;
197
+ const containerId = req.body?.containerId;
198
+ const enabled = req.body?.enabled === true;
199
+ if (!wsFile)
200
+ return res.status(400).json({ error: 'wsFile required' });
201
+ const { setWorkspaceAutoAccept, setContainerAutoAccept, collectFilesInWorkspace, collectFilesInContainer } = await import('./workspaces.js');
202
+ if (containerId) {
203
+ setContainerAutoAccept(wsFile, containerId, enabled);
204
+ }
205
+ else {
206
+ setWorkspaceAutoAccept(wsFile, enabled);
207
+ }
208
+ // If enabling, sweep any in-flight pending changes on every affected doc.
209
+ // (Pure inheritance leaves doc flags alone, but existing pending decorations
210
+ // should clear so the user enters a clean draft state.)
211
+ const affected = containerId ? collectFilesInContainer(wsFile, containerId) : collectFilesInWorkspace(wsFile);
212
+ if (enabled) {
213
+ const activeFn = getActiveFilename();
214
+ for (const file of affected) {
215
+ if (file === activeFn) {
216
+ stripPendingAttrs();
217
+ }
218
+ else {
219
+ stripPendingAttrsFromFile(file, true);
220
+ }
221
+ }
222
+ if (affected.includes(activeFn)) {
223
+ save();
224
+ updatePendingCacheForActiveDoc();
225
+ }
226
+ }
227
+ broadcastWorkspacesChanged();
228
+ broadcastDocumentsChanged();
229
+ broadcastPendingDocsChanged();
230
+ // Surface metadata change for the active doc so the editor banner updates.
231
+ if (affected.includes(getActiveFilename())) {
232
+ broadcastMetadataChanged(getMetadata());
233
+ }
234
+ res.json({ success: true, affected: affected.length });
235
+ }
236
+ catch (err) {
237
+ res.status(500).json({ error: err.message });
238
+ }
239
+ });
194
240
  app.post('/api/save', (_req, res) => {
195
241
  save();
196
242
  res.json({ success: true });
@@ -226,6 +272,67 @@ export async function startHttpServer(options = {}) {
226
272
  app.get('/api/documents', (_req, res) => {
227
273
  res.json(listDocuments());
228
274
  });
275
+ // Backlinks: full rebuild across all docs (idempotent rescue path).
276
+ // The normal flow updates backlinks incrementally on each save; this endpoint
277
+ // exists for repair after external edits or to bootstrap an unmigrated workspace.
278
+ app.post('/api/rebuild-backlinks', async (_req, res) => {
279
+ try {
280
+ const { rebuildAllBacklinks } = await import('./backlinks.js');
281
+ const result = rebuildAllBacklinks();
282
+ res.json(result);
283
+ }
284
+ catch (err) {
285
+ res.status(500).json({ error: err.message });
286
+ }
287
+ });
288
+ // List a doc's block-level paragraphs/headings for the manual paragraph-target
289
+ // picker in the right-click "Link to doc" UI. Returns nodeId + type + level +
290
+ // a short text preview per block. Active doc reads from in-memory state; other
291
+ // docs are parsed from disk.
292
+ app.get('/api/documents/by-doc-id/:docId/paragraphs', async (req, res) => {
293
+ try {
294
+ const { resolveDocId } = await import('./documents.js');
295
+ const filename = resolveDocId(req.params.docId);
296
+ const activeFilename = getActiveFilename();
297
+ let doc;
298
+ if (filename === activeFilename) {
299
+ doc = getDocument();
300
+ }
301
+ else {
302
+ const filePath = resolveDocPath(filename);
303
+ const raw = readFileSync(filePath, 'utf-8');
304
+ const parsed = markdownToTiptap(raw);
305
+ doc = parsed.document;
306
+ }
307
+ const out = [];
308
+ function walk(nodes) {
309
+ for (const node of nodes) {
310
+ if (node.type === 'heading' || node.type === 'paragraph') {
311
+ const text = (node.content || [])
312
+ .map((c) => (c.type === 'text' ? (c.text || '') : ''))
313
+ .join('')
314
+ .trim();
315
+ if (!text)
316
+ continue; // skip empty paragraphs
317
+ const preview = text.length > 80 ? text.slice(0, 79) + '…' : text;
318
+ const entry = { nodeId: node.attrs?.id || '', type: node.type, preview };
319
+ if (node.type === 'heading')
320
+ entry.level = node.attrs?.level || 1;
321
+ if (entry.nodeId)
322
+ out.push(entry);
323
+ }
324
+ else if (Array.isArray(node.content)) {
325
+ walk(node.content);
326
+ }
327
+ }
328
+ }
329
+ walk(doc.content || []);
330
+ res.json({ paragraphs: out });
331
+ }
332
+ catch (err) {
333
+ res.status(404).json({ error: err.message });
334
+ }
335
+ });
229
336
  app.get('/api/documents/:filename/text', (req, res) => {
230
337
  try {
231
338
  const filepath = resolveDocPath(req.params.filename);
@@ -529,6 +636,25 @@ export async function startHttpServer(options = {}) {
529
636
  res.status(500).json({ error: err.message });
530
637
  }
531
638
  });
639
+ app.patch('/api/marks', (req, res) => {
640
+ try {
641
+ const { filename, id, note } = req.body;
642
+ if (!filename || !id || typeof note !== 'string') {
643
+ res.status(400).json({ error: 'filename, id, and note are required' });
644
+ return;
645
+ }
646
+ const mark = editMark(filename, id, note);
647
+ if (!mark) {
648
+ res.status(404).json({ error: 'mark not found' });
649
+ return;
650
+ }
651
+ broadcastMarksChanged(filename);
652
+ res.json({ success: true, mark });
653
+ }
654
+ catch (err) {
655
+ res.status(500).json({ error: err.message });
656
+ }
657
+ });
532
658
  app.delete('/api/marks', (req, res) => {
533
659
  try {
534
660
  const { ids } = req.body;