openwriter 0.5.4 → 0.6.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 +3 -0
- package/dist/client/assets/index-CCMCrgTu.js +209 -0
- package/dist/client/assets/index-DN1_4Au6.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/connection-routes.js +327 -0
- package/dist/server/connections.js +35 -0
- package/dist/server/documents.js +30 -24
- package/dist/server/export-html-template.js +1 -1
- package/dist/server/export-routes.js +1 -1
- package/dist/server/gdoc-import.js +2 -2
- package/dist/server/git-sync.js +30 -30
- package/dist/server/helpers.js +175 -20
- package/dist/server/image-upload.js +10 -7
- package/dist/server/index.js +162 -5
- package/dist/server/marks.js +11 -11
- package/dist/server/mcp.js +134 -50
- package/dist/server/plugin-manager.js +2 -2
- package/dist/server/scheduler-routes.js +121 -0
- package/dist/server/state.js +126 -42
- package/dist/server/versions.js +6 -2
- package/dist/server/workspace-routes.js +14 -13
- package/dist/server/workspaces.js +7 -7
- package/dist/server/ws.js +15 -0
- package/package.json +1 -1
- package/skill/SKILL.md +95 -11
- package/dist/client/assets/index-BAbqg4Q8.js +0 -210
- package/dist/client/assets/index-BR_sMmFf.css +0 -1
package/dist/server/helpers.js
CHANGED
|
@@ -2,51 +2,78 @@
|
|
|
2
2
|
* Shared constants and utility functions for OpenWriter server.
|
|
3
3
|
* Both state.ts and documents.ts import from here to avoid duplication.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync } from 'fs';
|
|
6
6
|
import { join, isAbsolute, basename, dirname, resolve, sep } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
|
-
export const
|
|
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');
|
|
9
|
+
export const ROOT_DIR = join(homedir(), '.openwriter');
|
|
13
10
|
export const TEMP_PREFIX = '_untitled-';
|
|
11
|
+
// ---- Profile state ----
|
|
12
|
+
let activeProfile = 'Default';
|
|
13
|
+
export function getActiveProfile() {
|
|
14
|
+
return activeProfile;
|
|
15
|
+
}
|
|
16
|
+
export function setActiveProfile(name) {
|
|
17
|
+
activeProfile = name;
|
|
18
|
+
}
|
|
19
|
+
// ---- Profile-aware path getters ----
|
|
20
|
+
export function getDataDir() {
|
|
21
|
+
return join(ROOT_DIR, 'profiles', activeProfile);
|
|
22
|
+
}
|
|
23
|
+
export function getVersionsDir() {
|
|
24
|
+
return join(getDataDir(), '.versions');
|
|
25
|
+
}
|
|
26
|
+
export function getWorkspacesDir() {
|
|
27
|
+
return join(getDataDir(), '_workspaces');
|
|
28
|
+
}
|
|
29
|
+
export function getConfigFile() {
|
|
30
|
+
return join(ROOT_DIR, 'config.json');
|
|
31
|
+
}
|
|
32
|
+
// ---- Directory bootstrapping ----
|
|
14
33
|
export function ensureDataDir() {
|
|
15
|
-
|
|
16
|
-
|
|
34
|
+
// Ensure ROOT_DIR exists (with legacy migration)
|
|
35
|
+
if (!existsSync(ROOT_DIR)) {
|
|
17
36
|
const legacyDir = join(homedir(), '.superwriter');
|
|
18
37
|
if (existsSync(legacyDir)) {
|
|
19
|
-
renameSync(legacyDir,
|
|
38
|
+
renameSync(legacyDir, ROOT_DIR);
|
|
20
39
|
}
|
|
21
40
|
else {
|
|
22
|
-
mkdirSync(
|
|
41
|
+
mkdirSync(ROOT_DIR, { recursive: true });
|
|
23
42
|
}
|
|
24
43
|
}
|
|
44
|
+
// Migrate flat files into profiles/Default/ if needed
|
|
45
|
+
migrateToProfiles();
|
|
46
|
+
// Ensure active profile directory exists
|
|
47
|
+
const dataDir = getDataDir();
|
|
48
|
+
if (!existsSync(dataDir))
|
|
49
|
+
mkdirSync(dataDir, { recursive: true });
|
|
25
50
|
}
|
|
26
51
|
export function ensureWorkspacesDir() {
|
|
27
52
|
ensureDataDir();
|
|
28
|
-
|
|
29
|
-
|
|
53
|
+
const wsDir = getWorkspacesDir();
|
|
54
|
+
if (!existsSync(wsDir))
|
|
55
|
+
mkdirSync(wsDir, { recursive: true });
|
|
30
56
|
}
|
|
31
57
|
export function sanitizeFilename(name) {
|
|
32
58
|
return name.replace(/[<>:"/\\|?*]/g, '-').trim() || 'Untitled';
|
|
33
59
|
}
|
|
34
60
|
export function filePathForTitle(title) {
|
|
35
|
-
return join(
|
|
61
|
+
return join(getDataDir(), `${sanitizeFilename(title)}.md`);
|
|
36
62
|
}
|
|
37
63
|
export function tempFilePath() {
|
|
38
|
-
return join(
|
|
64
|
+
return join(getDataDir(), `${TEMP_PREFIX}${randomUUID()}.md`);
|
|
39
65
|
}
|
|
40
66
|
// ---- Path resolution for external documents ----
|
|
41
67
|
/** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
|
|
42
68
|
export function resolveDocPath(filename) {
|
|
69
|
+
const dataDir = getDataDir();
|
|
43
70
|
// External docs use absolute paths as identifiers — pass through
|
|
44
71
|
if (isAbsolute(filename))
|
|
45
72
|
return filename;
|
|
46
73
|
// Internal docs must resolve within DATA_DIR — block path traversal
|
|
47
|
-
const resolved = resolve(
|
|
48
|
-
const
|
|
49
|
-
if (resolved !==
|
|
74
|
+
const resolved = resolve(dataDir, filename);
|
|
75
|
+
const dataDirResolved = resolve(dataDir);
|
|
76
|
+
if (resolved !== dataDirResolved && !resolved.startsWith(dataDirResolved + sep)) {
|
|
50
77
|
throw new Error(`Path traversal blocked: ${filename}`);
|
|
51
78
|
}
|
|
52
79
|
return resolved;
|
|
@@ -55,7 +82,7 @@ export function resolveDocPath(filename) {
|
|
|
55
82
|
export function isExternalDoc(filename) {
|
|
56
83
|
if (isAbsolute(filename) || /[/\\]/.test(filename)) {
|
|
57
84
|
const resolved = isAbsolute(filename) ? filename : filename;
|
|
58
|
-
return !resolved.startsWith(
|
|
85
|
+
return !resolved.startsWith(getDataDir());
|
|
59
86
|
}
|
|
60
87
|
return false;
|
|
61
88
|
}
|
|
@@ -83,10 +110,11 @@ export function generateNodeId() {
|
|
|
83
110
|
export const LEAF_BLOCK_TYPES = new Set(['paragraph', 'heading', 'codeBlock', 'horizontalRule', 'table', 'image']);
|
|
84
111
|
export function readConfig() {
|
|
85
112
|
ensureDataDir();
|
|
86
|
-
|
|
113
|
+
const configFile = getConfigFile();
|
|
114
|
+
if (!existsSync(configFile))
|
|
87
115
|
return {};
|
|
88
116
|
try {
|
|
89
|
-
return JSON.parse(readFileSync(
|
|
117
|
+
return JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
90
118
|
}
|
|
91
119
|
catch {
|
|
92
120
|
return {};
|
|
@@ -96,5 +124,132 @@ export function saveConfig(updates) {
|
|
|
96
124
|
ensureDataDir();
|
|
97
125
|
const current = readConfig();
|
|
98
126
|
const merged = { ...current, ...updates };
|
|
99
|
-
atomicWriteFileSync(
|
|
127
|
+
atomicWriteFileSync(getConfigFile(), JSON.stringify(merged, null, 2));
|
|
128
|
+
}
|
|
129
|
+
// ---- Profile CRUD ----
|
|
130
|
+
export function listProfiles() {
|
|
131
|
+
const config = readConfig();
|
|
132
|
+
return config.profiles || ['Default'];
|
|
133
|
+
}
|
|
134
|
+
export function createProfile(name) {
|
|
135
|
+
if (!name.trim())
|
|
136
|
+
throw new Error('Profile name cannot be empty');
|
|
137
|
+
const config = readConfig();
|
|
138
|
+
const profiles = config.profiles || ['Default'];
|
|
139
|
+
if (profiles.includes(name))
|
|
140
|
+
throw new Error(`Profile "${name}" already exists`);
|
|
141
|
+
// Create profile directory
|
|
142
|
+
const profileDir = join(ROOT_DIR, 'profiles', name);
|
|
143
|
+
mkdirSync(profileDir, { recursive: true });
|
|
144
|
+
// Update config
|
|
145
|
+
profiles.push(name);
|
|
146
|
+
saveConfig({ profiles });
|
|
147
|
+
}
|
|
148
|
+
export function deleteProfile(name) {
|
|
149
|
+
if (name === 'Default')
|
|
150
|
+
throw new Error('Cannot delete the Default profile');
|
|
151
|
+
if (name === activeProfile)
|
|
152
|
+
throw new Error('Cannot delete the active profile');
|
|
153
|
+
const config = readConfig();
|
|
154
|
+
const profiles = config.profiles || ['Default'];
|
|
155
|
+
const idx = profiles.indexOf(name);
|
|
156
|
+
if (idx === -1)
|
|
157
|
+
throw new Error(`Profile "${name}" not found`);
|
|
158
|
+
// Move directory to .trash-{name}/ (soft delete)
|
|
159
|
+
const profileDir = join(ROOT_DIR, 'profiles', name);
|
|
160
|
+
const trashDir = join(ROOT_DIR, 'profiles', `.trash-${name}`);
|
|
161
|
+
if (existsSync(profileDir)) {
|
|
162
|
+
// If a trash dir already exists for this name, remove the old trash first
|
|
163
|
+
if (existsSync(trashDir)) {
|
|
164
|
+
rmSync(trashDir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
renameSync(profileDir, trashDir);
|
|
167
|
+
}
|
|
168
|
+
profiles.splice(idx, 1);
|
|
169
|
+
saveConfig({ profiles });
|
|
170
|
+
}
|
|
171
|
+
export function listTrashedProfiles() {
|
|
172
|
+
const profilesDir = join(ROOT_DIR, 'profiles');
|
|
173
|
+
if (!existsSync(profilesDir))
|
|
174
|
+
return [];
|
|
175
|
+
try {
|
|
176
|
+
return readdirSync(profilesDir)
|
|
177
|
+
.filter(name => name.startsWith('.trash-') && statSync(join(profilesDir, name)).isDirectory())
|
|
178
|
+
.map(name => name.slice('.trash-'.length));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function restoreProfile(name) {
|
|
185
|
+
const trashDir = join(ROOT_DIR, 'profiles', `.trash-${name}`);
|
|
186
|
+
if (!existsSync(trashDir))
|
|
187
|
+
throw new Error(`No trashed profile "${name}" found`);
|
|
188
|
+
const profileDir = join(ROOT_DIR, 'profiles', name);
|
|
189
|
+
if (existsSync(profileDir))
|
|
190
|
+
throw new Error(`A profile named "${name}" already exists`);
|
|
191
|
+
renameSync(trashDir, profileDir);
|
|
192
|
+
// Add back to config
|
|
193
|
+
const config = readConfig();
|
|
194
|
+
const profiles = config.profiles || ['Default'];
|
|
195
|
+
if (!profiles.includes(name)) {
|
|
196
|
+
profiles.push(name);
|
|
197
|
+
saveConfig({ profiles });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ---- Migration: flat files → profiles/Default/ ----
|
|
201
|
+
function migrateToProfiles() {
|
|
202
|
+
const profilesDir = join(ROOT_DIR, 'profiles');
|
|
203
|
+
if (existsSync(profilesDir))
|
|
204
|
+
return; // Already migrated
|
|
205
|
+
// Create profiles/Default/
|
|
206
|
+
const defaultDir = join(profilesDir, 'Default');
|
|
207
|
+
mkdirSync(defaultDir, { recursive: true });
|
|
208
|
+
// Items to move from ROOT_DIR → profiles/Default/
|
|
209
|
+
const dirsToMove = ['_workspaces', '_images', '.versions', '_marks'];
|
|
210
|
+
const filesToMove = ['_doc-order.json', 'external-docs.json'];
|
|
211
|
+
for (const dir of dirsToMove) {
|
|
212
|
+
const src = join(ROOT_DIR, dir);
|
|
213
|
+
if (existsSync(src)) {
|
|
214
|
+
renameSync(src, join(defaultDir, dir));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const file of filesToMove) {
|
|
218
|
+
const src = join(ROOT_DIR, file);
|
|
219
|
+
if (existsSync(src)) {
|
|
220
|
+
renameSync(src, join(defaultDir, file));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Move .git and .gitignore
|
|
224
|
+
for (const name of ['.git', '.gitignore']) {
|
|
225
|
+
const src = join(ROOT_DIR, name);
|
|
226
|
+
if (existsSync(src)) {
|
|
227
|
+
renameSync(src, join(defaultDir, name));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Move all .md files
|
|
231
|
+
try {
|
|
232
|
+
const entries = readdirSync(ROOT_DIR);
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
if (entry.endsWith('.md')) {
|
|
235
|
+
const src = join(ROOT_DIR, entry);
|
|
236
|
+
if (statSync(src).isFile()) {
|
|
237
|
+
renameSync(src, join(defaultDir, entry));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch { /* best effort */ }
|
|
243
|
+
// Update config
|
|
244
|
+
const configFile = getConfigFile();
|
|
245
|
+
let config = {};
|
|
246
|
+
if (existsSync(configFile)) {
|
|
247
|
+
try {
|
|
248
|
+
config = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
249
|
+
}
|
|
250
|
+
catch { /* */ }
|
|
251
|
+
}
|
|
252
|
+
config.activeProfile = 'Default';
|
|
253
|
+
config.profiles = ['Default'];
|
|
254
|
+
atomicWriteFileSync(configFile, JSON.stringify(config, null, 2));
|
|
100
255
|
}
|
|
@@ -7,18 +7,19 @@ import multer from 'multer';
|
|
|
7
7
|
import { existsSync, mkdirSync } from 'fs';
|
|
8
8
|
import { join, extname } from 'path';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
|
-
import {
|
|
10
|
+
import { getDataDir, ensureDataDir } from './helpers.js';
|
|
11
11
|
import express from 'express';
|
|
12
|
-
|
|
12
|
+
function getImagesDir() { return join(getDataDir(), '_images'); }
|
|
13
13
|
function ensureImagesDir() {
|
|
14
14
|
ensureDataDir();
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const dir = getImagesDir();
|
|
16
|
+
if (!existsSync(dir))
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
17
18
|
}
|
|
18
19
|
const storage = multer.diskStorage({
|
|
19
20
|
destination: (_req, _file, cb) => {
|
|
20
21
|
ensureImagesDir();
|
|
21
|
-
cb(null,
|
|
22
|
+
cb(null, getImagesDir());
|
|
22
23
|
},
|
|
23
24
|
filename: (_req, file, cb) => {
|
|
24
25
|
const ext = extname(file.originalname) || '.png';
|
|
@@ -39,9 +40,11 @@ const upload = multer({
|
|
|
39
40
|
});
|
|
40
41
|
export function createImageRouter() {
|
|
41
42
|
const router = Router();
|
|
42
|
-
//
|
|
43
|
+
// Dynamic static serving — resolves active profile's images dir per request
|
|
43
44
|
ensureImagesDir();
|
|
44
|
-
router.use('/_images',
|
|
45
|
+
router.use('/_images', (req, res, next) => {
|
|
46
|
+
express.static(getImagesDir())(req, res, next);
|
|
47
|
+
});
|
|
45
48
|
// Upload endpoint
|
|
46
49
|
router.post('/api/upload-image', upload.single('image'), (req, res) => {
|
|
47
50
|
if (!req.file) {
|
package/dist/server/index.js
CHANGED
|
@@ -11,19 +11,23 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
|
|
|
11
11
|
import { TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
-
import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
|
|
15
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument } from './documents.js';
|
|
14
|
+
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
|
|
15
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
|
|
16
16
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
17
17
|
import { createLinkRouter } from './link-routes.js';
|
|
18
18
|
import { createTweetRouter } from './tweet-routes.js';
|
|
19
19
|
import { markdownToTiptap } from './markdown.js';
|
|
20
20
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
21
21
|
import { createVersionRouter } from './version-routes.js';
|
|
22
|
+
import { clearVersionsCache } from './versions.js';
|
|
22
23
|
import { createSyncRouter } from './sync-routes.js';
|
|
23
24
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
24
|
-
import { resolveDocPath } from './helpers.js';
|
|
25
|
+
import { resolveDocPath, getActiveProfile, setActiveProfile, listProfiles, createProfile, deleteProfile, listTrashedProfiles, restoreProfile, saveConfig, readConfig } from './helpers.js';
|
|
25
26
|
import { createImageRouter } from './image-upload.js';
|
|
26
27
|
import { createExportRouter } from './export-routes.js';
|
|
28
|
+
import { createConnectionRouter } from './connection-routes.js';
|
|
29
|
+
import { createSchedulerRouter } from './scheduler-routes.js';
|
|
30
|
+
import { platformFetch, isAuthenticated } from './connections.js';
|
|
27
31
|
import { PluginManager } from './plugin-manager.js';
|
|
28
32
|
import { checkForUpdate } from './update-check.js';
|
|
29
33
|
import { addMark, getMarks, resolveMarks } from './marks.js';
|
|
@@ -81,6 +85,40 @@ export async function startHttpServer(options = {}) {
|
|
|
81
85
|
app.use(createSyncRouter(broadcastSyncStatus));
|
|
82
86
|
// Mount export routes
|
|
83
87
|
app.use(createExportRouter());
|
|
88
|
+
// Mount connection CRUD + profile binding routes
|
|
89
|
+
app.use(createConnectionRouter());
|
|
90
|
+
// Mount scheduler proxy routes
|
|
91
|
+
app.use(createSchedulerRouter());
|
|
92
|
+
// Newsletter analytics proxy routes
|
|
93
|
+
app.get('/api/publications', async (req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
if (!isAuthenticated()) {
|
|
96
|
+
res.status(401).json({ error: 'Not authenticated' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const qs = req.query.documentId ? `?documentId=${encodeURIComponent(req.query.documentId)}` : '';
|
|
100
|
+
const resp = await platformFetch(`/publications${qs}`);
|
|
101
|
+
const data = await resp.json();
|
|
102
|
+
res.status(resp.status).json(data);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
res.status(500).json({ error: err.message });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
app.get('/api/publications/:id/stats', async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
if (!isAuthenticated()) {
|
|
111
|
+
res.status(401).json({ error: 'Not authenticated' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const resp = await platformFetch(`/publications/${req.params.id}/stats`);
|
|
115
|
+
const data = await resp.json();
|
|
116
|
+
res.status(resp.status).json(data);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
res.status(500).json({ error: err.message });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
84
122
|
// Mount version history routes
|
|
85
123
|
app.use(createVersionRouter({
|
|
86
124
|
getDocId,
|
|
@@ -136,6 +174,26 @@ export async function startHttpServer(options = {}) {
|
|
|
136
174
|
app.get('/api/documents', (_req, res) => {
|
|
137
175
|
res.json(listDocuments());
|
|
138
176
|
});
|
|
177
|
+
app.get('/api/documents/:filename/text', (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const filepath = resolveDocPath(req.params.filename);
|
|
180
|
+
const raw = readFileSync(filepath, 'utf-8');
|
|
181
|
+
// Parse YAML frontmatter
|
|
182
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
183
|
+
const text = fmMatch ? fmMatch[2].trim() : raw.trim();
|
|
184
|
+
let meta = {};
|
|
185
|
+
if (fmMatch) {
|
|
186
|
+
try {
|
|
187
|
+
meta = JSON.parse(fmMatch[1]);
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
}
|
|
191
|
+
res.json({ text, meta });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
res.status(404).json({ error: 'Document not found' });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
139
197
|
app.put('/api/documents/reorder', (req, res) => {
|
|
140
198
|
try {
|
|
141
199
|
const { order } = req.body;
|
|
@@ -209,10 +267,32 @@ export async function startHttpServer(options = {}) {
|
|
|
209
267
|
res.status(400).json({ error: err.message });
|
|
210
268
|
}
|
|
211
269
|
});
|
|
270
|
+
// Sync browser editor content to server state — guarantees server has latest before MCP calls.
|
|
271
|
+
// Used by compose modals that need to read server state via MCP tools.
|
|
272
|
+
app.post('/api/documents/sync-content', (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
const { document: doc, filename } = req.body;
|
|
275
|
+
if (!doc || !filename) {
|
|
276
|
+
res.status(400).json({ error: 'document and filename required' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (filename === getActiveFilename()) {
|
|
280
|
+
updateDocument(doc);
|
|
281
|
+
save();
|
|
282
|
+
}
|
|
283
|
+
res.json({ ok: true });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
res.status(500).json({ error: err.message });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
212
289
|
app.post('/api/documents/switch', (req, res) => {
|
|
213
290
|
try {
|
|
291
|
+
const alreadyActive = req.body.filename === getActiveFilename();
|
|
214
292
|
const result = switchDocument(req.body.filename);
|
|
215
|
-
|
|
293
|
+
if (!alreadyActive) {
|
|
294
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
295
|
+
}
|
|
216
296
|
res.json(result);
|
|
217
297
|
}
|
|
218
298
|
catch (err) {
|
|
@@ -400,6 +480,83 @@ export async function startHttpServer(options = {}) {
|
|
|
400
480
|
res.status(500).json({ error: err.message });
|
|
401
481
|
}
|
|
402
482
|
});
|
|
483
|
+
// ---- Profile management ----
|
|
484
|
+
app.get('/api/profiles', (_req, res) => {
|
|
485
|
+
res.json({ profiles: listProfiles(), active: getActiveProfile() });
|
|
486
|
+
});
|
|
487
|
+
app.post('/api/profiles', (req, res) => {
|
|
488
|
+
try {
|
|
489
|
+
const { name } = req.body;
|
|
490
|
+
if (!name?.trim()) {
|
|
491
|
+
res.status(400).json({ error: 'name is required' });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
createProfile(name.trim());
|
|
495
|
+
res.json({ success: true });
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
res.status(400).json({ error: err.message });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
app.post('/api/profiles/switch', async (req, res) => {
|
|
502
|
+
try {
|
|
503
|
+
const { name } = req.body;
|
|
504
|
+
if (!name?.trim()) {
|
|
505
|
+
res.status(400).json({ error: 'name is required' });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const profiles = listProfiles();
|
|
509
|
+
if (!profiles.includes(name)) {
|
|
510
|
+
res.status(404).json({ error: `Profile "${name}" not found` });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// Flush current doc
|
|
514
|
+
cancelDebouncedSave();
|
|
515
|
+
save();
|
|
516
|
+
// Switch profile
|
|
517
|
+
setActiveProfile(name);
|
|
518
|
+
saveConfig({ activeProfile: name });
|
|
519
|
+
// Clear caches and reload
|
|
520
|
+
clearAllCaches();
|
|
521
|
+
clearVersionsCache();
|
|
522
|
+
load();
|
|
523
|
+
// Broadcast fresh state
|
|
524
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), getFilePath().split(/[/\\]/).pop() || '', getMetadata());
|
|
525
|
+
broadcastDocumentsChanged();
|
|
526
|
+
broadcastWorkspacesChanged();
|
|
527
|
+
broadcastPendingDocsChanged();
|
|
528
|
+
res.json({ success: true, active: name });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
res.status(500).json({ error: err.message });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
app.delete('/api/profiles/:name', (req, res) => {
|
|
535
|
+
try {
|
|
536
|
+
deleteProfile(req.params.name);
|
|
537
|
+
res.json({ success: true });
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
res.status(400).json({ error: err.message });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
app.get('/api/profiles/trash', (_req, res) => {
|
|
544
|
+
res.json({ profiles: listTrashedProfiles() });
|
|
545
|
+
});
|
|
546
|
+
app.post('/api/profiles/restore', (req, res) => {
|
|
547
|
+
try {
|
|
548
|
+
const { name } = req.body;
|
|
549
|
+
if (!name?.trim()) {
|
|
550
|
+
res.status(400).json({ error: 'name is required' });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
restoreProfile(name.trim());
|
|
554
|
+
res.json({ success: true });
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
res.status(400).json({ error: err.message });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
403
560
|
// Google Doc import
|
|
404
561
|
app.post('/api/import/gdoc', (req, res) => {
|
|
405
562
|
try {
|
|
@@ -422,7 +579,7 @@ export async function startHttpServer(options = {}) {
|
|
|
422
579
|
console.error(`[Plugin] ${result.error}`);
|
|
423
580
|
}
|
|
424
581
|
// Auto-enable from saved config.json
|
|
425
|
-
const savedConfig =
|
|
582
|
+
const savedConfig = readConfig();
|
|
426
583
|
for (const [name, state] of Object.entries(savedConfig.plugins || {})) {
|
|
427
584
|
if (state.enabled && !((options.plugins || []).includes(name))) {
|
|
428
585
|
const result = await pluginManager.enable(name);
|
package/dist/server/marks.js
CHANGED
|
@@ -5,17 +5,17 @@
|
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from 'fs';
|
|
7
7
|
import { randomUUID } from 'crypto';
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import { getDataDir, ensureDataDir } from './helpers.js';
|
|
9
|
+
function getMarksDir() { return join(getDataDir(), '_marks'); }
|
|
10
10
|
function ensureMarksDir() {
|
|
11
11
|
ensureDataDir();
|
|
12
|
-
if (!existsSync(
|
|
13
|
-
mkdirSync(
|
|
12
|
+
if (!existsSync(getMarksDir()))
|
|
13
|
+
mkdirSync(getMarksDir(), { recursive: true });
|
|
14
14
|
}
|
|
15
15
|
function markFilePath(filename) {
|
|
16
16
|
// Sanitize: replace path separators to avoid nested paths
|
|
17
17
|
const safe = filename.replace(/[/\\]/g, '_');
|
|
18
|
-
return join(
|
|
18
|
+
return join(getMarksDir(), `${safe}.json`);
|
|
19
19
|
}
|
|
20
20
|
function readMarkFile(filename) {
|
|
21
21
|
const path = markFilePath(filename);
|
|
@@ -63,13 +63,13 @@ export function getMarks(filename) {
|
|
|
63
63
|
ensureMarksDir();
|
|
64
64
|
const result = {};
|
|
65
65
|
try {
|
|
66
|
-
const files = readdirSync(
|
|
66
|
+
const files = readdirSync(getMarksDir());
|
|
67
67
|
for (const file of files) {
|
|
68
68
|
if (!file.endsWith('.json'))
|
|
69
69
|
continue;
|
|
70
70
|
const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
|
|
71
71
|
// Read raw to avoid filename roundtrip issues
|
|
72
|
-
const path = join(
|
|
72
|
+
const path = join(getMarksDir(), file);
|
|
73
73
|
try {
|
|
74
74
|
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
75
75
|
if (data.marks.length > 0)
|
|
@@ -90,7 +90,7 @@ export function getGlobalMarkSummary(excludeFilename) {
|
|
|
90
90
|
let totalMarks = 0;
|
|
91
91
|
let docCount = 0;
|
|
92
92
|
try {
|
|
93
|
-
const files = readdirSync(
|
|
93
|
+
const files = readdirSync(getMarksDir());
|
|
94
94
|
for (const file of files) {
|
|
95
95
|
if (!file.endsWith('.json'))
|
|
96
96
|
continue;
|
|
@@ -99,7 +99,7 @@ export function getGlobalMarkSummary(excludeFilename) {
|
|
|
99
99
|
if (file === `${safe}.json`)
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
|
-
const path = join(
|
|
102
|
+
const path = join(getMarksDir(), file);
|
|
103
103
|
try {
|
|
104
104
|
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
105
105
|
if (data.marks.length > 0) {
|
|
@@ -118,11 +118,11 @@ export function resolveMarks(ids) {
|
|
|
118
118
|
const resolved = [];
|
|
119
119
|
ensureMarksDir();
|
|
120
120
|
try {
|
|
121
|
-
const files = readdirSync(
|
|
121
|
+
const files = readdirSync(getMarksDir());
|
|
122
122
|
for (const file of files) {
|
|
123
123
|
if (!file.endsWith('.json'))
|
|
124
124
|
continue;
|
|
125
|
-
const filePath = join(
|
|
125
|
+
const filePath = join(getMarksDir(), file);
|
|
126
126
|
try {
|
|
127
127
|
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
128
128
|
const before = data.marks.length;
|