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.
- package/dist/bin/pad.js +64 -0
- package/dist/client/assets/index-DNJs7lC-.js +205 -0
- package/dist/client/assets/index-WweytMO1.css +1 -0
- package/dist/client/index.html +16 -0
- package/dist/server/compact.js +214 -0
- package/dist/server/documents.js +230 -0
- package/dist/server/export-html-template.js +109 -0
- package/dist/server/export-routes.js +96 -0
- package/dist/server/gdoc-import.js +200 -0
- package/dist/server/git-sync.js +272 -0
- package/dist/server/helpers.js +87 -0
- package/dist/server/image-upload.js +55 -0
- package/dist/server/index.js +315 -0
- package/dist/server/link-routes.js +116 -0
- package/dist/server/markdown-parse.js +405 -0
- package/dist/server/markdown-serialize.js +263 -0
- package/dist/server/markdown.js +6 -0
- package/dist/server/mcp-client.js +37 -0
- package/dist/server/mcp.js +457 -0
- package/dist/server/plugin-loader.js +36 -0
- package/dist/server/plugin-types.js +5 -0
- package/dist/server/state.js +749 -0
- package/dist/server/sync-routes.js +75 -0
- package/dist/server/text-edit.js +249 -0
- package/dist/server/version-routes.js +79 -0
- package/dist/server/versions.js +198 -0
- package/dist/server/workspace-routes.js +176 -0
- package/dist/server/workspace-tags.js +33 -0
- package/dist/server/workspace-tree.js +200 -0
- package/dist/server/workspace-types.js +38 -0
- package/dist/server/workspaces.js +257 -0
- package/dist/server/ws.js +211 -0
- package/package.json +88 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants and utility functions for OpenWriter server.
|
|
3
|
+
* Both state.ts and documents.ts import from here to avoid duplication.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
6
|
+
import { join, isAbsolute, basename, dirname } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
export const DATA_DIR = join(homedir(), '.openwriter');
|
|
10
|
+
export const VERSIONS_DIR = join(DATA_DIR, '.versions');
|
|
11
|
+
export const WORKSPACES_DIR = join(DATA_DIR, '_workspaces');
|
|
12
|
+
export const CONFIG_FILE = join(DATA_DIR, 'config.json');
|
|
13
|
+
export const TEMP_PREFIX = '_untitled-';
|
|
14
|
+
export function ensureDataDir() {
|
|
15
|
+
if (!existsSync(DATA_DIR)) {
|
|
16
|
+
// One-time migration: rename ~/.superwriter/ → ~/.openwriter/
|
|
17
|
+
const legacyDir = join(homedir(), '.superwriter');
|
|
18
|
+
if (existsSync(legacyDir)) {
|
|
19
|
+
renameSync(legacyDir, DATA_DIR);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function ensureWorkspacesDir() {
|
|
27
|
+
ensureDataDir();
|
|
28
|
+
if (!existsSync(WORKSPACES_DIR))
|
|
29
|
+
mkdirSync(WORKSPACES_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
export function sanitizeFilename(name) {
|
|
32
|
+
return name.replace(/[<>:"/\\|?*]/g, '-').trim() || 'Untitled';
|
|
33
|
+
}
|
|
34
|
+
export function filePathForTitle(title) {
|
|
35
|
+
return join(DATA_DIR, `${sanitizeFilename(title)}.md`);
|
|
36
|
+
}
|
|
37
|
+
export function tempFilePath() {
|
|
38
|
+
return join(DATA_DIR, `${TEMP_PREFIX}${randomUUID()}.md`);
|
|
39
|
+
}
|
|
40
|
+
// ---- Path resolution for external documents ----
|
|
41
|
+
/** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
|
|
42
|
+
export function resolveDocPath(filename) {
|
|
43
|
+
if (isAbsolute(filename) || /[/\\]/.test(filename))
|
|
44
|
+
return filename;
|
|
45
|
+
return join(DATA_DIR, filename);
|
|
46
|
+
}
|
|
47
|
+
/** Returns true if filename is a full path (not a simple basename in DATA_DIR). */
|
|
48
|
+
export function isExternalDoc(filename) {
|
|
49
|
+
if (isAbsolute(filename) || /[/\\]/.test(filename)) {
|
|
50
|
+
const resolved = isAbsolute(filename) ? filename : filename;
|
|
51
|
+
return !resolved.startsWith(DATA_DIR);
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
/** Extract basename from a path, or return as-is if already a basename. */
|
|
56
|
+
export function getDocBasename(filename) {
|
|
57
|
+
return basename(filename);
|
|
58
|
+
}
|
|
59
|
+
/** Extract parent directory name from a path. Returns empty string for basenames. */
|
|
60
|
+
export function getParentDirName(filename) {
|
|
61
|
+
if (!isAbsolute(filename) && !/[/\\]/.test(filename))
|
|
62
|
+
return '';
|
|
63
|
+
return basename(dirname(filename));
|
|
64
|
+
}
|
|
65
|
+
/** Generate an 8-char hex node ID for TipTap block nodes. */
|
|
66
|
+
export function generateNodeId() {
|
|
67
|
+
return randomUUID().replace(/-/g, '').slice(0, 8);
|
|
68
|
+
}
|
|
69
|
+
/** Leaf block types: text-containing blocks that get pending decorations. */
|
|
70
|
+
export const LEAF_BLOCK_TYPES = new Set(['paragraph', 'heading', 'codeBlock', 'horizontalRule', 'table', 'image']);
|
|
71
|
+
export function readConfig() {
|
|
72
|
+
ensureDataDir();
|
|
73
|
+
if (!existsSync(CONFIG_FILE))
|
|
74
|
+
return {};
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function saveConfig(updates) {
|
|
83
|
+
ensureDataDir();
|
|
84
|
+
const current = readConfig();
|
|
85
|
+
const merged = { ...current, ...updates };
|
|
86
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
|
|
87
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image upload and static serving for OpenWriter.
|
|
3
|
+
* Images are stored in {DATA_DIR}/_images/ and referenced as relative paths in markdown.
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import multer from 'multer';
|
|
7
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join, extname } from 'path';
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
10
|
+
import { DATA_DIR, ensureDataDir } from './helpers.js';
|
|
11
|
+
import express from 'express';
|
|
12
|
+
const IMAGES_DIR = join(DATA_DIR, '_images');
|
|
13
|
+
function ensureImagesDir() {
|
|
14
|
+
ensureDataDir();
|
|
15
|
+
if (!existsSync(IMAGES_DIR))
|
|
16
|
+
mkdirSync(IMAGES_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
const storage = multer.diskStorage({
|
|
19
|
+
destination: (_req, _file, cb) => {
|
|
20
|
+
ensureImagesDir();
|
|
21
|
+
cb(null, IMAGES_DIR);
|
|
22
|
+
},
|
|
23
|
+
filename: (_req, file, cb) => {
|
|
24
|
+
const ext = extname(file.originalname) || '.png';
|
|
25
|
+
cb(null, `${randomUUID().slice(0, 8)}${ext}`);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const upload = multer({
|
|
29
|
+
storage,
|
|
30
|
+
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
|
31
|
+
fileFilter: (_req, file, cb) => {
|
|
32
|
+
if (file.mimetype.startsWith('image/')) {
|
|
33
|
+
cb(null, true);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
cb(new Error('Only image files are allowed'));
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
export function createImageRouter() {
|
|
41
|
+
const router = Router();
|
|
42
|
+
// Static serving for images
|
|
43
|
+
ensureImagesDir();
|
|
44
|
+
router.use('/_images', express.static(IMAGES_DIR));
|
|
45
|
+
// Upload endpoint
|
|
46
|
+
router.post('/api/upload-image', upload.single('image'), (req, res) => {
|
|
47
|
+
if (!req.file) {
|
|
48
|
+
res.status(400).json({ error: 'No image file provided' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const src = `/_images/${req.file.filename}`;
|
|
52
|
+
res.json({ src });
|
|
53
|
+
});
|
|
54
|
+
return router;
|
|
55
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express server: serves built React app, WebSocket, orchestrates MCP.
|
|
3
|
+
*/
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import { createServer } from 'http';
|
|
6
|
+
import { createConnection } from 'net';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastSyncStatus } from './ws.js';
|
|
11
|
+
import { startMcpServer, TOOL_REGISTRY, registerPluginTools } from './mcp.js';
|
|
12
|
+
import { startMcpClientServer } from './mcp-client.js';
|
|
13
|
+
import { load, save, getDocument, getTitle, getFilePath, getDocId, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocFilenames, getPendingDocCounts } from './state.js';
|
|
14
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, reloadDocument, updateDocumentTitle, openFile } from './documents.js';
|
|
15
|
+
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
16
|
+
import { createLinkRouter } from './link-routes.js';
|
|
17
|
+
import { markdownToTiptap } from './markdown.js';
|
|
18
|
+
import { importGoogleDoc } from './gdoc-import.js';
|
|
19
|
+
import { createVersionRouter } from './version-routes.js';
|
|
20
|
+
import { createSyncRouter } from './sync-routes.js';
|
|
21
|
+
import { createImageRouter } from './image-upload.js';
|
|
22
|
+
import { createExportRouter } from './export-routes.js';
|
|
23
|
+
import { loadPlugins } from './plugin-loader.js';
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = dirname(__filename);
|
|
26
|
+
function isPortTaken(port) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const socket = createConnection({ port, host: '127.0.0.1' });
|
|
29
|
+
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
|
30
|
+
socket.once('error', () => { resolve(false); });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function startServer(options = {}) {
|
|
34
|
+
const port = options.port || 5050;
|
|
35
|
+
// Check if another instance already owns the port
|
|
36
|
+
const portTaken = await isPortTaken(port);
|
|
37
|
+
if (portTaken) {
|
|
38
|
+
console.error(`[OpenWriter] Port ${port} in use — entering client mode (proxying to existing server)`);
|
|
39
|
+
// Start client-mode MCP (proxies tool calls via HTTP)
|
|
40
|
+
startMcpClientServer(port).catch((err) => {
|
|
41
|
+
console.error('[MCP-Client] Failed to start:', err);
|
|
42
|
+
});
|
|
43
|
+
// Skip browser open — existing server already has it open
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Load saved document
|
|
47
|
+
load();
|
|
48
|
+
const app = express();
|
|
49
|
+
app.use(express.json({ limit: '10mb' }));
|
|
50
|
+
// API routes for direct HTTP access (fallback if WS not available)
|
|
51
|
+
app.get('/api/status', (_req, res) => {
|
|
52
|
+
res.json(getStatus());
|
|
53
|
+
});
|
|
54
|
+
// MCP-over-HTTP: allows client-mode terminals to proxy tool calls
|
|
55
|
+
app.post('/api/mcp-call', async (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const { tool: toolName, arguments: args } = req.body;
|
|
58
|
+
const tool = TOOL_REGISTRY.find((t) => t.name === toolName);
|
|
59
|
+
if (!tool) {
|
|
60
|
+
res.status(404).json({ error: `Unknown tool: ${toolName}` });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const result = await tool.handler(args || {});
|
|
64
|
+
res.json(result);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
app.get('/api/document', (_req, res) => {
|
|
71
|
+
res.json({ document: getDocument(), title: getTitle() });
|
|
72
|
+
});
|
|
73
|
+
app.get('/api/pending-docs', (_req, res) => {
|
|
74
|
+
res.json({
|
|
75
|
+
filenames: getPendingDocFilenames(),
|
|
76
|
+
counts: getPendingDocCounts(),
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
// Mount image upload + static serving
|
|
80
|
+
app.use(createImageRouter());
|
|
81
|
+
// Mount sync routes
|
|
82
|
+
app.use(createSyncRouter(broadcastSyncStatus));
|
|
83
|
+
// Mount export routes
|
|
84
|
+
app.use(createExportRouter());
|
|
85
|
+
// Mount version history routes
|
|
86
|
+
app.use(createVersionRouter({
|
|
87
|
+
getDocId,
|
|
88
|
+
getFilePath,
|
|
89
|
+
updateDocument,
|
|
90
|
+
save,
|
|
91
|
+
broadcastDocumentSwitched,
|
|
92
|
+
}));
|
|
93
|
+
app.post('/api/save', (_req, res) => {
|
|
94
|
+
save();
|
|
95
|
+
res.json({ success: true });
|
|
96
|
+
});
|
|
97
|
+
// Beacon-based flush: browser sends this on beforeunload/visibilitychange
|
|
98
|
+
// sendBeacon sends as text/plain, so we parse the JSON manually
|
|
99
|
+
app.post('/api/flush', express.text({ type: '*/*', limit: '10mb' }), (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
if (isAgentLocked()) {
|
|
102
|
+
console.log('[Flush] Blocked (agent write lock active)');
|
|
103
|
+
res.status(204).end();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const msg = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
|
|
107
|
+
if (msg.document) {
|
|
108
|
+
updateDocument(msg.document);
|
|
109
|
+
save();
|
|
110
|
+
}
|
|
111
|
+
else if (msg.markdown) {
|
|
112
|
+
const parsed = markdownToTiptap(msg.markdown);
|
|
113
|
+
updateDocument(parsed.document);
|
|
114
|
+
if (parsed.title !== 'Untitled')
|
|
115
|
+
setMetadata({ title: parsed.title });
|
|
116
|
+
save();
|
|
117
|
+
}
|
|
118
|
+
res.status(204).end();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
res.status(400).end();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
// Document CRUD routes
|
|
125
|
+
app.get('/api/documents', (_req, res) => {
|
|
126
|
+
res.json(listDocuments());
|
|
127
|
+
});
|
|
128
|
+
app.post('/api/documents', (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const result = createDocument(req.body.title, req.body.content, req.body.path);
|
|
131
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
132
|
+
res.json(result);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
res.status(400).json({ error: err.message });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
app.post('/api/documents/open', (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const { path } = req.body;
|
|
141
|
+
if (!path) {
|
|
142
|
+
res.status(400).json({ error: 'path is required' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const result = openFile(path);
|
|
146
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
147
|
+
res.json(result);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
res.status(400).json({ error: err.message });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
app.post('/api/documents/switch', (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
const result = switchDocument(req.body.filename);
|
|
156
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
157
|
+
res.json(result);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
res.status(404).json({ error: err.message });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
app.post('/api/documents/reload', (_req, res) => {
|
|
164
|
+
try {
|
|
165
|
+
const result = reloadDocument();
|
|
166
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
167
|
+
res.json(result);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
res.status(500).json({ error: err.message });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
app.delete('/api/documents/:filename', (req, res) => {
|
|
174
|
+
try {
|
|
175
|
+
const result = deleteDocument(req.params.filename);
|
|
176
|
+
if (result.switched && result.newDoc) {
|
|
177
|
+
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
broadcastDocumentsChanged();
|
|
181
|
+
}
|
|
182
|
+
res.json(result);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
res.status(400).json({ error: err.message });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
app.put('/api/documents/:filename', (req, res) => {
|
|
189
|
+
try {
|
|
190
|
+
// Title change = metadata only. Filename stays stable.
|
|
191
|
+
updateDocumentTitle(req.params.filename, req.body.title);
|
|
192
|
+
broadcastDocumentsChanged();
|
|
193
|
+
res.json({ filename: req.params.filename });
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
res.status(400).json({ error: err.message });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// Mount workspace CRUD + doc/container/tag routes
|
|
200
|
+
app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
|
|
201
|
+
// Mount link-doc routes (create-link-doc, auto-tag-link)
|
|
202
|
+
app.use(createLinkRouter({ broadcastDocumentsChanged, broadcastWorkspacesChanged }));
|
|
203
|
+
// Text edit (fine-grained find/replace + mark changes within a node)
|
|
204
|
+
app.post('/api/edit-text', (req, res) => {
|
|
205
|
+
try {
|
|
206
|
+
const { nodeId, edits } = req.body;
|
|
207
|
+
if (!nodeId || !edits) {
|
|
208
|
+
res.status(400).json({ error: 'nodeId and edits are required' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const result = applyTextEdits(nodeId, edits);
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
res.status(400).json({ error: result.error });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
res.json({ success: true });
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
res.status(500).json({ error: err.message });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// Google Doc import
|
|
223
|
+
app.post('/api/import/gdoc', (req, res) => {
|
|
224
|
+
try {
|
|
225
|
+
const result = importGoogleDoc(req.body.document, req.body.title);
|
|
226
|
+
broadcastDocumentsChanged();
|
|
227
|
+
broadcastWorkspacesChanged();
|
|
228
|
+
res.json(result);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
res.status(400).json({ error: err.message });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
// Load plugins
|
|
235
|
+
const pluginResult = await loadPlugins(options.plugins || [], options.pluginConfig || {});
|
|
236
|
+
for (const err of pluginResult.errors)
|
|
237
|
+
console.error(`[Plugin] ${err}`);
|
|
238
|
+
for (const { plugin, config } of pluginResult.plugins) {
|
|
239
|
+
if (plugin.registerRoutes)
|
|
240
|
+
await plugin.registerRoutes({ app, config });
|
|
241
|
+
if (plugin.mcpTools)
|
|
242
|
+
registerPluginTools(plugin.mcpTools(config));
|
|
243
|
+
console.log(`[Plugin] Loaded: ${plugin.name} v${plugin.version}`);
|
|
244
|
+
}
|
|
245
|
+
// Plugin discovery endpoint — client fetches to build dynamic context menu
|
|
246
|
+
app.get('/api/plugins', (_req, res) => {
|
|
247
|
+
const descriptors = pluginResult.plugins.map(({ plugin }) => ({
|
|
248
|
+
name: plugin.name,
|
|
249
|
+
contextMenuItems: plugin.contextMenuItems?.() || [],
|
|
250
|
+
}));
|
|
251
|
+
res.json({ plugins: descriptors });
|
|
252
|
+
});
|
|
253
|
+
// Plugin action dispatch — client sends action payload, routed to correct plugin
|
|
254
|
+
app.post('/api/plugin-action', async (req, res) => {
|
|
255
|
+
try {
|
|
256
|
+
const payload = req.body;
|
|
257
|
+
const prefix = payload.action.split(':')[0];
|
|
258
|
+
const loaded = pluginResult.plugins.find(({ plugin }) => plugin.contextMenuItems?.().some((item) => item.action.startsWith(prefix + ':')));
|
|
259
|
+
if (!loaded) {
|
|
260
|
+
res.status(404).json({ error: `No plugin handles action "${payload.action}"` });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Forward to plugin's registered route — plugins handle their own actions
|
|
264
|
+
// via routes registered in registerRoutes(). The client calls those directly.
|
|
265
|
+
res.status(404).json({ error: 'Use plugin-registered routes directly' });
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
res.status(500).json({ error: err.message });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// Serve built React app
|
|
272
|
+
const clientDir = join(__dirname, '..', 'client');
|
|
273
|
+
if (existsSync(clientDir)) {
|
|
274
|
+
app.use(express.static(clientDir));
|
|
275
|
+
app.get('*', (_req, res) => {
|
|
276
|
+
res.sendFile(join(clientDir, 'index.html'));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
// Dev mode: proxy to Vite
|
|
281
|
+
app.get('/', (_req, res) => {
|
|
282
|
+
res.send(`
|
|
283
|
+
<html>
|
|
284
|
+
<body style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">
|
|
285
|
+
<div style="text-align:center">
|
|
286
|
+
<h2>OpenWriter Server Running</h2>
|
|
287
|
+
<p>In development, run <code>npm run dev:client</code> and visit <a href="http://localhost:5173">localhost:5173</a></p>
|
|
288
|
+
</div>
|
|
289
|
+
</body>
|
|
290
|
+
</html>
|
|
291
|
+
`);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const server = createServer(app);
|
|
295
|
+
// Setup WebSocket on same server
|
|
296
|
+
setupWebSocket(server);
|
|
297
|
+
// Start MCP stdio server (for agent connections)
|
|
298
|
+
startMcpServer().then(() => {
|
|
299
|
+
broadcastAgentStatus(true);
|
|
300
|
+
}).catch((err) => {
|
|
301
|
+
console.error('[MCP] Failed to start:', err);
|
|
302
|
+
});
|
|
303
|
+
server.listen(port, () => {
|
|
304
|
+
console.log(`OpenWriter running at http://localhost:${port}`);
|
|
305
|
+
});
|
|
306
|
+
// Open browser unless --no-open or running as MCP stdio pipe
|
|
307
|
+
const isMcpStdio = !process.stdout.isTTY;
|
|
308
|
+
if (!options.noOpen && !isMcpStdio) {
|
|
309
|
+
const open = await import('open');
|
|
310
|
+
const url = existsSync(clientDir)
|
|
311
|
+
? `http://localhost:${port}`
|
|
312
|
+
: 'http://localhost:5173';
|
|
313
|
+
open.default(url).catch(() => { });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express routes for document linking: create-link-doc and auto-tag-link.
|
|
3
|
+
* Mounted in index.ts to keep the main file lean.
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import { existsSync, writeFileSync } from 'fs';
|
|
7
|
+
import { listWorkspaces, getWorkspace, addDoc, addContainerToWorkspace, tagDoc } from './workspaces.js';
|
|
8
|
+
import { collectAllFiles } from './workspace-tree.js';
|
|
9
|
+
import { getActiveFilename } from './documents.js';
|
|
10
|
+
import { filePathForTitle, ensureDataDir } from './helpers.js';
|
|
11
|
+
import { tiptapToMarkdown } from './markdown.js';
|
|
12
|
+
export function createLinkRouter(b) {
|
|
13
|
+
const router = Router();
|
|
14
|
+
// Create a new doc, link-ready, auto-organized into workspace "Linked" container
|
|
15
|
+
router.post('/api/create-link-doc', (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const { title } = req.body;
|
|
18
|
+
if (!title?.trim()) {
|
|
19
|
+
res.status(400).json({ error: 'title is required' });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// 1. Create the .md file without switching active document
|
|
23
|
+
ensureDataDir();
|
|
24
|
+
const filePath = filePathForTitle(title.trim());
|
|
25
|
+
const filename = filePath.split(/[/\\]/).pop();
|
|
26
|
+
if (existsSync(filePath)) {
|
|
27
|
+
res.status(409).json({ error: 'Document already exists', filename });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph', content: [] }] };
|
|
31
|
+
const metadata = { title: title.trim() };
|
|
32
|
+
const markdown = tiptapToMarkdown(emptyDoc, title.trim(), metadata);
|
|
33
|
+
writeFileSync(filePath, markdown, 'utf-8');
|
|
34
|
+
// 2. Find workspaces containing the current (source) doc
|
|
35
|
+
const currentFilename = getActiveFilename();
|
|
36
|
+
const allWorkspaces = listWorkspaces();
|
|
37
|
+
for (const wsInfo of allWorkspaces) {
|
|
38
|
+
try {
|
|
39
|
+
const ws = getWorkspace(wsInfo.filename);
|
|
40
|
+
const wsFiles = collectAllFiles(ws.root);
|
|
41
|
+
if (!wsFiles.includes(currentFilename))
|
|
42
|
+
continue;
|
|
43
|
+
// 3. Ensure "Linked" container exists (find or create at bottom)
|
|
44
|
+
let linkedContainerId = null;
|
|
45
|
+
for (const node of ws.root) {
|
|
46
|
+
if (node.type === 'container' && node.name === 'Linked') {
|
|
47
|
+
linkedContainerId = node.id;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!linkedContainerId) {
|
|
52
|
+
const result = addContainerToWorkspace(wsInfo.filename, null, 'Linked');
|
|
53
|
+
linkedContainerId = result.containerId;
|
|
54
|
+
}
|
|
55
|
+
// 4. Add new doc to "Linked" container
|
|
56
|
+
try {
|
|
57
|
+
addDoc(wsInfo.filename, linkedContainerId, filename, title.trim());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Doc may already be in workspace (e.g., duplicate call)
|
|
61
|
+
}
|
|
62
|
+
// 5. Tag with "linked"
|
|
63
|
+
try {
|
|
64
|
+
tagDoc(wsInfo.filename, filename, 'linked');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Doc not in workspace or already tagged
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Skip problematic workspaces
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
b.broadcastDocumentsChanged();
|
|
75
|
+
b.broadcastWorkspacesChanged();
|
|
76
|
+
res.json({ filename, title: title.trim() });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
res.status(500).json({ error: err.message });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// Auto-tag an existing doc with "linked" in shared workspaces
|
|
83
|
+
router.post('/api/auto-tag-link', (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const { targetFile } = req.body;
|
|
86
|
+
if (!targetFile) {
|
|
87
|
+
res.status(400).json({ error: 'targetFile is required' });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const currentFilename = getActiveFilename();
|
|
91
|
+
const allWorkspaces = listWorkspaces();
|
|
92
|
+
let tagged = false;
|
|
93
|
+
for (const wsInfo of allWorkspaces) {
|
|
94
|
+
try {
|
|
95
|
+
const ws = getWorkspace(wsInfo.filename);
|
|
96
|
+
const wsFiles = collectAllFiles(ws.root);
|
|
97
|
+
// Both source and target must be in same workspace
|
|
98
|
+
if (!wsFiles.includes(currentFilename) || !wsFiles.includes(targetFile))
|
|
99
|
+
continue;
|
|
100
|
+
tagDoc(wsInfo.filename, targetFile, 'linked');
|
|
101
|
+
tagged = true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Skip
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (tagged)
|
|
108
|
+
b.broadcastWorkspacesChanged();
|
|
109
|
+
res.json({ success: true, tagged });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
res.status(500).json({ error: err.message });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return router;
|
|
116
|
+
}
|