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.
@@ -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
+ }
@@ -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
- return processed.length;
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. 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.
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
- 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);
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
- mark(doc.content);
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, tagDoc, untagDoc, } from './workspaces.js';
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
  }