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.
@@ -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 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');
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
- if (!existsSync(DATA_DIR)) {
16
- // One-time migration: rename ~/.superwriter/ → ~/.openwriter/
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, DATA_DIR);
38
+ renameSync(legacyDir, ROOT_DIR);
20
39
  }
21
40
  else {
22
- mkdirSync(DATA_DIR, { recursive: true });
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
- if (!existsSync(WORKSPACES_DIR))
29
- mkdirSync(WORKSPACES_DIR, { recursive: true });
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(DATA_DIR, `${sanitizeFilename(title)}.md`);
61
+ return join(getDataDir(), `${sanitizeFilename(title)}.md`);
36
62
  }
37
63
  export function tempFilePath() {
38
- return join(DATA_DIR, `${TEMP_PREFIX}${randomUUID()}.md`);
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(DATA_DIR, filename);
48
- const dataDir = resolve(DATA_DIR);
49
- if (resolved !== dataDir && !resolved.startsWith(dataDir + sep)) {
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(DATA_DIR);
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
- if (!existsSync(CONFIG_FILE))
113
+ const configFile = getConfigFile();
114
+ if (!existsSync(configFile))
87
115
  return {};
88
116
  try {
89
- return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
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(CONFIG_FILE, JSON.stringify(merged, null, 2));
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 { DATA_DIR, ensureDataDir } from './helpers.js';
10
+ import { getDataDir, ensureDataDir } from './helpers.js';
11
11
  import express from 'express';
12
- const IMAGES_DIR = join(DATA_DIR, '_images');
12
+ function getImagesDir() { return join(getDataDir(), '_images'); }
13
13
  function ensureImagesDir() {
14
14
  ensureDataDir();
15
- if (!existsSync(IMAGES_DIR))
16
- mkdirSync(IMAGES_DIR, { recursive: true });
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, IMAGES_DIR);
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
- // Static serving for images
43
+ // Dynamic static serving resolves active profile's images dir per request
43
44
  ensureImagesDir();
44
- router.use('/_images', express.static(IMAGES_DIR));
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) {
@@ -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
- broadcastDocumentSwitched(result.document, result.title, result.filename);
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 = (await import('./helpers.js')).readConfig();
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);
@@ -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 { DATA_DIR, ensureDataDir } from './helpers.js';
9
- const MARKS_DIR = join(DATA_DIR, '_marks');
8
+ import { getDataDir, ensureDataDir } from './helpers.js';
9
+ function getMarksDir() { return join(getDataDir(), '_marks'); }
10
10
  function ensureMarksDir() {
11
11
  ensureDataDir();
12
- if (!existsSync(MARKS_DIR))
13
- mkdirSync(MARKS_DIR, { recursive: true });
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(MARKS_DIR, `${safe}.json`);
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(MARKS_DIR);
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(MARKS_DIR, file);
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(MARKS_DIR);
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(MARKS_DIR, file);
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(MARKS_DIR);
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(MARKS_DIR, file);
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;