openwriter 0.6.11 → 0.8.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.
@@ -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
  };
@@ -6,6 +6,8 @@
6
6
  import { Client, OAuth1 } from '@xdevplatform/xdk';
7
7
  import { join, extname } from 'path';
8
8
  import { readFileSync, existsSync } from 'fs';
9
+ import twitter from 'twitter-text';
10
+ const { parseTweet } = twitter;
9
11
  function createXClient(config) {
10
12
  const apiKey = config['api-key'] || process.env.X_API_KEY || '';
11
13
  const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
@@ -101,9 +103,9 @@ const plugin = {
101
103
  }
102
104
  // Normalize: accept string[] or { text, mediaIds? }[]
103
105
  const normalized = tweets.map((t) => typeof t === 'string' ? { text: t, mediaIds: undefined } : t);
104
- // Validate character limits (X API v2 supports up to 25k chars for Premium accounts)
106
+ // Validate character limits using X's weighted counting (emojis=2, URLs=23, CJK=2)
105
107
  const CHAR_LIMIT = 25000;
106
- const overLimit = normalized.map((t, i) => ({ i, len: t.text.length })).filter(x => x.len > CHAR_LIMIT);
108
+ const overLimit = normalized.map((t, i) => ({ i, len: parseTweet(t.text).weightedLength })).filter(x => x.len > CHAR_LIMIT);
107
109
  if (overLimit.length > 0) {
108
110
  res.status(400).json({
109
111
  success: false,
@@ -9,7 +9,8 @@
9
9
  "dev": "tsc --watch"
10
10
  },
11
11
  "dependencies": {
12
- "@xdevplatform/xdk": "^0.4.0"
12
+ "@xdevplatform/xdk": "^0.4.0",
13
+ "twitter-text": "^3.1.0"
13
14
  },
14
15
  "devDependencies": {
15
16
  "@types/express": "^5.0.0",
@@ -177,7 +177,7 @@ function nodeToCompactLines(node, indent) {
177
177
  lines.push(`${indent}${tag} ${text}`);
178
178
  return lines;
179
179
  }
180
- export function toCompactFormat(doc, title, wordCount, pendingCount, docId) {
180
+ export function toCompactFormat(doc, title, wordCount, pendingCount, docId, metadata) {
181
181
  const header = [
182
182
  `title: ${title}`,
183
183
  ...(docId ? [`id: ${docId}`] : []),
@@ -185,12 +185,50 @@ export function toCompactFormat(doc, title, wordCount, pendingCount, docId) {
185
185
  `pending: ${pendingCount}`,
186
186
  '---',
187
187
  ];
188
+ const isTweet = !!metadata?.tweetContext;
188
189
  const body = [];
189
190
  for (const node of doc.content || []) {
190
191
  body.push(...nodeToCompactLines(node, ''));
192
+ // Show char count after each tweet paragraph in a thread
193
+ if (isTweet && node.type === 'paragraph' && node.attrs?.id) {
194
+ const text = extractNodeText(node);
195
+ if (text.trim()) {
196
+ const chars = tweetWeightedLength(text);
197
+ const over = chars - 280;
198
+ body.push(over > 0 ? ` ⚠ ${chars}/280 (+${over})` : ` ✓ ${chars}/280`);
199
+ }
200
+ }
191
201
  }
192
202
  return [...header, ...body].join('\n');
193
203
  }
204
+ /** Extract plain text from a node tree. */
205
+ function extractNodeText(node) {
206
+ if (!node)
207
+ return '';
208
+ if (node.type === 'text' && typeof node.text === 'string')
209
+ return node.text;
210
+ if (node.type === 'hardBreak')
211
+ return '\n';
212
+ if (node.content)
213
+ return node.content.map(extractNodeText).join('');
214
+ return '';
215
+ }
216
+ /** X-weighted char count using twitter-text. */
217
+ let _parseTweet = null;
218
+ function tweetWeightedLength(text) {
219
+ if (!_parseTweet) {
220
+ try {
221
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
222
+ const twitter = require('twitter-text');
223
+ _parseTweet = twitter.parseTweet || twitter.default?.parseTweet;
224
+ }
225
+ catch {
226
+ // Fallback: plain char count
227
+ return text.length;
228
+ }
229
+ }
230
+ return _parseTweet(text).weightedLength;
231
+ }
194
232
  /**
195
233
  * Convert an array of TipTap nodes to compact tagged-line format.
196
234
  * Used by get_nodes tool.
@@ -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) {