openwriter 0.14.0 → 0.16.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-CbSQ8xxn.css +1 -0
- package/dist/client/assets/index-JMMJM_G_.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +293 -20
- package/dist/server/enrichment.js +114 -0
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/install-skill.js +15 -0
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-parse.js +71 -14
- package/dist/server/markdown-serialize.js +136 -41
- package/dist/server/mcp.js +538 -99
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +76 -49
- package/dist/server/pending-overlay.js +862 -0
- package/dist/server/state.js +1178 -98
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +42 -5
- package/dist/server/ws.js +194 -37
- package/package.json +1 -1
- package/skill/SKILL.md +51 -21
- package/skill/agents/openwriter-enrichment-minion.md +184 -0
- package/skill/docs/enrichment.md +179 -0
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X API plugin for OpenWriter.
|
|
3
|
+
* Registers routes for checking X connection status and posting tweets.
|
|
4
|
+
* Uses @xdevplatform/xdk with OAuth1 credentials from plugin config.
|
|
5
|
+
*/
|
|
6
|
+
import { Client, OAuth1 } from '@xdevplatform/xdk';
|
|
7
|
+
import { join, extname } from 'path';
|
|
8
|
+
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
import sharp from 'sharp';
|
|
10
|
+
import twitter from 'twitter-text';
|
|
11
|
+
const { parseTweet } = twitter;
|
|
12
|
+
function createXClient(config) {
|
|
13
|
+
const apiKey = config['api-key'] || process.env.X_API_KEY || '';
|
|
14
|
+
const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
|
|
15
|
+
const accessToken = config['access-token'] || process.env.X_ACCESS_TOKEN || '';
|
|
16
|
+
const accessTokenSecret = config['access-token-secret'] || process.env.X_ACCESS_TOKEN_SECRET || '';
|
|
17
|
+
if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret)
|
|
18
|
+
return null;
|
|
19
|
+
const oauth1 = new OAuth1({
|
|
20
|
+
apiKey,
|
|
21
|
+
apiSecret,
|
|
22
|
+
callback: 'oob',
|
|
23
|
+
accessToken,
|
|
24
|
+
accessTokenSecret,
|
|
25
|
+
});
|
|
26
|
+
return new Client({ oauth1 });
|
|
27
|
+
}
|
|
28
|
+
const plugin = {
|
|
29
|
+
name: '@openwriter/plugin-x-api',
|
|
30
|
+
version: '0.1.0',
|
|
31
|
+
description: 'Post tweets from OpenWriter',
|
|
32
|
+
category: 'social-media',
|
|
33
|
+
configSchema: {
|
|
34
|
+
'api-key': { type: 'string', env: 'X_API_KEY', description: 'X API Key' },
|
|
35
|
+
'api-secret': { type: 'string', env: 'X_API_SECRET', description: 'X API Secret' },
|
|
36
|
+
'access-token': { type: 'string', env: 'X_ACCESS_TOKEN', description: 'X Access Token' },
|
|
37
|
+
'access-token-secret': { type: 'string', env: 'X_ACCESS_TOKEN_SECRET', description: 'X Access Token Secret' },
|
|
38
|
+
},
|
|
39
|
+
registerRoutes(ctx) {
|
|
40
|
+
// GET /api/x/status — check if plugin is configured + authenticated
|
|
41
|
+
ctx.app.get('/api/x/status', async (_req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
const client = createXClient(ctx.config);
|
|
44
|
+
if (!client) {
|
|
45
|
+
res.json({ connected: false });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const me = await client.users.getMe();
|
|
49
|
+
const username = me?.data?.username;
|
|
50
|
+
res.json({ connected: true, username: username || undefined });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
console.error('[X Plugin] Status check failed:', err.message);
|
|
54
|
+
res.json({ connected: false, error: err.message });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// POST /api/x/post — post a tweet (with optional media)
|
|
58
|
+
ctx.app.post('/api/x/post', async (req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const { text, replyTo, quoteTweetId, mediaIds } = req.body;
|
|
61
|
+
if ((!text || typeof text !== 'string') && (!Array.isArray(mediaIds) || mediaIds.length === 0)) {
|
|
62
|
+
res.status(400).json({ success: false, error: 'text or mediaIds is required' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (mediaIds && (!Array.isArray(mediaIds) || mediaIds.length > 4)) {
|
|
66
|
+
res.status(400).json({ success: false, error: 'mediaIds must be an array of 1-4 IDs' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const client = createXClient(ctx.config);
|
|
70
|
+
if (!client) {
|
|
71
|
+
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const body = {};
|
|
75
|
+
if (text)
|
|
76
|
+
body.text = text;
|
|
77
|
+
if (replyTo) {
|
|
78
|
+
body.reply = { inReplyToTweetId: replyTo };
|
|
79
|
+
}
|
|
80
|
+
if (quoteTweetId) {
|
|
81
|
+
body.quoteTweetId = quoteTweetId;
|
|
82
|
+
}
|
|
83
|
+
if (mediaIds && mediaIds.length > 0) {
|
|
84
|
+
body.media = { media_ids: mediaIds };
|
|
85
|
+
}
|
|
86
|
+
const result = await client.posts.create(body);
|
|
87
|
+
const tweetId = result?.data?.id;
|
|
88
|
+
const tweetUrl = tweetId ? `https://x.com/i/status/${tweetId}` : undefined;
|
|
89
|
+
res.json({ success: true, tweetId, tweetUrl });
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const detail = err.data ? JSON.stringify(err.data) : err.message;
|
|
93
|
+
console.error('[X Plugin] Post failed:', detail);
|
|
94
|
+
res.status(500).json({ success: false, error: detail });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// POST /api/x/post-thread — post a full thread as a reply chain (with optional media per tweet)
|
|
98
|
+
ctx.app.post('/api/x/post-thread', async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const { tweets, replyTo } = req.body;
|
|
101
|
+
if (!Array.isArray(tweets) || tweets.length === 0) {
|
|
102
|
+
res.status(400).json({ success: false, error: 'tweets must be a non-empty array' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Normalize: accept string[] or { text, mediaIds? }[]
|
|
106
|
+
const normalized = tweets.map((t) => typeof t === 'string' ? { text: t, mediaIds: undefined } : t);
|
|
107
|
+
// Validate character limits using X's weighted counting (emojis=2, URLs=23, CJK=2)
|
|
108
|
+
const CHAR_LIMIT = 25000;
|
|
109
|
+
const overLimit = normalized.map((t, i) => ({ i, len: parseTweet(t.text).weightedLength })).filter(x => x.len > CHAR_LIMIT);
|
|
110
|
+
if (overLimit.length > 0) {
|
|
111
|
+
res.status(400).json({
|
|
112
|
+
success: false,
|
|
113
|
+
error: `${overLimit.length} tweet(s) exceed ${CHAR_LIMIT} chars: ${overLimit.map(x => `#${x.i + 1} (${x.len})`).join(', ')}`,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Validate mediaIds per tweet
|
|
118
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
119
|
+
const ids = normalized[i].mediaIds;
|
|
120
|
+
if (ids && (!Array.isArray(ids) || ids.length > 4)) {
|
|
121
|
+
res.status(400).json({ success: false, error: `Tweet ${i + 1}: mediaIds must be an array of 1-4 IDs` });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const client = createXClient(ctx.config);
|
|
126
|
+
if (!client) {
|
|
127
|
+
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const postedTweets = [];
|
|
131
|
+
let previousTweetId = replyTo;
|
|
132
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
133
|
+
const { text, mediaIds } = normalized[i];
|
|
134
|
+
const body = {};
|
|
135
|
+
if (text)
|
|
136
|
+
body.text = text;
|
|
137
|
+
if (previousTweetId) {
|
|
138
|
+
body.reply = { inReplyToTweetId: previousTweetId };
|
|
139
|
+
}
|
|
140
|
+
if (mediaIds && mediaIds.length > 0) {
|
|
141
|
+
body.media = { media_ids: mediaIds };
|
|
142
|
+
}
|
|
143
|
+
const result = await client.posts.create(body);
|
|
144
|
+
const tweetId = result?.data?.id;
|
|
145
|
+
if (!tweetId) {
|
|
146
|
+
res.status(500).json({
|
|
147
|
+
success: false,
|
|
148
|
+
postedTweets,
|
|
149
|
+
failedAt: i,
|
|
150
|
+
error: `Tweet ${i + 1} posted but no ID returned`,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
postedTweets.push({ index: i, tweetId, text });
|
|
155
|
+
previousTweetId = tweetId;
|
|
156
|
+
}
|
|
157
|
+
// Build thread URL from first tweet
|
|
158
|
+
const firstTweetId = postedTweets[0]?.tweetId;
|
|
159
|
+
const threadUrl = firstTweetId ? `https://x.com/i/status/${firstTweetId}` : undefined;
|
|
160
|
+
console.log(`[X Plugin] Thread posted: ${postedTweets.length} tweets, ${threadUrl}`);
|
|
161
|
+
res.json({ success: true, postedTweets, threadUrl });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
console.error('[X Plugin] Post thread failed:', err.message);
|
|
165
|
+
res.status(500).json({ success: false, error: err.message });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
// POST /api/x/upload-media — upload a local /_images/ file for tweet attachment
|
|
169
|
+
ctx.app.post('/api/x/upload-media', async (req, res) => {
|
|
170
|
+
try {
|
|
171
|
+
const { src } = req.body;
|
|
172
|
+
if (!src || typeof src !== 'string') {
|
|
173
|
+
res.status(400).json({ success: false, error: 'src is required' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Security: only allow /_images/ paths, no traversal
|
|
177
|
+
if (!/^\/_images\/[^/\\]+$/.test(src)) {
|
|
178
|
+
res.status(400).json({ success: false, error: 'Invalid image path — must be /_images/<filename>' });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const filename = src.replace('/_images/', '');
|
|
182
|
+
const filePath = join(ctx.dataDir, '_images', filename);
|
|
183
|
+
if (!existsSync(filePath)) {
|
|
184
|
+
res.status(404).json({ success: false, error: `Image not found: ${filename}` });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const ext = extname(filename).toLowerCase();
|
|
188
|
+
const mimeMap = {
|
|
189
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
190
|
+
'.png': 'image/png', '.webp': 'image/webp',
|
|
191
|
+
'.gif': 'image/jpeg', '.bmp': 'image/bmp',
|
|
192
|
+
'.tiff': 'image/tiff', '.tif': 'image/tiff',
|
|
193
|
+
};
|
|
194
|
+
const mediaType = mimeMap[ext] || 'image/jpeg';
|
|
195
|
+
const client = createXClient(ctx.config);
|
|
196
|
+
if (!client) {
|
|
197
|
+
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
198
|
+
return;
|
|
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');
|
|
211
|
+
const uploadResult = await client.media.upload({
|
|
212
|
+
body: { media: mediaBase64, mediaCategory: 'tweet_image', mediaType: uploadType },
|
|
213
|
+
});
|
|
214
|
+
const mediaId = uploadResult?.data?.id
|
|
215
|
+
|| uploadResult?.media_id_string;
|
|
216
|
+
if (!mediaId) {
|
|
217
|
+
res.status(500).json({ success: false, error: 'Upload succeeded but no media ID returned' });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
console.log(`[X Plugin] Media uploaded: ${filename} → ${mediaId}`);
|
|
221
|
+
res.json({ success: true, mediaId });
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
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)));
|
|
235
|
+
res.status(500).json({ success: false, error: err.message });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
export default plugin;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-x-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Post tweets from OpenWriter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@xdevplatform/xdk": "^0.4.0",
|
|
13
|
+
"twitter-text": "^3.1.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/express": "^5.0.0",
|
|
17
|
+
"typescript": "^5.6.0"
|
|
18
|
+
},
|
|
19
|
+
"openwriter": {
|
|
20
|
+
"displayName": "X / Twitter",
|
|
21
|
+
"category": "social-media"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/",
|
|
25
|
+
"package.json"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -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
|
+
}
|