openwriter 0.20.1 → 0.21.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.
@@ -160,19 +160,28 @@ export function writeFrontmatter(filename, newData) {
160
160
  atomicWriteFileSync(filePath, newFrontmatter);
161
161
  }
162
162
  // ============================================================================
163
- // COMPUTE-LIVE BACKLINKS — the new v0.20 surface
163
+ // COMPUTE-LIVE BACKLINKS — the v0.20 surface, extended in v0.21
164
164
  // ============================================================================
165
165
  //
166
- // `computeBacklinksFor(targetDocId)` returns every doc that lists targetDocId
167
- // in its `references:` frontmatter array. Cached in an inverse-index map keyed
168
- // by target docId. Any write that touches a source's references calls
169
- // `invalidateBacklinksCache(sourceDocId)` to wipe affected entries; the next
170
- // read rebuilds them lazily.
171
- /** Inverse index: target docId Set of source docIds that reference it. */
166
+ // `computeBacklinksFor(targetDocId)` returns every inbound edge pointing at
167
+ // targetDocId. Two sources contribute:
168
+ //
169
+ // 1. Doc-level edges from `references:` frontmatter arrays (v0.20 model
170
+ // structural, no node granularity). Entry: `{ from_doc }`.
171
+ // 2. Paragraph-anchored edges from prose `[text](doc:DOCID#NODEID)` link
172
+ // marks in the body (v0.21 — restores per-paragraph backlinks for the
173
+ // dotted-underline + "See connections" UI). Entry: `{ from_doc, from_node,
174
+ // to_node, text }`.
175
+ //
176
+ // Cached in memory; any write that touches references or body invalidates
177
+ // (state.ts:writeToDisk after every save). Cache rebuilds lazily on next read.
178
+ /** Inverse index: target docId → list of inbound edges. */
172
179
  let backlinksCache = null;
