openwriter 0.14.0 → 0.15.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/client/assets/index-B3iORmCT.css +1 -0
- package/dist/client/assets/index-B5MXw2pg.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +60 -18
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-serialize.js +122 -25
- package/dist/server/mcp.js +289 -77
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-matcher.js +57 -5
- package/dist/server/pending-overlay.js +845 -0
- package/dist/server/state.js +981 -78
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +15 -0
- package/dist/server/ws.js +184 -37
- package/package.json +1 -1
- package/skill/SKILL.md +30 -19
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-B5MXw2pg.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B3iORmCT.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comments: sidecar JSON storage for inline user feedback (formerly "agent marks").
|
|
3
|
+
* Each document gets a sidecar file at DATA_DIR/_marks/{filename}.json.
|
|
4
|
+
* Storage directory name `_marks/` is retained for backwards compatibility with
|
|
5
|
+
* existing user data; the public vocabulary is "comment" everywhere else.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from 'fs';
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
10
|
+
import { getDataDir, ensureDataDir } from './helpers.js';
|
|
11
|
+
function isResolved(c) {
|
|
12
|
+
return typeof c.resolvedAt === 'string' && c.resolvedAt.length > 0;
|
|
13
|
+
}
|
|
14
|
+
function getCommentsDir() { return join(getDataDir(), '_marks'); }
|
|
15
|
+
function ensureCommentsDir() {
|
|
16
|
+
ensureDataDir();
|
|
17
|
+
if (!existsSync(getCommentsDir()))
|
|
18
|
+
mkdirSync(getCommentsDir(), { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
function commentFilePath(filename) {
|
|
21
|
+
const safe = filename.replace(/[/\\]/g, '_');
|
|
22
|
+
return join(getCommentsDir(), `${safe}.json`);
|
|
23
|
+
}
|
|
24
|
+
function readCommentFile(filename) {
|
|
25
|
+
const path = commentFilePath(filename);
|
|
26
|
+
if (!existsSync(path))
|
|
27
|
+
return { marks: [] };
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { marks: [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function writeCommentFile(filename, data) {
|
|
36
|
+
ensureCommentsDir();
|
|
37
|
+
const path = commentFilePath(filename);
|
|
38
|
+
if (data.marks.length === 0) {
|
|
39
|
+
if (existsSync(path))
|
|
40
|
+
unlinkSync(path);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
writeFileSync(path, JSON.stringify(data, null, 2));
|
|
44
|
+
}
|
|
45
|
+
export function addComment(filename, text, note, nodeId, nodeIds) {
|
|
46
|
+
const data = readCommentFile(filename);
|
|
47
|
+
const comment = {
|
|
48
|
+
id: randomUUID().slice(0, 8),
|
|
49
|
+
text,
|
|
50
|
+
note,
|
|
51
|
+
nodeId,
|
|
52
|
+
...(nodeIds && nodeIds.length > 1 ? { nodeIds } : {}),
|
|
53
|
+
createdAt: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
data.marks.push(comment);
|
|
56
|
+
writeCommentFile(filename, data);
|
|
57
|
+
return comment;
|
|
58
|
+
}
|
|
59
|
+
export function getComments(filename, opts = {}) {
|
|
60
|
+
const keep = (list) => opts.includeResolved ? list : list.filter((c) => !isResolved(c));
|
|
61
|
+
if (filename) {
|
|
62
|
+
const data = readCommentFile(filename);
|
|
63
|
+
const list = keep(data.marks);
|
|
64
|
+
if (list.length === 0)
|
|
65
|
+
return {};
|
|
66
|
+
return { [filename]: list };
|
|
67
|
+
}
|
|
68
|
+
ensureCommentsDir();
|
|
69
|
+
const result = {};
|
|
70
|
+
try {
|
|
71
|
+
const files = readdirSync(getCommentsDir());
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
if (!file.endsWith('.json'))
|
|
74
|
+
continue;
|
|
75
|
+
const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
|
|
76
|
+
const path = join(getCommentsDir(), file);
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
79
|
+
const list = keep(data.marks);
|
|
80
|
+
if (list.length > 0)
|
|
81
|
+
result[docFilename] = list;
|
|
82
|
+
}
|
|
83
|
+
catch { /* skip corrupt files */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch { /* dir doesn't exist yet */ }
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
export function getCommentCount(filename) {
|
|
90
|
+
return readCommentFile(filename).marks.filter((c) => !isResolved(c)).length;
|
|
91
|
+
}
|
|
92
|
+
/** Count unresolved comments across all documents, optionally excluding one filename. */
|
|
93
|
+
export function getGlobalCommentSummary(excludeFilename) {
|
|
94
|
+
ensureCommentsDir();
|
|
95
|
+
let totalComments = 0;
|
|
96
|
+
let docCount = 0;
|
|
97
|
+
try {
|
|
98
|
+
const files = readdirSync(getCommentsDir());
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
if (!file.endsWith('.json'))
|
|
101
|
+
continue;
|
|
102
|
+
if (excludeFilename) {
|
|
103
|
+
const safe = excludeFilename.replace(/[/\\]/g, '_');
|
|
104
|
+
if (file === `${safe}.json`)
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const path = join(getCommentsDir(), file);
|
|
108
|
+
try {
|
|
109
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
110
|
+
const unresolved = data.marks.filter((c) => !isResolved(c));
|
|
111
|
+
if (unresolved.length > 0) {
|
|
112
|
+
totalComments += unresolved.length;
|
|
113
|
+
docCount++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch { /* skip */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch { /* dir doesn't exist */ }
|
|
120
|
+
return { totalComments, docCount };
|
|
121
|
+
}
|
|
122
|
+
export function editComment(filename, id, note) {
|
|
123
|
+
const data = readCommentFile(filename);
|
|
124
|
+
const comment = data.marks.find((m) => m.id === id);
|
|
125
|
+
if (!comment)
|
|
126
|
+
return null;
|
|
127
|
+
comment.note = note;
|
|
128
|
+
writeCommentFile(filename, data);
|
|
129
|
+
return comment;
|
|
130
|
+
}
|
|
131
|
+
/** Mark comments as resolved (state change, NOT deletion). The records stay
|
|
132
|
+
* on disk but get filtered out of normal `getComments` listings — so the
|
|
133
|
+
* decoration disappears in the browser without losing the history. */
|
|
134
|
+
export function resolveComments(ids) {
|
|
135
|
+
const idSet = new Set(ids);
|
|
136
|
+
const resolved = [];
|
|
137
|
+
const now = new Date().toISOString();
|
|
138
|
+
ensureCommentsDir();
|
|
139
|
+
try {
|
|
140
|
+
const files = readdirSync(getCommentsDir());
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
if (!file.endsWith('.json'))
|
|
143
|
+
continue;
|
|
144
|
+
const filePath = join(getCommentsDir(), file);
|
|
145
|
+
try {
|
|
146
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
147
|
+
let changed = false;
|
|
148
|
+
for (const c of data.marks) {
|
|
149
|
+
if (idSet.has(c.id) && !isResolved(c)) {
|
|
150
|
+
c.resolvedAt = now;
|
|
151
|
+
resolved.push(c.id);
|
|
152
|
+
changed = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (changed) {
|
|
156
|
+
const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
|
|
157
|
+
writeCommentFile(docFilename, data);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* skip */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch { /* dir doesn't exist */ }
|
|
164
|
+
return resolved;
|
|
165
|
+
}
|
|
166
|
+
/** Clear the resolved state on comments. Inverse of resolveComments. */
|
|
167
|
+
export function unresolveComments(ids) {
|
|
168
|
+
const idSet = new Set(ids);
|
|
169
|
+
const cleared = [];
|
|
170
|
+
ensureCommentsDir();
|
|
171
|
+
try {
|
|
172
|
+
const files = readdirSync(getCommentsDir());
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
if (!file.endsWith('.json'))
|
|
175
|
+
continue;
|
|
176
|
+
const filePath = join(getCommentsDir(), file);
|
|
177
|
+
try {
|
|
178
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
179
|
+
let changed = false;
|
|
180
|
+
for (const c of data.marks) {
|
|
181
|
+
if (idSet.has(c.id) && isResolved(c)) {
|
|
182
|
+
delete c.resolvedAt;
|
|
183
|
+
cleared.push(c.id);
|
|
184
|
+
changed = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (changed) {
|
|
188
|
+
const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
|
|
189
|
+
writeCommentFile(docFilename, data);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch { /* skip */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch { /* dir doesn't exist */ }
|
|
196
|
+
return cleared;
|
|
197
|
+
}
|
|
198
|
+
/** Permanently remove comments from the sidecar. Distinct from resolveComments —
|
|
199
|
+
* resolve is a state change ("addressed, archive it"), delete is the destructive
|
|
200
|
+
* "this record never should have existed" path. */
|
|
201
|
+
export function deleteComments(ids) {
|
|
202
|
+
const idSet = new Set(ids);
|
|
203
|
+
const deleted = [];
|
|
204
|
+
ensureCommentsDir();
|
|
205
|
+
try {
|
|
206
|
+
const files = readdirSync(getCommentsDir());
|
|
207
|
+
for (const file of files) {
|
|
208
|
+
if (!file.endsWith('.json'))
|
|
209
|
+
continue;
|
|
210
|
+
const filePath = join(getCommentsDir(), file);
|
|
211
|
+
try {
|
|
212
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
213
|
+
const before = data.marks.length;
|
|
214
|
+
data.marks = data.marks.filter((m) => {
|
|
215
|
+
if (idSet.has(m.id)) {
|
|
216
|
+
deleted.push(m.id);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
if (data.marks.length !== before) {
|
|
222
|
+
const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
|
|
223
|
+
writeCommentFile(docFilename, data);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch { /* skip */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch { /* dir doesn't exist */ }
|
|
230
|
+
return deleted;
|
|
231
|
+
}
|
|
232
|
+
export function pruneStaleComments(filename, validNodeIds) {
|
|
233
|
+
const data = readCommentFile(filename);
|
|
234
|
+
if (data.marks.length === 0)
|
|
235
|
+
return 0;
|
|
236
|
+
const validSet = new Set(validNodeIds);
|
|
237
|
+
const before = data.marks.length;
|
|
238
|
+
data.marks = data.marks.filter((m) => {
|
|
239
|
+
if (m.nodeIds && m.nodeIds.length > 0) {
|
|
240
|
+
return m.nodeIds.some((id) => validSet.has(id));
|
|
241
|
+
}
|
|
242
|
+
return validSet.has(m.nodeId);
|
|
243
|
+
});
|
|
244
|
+
const pruned = before - data.marks.length;
|
|
245
|
+
if (pruned > 0)
|
|
246
|
+
writeCommentFile(filename, data);
|
|
247
|
+
return pruned;
|
|
248
|
+
}
|
|
249
|
+
/** Rename a comment sidecar file when a document is renamed. */
|
|
250
|
+
export function renameComments(oldFilename, newFilename) {
|
|
251
|
+
const oldPath = commentFilePath(oldFilename);
|
|
252
|
+
if (!existsSync(oldPath))
|
|
253
|
+
return;
|
|
254
|
+
const newPath = commentFilePath(newFilename);
|
|
255
|
+
renameSync(oldPath, newPath);
|
|
256
|
+
}
|
package/dist/server/documents.js
CHANGED
|
@@ -9,11 +9,12 @@ import matter from 'gray-matter';
|
|
|
9
9
|
import trash from 'trash';
|
|
10
10
|
import { tiptapToMarkdownChecked, markdownToTiptap } from './markdown.js';
|
|
11
11
|
import { parseMarkdownContent } from './compact.js';
|
|
12
|
-
import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, } from './state.js';
|
|
13
|
-
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
12
|
+
import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, markAsAgentStub, unmarkAgentStub, isAgentStub, } from './state.js';
|
|
13
|
+
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath } from './helpers.js';
|
|
14
14
|
import { ensureDocId } from './versions.js';
|
|
15
15
|
import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces } from './workspaces.js';
|
|
16
|
-
import {
|
|
16
|
+
import { renameComments } from './comments.js';
|
|
17
|
+
import { deleteOverlay } from './pending-overlay.js';
|
|
17
18
|
import { getDocId as getActiveDocId } from './state.js';
|
|
18
19
|
function getDocOrderFile() { return join(getDataDir(), '_doc-order.json'); }
|
|
19
20
|
/** Scan files for matching docId. Checks active doc first (free), then getDataDir(), then external docs. */
|
|
@@ -518,10 +519,17 @@ export function createDocumentFile(title, path, extraMeta) {
|
|
|
518
519
|
filename = filePath.split(/[/\\]/).pop();
|
|
519
520
|
}
|
|
520
521
|
const newDoc = { type: 'doc', content: [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }] };
|
|
521
|
-
|
|
522
|
+
// No `agentCreated: true` in metadata — stub status is in-memory only.
|
|
523
|
+
// adr: adr/agent-stub-model.md
|
|
524
|
+
const metadata = { title: docTitle, docId: generateNodeId(), ...extraMeta };
|
|
522
525
|
const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
|
|
523
526
|
ensureDataDir();
|
|
524
527
|
atomicWriteFileSync(filePath, markdown);
|
|
528
|
+
// Mark this filename as a fresh agent stub. Process-lifetime only — any
|
|
529
|
+
// accepted content via subsequent save graduates it out of the set, and a
|
|
530
|
+
// server restart naturally forgets stub status (a stub that survives a
|
|
531
|
+
// restart is by definition no longer fresh).
|
|
532
|
+
markAsAgentStub(filename);
|
|
525
533
|
// Prepend to doc order so new docs appear at top and stay put after edits
|
|
526
534
|
const order = readDocOrder();
|
|
527
535
|
const fn = filePath.split(/[/\\]/).pop();
|
|
@@ -536,6 +544,9 @@ export async function deleteDocument(filename) {
|
|
|
536
544
|
const targetPath = resolveDocPath(filename);
|
|
537
545
|
// Invalidate cache for deleted doc
|
|
538
546
|
invalidateDocCache(targetPath);
|
|
547
|
+
// Remove stub status for the deleted filename so a future recreate with
|
|
548
|
+
// the same name doesn't inherit the prior stub flag.
|
|
549
|
+
unmarkAgentStub(filename);
|
|
539
550
|
// Unregister if external
|
|
540
551
|
if (isExternalDoc(filename)) {
|
|
541
552
|
unregisterExternalDoc(targetPath);
|
|
@@ -545,9 +556,25 @@ export async function deleteDocument(filename) {
|
|
|
545
556
|
throw new Error('Cannot delete the only document');
|
|
546
557
|
}
|
|
547
558
|
const isDeletingActive = targetPath === getFilePath();
|
|
559
|
+
// Read docId BEFORE deleting the file so we can retire its overlay sidecar
|
|
560
|
+
// in lockstep. The sidecar's lifecycle is bound to the docId's existence in
|
|
561
|
+
// the workspace; delete retires the docId, archive does not.
|
|
562
|
+
// adr: adr/pending-overlay-model.md
|
|
563
|
+
let docIdToRetire = '';
|
|
564
|
+
if (existsSync(targetPath)) {
|
|
565
|
+
try {
|
|
566
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
567
|
+
const { data } = matter(raw);
|
|
568
|
+
if (typeof data?.docId === 'string')
|
|
569
|
+
docIdToRetire = data.docId;
|
|
570
|
+
}
|
|
571
|
+
catch { /* best-effort */ }
|
|
572
|
+
}
|
|
548
573
|
if (!isExternalDoc(filename) && existsSync(targetPath)) {
|
|
549
574
|
await trash(targetPath);
|
|
550
575
|
}
|
|
576
|
+
if (docIdToRetire)
|
|
577
|
+
deleteOverlay(docIdToRetire);
|
|
551
578
|
if (isDeletingActive) {
|
|
552
579
|
const remaining = readdirSync(getDataDir())
|
|
553
580
|
.filter((f) => f.endsWith('.md'))
|
|
@@ -594,42 +621,49 @@ export function updateDocumentTitle(filename, newTitle) {
|
|
|
594
621
|
setActiveDocument(getDocument(), newTitle, filePath, baseName.startsWith(TEMP_PREFIX), undefined, metadata);
|
|
595
622
|
}
|
|
596
623
|
}
|
|
597
|
-
/** Open an existing file from any path. Saves current doc, registers as external, sets as active.
|
|
624
|
+
/** Open an existing file from any path. Saves current doc, registers as external, sets as active.
|
|
625
|
+
*
|
|
626
|
+
* Canonicalizes the input path at the boundary so opening the same physical
|
|
627
|
+
* file via different spellings (forward/back slash, drive-letter case,
|
|
628
|
+
* symlink) hits the same doc identity — same cache slot, same watcher
|
|
629
|
+
* subscription, same pending overlay.
|
|
630
|
+
* adr: adr/path-canonicalization.md */
|
|
598
631
|
export function openFile(fullPath) {
|
|
599
632
|
if (!existsSync(fullPath)) {
|
|
600
633
|
throw new Error(`File not found: ${fullPath}`);
|
|
601
634
|
}
|
|
635
|
+
const canonPath = canonicalizePath(fullPath);
|
|
602
636
|
// Cancel any pending debounced save, then save current doc immediately
|
|
603
637
|
cancelDebouncedSave();
|
|
604
638
|
save();
|
|
605
639
|
// Cache current doc before switching
|
|
606
640
|
cacheActiveDocument();
|
|
607
641
|
// Register as external if not in getDataDir()
|
|
608
|
-
if (isExternalDoc(
|
|
609
|
-
registerExternalDoc(
|
|
642
|
+
if (isExternalDoc(canonPath)) {
|
|
643
|
+
registerExternalDoc(canonPath);
|
|
610
644
|
}
|
|
611
645
|
// Check cache first — preserves stable node IDs
|
|
612
|
-
const cached = getCachedDocument(
|
|
646
|
+
const cached = getCachedDocument(canonPath);
|
|
613
647
|
if (cached) {
|
|
614
|
-
setActiveDocument(cached.document, cached.title,
|
|
615
|
-
const filename = isExternalDoc(
|
|
648
|
+
setActiveDocument(cached.document, cached.title, canonPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
|
|
649
|
+
const filename = isExternalDoc(canonPath) ? canonPath : (canonPath.split(/[/\\]/).pop() || '');
|
|
616
650
|
return { document: getDocument(), title: getTitle(), filename };
|
|
617
651
|
}
|
|
618
|
-
const raw = readFileSync(
|
|
652
|
+
const raw = readFileSync(canonPath, 'utf-8');
|
|
619
653
|
const parsed = markdownToTiptap(raw);
|
|
620
|
-
const mtime = new Date(statSync(
|
|
654
|
+
const mtime = new Date(statSync(canonPath).mtimeMs);
|
|
621
655
|
ensureDocId(parsed.metadata);
|
|
622
656
|
// Title fallback: use filename stem instead of "Untitled" for files without a title
|
|
623
657
|
let title = parsed.title;
|
|
624
658
|
if (title === 'Untitled') {
|
|
625
|
-
const stem =
|
|
659
|
+
const stem = canonPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
|
|
626
660
|
if (stem)
|
|
627
661
|
title = stem;
|
|
628
662
|
}
|
|
629
|
-
const baseName =
|
|
630
|
-
setActiveDocument(parsed.document, title,
|
|
663
|
+
const baseName = canonPath.split(/[/\\]/).pop() || '';
|
|
664
|
+
setActiveDocument(parsed.document, title, canonPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
|
|
631
665
|
// Use full path as filename for external docs, basename for getDataDir() docs
|
|
632
|
-
const filename = isExternalDoc(
|
|
666
|
+
const filename = isExternalDoc(canonPath) ? canonPath : baseName;
|
|
633
667
|
return { document: getDocument(), title: getTitle(), filename };
|
|
634
668
|
}
|
|
635
669
|
export function duplicateDocument(filename) {
|
|
@@ -695,10 +729,18 @@ export function promoteTempFile(newTitle) {
|
|
|
695
729
|
// Invalidate old caches
|
|
696
730
|
removePendingCacheEntry(oldFilename);
|
|
697
731
|
invalidateDocCache(oldPath);
|
|
732
|
+
// Carry the agent-stub flag across the rename (if the doc was still a
|
|
733
|
+
// fresh stub when renamed — uncommon but possible). The Set is keyed by
|
|
734
|
+
// filename, so we must transfer the entry to the new key.
|
|
735
|
+
// adr: adr/agent-stub-model.md
|
|
736
|
+
if (isAgentStub(oldFilename)) {
|
|
737
|
+
unmarkAgentStub(oldFilename);
|
|
738
|
+
markAsAgentStub(newFilename);
|
|
739
|
+
}
|
|
698
740
|
// Update workspace references
|
|
699
741
|
renameDocInAllWorkspaces(oldFilename, newFilename, newTitle);
|
|
700
|
-
// Rename
|
|
701
|
-
|
|
742
|
+
// Rename comments sidecar
|
|
743
|
+
renameComments(oldFilename, newFilename);
|
|
702
744
|
return newFilename;
|
|
703
745
|
}
|
|
704
746
|
// ============================================================================
|
package/dist/server/helpers.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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, readdirSync, statSync, rmSync } from 'fs';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync, realpathSync } 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';
|
|
@@ -63,7 +63,36 @@ export function filePathForTitle(title) {
|
|
|
63
63
|
export function tempFilePath() {
|
|
64
64
|
return join(getDataDir(), `${TEMP_PREFIX}${randomUUID()}.md`);
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Produce one canonical representation per physical file. Idempotent:
|
|
68
|
+
* `canonicalizePath(canonicalizePath(p)) === canonicalizePath(p)`.
|
|
69
|
+
*
|
|
70
|
+
* On Windows: resolves separator direction (`/` vs `\`), drive-letter
|
|
71
|
+
* case (`c:` vs `C:`), 8.3 short names, and symlinks — whenever the
|
|
72
|
+
* file exists. `realpathSync.native` is the OS asking itself "what's
|
|
73
|
+
* the real path of this thing?", which is the only authoritative
|
|
74
|
+
* answer.
|
|
75
|
+
*
|
|
76
|
+
* On Unix: resolves symlinks and normalizes relative segments.
|
|
77
|
+
*
|
|
78
|
+
* Falls back to `path.resolve` (absolute path with platform separators)
|
|
79
|
+
* when the file doesn't exist yet. That's a weaker form — it won't
|
|
80
|
+
* catch drive-letter case mismatches on a path to a not-yet-created
|
|
81
|
+
* file — but every openwriter identity boundary hits an existing file,
|
|
82
|
+
* so the fallback is a safety net rather than a primary path.
|
|
83
|
+
*
|
|
84
|
+
* adr: adr/path-canonicalization.md
|
|
85
|
+
*/
|
|
86
|
+
export function canonicalizePath(p) {
|
|
87
|
+
if (!p)
|
|
88
|
+
return p;
|
|
89
|
+
try {
|
|
90
|
+
return realpathSync.native(p);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return resolve(p);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
67
96
|
/** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
|
|
68
97
|
export function resolveDocPath(filename) {
|
|
69
98
|
const dataDir = getDataDir();
|
|
@@ -78,13 +107,39 @@ export function resolveDocPath(filename) {
|
|
|
78
107
|
}
|
|
79
108
|
return resolved;
|
|
80
109
|
}
|
|
81
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Canonicalize a doc identifier that might be a bare basename (internal
|
|
112
|
+
* doc) or an absolute path (external doc). Basenames pass through
|
|
113
|
+
* untouched; absolute paths route through `canonicalizePath`. Use this
|
|
114
|
+
* at WebSocket and HTTP boundaries where browser-sent identifiers can
|
|
115
|
+
* be either form and must compare equal against server-side
|
|
116
|
+
* `getActiveFilename()` regardless of how they were spelled.
|
|
117
|
+
*
|
|
118
|
+
* adr: adr/path-canonicalization.md
|
|
119
|
+
*/
|
|
120
|
+
export function canonicalizeIdentifier(id) {
|
|
121
|
+
if (!id)
|
|
122
|
+
return id;
|
|
123
|
+
return isAbsolute(id) ? canonicalizePath(id) : id;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Returns true if filename is a full path pointing outside DATA_DIR.
|
|
127
|
+
*
|
|
128
|
+
* Canonicalizes both sides of the comparison so that mixed separators,
|
|
129
|
+
* drive-letter case, and symlink-resolved variants of the same file all
|
|
130
|
+
* classify consistently. The pre-canonicalization version compared raw
|
|
131
|
+
* strings via `startsWith`, which let `C:/Users/.../data-dir/foo.md`
|
|
132
|
+
* be classified as external on Windows because `getDataDir()` returns
|
|
133
|
+
* `C:\Users\...\data-dir` (different separators).
|
|
134
|
+
*
|
|
135
|
+
* adr: adr/path-canonicalization.md
|
|
136
|
+
*/
|
|
82
137
|
export function isExternalDoc(filename) {
|
|
83
|
-
if (isAbsolute(filename)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
138
|
+
if (!isAbsolute(filename) && !/[/\\]/.test(filename))
|
|
139
|
+
return false;
|
|
140
|
+
const canonFile = canonicalizePath(filename);
|
|
141
|
+
const canonDataDir = canonicalizePath(getDataDir());
|
|
142
|
+
return canonFile !== canonDataDir && !canonFile.startsWith(canonDataDir + sep);
|
|
88
143
|
}
|
|
89
144
|
/** Extract basename from a path, or return as-is if already a basename. */
|
|
90
145
|
export function getDocBasename(filename) {
|