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.
- package/dist/client/assets/index-BxI3DazW.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/documents.js +11 -11
- package/dist/server/index.js +132 -6
- package/dist/server/markdown-parse.js +187 -9
- package/dist/server/markdown-serialize.js +97 -2
- package/dist/server/markdown.js +32 -0
- package/dist/server/marks.js +9 -0
- package/dist/server/mcp.js +148 -6
- package/dist/server/node-blocks.js +256 -0
- package/dist/server/node-fingerprint.js +264 -0
- package/dist/server/node-matcher.js +564 -0
- package/dist/server/node-sync-check.js +110 -0
- package/dist/server/state.js +210 -43
- package/dist/server/workspace-routes.js +31 -3
- package/dist/server/workspaces.js +85 -0
- package/package.json +1 -1
- package/skill/SKILL.md +4 -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-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
|
+
}
|
package/dist/server/documents.js
CHANGED
|
@@ -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 {
|
|
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 ===
|
|
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 ===
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
791
|
+
const { markdown: newRaw } = tiptapToMarkdownChecked(doc, parsed.title, parsed.metadata);
|
|
792
792
|
atomicWriteFileSync(filePath, newRaw);
|
|
793
793
|
return count;
|
|
794
794
|
}
|
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 = {}) {
|
|
@@ -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;
|