openwriter 0.6.11 → 0.7.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-BhlEJsdX.css +1 -0
- package/dist/client/assets/index-XajWsVLO.js +210 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/publish/dist/index.js +244 -13
- package/dist/server/connection-routes.js +8 -1
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +43 -20
- package/dist/server/post-sync.js +114 -0
- package/dist/server/scheduler-routes.js +33 -0
- package/dist/server/workspaces.js +35 -0
- package/package.json +1 -1
- package/skill/SKILL.md +15 -3
- package/dist/client/assets/index-BFXmrfky.js +0 -210
- package/dist/client/assets/index-DnndZMJ9.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-XajWsVLO.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BhlEJsdX.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getServerModules, publishFetch } from './helpers.js';
|
|
2
2
|
import { newsletterTools } from './newsletter-tools.js';
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join, extname } from 'path';
|
|
3
5
|
const plugin = {
|
|
4
6
|
name: '@openwriter/plugin-publish',
|
|
5
7
|
version: '0.1.0',
|
|
@@ -332,33 +334,166 @@ const plugin = {
|
|
|
332
334
|
// --- Scheduler tools ---
|
|
333
335
|
{
|
|
334
336
|
name: 'schedule_post',
|
|
335
|
-
description: 'Schedule
|
|
337
|
+
description: 'Schedule the current document for posting. Reads content and content_type from the active document automatically. Default mode: queue (next available slot).',
|
|
336
338
|
inputSchema: {
|
|
337
339
|
type: 'object',
|
|
338
340
|
properties: {
|
|
339
|
-
|
|
340
|
-
connection_id: { type: 'string', description: 'Target connection ID (use list_connections to find)' },
|
|
341
|
-
content_type: { type: 'string', enum: ['tweet', 'x', 'linkedin', 'newsletter'], description: 'Content type' },
|
|
341
|
+
connection_id: { type: 'string', description: 'Target connection ID (use list_connections to find). If omitted, infers from content_type.' },
|
|
342
342
|
mode: { type: 'string', enum: ['queue', 'now', 'custom'], description: 'Scheduling mode (default: queue)' },
|
|
343
343
|
scheduled_at: { type: 'string', description: 'ISO datetime for custom mode' },
|
|
344
|
+
slot_id: { type: 'string', description: 'Specific slot ID to target (overrides automatic slot selection)' },
|
|
344
345
|
},
|
|
345
|
-
required: ['content', 'content_type'],
|
|
346
346
|
},
|
|
347
347
|
handler: async (params) => {
|
|
348
|
+
const server = await getServerModules();
|
|
349
|
+
const doc = server.getDocument();
|
|
350
|
+
const metadata = server.getMetadata();
|
|
351
|
+
const docId = server.getDocId();
|
|
352
|
+
if (!doc || !doc.content)
|
|
353
|
+
return { error: 'No active document. Switch to a document first.' };
|
|
354
|
+
// --- Helpers ---
|
|
355
|
+
const extractText = (nodes) => {
|
|
356
|
+
const walk = (node) => {
|
|
357
|
+
if (node.type === 'text')
|
|
358
|
+
return node.text || '';
|
|
359
|
+
if (node.type === 'hardBreak')
|
|
360
|
+
return '\n';
|
|
361
|
+
if (!node.content)
|
|
362
|
+
return '';
|
|
363
|
+
const inner = node.content.map(walk).join('');
|
|
364
|
+
if (node.type === 'paragraph')
|
|
365
|
+
return inner + '\n\n';
|
|
366
|
+
return inner;
|
|
367
|
+
};
|
|
368
|
+
return nodes.map(walk).join('').trim();
|
|
369
|
+
};
|
|
370
|
+
const extractImages = (nodes) => {
|
|
371
|
+
const srcs = [];
|
|
372
|
+
const walk = (node) => {
|
|
373
|
+
if (node.type === 'image' && node.attrs?.src)
|
|
374
|
+
srcs.push(node.attrs.src);
|
|
375
|
+
if (node.content)
|
|
376
|
+
node.content.forEach(walk);
|
|
377
|
+
};
|
|
378
|
+
nodes.forEach(walk);
|
|
379
|
+
return srcs.slice(0, 4); // X limit: 4 images per tweet
|
|
380
|
+
};
|
|
381
|
+
const mimeMap = {
|
|
382
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
383
|
+
'.png': 'image/png', '.webp': 'image/webp',
|
|
384
|
+
'.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
385
|
+
};
|
|
386
|
+
const uploadImages = async (srcs, connId) => {
|
|
387
|
+
const ids = [];
|
|
388
|
+
const dataDir = server.getDataDir();
|
|
389
|
+
for (const src of srcs) {
|
|
390
|
+
if (!src.startsWith('/_images/'))
|
|
391
|
+
continue;
|
|
392
|
+
const filename = src.replace('/_images/', '');
|
|
393
|
+
const filePath = join(dataDir, '_images', filename);
|
|
394
|
+
if (!existsSync(filePath))
|
|
395
|
+
continue;
|
|
396
|
+
const ext = extname(filename).toLowerCase();
|
|
397
|
+
const mediaType = mimeMap[ext] || 'image/jpeg';
|
|
398
|
+
const mediaBase64 = readFileSync(filePath).toString('base64');
|
|
399
|
+
const res = await server.platformFetch(`/connections/${connId}/upload-media`, {
|
|
400
|
+
method: 'POST',
|
|
401
|
+
body: JSON.stringify({ media_base64: mediaBase64, media_type: mediaType }),
|
|
402
|
+
});
|
|
403
|
+
if (res.ok) {
|
|
404
|
+
const data = await res.json();
|
|
405
|
+
if (data.mediaId)
|
|
406
|
+
ids.push(data.mediaId);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return ids;
|
|
410
|
+
};
|
|
411
|
+
// --- Split doc at horizontalRule nodes (thread detection) ---
|
|
412
|
+
const docNodes = doc.content || [];
|
|
413
|
+
const tweetGroups = [[]];
|
|
414
|
+
for (const node of docNodes) {
|
|
415
|
+
if (node.type === 'horizontalRule') {
|
|
416
|
+
tweetGroups.push([]);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
tweetGroups[tweetGroups.length - 1].push(node);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Filter empty groups
|
|
423
|
+
const tweets = tweetGroups.filter(g => g.length > 0);
|
|
424
|
+
const isThread = tweets.length > 1;
|
|
425
|
+
// Derive content_type from metadata
|
|
426
|
+
const contentType = metadata?.content_type || 'tweet';
|
|
427
|
+
// Auto-find connection
|
|
428
|
+
let connectionId = params.connection_id;
|
|
429
|
+
if (!connectionId) {
|
|
430
|
+
const provider = contentType === 'linkedin' ? 'linkedin' : 'x';
|
|
431
|
+
const listRes = await server.platformFetch('/connections');
|
|
432
|
+
if (listRes.ok) {
|
|
433
|
+
const data = await listRes.json();
|
|
434
|
+
const conn = data.connections.find((c) => c.provider === provider && c.status === 'active');
|
|
435
|
+
if (conn)
|
|
436
|
+
connectionId = conn.id;
|
|
437
|
+
}
|
|
438
|
+
if (!connectionId)
|
|
439
|
+
return { error: `No active ${provider} connection found.` };
|
|
440
|
+
}
|
|
441
|
+
// --- Build content: single tweet or thread ---
|
|
442
|
+
let queueContent;
|
|
443
|
+
let totalMedia = 0;
|
|
444
|
+
if (isThread) {
|
|
445
|
+
const tweetData = [];
|
|
446
|
+
for (const group of tweets) {
|
|
447
|
+
const text = extractText(group);
|
|
448
|
+
if (!text)
|
|
449
|
+
continue;
|
|
450
|
+
const images = extractImages(group);
|
|
451
|
+
const mediaIds = images.length > 0 ? await uploadImages(images, connectionId) : [];
|
|
452
|
+
totalMedia += mediaIds.length;
|
|
453
|
+
tweetData.push(mediaIds.length > 0 ? { text, mediaIds } : { text });
|
|
454
|
+
}
|
|
455
|
+
if (tweetData.length === 0)
|
|
456
|
+
return { error: 'Document is empty.' };
|
|
457
|
+
queueContent = { tweets: tweetData };
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const text = extractText(docNodes);
|
|
461
|
+
if (!text)
|
|
462
|
+
return { error: 'Document is empty.' };
|
|
463
|
+
const images = extractImages(docNodes);
|
|
464
|
+
const mediaIds = images.length > 0 ? await uploadImages(images, connectionId) : [];
|
|
465
|
+
totalMedia = mediaIds.length;
|
|
466
|
+
queueContent = mediaIds.length > 0 ? { text, mediaIds } : { text };
|
|
467
|
+
}
|
|
468
|
+
// --- Queue it ---
|
|
469
|
+
const body = {
|
|
470
|
+
content: queueContent,
|
|
471
|
+
content_type: isThread ? 'thread' : contentType,
|
|
472
|
+
connection_id: connectionId,
|
|
473
|
+
mode: params.mode || 'queue',
|
|
474
|
+
doc_id: docId,
|
|
475
|
+
};
|
|
476
|
+
if (params.scheduled_at)
|
|
477
|
+
body.scheduled_at = params.scheduled_at;
|
|
478
|
+
if (params.slot_id)
|
|
479
|
+
body.slot_id = params.slot_id;
|
|
348
480
|
const res = await publishFetch(config, '/scheduler/queue', {
|
|
349
481
|
method: 'POST',
|
|
350
|
-
body: JSON.stringify(
|
|
351
|
-
content: params.content,
|
|
352
|
-
content_type: params.content_type,
|
|
353
|
-
connection_id: params.connection_id,
|
|
354
|
-
mode: params.mode || 'queue',
|
|
355
|
-
scheduled_at: params.scheduled_at,
|
|
356
|
-
}),
|
|
482
|
+
body: JSON.stringify(body),
|
|
357
483
|
});
|
|
358
484
|
const data = await res.json();
|
|
359
485
|
if (!res.ok)
|
|
360
486
|
return { error: `Schedule failed: ${data.error || res.statusText}` };
|
|
361
|
-
return {
|
|
487
|
+
return {
|
|
488
|
+
success: true,
|
|
489
|
+
docId,
|
|
490
|
+
scheduled_at: data.item?.scheduled_at,
|
|
491
|
+
connection: connectionId,
|
|
492
|
+
mode: params.mode || 'queue',
|
|
493
|
+
type: isThread ? 'thread' : 'tweet',
|
|
494
|
+
tweetCount: isThread ? tweets.length : 1,
|
|
495
|
+
mediaCount: totalMedia,
|
|
496
|
+
};
|
|
362
497
|
},
|
|
363
498
|
},
|
|
364
499
|
{
|
|
@@ -691,6 +826,102 @@ const plugin = {
|
|
|
691
826
|
return { success: true, ...data };
|
|
692
827
|
},
|
|
693
828
|
},
|
|
829
|
+
{
|
|
830
|
+
name: 'naturalize_slots',
|
|
831
|
+
description: 'Scramble slot times so posts don\'t look scheduled. Splits multi-day slots into per-day slots with jittered times (+/- 15 min). Each day gets a slightly different minute offset. Run after creating slots at clean macro times.',
|
|
832
|
+
inputSchema: {
|
|
833
|
+
type: 'object',
|
|
834
|
+
properties: {
|
|
835
|
+
jitter_minutes: { type: 'number', description: 'Max jitter in minutes (default: 15). Each slot shifts by a random amount within this range.' },
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
handler: async (params) => {
|
|
839
|
+
const jitter = params.jitter_minutes || 15;
|
|
840
|
+
const ALL_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
841
|
+
// Fetch current slots
|
|
842
|
+
const listRes = await publishFetch(config, '/scheduler/slots');
|
|
843
|
+
const listData = await listRes.json();
|
|
844
|
+
if (!listRes.ok)
|
|
845
|
+
return { error: `Failed to list slots: ${listData.error || listRes.statusText}` };
|
|
846
|
+
const slots = listData.slots || [];
|
|
847
|
+
if (!slots.length)
|
|
848
|
+
return { error: 'No slots to naturalize' };
|
|
849
|
+
// Parse HH:MM:SS or HH:MM to total minutes
|
|
850
|
+
const parseTime = (t) => {
|
|
851
|
+
const [h, m] = t.split(':').map(Number);
|
|
852
|
+
return h * 60 + m;
|
|
853
|
+
};
|
|
854
|
+
// Format total minutes back to HH:MM
|
|
855
|
+
const formatTime = (mins) => {
|
|
856
|
+
const clamped = ((mins % 1440) + 1440) % 1440;
|
|
857
|
+
const h = Math.floor(clamped / 60);
|
|
858
|
+
const m = clamped % 60;
|
|
859
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
860
|
+
};
|
|
861
|
+
// Deterministic-ish jitter per day index and slot index
|
|
862
|
+
const jitterFor = (dayIdx, slotIdx) => {
|
|
863
|
+
const seed = (dayIdx * 7 + slotIdx * 13 + 3) % (jitter * 2 + 1);
|
|
864
|
+
return seed - jitter;
|
|
865
|
+
};
|
|
866
|
+
let created = 0;
|
|
867
|
+
let deleted = 0;
|
|
868
|
+
const results = [];
|
|
869
|
+
for (let si = 0; si < slots.length; si++) {
|
|
870
|
+
const slot = slots[si];
|
|
871
|
+
const days = slot.days || ['default'];
|
|
872
|
+
const expandedDays = days.includes('default') ? ALL_DAYS : days;
|
|
873
|
+
// Skip already single-day slots (already naturalized)
|
|
874
|
+
if (expandedDays.length === 1 && !days.includes('default')) {
|
|
875
|
+
// Still jitter the time if it's on a round number
|
|
876
|
+
const mins = parseTime(slot.time);
|
|
877
|
+
if (mins % 5 === 0) {
|
|
878
|
+
const dayIdx = ALL_DAYS.indexOf(expandedDays[0]);
|
|
879
|
+
const newMins = mins + jitterFor(dayIdx, si);
|
|
880
|
+
const editRes = await publishFetch(config, `/scheduler/slots/${slot.id}`, {
|
|
881
|
+
method: 'PATCH',
|
|
882
|
+
body: JSON.stringify({ time: formatTime(newMins) }),
|
|
883
|
+
});
|
|
884
|
+
if (editRes.ok) {
|
|
885
|
+
results.push(`${expandedDays[0]}: ${slot.time} → ${formatTime(newMins)}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// Multi-day slot: delete and create per-day replacements
|
|
891
|
+
const baseMinutes = parseTime(slot.time);
|
|
892
|
+
const delRes = await publishFetch(config, `/scheduler/slots/${slot.id}`, { method: 'DELETE' });
|
|
893
|
+
if (delRes.ok)
|
|
894
|
+
deleted++;
|
|
895
|
+
for (let di = 0; di < expandedDays.length; di++) {
|
|
896
|
+
const day = expandedDays[di];
|
|
897
|
+
const dayIdx = ALL_DAYS.indexOf(day);
|
|
898
|
+
const offset = jitterFor(dayIdx, si);
|
|
899
|
+
const newTime = formatTime(baseMinutes + offset);
|
|
900
|
+
const createRes = await publishFetch(config, '/scheduler/slots', {
|
|
901
|
+
method: 'POST',
|
|
902
|
+
body: JSON.stringify({
|
|
903
|
+
time: newTime,
|
|
904
|
+
days: [day],
|
|
905
|
+
filter_type: slot.filter_type || 'any',
|
|
906
|
+
filter_value: slot.filter_value || null,
|
|
907
|
+
timezone: slot.timezone || 'America/Los_Angeles',
|
|
908
|
+
}),
|
|
909
|
+
});
|
|
910
|
+
if (createRes.ok) {
|
|
911
|
+
created++;
|
|
912
|
+
results.push(`${day}: ${slot.time} → ${newTime}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
success: true,
|
|
918
|
+
deleted,
|
|
919
|
+
created,
|
|
920
|
+
summary: `Naturalized ${deleted} multi-day slots into ${created} per-day slots`,
|
|
921
|
+
details: results,
|
|
922
|
+
};
|
|
923
|
+
},
|
|
924
|
+
},
|
|
694
925
|
];
|
|
695
926
|
},
|
|
696
927
|
};
|
|
@@ -61,7 +61,14 @@ export function createConnectionRouter() {
|
|
|
61
61
|
router.delete('/api/connections/:id', async (req, res) => {
|
|
62
62
|
try {
|
|
63
63
|
const upstream = await platformFetch(`/connections/${req.params.id}`, { method: 'DELETE' });
|
|
64
|
-
const
|
|
64
|
+
const text = await upstream.text();
|
|
65
|
+
let data;
|
|
66
|
+
try {
|
|
67
|
+
data = JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
data = { error: text };
|
|
71
|
+
}
|
|
65
72
|
if (!upstream.ok) {
|
|
66
73
|
res.status(upstream.status).json(data);
|
|
67
74
|
return;
|
package/dist/server/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { TOOL_REGISTRY } from './mcp.js';
|
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
14
|
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
|
|
15
|
+
import { syncPostHistory } from './post-sync.js';
|
|
15
16
|
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
|
|
16
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
17
18
|
import { createLinkRouter } from './link-routes.js';
|
|
@@ -732,6 +733,8 @@ export async function startHttpServer(options = {}) {
|
|
|
732
733
|
resolve();
|
|
733
734
|
});
|
|
734
735
|
});
|
|
736
|
+
// Sync post history from platform (catch posts made while app was closed)
|
|
737
|
+
syncPostHistory().catch(() => { });
|
|
735
738
|
// Open browser unless --no-open or running as MCP stdio pipe
|
|
736
739
|
const isMcpStdio = !process.stdout.isTTY;
|
|
737
740
|
if (!options.noOpen && !isMcpStdio) {
|
package/dist/server/mcp.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId } from './hel
|
|
|
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';
|
|
16
|
-
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, removeContainer, renameWorkspace, renameContainer } from './workspaces.js';
|
|
16
|
+
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer } from './workspaces.js';
|
|
17
17
|
import { addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument } from './state.js';
|
|
18
18
|
import { findDocNode } from './workspace-tree.js';
|
|
19
19
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
@@ -618,28 +618,51 @@ export const TOOL_REGISTRY = [
|
|
|
618
618
|
},
|
|
619
619
|
},
|
|
620
620
|
{
|
|
621
|
-
name: '
|
|
622
|
-
description: '
|
|
621
|
+
name: 'move_item',
|
|
622
|
+
description: 'Move or reorder a doc, container, or workspace. For docs: add to workspace or move within it. For containers: move to different parent or reorder within current parent. For workspaces: reorder in sidebar.',
|
|
623
623
|
schema: {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
624
|
+
type: z.enum(['doc', 'container', 'workspace']).describe('What to move'),
|
|
625
|
+
workspaceFile: z.string().optional().describe('Workspace manifest filename (required for doc/container)'),
|
|
626
|
+
itemId: z.string().describe('docId (8-char hex), containerId, or workspace filename'),
|
|
627
|
+
targetContainerId: z.string().optional().describe('Destination container (omit for root or same-parent reorder). Doc/container only.'),
|
|
628
|
+
afterId: z.string().optional().describe('Place after this item (omit for beginning)'),
|
|
629
|
+
},
|
|
630
|
+
handler: async ({ type, workspaceFile, itemId, targetContainerId, afterId }) => {
|
|
631
|
+
if (type === 'doc') {
|
|
632
|
+
if (!workspaceFile)
|
|
633
|
+
return { content: [{ type: 'text', text: 'Error: workspaceFile is required for doc moves' }] };
|
|
634
|
+
const filename = resolveDocId(itemId);
|
|
635
|
+
const ws = getWorkspace(workspaceFile);
|
|
636
|
+
const existing = findDocNode(ws.root, filename);
|
|
637
|
+
if (existing) {
|
|
638
|
+
moveDoc(workspaceFile, filename, targetContainerId ?? null, afterId ?? null);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
const title = getDocTitle(filename);
|
|
642
|
+
addDoc(workspaceFile, targetContainerId ?? null, filename, title, afterId ?? null);
|
|
643
|
+
}
|
|
644
|
+
broadcastWorkspacesChanged();
|
|
645
|
+
const action = existing ? 'Moved' : 'Added';
|
|
646
|
+
return { content: [{ type: 'text', text: `${action} "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
|
|
647
|
+
}
|
|
648
|
+
if (type === 'container') {
|
|
649
|
+
if (!workspaceFile)
|
|
650
|
+
return { content: [{ type: 'text', text: 'Error: workspaceFile is required for container moves' }] };
|
|
651
|
+
if (targetContainerId !== undefined) {
|
|
652
|
+
moveContainer(workspaceFile, itemId, targetContainerId, afterId ?? null);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
moveContainer(workspaceFile, itemId, null, afterId ?? null);
|
|
656
|
+
}
|
|
657
|
+
broadcastWorkspacesChanged();
|
|
658
|
+
return { content: [{ type: 'text', text: `Moved container "${itemId}"${targetContainerId ? ` to container ${targetContainerId}` : ''}${afterId ? ` after ${afterId}` : ' to beginning'}` }] };
|
|
635
659
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
660
|
+
if (type === 'workspace') {
|
|
661
|
+
reorderWorkspaceAfter(itemId, afterId ?? null);
|
|
662
|
+
broadcastWorkspacesChanged();
|
|
663
|
+
return { content: [{ type: 'text', text: `Reordered workspace "${itemId}"${afterId ? ` after ${afterId}` : ' to beginning'}` }] };
|
|
639
664
|
}
|
|
640
|
-
|
|
641
|
-
const action = existing ? 'Moved' : 'Added';
|
|
642
|
-
return { content: [{ type: 'text', text: `${action} "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
|
|
665
|
+
return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
|
|
643
666
|
},
|
|
644
667
|
},
|
|
645
668
|
{
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post History Sync — pulls scheduler_history from platform,
|
|
3
|
+
* updates local doc frontmatter with lastPost metadata.
|
|
4
|
+
*
|
|
5
|
+
* The platform is the canonical record for posted items.
|
|
6
|
+
* Local frontmatter is a cached view for sidebar display.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import matter from 'gray-matter';
|
|
11
|
+
import { filenameByDocId } from './documents.js';
|
|
12
|
+
import { getDataDir } from './helpers.js';
|
|
13
|
+
import { isAuthenticated, platformFetch } from './connections.js';
|
|
14
|
+
import { isExternalDoc } from './helpers.js';
|
|
15
|
+
/**
|
|
16
|
+
* Sync posted history from platform → local doc frontmatter.
|
|
17
|
+
* For each history item with a doc_id:
|
|
18
|
+
* - Find the local doc by docId
|
|
19
|
+
* - Check if frontmatter already has a lastPost >= posted_at
|
|
20
|
+
* - If not, write the appropriate context metadata
|
|
21
|
+
*/
|
|
22
|
+
export async function syncPostHistory() {
|
|
23
|
+
if (!isAuthenticated())
|
|
24
|
+
return { synced: 0, skipped: 0, errors: 0 };
|
|
25
|
+
let items;
|
|
26
|
+
try {
|
|
27
|
+
const res = await platformFetch('/scheduler/history');
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
return { synced: 0, skipped: 0, errors: 0 };
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
items = data.items || [];
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { synced: 0, skipped: 0, errors: 0 };
|
|
35
|
+
}
|
|
36
|
+
let synced = 0, skipped = 0, errors = 0;
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
if (!item.doc_id || !item.result?.success || !item.posted_at) {
|
|
39
|
+
skipped++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const filename = filenameByDocId(item.doc_id);
|
|
44
|
+
if (!filename) {
|
|
45
|
+
skipped++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const filePath = isExternalDoc(filename)
|
|
49
|
+
? filename
|
|
50
|
+
: join(getDataDir(), filename);
|
|
51
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
52
|
+
const { data, content } = matter(raw);
|
|
53
|
+
// Determine the context key based on content_type/provider
|
|
54
|
+
const provider = item.provider || item.content_type;
|
|
55
|
+
let contextKey;
|
|
56
|
+
let lastPostField;
|
|
57
|
+
let urlField;
|
|
58
|
+
if (provider === 'x' || item.content_type === 'tweet' || item.content_type === 'thread') {
|
|
59
|
+
contextKey = 'tweetContext';
|
|
60
|
+
lastPostField = 'lastPost';
|
|
61
|
+
urlField = 'tweetUrl';
|
|
62
|
+
}
|
|
63
|
+
else if (provider === 'linkedin' || item.content_type === 'linkedin') {
|
|
64
|
+
contextKey = 'linkedinContext';
|
|
65
|
+
lastPostField = 'lastPost';
|
|
66
|
+
urlField = 'postUrl';
|
|
67
|
+
}
|
|
68
|
+
else if (item.content_type === 'blog' || provider === 'github') {
|
|
69
|
+
contextKey = 'blogContext';
|
|
70
|
+
lastPostField = 'lastPublish';
|
|
71
|
+
urlField = 'publishUrl';
|
|
72
|
+
}
|
|
73
|
+
else if (item.content_type === 'newsletter') {
|
|
74
|
+
// Newsletter uses its own sync path
|
|
75
|
+
skipped++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
contextKey = 'tweetContext';
|
|
80
|
+
lastPostField = 'lastPost';
|
|
81
|
+
urlField = 'tweetUrl';
|
|
82
|
+
}
|
|
83
|
+
// Check if already synced (existing lastPost >= this posted_at)
|
|
84
|
+
const existingContext = data[contextKey] || {};
|
|
85
|
+
const existingPost = existingContext[lastPostField];
|
|
86
|
+
if (existingPost?.postedAt) {
|
|
87
|
+
const existingTime = new Date(existingPost.postedAt).getTime();
|
|
88
|
+
const newTime = new Date(item.posted_at).getTime();
|
|
89
|
+
if (existingTime >= newTime) {
|
|
90
|
+
skipped++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Write updated metadata
|
|
95
|
+
data[contextKey] = {
|
|
96
|
+
...existingContext,
|
|
97
|
+
[lastPostField]: {
|
|
98
|
+
postedAt: item.posted_at,
|
|
99
|
+
...(item.result.url ? { [urlField]: item.result.url } : {}),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const updated = matter.stringify(content, data);
|
|
103
|
+
writeFileSync(filePath, updated, 'utf-8');
|
|
104
|
+
synced++;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
errors++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (synced > 0) {
|
|
111
|
+
console.log(`[PostSync] Synced ${synced} posted items to local docs`);
|
|
112
|
+
}
|
|
113
|
+
return { synced, skipped, errors };
|
|
114
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { Router } from 'express';
|
|
5
5
|
import { platformFetch, isAuthenticated } from './connections.js';
|
|
6
|
+
import { syncPostHistory } from './post-sync.js';
|
|
6
7
|
export function createSchedulerRouter() {
|
|
7
8
|
const router = Router();
|
|
8
9
|
function proxy(path, method = 'GET') {
|
|
@@ -115,8 +116,40 @@ export function createSchedulerRouter() {
|
|
|
115
116
|
});
|
|
116
117
|
// History
|
|
117
118
|
router.get('/api/scheduler/history', proxy('/scheduler/history'));
|
|
119
|
+
// Sync post history from platform → local doc frontmatter
|
|
120
|
+
router.post('/api/scheduler/sync', async (_req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const result = await syncPostHistory();
|
|
123
|
+
res.json(result);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
res.status(500).json({ error: err.message });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
118
129
|
// Available connections for scheduler
|
|
119
130
|
router.get('/api/scheduler/connections', proxy('/scheduler/connections'));
|
|
131
|
+
// Upload media via connection (proxies to platform)
|
|
132
|
+
router.post('/api/connections/:id/upload-media', async (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
if (!isAuthenticated()) {
|
|
135
|
+
res.json({ error: 'Not authenticated' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const upstream = await platformFetch(`/connections/${req.params.id}/upload-media`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
body: JSON.stringify(req.body),
|
|
141
|
+
});
|
|
142
|
+
const data = await upstream.json();
|
|
143
|
+
if (!upstream.ok) {
|
|
144
|
+
res.status(upstream.status).json(data);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
res.json(data);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
res.status(500).json({ error: err.message });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
120
153
|
// --- Autoplugs ---
|
|
121
154
|
// Goals
|
|
122
155
|
router.get('/api/scheduler/autoplugs/goals', proxy('/scheduler/autoplugs/goals'));
|
|
@@ -246,6 +246,41 @@ export function reorderContainer(wsFile, containerId, afterIdentifier) {
|
|
|
246
246
|
writeWorkspace(wsFile, ws);
|
|
247
247
|
return ws;
|
|
248
248
|
}
|
|
249
|
+
export function moveContainer(wsFile, containerId, targetContainerId, afterIdentifier) {
|
|
250
|
+
const ws = getWorkspace(wsFile);
|
|
251
|
+
moveNode(ws.root, containerId, targetContainerId, afterIdentifier);
|
|
252
|
+
writeWorkspace(wsFile, ws);
|
|
253
|
+
return ws;
|
|
254
|
+
}
|
|
255
|
+
export function reorderWorkspaceAfter(filename, afterFilename) {
|
|
256
|
+
ensureWorkspacesDir();
|
|
257
|
+
const order = readOrder();
|
|
258
|
+
// Ensure all current workspace files are in the order array
|
|
259
|
+
const files = readdirSync(getWorkspacesDir()).filter(f => f.endsWith('.json') && f !== '_order.json');
|
|
260
|
+
for (const f of files) {
|
|
261
|
+
if (!order.includes(f))
|
|
262
|
+
order.push(f);
|
|
263
|
+
}
|
|
264
|
+
// Remove target
|
|
265
|
+
const idx = order.indexOf(filename);
|
|
266
|
+
if (idx === -1)
|
|
267
|
+
throw new Error(`Workspace "${filename}" not found in order`);
|
|
268
|
+
order.splice(idx, 1);
|
|
269
|
+
// Insert
|
|
270
|
+
if (afterFilename === null) {
|
|
271
|
+
order.unshift(filename);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const afterIdx = order.indexOf(afterFilename);
|
|
275
|
+
if (afterIdx === -1) {
|
|
276
|
+
order.push(filename);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
order.splice(afterIdx + 1, 0, filename);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
writeOrder(order);
|
|
283
|
+
}
|
|
249
284
|
// ============================================================================
|
|
250
285
|
// CONTEXT
|
|
251
286
|
// ============================================================================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|