@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.
- package/dist/src/commands/calendar.js +703 -12
- package/dist/src/commands/calendar.js.map +1 -1
- package/dist/src/commands/context.d.ts +2 -0
- package/dist/src/commands/context.js +178 -0
- package/dist/src/commands/context.js.map +1 -0
- package/dist/src/commands/media.js +10 -4
- package/dist/src/commands/media.js.map +1 -1
- package/dist/src/commands/post.js +46 -12
- package/dist/src/commands/post.js.map +1 -1
- package/dist/src/commands/projects.js +139 -2
- package/dist/src/commands/projects.js.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/media.d.ts +2 -0
- package/dist/src/lib/media.js +20 -0
- package/dist/src/lib/media.js.map +1 -0
- package/dist/src/lib/platform-rules.d.ts +10 -0
- package/dist/src/lib/platform-rules.js +9 -0
- package/dist/src/lib/platform-rules.js.map +1 -0
- package/dist/src/types.d.ts +25 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
182
|
+
// Upload media if present
|
|
128
183
|
let mediaUrl;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|