openwriter 0.2.0 → 0.2.2
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/README.md +7 -5
- package/dist/client/assets/index-De-jpZgc.css +1 -0
- package/dist/client/assets/index-FOERHzGc.js +205 -0
- package/dist/client/index.html +2 -2
- package/dist/server/compact.js +1 -2
- package/dist/server/documents.js +6 -5
- package/dist/server/index.js +88 -30
- package/dist/server/link-routes.js +6 -5
- package/dist/server/mcp.js +148 -76
- package/dist/server/plugin-discovery.js +64 -0
- package/dist/server/plugin-manager.js +155 -0
- package/dist/server/state.js +197 -26
- package/dist/server/workspace-routes.js +3 -24
- package/dist/server/workspace-tags.js +0 -3
- package/dist/server/workspace-types.js +0 -8
- package/dist/server/workspaces.js +128 -38
- package/dist/server/ws.js +63 -12
- package/package.json +3 -2
- package/skill/SKILL.md +112 -32
- package/dist/client/assets/index-DNJs7lC-.js +0 -205
- package/dist/client/assets/index-WweytMO1.css +0 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin discovery: scans the plugins/ directory for available plugins.
|
|
3
|
+
* Reads package.json metadata without importing or loading the plugin code.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { dirname } from 'path';
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
/**
|
|
12
|
+
* Scan the plugins/ directory at the monorepo root.
|
|
13
|
+
* Returns metadata from each plugin's package.json without importing code.
|
|
14
|
+
* Returns [] if plugins/ doesn't exist (e.g. npm install scenario).
|
|
15
|
+
*/
|
|
16
|
+
export function discoverPlugins() {
|
|
17
|
+
// At runtime: dist/server/ → ../../../.. → monorepo root → /plugins/
|
|
18
|
+
const pluginsDir = join(__dirname, '..', '..', '..', '..', 'plugins');
|
|
19
|
+
if (!existsSync(pluginsDir))
|
|
20
|
+
return [];
|
|
21
|
+
const results = [];
|
|
22
|
+
for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) {
|
|
23
|
+
if (!entry.isDirectory())
|
|
24
|
+
continue;
|
|
25
|
+
const pkgPath = join(pluginsDir, entry.name, 'package.json');
|
|
26
|
+
if (!existsSync(pkgPath))
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
30
|
+
if (!pkg.name)
|
|
31
|
+
continue;
|
|
32
|
+
results.push({
|
|
33
|
+
name: pkg.name,
|
|
34
|
+
dirName: entry.name,
|
|
35
|
+
version: pkg.version || '0.0.0',
|
|
36
|
+
description: pkg.description || '',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Skip malformed package.json
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Import a plugin by npm package name and extract its metadata.
|
|
47
|
+
* Returns the plugin's configSchema and full module export.
|
|
48
|
+
*/
|
|
49
|
+
export async function loadPluginModule(name) {
|
|
50
|
+
try {
|
|
51
|
+
const mod = await import(name);
|
|
52
|
+
const plugin = mod.default || mod.plugin || mod;
|
|
53
|
+
if (!plugin.name || !plugin.version)
|
|
54
|
+
return null;
|
|
55
|
+
return {
|
|
56
|
+
plugin,
|
|
57
|
+
configSchema: plugin.configSchema || {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error(`[PluginDiscovery] Failed to import "${name}":`, err.message);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Manager: dynamic enable/disable, config persistence, route management.
|
|
3
|
+
* Replaces the one-shot loadPlugins() with a full lifecycle manager.
|
|
4
|
+
*/
|
|
5
|
+
import { Router as createRouter } from 'express';
|
|
6
|
+
import { discoverPlugins, loadPluginModule } from './plugin-discovery.js';
|
|
7
|
+
import { registerPluginTools, removePluginTools } from './mcp.js';
|
|
8
|
+
import { readConfig, saveConfig } from './helpers.js';
|
|
9
|
+
import { broadcastPluginsChanged } from './ws.js';
|
|
10
|
+
export class PluginManager {
|
|
11
|
+
app;
|
|
12
|
+
plugins = new Map();
|
|
13
|
+
constructor(app) {
|
|
14
|
+
this.app = app;
|
|
15
|
+
}
|
|
16
|
+
/** Scan plugins/ directory and build the available plugins map. */
|
|
17
|
+
async discover() {
|
|
18
|
+
const discovered = discoverPlugins();
|
|
19
|
+
const savedConfig = readConfig();
|
|
20
|
+
const savedPlugins = savedConfig.plugins || {};
|
|
21
|
+
for (const d of discovered) {
|
|
22
|
+
// Load module to get configSchema
|
|
23
|
+
const loaded = await loadPluginModule(d.name);
|
|
24
|
+
const saved = savedPlugins[d.name];
|
|
25
|
+
this.plugins.set(d.name, {
|
|
26
|
+
discovered: d,
|
|
27
|
+
plugin: loaded?.plugin,
|
|
28
|
+
configSchema: loaded?.configSchema || {},
|
|
29
|
+
enabled: false,
|
|
30
|
+
config: saved?.config || {},
|
|
31
|
+
toolNames: [],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Enable a plugin: import, register routes + tools, save state. */
|
|
36
|
+
async enable(name) {
|
|
37
|
+
const managed = this.plugins.get(name);
|
|
38
|
+
if (!managed)
|
|
39
|
+
return { success: false, error: `Plugin "${name}" not found` };
|
|
40
|
+
if (managed.enabled)
|
|
41
|
+
return { success: true };
|
|
42
|
+
// Ensure plugin module is loaded
|
|
43
|
+
if (!managed.plugin) {
|
|
44
|
+
const loaded = await loadPluginModule(name);
|
|
45
|
+
if (!loaded)
|
|
46
|
+
return { success: false, error: `Failed to import "${name}"` };
|
|
47
|
+
managed.plugin = loaded.plugin;
|
|
48
|
+
managed.configSchema = loaded.configSchema;
|
|
49
|
+
}
|
|
50
|
+
if (!managed.plugin)
|
|
51
|
+
return { success: false, error: `Plugin "${name}" failed to load` };
|
|
52
|
+
const plugin = managed.plugin;
|
|
53
|
+
// Resolve config: saved config → env vars → empty
|
|
54
|
+
const resolvedConfig = this.resolveConfig(managed);
|
|
55
|
+
// Register routes via togglable middleware
|
|
56
|
+
if (plugin.registerRoutes) {
|
|
57
|
+
const router = createRouter();
|
|
58
|
+
await plugin.registerRoutes({ app: router, config: resolvedConfig });
|
|
59
|
+
managed.router = router;
|
|
60
|
+
// Wrap in middleware that skips when disabled
|
|
61
|
+
managed.middleware = (req, res, next) => {
|
|
62
|
+
if (!managed.enabled)
|
|
63
|
+
return next();
|
|
64
|
+
managed.router(req, res, next);
|
|
65
|
+
};
|
|
66
|
+
this.app.use(managed.middleware);
|
|
67
|
+
}
|
|
68
|
+
// Register MCP tools
|
|
69
|
+
if (plugin.mcpTools) {
|
|
70
|
+
const tools = plugin.mcpTools(resolvedConfig);
|
|
71
|
+
managed.toolNames = tools.map((t) => t.name);
|
|
72
|
+
registerPluginTools(tools);
|
|
73
|
+
}
|
|
74
|
+
managed.enabled = true;
|
|
75
|
+
managed.config = resolvedConfig;
|
|
76
|
+
this.savePluginState();
|
|
77
|
+
broadcastPluginsChanged();
|
|
78
|
+
console.log(`[PluginManager] Enabled: ${plugin.name} v${plugin.version}`);
|
|
79
|
+
return { success: true };
|
|
80
|
+
}
|
|
81
|
+
/** Disable a plugin: skip routes, remove tools, save state. */
|
|
82
|
+
async disable(name) {
|
|
83
|
+
const managed = this.plugins.get(name);
|
|
84
|
+
if (!managed)
|
|
85
|
+
return { success: false, error: `Plugin "${name}" not found` };
|
|
86
|
+
if (!managed.enabled)
|
|
87
|
+
return { success: true };
|
|
88
|
+
// Remove MCP tools
|
|
89
|
+
if (managed.toolNames.length > 0) {
|
|
90
|
+
removePluginTools(managed.toolNames);
|
|
91
|
+
managed.toolNames = [];
|
|
92
|
+
}
|
|
93
|
+
managed.enabled = false;
|
|
94
|
+
this.savePluginState();
|
|
95
|
+
broadcastPluginsChanged();
|
|
96
|
+
console.log(`[PluginManager] Disabled: ${name}`);
|
|
97
|
+
return { success: true };
|
|
98
|
+
}
|
|
99
|
+
/** Update plugin config values and save. */
|
|
100
|
+
updateConfig(name, values) {
|
|
101
|
+
const managed = this.plugins.get(name);
|
|
102
|
+
if (!managed)
|
|
103
|
+
return { success: false, error: `Plugin "${name}" not found` };
|
|
104
|
+
managed.config = { ...managed.config, ...values };
|
|
105
|
+
this.savePluginState();
|
|
106
|
+
return { success: true };
|
|
107
|
+
}
|
|
108
|
+
/** Get all discovered plugins with status and config info. */
|
|
109
|
+
getAvailablePlugins() {
|
|
110
|
+
return Array.from(this.plugins.values()).map((m) => ({
|
|
111
|
+
name: m.discovered.name,
|
|
112
|
+
version: m.discovered.version,
|
|
113
|
+
description: m.discovered.description,
|
|
114
|
+
enabled: m.enabled,
|
|
115
|
+
configSchema: m.configSchema,
|
|
116
|
+
config: m.config,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
/** Get enabled plugins' context menu items (backward-compatible with GET /api/plugins). */
|
|
120
|
+
getEnabledPluginDescriptors() {
|
|
121
|
+
const results = [];
|
|
122
|
+
for (const managed of this.plugins.values()) {
|
|
123
|
+
if (!managed.enabled || !managed.plugin)
|
|
124
|
+
continue;
|
|
125
|
+
results.push({
|
|
126
|
+
name: managed.plugin.name,
|
|
127
|
+
contextMenuItems: managed.plugin.contextMenuItems?.() || [],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
/** Resolve config values: saved config → env vars → empty. */
|
|
133
|
+
resolveConfig(managed) {
|
|
134
|
+
const resolved = { ...managed.config };
|
|
135
|
+
for (const [key, field] of Object.entries(managed.configSchema)) {
|
|
136
|
+
if (resolved[key])
|
|
137
|
+
continue;
|
|
138
|
+
const envVal = field.env ? process.env[field.env] : undefined;
|
|
139
|
+
if (envVal)
|
|
140
|
+
resolved[key] = envVal;
|
|
141
|
+
}
|
|
142
|
+
return resolved;
|
|
143
|
+
}
|
|
144
|
+
/** Persist enabled/config state to ~/.openwriter/config.json. */
|
|
145
|
+
savePluginState() {
|
|
146
|
+
const pluginsState = {};
|
|
147
|
+
for (const [name, managed] of this.plugins) {
|
|
148
|
+
pluginsState[name] = {
|
|
149
|
+
enabled: managed.enabled,
|
|
150
|
+
config: managed.config,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
saveConfig({ plugins: pluginsState });
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/server/state.js
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from 'path';
|
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
9
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
|
-
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, isExternalDoc } from './helpers.js';
|
|
11
|
+
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
13
|
const DEFAULT_DOC = {
|
|
14
14
|
type: 'doc',
|
|
@@ -213,7 +213,7 @@ function transferPendingAttrs(source, target) {
|
|
|
213
213
|
const AGENT_LOCK_MS = 5000; // Block browser doc-updates for 5s after agent write
|
|
214
214
|
let lastAgentWriteTime = 0;
|
|
215
215
|
/** Set the agent write lock (called after agent changes). */
|
|
216
|
-
function setAgentLock() {
|
|
216
|
+
export function setAgentLock() {
|
|
217
217
|
lastAgentWriteTime = Date.now();
|
|
218
218
|
}
|
|
219
219
|
/** Check if the agent write lock is active. */
|
|
@@ -249,7 +249,20 @@ export function applyChanges(changes) {
|
|
|
249
249
|
}
|
|
250
250
|
// Debounced save — coalesces rapid agent writes into a single disk write
|
|
251
251
|
debouncedSave();
|
|
252
|
-
|
|
252
|
+
// Find the last created node ID for chaining inserts
|
|
253
|
+
let lastNodeId = null;
|
|
254
|
+
for (let i = processed.length - 1; i >= 0; i--) {
|
|
255
|
+
const change = processed[i];
|
|
256
|
+
if (change.content) {
|
|
257
|
+
const contentArr = Array.isArray(change.content) ? change.content : [change.content];
|
|
258
|
+
const lastNode = contentArr[contentArr.length - 1];
|
|
259
|
+
if (lastNode?.attrs?.id) {
|
|
260
|
+
lastNodeId = lastNode.attrs.id;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { count: processed.length, lastNodeId };
|
|
253
266
|
}
|
|
254
267
|
export function onChanges(listener) {
|
|
255
268
|
listeners.add(listener);
|
|
@@ -264,6 +277,14 @@ export function onChanges(listener) {
|
|
|
264
277
|
* Returns the parent array and index for in-place mutation.
|
|
265
278
|
*/
|
|
266
279
|
function findNodeInDoc(nodes, id) {
|
|
280
|
+
// Special sentinel: "end" resolves to the last top-level node in the document
|
|
281
|
+
if (id === 'end') {
|
|
282
|
+
const topLevel = state.document.content;
|
|
283
|
+
if (topLevel && topLevel.length > 0) {
|
|
284
|
+
return { parent: topLevel, index: topLevel.length - 1 };
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
267
288
|
for (let i = 0; i < nodes.length; i++) {
|
|
268
289
|
if (nodes[i].attrs?.id === id) {
|
|
269
290
|
return { parent: nodes, index: i };
|
|
@@ -307,9 +328,9 @@ function applyChangesToDocument(changes) {
|
|
|
307
328
|
attrs: {
|
|
308
329
|
...node.attrs,
|
|
309
330
|
id: node.attrs?.id || generateNodeId(),
|
|
310
|
-
pendingStatus: 'insert',
|
|
311
331
|
},
|
|
312
332
|
}));
|
|
333
|
+
markLeafBlocksAsPending(extraNodes, 'insert');
|
|
313
334
|
found.parent.splice(found.index, 1, firstNode, ...extraNodes);
|
|
314
335
|
processed.push({
|
|
315
336
|
...change,
|
|
@@ -324,9 +345,10 @@ function applyChangesToDocument(changes) {
|
|
|
324
345
|
attrs: {
|
|
325
346
|
...node.attrs,
|
|
326
347
|
id: node.attrs?.id || (change.nodeId && !change.afterNodeId && i === 0 ? change.nodeId : generateNodeId()),
|
|
327
|
-
pendingStatus: 'insert',
|
|
328
348
|
},
|
|
329
349
|
}));
|
|
350
|
+
// Mark leaf blocks as pending (not containers) for correct serialization
|
|
351
|
+
markLeafBlocksAsPending(contentWithIds, 'insert');
|
|
330
352
|
if (change.nodeId && !change.afterNodeId) {
|
|
331
353
|
// Replace empty node
|
|
332
354
|
const found = findNodeInDoc(state.document.content, change.nodeId);
|
|
@@ -440,31 +462,28 @@ export function stripPendingAttrs() {
|
|
|
440
462
|
strip(state.document.content);
|
|
441
463
|
}
|
|
442
464
|
/**
|
|
443
|
-
* Mark leaf block nodes as pending
|
|
444
|
-
* (paragraph, heading, codeBlock,
|
|
445
|
-
* (bulletList, orderedList, listItem, blockquote)
|
|
446
|
-
* This
|
|
465
|
+
* Mark leaf block nodes as pending within a node array.
|
|
466
|
+
* Only marks text-containing blocks (paragraph, heading, codeBlock, etc.)
|
|
467
|
+
* NOT container nodes (bulletList, orderedList, listItem, blockquote).
|
|
468
|
+
* This ensures collectPendingState captures them correctly on save.
|
|
447
469
|
*/
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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);
|
|
470
|
+
function markLeafBlocksAsPending(nodes, status) {
|
|
471
|
+
if (!nodes)
|
|
472
|
+
return;
|
|
473
|
+
for (const node of nodes) {
|
|
474
|
+
if (node.type && LEAF_BLOCK_TYPES.has(node.type)) {
|
|
475
|
+
node.attrs = { ...node.attrs, pendingStatus: status };
|
|
476
|
+
if (!node.attrs.id) {
|
|
477
|
+
node.attrs.id = generateNodeId();
|
|
464
478
|
}
|
|
465
479
|
}
|
|
480
|
+
else if (node.content) {
|
|
481
|
+
markLeafBlocksAsPending(node.content, status);
|
|
482
|
+
}
|
|
466
483
|
}
|
|
467
|
-
|
|
484
|
+
}
|
|
485
|
+
export function markAllNodesAsPending(doc, status) {
|
|
486
|
+
markLeafBlocksAsPending(doc.content, status);
|
|
468
487
|
}
|
|
469
488
|
/** Get filenames of all docs with pending changes (disk scan + external docs + current in-memory doc). */
|
|
470
489
|
export function getPendingDocFilenames() {
|
|
@@ -747,3 +766,155 @@ function cleanupEmptyTempFiles() {
|
|
|
747
766
|
}
|
|
748
767
|
catch { /* ignore errors during cleanup */ }
|
|
749
768
|
}
|
|
769
|
+
// ============================================================================
|
|
770
|
+
// DOCUMENT-LEVEL TAG OPERATIONS
|
|
771
|
+
// ============================================================================
|
|
772
|
+
/** Get tags for the active document from its metadata. */
|
|
773
|
+
export function getDocTags() {
|
|
774
|
+
const tags = state.metadata.tags;
|
|
775
|
+
return Array.isArray(tags) ? tags : [];
|
|
776
|
+
}
|
|
777
|
+
/** Get tags for any document by filename (reads from disk if not active). */
|
|
778
|
+
export function getDocTagsByFilename(filename) {
|
|
779
|
+
// If it's the active doc, use in-memory state
|
|
780
|
+
const activeFilename = state.filePath
|
|
781
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
782
|
+
: '';
|
|
783
|
+
if (filename === activeFilename)
|
|
784
|
+
return getDocTags();
|
|
785
|
+
// Otherwise read from disk
|
|
786
|
+
const targetPath = resolveDocPath(filename);
|
|
787
|
+
if (!existsSync(targetPath))
|
|
788
|
+
return [];
|
|
789
|
+
try {
|
|
790
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
791
|
+
const { data } = matter(raw);
|
|
792
|
+
return Array.isArray(data.tags) ? data.tags : [];
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return [];
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/** Add a tag to a document. Works on active doc or any file on disk. */
|
|
799
|
+
export function addDocTag(filename, tag) {
|
|
800
|
+
const activeFilename = state.filePath
|
|
801
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
802
|
+
: '';
|
|
803
|
+
if (filename === activeFilename) {
|
|
804
|
+
// Active doc — update in-memory metadata
|
|
805
|
+
const tags = Array.isArray(state.metadata.tags) ? [...state.metadata.tags] : [];
|
|
806
|
+
if (!tags.includes(tag)) {
|
|
807
|
+
tags.push(tag);
|
|
808
|
+
state.metadata.tags = tags;
|
|
809
|
+
save();
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
// Non-active doc — read/write disk
|
|
814
|
+
const targetPath = resolveDocPath(filename);
|
|
815
|
+
if (!existsSync(targetPath))
|
|
816
|
+
return;
|
|
817
|
+
try {
|
|
818
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
819
|
+
const parsed = markdownToTiptap(raw);
|
|
820
|
+
const tags = Array.isArray(parsed.metadata.tags) ? [...parsed.metadata.tags] : [];
|
|
821
|
+
if (!tags.includes(tag)) {
|
|
822
|
+
tags.push(tag);
|
|
823
|
+
parsed.metadata.tags = tags;
|
|
824
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
825
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
catch { /* best-effort */ }
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** Remove a tag from a document. Works on active doc or any file on disk. */
|
|
832
|
+
export function removeDocTag(filename, tag) {
|
|
833
|
+
const activeFilename = state.filePath
|
|
834
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
835
|
+
: '';
|
|
836
|
+
if (filename === activeFilename) {
|
|
837
|
+
const tags = Array.isArray(state.metadata.tags) ? [...state.metadata.tags] : [];
|
|
838
|
+
const idx = tags.indexOf(tag);
|
|
839
|
+
if (idx >= 0) {
|
|
840
|
+
tags.splice(idx, 1);
|
|
841
|
+
state.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
842
|
+
save();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
const targetPath = resolveDocPath(filename);
|
|
847
|
+
if (!existsSync(targetPath))
|
|
848
|
+
return;
|
|
849
|
+
try {
|
|
850
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
851
|
+
const parsed = markdownToTiptap(raw);
|
|
852
|
+
const tags = Array.isArray(parsed.metadata.tags) ? [...parsed.metadata.tags] : [];
|
|
853
|
+
const idx = tags.indexOf(tag);
|
|
854
|
+
if (idx >= 0) {
|
|
855
|
+
tags.splice(idx, 1);
|
|
856
|
+
parsed.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
857
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
858
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
catch { /* best-effort */ }
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// ============================================================================
|
|
865
|
+
// CROSS-DOCUMENT HELPERS (operate on specific files, not the active singleton)
|
|
866
|
+
// ============================================================================
|
|
867
|
+
/**
|
|
868
|
+
* Save a browser doc-update to a specific file on disk.
|
|
869
|
+
* Used when the browser sends a doc-update for a non-active document (race condition guard).
|
|
870
|
+
*/
|
|
871
|
+
export function saveDocToFile(filename, doc) {
|
|
872
|
+
const targetPath = resolveDocPath(filename);
|
|
873
|
+
if (!existsSync(targetPath))
|
|
874
|
+
return; // Target doesn't exist, nothing to save to
|
|
875
|
+
try {
|
|
876
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
877
|
+
const parsed = markdownToTiptap(raw);
|
|
878
|
+
// Transfer pending attrs from on-disk version to the incoming doc
|
|
879
|
+
if (hasPendingChanges(parsed.document)) {
|
|
880
|
+
transferPendingAttrs(parsed.document, doc);
|
|
881
|
+
}
|
|
882
|
+
const markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
883
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
884
|
+
}
|
|
885
|
+
catch { /* best-effort */ }
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Strip pending attrs from a specific file on disk (not the active document).
|
|
889
|
+
* Optionally clears agentCreated metadata (on accept).
|
|
890
|
+
*/
|
|
891
|
+
export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
892
|
+
const targetPath = resolveDocPath(filename);
|
|
893
|
+
if (!existsSync(targetPath))
|
|
894
|
+
return;
|
|
895
|
+
try {
|
|
896
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
897
|
+
const parsed = markdownToTiptap(raw);
|
|
898
|
+
// Strip pending attrs from the parsed document
|
|
899
|
+
function strip(nodes) {
|
|
900
|
+
if (!nodes)
|
|
901
|
+
return;
|
|
902
|
+
for (const node of nodes) {
|
|
903
|
+
if (node.attrs?.pendingStatus) {
|
|
904
|
+
delete node.attrs.pendingStatus;
|
|
905
|
+
delete node.attrs.pendingOriginalContent;
|
|
906
|
+
delete node.attrs.pendingTextEdits;
|
|
907
|
+
}
|
|
908
|
+
if (node.content)
|
|
909
|
+
strip(node.content);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
strip(parsed.document.content);
|
|
913
|
+
if (clearAgentCreated && parsed.metadata.agentCreated) {
|
|
914
|
+
delete parsed.metadata.agentCreated;
|
|
915
|
+
}
|
|
916
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
917
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
918
|
+
}
|
|
919
|
+
catch { /* best-effort */ }
|
|
920
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Mounted in index.ts to keep the main file lean.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
|
-
import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, reorderContainer,
|
|
6
|
+
import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, reorderContainer, } from './workspaces.js';
|
|
7
7
|
export function createWorkspaceRouter(b) {
|
|
8
8
|
const router = Router();
|
|
9
9
|
router.get('/api/workspaces', (_req, res) => {
|
|
@@ -30,9 +30,9 @@ export function createWorkspaceRouter(b) {
|
|
|
30
30
|
res.status(404).json({ error: err.message });
|
|
31
31
|
}
|
|
32
32
|
});
|
|
33
|
-
router.delete('/api/workspaces/:filename', (req, res) => {
|
|
33
|
+
router.delete('/api/workspaces/:filename', async (req, res) => {
|
|
34
34
|
try {
|
|
35
|
-
deleteWorkspace(req.params.filename);
|
|
35
|
+
await deleteWorkspace(req.params.filename);
|
|
36
36
|
b.broadcastWorkspacesChanged();
|
|
37
37
|
res.json({ success: true });
|
|
38
38
|
}
|
|
@@ -138,27 +138,6 @@ export function createWorkspaceRouter(b) {
|
|
|
138
138
|
res.status(400).json({ error: err.message });
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
|
-
// Tag operations
|
|
142
|
-
router.post('/api/workspaces/:filename/tags/:docFile', (req, res) => {
|
|
143
|
-
try {
|
|
144
|
-
const ws = tagDoc(req.params.filename, req.params.docFile, req.body.tag);
|
|
145
|
-
b.broadcastWorkspacesChanged();
|
|
146
|
-
res.json(ws);
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
res.status(400).json({ error: err.message });
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
router.delete('/api/workspaces/:filename/tags/:docFile/:tag', (req, res) => {
|
|
153
|
-
try {
|
|
154
|
-
const ws = untagDoc(req.params.filename, req.params.docFile, req.params.tag);
|
|
155
|
-
b.broadcastWorkspacesChanged();
|
|
156
|
-
res.json(ws);
|
|
157
|
-
}
|
|
158
|
-
catch (err) {
|
|
159
|
-
res.status(400).json({ error: err.message });
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
141
|
// Cross-workspace move (from one workspace to another)
|
|
163
142
|
router.post('/api/workspaces/:targetFilename/docs/:docFile/cross-move', (req, res) => {
|
|
164
143
|
try {
|
|
@@ -20,9 +20,6 @@ export function removeFileFromAllTags(tags, file) {
|
|
|
20
20
|
removeTag(tags, tagName, file);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
export function listFilesForTag(tags, tagName) {
|
|
24
|
-
return tags[tagName] || [];
|
|
25
|
-
}
|
|
26
23
|
export function listTagsForFile(tags, file) {
|
|
27
24
|
const result = [];
|
|
28
25
|
for (const [tagName, files] of Object.entries(tags)) {
|
|
@@ -10,23 +10,15 @@ export function isV1(data) {
|
|
|
10
10
|
return !data.version || data.version < 2;
|
|
11
11
|
}
|
|
12
12
|
export function migrateV1toV2(legacy) {
|
|
13
|
-
const tags = {};
|
|
14
13
|
const root = [];
|
|
15
14
|
for (const item of legacy.items || []) {
|
|
16
15
|
root.push({ type: 'doc', file: item.file, title: item.file.replace(/\.md$/, '') });
|
|
17
|
-
if (item.tag) {
|
|
18
|
-
if (!tags[item.tag])
|
|
19
|
-
tags[item.tag] = [];
|
|
20
|
-
if (!tags[item.tag].includes(item.file))
|
|
21
|
-
tags[item.tag].push(item.file);
|
|
22
|
-
}
|
|
23
16
|
}
|
|
24
17
|
return {
|
|
25
18
|
version: 2,
|
|
26
19
|
title: legacy.title,
|
|
27
20
|
voiceProfileId: legacy.voiceProfileId ?? null,
|
|
28
21
|
root,
|
|
29
|
-
tags,
|
|
30
22
|
context: legacy.context,
|
|
31
23
|
};
|
|
32
24
|
}
|