openwriter 0.6.6 → 0.6.7
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-BuXVL8Bc.css → index-BDSezXwn.css} +1 -1
- package/dist/client/assets/{index-CZrV9Ryb.js → index-DxAyyL3w.js} +45 -45
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +19 -6
- package/dist/server/markdown-parse.js +3 -2
- package/dist/server/markdown-serialize.js +4 -0
- package/dist/server/markdown.js +1 -1
- package/dist/server/mcp.js +70 -38
- package/dist/server/state.js +155 -8
- package/package.json +1 -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-DxAyyL3w.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BDSezXwn.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/server/documents.js
CHANGED
|
@@ -119,7 +119,13 @@ export function listDocuments() {
|
|
|
119
119
|
const stat = statSync(extPath);
|
|
120
120
|
const raw = readFileSync(extPath, 'utf-8');
|
|
121
121
|
const { data, content } = matter(raw);
|
|
122
|
-
|
|
122
|
+
let title = data.title || 'Untitled';
|
|
123
|
+
// Title fallback: use filename stem for external files without a title
|
|
124
|
+
if (title === 'Untitled') {
|
|
125
|
+
const stem = extPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
|
|
126
|
+
if (stem)
|
|
127
|
+
title = stem;
|
|
128
|
+
}
|
|
123
129
|
const trimmed = content.trim();
|
|
124
130
|
const wordCount = trimmed ? trimmed.split(/\s+/).length : 0;
|
|
125
131
|
files.push({
|
|
@@ -358,7 +364,7 @@ export function switchDocument(filename) {
|
|
|
358
364
|
// Check cache first — preserves stable node IDs across switches
|
|
359
365
|
const cached = getCachedDocument(targetPath);
|
|
360
366
|
if (cached) {
|
|
361
|
-
setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
367
|
+
setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
|
|
362
368
|
return { document: getDocument(), title: getTitle(), filename };
|
|
363
369
|
}
|
|
364
370
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
@@ -367,7 +373,7 @@ export function switchDocument(filename) {
|
|
|
367
373
|
// Ensure docId exists on loaded doc metadata (lazy migration)
|
|
368
374
|
ensureDocId(parsed.metadata);
|
|
369
375
|
const baseName = targetPath.split(/[/\\]/).pop() || '';
|
|
370
|
-
setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
|
|
376
|
+
setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
|
|
371
377
|
return { document: getDocument(), title: getTitle(), filename };
|
|
372
378
|
}
|
|
373
379
|
export function createDocument(title, content, path) {
|
|
@@ -486,7 +492,7 @@ export async function deleteDocument(filename) {
|
|
|
486
492
|
throw new Error('Cannot delete the only document');
|
|
487
493
|
}
|
|
488
494
|
const isDeletingActive = targetPath === getFilePath();
|
|
489
|
-
if (existsSync(targetPath)) {
|
|
495
|
+
if (!isExternalDoc(filename) && existsSync(targetPath)) {
|
|
490
496
|
await trash(targetPath);
|
|
491
497
|
}
|
|
492
498
|
if (isDeletingActive) {
|
|
@@ -552,7 +558,7 @@ export function openFile(fullPath) {
|
|
|
552
558
|
// Check cache first — preserves stable node IDs
|
|
553
559
|
const cached = getCachedDocument(fullPath);
|
|
554
560
|
if (cached) {
|
|
555
|
-
setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
561
|
+
setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
|
|
556
562
|
const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
|
|
557
563
|
return { document: getDocument(), title: getTitle(), filename };
|
|
558
564
|
}
|
|
@@ -560,8 +566,15 @@ export function openFile(fullPath) {
|
|
|
560
566
|
const parsed = markdownToTiptap(raw);
|
|
561
567
|
const mtime = new Date(statSync(fullPath).mtimeMs);
|
|
562
568
|
ensureDocId(parsed.metadata);
|
|
569
|
+
// Title fallback: use filename stem instead of "Untitled" for files without a title
|
|
570
|
+
let title = parsed.title;
|
|
571
|
+
if (title === 'Untitled') {
|
|
572
|
+
const stem = fullPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
|
|
573
|
+
if (stem)
|
|
574
|
+
title = stem;
|
|
575
|
+
}
|
|
563
576
|
const baseName = fullPath.split(/[/\\]/).pop() || '';
|
|
564
|
-
setActiveDocument(parsed.document,
|
|
577
|
+
setActiveDocument(parsed.document, title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
|
|
565
578
|
// Use full path as filename for external docs, basename for getDataDir() docs
|
|
566
579
|
const filename = isExternalDoc(fullPath) ? fullPath : baseName;
|
|
567
580
|
return { document: getDocument(), title: getTitle(), filename };
|
|
@@ -20,7 +20,8 @@ md.use(markdownItMark);
|
|
|
20
20
|
md.use(markdownItSub);
|
|
21
21
|
md.use(markdownItSup);
|
|
22
22
|
export function markdownToTiptap(markdown) {
|
|
23
|
-
const
|
|
23
|
+
const result = matter(markdown);
|
|
24
|
+
const { data, content } = result;
|
|
24
25
|
const title = data.title || 'Untitled';
|
|
25
26
|
const tokens = md.parse(content, {});
|
|
26
27
|
const docContent = tokensToTiptap(tokens);
|
|
@@ -35,7 +36,7 @@ export function markdownToTiptap(markdown) {
|
|
|
35
36
|
// Strip pending from returned metadata (consumed into node attrs)
|
|
36
37
|
const metadata = { ...data };
|
|
37
38
|
delete metadata.pending;
|
|
38
|
-
return { title, metadata, document: doc };
|
|
39
|
+
return { title, metadata, document: doc, rawFrontmatter: result.matter || null };
|
|
39
40
|
}
|
|
40
41
|
/**
|
|
41
42
|
* Rehydrate pending state from frontmatter into leaf block node attrs.
|
|
@@ -82,6 +82,10 @@ export function tiptapToMarkdown(doc, title, metadata) {
|
|
|
82
82
|
const body = nodesToMarkdown(doc.content || []);
|
|
83
83
|
return frontmatter + body;
|
|
84
84
|
}
|
|
85
|
+
/** Convert TipTap document to markdown body only (no frontmatter). */
|
|
86
|
+
export function tiptapToBody(doc) {
|
|
87
|
+
return nodesToMarkdown(doc.content || []);
|
|
88
|
+
}
|
|
85
89
|
function nodesToMarkdown(nodes) {
|
|
86
90
|
let result = '';
|
|
87
91
|
for (const node of nodes) {
|
package/dist/server/markdown.js
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* Barrel re-export for markdown serialization and parsing.
|
|
3
3
|
* All existing imports from './markdown.js' continue to work unchanged.
|
|
4
4
|
*/
|
|
5
|
-
export { tiptapToMarkdown, nodeText, inlineToMarkdown } from './markdown-serialize.js';
|
|
5
|
+
export { tiptapToMarkdown, tiptapToBody, nodeText, inlineToMarkdown } from './markdown-serialize.js';
|
|
6
6
|
export { markdownToTiptap, markdownToNodes } from './markdown-parse.js';
|
package/dist/server/mcp.js
CHANGED
|
@@ -9,7 +9,7 @@ import { randomUUID } from 'crypto';
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import { getDataDir, ensureDataDir, resolveDocPath } from './helpers.js';
|
|
12
|
+
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId } from './helpers.js';
|
|
13
13
|
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, } from './state.js';
|
|
14
14
|
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
|
|
15
15
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
@@ -826,37 +826,66 @@ export const TOOL_REGISTRY = [
|
|
|
826
826
|
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
827
827
|
}
|
|
828
828
|
const filename = resolveDocId(docId);
|
|
829
|
-
// Generate image via Gemini
|
|
830
|
-
const { GoogleGenAI } = await import('@google/genai');
|
|
831
|
-
const ai = new GoogleGenAI({ apiKey });
|
|
832
|
-
const response = await ai.models.generateContent({
|
|
833
|
-
model: 'gemini-3.1-flash-image-preview',
|
|
834
|
-
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
835
|
-
config: {
|
|
836
|
-
responseModalities: ['IMAGE'],
|
|
837
|
-
},
|
|
838
|
-
});
|
|
839
|
-
const parts = response.candidates?.[0]?.content?.parts;
|
|
840
|
-
const imagePart = parts?.find((p) => p.inlineData);
|
|
841
|
-
if (!imagePart?.inlineData?.data) {
|
|
842
|
-
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
843
|
-
}
|
|
844
|
-
// Save to ~/.openwriter/_images/
|
|
845
|
-
ensureDataDir();
|
|
846
|
-
const imagesDir = join(getDataDir(), '_images');
|
|
847
|
-
if (!existsSync(imagesDir))
|
|
848
|
-
mkdirSync(imagesDir, { recursive: true });
|
|
849
|
-
const imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
850
|
-
const filePath = join(imagesDir, imgFilename);
|
|
851
|
-
writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
852
|
-
const src = `/_images/${imgFilename}`;
|
|
853
|
-
// Build image node and insert change
|
|
854
|
-
const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
|
|
855
|
-
const change = { operation: 'insert', afterNodeId, content: [imageNode] };
|
|
856
829
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
830
|
+
// Phase 1: Insert imageLoading placeholder immediately (active doc only)
|
|
831
|
+
const loadingNodeId = generateNodeId();
|
|
832
|
+
if (!targetIsNonActive) {
|
|
833
|
+
const loadingChange = {
|
|
834
|
+
operation: 'insert',
|
|
835
|
+
afterNodeId,
|
|
836
|
+
content: [{ type: 'imageLoading', attrs: { id: loadingNodeId } }],
|
|
837
|
+
};
|
|
838
|
+
applyChanges([loadingChange]);
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
// Generate image via Gemini
|
|
842
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
843
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
844
|
+
const response = await ai.models.generateContent({
|
|
845
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
846
|
+
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
847
|
+
config: {
|
|
848
|
+
responseModalities: ['IMAGE'],
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
852
|
+
const imagePart = parts?.find((p) => p.inlineData);
|
|
853
|
+
if (!imagePart?.inlineData?.data) {
|
|
854
|
+
// Remove placeholder on failure
|
|
855
|
+
if (!targetIsNonActive) {
|
|
856
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
857
|
+
}
|
|
858
|
+
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
859
|
+
}
|
|
860
|
+
// Save to ~/.openwriter/_images/
|
|
861
|
+
ensureDataDir();
|
|
862
|
+
const imagesDir = join(getDataDir(), '_images');
|
|
863
|
+
if (!existsSync(imagesDir))
|
|
864
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
865
|
+
const imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
866
|
+
const filePath = join(imagesDir, imgFilename);
|
|
867
|
+
writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
868
|
+
const src = `/_images/${imgFilename}`;
|
|
869
|
+
// Phase 2: Replace placeholder with real image (or direct insert for non-active)
|
|
870
|
+
if (targetIsNonActive) {
|
|
871
|
+
const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
|
|
872
|
+
const change = { operation: 'insert', afterNodeId, content: [imageNode] };
|
|
873
|
+
const { lastNodeId } = applyChangesToFile(filename, [change]);
|
|
874
|
+
broadcastPendingDocsChanged();
|
|
875
|
+
return {
|
|
876
|
+
content: [{
|
|
877
|
+
type: 'text',
|
|
878
|
+
text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
|
|
879
|
+
}],
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
|
|
883
|
+
const rewriteChange = {
|
|
884
|
+
operation: 'rewrite',
|
|
885
|
+
nodeId: loadingNodeId,
|
|
886
|
+
content: [imageNode],
|
|
887
|
+
};
|
|
888
|
+
const { lastNodeId } = applyChanges([rewriteChange]);
|
|
860
889
|
return {
|
|
861
890
|
content: [{
|
|
862
891
|
type: 'text',
|
|
@@ -864,13 +893,16 @@ export const TOOL_REGISTRY = [
|
|
|
864
893
|
}],
|
|
865
894
|
};
|
|
866
895
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
}
|
|
873
|
-
|
|
896
|
+
catch (err) {
|
|
897
|
+
// Remove placeholder on error
|
|
898
|
+
if (!targetIsNonActive) {
|
|
899
|
+
try {
|
|
900
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
901
|
+
}
|
|
902
|
+
catch { }
|
|
903
|
+
}
|
|
904
|
+
return { content: [{ type: 'text', text: `Error: ${err.message || 'Image generation failed.'}` }] };
|
|
905
|
+
}
|
|
874
906
|
},
|
|
875
907
|
},
|
|
876
908
|
{
|
package/dist/server/state.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
|
-
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
9
|
+
import { tiptapToMarkdown, tiptapToBody, markdownToTiptap } from './markdown.js';
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
11
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
@@ -22,6 +22,7 @@ let state = {
|
|
|
22
22
|
isTemp: true,
|
|
23
23
|
lastModified: new Date(),
|
|
24
24
|
docId: '',
|
|
25
|
+
originalFrontmatter: null,
|
|
25
26
|
};
|
|
26
27
|
const listeners = new Set();
|
|
27
28
|
// ============================================================================
|
|
@@ -104,6 +105,105 @@ function extractText(nodes) {
|
|
|
104
105
|
})
|
|
105
106
|
.join('\n');
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Compute linear text from a node's inline content array.
|
|
110
|
+
* Matches the frontend's mapTextOffsetToPos: text chars + hardBreak=1 char.
|
|
111
|
+
*/
|
|
112
|
+
function linearText(content) {
|
|
113
|
+
if (!content)
|
|
114
|
+
return '';
|
|
115
|
+
let out = '';
|
|
116
|
+
for (const child of content) {
|
|
117
|
+
if (child.type === 'text' && typeof child.text === 'string')
|
|
118
|
+
out += child.text;
|
|
119
|
+
else if (child.type === 'hardBreak')
|
|
120
|
+
out += '\n';
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Tokenize text into words with character offsets.
|
|
126
|
+
*/
|
|
127
|
+
function tokenize(text) {
|
|
128
|
+
const words = [];
|
|
129
|
+
const re = /\S+/g;
|
|
130
|
+
let match;
|
|
131
|
+
while ((match = re.exec(text))) {
|
|
132
|
+
words.push({ word: match[0], start: match.index, end: match.index + match[0].length });
|
|
133
|
+
}
|
|
134
|
+
return words;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Compute sub-node selection range by word-level diff.
|
|
138
|
+
* Finds first and last differing words → decoration spans that range.
|
|
139
|
+
*/
|
|
140
|
+
function computePartialRange(origContent, newContent) {
|
|
141
|
+
const origText = linearText(origContent || []);
|
|
142
|
+
const newText = linearText(newContent || []);
|
|
143
|
+
if (!origText || !newText || origText === newText)
|
|
144
|
+
return null;
|
|
145
|
+
const origWords = tokenize(origText);
|
|
146
|
+
const newWords = tokenize(newText);
|
|
147
|
+
if (origWords.length === 0 || newWords.length === 0)
|
|
148
|
+
return null;
|
|
149
|
+
// First differing word from start
|
|
150
|
+
let firstDiff = 0;
|
|
151
|
+
while (firstDiff < origWords.length && firstDiff < newWords.length &&
|
|
152
|
+
origWords[firstDiff].word === newWords[firstDiff].word)
|
|
153
|
+
firstDiff++;
|
|
154
|
+
// All words identical (shouldn't happen since text differs, but guard)
|
|
155
|
+
if (firstDiff === origWords.length && firstDiff === newWords.length)
|
|
156
|
+
return null;
|
|
157
|
+
// Last differing word from end
|
|
158
|
+
let origEnd = origWords.length - 1;
|
|
159
|
+
let newEnd = newWords.length - 1;
|
|
160
|
+
while (origEnd >= firstDiff && newEnd >= firstDiff &&
|
|
161
|
+
origWords[origEnd].word === newWords[newEnd].word) {
|
|
162
|
+
origEnd--;
|
|
163
|
+
newEnd--;
|
|
164
|
+
}
|
|
165
|
+
// No common words at start or end → full rewrite, skip partial
|
|
166
|
+
if (firstDiff === 0 && origEnd === origWords.length - 1 && newEnd === newWords.length - 1)
|
|
167
|
+
return null;
|
|
168
|
+
// Raw character offsets from first/last changed words
|
|
169
|
+
let origFrom = firstDiff < origWords.length ? origWords[firstDiff].start : origText.length;
|
|
170
|
+
let origTo = origEnd >= firstDiff && origEnd < origWords.length ? origWords[origEnd].end : origFrom;
|
|
171
|
+
let newFrom = firstDiff < newWords.length ? newWords[firstDiff].start : newText.length;
|
|
172
|
+
let newTo = newEnd >= firstDiff && newEnd < newWords.length ? newWords[newEnd].end : newFrom;
|
|
173
|
+
// Snap start back to previous sentence boundary (after ". ")
|
|
174
|
+
const snapBack = (text, pos) => {
|
|
175
|
+
let i = pos - 1;
|
|
176
|
+
while (i > 0) {
|
|
177
|
+
if (text[i] === '.' && i + 1 < text.length && text[i + 1] === ' ')
|
|
178
|
+
return i + 2;
|
|
179
|
+
i--;
|
|
180
|
+
}
|
|
181
|
+
return 0; // No period found → start of text
|
|
182
|
+
};
|
|
183
|
+
// Snap end forward to next sentence boundary (the ". " or end of text)
|
|
184
|
+
const snapForward = (text, pos) => {
|
|
185
|
+
let i = pos;
|
|
186
|
+
while (i < text.length) {
|
|
187
|
+
if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' '))
|
|
188
|
+
return i + 1;
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
return text.length;
|
|
192
|
+
};
|
|
193
|
+
origFrom = snapBack(origText, origFrom);
|
|
194
|
+
origTo = snapForward(origText, origTo);
|
|
195
|
+
newFrom = snapBack(newText, newFrom);
|
|
196
|
+
newTo = snapForward(newText, newTo);
|
|
197
|
+
// If almost everything changed, full-node decoration
|
|
198
|
+
if ((origTo - origFrom + newTo - newFrom) >= (origText.length + newText.length) * 0.8)
|
|
199
|
+
return null;
|
|
200
|
+
return {
|
|
201
|
+
selectionFrom: newFrom,
|
|
202
|
+
selectionTo: newTo,
|
|
203
|
+
originalFrom: origFrom,
|
|
204
|
+
originalTo: origTo,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
107
207
|
export function getWordCount() {
|
|
108
208
|
const text = getPlainText();
|
|
109
209
|
return text.trim() ? text.trim().split(/\s+/).length : 0;
|
|
@@ -419,6 +519,12 @@ function applyChangesToDoc(doc, changes) {
|
|
|
419
519
|
const isEmptyNode = !originalText.trim();
|
|
420
520
|
// Only store original on first rewrite (preserve baseline for reject)
|
|
421
521
|
const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
|
|
522
|
+
// Detect partial change: if only a sub-range of the node text changed,
|
|
523
|
+
// attach selection range attrs so the frontend decorates only that part
|
|
524
|
+
let partialRange = null;
|
|
525
|
+
if (!isEmptyNode && contentArray.length === 1) {
|
|
526
|
+
partialRange = computePartialRange(originalNode.content || [], contentArray[0].content || []);
|
|
527
|
+
}
|
|
422
528
|
// First node replaces the target (rewrite or insert if empty)
|
|
423
529
|
const firstNode = {
|
|
424
530
|
...contentArray[0],
|
|
@@ -427,6 +533,12 @@ function applyChangesToDoc(doc, changes) {
|
|
|
427
533
|
id: change.nodeId,
|
|
428
534
|
pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
|
|
429
535
|
...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
|
|
536
|
+
...(partialRange ? {
|
|
537
|
+
pendingSelectionFrom: partialRange.selectionFrom,
|
|
538
|
+
pendingSelectionTo: partialRange.selectionTo,
|
|
539
|
+
pendingOriginalFrom: partialRange.originalFrom,
|
|
540
|
+
pendingOriginalTo: partialRange.originalTo,
|
|
541
|
+
} : {}),
|
|
430
542
|
},
|
|
431
543
|
};
|
|
432
544
|
// Additional nodes get inserted after as pending inserts
|
|
@@ -533,7 +645,7 @@ export function applyTextEdits(nodeId, edits) {
|
|
|
533
645
|
return { success: true };
|
|
534
646
|
}
|
|
535
647
|
/** Set the active document state. Used by documents.ts for multi-doc operations. */
|
|
536
|
-
export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata) {
|
|
648
|
+
export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
|
|
537
649
|
state.document = doc;
|
|
538
650
|
state.title = title;
|
|
539
651
|
state.metadata = metadata || { title };
|
|
@@ -541,6 +653,7 @@ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, me
|
|
|
541
653
|
state.isTemp = isTemp;
|
|
542
654
|
state.lastModified = lastModified || new Date();
|
|
543
655
|
state.docId = ensureDocId(state.metadata);
|
|
656
|
+
state.originalFrontmatter = originalFrontmatter ?? null;
|
|
544
657
|
}
|
|
545
658
|
// ============================================================================
|
|
546
659
|
// PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
|
|
@@ -628,6 +741,7 @@ export function cacheActiveDocument() {
|
|
|
628
741
|
lastModified: state.lastModified,
|
|
629
742
|
docId: state.docId,
|
|
630
743
|
fileMtime,
|
|
744
|
+
originalFrontmatter: state.originalFrontmatter,
|
|
631
745
|
});
|
|
632
746
|
}
|
|
633
747
|
/** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
|
|
@@ -661,6 +775,8 @@ function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
|
661
775
|
fileMtime = statSync(filePath).mtimeMs;
|
|
662
776
|
}
|
|
663
777
|
catch { /* best-effort */ }
|
|
778
|
+
// Preserve originalFrontmatter from existing cache entry (if any)
|
|
779
|
+
const existing = docCache.get(filePath);
|
|
664
780
|
docCache.set(filePath, {
|
|
665
781
|
document: structuredClone(doc),
|
|
666
782
|
metadata: structuredClone(metadata),
|
|
@@ -669,6 +785,7 @@ function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
|
669
785
|
lastModified: new Date(),
|
|
670
786
|
docId,
|
|
671
787
|
fileMtime,
|
|
788
|
+
originalFrontmatter: existing?.originalFrontmatter ?? null,
|
|
672
789
|
});
|
|
673
790
|
}
|
|
674
791
|
/** Reset all in-memory caches. Called on profile switch. */
|
|
@@ -684,6 +801,7 @@ export function clearAllCaches() {
|
|
|
684
801
|
isTemp: true,
|
|
685
802
|
lastModified: new Date(),
|
|
686
803
|
docId: '',
|
|
804
|
+
originalFrontmatter: null,
|
|
687
805
|
};
|
|
688
806
|
}
|
|
689
807
|
// ============================================================================
|
|
@@ -772,7 +890,17 @@ export function getPendingDocInfo() {
|
|
|
772
890
|
// ============================================================================
|
|
773
891
|
function writeToDisk() {
|
|
774
892
|
ensureDataDir();
|
|
775
|
-
|
|
893
|
+
let markdown;
|
|
894
|
+
if (isExternalDoc(state.filePath)) {
|
|
895
|
+
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
|
|
896
|
+
const body = tiptapToBody(state.document).replace(/(?:\s*<!-- -->\s*)+$/, '\n');
|
|
897
|
+
markdown = state.originalFrontmatter
|
|
898
|
+
? `---\n${state.originalFrontmatter}\n---\n\n${body}`
|
|
899
|
+
: body;
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
markdown = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
903
|
+
}
|
|
776
904
|
if (existsSync(state.filePath)) {
|
|
777
905
|
// Skip write if content is identical (prevents phantom git changes on doc switch)
|
|
778
906
|
try {
|
|
@@ -1048,9 +1176,9 @@ export function addDocTag(filename, tag) {
|
|
|
1048
1176
|
}
|
|
1049
1177
|
}
|
|
1050
1178
|
else {
|
|
1051
|
-
// Non-active doc — read/write disk
|
|
1179
|
+
// Non-active doc — read/write disk (skip external files: tags are OpenWriter metadata)
|
|
1052
1180
|
const targetPath = resolveDocPath(filename);
|
|
1053
|
-
if (!existsSync(targetPath))
|
|
1181
|
+
if (isExternalDoc(targetPath) || !existsSync(targetPath))
|
|
1054
1182
|
return;
|
|
1055
1183
|
try {
|
|
1056
1184
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
@@ -1088,8 +1216,9 @@ export function removeDocTag(filename, tag) {
|
|
|
1088
1216
|
}
|
|
1089
1217
|
}
|
|
1090
1218
|
else {
|
|
1219
|
+
// Non-active doc — read/write disk (skip external files: tags are OpenWriter metadata)
|
|
1091
1220
|
const targetPath = resolveDocPath(filename);
|
|
1092
|
-
if (!existsSync(targetPath))
|
|
1221
|
+
if (isExternalDoc(targetPath) || !existsSync(targetPath))
|
|
1093
1222
|
return;
|
|
1094
1223
|
try {
|
|
1095
1224
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
@@ -1127,7 +1256,16 @@ export function saveDocToFile(filename, doc) {
|
|
|
1127
1256
|
if (hasPendingChanges(parsed.document)) {
|
|
1128
1257
|
transferPendingAttrs(parsed.document, doc);
|
|
1129
1258
|
}
|
|
1130
|
-
|
|
1259
|
+
let markdown;
|
|
1260
|
+
if (isExternalDoc(targetPath)) {
|
|
1261
|
+
const body = tiptapToBody(doc);
|
|
1262
|
+
markdown = parsed.rawFrontmatter
|
|
1263
|
+
? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
|
|
1264
|
+
: body;
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
1268
|
+
}
|
|
1131
1269
|
atomicWriteFileSync(targetPath, markdown);
|
|
1132
1270
|
}
|
|
1133
1271
|
catch { /* best-effort */ }
|
|
@@ -1161,7 +1299,16 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
1161
1299
|
if (clearAgentCreated && parsed.metadata.agentCreated) {
|
|
1162
1300
|
delete parsed.metadata.agentCreated;
|
|
1163
1301
|
}
|
|
1164
|
-
|
|
1302
|
+
let markdown;
|
|
1303
|
+
if (isExternalDoc(targetPath)) {
|
|
1304
|
+
const body = tiptapToBody(parsed.document);
|
|
1305
|
+
markdown = parsed.rawFrontmatter
|
|
1306
|
+
? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
|
|
1307
|
+
: body;
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
1311
|
+
}
|
|
1165
1312
|
atomicWriteFileSync(targetPath, markdown);
|
|
1166
1313
|
removePendingCacheEntry(filename);
|
|
1167
1314
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|