openwriter 0.4.0 → 0.5.1
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-Be3gaGeo.css +1 -0
- package/dist/client/assets/index-BwT1KW6a.js +207 -0
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +105 -8
- package/dist/server/git-sync.js +3 -2
- package/dist/server/helpers.js +17 -4
- package/dist/server/index.js +115 -10
- package/dist/server/markdown-parse.js +12 -0
- package/dist/server/markdown-serialize.js +12 -0
- package/dist/server/mcp.js +96 -19
- package/dist/server/plugin-manager.js +1 -0
- package/dist/server/prompt-debug.js +58 -0
- package/dist/server/state.js +269 -25
- package/dist/server/workspace-routes.js +11 -1
- package/dist/server/workspaces.js +6 -0
- package/dist/server/ws.js +36 -9
- package/package.json +1 -1
- package/skill/SKILL.md +10 -9
- package/dist/client/assets/index-CqeJ7cMy.css +0 -1
- package/dist/client/assets/index-DiDoklNt.js +0 -209
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-BwT1KW6a.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Be3gaGeo.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/server/documents.js
CHANGED
|
@@ -9,9 +9,27 @@ import matter from 'gray-matter';
|
|
|
9
9
|
import trash from 'trash';
|
|
10
10
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
11
11
|
import { parseMarkdownContent } from './compact.js';
|
|
12
|
-
import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, } from './state.js';
|
|
13
|
-
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
12
|
+
import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, } from './state.js';
|
|
13
|
+
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
14
14
|
import { ensureDocId } from './versions.js';
|
|
15
|
+
const DOC_ORDER_FILE = join(DATA_DIR, '_doc-order.json');
|
|
16
|
+
function readDocOrder() {
|
|
17
|
+
try {
|
|
18
|
+
if (!existsSync(DOC_ORDER_FILE))
|
|
19
|
+
return [];
|
|
20
|
+
return JSON.parse(readFileSync(DOC_ORDER_FILE, 'utf-8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeDocOrder(order) {
|
|
27
|
+
ensureDataDir();
|
|
28
|
+
writeFileSync(DOC_ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
export function reorderDocs(orderedFilenames) {
|
|
31
|
+
writeDocOrder(orderedFilenames);
|
|
32
|
+
}
|
|
15
33
|
export function listDocuments() {
|
|
16
34
|
ensureDataDir();
|
|
17
35
|
const currentPath = getFilePath();
|
|
@@ -68,14 +86,32 @@ export function listDocuments() {
|
|
|
68
86
|
}
|
|
69
87
|
catch { /* skip unreadable external files */ }
|
|
70
88
|
}
|
|
71
|
-
//
|
|
72
|
-
|
|
89
|
+
// Sort by persisted order; docs not in manifest prepend (newest first by mtime)
|
|
90
|
+
const order = readDocOrder();
|
|
91
|
+
if (order.length > 0) {
|
|
92
|
+
const orderIndex = new Map(order.map((f, i) => [f, i]));
|
|
93
|
+
files.sort((a, b) => {
|
|
94
|
+
const ai = orderIndex.get(a.filename) ?? -1;
|
|
95
|
+
const bi = orderIndex.get(b.filename) ?? -1;
|
|
96
|
+
// Both unknown → newest first by mtime
|
|
97
|
+
if (ai === -1 && bi === -1)
|
|
98
|
+
return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
|
|
99
|
+
// Unknown docs sort before known (prepend)
|
|
100
|
+
if (ai === -1)
|
|
101
|
+
return -1;
|
|
102
|
+
if (bi === -1)
|
|
103
|
+
return 1;
|
|
104
|
+
return ai - bi;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
73
107
|
return files;
|
|
74
108
|
}
|
|
75
109
|
export function switchDocument(filename) {
|
|
76
|
-
// Cancel any pending debounced save, then save current doc immediately
|
|
110
|
+
// Cancel any pending debounced save, then save current doc immediately.
|
|
77
111
|
cancelDebouncedSave();
|
|
78
112
|
save();
|
|
113
|
+
// Cache current doc before switching (preserves node IDs)
|
|
114
|
+
cacheActiveDocument();
|
|
79
115
|
// Read target from disk — markdownToTiptap rehydrates pending state
|
|
80
116
|
const targetPath = resolveDocPath(filename);
|
|
81
117
|
if (!existsSync(targetPath)) {
|
|
@@ -85,6 +121,12 @@ export function switchDocument(filename) {
|
|
|
85
121
|
if (isExternalDoc(filename)) {
|
|
86
122
|
registerExternalDoc(targetPath);
|
|
87
123
|
}
|
|
124
|
+
// Check cache first — preserves stable node IDs across switches
|
|
125
|
+
const cached = getCachedDocument(targetPath);
|
|
126
|
+
if (cached) {
|
|
127
|
+
setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
128
|
+
return { document: getDocument(), title: getTitle(), filename };
|
|
129
|
+
}
|
|
88
130
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
89
131
|
const parsed = markdownToTiptap(raw);
|
|
90
132
|
const mtime = new Date(statSync(targetPath).mtimeMs);
|
|
@@ -98,6 +140,8 @@ export function createDocument(title, content, path) {
|
|
|
98
140
|
// Cancel any pending debounced save, then save current doc immediately
|
|
99
141
|
cancelDebouncedSave();
|
|
100
142
|
save();
|
|
143
|
+
// Cache current doc before switching to new one
|
|
144
|
+
cacheActiveDocument();
|
|
101
145
|
const docTitle = title || 'Untitled';
|
|
102
146
|
let filePath;
|
|
103
147
|
let isTemp;
|
|
@@ -116,7 +160,19 @@ export function createDocument(title, content, path) {
|
|
|
116
160
|
}
|
|
117
161
|
else {
|
|
118
162
|
isTemp = !title;
|
|
119
|
-
|
|
163
|
+
if (isTemp) {
|
|
164
|
+
filePath = tempFilePath();
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
filePath = filePathForTitle(docTitle);
|
|
168
|
+
// Deduplicate: append counter if file already exists
|
|
169
|
+
if (existsSync(filePath)) {
|
|
170
|
+
let counter = 2;
|
|
171
|
+
while (existsSync(filePathForTitle(`${docTitle} ${counter}`)))
|
|
172
|
+
counter++;
|
|
173
|
+
filePath = filePathForTitle(`${docTitle} ${counter}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
120
176
|
filename = filePath.split(/[/\\]/).pop();
|
|
121
177
|
}
|
|
122
178
|
let newDoc;
|
|
@@ -138,12 +194,14 @@ export function createDocument(title, content, path) {
|
|
|
138
194
|
// Write doc to disk
|
|
139
195
|
const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
|
|
140
196
|
ensureDataDir();
|
|
141
|
-
|
|
197
|
+
atomicWriteFileSync(filePath, markdown);
|
|
142
198
|
return { document: getDocument(), title: getTitle(), filename };
|
|
143
199
|
}
|
|
144
200
|
export async function deleteDocument(filename) {
|
|
145
201
|
ensureDataDir();
|
|
146
202
|
const targetPath = resolveDocPath(filename);
|
|
203
|
+
// Invalidate cache for deleted doc
|
|
204
|
+
invalidateDocCache(targetPath);
|
|
147
205
|
// Unregister if external
|
|
148
206
|
if (isExternalDoc(filename)) {
|
|
149
207
|
unregisterExternalDoc(targetPath);
|
|
@@ -176,6 +234,8 @@ export function reloadDocument() {
|
|
|
176
234
|
if (!existsSync(filePath)) {
|
|
177
235
|
throw new Error('Active document file not found on disk');
|
|
178
236
|
}
|
|
237
|
+
// Force fresh parse — invalidate any cached version
|
|
238
|
+
invalidateDocCache(filePath);
|
|
179
239
|
const filename = filePath.split(/[/\\]/).pop();
|
|
180
240
|
const raw = readFileSync(filePath, 'utf-8');
|
|
181
241
|
const parsed = markdownToTiptap(raw);
|
|
@@ -193,7 +253,7 @@ export function updateDocumentTitle(filename, newTitle) {
|
|
|
193
253
|
const parsed = markdownToTiptap(raw);
|
|
194
254
|
const metadata = { ...parsed.metadata, title: newTitle };
|
|
195
255
|
const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
|
|
196
|
-
|
|
256
|
+
atomicWriteFileSync(filePath, markdown);
|
|
197
257
|
// Update state if this is the active document
|
|
198
258
|
const baseName = filePath.split(/[/\\]/).pop() || '';
|
|
199
259
|
if (getFilePath() === filePath) {
|
|
@@ -208,10 +268,19 @@ export function openFile(fullPath) {
|
|
|
208
268
|
// Cancel any pending debounced save, then save current doc immediately
|
|
209
269
|
cancelDebouncedSave();
|
|
210
270
|
save();
|
|
271
|
+
// Cache current doc before switching
|
|
272
|
+
cacheActiveDocument();
|
|
211
273
|
// Register as external if not in DATA_DIR
|
|
212
274
|
if (isExternalDoc(fullPath)) {
|
|
213
275
|
registerExternalDoc(fullPath);
|
|
214
276
|
}
|
|
277
|
+
// Check cache first — preserves stable node IDs
|
|
278
|
+
const cached = getCachedDocument(fullPath);
|
|
279
|
+
if (cached) {
|
|
280
|
+
setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
281
|
+
const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
|
|
282
|
+
return { document: getDocument(), title: getTitle(), filename };
|
|
283
|
+
}
|
|
215
284
|
const raw = readFileSync(fullPath, 'utf-8');
|
|
216
285
|
const parsed = markdownToTiptap(raw);
|
|
217
286
|
const mtime = new Date(statSync(fullPath).mtimeMs);
|
|
@@ -222,6 +291,34 @@ export function openFile(fullPath) {
|
|
|
222
291
|
const filename = isExternalDoc(fullPath) ? fullPath : baseName;
|
|
223
292
|
return { document: getDocument(), title: getTitle(), filename };
|
|
224
293
|
}
|
|
294
|
+
export function duplicateDocument(filename) {
|
|
295
|
+
// Cancel any pending debounced save, then save current doc immediately
|
|
296
|
+
cancelDebouncedSave();
|
|
297
|
+
save();
|
|
298
|
+
const sourcePath = resolveDocPath(filename);
|
|
299
|
+
if (!existsSync(sourcePath)) {
|
|
300
|
+
throw new Error(`Document not found: ${filename}`);
|
|
301
|
+
}
|
|
302
|
+
const raw = readFileSync(sourcePath, 'utf-8');
|
|
303
|
+
const parsed = markdownToTiptap(raw);
|
|
304
|
+
// Generate deduplicated title
|
|
305
|
+
let newTitle = `${parsed.title} (Copy)`;
|
|
306
|
+
let filePath = filePathForTitle(newTitle);
|
|
307
|
+
if (existsSync(filePath)) {
|
|
308
|
+
let counter = 2;
|
|
309
|
+
while (existsSync(filePathForTitle(`${parsed.title} (Copy ${counter})`)))
|
|
310
|
+
counter++;
|
|
311
|
+
newTitle = `${parsed.title} (Copy ${counter})`;
|
|
312
|
+
filePath = filePathForTitle(newTitle);
|
|
313
|
+
}
|
|
314
|
+
const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
|
|
315
|
+
setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
|
|
316
|
+
const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
|
|
317
|
+
ensureDataDir();
|
|
318
|
+
atomicWriteFileSync(filePath, markdown);
|
|
319
|
+
const newFilename = filePath.split(/[/\\]/).pop();
|
|
320
|
+
return { document: getDocument(), title: getTitle(), filename: newFilename };
|
|
321
|
+
}
|
|
225
322
|
export function getActiveFilename() {
|
|
226
323
|
const filePath = getFilePath();
|
|
227
324
|
// For external docs, return the full path as the identifier
|
package/dist/server/git-sync.js
CHANGED
|
@@ -6,7 +6,7 @@ import { execFile } from 'child_process';
|
|
|
6
6
|
import { existsSync, writeFileSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { DATA_DIR, readConfig, saveConfig } from './helpers.js';
|
|
9
|
-
import { save } from './state.js';
|
|
9
|
+
import { save, cancelDebouncedSave } from './state.js';
|
|
10
10
|
const GITIGNORE_CONTENT = `config.json\n.versions/\n`;
|
|
11
11
|
const NETWORK_TIMEOUT = 30000;
|
|
12
12
|
let currentSyncState = 'unconfigured';
|
|
@@ -242,7 +242,8 @@ export async function pushSync(onStatus) {
|
|
|
242
242
|
lastError = undefined;
|
|
243
243
|
onStatus({ state: 'syncing' });
|
|
244
244
|
try {
|
|
245
|
-
// Flush current document to disk first
|
|
245
|
+
// Flush current document to disk first (cancel debounce to ensure immediate write)
|
|
246
|
+
cancelDebouncedSave();
|
|
246
247
|
save();
|
|
247
248
|
ensureGitignore();
|
|
248
249
|
await exec('git', ['add', '-A'], DATA_DIR);
|
package/dist/server/helpers.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Both state.ts and documents.ts import from here to avoid duplication.
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
6
|
-
import { join, isAbsolute, basename, dirname } from 'path';
|
|
6
|
+
import { join, isAbsolute, basename, dirname, resolve, sep } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
export const DATA_DIR = join(homedir(), '.openwriter');
|
|
@@ -40,9 +40,16 @@ export function tempFilePath() {
|
|
|
40
40
|
// ---- Path resolution for external documents ----
|
|
41
41
|
/** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
|
|
42
42
|
export function resolveDocPath(filename) {
|
|
43
|
-
|
|
43
|
+
// External docs use absolute paths as identifiers — pass through
|
|
44
|
+
if (isAbsolute(filename))
|
|
44
45
|
return filename;
|
|
45
|
-
|
|
46
|
+
// Internal docs must resolve within DATA_DIR — block path traversal
|
|
47
|
+
const resolved = resolve(DATA_DIR, filename);
|
|
48
|
+
const dataDir = resolve(DATA_DIR);
|
|
49
|
+
if (resolved !== dataDir && !resolved.startsWith(dataDir + sep)) {
|
|
50
|
+
throw new Error(`Path traversal blocked: ${filename}`);
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
46
53
|
}
|
|
47
54
|
/** Returns true if filename is a full path (not a simple basename in DATA_DIR). */
|
|
48
55
|
export function isExternalDoc(filename) {
|
|
@@ -62,6 +69,12 @@ export function getParentDirName(filename) {
|
|
|
62
69
|
return '';
|
|
63
70
|
return basename(dirname(filename));
|
|
64
71
|
}
|
|
72
|
+
/** Atomic write: write to temp file + rename to prevent corruption on crash. */
|
|
73
|
+
export function atomicWriteFileSync(filePath, data) {
|
|
74
|
+
const tmpPath = filePath + '.tmp';
|
|
75
|
+
writeFileSync(tmpPath, data, 'utf-8');
|
|
76
|
+
renameSync(tmpPath, filePath);
|
|
77
|
+
}
|
|
65
78
|
/** Generate an 8-char hex node ID for TipTap block nodes. */
|
|
66
79
|
export function generateNodeId() {
|
|
67
80
|
return randomUUID().replace(/-/g, '').slice(0, 8);
|
|
@@ -83,5 +96,5 @@ export function saveConfig(updates) {
|
|
|
83
96
|
ensureDataDir();
|
|
84
97
|
const current = readConfig();
|
|
85
98
|
const merged = { ...current, ...updates };
|
|
86
|
-
|
|
99
|
+
atomicWriteFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
87
100
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -6,13 +6,14 @@ import express from 'express';
|
|
|
6
6
|
import { createServer } from 'http';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { dirname, join } from 'path';
|
|
9
|
-
import { existsSync } from 'fs';
|
|
10
|
-
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastSyncStatus } from './ws.js';
|
|
9
|
+
import { existsSync, readFileSync } from 'fs';
|
|
10
|
+
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
11
11
|
import { TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
-
import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag } from './state.js';
|
|
15
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, reloadDocument, updateDocumentTitle, openFile } from './documents.js';
|
|
14
|
+
import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
|
|
15
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs } from './documents.js';
|
|
16
|
+
import { writePromptDebug } from './prompt-debug.js';
|
|
16
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
17
18
|
import { createLinkRouter } from './link-routes.js';
|
|
18
19
|
import { createTweetRouter } from './tweet-routes.js';
|
|
@@ -21,6 +22,7 @@ import { importGoogleDoc } from './gdoc-import.js';
|
|
|
21
22
|
import { createVersionRouter } from './version-routes.js';
|
|
22
23
|
import { createSyncRouter } from './sync-routes.js';
|
|
23
24
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
25
|
+
import { resolveDocPath } from './helpers.js';
|
|
24
26
|
import { createImageRouter } from './image-upload.js';
|
|
25
27
|
import { createExportRouter } from './export-routes.js';
|
|
26
28
|
import { PluginManager } from './plugin-manager.js';
|
|
@@ -97,15 +99,15 @@ export async function startHttpServer(options = {}) {
|
|
|
97
99
|
res.json({ success: true });
|
|
98
100
|
});
|
|
99
101
|
// Beacon-based flush: browser sends this on beforeunload/visibilitychange
|
|
100
|
-
//
|
|
101
|
-
app.post('/api/flush',
|
|
102
|
+
// Client sends as application/json Blob (non-CORS-safelisted, so cross-origin sendBeacon is blocked)
|
|
103
|
+
app.post('/api/flush', (req, res) => {
|
|
102
104
|
try {
|
|
103
105
|
if (isAgentLocked()) {
|
|
104
106
|
console.log('[Flush] Blocked (agent write lock active)');
|
|
105
107
|
res.status(204).end();
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
|
-
const msg =
|
|
110
|
+
const msg = req.body;
|
|
109
111
|
if (msg.document) {
|
|
110
112
|
updateDocument(msg.document);
|
|
111
113
|
save();
|
|
@@ -127,10 +129,58 @@ export async function startHttpServer(options = {}) {
|
|
|
127
129
|
app.get('/api/documents', (_req, res) => {
|
|
128
130
|
res.json(listDocuments());
|
|
129
131
|
});
|
|
132
|
+
app.put('/api/documents/reorder', (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
const { order } = req.body;
|
|
135
|
+
if (!Array.isArray(order))
|
|
136
|
+
return res.status(400).json({ error: 'order must be an array' });
|
|
137
|
+
reorderDocs(order);
|
|
138
|
+
broadcastDocumentsChanged();
|
|
139
|
+
res.json({ success: true });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
res.status(400).json({ error: err.message });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
130
145
|
app.post('/api/documents', (req, res) => {
|
|
131
146
|
try {
|
|
132
147
|
const result = createDocument(req.body.title, req.body.content, req.body.path);
|
|
148
|
+
// Apply metadata if provided (e.g. tweetContext for threadified docs)
|
|
149
|
+
if (req.body.metadata) {
|
|
150
|
+
setMetadata(req.body.metadata);
|
|
151
|
+
save();
|
|
152
|
+
}
|
|
153
|
+
// Plugin flags: mark all content as pending + tag as agent-created
|
|
154
|
+
if (req.body.markPending) {
|
|
155
|
+
markAllNodesAsPending(getDocument(), 'insert');
|
|
156
|
+
updatePendingCacheForActiveDoc();
|
|
157
|
+
save();
|
|
158
|
+
}
|
|
159
|
+
if (req.body.agentCreated) {
|
|
160
|
+
setMetadata({ agentCreated: true });
|
|
161
|
+
save();
|
|
162
|
+
}
|
|
133
163
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
164
|
+
if (req.body.markPending || req.body.agentCreated) {
|
|
165
|
+
broadcastDocumentsChanged();
|
|
166
|
+
broadcastPendingDocsChanged();
|
|
167
|
+
}
|
|
168
|
+
res.json(result);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
res.status(400).json({ error: err.message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
app.post('/api/documents/duplicate', (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const { filename } = req.body;
|
|
177
|
+
if (!filename) {
|
|
178
|
+
res.status(400).json({ error: 'filename is required' });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const result = duplicateDocument(filename);
|
|
182
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
183
|
+
broadcastDocumentsChanged();
|
|
134
184
|
res.json(result);
|
|
135
185
|
}
|
|
136
186
|
catch (err) {
|
|
@@ -172,6 +222,25 @@ export async function startHttpServer(options = {}) {
|
|
|
172
222
|
res.status(500).json({ error: err.message });
|
|
173
223
|
}
|
|
174
224
|
});
|
|
225
|
+
app.get('/api/documents/:filename/content', (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
const targetPath = resolveDocPath(req.params.filename);
|
|
228
|
+
if (!existsSync(targetPath)) {
|
|
229
|
+
res.status(404).json({ error: 'Document not found' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
233
|
+
const parsed = markdownToTiptap(raw);
|
|
234
|
+
res.json({
|
|
235
|
+
title: parsed.title,
|
|
236
|
+
document: parsed.document,
|
|
237
|
+
metadata: parsed.metadata,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
res.status(500).json({ error: err.message });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
175
244
|
app.delete('/api/documents/:filename', async (req, res) => {
|
|
176
245
|
try {
|
|
177
246
|
removeDocFromAllWorkspaces(req.params.filename);
|
|
@@ -252,6 +321,22 @@ export async function startHttpServer(options = {}) {
|
|
|
252
321
|
res.status(500).json({ error: err.message });
|
|
253
322
|
}
|
|
254
323
|
});
|
|
324
|
+
// Prompt debug: write full prompt to a timestamped .md file for inspection
|
|
325
|
+
app.post('/api/prompt-debug', (req, res) => {
|
|
326
|
+
try {
|
|
327
|
+
const { action, debug, metadata } = req.body;
|
|
328
|
+
if (!debug) {
|
|
329
|
+
res.status(400).json({ error: 'debug payload is required' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const filename = writePromptDebug(action, debug, metadata);
|
|
333
|
+
broadcastDocumentsChanged();
|
|
334
|
+
res.json({ success: true, filename });
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
res.status(500).json({ error: err.message });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
255
340
|
// Google Doc import
|
|
256
341
|
app.post('/api/import/gdoc', (req, res) => {
|
|
257
342
|
try {
|
|
@@ -340,7 +425,7 @@ export async function startHttpServer(options = {}) {
|
|
|
340
425
|
// Sidebar context menu action dispatch — routes to plugin's registered HTTP routes
|
|
341
426
|
app.post('/api/plugins/sidebar-action', async (req, res) => {
|
|
342
427
|
try {
|
|
343
|
-
const { action, filename, title } = req.body;
|
|
428
|
+
const { action, filename, title, instructions, label } = req.body;
|
|
344
429
|
if (!action || !filename) {
|
|
345
430
|
res.status(400).json({ error: 'action and filename are required' });
|
|
346
431
|
return;
|
|
@@ -353,15 +438,35 @@ export async function startHttpServer(options = {}) {
|
|
|
353
438
|
}
|
|
354
439
|
const prefix = action.slice(0, colonIdx);
|
|
355
440
|
const actionName = action.slice(colonIdx + 1);
|
|
441
|
+
// Read document content so plugins don't need to call back
|
|
442
|
+
let docContent = '';
|
|
443
|
+
try {
|
|
444
|
+
const targetPath = resolveDocPath(filename);
|
|
445
|
+
if (existsSync(targetPath)) {
|
|
446
|
+
docContent = readFileSync(targetPath, 'utf-8');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch { /* content stays empty */ }
|
|
450
|
+
// Show sidebar spinner while plugin processes
|
|
451
|
+
const spinnerTitle = label ? `${label}: ${title}` : title;
|
|
452
|
+
broadcastWritingStarted(spinnerTitle);
|
|
453
|
+
// Intercept res.json to clear spinner when plugin handler responds
|
|
454
|
+
const origJson = res.json.bind(res);
|
|
455
|
+
res.json = (body) => {
|
|
456
|
+
broadcastWritingFinished();
|
|
457
|
+
return origJson(body);
|
|
458
|
+
};
|
|
356
459
|
// Forward to plugin route: POST /api/{prefix}/sidebar-action
|
|
357
460
|
// Re-route the request through Express's internal router
|
|
358
461
|
req.url = `/api/${prefix}/sidebar-action`;
|
|
359
|
-
req.body = { action: actionName, filename, title };
|
|
462
|
+
req.body = { action: actionName, filename, title, instructions, content: docContent };
|
|
360
463
|
app.handle(req, res, () => {
|
|
464
|
+
broadcastWritingFinished();
|
|
361
465
|
res.status(404).json({ error: `No handler registered for action "${action}"` });
|
|
362
466
|
});
|
|
363
467
|
}
|
|
364
468
|
catch (err) {
|
|
469
|
+
broadcastWritingFinished();
|
|
365
470
|
res.status(500).json({ error: err.message });
|
|
366
471
|
}
|
|
367
472
|
});
|
|
@@ -393,7 +498,7 @@ export async function startHttpServer(options = {}) {
|
|
|
393
498
|
setupWebSocket(server);
|
|
394
499
|
// Broadcast agent status now that WS is ready
|
|
395
500
|
broadcastAgentStatus(true);
|
|
396
|
-
server.listen(port, () => {
|
|
501
|
+
server.listen(port, '127.0.0.1', () => {
|
|
397
502
|
console.log(`OpenWriter running at http://localhost:${port}`);
|
|
398
503
|
});
|
|
399
504
|
// Open browser unless --no-open or running as MCP stdio pipe
|
|
@@ -85,6 +85,18 @@ function rehydratePendingState(doc, pending) {
|
|
|
85
85
|
if (entry.o) {
|
|
86
86
|
target.attrs.pendingOriginalContent = entry.o;
|
|
87
87
|
}
|
|
88
|
+
if (entry.g) {
|
|
89
|
+
target.attrs.pendingGroupId = entry.g;
|
|
90
|
+
}
|
|
91
|
+
// Selection range attrs (sub-paragraph enhance)
|
|
92
|
+
if (entry.sf != null)
|
|
93
|
+
target.attrs.pendingSelectionFrom = entry.sf;
|
|
94
|
+
if (entry.st != null)
|
|
95
|
+
target.attrs.pendingSelectionTo = entry.st;
|
|
96
|
+
if (entry.of != null)
|
|
97
|
+
target.attrs.pendingOriginalFrom = entry.of;
|
|
98
|
+
if (entry.ot != null)
|
|
99
|
+
target.attrs.pendingOriginalTo = entry.ot;
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
}
|
|
@@ -30,6 +30,18 @@ function collectPendingState(doc) {
|
|
|
30
30
|
if (node.attrs.pendingOriginalContent) {
|
|
31
31
|
entry.o = node.attrs.pendingOriginalContent;
|
|
32
32
|
}
|
|
33
|
+
if (node.attrs.pendingGroupId) {
|
|
34
|
+
entry.g = node.attrs.pendingGroupId;
|
|
35
|
+
}
|
|
36
|
+
// Selection range attrs (sub-paragraph enhance)
|
|
37
|
+
if (node.attrs.pendingSelectionFrom != null)
|
|
38
|
+
entry.sf = node.attrs.pendingSelectionFrom;
|
|
39
|
+
if (node.attrs.pendingSelectionTo != null)
|
|
40
|
+
entry.st = node.attrs.pendingSelectionTo;
|
|
41
|
+
if (node.attrs.pendingOriginalFrom != null)
|
|
42
|
+
entry.of = node.attrs.pendingOriginalFrom;
|
|
43
|
+
if (node.attrs.pendingOriginalTo != null)
|
|
44
|
+
entry.ot = node.attrs.pendingOriginalTo;
|
|
33
45
|
const t = nodeText(node);
|
|
34
46
|
if (t)
|
|
35
47
|
entry.t = t;
|