@agendapanda/cli 0.1.0 → 0.3.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,8 +1,9 @@
1
1
  import chalk from 'chalk';
2
- import { readFile } from 'fs/promises';
2
+ import { readFile, writeFile } from 'fs/promises';
3
3
  import { createHash } from 'crypto';
4
4
  import { api, ApiError } from '../lib/api.js';
5
5
  import { getActiveProject } from '../lib/config.js';
6
+ import { filenameFromPath, guessMimeTypeFromPath } from '../lib/media.js';
6
7
  import { initJsonMode, isJsonMode, outputJson, outputTable, outputError } from '../lib/output.js';
7
8
  function computeIdempotencyKey(projectId, connectionId, content, scheduledAt) {
8
9
  const normalized = [
@@ -18,12 +19,60 @@ function matchesExistingPost(post, connectionId, content, scheduledAt) {
18
19
  post.content.trim() === content.trim() &&
19
20
  new Date(post.scheduled_at).toISOString() === new Date(scheduledAt).toISOString());
20
21
  }
22
+ function validateScheduleUtc(schedule, index) {
23
+ const trimmed = schedule.trim();
24
+ // Must end with Z
25
+ if (!/Z$/i.test(trimmed)) {
26
+ outputError(`Item ${index}: schedule must be UTC ISO 8601 ending with Z ` +
27
+ `(e.g. 2025-03-15T14:00:00Z). Got: "${trimmed}". ` +
28
+ `schedule is execution time in UTC; timezone is display metadata only.`, 'INVALID_SCHEDULE');
29
+ process.exit(1);
30
+ }
31
+ // Must parse to valid date
32
+ const parsed = new Date(trimmed);
33
+ if (isNaN(parsed.getTime())) {
34
+ outputError(`Item ${index}: invalid date "${trimmed}"`, 'INVALID_SCHEDULE');
35
+ process.exit(1);
36
+ }
37
+ return parsed.toISOString();
38
+ }
39
+ function validateTimezone(tz, index) {
40
+ try {
41
+ Intl.DateTimeFormat(undefined, { timeZone: tz });
42
+ }
43
+ catch {
44
+ outputError(`Item ${index}: invalid timezone "${tz}". Use IANA format (e.g. America/New_York).`, 'INVALID_TIMEZONE');
45
+ process.exit(1);
46
+ }
47
+ }
48
+ function isUrl(value) {
49
+ return /^https?:\/\//i.test(value);
50
+ }
51
+ async function resolveMediaFile(mediaPath) {
52
+ const mimeType = guessMimeTypeFromPath(mediaPath);
53
+ if (!mimeType) {
54
+ throw new Error(`Unsupported media extension for ${mediaPath}. Use jpg/jpeg/png/gif/webp/mp4/mov/webm/m4v.`);
55
+ }
56
+ const mediaData = await readFile(mediaPath);
57
+ const filename = filenameFromPath(mediaPath);
58
+ const blob = new Blob([mediaData], { type: mimeType });
59
+ const formData = new FormData();
60
+ formData.append('file', blob, filename);
61
+ const uploadResult = await api.upload('/api/media/upload', formData);
62
+ return uploadResult.url;
63
+ }
64
+ /** Resolve a media value to a URL: passthrough if already a URL, upload if file path. */
65
+ async function resolveMedia(value) {
66
+ if (isUrl(value))
67
+ return value;
68
+ return resolveMediaFile(value);
69
+ }
21
70
  export function registerCalendarCommands(program) {
22
71
  const calendar = program.command('calendar').description('Bulk content calendar operations');
23
72
  // ap calendar import
24
73
  calendar
25
74
  .command('import')
26
- .description('Import a content calendar from a JSON file')
75
+ .description('Import a content calendar from a JSON file. schedule must be UTC ISO 8601 (Z suffix); timezone is display metadata only.')
27
76
  .requiredOption('--file <path>', 'Path to JSON file with posts array')
28
77
  .option('--project <id>', 'Override active project')
29
78
  .option('--dry-run', 'Show what would be created without actually creating')
@@ -58,6 +107,12 @@ export function registerCalendarCommands(program) {
58
107
  outputError(`Item ${i}: requires content, connection, and schedule fields`, 'INVALID_ITEM');
59
108
  process.exit(1);
60
109
  }
110
+ // Strict UTC validation
111
+ items[i].schedule = validateScheduleUtc(item.schedule, i);
112
+ // Timezone validation if present
113
+ if (item.timezone) {
114
+ validateTimezone(item.timezone, i);
115
+ }
61
116
  }
62
117
  // Compute idempotency keys
63
118
  const itemKeys = items.map((item) => ({
@@ -119,21 +174,16 @@ export function registerCalendarCommands(program) {
119
174
  }
120
175
  return;
121
176
  }
122
- // Process: upload images and create posts sequentially
177
+ // Process: upload media and create posts sequentially
123
178
  let created = 0;
124
179
  let failed = 0;
125
180
  for (const item of toCreate) {
126
181
  try {
127
- // Upload image if present
182
+ // Upload media if present
128
183
  let mediaUrl;
129
- if (item.image) {
130
- const imageData = await readFile(item.image);
131
- const filename = item.image.split('/').pop() || 'image';
132
- const blob = new Blob([imageData]);
133
- const formData = new FormData();
134
- formData.append('file', blob, filename);
135
- const uploadResult = await api.upload('/api/media/upload', formData);
136
- mediaUrl = uploadResult.url;
184
+ const mediaPath = item.media || item.image || item.video;
185
+ if (mediaPath) {
186
+ mediaUrl = await resolveMedia(mediaPath);
137
187
  }
138
188
  const scheduledAt = new Date(item.schedule).toISOString();
139
189
  const data = await api.post(`/api/projects/${projectId}/posts`, {
@@ -203,5 +253,646 @@ export function registerCalendarCommands(program) {
203
253
  process.exit(1);
204
254
  }
205
255
  });
256
+ // ap calendar sync
257
+ calendar
258
+ .command('sync')
259
+ .description('Sync a calendar file with existing posts. Creates, updates, and optionally deletes posts by external_id. schedule must be UTC ISO 8601 (Z suffix); timezone is display metadata only.')
260
+ .requiredOption('--file <path>', 'Path to JSON file with CalendarItem[] (each must have external_id)')
261
+ .option('--project <id>', 'Override active project')
262
+ .option('--delete-missing', 'Delete unposted calendar_sync posts whose external_id is not in the file')
263
+ .option('--dry-run', 'Show what would happen without making changes')
264
+ .option('--json', 'Output as JSON')
265
+ .action(async (options) => {
266
+ initJsonMode(options);
267
+ try {
268
+ // Resolve project
269
+ const projectId = options.project || getActiveProject();
270
+ if (!projectId) {
271
+ outputError('No project set. Use --project, AP_PROJECT env, or `ap projects use <id>`.', 'MISSING_PROJECT');
272
+ process.exit(1);
273
+ }
274
+ // Permission check — fetch project and verify editor+ role
275
+ let project;
276
+ try {
277
+ const data = await api.get(`/api/projects/${projectId}`);
278
+ project = data.project;
279
+ }
280
+ catch (err) {
281
+ if (err instanceof ApiError && err.statusCode === 403) {
282
+ outputError("You don't have access to this workspace.", 'FORBIDDEN');
283
+ process.exit(1);
284
+ }
285
+ throw err;
286
+ }
287
+ const editorRoles = ['owner', 'admin', 'editor'];
288
+ if (!editorRoles.includes(project.user_role)) {
289
+ outputError(`Sync requires editor+ role. Your role is "${project.user_role}". Ask a workspace admin to upgrade your access.`, 'INSUFFICIENT_ROLE');
290
+ process.exit(1);
291
+ }
292
+ // Read and parse file
293
+ let items;
294
+ try {
295
+ const raw = await readFile(options.file, 'utf8');
296
+ items = JSON.parse(raw);
297
+ }
298
+ catch (err) {
299
+ outputError(`Failed to read/parse ${options.file}: ${err}`, 'INVALID_FILE');
300
+ process.exit(1);
301
+ }
302
+ if (!Array.isArray(items) || items.length === 0) {
303
+ outputError('File must contain a non-empty JSON array of items', 'INVALID_FORMAT');
304
+ process.exit(1);
305
+ }
306
+ // Validate: require external_id on every item, strict UTC schedule, IANA timezone
307
+ const seenExternalIds = new Set();
308
+ for (let i = 0; i < items.length; i++) {
309
+ const item = items[i];
310
+ if (!item.content || !item.connection || !item.schedule) {
311
+ outputError(`Item ${i}: requires content, connection, and schedule fields`, 'INVALID_ITEM');
312
+ process.exit(1);
313
+ }
314
+ if (!item.external_id) {
315
+ outputError(`Item ${i}: external_id is required for sync`, 'MISSING_EXTERNAL_ID');
316
+ process.exit(1);
317
+ }
318
+ if (seenExternalIds.has(item.external_id)) {
319
+ outputError(`Duplicate external_id "${item.external_id}" in file`, 'DUPLICATE_EXTERNAL_ID');
320
+ process.exit(1);
321
+ }
322
+ seenExternalIds.add(item.external_id);
323
+ items[i].schedule = validateScheduleUtc(item.schedule, i);
324
+ if (item.timezone) {
325
+ validateTimezone(item.timezone, i);
326
+ }
327
+ }
328
+ // Fetch existing posts by external_ids
329
+ const externalIds = items.map((it) => it.external_id);
330
+ const existingData = await api.post(`/api/projects/${projectId}/posts/by-external-ids`, { external_ids: externalIds });
331
+ const existingPosts = existingData.posts || [];
332
+ const existingByExternalId = new Map();
333
+ for (const p of existingPosts) {
334
+ if (p.external_id)
335
+ existingByExternalId.set(p.external_id, p);
336
+ }
337
+ // Classify each item
338
+ const results = [];
339
+ const toCreate = [];
340
+ const toUpdate = [];
341
+ // Connection changes require delete+recreate (PUT can't change connection/platform)
342
+ const toReplace = [];
343
+ for (const item of items) {
344
+ const eid = item.external_id;
345
+ const existing = existingByExternalId.get(eid);
346
+ if (!existing) {
347
+ toCreate.push(item);
348
+ }
349
+ else if (existing.posted) {
350
+ results.push({
351
+ status: 'skipped_published',
352
+ external_id: eid,
353
+ content: item.content.slice(0, 50),
354
+ connection: item.connection,
355
+ schedule: item.schedule,
356
+ });
357
+ }
358
+ else {
359
+ const connectionMatch = existing.social_connection_id === item.connection;
360
+ if (!connectionMatch) {
361
+ // Connection changed — must delete + recreate
362
+ toReplace.push({ item, existing });
363
+ }
364
+ else {
365
+ // Check if content/schedule/media changed
366
+ const scheduleMatch = new Date(existing.scheduled_at).toISOString() === new Date(item.schedule).toISOString();
367
+ const contentMatch = existing.content.trim() === item.content.trim();
368
+ // Media comparison:
369
+ // - both null → match
370
+ // - one null, other not → changed
371
+ // - both URLs → direct string comparison
372
+ // - file path vs URL → can't verify equality, assume changed
373
+ // (causes reupload on every sync for file-path items, but
374
+ // never silently drops media changes; use URLs to avoid this)
375
+ const itemMedia = item.media || item.image || item.video || null;
376
+ const itemMediaIsUrl = itemMedia !== null && /^https?:\/\//i.test(itemMedia);
377
+ let mediaMatch;
378
+ if (itemMedia === null) {
379
+ mediaMatch = (existing.media_url || null) === null;
380
+ }
381
+ else if (itemMediaIsUrl) {
382
+ mediaMatch = itemMedia === (existing.media_url || null);
383
+ }
384
+ else {
385
+ // File path — can't compare to stored URL, assume changed
386
+ mediaMatch = false;
387
+ }
388
+ if (contentMatch && scheduleMatch && mediaMatch) {
389
+ results.push({
390
+ status: 'unchanged',
391
+ external_id: eid,
392
+ content: item.content.slice(0, 50),
393
+ connection: item.connection,
394
+ schedule: item.schedule,
395
+ post: existing,
396
+ });
397
+ }
398
+ else {
399
+ toUpdate.push({ item, existing });
400
+ }
401
+ }
402
+ }
403
+ }
404
+ // Identify posts to delete (if --delete-missing)
405
+ const toDelete = [];
406
+ if (options.deleteMissing) {
407
+ // Fetch all project posts to find calendar_sync posts not in file
408
+ const allPostsData = await api.get(`/api/projects/${projectId}/posts`);
409
+ const allPosts = allPostsData.posts || [];
410
+ for (const p of allPosts) {
411
+ if (p.external_id &&
412
+ p.source === 'calendar_sync' &&
413
+ !p.posted &&
414
+ !seenExternalIds.has(p.external_id)) {
415
+ toDelete.push(p);
416
+ }
417
+ }
418
+ }
419
+ // Dry run
420
+ if (options.dryRun) {
421
+ const dryResults = [
422
+ ...results,
423
+ ...toCreate.map((item) => ({
424
+ status: 'created',
425
+ external_id: item.external_id,
426
+ content: item.content.slice(0, 50),
427
+ connection: item.connection,
428
+ schedule: item.schedule,
429
+ })),
430
+ ...toReplace.map(({ item }) => ({
431
+ status: 'updated',
432
+ external_id: item.external_id,
433
+ content: item.content.slice(0, 50),
434
+ connection: item.connection,
435
+ schedule: item.schedule,
436
+ })),
437
+ ...toUpdate.map(({ item }) => ({
438
+ status: 'updated',
439
+ external_id: item.external_id,
440
+ content: item.content.slice(0, 50),
441
+ connection: item.connection,
442
+ schedule: item.schedule,
443
+ })),
444
+ ...toDelete.map((p) => ({
445
+ status: 'deleted',
446
+ external_id: p.external_id,
447
+ content: p.content.slice(0, 50),
448
+ connection: p.social_connection_id || '',
449
+ schedule: p.scheduled_at,
450
+ })),
451
+ ];
452
+ const counts = {
453
+ created: toCreate.length,
454
+ updated: toUpdate.length + toReplace.length,
455
+ deleted: toDelete.length,
456
+ unchanged: results.filter((r) => r.status === 'unchanged').length,
457
+ skipped_published: results.filter((r) => r.status === 'skipped_published').length,
458
+ };
459
+ if (isJsonMode(options)) {
460
+ outputJson({ dry_run: true, ...counts, items: dryResults });
461
+ }
462
+ else {
463
+ console.log(chalk.bold('Dry run results:'));
464
+ console.log(` Would create: ${chalk.green(String(counts.created))}`);
465
+ console.log(` Would update: ${chalk.blue(String(counts.updated))}`);
466
+ console.log(` Would delete: ${chalk.red(String(counts.deleted))}`);
467
+ console.log(` Unchanged: ${chalk.dim(String(counts.unchanged))}`);
468
+ console.log(` Skip (posted): ${chalk.yellow(String(counts.skipped_published))}`);
469
+ if (dryResults.length > 0) {
470
+ console.log('');
471
+ outputTable(['ACTION', 'EXTERNAL_ID', 'CONTENT', 'SCHEDULE'], dryResults.map((r) => [
472
+ r.status === 'created' ? chalk.green('create') :
473
+ r.status === 'updated' ? chalk.blue('update') :
474
+ r.status === 'deleted' ? chalk.red('delete') :
475
+ r.status === 'skipped_published' ? chalk.yellow('skip') :
476
+ chalk.dim('unchanged'),
477
+ r.external_id,
478
+ r.content.length > 30 ? r.content.slice(0, 27) + '...' : r.content,
479
+ r.schedule,
480
+ ]));
481
+ }
482
+ }
483
+ return;
484
+ }
485
+ // Execute sync
486
+ let createdCount = 0;
487
+ let updatedCount = 0;
488
+ let deletedCount = 0;
489
+ let failedCount = 0;
490
+ const total = toCreate.length + toReplace.length + toUpdate.length + toDelete.length;
491
+ let processed = 0;
492
+ // Creates
493
+ for (const item of toCreate) {
494
+ let mediaUrl;
495
+ const mediaPath = item.media || item.image || item.video;
496
+ try {
497
+ if (mediaPath) {
498
+ mediaUrl = await resolveMedia(mediaPath);
499
+ }
500
+ const createPayload = {
501
+ content: item.content,
502
+ scheduled_at: new Date(item.schedule).toISOString(),
503
+ social_connection_id: item.connection,
504
+ media_url: mediaUrl,
505
+ post_now: false,
506
+ external_id: item.external_id,
507
+ source: 'calendar_sync',
508
+ };
509
+ if (item.timezone) {
510
+ createPayload.timezone = item.timezone;
511
+ }
512
+ const data = await api.post(`/api/projects/${projectId}/posts`, createPayload);
513
+ results.push({
514
+ status: 'created',
515
+ external_id: item.external_id,
516
+ content: item.content.slice(0, 50),
517
+ connection: item.connection,
518
+ schedule: item.schedule,
519
+ post: data.post,
520
+ });
521
+ createdCount++;
522
+ }
523
+ catch (err) {
524
+ const msg = err instanceof ApiError ? err.message : String(err);
525
+ // Race safety: unique constraint collision → try update
526
+ if (msg.includes('UNIQUE constraint')) {
527
+ try {
528
+ // Re-fetch and update
529
+ const refetch = await api.post(`/api/projects/${projectId}/posts/by-external-ids`, { external_ids: [item.external_id] });
530
+ const existing = refetch.posts?.[0];
531
+ if (existing) {
532
+ const retryPayload = {
533
+ content: item.content,
534
+ scheduled_at: new Date(item.schedule).toISOString(),
535
+ last_synced_at: new Date().toISOString(),
536
+ source: 'calendar_sync',
537
+ };
538
+ if (item.timezone)
539
+ retryPayload.timezone = item.timezone;
540
+ if (mediaUrl !== undefined)
541
+ retryPayload.media_url = mediaUrl;
542
+ await api.put(`/api/posts/${existing.id}`, retryPayload);
543
+ results.push({
544
+ status: 'updated',
545
+ external_id: item.external_id,
546
+ content: item.content.slice(0, 50),
547
+ connection: item.connection,
548
+ schedule: item.schedule,
549
+ });
550
+ updatedCount++;
551
+ }
552
+ else {
553
+ throw new Error(msg);
554
+ }
555
+ }
556
+ catch (retryErr) {
557
+ const retryMsg = retryErr instanceof ApiError ? retryErr.message : String(retryErr);
558
+ results.push({
559
+ status: 'failed',
560
+ external_id: item.external_id,
561
+ content: item.content.slice(0, 50),
562
+ connection: item.connection,
563
+ schedule: item.schedule,
564
+ error: retryMsg,
565
+ });
566
+ failedCount++;
567
+ }
568
+ }
569
+ else {
570
+ results.push({
571
+ status: 'failed',
572
+ external_id: item.external_id,
573
+ content: item.content.slice(0, 50),
574
+ connection: item.connection,
575
+ schedule: item.schedule,
576
+ error: msg,
577
+ });
578
+ failedCount++;
579
+ }
580
+ }
581
+ processed++;
582
+ if (process.stdout.isTTY) {
583
+ process.stdout.write(`\r Syncing: ${processed}/${total}`);
584
+ }
585
+ }
586
+ // Replacements (connection changed → create new, then delete old)
587
+ // Create-first ordering: if create fails, old post is preserved.
588
+ for (const { item, existing } of toReplace) {
589
+ try {
590
+ // Upload media if present (before any mutations)
591
+ let replaceMediaUrl;
592
+ const replaceMediaPath = item.media || item.image || item.video;
593
+ if (replaceMediaPath) {
594
+ replaceMediaUrl = await resolveMedia(replaceMediaPath);
595
+ }
596
+ // 1. Clear external_id on old post so unique constraint won't block create
597
+ await api.put(`/api/posts/${existing.id}`, { external_id: null });
598
+ // 2. Create new post with the external_id
599
+ let newPost;
600
+ try {
601
+ const replacePayload = {
602
+ content: item.content,
603
+ scheduled_at: new Date(item.schedule).toISOString(),
604
+ social_connection_id: item.connection,
605
+ media_url: replaceMediaUrl,
606
+ post_now: false,
607
+ external_id: item.external_id,
608
+ source: 'calendar_sync',
609
+ };
610
+ if (item.timezone) {
611
+ replacePayload.timezone = item.timezone;
612
+ }
613
+ const data = await api.post(`/api/projects/${projectId}/posts`, replacePayload);
614
+ newPost = data.post;
615
+ }
616
+ catch (createErr) {
617
+ // Restore external_id on old post so sync identity stays intact
618
+ try {
619
+ await api.put(`/api/posts/${existing.id}`, { external_id: item.external_id });
620
+ }
621
+ catch { /* best-effort restore */ }
622
+ throw createErr;
623
+ }
624
+ // 3. Delete old post (safe — new one already exists)
625
+ let orphanWarning;
626
+ try {
627
+ await api.delete(`/api/posts/${existing.id}`);
628
+ }
629
+ catch {
630
+ // Delete failed — old post is orphaned without external_id.
631
+ // Restore its external_id with a prefix so --delete-missing can
632
+ // still target it, and it won't collide with the new post's id.
633
+ orphanWarning = `Old post ${existing.id} could not be deleted and is now orphaned. Run sync with --delete-missing to clean up.`;
634
+ try {
635
+ await api.put(`/api/posts/${existing.id}`, {
636
+ external_id: `_orphan:${item.external_id}`,
637
+ source: 'calendar_sync',
638
+ });
639
+ }
640
+ catch { /* best-effort */ }
641
+ if (process.stderr.isTTY) {
642
+ console.error(chalk.yellow('Warning:') + ` ${orphanWarning}`);
643
+ }
644
+ }
645
+ results.push({
646
+ status: 'updated',
647
+ external_id: item.external_id,
648
+ content: item.content.slice(0, 50),
649
+ connection: item.connection,
650
+ schedule: item.schedule,
651
+ post: newPost,
652
+ error: orphanWarning,
653
+ });
654
+ updatedCount++;
655
+ }
656
+ catch (err) {
657
+ const msg = err instanceof ApiError ? err.message : String(err);
658
+ results.push({
659
+ status: 'failed',
660
+ external_id: item.external_id,
661
+ content: item.content.slice(0, 50),
662
+ connection: item.connection,
663
+ schedule: item.schedule,
664
+ error: msg,
665
+ });
666
+ failedCount++;
667
+ }
668
+ processed++;
669
+ if (process.stdout.isTTY) {
670
+ process.stdout.write(`\r Syncing: ${processed}/${total}`);
671
+ }
672
+ }
673
+ // Updates (same connection, content/schedule/media changed)
674
+ for (const { item, existing } of toUpdate) {
675
+ try {
676
+ // Upload media only for new file paths or URL changes
677
+ let mediaUrl;
678
+ const mediaPath = item.media || item.image || item.video;
679
+ if (!mediaPath && existing.media_url) {
680
+ mediaUrl = null; // media removed
681
+ }
682
+ else if (mediaPath) {
683
+ const isUrl = /^https?:\/\//i.test(mediaPath);
684
+ if (isUrl && mediaPath !== existing.media_url) {
685
+ // URL changed — send new URL directly
686
+ mediaUrl = mediaPath;
687
+ }
688
+ else if (!isUrl) {
689
+ // File path — upload it
690
+ mediaUrl = await resolveMedia(mediaPath);
691
+ }
692
+ // else: same URL, no change
693
+ }
694
+ const updatePayload = {
695
+ content: item.content,
696
+ scheduled_at: new Date(item.schedule).toISOString(),
697
+ last_synced_at: new Date().toISOString(),
698
+ source: 'calendar_sync',
699
+ };
700
+ if (mediaUrl !== undefined) {
701
+ updatePayload.media_url = mediaUrl;
702
+ }
703
+ if (item.timezone) {
704
+ updatePayload.timezone = item.timezone;
705
+ }
706
+ await api.put(`/api/posts/${existing.id}`, updatePayload);
707
+ results.push({
708
+ status: 'updated',
709
+ external_id: item.external_id,
710
+ content: item.content.slice(0, 50),
711
+ connection: item.connection,
712
+ schedule: item.schedule,
713
+ });
714
+ updatedCount++;
715
+ }
716
+ catch (err) {
717
+ const msg = err instanceof ApiError ? err.message : String(err);
718
+ results.push({
719
+ status: 'failed',
720
+ external_id: item.external_id,
721
+ content: item.content.slice(0, 50),
722
+ connection: item.connection,
723
+ schedule: item.schedule,
724
+ error: msg,
725
+ });
726
+ failedCount++;
727
+ }
728
+ processed++;
729
+ if (process.stdout.isTTY) {
730
+ process.stdout.write(`\r Syncing: ${processed}/${total}`);
731
+ }
732
+ }
733
+ // Deletes
734
+ for (const post of toDelete) {
735
+ try {
736
+ await api.delete(`/api/posts/${post.id}`);
737
+ results.push({
738
+ status: 'deleted',
739
+ external_id: post.external_id,
740
+ content: post.content.slice(0, 50),
741
+ connection: post.social_connection_id || '',
742
+ schedule: post.scheduled_at,
743
+ });
744
+ deletedCount++;
745
+ }
746
+ catch (err) {
747
+ const msg = err instanceof ApiError ? err.message : String(err);
748
+ results.push({
749
+ status: 'failed',
750
+ external_id: post.external_id,
751
+ content: post.content.slice(0, 50),
752
+ connection: post.social_connection_id || '',
753
+ schedule: post.scheduled_at,
754
+ error: msg,
755
+ });
756
+ failedCount++;
757
+ }
758
+ processed++;
759
+ if (process.stdout.isTTY) {
760
+ process.stdout.write(`\r Syncing: ${processed}/${total}`);
761
+ }
762
+ }
763
+ if (process.stdout.isTTY && total > 0) {
764
+ process.stdout.write('\n');
765
+ }
766
+ const unchangedCount = results.filter((r) => r.status === 'unchanged').length;
767
+ const skippedPublishedCount = results.filter((r) => r.status === 'skipped_published').length;
768
+ if (isJsonMode(options)) {
769
+ outputJson({
770
+ success: failedCount === 0,
771
+ created: createdCount,
772
+ updated: updatedCount,
773
+ deleted: deletedCount,
774
+ unchanged: unchangedCount,
775
+ skipped_published: skippedPublishedCount,
776
+ failed: failedCount,
777
+ items: results,
778
+ });
779
+ }
780
+ else {
781
+ console.log('');
782
+ console.log(chalk.bold('Calendar sync complete:'));
783
+ console.log(` Created: ${chalk.green(String(createdCount))}`);
784
+ console.log(` Updated: ${chalk.blue(String(updatedCount))}`);
785
+ console.log(` Deleted: ${chalk.red(String(deletedCount))}`);
786
+ console.log(` Unchanged: ${chalk.dim(String(unchangedCount))}`);
787
+ console.log(` Skipped: ${chalk.yellow(String(skippedPublishedCount))} (already published)`);
788
+ if (failedCount > 0) {
789
+ console.log(` Failed: ${chalk.red(String(failedCount))}`);
790
+ }
791
+ }
792
+ if (failedCount > 0) {
793
+ process.exit(1);
794
+ }
795
+ }
796
+ catch (err) {
797
+ if (err instanceof ApiError) {
798
+ outputError(err.message, err.code);
799
+ }
800
+ else {
801
+ outputError(String(err));
802
+ }
803
+ process.exit(1);
804
+ }
805
+ });
806
+ // ap calendar pull
807
+ calendar
808
+ .command('pull')
809
+ .description('Pull scheduled posts from the server as a CalendarItem JSON array. Use with sync for round-trip editing.')
810
+ .option('--file <path>', 'Write output to file instead of stdout')
811
+ .option('--project <id>', 'Override active project')
812
+ .option('--include-published', 'Include already-published posts (excluded by default)')
813
+ .option('--from <date>', 'Only include posts scheduled on or after this date (ISO 8601)')
814
+ .option('--to <date>', 'Only include posts scheduled on or before this date (ISO 8601)')
815
+ .option('--json', 'Output as JSON (default when piped)')
816
+ .action(async (options) => {
817
+ initJsonMode(options);
818
+ try {
819
+ const projectId = options.project || getActiveProject();
820
+ if (!projectId) {
821
+ outputError('No project set. Use --project, AP_PROJECT env, or `ap projects use <id>`.', 'MISSING_PROJECT');
822
+ process.exit(1);
823
+ }
824
+ // Validate date filters
825
+ let fromDate = null;
826
+ let toDate = null;
827
+ if (options.from) {
828
+ fromDate = new Date(options.from);
829
+ if (isNaN(fromDate.getTime())) {
830
+ outputError(`Invalid --from date: "${options.from}"`, 'INVALID_DATE');
831
+ process.exit(1);
832
+ }
833
+ }
834
+ if (options.to) {
835
+ toDate = new Date(options.to);
836
+ if (isNaN(toDate.getTime())) {
837
+ outputError(`Invalid --to date: "${options.to}"`, 'INVALID_DATE');
838
+ process.exit(1);
839
+ }
840
+ }
841
+ // Fetch posts
842
+ const data = await api.get(`/api/projects/${projectId}/posts`);
843
+ let posts = data.posts || [];
844
+ // Filter: exclude published by default
845
+ if (!options.includePublished) {
846
+ posts = posts.filter((p) => !p.posted);
847
+ }
848
+ // Filter by date range
849
+ if (fromDate) {
850
+ const fromMs = fromDate.getTime();
851
+ posts = posts.filter((p) => new Date(p.scheduled_at).getTime() >= fromMs);
852
+ }
853
+ if (toDate) {
854
+ const toMs = toDate.getTime();
855
+ posts = posts.filter((p) => new Date(p.scheduled_at).getTime() <= toMs);
856
+ }
857
+ // Map to CalendarItem format
858
+ const items = posts.map((p) => {
859
+ const item = {
860
+ content: p.content,
861
+ connection: p.social_connection_id || '',
862
+ schedule: new Date(p.scheduled_at).toISOString(),
863
+ };
864
+ if (p.external_id) {
865
+ item.external_id = p.external_id;
866
+ }
867
+ if (p.media_url) {
868
+ item.media = p.media_url;
869
+ }
870
+ return item;
871
+ });
872
+ const output = JSON.stringify(items, null, 2);
873
+ if (options.file) {
874
+ await writeFile(options.file, output + '\n', 'utf8');
875
+ if (process.stderr.isTTY) {
876
+ console.error(chalk.green(`Wrote ${items.length} post(s) to ${options.file}`));
877
+ }
878
+ }
879
+ else if (isJsonMode(options)) {
880
+ outputJson({ items, count: items.length });
881
+ }
882
+ else {
883
+ // Default: write raw array to stdout (ready for editing / piping to sync)
884
+ process.stdout.write(output + '\n');
885
+ }
886
+ }
887
+ catch (err) {
888
+ if (err instanceof ApiError) {
889
+ outputError(err.message, err.code);
890
+ }
891
+ else {
892
+ outputError(String(err));
893
+ }
894
+ process.exit(1);
895
+ }
896
+ });
206
897
  }
207
898
  //# sourceMappingURL=calendar.js.map