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.
@@ -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
+ }
@@ -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
- return processed.length;
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. 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.
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
- 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);
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
- mark(doc.content);
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
- 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');
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
- catch {
640
- // Corrupt file — start fresh
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
+ }