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.
- package/dist/bin/pad.js +101 -146
- package/dist/client/assets/index-BlLnLdoc.js +212 -0
- package/dist/client/assets/{index-CRImKlcp.css → index-OV13QtgQ.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/backlinks.js +323 -0
- package/dist/server/index.js +130 -1
- package/dist/server/markdown-parse.js +45 -6
- package/dist/server/markdown-serialize.js +10 -2
- package/dist/server/marks.js +9 -0
- package/dist/server/mcp.js +148 -6
- package/dist/server/state.js +47 -6
- package/dist/server/workspace-routes.js +31 -3
- package/dist/server/workspaces.js +85 -0
- package/package.json +1 -1
- package/skill/SKILL.md +3 -7
- package/dist/client/assets/index-CNmzNvB_.js +0 -211
- package/skill/docs/anti-ai.md +0 -71
- package/skill/docs/voices.md +0 -88
- package/skill/voices/authority.md +0 -102
- package/skill/voices/business.md +0 -103
- package/skill/voices/logical.md +0 -104
- package/skill/voices/provocateur.md +0 -101
- package/skill/voices/storyteller.md +0 -104
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/server/marks.js
CHANGED
|
@@ -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 = [];
|