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.
- package/dist/server/backlinks.js +89 -26
- package/dist/server/state.js +9 -0
- package/package.json +1 -1
package/dist/server/backlinks.js
CHANGED
|
@@ -160,19 +160,28 @@ export function writeFrontmatter(filename, newData) {
|
|
|
160
160
|
atomicWriteFileSync(filePath, newFrontmatter);
|
|
161
161
|
}
|
|
162
162
|
// ============================================================================
|
|
163
|
-
// COMPUTE-LIVE BACKLINKS — the
|
|
163
|
+
// COMPUTE-LIVE BACKLINKS — the v0.20 surface, extended in v0.21
|
|
164
164
|
// ============================================================================
|
|
165
165
|
//
|
|
166
|
-
// `computeBacklinksFor(targetDocId)` returns every
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// `
|
|
170
|
-
//
|
|
171
|
-
|
|
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.
|
|
175
|
-
*
|
|
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 (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
223
|
-
if (!
|
|
275
|
+
const entries = backlinksCache.get(targetDocId);
|
|
276
|
+
if (!entries)
|
|
224
277
|
return [];
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
package/dist/server/state.js
CHANGED
|
@@ -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.
|
|
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",
|