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.
@@ -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-BFXmrfky.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-DnndZMJ9.css">
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 content for posting. Modes: queue (next available slot), now (immediate), custom (specific time).',
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
- content: { type: 'string', description: 'Post content' },
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 { success: true, item: data.item, message: `Content scheduled for ${data.item?.scheduled_at}` };
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 data = await upstream.json();
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;
@@ -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) {
@@ -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: 'move_doc',
622
- description: 'Add a document to a workspace, or move it within the workspace. If the doc is not yet in the workspace it will be added; if it is already present it will be moved to the target container.',
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
- workspaceFile: z.string().describe('Workspace manifest filename'),
625
- docId: z.string().describe('Document docId (8-char hex from list_documents)'),
626
- targetContainerId: z.string().optional().describe('Target container ID (omit for root level)'),
627
- afterFile: z.string().optional().describe('Place after this file (omit for beginning)'),
628
- },
629
- handler: async ({ workspaceFile, docId, targetContainerId, afterFile }) => {
630
- const filename = resolveDocId(docId);
631
- const ws = getWorkspace(workspaceFile);
632
- const existing = findDocNode(ws.root, filename);
633
- if (existing) {
634
- moveDoc(workspaceFile, filename, targetContainerId ?? null, afterFile ?? null);
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
- else {
637
- const title = getDocTitle(filename);
638
- addDoc(workspaceFile, targetContainerId ?? null, filename, title, afterFile ?? null);
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
- broadcastWorkspacesChanged();
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.6.11",
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",