openwriter 0.1.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 +64 -0
- package/dist/client/assets/index-DNJs7lC-.js +205 -0
- package/dist/client/assets/index-WweytMO1.css +1 -0
- package/dist/client/index.html +16 -0
- package/dist/server/compact.js +214 -0
- package/dist/server/documents.js +230 -0
- package/dist/server/export-html-template.js +109 -0
- package/dist/server/export-routes.js +96 -0
- package/dist/server/gdoc-import.js +200 -0
- package/dist/server/git-sync.js +272 -0
- package/dist/server/helpers.js +87 -0
- package/dist/server/image-upload.js +55 -0
- package/dist/server/index.js +315 -0
- package/dist/server/link-routes.js +116 -0
- package/dist/server/markdown-parse.js +405 -0
- package/dist/server/markdown-serialize.js +263 -0
- package/dist/server/markdown.js +6 -0
- package/dist/server/mcp-client.js +37 -0
- package/dist/server/mcp.js +457 -0
- package/dist/server/plugin-loader.js +36 -0
- package/dist/server/plugin-types.js +5 -0
- package/dist/server/state.js +749 -0
- package/dist/server/sync-routes.js +75 -0
- package/dist/server/text-edit.js +249 -0
- package/dist/server/version-routes.js +79 -0
- package/dist/server/versions.js +198 -0
- package/dist/server/workspace-routes.js +176 -0
- package/dist/server/workspace-tags.js +33 -0
- package/dist/server/workspace-tree.js +200 -0
- package/dist/server/workspace-types.js +38 -0
- package/dist/server/workspaces.js +257 -0
- package/dist/server/ws.js +211 -0
- package/package.json +88 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed document state for OpenWriter.
|
|
3
|
+
* Each document is a .md file in ~/.openwriter/ with YAML frontmatter.
|
|
4
|
+
* Title lives in frontmatter metadata. Filenames are stable identifiers.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import matter from 'gray-matter';
|
|
9
|
+
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
10
|
+
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
|
+
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, isExternalDoc } from './helpers.js';
|
|
12
|
+
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
|
+
const DEFAULT_DOC = {
|
|
14
|
+
type: 'doc',
|
|
15
|
+
content: [{ type: 'paragraph', content: [] }],
|
|
16
|
+
};
|
|
17
|
+
let state = {
|
|
18
|
+
document: DEFAULT_DOC,
|
|
19
|
+
title: 'Untitled',
|
|
20
|
+
metadata: { title: 'Untitled' },
|
|
21
|
+
filePath: '',
|
|
22
|
+
isTemp: true,
|
|
23
|
+
lastModified: new Date(),
|
|
24
|
+
docId: '',
|
|
25
|
+
};
|
|
26
|
+
const listeners = new Set();
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// EXTERNAL DOCUMENT REGISTRY
|
|
29
|
+
// ============================================================================
|
|
30
|
+
const EXTERNAL_DOCS_FILE = join(DATA_DIR, 'external-docs.json');
|
|
31
|
+
const externalDocs = new Set();
|
|
32
|
+
function persistExternalDocs() {
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]), 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
catch { /* best-effort */ }
|
|
37
|
+
}
|
|
38
|
+
function loadExternalDocs() {
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(EXTERNAL_DOCS_FILE)) {
|
|
41
|
+
const paths = JSON.parse(readFileSync(EXTERNAL_DOCS_FILE, 'utf-8'));
|
|
42
|
+
for (const p of paths) {
|
|
43
|
+
if (existsSync(p))
|
|
44
|
+
externalDocs.add(p);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { /* corrupt file — start fresh */ }
|
|
49
|
+
}
|
|
50
|
+
export function registerExternalDoc(fullPath) {
|
|
51
|
+
externalDocs.add(fullPath);
|
|
52
|
+
persistExternalDocs();
|
|
53
|
+
}
|
|
54
|
+
export function unregisterExternalDoc(fullPath) {
|
|
55
|
+
externalDocs.delete(fullPath);
|
|
56
|
+
persistExternalDocs();
|
|
57
|
+
}
|
|
58
|
+
export function getExternalDocs() {
|
|
59
|
+
return [...externalDocs];
|
|
60
|
+
}
|
|
61
|
+
function isDocEmpty(doc) {
|
|
62
|
+
if (!doc.content || doc.content.length === 0)
|
|
63
|
+
return true;
|
|
64
|
+
if (doc.content.length === 1) {
|
|
65
|
+
const node = doc.content[0];
|
|
66
|
+
if (!node.content || node.content.length === 0)
|
|
67
|
+
return true;
|
|
68
|
+
if (node.content.length === 1 && !node.content[0].text?.trim())
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// GETTERS
|
|
75
|
+
// ============================================================================
|
|
76
|
+
export function getDocument() {
|
|
77
|
+
return state.document;
|
|
78
|
+
}
|
|
79
|
+
export function getTitle() {
|
|
80
|
+
return state.title;
|
|
81
|
+
}
|
|
82
|
+
export function getFilePath() {
|
|
83
|
+
return state.filePath;
|
|
84
|
+
}
|
|
85
|
+
export function getDocId() {
|
|
86
|
+
return state.docId;
|
|
87
|
+
}
|
|
88
|
+
export function getPlainText() {
|
|
89
|
+
return extractText(state.document.content);
|
|
90
|
+
}
|
|
91
|
+
function extractText(nodes) {
|
|
92
|
+
if (!nodes)
|
|
93
|
+
return '';
|
|
94
|
+
return nodes
|
|
95
|
+
.map((node) => {
|
|
96
|
+
if (node.text)
|
|
97
|
+
return node.text;
|
|
98
|
+
if (node.content)
|
|
99
|
+
return extractText(node.content);
|
|
100
|
+
return '';
|
|
101
|
+
})
|
|
102
|
+
.join('\n');
|
|
103
|
+
}
|
|
104
|
+
export function getWordCount() {
|
|
105
|
+
const text = getPlainText();
|
|
106
|
+
return text.trim() ? text.trim().split(/\s+/).length : 0;
|
|
107
|
+
}
|
|
108
|
+
export function getPendingChangeCount() {
|
|
109
|
+
let count = 0;
|
|
110
|
+
function scan(nodes) {
|
|
111
|
+
if (!nodes)
|
|
112
|
+
return;
|
|
113
|
+
for (const node of nodes) {
|
|
114
|
+
if (node.attrs?.pendingStatus)
|
|
115
|
+
count++;
|
|
116
|
+
if (node.content)
|
|
117
|
+
scan(node.content);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
scan(state.document.content);
|
|
121
|
+
return count;
|
|
122
|
+
}
|
|
123
|
+
export function getNodesByIds(ids) {
|
|
124
|
+
const result = [];
|
|
125
|
+
function scan(nodes) {
|
|
126
|
+
if (!nodes)
|
|
127
|
+
return;
|
|
128
|
+
for (const node of nodes) {
|
|
129
|
+
if (node.attrs?.id && ids.includes(node.attrs.id)) {
|
|
130
|
+
result.push(node);
|
|
131
|
+
}
|
|
132
|
+
if (node.content)
|
|
133
|
+
scan(node.content);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
scan(state.document.content);
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
export function getMetadata() {
|
|
140
|
+
return state.metadata;
|
|
141
|
+
}
|
|
142
|
+
export function setMetadata(updates) {
|
|
143
|
+
state.metadata = { ...state.metadata, ...updates };
|
|
144
|
+
if (updates.title)
|
|
145
|
+
state.title = updates.title;
|
|
146
|
+
}
|
|
147
|
+
export function getStatus() {
|
|
148
|
+
return {
|
|
149
|
+
title: state.title,
|
|
150
|
+
wordCount: getWordCount(),
|
|
151
|
+
pendingChanges: getPendingChangeCount(),
|
|
152
|
+
lastModified: state.lastModified.toISOString(),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// SETTERS
|
|
157
|
+
// ============================================================================
|
|
158
|
+
export function updateDocument(doc) {
|
|
159
|
+
// Preserve pending attrs that the browser doesn't track in its document model.
|
|
160
|
+
// Browser manages pending state as decorations, so its doc-updates lack pendingStatus.
|
|
161
|
+
// Without this, browser overwrites server state and pending info is lost on next save.
|
|
162
|
+
if (hasPendingChanges()) {
|
|
163
|
+
transferPendingAttrs(state.document, doc);
|
|
164
|
+
}
|
|
165
|
+
state.document = doc;
|
|
166
|
+
state.lastModified = new Date();
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Transfer pending attrs from source doc to target doc by matching node IDs.
|
|
170
|
+
* Copies pendingStatus, pendingOriginalContent, and pendingTextEdits.
|
|
171
|
+
*/
|
|
172
|
+
function transferPendingAttrs(source, target) {
|
|
173
|
+
// Build a map of nodeId → pending attrs from source
|
|
174
|
+
const pendingMap = new Map();
|
|
175
|
+
function collectPending(nodes) {
|
|
176
|
+
if (!nodes)
|
|
177
|
+
return;
|
|
178
|
+
for (const node of nodes) {
|
|
179
|
+
if (node.attrs?.pendingStatus && node.attrs?.id) {
|
|
180
|
+
pendingMap.set(node.attrs.id, {
|
|
181
|
+
status: node.attrs.pendingStatus,
|
|
182
|
+
original: node.attrs.pendingOriginalContent,
|
|
183
|
+
textEdits: node.attrs.pendingTextEdits,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (node.content)
|
|
187
|
+
collectPending(node.content);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
collectPending(source.content);
|
|
191
|
+
// Apply pending attrs to matching nodes in target
|
|
192
|
+
function applyPending(nodes) {
|
|
193
|
+
if (!nodes)
|
|
194
|
+
return;
|
|
195
|
+
for (const node of nodes) {
|
|
196
|
+
if (node.attrs?.id && pendingMap.has(node.attrs.id)) {
|
|
197
|
+
const p = pendingMap.get(node.attrs.id);
|
|
198
|
+
node.attrs.pendingStatus = p.status;
|
|
199
|
+
if (p.original)
|
|
200
|
+
node.attrs.pendingOriginalContent = p.original;
|
|
201
|
+
if (p.textEdits)
|
|
202
|
+
node.attrs.pendingTextEdits = p.textEdits;
|
|
203
|
+
}
|
|
204
|
+
if (node.content)
|
|
205
|
+
applyPending(node.content);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
applyPending(target.content);
|
|
209
|
+
}
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// AGENT WRITE LOCK
|
|
212
|
+
// ============================================================================
|
|
213
|
+
const AGENT_LOCK_MS = 5000; // Block browser doc-updates for 5s after agent write
|
|
214
|
+
let lastAgentWriteTime = 0;
|
|
215
|
+
/** Set the agent write lock (called after agent changes). */
|
|
216
|
+
function setAgentLock() {
|
|
217
|
+
lastAgentWriteTime = Date.now();
|
|
218
|
+
}
|
|
219
|
+
/** Check if the agent write lock is active. */
|
|
220
|
+
export function isAgentLocked() {
|
|
221
|
+
return Date.now() - lastAgentWriteTime < AGENT_LOCK_MS;
|
|
222
|
+
}
|
|
223
|
+
// ---- Debounced save: coalesces rapid agent writes into a single disk write ----
|
|
224
|
+
let saveTimer = null;
|
|
225
|
+
const SAVE_DEBOUNCE_MS = 500;
|
|
226
|
+
function debouncedSave() {
|
|
227
|
+
if (saveTimer)
|
|
228
|
+
clearTimeout(saveTimer);
|
|
229
|
+
saveTimer = setTimeout(() => {
|
|
230
|
+
saveTimer = null;
|
|
231
|
+
save();
|
|
232
|
+
}, SAVE_DEBOUNCE_MS);
|
|
233
|
+
}
|
|
234
|
+
/** Cancel any pending debounced save. Call before doc switch (which does its own save). */
|
|
235
|
+
export function cancelDebouncedSave() {
|
|
236
|
+
if (saveTimer) {
|
|
237
|
+
clearTimeout(saveTimer);
|
|
238
|
+
saveTimer = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
export function applyChanges(changes) {
|
|
242
|
+
// Apply to server-side document (source of truth)
|
|
243
|
+
const processed = applyChangesToDocument(changes);
|
|
244
|
+
// Lock browser doc-updates to prevent stale state overwrite
|
|
245
|
+
setAgentLock();
|
|
246
|
+
// Broadcast processed changes (with server-assigned IDs) to browser clients
|
|
247
|
+
for (const listener of listeners) {
|
|
248
|
+
listener(processed);
|
|
249
|
+
}
|
|
250
|
+
// Debounced save — coalesces rapid agent writes into a single disk write
|
|
251
|
+
debouncedSave();
|
|
252
|
+
return processed.length;
|
|
253
|
+
}
|
|
254
|
+
export function onChanges(listener) {
|
|
255
|
+
listeners.add(listener);
|
|
256
|
+
return () => listeners.delete(listener);
|
|
257
|
+
}
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// SERVER-SIDE DOCUMENT MUTATIONS
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// generateNodeId imported from helpers.ts
|
|
262
|
+
/**
|
|
263
|
+
* Find a node by ID in the document tree.
|
|
264
|
+
* Returns the parent array and index for in-place mutation.
|
|
265
|
+
*/
|
|
266
|
+
function findNodeInDoc(nodes, id) {
|
|
267
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
268
|
+
if (nodes[i].attrs?.id === id) {
|
|
269
|
+
return { parent: nodes, index: i };
|
|
270
|
+
}
|
|
271
|
+
if (nodes[i].content && Array.isArray(nodes[i].content)) {
|
|
272
|
+
const result = findNodeInDoc(nodes[i].content, id);
|
|
273
|
+
if (result)
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Apply changes to server-side document and return processed changes
|
|
281
|
+
* with server-assigned IDs for broadcast to browsers.
|
|
282
|
+
*/
|
|
283
|
+
function applyChangesToDocument(changes) {
|
|
284
|
+
const processed = [];
|
|
285
|
+
for (const change of changes) {
|
|
286
|
+
if (change.operation === 'rewrite' && change.nodeId && change.content) {
|
|
287
|
+
const found = findNodeInDoc(state.document.content, change.nodeId);
|
|
288
|
+
if (!found)
|
|
289
|
+
continue;
|
|
290
|
+
const contentArray = Array.isArray(change.content) ? change.content : [change.content];
|
|
291
|
+
const originalNode = JSON.parse(JSON.stringify(found.parent[found.index]));
|
|
292
|
+
// Only store original on first rewrite (preserve baseline for reject)
|
|
293
|
+
const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
|
|
294
|
+
// First node replaces the target (rewrite)
|
|
295
|
+
const firstNode = {
|
|
296
|
+
...contentArray[0],
|
|
297
|
+
attrs: {
|
|
298
|
+
...contentArray[0].attrs,
|
|
299
|
+
id: change.nodeId,
|
|
300
|
+
pendingStatus: 'rewrite',
|
|
301
|
+
pendingOriginalContent: existingOriginal || originalNode,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
// Additional nodes get inserted after as pending inserts
|
|
305
|
+
const extraNodes = contentArray.slice(1).map((node) => ({
|
|
306
|
+
...node,
|
|
307
|
+
attrs: {
|
|
308
|
+
...node.attrs,
|
|
309
|
+
id: node.attrs?.id || generateNodeId(),
|
|
310
|
+
pendingStatus: 'insert',
|
|
311
|
+
},
|
|
312
|
+
}));
|
|
313
|
+
found.parent.splice(found.index, 1, firstNode, ...extraNodes);
|
|
314
|
+
processed.push({
|
|
315
|
+
...change,
|
|
316
|
+
content: [firstNode, ...extraNodes],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
else if (change.operation === 'insert' && change.content) {
|
|
320
|
+
const contentArray = Array.isArray(change.content) ? change.content : [change.content];
|
|
321
|
+
// Assign IDs to all new nodes before broadcast
|
|
322
|
+
const contentWithIds = contentArray.map((node, i) => ({
|
|
323
|
+
...node,
|
|
324
|
+
attrs: {
|
|
325
|
+
...node.attrs,
|
|
326
|
+
id: node.attrs?.id || (change.nodeId && !change.afterNodeId && i === 0 ? change.nodeId : generateNodeId()),
|
|
327
|
+
pendingStatus: 'insert',
|
|
328
|
+
},
|
|
329
|
+
}));
|
|
330
|
+
if (change.nodeId && !change.afterNodeId) {
|
|
331
|
+
// Replace empty node
|
|
332
|
+
const found = findNodeInDoc(state.document.content, change.nodeId);
|
|
333
|
+
if (!found)
|
|
334
|
+
continue;
|
|
335
|
+
found.parent.splice(found.index, 1, ...contentWithIds);
|
|
336
|
+
}
|
|
337
|
+
else if (change.afterNodeId) {
|
|
338
|
+
const found = findNodeInDoc(state.document.content, change.afterNodeId);
|
|
339
|
+
if (!found)
|
|
340
|
+
continue;
|
|
341
|
+
found.parent.splice(found.index + 1, 0, ...contentWithIds);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// Broadcast with server-assigned IDs so browser uses the same IDs
|
|
347
|
+
processed.push({
|
|
348
|
+
...change,
|
|
349
|
+
content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else if (change.operation === 'delete' && change.nodeId) {
|
|
353
|
+
const found = findNodeInDoc(state.document.content, change.nodeId);
|
|
354
|
+
if (!found)
|
|
355
|
+
continue;
|
|
356
|
+
found.parent[found.index] = {
|
|
357
|
+
...found.parent[found.index],
|
|
358
|
+
attrs: {
|
|
359
|
+
...found.parent[found.index].attrs,
|
|
360
|
+
pendingStatus: 'delete',
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
processed.push(change);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (processed.length > 0) {
|
|
367
|
+
state.lastModified = new Date();
|
|
368
|
+
}
|
|
369
|
+
return processed;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Apply fine-grained text edits to a node. Resolves text matches,
|
|
373
|
+
* produces a modified node, and routes through applyChanges as a rewrite.
|
|
374
|
+
*/
|
|
375
|
+
export function applyTextEdits(nodeId, edits) {
|
|
376
|
+
const found = findNodeInDoc(state.document.content, nodeId);
|
|
377
|
+
if (!found)
|
|
378
|
+
return { success: false, error: `Node ${nodeId} not found` };
|
|
379
|
+
const originalNode = found.parent[found.index];
|
|
380
|
+
const result = applyTextEditsToNode(originalNode, edits);
|
|
381
|
+
if (!result)
|
|
382
|
+
return { success: false, error: 'No edits matched' };
|
|
383
|
+
// Store inline edit ranges for fine-grained decoration
|
|
384
|
+
result.node.attrs = {
|
|
385
|
+
...result.node.attrs,
|
|
386
|
+
pendingTextEdits: result.textEdits,
|
|
387
|
+
};
|
|
388
|
+
// Route through applyChanges as a rewrite so it goes through the normal pipeline
|
|
389
|
+
applyChanges([{
|
|
390
|
+
operation: 'rewrite',
|
|
391
|
+
nodeId,
|
|
392
|
+
content: result.node,
|
|
393
|
+
}]);
|
|
394
|
+
return { success: true };
|
|
395
|
+
}
|
|
396
|
+
/** Set the active document state. Used by documents.ts for multi-doc operations. */
|
|
397
|
+
export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata) {
|
|
398
|
+
state.document = doc;
|
|
399
|
+
state.title = title;
|
|
400
|
+
state.metadata = metadata || { title };
|
|
401
|
+
state.filePath = filePath;
|
|
402
|
+
state.isTemp = isTemp;
|
|
403
|
+
state.lastModified = lastModified || new Date();
|
|
404
|
+
state.docId = ensureDocId(state.metadata);
|
|
405
|
+
}
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// PENDING DOCUMENT STORE OPERATIONS
|
|
408
|
+
// ============================================================================
|
|
409
|
+
/** Check if a document (or the current doc) has any pending changes. */
|
|
410
|
+
export function hasPendingChanges(doc) {
|
|
411
|
+
const target = doc || state.document;
|
|
412
|
+
function scan(nodes) {
|
|
413
|
+
if (!nodes)
|
|
414
|
+
return false;
|
|
415
|
+
for (const node of nodes) {
|
|
416
|
+
if (node.attrs?.pendingStatus)
|
|
417
|
+
return true;
|
|
418
|
+
if (node.content && scan(node.content))
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
return scan(target.content);
|
|
424
|
+
}
|
|
425
|
+
/** Strip all pending attrs from the current document (after browser resolves all changes). */
|
|
426
|
+
export function stripPendingAttrs() {
|
|
427
|
+
function strip(nodes) {
|
|
428
|
+
if (!nodes)
|
|
429
|
+
return;
|
|
430
|
+
for (const node of nodes) {
|
|
431
|
+
if (node.attrs?.pendingStatus) {
|
|
432
|
+
delete node.attrs.pendingStatus;
|
|
433
|
+
delete node.attrs.pendingOriginalContent;
|
|
434
|
+
delete node.attrs.pendingTextEdits;
|
|
435
|
+
}
|
|
436
|
+
if (node.content)
|
|
437
|
+
strip(node.content);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
strip(state.document.content);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Mark leaf block nodes as pending. Only marks text-containing blocks
|
|
444
|
+
* (paragraph, heading, codeBlock, horizontalRule) — NOT container nodes
|
|
445
|
+
* (bulletList, orderedList, listItem, blockquote) whose children get marked instead.
|
|
446
|
+
* This prevents overlapping decorations and ensures accept/reject acts on visible blocks.
|
|
447
|
+
*/
|
|
448
|
+
export function markAllNodesAsPending(doc, status) {
|
|
449
|
+
function mark(nodes) {
|
|
450
|
+
if (!nodes)
|
|
451
|
+
return;
|
|
452
|
+
for (const node of nodes) {
|
|
453
|
+
if (node.type && LEAF_BLOCK_TYPES.has(node.type)) {
|
|
454
|
+
node.attrs = { ...node.attrs, pendingStatus: status };
|
|
455
|
+
if (!node.attrs.id) {
|
|
456
|
+
node.attrs.id = generateNodeId();
|
|
457
|
+
}
|
|
458
|
+
// Don't recurse into leaf blocks — prevents overlapping decorations
|
|
459
|
+
// (e.g. table marked + its inner paragraphs also marked)
|
|
460
|
+
}
|
|
461
|
+
else if (node.content) {
|
|
462
|
+
// Recurse into container children to mark nested leaf blocks (e.g. paragraphs inside listItems)
|
|
463
|
+
mark(node.content);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
mark(doc.content);
|
|
468
|
+
}
|
|
469
|
+
/** Get filenames of all docs with pending changes (disk scan + external docs + current in-memory doc). */
|
|
470
|
+
export function getPendingDocFilenames() {
|
|
471
|
+
const filenames = [];
|
|
472
|
+
try {
|
|
473
|
+
const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
|
|
474
|
+
for (const f of files) {
|
|
475
|
+
try {
|
|
476
|
+
const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
|
|
477
|
+
const { data } = matter(raw);
|
|
478
|
+
if (data.pending && Object.keys(data.pending).length > 0) {
|
|
479
|
+
filenames.push(f);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch { /* skip unreadable files */ }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch { /* ignore */ }
|
|
486
|
+
// Scan external docs for pending frontmatter
|
|
487
|
+
for (const extPath of externalDocs) {
|
|
488
|
+
try {
|
|
489
|
+
if (!existsSync(extPath))
|
|
490
|
+
continue;
|
|
491
|
+
const raw = readFileSync(extPath, 'utf-8');
|
|
492
|
+
const { data } = matter(raw);
|
|
493
|
+
if (data.pending && Object.keys(data.pending).length > 0) {
|
|
494
|
+
filenames.push(extPath);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch { /* skip unreadable files */ }
|
|
498
|
+
}
|
|
499
|
+
// Check current in-memory doc (may have unsaved pending state)
|
|
500
|
+
const currentFilename = state.filePath
|
|
501
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
502
|
+
: '';
|
|
503
|
+
if (currentFilename && hasPendingChanges() && !filenames.includes(currentFilename)) {
|
|
504
|
+
filenames.push(currentFilename);
|
|
505
|
+
}
|
|
506
|
+
return filenames;
|
|
507
|
+
}
|
|
508
|
+
/** Get pending change counts per filename (disk scan + external docs + current in-memory doc). */
|
|
509
|
+
export function getPendingDocCounts() {
|
|
510
|
+
const counts = {};
|
|
511
|
+
try {
|
|
512
|
+
const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
|
|
513
|
+
for (const f of files) {
|
|
514
|
+
try {
|
|
515
|
+
const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
|
|
516
|
+
const { data } = matter(raw);
|
|
517
|
+
if (data.pending && Object.keys(data.pending).length > 0) {
|
|
518
|
+
counts[f] = Object.keys(data.pending).length;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch { /* skip unreadable files */ }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch { /* ignore */ }
|
|
525
|
+
// Scan external docs
|
|
526
|
+
for (const extPath of externalDocs) {
|
|
527
|
+
try {
|
|
528
|
+
if (!existsSync(extPath))
|
|
529
|
+
continue;
|
|
530
|
+
const raw = readFileSync(extPath, 'utf-8');
|
|
531
|
+
const { data } = matter(raw);
|
|
532
|
+
if (data.pending && Object.keys(data.pending).length > 0) {
|
|
533
|
+
counts[extPath] = Object.keys(data.pending).length;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch { /* skip unreadable files */ }
|
|
537
|
+
}
|
|
538
|
+
// Current in-memory doc may have unsaved pending state
|
|
539
|
+
const currentFilename = state.filePath
|
|
540
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
541
|
+
: '';
|
|
542
|
+
if (currentFilename && hasPendingChanges()) {
|
|
543
|
+
counts[currentFilename] = getPendingChangeCount();
|
|
544
|
+
}
|
|
545
|
+
return counts;
|
|
546
|
+
}
|
|
547
|
+
// ============================================================================
|
|
548
|
+
// PERSISTENCE
|
|
549
|
+
// ============================================================================
|
|
550
|
+
function writeToDisk() {
|
|
551
|
+
ensureDataDir();
|
|
552
|
+
const markdown = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
553
|
+
if (existsSync(state.filePath)) {
|
|
554
|
+
// Skip write if content is identical (prevents phantom git changes on doc switch)
|
|
555
|
+
try {
|
|
556
|
+
const existing = readFileSync(state.filePath, 'utf-8');
|
|
557
|
+
if (existing === markdown)
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
catch { /* read failed, proceed with write */ }
|
|
561
|
+
// Safety: don't overwrite a file with substantial content using near-empty content.
|
|
562
|
+
// Prevents save cascades where empty editor state destroys chapter files.
|
|
563
|
+
// Exception: docs with pending changes may legitimately be smaller (agent replaced content).
|
|
564
|
+
if (!hasPendingChanges()) {
|
|
565
|
+
try {
|
|
566
|
+
const existingSize = statSync(state.filePath).size;
|
|
567
|
+
if (existingSize > 200 && markdown.length < existingSize * 0.1) {
|
|
568
|
+
console.error(`[State] BLOCKED destructive save: ${markdown.length} bytes would replace ${existingSize} bytes in ${state.filePath}`);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch { /* stat failed, proceed with save */ }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
writeFileSync(state.filePath, markdown, 'utf-8');
|
|
576
|
+
// Best-effort version snapshot — never blocks saves
|
|
577
|
+
try {
|
|
578
|
+
snapshotIfNeeded(state.docId, state.filePath);
|
|
579
|
+
}
|
|
580
|
+
catch { /* ignore */ }
|
|
581
|
+
}
|
|
582
|
+
export function save() {
|
|
583
|
+
if (!state.filePath) {
|
|
584
|
+
// First save — assign a file path
|
|
585
|
+
ensureDataDir();
|
|
586
|
+
if (state.title === 'Untitled') {
|
|
587
|
+
state.filePath = tempFilePath();
|
|
588
|
+
state.isTemp = true;
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
state.filePath = filePathForTitle(state.title);
|
|
592
|
+
state.isTemp = false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
writeToDisk();
|
|
596
|
+
}
|
|
597
|
+
export function load() {
|
|
598
|
+
ensureDataDir();
|
|
599
|
+
// Restore external document registry from disk
|
|
600
|
+
loadExternalDocs();
|
|
601
|
+
// Migrate any .sw.json files to .md
|
|
602
|
+
migrateSwJsonFiles();
|
|
603
|
+
// Clean up empty temp files from previous sessions
|
|
604
|
+
cleanupEmptyTempFiles();
|
|
605
|
+
// Find most recently modified .md file
|
|
606
|
+
const files = readdirSync(DATA_DIR)
|
|
607
|
+
.filter((f) => f.endsWith('.md'))
|
|
608
|
+
.map((f) => {
|
|
609
|
+
const fullPath = join(DATA_DIR, f);
|
|
610
|
+
const stat = statSync(fullPath);
|
|
611
|
+
return { name: f, path: fullPath, mtime: stat.mtimeMs };
|
|
612
|
+
})
|
|
613
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
614
|
+
if (files.length === 0) {
|
|
615
|
+
// No existing docs — start fresh with temp file
|
|
616
|
+
state.filePath = tempFilePath();
|
|
617
|
+
state.isTemp = true;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Open the most recent file
|
|
621
|
+
const latest = files[0];
|
|
622
|
+
try {
|
|
623
|
+
const raw = readFileSync(latest.path, 'utf-8');
|
|
624
|
+
const parsed = markdownToTiptap(raw);
|
|
625
|
+
state.document = parsed.document;
|
|
626
|
+
state.title = parsed.title;
|
|
627
|
+
state.metadata = parsed.metadata;
|
|
628
|
+
state.lastModified = new Date(statSync(latest.path).mtimeMs);
|
|
629
|
+
state.filePath = latest.path;
|
|
630
|
+
state.isTemp = latest.name.startsWith(TEMP_PREFIX);
|
|
631
|
+
// Lazy docId migration: assign if missing, save to persist
|
|
632
|
+
const hadDocId = !!state.metadata.docId;
|
|
633
|
+
state.docId = ensureDocId(state.metadata);
|
|
634
|
+
if (!hadDocId) {
|
|
635
|
+
const md = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
636
|
+
writeFileSync(state.filePath, md, 'utf-8');
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// Corrupt file — start fresh
|
|
641
|
+
state.filePath = tempFilePath();
|
|
642
|
+
state.isTemp = true;
|
|
643
|
+
}
|
|
644
|
+
// Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
|
|
645
|
+
setAgentLock();
|
|
646
|
+
}
|
|
647
|
+
/** Migrate legacy .sw.json files to .md format */
|
|
648
|
+
function migrateSwJsonFiles() {
|
|
649
|
+
try {
|
|
650
|
+
const jsonFiles = readdirSync(DATA_DIR).filter((f) => f.endsWith('.sw.json'));
|
|
651
|
+
for (const f of jsonFiles) {
|
|
652
|
+
const jsonPath = join(DATA_DIR, f);
|
|
653
|
+
const mdName = f.replace(/\.sw\.json$/, '.md');
|
|
654
|
+
const mdPath = join(DATA_DIR, mdName);
|
|
655
|
+
// Skip if .md already exists
|
|
656
|
+
if (existsSync(mdPath)) {
|
|
657
|
+
try {
|
|
658
|
+
unlinkSync(jsonPath);
|
|
659
|
+
}
|
|
660
|
+
catch { /* ignore */ }
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
const raw = readFileSync(jsonPath, 'utf-8');
|
|
665
|
+
const data = JSON.parse(raw);
|
|
666
|
+
if (data.document) {
|
|
667
|
+
const title = data.title || 'Untitled';
|
|
668
|
+
const markdown = tiptapToMarkdown(data.document, title);
|
|
669
|
+
writeFileSync(mdPath, markdown, 'utf-8');
|
|
670
|
+
console.log(`[State] Migrated ${f} → ${mdName}`);
|
|
671
|
+
}
|
|
672
|
+
unlinkSync(jsonPath);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
// Corrupt JSON file — delete it
|
|
676
|
+
try {
|
|
677
|
+
unlinkSync(jsonPath);
|
|
678
|
+
}
|
|
679
|
+
catch { /* ignore */ }
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
catch { /* ignore errors during migration */ }
|
|
684
|
+
}
|
|
685
|
+
/** Collect all filenames referenced by any workspace manifest. */
|
|
686
|
+
function getWorkspaceReferencedFiles() {
|
|
687
|
+
const referenced = new Set();
|
|
688
|
+
try {
|
|
689
|
+
const wsDir = join(DATA_DIR, '_workspaces');
|
|
690
|
+
if (!existsSync(wsDir))
|
|
691
|
+
return referenced;
|
|
692
|
+
const manifests = readdirSync(wsDir).filter((f) => f.endsWith('.json'));
|
|
693
|
+
for (const m of manifests) {
|
|
694
|
+
try {
|
|
695
|
+
const raw = readFileSync(join(wsDir, m), 'utf-8');
|
|
696
|
+
const ws = JSON.parse(raw);
|
|
697
|
+
// Recursively collect doc files from root tree
|
|
698
|
+
const collect = (nodes) => {
|
|
699
|
+
for (const n of nodes) {
|
|
700
|
+
if (n.type === 'doc' && n.file)
|
|
701
|
+
referenced.add(n.file);
|
|
702
|
+
if (n.type === 'container' && Array.isArray(n.items))
|
|
703
|
+
collect(n.items);
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
if (Array.isArray(ws.root))
|
|
707
|
+
collect(ws.root);
|
|
708
|
+
else if (Array.isArray(ws.items)) {
|
|
709
|
+
// v1 format
|
|
710
|
+
for (const item of ws.items) {
|
|
711
|
+
if (item.file)
|
|
712
|
+
referenced.add(item.file);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch { /* skip corrupt manifests */ }
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch { /* ignore */ }
|
|
720
|
+
return referenced;
|
|
721
|
+
}
|
|
722
|
+
/** Remove temp files that are empty (from abandoned sessions) */
|
|
723
|
+
function cleanupEmptyTempFiles() {
|
|
724
|
+
try {
|
|
725
|
+
const wsRefs = getWorkspaceReferencedFiles();
|
|
726
|
+
const files = readdirSync(DATA_DIR).filter((f) => f.startsWith(TEMP_PREFIX) && f.endsWith('.md'));
|
|
727
|
+
for (const f of files) {
|
|
728
|
+
// Never delete temp files that are referenced by a workspace
|
|
729
|
+
if (wsRefs.has(f))
|
|
730
|
+
continue;
|
|
731
|
+
const fullPath = join(DATA_DIR, f);
|
|
732
|
+
try {
|
|
733
|
+
const raw = readFileSync(fullPath, 'utf-8');
|
|
734
|
+
const parsed = markdownToTiptap(raw);
|
|
735
|
+
if (isDocEmpty(parsed.document)) {
|
|
736
|
+
unlinkSync(fullPath);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// Corrupt temp file — delete it (but only if not workspace-referenced)
|
|
741
|
+
try {
|
|
742
|
+
unlinkSync(fullPath);
|
|
743
|
+
}
|
|
744
|
+
catch { /* ignore */ }
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch { /* ignore errors during cleanup */ }
|
|
749
|
+
}
|