openwriter 0.2.1 → 0.3.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/README.md +2 -2
- package/dist/bin/pad.js +35 -3
- package/dist/client/assets/index-BLVKwyNi.js +209 -0
- package/dist/client/assets/index-NIq_FmFc.css +1 -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 +131 -62
- package/dist/server/link-routes.js +6 -5
- package/dist/server/mcp-client.js +29 -28
- package/dist/server/mcp.js +229 -79
- package/dist/server/plugin-discovery.js +64 -0
- package/dist/server/plugin-manager.js +155 -0
- package/dist/server/state.js +266 -51
- package/dist/server/tweet-routes.js +98 -0
- package/dist/server/update-check.js +96 -0
- 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 +101 -14
- package/package.json +3 -1
- package/skill/SKILL.md +92 -23
- package/dist/client/assets/index-DNJs7lC-.js +0 -205
- package/dist/client/assets/index-WweytMO1.css +0 -1
|
@@ -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,8 +8,9 @@ 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
|
+
import trash from 'trash';
|
|
13
14
|
const DEFAULT_DOC = {
|
|
14
15
|
type: 'doc',
|
|
15
16
|
content: [{ type: 'paragraph', content: [] }],
|
|
@@ -143,6 +144,22 @@ export function setMetadata(updates) {
|
|
|
143
144
|
state.metadata = { ...state.metadata, ...updates };
|
|
144
145
|
if (updates.title)
|
|
145
146
|
state.title = updates.title;
|
|
147
|
+
// Auto-tag: tweetContext / articleContext ↔ "x" tag
|
|
148
|
+
for (const key of ['tweetContext', 'articleContext']) {
|
|
149
|
+
if (key in updates) {
|
|
150
|
+
const filename = state.filePath
|
|
151
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
152
|
+
: '';
|
|
153
|
+
if (filename) {
|
|
154
|
+
if (updates[key]) {
|
|
155
|
+
addDocTag(filename, 'x');
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
removeDocTag(filename, 'x');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
146
163
|
}
|
|
147
164
|
export function getStatus() {
|
|
148
165
|
return {
|
|
@@ -213,7 +230,7 @@ function transferPendingAttrs(source, target) {
|
|
|
213
230
|
const AGENT_LOCK_MS = 5000; // Block browser doc-updates for 5s after agent write
|
|
214
231
|
let lastAgentWriteTime = 0;
|
|
215
232
|
/** Set the agent write lock (called after agent changes). */
|
|
216
|
-
function setAgentLock() {
|
|
233
|
+
export function setAgentLock() {
|
|
217
234
|
lastAgentWriteTime = Date.now();
|
|
218
235
|
}
|
|
219
236
|
/** Check if the agent write lock is active. */
|
|
@@ -249,7 +266,20 @@ export function applyChanges(changes) {
|
|
|
249
266
|
}
|
|
250
267
|
// Debounced save — coalesces rapid agent writes into a single disk write
|
|
251
268
|
debouncedSave();
|
|
252
|
-
|
|
269
|
+
// Find the last created node ID for chaining inserts
|
|
270
|
+
let lastNodeId = null;
|
|
271
|
+
for (let i = processed.length - 1; i >= 0; i--) {
|
|
272
|
+
const change = processed[i];
|
|
273
|
+
if (change.content) {
|
|
274
|
+
const contentArr = Array.isArray(change.content) ? change.content : [change.content];
|
|
275
|
+
const lastNode = contentArr[contentArr.length - 1];
|
|
276
|
+
if (lastNode?.attrs?.id) {
|
|
277
|
+
lastNodeId = lastNode.attrs.id;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { count: processed.length, lastNodeId };
|
|
253
283
|
}
|
|
254
284
|
export function onChanges(listener) {
|
|
255
285
|
listeners.add(listener);
|
|
@@ -264,6 +294,14 @@ export function onChanges(listener) {
|
|
|
264
294
|
* Returns the parent array and index for in-place mutation.
|
|
265
295
|
*/
|
|
266
296
|
function findNodeInDoc(nodes, id) {
|
|
297
|
+
// Special sentinel: "end" resolves to the last top-level node in the document
|
|
298
|
+
if (id === 'end') {
|
|
299
|
+
const topLevel = state.document.content;
|
|
300
|
+
if (topLevel && topLevel.length > 0) {
|
|
301
|
+
return { parent: topLevel, index: topLevel.length - 1 };
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
267
305
|
for (let i = 0; i < nodes.length; i++) {
|
|
268
306
|
if (nodes[i].attrs?.id === id) {
|
|
269
307
|
return { parent: nodes, index: i };
|
|
@@ -307,9 +345,9 @@ function applyChangesToDocument(changes) {
|
|
|
307
345
|
attrs: {
|
|
308
346
|
...node.attrs,
|
|
309
347
|
id: node.attrs?.id || generateNodeId(),
|
|
310
|
-
pendingStatus: 'insert',
|
|
311
348
|
},
|
|
312
349
|
}));
|
|
350
|
+
markLeafBlocksAsPending(extraNodes, 'insert');
|
|
313
351
|
found.parent.splice(found.index, 1, firstNode, ...extraNodes);
|
|
314
352
|
processed.push({
|
|
315
353
|
...change,
|
|
@@ -324,9 +362,10 @@ function applyChangesToDocument(changes) {
|
|
|
324
362
|
attrs: {
|
|
325
363
|
...node.attrs,
|
|
326
364
|
id: node.attrs?.id || (change.nodeId && !change.afterNodeId && i === 0 ? change.nodeId : generateNodeId()),
|
|
327
|
-
pendingStatus: 'insert',
|
|
328
365
|
},
|
|
329
366
|
}));
|
|
367
|
+
// Mark leaf blocks as pending (not containers) for correct serialization
|
|
368
|
+
markLeafBlocksAsPending(contentWithIds, 'insert');
|
|
330
369
|
if (change.nodeId && !change.afterNodeId) {
|
|
331
370
|
// Replace empty node
|
|
332
371
|
const found = findNodeInDoc(state.document.content, change.nodeId);
|
|
@@ -440,31 +479,28 @@ export function stripPendingAttrs() {
|
|
|
440
479
|
strip(state.document.content);
|
|
441
480
|
}
|
|
442
481
|
/**
|
|
443
|
-
* Mark leaf block nodes as pending
|
|
444
|
-
* (paragraph, heading, codeBlock,
|
|
445
|
-
* (bulletList, orderedList, listItem, blockquote)
|
|
446
|
-
* This
|
|
482
|
+
* Mark leaf block nodes as pending within a node array.
|
|
483
|
+
* Only marks text-containing blocks (paragraph, heading, codeBlock, etc.)
|
|
484
|
+
* NOT container nodes (bulletList, orderedList, listItem, blockquote).
|
|
485
|
+
* This ensures collectPendingState captures them correctly on save.
|
|
447
486
|
*/
|
|
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);
|
|
487
|
+
function markLeafBlocksAsPending(nodes, status) {
|
|
488
|
+
if (!nodes)
|
|
489
|
+
return;
|
|
490
|
+
for (const node of nodes) {
|
|
491
|
+
if (node.type && LEAF_BLOCK_TYPES.has(node.type)) {
|
|
492
|
+
node.attrs = { ...node.attrs, pendingStatus: status };
|
|
493
|
+
if (!node.attrs.id) {
|
|
494
|
+
node.attrs.id = generateNodeId();
|
|
464
495
|
}
|
|
465
496
|
}
|
|
497
|
+
else if (node.content) {
|
|
498
|
+
markLeafBlocksAsPending(node.content, status);
|
|
499
|
+
}
|
|
466
500
|
}
|
|
467
|
-
|
|
501
|
+
}
|
|
502
|
+
export function markAllNodesAsPending(doc, status) {
|
|
503
|
+
markLeafBlocksAsPending(doc.content, status);
|
|
468
504
|
}
|
|
469
505
|
/** Get filenames of all docs with pending changes (disk scan + external docs + current in-memory doc). */
|
|
470
506
|
export function getPendingDocFilenames() {
|
|
@@ -602,6 +638,8 @@ export function load() {
|
|
|
602
638
|
migrateSwJsonFiles();
|
|
603
639
|
// Clean up empty temp files from previous sessions
|
|
604
640
|
cleanupEmptyTempFiles();
|
|
641
|
+
// Trash docs marked as ephemeral from previous sessions
|
|
642
|
+
cleanupEphemeralDocs();
|
|
605
643
|
// Find most recently modified .md file
|
|
606
644
|
const files = readdirSync(DATA_DIR)
|
|
607
645
|
.filter((f) => f.endsWith('.md'))
|
|
@@ -611,33 +649,38 @@ export function load() {
|
|
|
611
649
|
return { name: f, path: fullPath, mtime: stat.mtimeMs };
|
|
612
650
|
})
|
|
613
651
|
.sort((a, b) => b.mtime - a.mtime);
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
652
|
+
// Walk sorted files until we find a real document with content.
|
|
653
|
+
// Skip empty temp files so we don't open a blank scratch pad when real docs exist.
|
|
654
|
+
for (const file of files) {
|
|
655
|
+
try {
|
|
656
|
+
const raw = readFileSync(file.path, 'utf-8');
|
|
657
|
+
const parsed = markdownToTiptap(raw);
|
|
658
|
+
const isTemp = file.name.startsWith(TEMP_PREFIX);
|
|
659
|
+
// Skip empty temp files — prefer a real document
|
|
660
|
+
if (isTemp && isDocEmpty(parsed.document))
|
|
661
|
+
continue;
|
|
662
|
+
state.document = parsed.document;
|
|
663
|
+
state.title = parsed.title;
|
|
664
|
+
state.metadata = parsed.metadata;
|
|
665
|
+
state.lastModified = new Date(statSync(file.path).mtimeMs);
|
|
666
|
+
state.filePath = file.path;
|
|
667
|
+
state.isTemp = isTemp;
|
|
668
|
+
// Lazy docId migration: assign if missing, save to persist
|
|
669
|
+
const hadDocId = !!state.metadata.docId;
|
|
670
|
+
state.docId = ensureDocId(state.metadata);
|
|
671
|
+
if (!hadDocId) {
|
|
672
|
+
const md = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
673
|
+
writeFileSync(state.filePath, md, 'utf-8');
|
|
674
|
+
}
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// Corrupt file — try next one
|
|
679
|
+
continue;
|
|
637
680
|
}
|
|
638
681
|
}
|
|
639
|
-
|
|
640
|
-
|
|
682
|
+
// If nothing loaded (all files were empty temps or corrupt), start fresh
|
|
683
|
+
if (!state.filePath) {
|
|
641
684
|
state.filePath = tempFilePath();
|
|
642
685
|
state.isTemp = true;
|
|
643
686
|
}
|
|
@@ -747,3 +790,175 @@ function cleanupEmptyTempFiles() {
|
|
|
747
790
|
}
|
|
748
791
|
catch { /* ignore errors during cleanup */ }
|
|
749
792
|
}
|
|
793
|
+
/** Delete docs marked as ephemeral from previous sessions */
|
|
794
|
+
function cleanupEphemeralDocs() {
|
|
795
|
+
try {
|
|
796
|
+
const wsRefs = getWorkspaceReferencedFiles();
|
|
797
|
+
const files = readdirSync(DATA_DIR).filter(f => f.endsWith('.md'));
|
|
798
|
+
for (const f of files) {
|
|
799
|
+
if (wsRefs.has(f))
|
|
800
|
+
continue; // protect workspace-referenced docs
|
|
801
|
+
try {
|
|
802
|
+
const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
|
|
803
|
+
const { data } = matter(raw);
|
|
804
|
+
if (data.ephemeral) {
|
|
805
|
+
trash(join(DATA_DIR, f)).catch(() => { }); // move to OS trash, fire-and-forget
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch { /* skip unreadable */ }
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
catch { /* ignore */ }
|
|
812
|
+
}
|
|
813
|
+
// ============================================================================
|
|
814
|
+
// DOCUMENT-LEVEL TAG OPERATIONS
|
|
815
|
+
// ============================================================================
|
|
816
|
+
/** Get tags for the active document from its metadata. */
|
|
817
|
+
export function getDocTags() {
|
|
818
|
+
const tags = state.metadata.tags;
|
|
819
|
+
return Array.isArray(tags) ? tags : [];
|
|
820
|
+
}
|
|
821
|
+
/** Get tags for any document by filename (reads from disk if not active). */
|
|
822
|
+
export function getDocTagsByFilename(filename) {
|
|
823
|
+
// If it's the active doc, use in-memory state
|
|
824
|
+
const activeFilename = state.filePath
|
|
825
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
826
|
+
: '';
|
|
827
|
+
if (filename === activeFilename)
|
|
828
|
+
return getDocTags();
|
|
829
|
+
// Otherwise read from disk
|
|
830
|
+
const targetPath = resolveDocPath(filename);
|
|
831
|
+
if (!existsSync(targetPath))
|
|
832
|
+
return [];
|
|
833
|
+
try {
|
|
834
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
835
|
+
const { data } = matter(raw);
|
|
836
|
+
return Array.isArray(data.tags) ? data.tags : [];
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return [];
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** Add a tag to a document. Works on active doc or any file on disk. */
|
|
843
|
+
export function addDocTag(filename, tag) {
|
|
844
|
+
const activeFilename = state.filePath
|
|
845
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
846
|
+
: '';
|
|
847
|
+
if (filename === activeFilename) {
|
|
848
|
+
// Active doc — update in-memory metadata
|
|
849
|
+
const tags = Array.isArray(state.metadata.tags) ? [...state.metadata.tags] : [];
|
|
850
|
+
if (!tags.includes(tag)) {
|
|
851
|
+
tags.push(tag);
|
|
852
|
+
state.metadata.tags = tags;
|
|
853
|
+
save();
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
// Non-active doc — read/write disk
|
|
858
|
+
const targetPath = resolveDocPath(filename);
|
|
859
|
+
if (!existsSync(targetPath))
|
|
860
|
+
return;
|
|
861
|
+
try {
|
|
862
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
863
|
+
const parsed = markdownToTiptap(raw);
|
|
864
|
+
const tags = Array.isArray(parsed.metadata.tags) ? [...parsed.metadata.tags] : [];
|
|
865
|
+
if (!tags.includes(tag)) {
|
|
866
|
+
tags.push(tag);
|
|
867
|
+
parsed.metadata.tags = tags;
|
|
868
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
869
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
catch { /* best-effort */ }
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/** Remove a tag from a document. Works on active doc or any file on disk. */
|
|
876
|
+
export function removeDocTag(filename, tag) {
|
|
877
|
+
const activeFilename = state.filePath
|
|
878
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
879
|
+
: '';
|
|
880
|
+
if (filename === activeFilename) {
|
|
881
|
+
const tags = Array.isArray(state.metadata.tags) ? [...state.metadata.tags] : [];
|
|
882
|
+
const idx = tags.indexOf(tag);
|
|
883
|
+
if (idx >= 0) {
|
|
884
|
+
tags.splice(idx, 1);
|
|
885
|
+
state.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
886
|
+
save();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
const targetPath = resolveDocPath(filename);
|
|
891
|
+
if (!existsSync(targetPath))
|
|
892
|
+
return;
|
|
893
|
+
try {
|
|
894
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
895
|
+
const parsed = markdownToTiptap(raw);
|
|
896
|
+
const tags = Array.isArray(parsed.metadata.tags) ? [...parsed.metadata.tags] : [];
|
|
897
|
+
const idx = tags.indexOf(tag);
|
|
898
|
+
if (idx >= 0) {
|
|
899
|
+
tags.splice(idx, 1);
|
|
900
|
+
parsed.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
901
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
902
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
catch { /* best-effort */ }
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ============================================================================
|
|
909
|
+
// CROSS-DOCUMENT HELPERS (operate on specific files, not the active singleton)
|
|
910
|
+
// ============================================================================
|
|
911
|
+
/**
|
|
912
|
+
* Save a browser doc-update to a specific file on disk.
|
|
913
|
+
* Used when the browser sends a doc-update for a non-active document (race condition guard).
|
|
914
|
+
*/
|
|
915
|
+
export function saveDocToFile(filename, doc) {
|
|
916
|
+
const targetPath = resolveDocPath(filename);
|
|
917
|
+
if (!existsSync(targetPath))
|
|
918
|
+
return; // Target doesn't exist, nothing to save to
|
|
919
|
+
try {
|
|
920
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
921
|
+
const parsed = markdownToTiptap(raw);
|
|
922
|
+
// Transfer pending attrs from on-disk version to the incoming doc
|
|
923
|
+
if (hasPendingChanges(parsed.document)) {
|
|
924
|
+
transferPendingAttrs(parsed.document, doc);
|
|
925
|
+
}
|
|
926
|
+
const markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
927
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
928
|
+
}
|
|
929
|
+
catch { /* best-effort */ }
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Strip pending attrs from a specific file on disk (not the active document).
|
|
933
|
+
* Optionally clears agentCreated metadata (on accept).
|
|
934
|
+
*/
|
|
935
|
+
export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
936
|
+
const targetPath = resolveDocPath(filename);
|
|
937
|
+
if (!existsSync(targetPath))
|
|
938
|
+
return;
|
|
939
|
+
try {
|
|
940
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
941
|
+
const parsed = markdownToTiptap(raw);
|
|
942
|
+
// Strip pending attrs from the parsed document
|
|
943
|
+
function strip(nodes) {
|
|
944
|
+
if (!nodes)
|
|
945
|
+
return;
|
|
946
|
+
for (const node of nodes) {
|
|
947
|
+
if (node.attrs?.pendingStatus) {
|
|
948
|
+
delete node.attrs.pendingStatus;
|
|
949
|
+
delete node.attrs.pendingOriginalContent;
|
|
950
|
+
delete node.attrs.pendingTextEdits;
|
|
951
|
+
}
|
|
952
|
+
if (node.content)
|
|
953
|
+
strip(node.content);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
strip(parsed.document.content);
|
|
957
|
+
if (clearAgentCreated && parsed.metadata.agentCreated) {
|
|
958
|
+
delete parsed.metadata.agentCreated;
|
|
959
|
+
}
|
|
960
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
961
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
962
|
+
}
|
|
963
|
+
catch { /* best-effort */ }
|
|
964
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tweet embed proxy: fetches tweet data from fxtwitter API.
|
|
3
|
+
* GET /api/tweet-embed?url=... → normalized TweetEmbedData JSON.
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
// In-memory cache: URL → { data, expires }
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
9
|
+
function parseTweetUrl(url) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(url);
|
|
12
|
+
if (!['twitter.com', 'x.com', 'www.twitter.com', 'www.x.com'].includes(parsed.hostname)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
// Path: /{username}/status/{id}
|
|
16
|
+
const match = parsed.pathname.match(/^\/([^/]+)\/status\/(\d+)/);
|
|
17
|
+
if (!match)
|
|
18
|
+
return null;
|
|
19
|
+
return { username: match[1], statusId: match[2] };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function normalizeTweet(tweet) {
|
|
26
|
+
const data = {
|
|
27
|
+
author: {
|
|
28
|
+
name: tweet.author?.name || '',
|
|
29
|
+
username: tweet.author?.screen_name || '',
|
|
30
|
+
avatarUrl: tweet.author?.avatar_url || '',
|
|
31
|
+
},
|
|
32
|
+
text: tweet.text || '',
|
|
33
|
+
createdAt: tweet.created_at || '',
|
|
34
|
+
metrics: {
|
|
35
|
+
likes: tweet.likes ?? 0,
|
|
36
|
+
retweets: tweet.retweets ?? 0,
|
|
37
|
+
replies: tweet.replies ?? 0,
|
|
38
|
+
views: tweet.views ?? 0,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
if (tweet.media?.all?.length) {
|
|
42
|
+
data.media = tweet.media.all.map((m) => ({
|
|
43
|
+
type: m.type || 'photo',
|
|
44
|
+
url: m.url || m.thumbnail_url || '',
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
if (tweet.quote) {
|
|
48
|
+
data.quoteTweet = normalizeTweet(tweet.quote);
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
export function createTweetRouter() {
|
|
53
|
+
const router = Router();
|
|
54
|
+
router.get('/api/tweet-embed', async (req, res) => {
|
|
55
|
+
const url = req.query.url;
|
|
56
|
+
if (!url) {
|
|
57
|
+
res.status(400).json({ error: 'url query parameter is required' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const parsed = parseTweetUrl(url);
|
|
61
|
+
if (!parsed) {
|
|
62
|
+
res.status(400).json({ error: 'Invalid tweet URL. Supports x.com and twitter.com URLs.' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Check cache
|
|
66
|
+
const cacheKey = `${parsed.username}/${parsed.statusId}`;
|
|
67
|
+
const cached = cache.get(cacheKey);
|
|
68
|
+
if (cached && cached.expires > Date.now()) {
|
|
69
|
+
res.json(cached.data);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const apiUrl = `https://api.fxtwitter.com/${parsed.username}/status/${parsed.statusId}`;
|
|
74
|
+
const response = await fetch(apiUrl);
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
if (response.status === 404) {
|
|
77
|
+
res.status(404).json({ error: 'Tweet not found' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
res.status(502).json({ error: `fxtwitter API returned ${response.status}` });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const json = await response.json();
|
|
84
|
+
if (!json.tweet) {
|
|
85
|
+
res.status(404).json({ error: 'Tweet not found in API response' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const data = normalizeTweet(json.tweet);
|
|
89
|
+
// Cache it
|
|
90
|
+
cache.set(cacheKey, { data, expires: Date.now() + CACHE_TTL_MS });
|
|
91
|
+
res.json(data);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
res.status(502).json({ error: `Failed to fetch tweet: ${err.message}` });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return router;
|
|
98
|
+
}
|