openwriter 0.9.2 → 0.10.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-BbzNoMAw.css → index-CuPYxtxy.css} +1 -1
- package/dist/client/assets/index-deMuWDiP.js +211 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.js +13 -11
- package/dist/plugins/publish/dist/helpers.d.ts +12 -0
- package/dist/plugins/publish/dist/index.js +138 -0
- package/dist/plugins/x-api/dist/index.js +23 -2
- package/dist/server/documents.js +6 -0
- package/dist/server/image-upload.js +35 -0
- package/dist/server/index.js +18 -3
- package/dist/server/marks.js +9 -2
- package/dist/server/state.js +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +21 -12
- package/dist/client/assets/index-BHiZqytt.js +0 -210
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-deMuWDiP.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CuPYxtxy.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -189,16 +189,18 @@ const plugin = {
|
|
|
189
189
|
{ label: 'Fill sentence', action: 'av:fill-sentence', condition: 'empty-node' },
|
|
190
190
|
];
|
|
191
191
|
},
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
},
|
|
192
|
+
// Sidebar transforms disabled — now handled by publish plugin.
|
|
193
|
+
// Kept commented for reference during transition.
|
|
194
|
+
// sidebarMenuItems() {
|
|
195
|
+
// return [
|
|
196
|
+
// { label: 'Vary', action: 'voice:vary', promptForFocus: true },
|
|
197
|
+
// { label: 'Shrinkify', action: 'voice:shrinkify', promptForFocus: true },
|
|
198
|
+
// { label: 'Expandify', action: 'voice:expandify', promptForFocus: true },
|
|
199
|
+
// { label: 'Threadify', action: 'voice:threadify', promptForFocus: true },
|
|
200
|
+
// { label: 'Storify', action: 'voice:storify', promptForFocus: true },
|
|
201
|
+
// { label: 'Emailify', action: 'voice:emailify', promptForFocus: true },
|
|
202
|
+
// { label: 'Postify', action: 'voice:postify', promptForFocus: true },
|
|
203
|
+
// ];
|
|
204
|
+
// },
|
|
203
205
|
};
|
|
204
206
|
export default plugin;
|
|
@@ -22,13 +22,25 @@ export interface PluginMcpTool {
|
|
|
22
22
|
inputSchema: Record<string, unknown>;
|
|
23
23
|
handler: (params: Record<string, unknown>) => Promise<unknown>;
|
|
24
24
|
}
|
|
25
|
+
export interface PluginRouteContext {
|
|
26
|
+
app: import('express').Router;
|
|
27
|
+
config: Record<string, string>;
|
|
28
|
+
dataDir: string;
|
|
29
|
+
}
|
|
30
|
+
export interface PluginSidebarMenuItem {
|
|
31
|
+
label: string;
|
|
32
|
+
action: string;
|
|
33
|
+
promptForFocus?: boolean;
|
|
34
|
+
}
|
|
25
35
|
export interface OpenWriterPlugin {
|
|
26
36
|
name: string;
|
|
27
37
|
version: string;
|
|
28
38
|
description?: string;
|
|
29
39
|
category?: 'writing' | 'social-media' | 'image-generation' | 'publishing' | 'productivity' | 'analytics';
|
|
30
40
|
configSchema?: Record<string, PluginConfigField>;
|
|
41
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
31
42
|
mcpTools?(config: Record<string, string>): PluginMcpTool[];
|
|
43
|
+
sidebarMenuItems?(): PluginSidebarMenuItem[];
|
|
32
44
|
}
|
|
33
45
|
export declare const md: MarkdownIt;
|
|
34
46
|
/** Strip YAML frontmatter and TipTap empty markers from markdown output */
|
|
@@ -2,6 +2,41 @@ import { getServerModules, publishFetch } from './helpers.js';
|
|
|
2
2
|
import { newsletterTools } from './newsletter-tools.js';
|
|
3
3
|
import { readFileSync, existsSync } from 'fs';
|
|
4
4
|
import { join, extname } from 'path';
|
|
5
|
+
/** Extract docId from raw markdown frontmatter (JSON or YAML) */
|
|
6
|
+
function extractDocId(rawContent) {
|
|
7
|
+
// JSON frontmatter: "docId":"abc123"
|
|
8
|
+
const jsonMatch = rawContent.match(/"docId"\s*:\s*"([^"]+)"/);
|
|
9
|
+
if (jsonMatch)
|
|
10
|
+
return jsonMatch[1];
|
|
11
|
+
// YAML frontmatter: docId: abc123
|
|
12
|
+
const yamlMatch = rawContent.match(/^docId:\s*["']?(\S+?)["']?\s*$/m);
|
|
13
|
+
if (yamlMatch)
|
|
14
|
+
return yamlMatch[1];
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
/** Map transform action to variant content type */
|
|
18
|
+
const ACTION_VARIANT_TYPE = {
|
|
19
|
+
vary: 'document',
|
|
20
|
+
shrinkify: 'document',
|
|
21
|
+
expandify: 'document',
|
|
22
|
+
threadify: 'tweet',
|
|
23
|
+
storify: 'document',
|
|
24
|
+
emailify: 'newsletter',
|
|
25
|
+
postify: 'tweet',
|
|
26
|
+
};
|
|
27
|
+
/** Simple HTML → markdown conversion for document creation */
|
|
28
|
+
function htmlToMarkdown(html) {
|
|
29
|
+
let md = html;
|
|
30
|
+
md = md.replace(/<hr\s*\/?>/gi, '\n---\n');
|
|
31
|
+
md = md.replace(/<br\s*\/?>/gi, '\n');
|
|
32
|
+
md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**');
|
|
33
|
+
md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, '*$2*');
|
|
34
|
+
md = md.replace(/<p[^>]*>/gi, '');
|
|
35
|
+
md = md.replace(/<\/p>/gi, '\n\n');
|
|
36
|
+
md = md.replace(/<[^>]+>/g, '');
|
|
37
|
+
md = md.replace(/\n{3,}/g, '\n\n');
|
|
38
|
+
return md.trim();
|
|
39
|
+
}
|
|
5
40
|
const plugin = {
|
|
6
41
|
name: '@openwriter/plugin-publish',
|
|
7
42
|
version: '0.1.0',
|
|
@@ -988,5 +1023,108 @@ const plugin = {
|
|
|
988
1023
|
},
|
|
989
1024
|
];
|
|
990
1025
|
},
|
|
1026
|
+
registerRoutes(ctx) {
|
|
1027
|
+
// Sidebar action handler for document transforms
|
|
1028
|
+
ctx.app.post('/api/publish/sidebar-action', async (req, res) => {
|
|
1029
|
+
try {
|
|
1030
|
+
const { action, filename, title, instructions, content } = req.body;
|
|
1031
|
+
console.log(`[Publish Plugin] Sidebar action: ${action} on "${title}"`);
|
|
1032
|
+
if (!content) {
|
|
1033
|
+
res.status(400).json({ error: 'Document content is required' });
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
// Call publish worker /transforms endpoint
|
|
1037
|
+
const transformRes = await publishFetch(ctx.config, '/transforms', {
|
|
1038
|
+
method: 'POST',
|
|
1039
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1040
|
+
body: JSON.stringify({ action, content, title, instructions }),
|
|
1041
|
+
});
|
|
1042
|
+
if (!transformRes.ok) {
|
|
1043
|
+
const errData = await transformRes.json().catch(() => ({}));
|
|
1044
|
+
console.error('[Publish Plugin] Transform failed:', transformRes.status, errData);
|
|
1045
|
+
res.status(transformRes.status).json(errData);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
const transformResult = await transformRes.json();
|
|
1049
|
+
// Convert HTML output to markdown for document creation
|
|
1050
|
+
let markdownContent = htmlToMarkdown(transformResult.html);
|
|
1051
|
+
// Extract source doc's docId for variant relationship
|
|
1052
|
+
const masterDocId = extractDocId(content);
|
|
1053
|
+
const variantType = ACTION_VARIANT_TYPE[action] || 'document';
|
|
1054
|
+
// Build document creation payload
|
|
1055
|
+
const createBody = {
|
|
1056
|
+
title: transformResult.newTitle,
|
|
1057
|
+
content: markdownContent,
|
|
1058
|
+
markPending: true,
|
|
1059
|
+
agentCreated: true,
|
|
1060
|
+
...(masterDocId ? { masterDocId, variantType } : {}),
|
|
1061
|
+
};
|
|
1062
|
+
if (action === 'threadify') {
|
|
1063
|
+
// Build TipTap JSON directly — markdown parser converts "- item" lines
|
|
1064
|
+
// to bulletList nodes the tweet editor can't render. Using paragraph +
|
|
1065
|
+
// hardBreak nodes keeps all tweet text as plain text.
|
|
1066
|
+
if (transformResult.thread?.tweets?.length) {
|
|
1067
|
+
const docContent = [];
|
|
1068
|
+
transformResult.thread.tweets.forEach((t, i) => {
|
|
1069
|
+
const lines = t.text.split('\n');
|
|
1070
|
+
const nodes = [];
|
|
1071
|
+
lines.forEach((line, j) => {
|
|
1072
|
+
if (j > 0)
|
|
1073
|
+
nodes.push({ type: 'hardBreak' });
|
|
1074
|
+
if (line)
|
|
1075
|
+
nodes.push({ type: 'text', text: line });
|
|
1076
|
+
});
|
|
1077
|
+
if (nodes.length) {
|
|
1078
|
+
docContent.push({ type: 'paragraph', content: nodes });
|
|
1079
|
+
}
|
|
1080
|
+
if (i < transformResult.thread.tweets.length - 1) {
|
|
1081
|
+
docContent.push({ type: 'horizontalRule' });
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
createBody.content = { type: 'doc', content: docContent };
|
|
1085
|
+
}
|
|
1086
|
+
createBody.metadata = { tweetContext: { mode: 'tweet' } };
|
|
1087
|
+
}
|
|
1088
|
+
// Create new document via internal HTTP call
|
|
1089
|
+
const host = req.get('host') || 'localhost:5050';
|
|
1090
|
+
const protocol = req.protocol || 'http';
|
|
1091
|
+
const createUrl = `${protocol}://${host}/api/documents`;
|
|
1092
|
+
const createRes = await fetch(createUrl, {
|
|
1093
|
+
method: 'POST',
|
|
1094
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1095
|
+
body: JSON.stringify(createBody),
|
|
1096
|
+
});
|
|
1097
|
+
if (!createRes.ok) {
|
|
1098
|
+
const errData = await createRes.json().catch(() => ({}));
|
|
1099
|
+
console.error('[Publish Plugin] Document creation failed:', errData);
|
|
1100
|
+
res.status(500).json({ error: 'Failed to create result document' });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const docResult = await createRes.json();
|
|
1104
|
+
res.json({
|
|
1105
|
+
success: true,
|
|
1106
|
+
action,
|
|
1107
|
+
filename: docResult.filename,
|
|
1108
|
+
title: transformResult.newTitle,
|
|
1109
|
+
metadata: transformResult.metadata,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
console.error('[Publish Plugin] Sidebar action error:', err?.message || err);
|
|
1114
|
+
res.status(500).json({ error: 'Sidebar action failed' });
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
},
|
|
1118
|
+
sidebarMenuItems() {
|
|
1119
|
+
return [
|
|
1120
|
+
{ label: 'Vary', action: 'publish:vary', promptForFocus: true },
|
|
1121
|
+
{ label: 'Shrinkify', action: 'publish:shrinkify', promptForFocus: true },
|
|
1122
|
+
{ label: 'Expandify', action: 'publish:expandify', promptForFocus: true },
|
|
1123
|
+
{ label: 'Threadify', action: 'publish:threadify', promptForFocus: true },
|
|
1124
|
+
{ label: 'Storify', action: 'publish:storify', promptForFocus: true },
|
|
1125
|
+
{ label: 'Emailify', action: 'publish:emailify', promptForFocus: true },
|
|
1126
|
+
{ label: 'Postify', action: 'publish:postify', promptForFocus: true },
|
|
1127
|
+
];
|
|
1128
|
+
},
|
|
991
1129
|
};
|
|
992
1130
|
export default plugin;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Client, OAuth1 } from '@xdevplatform/xdk';
|
|
7
7
|
import { join, extname } from 'path';
|
|
8
8
|
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
import sharp from 'sharp';
|
|
9
10
|
import twitter from 'twitter-text';
|
|
10
11
|
const { parseTweet } = twitter;
|
|
11
12
|
function createXClient(config) {
|
|
@@ -196,9 +197,19 @@ const plugin = {
|
|
|
196
197
|
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
197
198
|
return;
|
|
198
199
|
}
|
|
199
|
-
|
|
200
|
+
let fileBuffer = readFileSync(filePath);
|
|
201
|
+
let uploadType = mediaType;
|
|
202
|
+
const origSize = fileBuffer.length;
|
|
203
|
+
// Compress large images or PNGs to JPEG to stay under X API limits
|
|
204
|
+
if (fileBuffer.length > 3 * 1024 * 1024 || ext === '.png') {
|
|
205
|
+
fileBuffer = Buffer.from(await sharp(fileBuffer).jpeg({ quality: 85 }).toBuffer());
|
|
206
|
+
uploadType = 'image/jpeg';
|
|
207
|
+
console.log(`[X Plugin] Compressed ${filename}: ${(origSize / 1024 / 1024).toFixed(2)}MB → ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB`);
|
|
208
|
+
}
|
|
209
|
+
console.log(`[X Plugin] Uploading ${filename}: ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB, type: ${uploadType}`);
|
|
210
|
+
const mediaBase64 = fileBuffer.toString('base64');
|
|
200
211
|
const uploadResult = await client.media.upload({
|
|
201
|
-
body: { media: mediaBase64, mediaCategory: 'tweet_image', mediaType },
|
|
212
|
+
body: { media: mediaBase64, mediaCategory: 'tweet_image', mediaType: uploadType },
|
|
202
213
|
});
|
|
203
214
|
const mediaId = uploadResult?.data?.id
|
|
204
215
|
|| uploadResult?.media_id_string;
|
|
@@ -211,6 +222,16 @@ const plugin = {
|
|
|
211
222
|
}
|
|
212
223
|
catch (err) {
|
|
213
224
|
console.error('[X Plugin] Media upload failed:', err.message);
|
|
225
|
+
if (err.response) {
|
|
226
|
+
try {
|
|
227
|
+
const body = await err.response.text();
|
|
228
|
+
console.error('[X Plugin] X API response:', err.response.status, body);
|
|
229
|
+
}
|
|
230
|
+
catch { /* ignore */ }
|
|
231
|
+
}
|
|
232
|
+
if (err.data)
|
|
233
|
+
console.error('[X Plugin] Error data:', JSON.stringify(err.data));
|
|
234
|
+
console.error('[X Plugin] Full error:', JSON.stringify(err, Object.getOwnPropertyNames(err)));
|
|
214
235
|
res.status(500).json({ success: false, error: err.message });
|
|
215
236
|
}
|
|
216
237
|
});
|
package/dist/server/documents.js
CHANGED
|
@@ -120,6 +120,8 @@ export function listDocuments() {
|
|
|
120
120
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
121
121
|
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
122
122
|
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
123
|
+
...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
|
|
124
|
+
...(data.variantType ? { variantType: data.variantType } : {}),
|
|
123
125
|
};
|
|
124
126
|
}
|
|
125
127
|
catch {
|
|
@@ -158,6 +160,8 @@ export function listDocuments() {
|
|
|
158
160
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
159
161
|
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
160
162
|
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
163
|
+
...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
|
|
164
|
+
...(data.variantType ? { variantType: data.variantType } : {}),
|
|
161
165
|
});
|
|
162
166
|
}
|
|
163
167
|
catch { /* skip unreadable external files */ }
|
|
@@ -217,6 +221,8 @@ export function listArchivedDocuments() {
|
|
|
217
221
|
wordCount,
|
|
218
222
|
isActive: false,
|
|
219
223
|
...(data.docId ? { docId: data.docId } : {}),
|
|
224
|
+
...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
|
|
225
|
+
...(data.variantType ? { variantType: data.variantType } : {}),
|
|
220
226
|
archivedAt: data.archivedAt,
|
|
221
227
|
};
|
|
222
228
|
}
|
|
@@ -54,5 +54,40 @@ export function createImageRouter() {
|
|
|
54
54
|
const src = `/_images/${req.file.filename}`;
|
|
55
55
|
res.json({ src });
|
|
56
56
|
});
|
|
57
|
+
// Download external URL and save locally
|
|
58
|
+
router.post('/api/download-image', async (req, res) => {
|
|
59
|
+
const { url } = req.body;
|
|
60
|
+
if (!url || typeof url !== 'string') {
|
|
61
|
+
res.status(400).json({ error: 'No URL provided' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(url);
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
res.status(400).json({ error: 'Failed to fetch image' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const contentType = response.headers.get('content-type') || 'image/png';
|
|
71
|
+
if (!contentType.startsWith('image/')) {
|
|
72
|
+
res.status(400).json({ error: 'URL is not an image' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const ext = contentType.includes('jpeg') || contentType.includes('jpg') ? '.jpg'
|
|
76
|
+
: contentType.includes('gif') ? '.gif'
|
|
77
|
+
: contentType.includes('webp') ? '.webp'
|
|
78
|
+
: '.png';
|
|
79
|
+
ensureImagesDir();
|
|
80
|
+
const filename = `${randomUUID().slice(0, 8)}${ext}`;
|
|
81
|
+
const filePath = join(getImagesDir(), filename);
|
|
82
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
83
|
+
const { writeFileSync } = await import('fs');
|
|
84
|
+
writeFileSync(filePath, buffer);
|
|
85
|
+
const src = `/_images/${filename}`;
|
|
86
|
+
res.json({ src });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
res.status(500).json({ error: 'Download failed' });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
57
92
|
return router;
|
|
58
93
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -230,6 +230,16 @@ export async function startHttpServer(options = {}) {
|
|
|
230
230
|
setMetadata(req.body.metadata);
|
|
231
231
|
save();
|
|
232
232
|
}
|
|
233
|
+
// Variant relationship — set masterDocId and variantType in frontmatter
|
|
234
|
+
if (req.body.masterDocId || req.body.variantType) {
|
|
235
|
+
const variantMeta = {};
|
|
236
|
+
if (req.body.masterDocId)
|
|
237
|
+
variantMeta.masterDocId = req.body.masterDocId;
|
|
238
|
+
if (req.body.variantType)
|
|
239
|
+
variantMeta.variantType = req.body.variantType;
|
|
240
|
+
setMetadata(variantMeta);
|
|
241
|
+
save();
|
|
242
|
+
}
|
|
233
243
|
// Plugin flags: mark all content as pending + tag as agent-created
|
|
234
244
|
if (req.body.markPending) {
|
|
235
245
|
markAllNodesAsPending(getDocument(), 'insert');
|
|
@@ -460,12 +470,12 @@ export async function startHttpServer(options = {}) {
|
|
|
460
470
|
// Agent marks
|
|
461
471
|
app.post('/api/marks', (req, res) => {
|
|
462
472
|
try {
|
|
463
|
-
const { filename, text, note, nodeId } = req.body;
|
|
473
|
+
const { filename, text, note, nodeId, nodeIds } = req.body;
|
|
464
474
|
if (!filename || !text || !nodeId) {
|
|
465
475
|
res.status(400).json({ error: 'filename, text, and nodeId are required' });
|
|
466
476
|
return;
|
|
467
477
|
}
|
|
468
|
-
const mark = addMark(filename, text, note || '', nodeId);
|
|
478
|
+
const mark = addMark(filename, text, note || '', nodeId, nodeIds);
|
|
469
479
|
broadcastMarksChanged(filename);
|
|
470
480
|
res.json({ success: true, mark });
|
|
471
481
|
}
|
|
@@ -708,9 +718,14 @@ export async function startHttpServer(options = {}) {
|
|
|
708
718
|
}
|
|
709
719
|
}
|
|
710
720
|
catch { /* content stays empty */ }
|
|
721
|
+
// Extract source doc's docId for variant spinner positioning
|
|
722
|
+
let sourceDocId;
|
|
723
|
+
const docIdMatch = docContent.match(/"docId"\s*:\s*"([^"]+)"/);
|
|
724
|
+
if (docIdMatch)
|
|
725
|
+
sourceDocId = docIdMatch[1];
|
|
711
726
|
// Show sidebar spinner while plugin processes
|
|
712
727
|
const spinnerTitle = label ? `${label}: ${title}` : title;
|
|
713
|
-
broadcastWritingStarted(spinnerTitle);
|
|
728
|
+
broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined);
|
|
714
729
|
// Intercept res.json to clear spinner when plugin handler responds
|
|
715
730
|
const origJson = res.json.bind(res);
|
|
716
731
|
res.json = (body) => {
|
package/dist/server/marks.js
CHANGED
|
@@ -39,13 +39,14 @@ function writeMarkFile(filename, data) {
|
|
|
39
39
|
}
|
|
40
40
|
writeFileSync(path, JSON.stringify(data, null, 2));
|
|
41
41
|
}
|
|
42
|
-
export function addMark(filename, text, note, nodeId) {
|
|
42
|
+
export function addMark(filename, text, note, nodeId, nodeIds) {
|
|
43
43
|
const data = readMarkFile(filename);
|
|
44
44
|
const mark = {
|
|
45
45
|
id: randomUUID().slice(0, 8),
|
|
46
46
|
text,
|
|
47
47
|
note,
|
|
48
48
|
nodeId,
|
|
49
|
+
...(nodeIds && nodeIds.length > 1 ? { nodeIds } : {}),
|
|
49
50
|
createdAt: new Date().toISOString(),
|
|
50
51
|
};
|
|
51
52
|
data.marks.push(mark);
|
|
@@ -150,7 +151,13 @@ export function pruneStaleMarks(filename, validNodeIds) {
|
|
|
150
151
|
return 0;
|
|
151
152
|
const validSet = new Set(validNodeIds);
|
|
152
153
|
const before = data.marks.length;
|
|
153
|
-
data.marks = data.marks.filter((m) =>
|
|
154
|
+
data.marks = data.marks.filter((m) => {
|
|
155
|
+
// Multi-node mark: keep if ANY nodeId is still valid
|
|
156
|
+
if (m.nodeIds && m.nodeIds.length > 0) {
|
|
157
|
+
return m.nodeIds.some((id) => validSet.has(id));
|
|
158
|
+
}
|
|
159
|
+
return validSet.has(m.nodeId);
|
|
160
|
+
});
|
|
154
161
|
const pruned = before - data.marks.length;
|
|
155
162
|
if (pruned > 0)
|
|
156
163
|
writeMarkFile(filename, data);
|
package/dist/server/state.js
CHANGED
|
@@ -680,7 +680,8 @@ function applyChangesToDoc(doc, changes) {
|
|
|
680
680
|
// Tweet thread: hard-delete paragraphs + adjacent HR immediately.
|
|
681
681
|
// Tweet compose view can't handle pending deletes near HRs — hard-delete and resync.
|
|
682
682
|
const delNode = found.parent[found.index];
|
|
683
|
-
|
|
683
|
+
const hardDeleteTypes = ['paragraph', 'image', 'imageLoading'];
|
|
684
|
+
if (hardDeleteTypes.includes(delNode.type) && state.metadata?.tweetContext) {
|
|
684
685
|
const idx = found.index;
|
|
685
686
|
if (idx > 0 && found.parent[idx - 1].type === 'horizontalRule') {
|
|
686
687
|
found.parent.splice(idx, 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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
|
@@ -16,7 +16,7 @@ description: |
|
|
|
16
16
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
17
17
|
metadata:
|
|
18
18
|
author: travsteward
|
|
19
|
-
version: "0.4.
|
|
19
|
+
version: "0.4.5"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -402,29 +402,38 @@ Threads are single documents with `horizontalRule` nodes separating each tweet.
|
|
|
402
402
|
|
|
403
403
|
**Do NOT use `populate_document` for threads.** Use `create_document` with `content_type: "tweet"` + `empty: true`, then `write_to_pad` with `horizontalRule` JSON nodes between tweets. The `content_type` flag sets `tweetContext` metadata automatically.
|
|
404
404
|
|
|
405
|
-
**
|
|
405
|
+
**THREE RULES for thread HRs:**
|
|
406
|
+
|
|
407
|
+
1. **`horizontalRule` separators MUST use TipTap JSON `{ type: "horizontalRule" }`.** Markdown `---` does NOT create proper HR nodes.
|
|
408
|
+
2. **Each HR must be its own change.** Do NOT use content arrays `[{type: "horizontalRule"}, {type: "paragraph", ...}]` — this silently drops the HR.
|
|
409
|
+
3. **Send the ENTIRE thread in ONE `write_to_pad` call.** Do NOT split across multiple calls. Multiple calls create race conditions — if the user accepts changes between calls, pending HRs can be dropped. One call = atomic = no race conditions.
|
|
406
410
|
|
|
407
411
|
```
|
|
408
412
|
1. create_document({ title: "Thread title", content_type: "tweet", empty: true })
|
|
409
413
|
2. write_to_pad({ docId: "<docId>", changes: [
|
|
410
|
-
{ operation: "insert", afterNodeId: "end", content: "Tweet 1
|
|
414
|
+
{ operation: "insert", afterNodeId: "end", content: "Tweet 1 paragraph 1" },
|
|
415
|
+
{ operation: "insert", afterNodeId: "end", content: "Tweet 1 paragraph 2" },
|
|
411
416
|
{ operation: "insert", afterNodeId: "end", content: { type: "horizontalRule" } },
|
|
412
|
-
{ operation: "insert", afterNodeId: "end", content: "Tweet 2
|
|
417
|
+
{ operation: "insert", afterNodeId: "end", content: "Tweet 2 paragraph 1" },
|
|
418
|
+
{ operation: "insert", afterNodeId: "end", content: "Tweet 2 paragraph 2" },
|
|
413
419
|
{ operation: "insert", afterNodeId: "end", content: { type: "horizontalRule" } },
|
|
414
|
-
{ operation: "insert", afterNodeId: "end", content: "Tweet 3
|
|
420
|
+
{ operation: "insert", afterNodeId: "end", content: "Tweet 3 paragraph 1" }
|
|
415
421
|
]})
|
|
416
422
|
```
|
|
417
423
|
|
|
424
|
+
**For long threads (many tweets):** still send in ONE call. The changes array can hold dozens of items. Atomicity matters more than streaming feel for threads — a half-built thread with missing HRs is worse than waiting for the full thread to arrive.
|
|
425
|
+
|
|
418
426
|
### Inserting New Tweets into Existing Threads
|
|
419
427
|
|
|
420
|
-
**
|
|
428
|
+
**Mid-thread insertion is unreliable.** `afterNodeId: "end"` always means document end, not after your last insert. Inserting after specific node IDs mid-document has edge cases with pending changes and image nodes.
|
|
421
429
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
430
|
+
**Preferred approach: rebuild the full thread.** Delete the document and recreate with all tweets in one atomic `write_to_pad` call. This is the only pattern that reliably produces correct thread structure.
|
|
431
|
+
|
|
432
|
+
**If you must insert mid-thread:** use a single `write_to_pad` call with the HR and all content targeting the same `afterNodeId` (the last node of the preceding tweet). Content inserts in reverse order when sharing an afterNodeId, so list changes in reverse. This is fragile — prefer full rebuild.
|
|
433
|
+
|
|
434
|
+
**Do NOT delete empty paragraphs after images.** Images create empty `<p>` nodes after them. These look like junk but HRs (thread separators) are dependent on them. Deleting the empty paragraph kills the HR too, merging two tweets into one. Leave them alone.
|
|
435
|
+
|
|
436
|
+
**NEVER bulk-delete text nodes in a thread that contains images.** Image nodes survive text deletion and become orphans — stranded in the wrong position with no surrounding content. The user must then manually delete every orphan image from the browser. This is catastrophic. If you need to reorder tweets, move text around the existing images, or delete the entire document and start fresh (which properly removes everything including images).
|
|
428
437
|
|
|
429
438
|
### Paragraph Spacing in Tweets
|
|
430
439
|
|