openwriter 0.12.0 → 0.13.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-BlLnLdoc.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
+ }
@@ -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 = {}) {
@@ -191,6 +191,55 @@ export async function startHttpServer(options = {}) {
191
191
  res.status(500).json({ error: err.message });
192
192
  }
193
193
  });
194
+ // Toggle auto-accept on a workspace or container. Inherits to every doc inside.
195
+ // Body: { wsFile, containerId?, enabled }. Omit containerId to target the
196
+ // whole workspace; pass it to target a specific container.
197
+ app.post('/api/auto-accept/inherit', async (req, res) => {
198
+ try {
199
+ const wsFile = req.body?.wsFile;
200
+ const containerId = req.body?.containerId;
201
+ const enabled = req.body?.enabled === true;
202
+ if (!wsFile)
203
+ return res.status(400).json({ error: 'wsFile required' });
204
+ const { setWorkspaceAutoAccept, setContainerAutoAccept, collectFilesInWorkspace, collectFilesInContainer } = await import('./workspaces.js');
205
+ if (containerId) {
206
+ setContainerAutoAccept(wsFile, containerId, enabled);
207
+ }
208
+ else {
209
+ setWorkspaceAutoAccept(wsFile, enabled);
210
+ }
211
+ // If enabling, sweep any in-flight pending changes on every affected doc.
212
+ // (Pure inheritance leaves doc flags alone, but existing pending decorations
213
+ // should clear so the user enters a clean draft state.)
214
+ const affected = containerId ? collectFilesInContainer(wsFile, containerId) : collectFilesInWorkspace(wsFile);
215
+ if (enabled) {
216
+ const activeFn = getActiveFilename();
217
+ for (const file of affected) {
218
+ if (file === activeFn) {
219
+ stripPendingAttrs();
220
+ }
221
+ else {
222
+ stripPendingAttrsFromFile(file, true);
223
+ }
224
+ }
225
+ if (affected.includes(activeFn)) {
226
+ save();
227
+ updatePendingCacheForActiveDoc();
228
+ }
229
+ }
230
+ broadcastWorkspacesChanged();
231
+ broadcastDocumentsChanged();
232
+ broadcastPendingDocsChanged();
233
+ // Surface metadata change for the active doc so the editor banner updates.
234
+ if (affected.includes(getActiveFilename())) {
235
+ broadcastMetadataChanged(getMetadata());
236
+ }
237
+ res.json({ success: true, affected: affected.length });
238
+ }
239
+ catch (err) {
240
+ res.status(500).json({ error: err.message });
241
+ }
242
+ });
194
243
  app.post('/api/save', (_req, res) => {
195
244
  save();
196
245
  res.json({ success: true });
@@ -226,6 +275,67 @@ export async function startHttpServer(options = {}) {
226
275
  app.get('/api/documents', (_req, res) => {
227
276
  res.json(listDocuments());
228
277
  });
278
+ // Backlinks: full rebuild across all docs (idempotent rescue path).
279
+ // The normal flow updates backlinks incrementally on each save; this endpoint
280
+ // exists for repair after external edits or to bootstrap an unmigrated workspace.
281
+ app.post('/api/rebuild-backlinks', async (_req, res) => {
282
+ try {
283
+ const { rebuildAllBacklinks } = await import('./backlinks.js');
284
+ const result = rebuildAllBacklinks();
285
+ res.json(result);
286
+ }
287
+ catch (err) {
288
+ res.status(500).json({ error: err.message });
289
+ }
290
+ });
291
+ // List a doc's block-level paragraphs/headings for the manual paragraph-target
292
+ // picker in the right-click "Link to doc" UI. Returns nodeId + type + level +
293
+ // a short text preview per block. Active doc reads from in-memory state; other
294
+ // docs are parsed from disk.
295
+ app.get('/api/documents/by-doc-id/:docId/paragraphs', async (req, res) => {
296
+ try {
297
+ const { resolveDocId } = await import('./documents.js');
298
+ const filename = resolveDocId(req.params.docId);
299
+ const activeFilename = getActiveFilename();
300
+ let doc;
301
+ if (filename === activeFilename) {
302
+ doc = getDocument();
303
+ }
304
+ else {
305
+ const filePath = resolveDocPath(filename);
306
+ const raw = readFileSync(filePath, 'utf-8');
307
+ const parsed = markdownToTiptap(raw);
308
+ doc = parsed.document;
309
+ }
310
+ const out = [];
311
+ function walk(nodes) {
312
+ for (const node of nodes) {
313
+ if (node.type === 'heading' || node.type === 'paragraph') {
314
+ const text = (node.content || [])
315
+ .map((c) => (c.type === 'text' ? (c.text || '') : ''))
316
+ .join('')
317
+ .trim();
318
+ if (!text)
319
+ continue; // skip empty paragraphs
320
+ const preview = text.length > 80 ? text.slice(0, 79) + '…' : text;
321
+ const entry = { nodeId: node.attrs?.id || '', type: node.type, preview };
322
+ if (node.type === 'heading')
323
+ entry.level = node.attrs?.level || 1;
324
+ if (entry.nodeId)
325
+ out.push(entry);
326
+ }
327
+ else if (Array.isArray(node.content)) {
328
+ walk(node.content);
329
+ }
330
+ }
331
+ }
332
+ walk(doc.content || []);
333
+ res.json({ paragraphs: out });
334
+ }
335
+ catch (err) {
336
+ res.status(404).json({ error: err.message });
337
+ }
338
+ });
229
339
  app.get('/api/documents/:filename/text', (req, res) => {
230
340
  try {
231
341
  const filepath = resolveDocPath(req.params.filename);
@@ -529,6 +639,25 @@ export async function startHttpServer(options = {}) {
529
639
  res.status(500).json({ error: err.message });
530
640
  }
531
641
  });
642
+ app.patch('/api/marks', (req, res) => {
643
+ try {
644
+ const { filename, id, note } = req.body;
645
+ if (!filename || !id || typeof note !== 'string') {
646
+ res.status(400).json({ error: 'filename, id, and note are required' });
647
+ return;
648
+ }
649
+ const mark = editMark(filename, id, note);
650
+ if (!mark) {
651
+ res.status(404).json({ error: 'mark not found' });
652
+ return;
653
+ }
654
+ broadcastMarksChanged(filename);
655
+ res.json({ success: true, mark });
656
+ }
657
+ catch (err) {
658
+ res.status(500).json({ error: err.message });
659
+ }
660
+ });
532
661
  app.delete('/api/marks', (req, res) => {
533
662
  try {
534
663
  const { ids } = req.body;
@@ -116,19 +116,21 @@ function tokensToTiptap(tokens) {
116
116
  if (token.type === 'heading_open') {
117
117
  const level = parseInt(token.tag.slice(1));
118
118
  const inlineToken = tokens[i + 1];
119
- const content = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
120
- nodes.push({ type: 'heading', attrs: { id: generateNodeId(), level }, content });
119
+ const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
120
+ const { content, id } = extractTrailingNodeId(rawContent);
121
+ nodes.push({ type: 'heading', attrs: { id: id || generateNodeId(), level }, content });
121
122
  i += 3;
122
123
  }
123
124
  else if (token.type === 'paragraph_open') {
124
125
  const inlineToken = tokens[i + 1];
125
- const content = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
126
+ const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
127
+ const { content, id } = extractTrailingNodeId(rawContent);
126
128
  // Check for solo image — promote to block-level image node
127
129
  if (content.length === 1 && content[0].type === 'image') {
128
130
  nodes.push(content[0]);
129
131
  }
130
132
  else {
131
- nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content });
133
+ nodes.push({ type: 'paragraph', attrs: { id: id || generateNodeId() }, content });
132
134
  }
133
135
  i += 3;
134
136
  }
@@ -168,10 +170,18 @@ function tokensToTiptap(tokens) {
168
170
  i += 1;
169
171
  }
170
172
  else if (token.type === 'html_block') {
171
- // <!-- --> is our sentinel for empty paragraphs
172
- if (token.content.trim() === '<!-- -->') {
173
+ // <!-- --> is our sentinel for empty paragraphs.
174
+ // <!-- ^abc12345 --> is the same sentinel with a persisted nodeId.
175
+ const trimmed = token.content.trim();
176
+ if (trimmed === '<!-- -->') {
173
177
  nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] });
174
178
  }
179
+ else {
180
+ const idMatch = trimmed.match(/^<!--\s*\^([a-f0-9]{8})\s*-->$/);
181
+ if (idMatch) {
182
+ nodes.push({ type: 'paragraph', attrs: { id: idMatch[1] }, content: [] });
183
+ }
184
+ }
175
185
  i += 1;
176
186
  }
177
187
  else if (token.type === 'table_open') {
@@ -440,3 +450,32 @@ function popMarkByType(stack, type) {
440
450
  }
441
451
  }
442
452
  }
453
+ /**
454
+ * Extract a trailing nodeId anchor from inline content.
455
+ * Format: ` ^abc12345` (space + caret + 8 lowercase hex chars at end of line).
456
+ * Matches Obsidian's block-reference convention. Strips the marker from the
457
+ * visible text and returns the captured id. Returns id=null if no anchor found.
458
+ *
459
+ * Known limit: prose ending with the literal pattern ` ^[8 lowercase hex]`
460
+ * will be interpreted as an anchor. Vanishingly rare in real writing.
461
+ */
462
+ function extractTrailingNodeId(content) {
463
+ if (!content || content.length === 0)
464
+ return { content, id: null };
465
+ const lastNode = content[content.length - 1];
466
+ if (lastNode.type !== 'text' || !lastNode.text)
467
+ return { content, id: null };
468
+ const match = lastNode.text.match(/ \^([a-f0-9]{8})\s*$/);
469
+ if (!match)
470
+ return { content, id: null };
471
+ const id = match[1];
472
+ const newText = lastNode.text.slice(0, match.index);
473
+ const newContent = [...content];
474
+ if (newText) {
475
+ newContent[newContent.length - 1] = { ...lastNode, text: newText };
476
+ }
477
+ else {
478
+ newContent.pop();
479
+ }
480
+ return { content: newContent, id };
481
+ }
@@ -98,11 +98,19 @@ function nodeToMarkdown(node, indent) {
98
98
  case 'heading': {
99
99
  const level = node.attrs?.level || 1;
100
100
  const prefix = '#'.repeat(level);
101
- return `${prefix} ${inlineToMarkdown(node.content)}\n\n`;
101
+ const idSuffix = node.attrs?.id ? ` ^${node.attrs.id}` : '';
102
+ return `${prefix} ${inlineToMarkdown(node.content)}${idSuffix}\n\n`;
102
103
  }
103
104
  case 'paragraph': {
104
105
  const text = inlineToMarkdown(node.content);
105
- return text ? `${indent}${text}\n\n` : `${indent}<!-- -->\n\n`;
106
+ const id = node.attrs?.id;
107
+ if (text) {
108
+ const idSuffix = id ? ` ^${id}` : '';
109
+ return `${indent}${text}${idSuffix}\n\n`;
110
+ }
111
+ // Empty paragraph: embed id in the existing sentinel comment
112
+ const emptyMarker = id ? `<!-- ^${id} -->` : '<!-- -->';
113
+ return `${indent}${emptyMarker}\n\n`;
106
114
  }
107
115
  case 'bulletList':
108
116
  return listToMarkdown(node.content, '- ', indent);
@@ -114,6 +114,15 @@ export function getGlobalMarkSummary(excludeFilename) {
114
114
  catch { /* dir doesn't exist */ }
115
115
  return { totalMarks, docCount };
116
116
  }
117
+ export function editMark(filename, id, note) {
118
+ const data = readMarkFile(filename);
119
+ const mark = data.marks.find((m) => m.id === id);
120
+ if (!mark)
121
+ return null;
122
+ mark.note = note;
123
+ writeMarkFile(filename, data);
124
+ return mark;
125
+ }
117
126
  export function resolveMarks(ids) {
118
127
  const idSet = new Set(ids);
119
128
  const resolved = [];