openwriter 0.1.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,176 @@
1
+ /**
2
+ * Express routes for workspace CRUD, doc/container/tag operations.
3
+ * Mounted in index.ts to keep the main file lean.
4
+ */
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';
7
+ export function createWorkspaceRouter(b) {
8
+ const router = Router();
9
+ router.get('/api/workspaces', (_req, res) => {
10
+ res.json(listWorkspaces());
11
+ });
12
+ router.post('/api/workspaces', (req, res) => {
13
+ try {
14
+ const result = createWorkspace({
15
+ title: req.body.title,
16
+ voiceProfileId: req.body.voiceProfileId,
17
+ });
18
+ b.broadcastWorkspacesChanged();
19
+ res.json(result);
20
+ }
21
+ catch (err) {
22
+ res.status(400).json({ error: err.message });
23
+ }
24
+ });
25
+ router.get('/api/workspaces/:filename', (req, res) => {
26
+ try {
27
+ res.json(getWorkspace(req.params.filename));
28
+ }
29
+ catch (err) {
30
+ res.status(404).json({ error: err.message });
31
+ }
32
+ });
33
+ router.delete('/api/workspaces/:filename', (req, res) => {
34
+ try {
35
+ deleteWorkspace(req.params.filename);
36
+ b.broadcastWorkspacesChanged();
37
+ res.json({ success: true });
38
+ }
39
+ catch (err) {
40
+ res.status(400).json({ error: err.message });
41
+ }
42
+ });
43
+ router.put('/api/workspaces/reorder', (req, res) => {
44
+ try {
45
+ const { order } = req.body;
46
+ if (!Array.isArray(order))
47
+ return res.status(400).json({ error: 'order must be an array' });
48
+ reorderWorkspaces(order);
49
+ b.broadcastWorkspacesChanged();
50
+ res.json({ success: true });
51
+ }
52
+ catch (err) {
53
+ res.status(400).json({ error: err.message });
54
+ }
55
+ });
56
+ // Doc operations
57
+ router.post('/api/workspaces/:filename/docs', (req, res) => {
58
+ try {
59
+ const ws = addDoc(req.params.filename, req.body.containerId ?? null, req.body.file, req.body.title || req.body.file, req.body.afterFile ?? null);
60
+ b.broadcastWorkspacesChanged();
61
+ res.json(ws);
62
+ }
63
+ catch (err) {
64
+ res.status(400).json({ error: err.message });
65
+ }
66
+ });
67
+ router.delete('/api/workspaces/:filename/docs/:docFile', (req, res) => {
68
+ try {
69
+ const ws = removeDoc(req.params.filename, req.params.docFile);
70
+ b.broadcastWorkspacesChanged();
71
+ res.json(ws);
72
+ }
73
+ catch (err) {
74
+ res.status(400).json({ error: err.message });
75
+ }
76
+ });
77
+ router.put('/api/workspaces/:filename/docs/:docFile/move', (req, res) => {
78
+ try {
79
+ const ws = moveDoc(req.params.filename, req.params.docFile, req.body.targetContainerId ?? null, req.body.afterFile ?? null);
80
+ b.broadcastWorkspacesChanged();
81
+ res.json(ws);
82
+ }
83
+ catch (err) {
84
+ res.status(400).json({ error: err.message });
85
+ }
86
+ });
87
+ router.put('/api/workspaces/:filename/docs/:docFile/reorder', (req, res) => {
88
+ try {
89
+ const ws = reorderDoc(req.params.filename, req.params.docFile, req.body.afterFile ?? null);
90
+ b.broadcastWorkspacesChanged();
91
+ res.json(ws);
92
+ }
93
+ catch (err) {
94
+ res.status(400).json({ error: err.message });
95
+ }
96
+ });
97
+ // Container operations
98
+ router.post('/api/workspaces/:filename/containers', (req, res) => {
99
+ try {
100
+ const result = addContainerToWorkspace(req.params.filename, req.body.parentContainerId ?? null, req.body.name);
101
+ b.broadcastWorkspacesChanged();
102
+ res.json(result);
103
+ }
104
+ catch (err) {
105
+ res.status(400).json({ error: err.message });
106
+ }
107
+ });
108
+ router.delete('/api/workspaces/:filename/containers/:containerId', (req, res) => {
109
+ try {
110
+ const ws = removeContainer(req.params.filename, req.params.containerId);
111
+ b.broadcastWorkspacesChanged();
112
+ res.json(ws);
113
+ }
114
+ catch (err) {
115
+ res.status(400).json({ error: err.message });
116
+ }
117
+ });
118
+ router.put('/api/workspaces/:filename/containers/:containerId', (req, res) => {
119
+ try {
120
+ let ws;
121
+ if (req.body.name !== undefined) {
122
+ ws = renameContainer(req.params.filename, req.params.containerId, req.body.name);
123
+ }
124
+ b.broadcastWorkspacesChanged();
125
+ res.json(ws || getWorkspace(req.params.filename));
126
+ }
127
+ catch (err) {
128
+ res.status(400).json({ error: err.message });
129
+ }
130
+ });
131
+ router.put('/api/workspaces/:filename/containers/:containerId/reorder', (req, res) => {
132
+ try {
133
+ const ws = reorderContainer(req.params.filename, req.params.containerId, req.body.afterIdentifier ?? null);
134
+ b.broadcastWorkspacesChanged();
135
+ res.json(ws);
136
+ }
137
+ catch (err) {
138
+ res.status(400).json({ error: err.message });
139
+ }
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
+ // Cross-workspace move (from one workspace to another)
163
+ router.post('/api/workspaces/:targetFilename/docs/:docFile/cross-move', (req, res) => {
164
+ try {
165
+ const { sourceWorkspace } = req.body;
166
+ removeDoc(sourceWorkspace, req.params.docFile);
167
+ const ws = addDoc(req.params.targetFilename, req.body.containerId ?? null, req.params.docFile, req.body.title || req.params.docFile, req.body.afterFile ?? null);
168
+ b.broadcastWorkspacesChanged();
169
+ res.json(ws);
170
+ }
171
+ catch (err) {
172
+ res.status(400).json({ error: err.message });
173
+ }
174
+ });
175
+ return router;
176
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tag index operations for workspace v2.
3
+ * Tags are a cross-cutting index: tag → [file1, file2, ...].
4
+ */
5
+ export function addTag(tags, tagName, file) {
6
+ if (!tags[tagName])
7
+ tags[tagName] = [];
8
+ if (!tags[tagName].includes(file))
9
+ tags[tagName].push(file);
10
+ }
11
+ export function removeTag(tags, tagName, file) {
12
+ if (!tags[tagName])
13
+ return;
14
+ tags[tagName] = tags[tagName].filter((f) => f !== file);
15
+ if (tags[tagName].length === 0)
16
+ delete tags[tagName];
17
+ }
18
+ export function removeFileFromAllTags(tags, file) {
19
+ for (const tagName of Object.keys(tags)) {
20
+ removeTag(tags, tagName, file);
21
+ }
22
+ }
23
+ export function listFilesForTag(tags, tagName) {
24
+ return tags[tagName] || [];
25
+ }
26
+ export function listTagsForFile(tags, file) {
27
+ const result = [];
28
+ for (const [tagName, files] of Object.entries(tags)) {
29
+ if (files.includes(file))
30
+ result.push(tagName);
31
+ }
32
+ return result;
33
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Pure tree operations on WorkspaceNode[].
3
+ * All functions return mutated copies — caller writes to disk.
4
+ */
5
+ import { generateContainerId } from './workspace-types.js';
6
+ const MAX_DEPTH = 3;
7
+ /** Recursive find — returns node, its parent array, and index within that array. */
8
+ export function findNode(nodes, predicate, parentRef) {
9
+ for (let i = 0; i < nodes.length; i++) {
10
+ const node = nodes[i];
11
+ if (predicate(node)) {
12
+ return { node, parent: parentRef ?? nodes, index: i };
13
+ }
14
+ if (node.type === 'container') {
15
+ const found = findNode(node.items, predicate, node);
16
+ if (found)
17
+ return found;
18
+ }
19
+ if (node.type === 'doc' && node.children) {
20
+ for (const child of node.children) {
21
+ const found = findNode(child.items, predicate, child);
22
+ if (found)
23
+ return found;
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ export function findDocNode(root, file) {
30
+ return findNode(root, (n) => n.type === 'doc' && n.file === file);
31
+ }
32
+ export function findContainer(root, containerId) {
33
+ return findNode(root, (n) => n.type === 'container' && n.id === containerId);
34
+ }
35
+ /** Get the items array that a container or root holds. */
36
+ function getItemsArray(root, containerId) {
37
+ if (containerId === null)
38
+ return root;
39
+ const found = findContainer(root, containerId);
40
+ if (!found)
41
+ return null;
42
+ return found.node.items;
43
+ }
44
+ // ============================================================================
45
+ // DEPTH
46
+ // ============================================================================
47
+ /** Calculate the depth of a node in the tree. Root level = 0. */
48
+ export function getDepth(root, identifier) {
49
+ function walk(nodes, depth) {
50
+ for (const node of nodes) {
51
+ const id = node.type === 'doc' ? node.file : node.id;
52
+ if (id === identifier)
53
+ return depth;
54
+ if (node.type === 'container') {
55
+ const found = walk(node.items, depth + 1);
56
+ if (found >= 0)
57
+ return found;
58
+ }
59
+ }
60
+ return -1;
61
+ }
62
+ return walk(root, 0);
63
+ }
64
+ /** Calculate the depth of a target container (where we'd insert into). */
65
+ function getContainerDepth(root, containerId) {
66
+ if (containerId === null)
67
+ return 0;
68
+ return getDepth(root, containerId) + 1;
69
+ }
70
+ // ============================================================================
71
+ // ADD
72
+ // ============================================================================
73
+ export function addDocToContainer(root, containerId, file, title, afterIdentifier) {
74
+ // Check for duplicate
75
+ if (findDocNode(root, file)) {
76
+ throw new Error(`Document "${file}" already exists in workspace`);
77
+ }
78
+ const target = getItemsArray(root, containerId);
79
+ if (!target)
80
+ throw new Error(`Container "${containerId}" not found`);
81
+ const doc = { type: 'doc', file, title };
82
+ if (afterIdentifier) {
83
+ const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
84
+ if (afterIdx === -1) {
85
+ target.push(doc);
86
+ }
87
+ else {
88
+ target.splice(afterIdx + 1, 0, doc);
89
+ }
90
+ }
91
+ else {
92
+ target.unshift(doc);
93
+ }
94
+ }
95
+ export function addContainer(root, parentContainerId, name) {
96
+ const depth = getContainerDepth(root, parentContainerId);
97
+ if (depth >= MAX_DEPTH) {
98
+ throw new Error(`Maximum nesting depth (${MAX_DEPTH}) reached`);
99
+ }
100
+ const target = getItemsArray(root, parentContainerId);
101
+ if (!target)
102
+ throw new Error(`Parent container "${parentContainerId}" not found`);
103
+ const container = {
104
+ type: 'container',
105
+ id: generateContainerId(),
106
+ name,
107
+ items: [],
108
+ };
109
+ target.unshift(container);
110
+ return container;
111
+ }
112
+ // ============================================================================
113
+ // REMOVE
114
+ // ============================================================================
115
+ /** Remove a node by file (doc) or id (container). Returns the removed node. */
116
+ export function removeNode(root, identifier) {
117
+ // Try as doc first, then as container
118
+ const found = findNode(root, (n) => (n.type === 'doc' && n.file === identifier) || (n.type === 'container' && n.id === identifier));
119
+ if (!found)
120
+ throw new Error(`Node "${identifier}" not found`);
121
+ const parentArray = found.parent instanceof Array ? found.parent : found.parent.items;
122
+ const [removed] = parentArray.splice(found.index, 1);
123
+ return removed;
124
+ }
125
+ // ============================================================================
126
+ // MOVE / REORDER
127
+ // ============================================================================
128
+ /**
129
+ * Move a node to a different container (or root).
130
+ * afterIdentifier = null → insert at beginning; otherwise insert after that node.
131
+ */
132
+ export function moveNode(root, identifier, targetContainerId, afterIdentifier) {
133
+ const removed = removeNode(root, identifier);
134
+ // Check depth for containers being moved
135
+ if (removed.type === 'container') {
136
+ const targetDepth = getContainerDepth(root, targetContainerId);
137
+ if (targetDepth >= MAX_DEPTH) {
138
+ // Re-add at root to not lose data, then throw
139
+ root.push(removed);
140
+ throw new Error(`Cannot move: would exceed max depth (${MAX_DEPTH})`);
141
+ }
142
+ }
143
+ const target = getItemsArray(root, targetContainerId);
144
+ if (!target) {
145
+ root.push(removed); // don't lose data
146
+ throw new Error(`Target container "${targetContainerId}" not found`);
147
+ }
148
+ if (afterIdentifier === null) {
149
+ target.unshift(removed);
150
+ }
151
+ else {
152
+ const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
153
+ if (afterIdx === -1) {
154
+ target.push(removed);
155
+ }
156
+ else {
157
+ target.splice(afterIdx + 1, 0, removed);
158
+ }
159
+ }
160
+ }
161
+ /** Reorder within the same parent. */
162
+ export function reorderNode(root, identifier, afterIdentifier) {
163
+ const found = findNode(root, (n) => (n.type === 'doc' && n.file === identifier) || (n.type === 'container' && n.id === identifier));
164
+ if (!found)
165
+ throw new Error(`Node "${identifier}" not found`);
166
+ const parentArray = found.parent instanceof Array ? found.parent : found.parent.items;
167
+ const [removed] = parentArray.splice(found.index, 1);
168
+ if (afterIdentifier === null) {
169
+ parentArray.unshift(removed);
170
+ }
171
+ else {
172
+ const afterIdx = parentArray.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
173
+ if (afterIdx === -1) {
174
+ parentArray.push(removed);
175
+ }
176
+ else {
177
+ parentArray.splice(afterIdx + 1, 0, removed);
178
+ }
179
+ }
180
+ }
181
+ // ============================================================================
182
+ // QUERIES
183
+ // ============================================================================
184
+ /** Collect all doc files in the tree. */
185
+ export function collectAllFiles(nodes) {
186
+ const files = [];
187
+ for (const node of nodes) {
188
+ if (node.type === 'doc') {
189
+ files.push(node.file);
190
+ }
191
+ else if (node.type === 'container') {
192
+ files.push(...collectAllFiles(node.items));
193
+ }
194
+ }
195
+ return files;
196
+ }
197
+ /** Count total doc items in the tree. */
198
+ export function countDocs(nodes) {
199
+ return collectAllFiles(nodes).length;
200
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Workspace v2 type definitions and migration logic.
3
+ * Unified container model: ordered/unordered containers hold docs, tags are cross-cutting.
4
+ */
5
+ import { randomUUID } from 'crypto';
6
+ // ============================================================================
7
+ // MIGRATION
8
+ // ============================================================================
9
+ export function isV1(data) {
10
+ return !data.version || data.version < 2;
11
+ }
12
+ export function migrateV1toV2(legacy) {
13
+ const tags = {};
14
+ const root = [];
15
+ for (const item of legacy.items || []) {
16
+ 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
+ }
24
+ return {
25
+ version: 2,
26
+ title: legacy.title,
27
+ voiceProfileId: legacy.voiceProfileId ?? null,
28
+ root,
29
+ tags,
30
+ context: legacy.context,
31
+ };
32
+ }
33
+ // ============================================================================
34
+ // HELPERS
35
+ // ============================================================================
36
+ export function generateContainerId() {
37
+ return randomUUID().slice(0, 8);
38
+ }