173
180
  /** Build (or rebuild) the entire inverse index by scanning every .md in the
174
- * data dir. Runs O(N) over the corpus; called once on first read after an
175
- * invalidation. Personal corpora a few hundred docs make this trivial. */
181
+ * data dir. Two passes per file: frontmatter references (cheap) + body
182
+ * paragraph-anchored prose links (parse + walk). For personal corpora of a
183
+ * few hundred docs this lands in ~1-2 seconds; the cache holds across many
184
+ * reads, so amortized cost is negligible. */
176
185
  function buildBacklinksCache() {
177
186
  const cache = new Map();
178
187
  let files = [];
@@ -182,6 +191,24 @@ function buildBacklinksCache() {
182
191
  catch {
183
192
  return cache;
184
193
  }
194
+ /** Dedup keys per target: source docs with no `to_node` collapse to one
195
+ * doc-level entry; paragraph-anchored entries dedup per (from_doc, to_node)
196
+ * pair so multi-link-same-anchor in a single source counts once. */
197
+ const seen = new Map();
198
+ function push(targetDocId, entry) {
199
+ const key = entry.to_node ? `${entry.from_doc}#${entry.to_node}` : entry.from_doc;
200
+ let seenForTarget = seen.get(targetDocId);
201
+ if (!seenForTarget) {
202
+ seenForTarget = new Set();
203
+ seen.set(targetDocId, seenForTarget);
204
+ }
205
+ if (seenForTarget.has(key))
206
+ return;
207
+ seenForTarget.add(key);
208
+ if (!cache.has(targetDocId))
209
+ cache.set(targetDocId, []);
210
+ cache.get(targetDocId).push(entry);
211
+ }
185
212
  for (const f of files) {
186
213
  try {
187
214
  const raw = readFileSync(join(getDataDir(), f), 'utf-8');
@@ -189,15 +216,34 @@ function buildBacklinksCache() {
189
216
  const sourceDocId = parsed.data?.docId;
190
217
  if (!sourceDocId || typeof sourceDocId !== 'string')
191
218
  continue;
219
+ // Pass 1: structural references (frontmatter). Doc-level only.
192
220
  const refs = parsed.data?.references;
193
- if (!Array.isArray(refs))
194
- continue;
195
- for (const targetDocId of refs) {
196
- if (typeof targetDocId !== 'string')
197
- continue;
198
- if (!cache.has(targetDocId))
199
- cache.set(targetDocId, new Set());
200
- cache.get(targetDocId).add(sourceDocId);
221
+ if (Array.isArray(refs)) {
222
+ for (const targetDocId of refs) {
223
+ if (typeof targetDocId !== 'string')
224
+ continue;
225
+ push(targetDocId, { from_doc: sourceDocId });
226
+ }
227
+ }
228
+ // Pass 2: paragraph-anchored prose links. Only entries with a #NODEID
229
+ // anchor in the href contribute — doc-level prose links are already
230
+ // captured by Pass 1 via the references-auto-sync at save time.
231
+ try {
232
+ const tipDoc = markdownToTiptap(raw).document;
233
+ const proseLinks = extractForwardLinks(tipDoc, sourceDocId);
234
+ for (const link of proseLinks) {
235
+ if (!link.to_node)
236
+ continue; // doc-level — Pass 1 handles it
237
+ push(link.to_doc, {
238
+ from_doc: link.from_doc,
239
+ from_node: link.from_node,
240
+ to_node: link.to_node,
241
+ text: link.text,
242
+ });
243
+ }
244
+ }
245
+ catch {
246
+ // markdownToTiptap can throw on malformed bodies — best-effort skip
201
247
  }
202
248
  }
203
249
  catch {
@@ -207,24 +253,41 @@ function buildBacklinksCache() {
207
253
  return cache;
208
254
  }
209
255
  /** Drop the in-memory cache. Next read rebuilds from disk. Called from
210
- * state.ts:writeToDisk after a save that may have changed references. */
256
+ * state.ts:writeToDisk after a save that may have changed references OR the
257
+ * body's prose link set. */
211
258
  export function invalidateBacklinksCache() {
212
259
  backlinksCache = null;
213
260
  }
214
261
  /**
215
- * Return every source doc that references targetDocId. Pure read; the
216
- * frontmatter `references:` arrays across the workspace are the only data
217
- * consulted. Cached in memory.
262
+ * Return every inbound edge pointing at targetDocId both doc-level (from
263
+ * `references:` frontmatter) and paragraph-anchored (from prose
264
+ * `[text](doc:DOCID#NODEID)` links). Cached in memory.
265
+ *
266
+ * Entries with `to_node` populated are paragraph-anchored: the backlinks
267
+ * decoration plugin paints a dotted underline on the matching target
268
+ * paragraph, and the context menu surfaces "See connections" listing the
269
+ * sources. Entries without `to_node` are doc-level and intended for
270
+ * doc-scope UI (e.g. "N sources link to this doc").
218
271
  */
219
272
  export function computeBacklinksFor(targetDocId) {
220
273
  if (!backlinksCache)
221
274
  backlinksCache = buildBacklinksCache();
222
- const sources = backlinksCache.get(targetDocId);
223
- if (!sources)
275
+ const entries = backlinksCache.get(targetDocId);
276
+ if (!entries)
224
277
  return [];
225
- return Array.from(sources)
226
- .sort()
227
- .map((from_doc) => ({ from_doc }));
278
+ // Stable sort: paragraph-anchored entries first (so per-paragraph UI gets
279
+ // them ordered consistently), then doc-level, both by from_doc.
280
+ return [...entries].sort((a, b) => {
281
+ const aAnchored = a.to_node ? 0 : 1;
282
+ const bAnchored = b.to_node ? 0 : 1;
283
+ if (aAnchored !== bAnchored)
284
+ return aAnchored - bAnchored;
285
+ if (a.from_doc !== b.from_doc)
286
+ return a.from_doc < b.from_doc ? -1 : 1;
287
+ if ((a.to_node ?? '') !== (b.to_node ?? ''))
288
+ return (a.to_node ?? '') < (b.to_node ?? '') ? -1 : 1;
289
+ return 0;
290
+ });
228
291
  }
229
292
  // ============================================================================
230
293
  // PROSE-LINK AUTO-SYNC — backward compat for legacy [text](doc:id) prose links
@@ -2609,6 +2609,9 @@ export function saveDocToFile(filename, doc) {
2609
2609
  const overlay = extractOverlay(doc);
2610
2610
  saveOverlay(docId, overlay);
2611
2611
  }
2612
+ // Backlinks cache invalidate — browser sent a doc-update for a non-active
2613
+ // doc; the prose-link set on that doc may have changed.
2614
+ invalidateBacklinksCache();
2612
2615
  }
2613
2616
  catch { /* best-effort */ }
2614
2617
  }
@@ -2758,6 +2761,12 @@ function flushDocToFile(filename, doc, title, metadata) {
2758
2761
  saveOverlay(docId, overlay);
2759
2762
  }
2760
2763
  setPendingCacheEntry(filename, countPending(doc.content));
2764
+ // Backlinks cache invalidation — non-active write paths (populate_document on
2765
+ // a fresh doc, applyChangesToFile, applyTextEditsToFile) all funnel through
2766
+ // here. Any of them can change references: or the prose-link set, so the
2767
+ // computed inverse cache must drop. Mirrors the active-doc invalidate at the
2768
+ // tail of writeToDisk.
2769
+ invalidateBacklinksCache();
2761
2770
  }
2762
2771
  export function populateDocumentFile(filename, doc) {
2763
2772
  const targetPath = resolveDocPath(filename);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",