openwriter 0.6.6 → 0.6.8
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-CeGtzvqW.css} +1 -1
- package/dist/client/assets/index-iGNTYLM-.js +210 -0
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +21 -6
- package/dist/server/index.js +5 -1
- 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/dist/server/update-check.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +5 -1
- package/dist/client/assets/index-CZrV9Ryb.js +0 -209
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-iGNTYLM-.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CeGtzvqW.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/server/documents.js
CHANGED
|
@@ -102,6 +102,7 @@ export function listDocuments() {
|
|
|
102
102
|
...(data.docId ? { docId: data.docId } : {}),
|
|
103
103
|
...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : data.articleContext?.lastPost?.postedAt ? { lastSent: data.articleContext.lastPost.postedAt } : data.manualPost?.postedAt ? { lastSent: data.manualPost.postedAt } : {}),
|
|
104
104
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
105
|
+
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
catch {
|
|
@@ -119,7 +120,13 @@ export function listDocuments() {
|
|
|
119
120
|
const stat = statSync(extPath);
|
|
120
121
|
const raw = readFileSync(extPath, 'utf-8');
|
|
121
122
|
const { data, content } = matter(raw);
|
|
122
|
-
|
|
123
|
+
let title = data.title || 'Untitled';
|
|
124
|
+
// Title fallback: use filename stem for external files without a title
|
|
125
|
+
if (title === 'Untitled') {
|
|
126
|
+
const stem = extPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
|
|
127
|
+
if (stem)
|
|
128
|
+
title = stem;
|
|
129
|
+
}
|
|
123
130
|
const trimmed = content.trim();
|
|
124
131
|
const wordCount = trimmed ? trimmed.split(/\s+/).length : 0;
|
|
125
132
|
files.push({
|
|
@@ -132,6 +139,7 @@ export function listDocuments() {
|
|
|
132
139
|
...(data.docId ? { docId: data.docId } : {}),
|
|
133
140
|
...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : {}),
|
|
134
141
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
142
|
+
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
135
143
|
});
|
|
136
144
|
}
|
|
137
145
|
catch { /* skip unreadable external files */ }
|
|
@@ -358,7 +366,7 @@ export function switchDocument(filename) {
|
|
|
358
366
|
// Check cache first — preserves stable node IDs across switches
|
|
359
367
|
const cached = getCachedDocument(targetPath);
|
|
360
368
|
if (cached) {
|
|
361
|
-
setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
369
|
+
setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
|
|
362
370
|
return { document: getDocument(), title: getTitle(), filename };
|
|
363
371
|
}
|
|
364
372
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
@@ -367,7 +375,7 @@ export function switchDocument(filename) {
|
|
|
367
375
|
// Ensure docId exists on loaded doc metadata (lazy migration)
|
|
368
376
|
ensureDocId(parsed.metadata);
|
|
369
377
|
const baseName = targetPath.split(/[/\\]/).pop() || '';
|
|
370
|
-
setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
|
|
378
|
+
setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
|
|
371
379
|
return { document: getDocument(), title: getTitle(), filename };
|
|
372
380
|
}
|
|
373
381
|
export function createDocument(title, content, path) {
|
|
@@ -486,7 +494,7 @@ export async function deleteDocument(filename) {
|
|
|
486
494
|
throw new Error('Cannot delete the only document');
|
|
487
495
|
}
|
|
488
496
|
const isDeletingActive = targetPath === getFilePath();
|
|
489
|
-
if (existsSync(targetPath)) {
|
|
497
|
+
if (!isExternalDoc(filename) && existsSync(targetPath)) {
|
|
490
498
|
await trash(targetPath);
|
|
491
499
|
}
|
|
492
500
|
if (isDeletingActive) {
|
|
@@ -552,7 +560,7 @@ export function openFile(fullPath) {
|
|
|
552
560
|
// Check cache first — preserves stable node IDs
|
|
553
561
|
const cached = getCachedDocument(fullPath);
|
|
554
562
|
if (cached) {
|
|
555
|
-
setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
563
|
+
setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
|
|
556
564
|
const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
|
|
557
565
|
return { document: getDocument(), title: getTitle(), filename };
|
|
558
566
|
}
|
|
@@ -560,8 +568,15 @@ export function openFile(fullPath) {
|
|
|
560
568
|
const parsed = markdownToTiptap(raw);
|
|
561
569
|
const mtime = new Date(statSync(fullPath).mtimeMs);
|
|
562
570
|
ensureDocId(parsed.metadata);
|
|
571
|
+
// Title fallback: use filename stem instead of "Untitled" for files without a title
|
|
572
|
+
let title = parsed.title;
|
|
573
|
+
if (title === 'Untitled') {
|
|
574
|
+
const stem = fullPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
|
|
575
|
+
if (stem)
|
|
576
|
+
title = stem;
|
|
577
|
+
}
|
|
563
578
|
const baseName = fullPath.split(/[/\\]/).pop() || '';
|
|
564
|
-
setActiveDocument(parsed.document,
|
|
579
|
+
setActiveDocument(parsed.document, title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
|
|
565
580
|
// Use full path as filename for external docs, basename for getDataDir() docs
|
|
566
581
|
const filename = isExternalDoc(fullPath) ? fullPath : baseName;
|
|
567
582
|
return { document: getDocument(), title: getTitle(), filename };
|
package/dist/server/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import { createSchedulerRouter } from './scheduler-routes.js';
|
|
|
30
30
|
import { createBlogRouter } from './blog-routes.js';
|
|
31
31
|
import { platformFetch, isAuthenticated } from './connections.js';
|
|
32
32
|
import { PluginManager } from './plugin-manager.js';
|
|
33
|
-
import { checkForUpdate } from './update-check.js';
|
|
33
|
+
import { checkForUpdate, getUpdateInfo, getCurrentVersion } from './update-check.js';
|
|
34
34
|
import { addMark, getMarks, resolveMarks } from './marks.js';
|
|
35
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
36
36
|
const __dirname = dirname(__filename);
|
|
@@ -74,6 +74,10 @@ export async function startHttpServer(options = {}) {
|
|
|
74
74
|
res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
|
|
75
75
|
}
|
|
76
76
|
});
|
|
77
|
+
app.get('/api/update-info', (_req, res) => {
|
|
78
|
+
const latestVersion = getUpdateInfo();
|
|
79
|
+
res.json({ updateAvailable: latestVersion, currentVersion: getCurrentVersion() });
|
|
80
|
+
});
|
|
77
81
|
app.get('/api/document', (_req, res) => {
|
|
78
82
|
res.json({ document: getDocument(), title: getTitle(), metadata: getMetadata() });
|
|
79
83
|
});
|
|
@@ -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
|
}
|
|
@@ -28,7 +28,7 @@ export function compareVersions(a, b) {
|
|
|
28
28
|
return 0;
|
|
29
29
|
}
|
|
30
30
|
/** Read current package version from package.json on disk. */
|
|
31
|
-
function getCurrentVersion() {
|
|
31
|
+
export function getCurrentVersion() {
|
|
32
32
|
try {
|
|
33
33
|
const pkgPath = join(__dirname, '../../package.json');
|
|
34
34
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
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",
|
package/skill/SKILL.md
CHANGED
|
@@ -15,7 +15,7 @@ description: |
|
|
|
15
15
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
16
16
|
metadata:
|
|
17
17
|
author: travsteward
|
|
18
|
-
version: "0.2.
|
|
18
|
+
version: "0.2.5"
|
|
19
19
|
repository: https://github.com/travsteward/openwriter
|
|
20
20
|
license: MIT
|
|
21
21
|
---
|
|
@@ -528,6 +528,10 @@ Requires authentication via `request_login_code` + `verify_login`. All publish t
|
|
|
528
528
|
→ returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
|
|
529
529
|
```
|
|
530
530
|
|
|
531
|
+
## Author's Voice Plugin
|
|
532
|
+
|
|
533
|
+
When the user enables the Author's Voice plugin in Settings, install the skill — see [authors-voice.com](https://www.authors-voice.com) for install methods. The skill handles API key setup and everything else.
|
|
534
|
+
|
|
531
535
|
## Troubleshooting
|
|
532
536
|
|
|
533
537
|
**MCP tools not available** — The OpenWriter MCP server isn't configured yet. Follow the [setup instructions](#mcp-tools-are-not-available-skill-first-install) above. After adding the MCP config, the user must restart their Claude Code session.
|