myaidev-method 0.2.24-1 → 0.2.24-2
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/.claude-plugin/plugin.json +251 -0
- package/PLUGIN_ARCHITECTURE.md +276 -0
- package/README.md +204 -0
- package/USER_GUIDE.md +436 -9
- package/bin/cli.js +152 -0
- package/extension.json +174 -0
- package/hooks/hooks.json +221 -0
- package/marketplace.json +179 -0
- package/package.json +15 -3
- package/skills/content-verifier/SKILL.md +178 -0
- package/skills/content-writer/SKILL.md +151 -0
- package/skills/coolify-deployer/SKILL.md +207 -0
- package/skills/openstack-manager/SKILL.md +213 -0
- package/skills/security-auditor/SKILL.md +180 -0
- package/skills/security-tester/SKILL.md +171 -0
- package/skills/sparc-architect/SKILL.md +146 -0
- package/skills/sparc-coder/SKILL.md +136 -0
- package/skills/sparc-documenter/SKILL.md +195 -0
- package/skills/sparc-reviewer/SKILL.md +179 -0
- package/skills/sparc-tester/SKILL.md +156 -0
- package/skills/visual-generator/SKILL.md +147 -0
- package/skills/wordpress-publisher/SKILL.md +150 -0
- package/src/lib/content-coordinator.js +2562 -0
- package/src/lib/installation-detector.js +266 -0
- package/src/lib/visual-config-utils.js +1 -1
- package/src/lib/visual-generation-utils.js +34 -14
- package/src/scripts/generate-visual-cli.js +39 -10
- package/src/scripts/ping.js +0 -1
- package/src/templates/claude/agents/content-production-coordinator.md +689 -15
- package/src/templates/claude/commands/myai-content-enrichment.md +227 -0
- package/src/templates/claude/commands/myai-content-writer.md +48 -37
- package/src/templates/claude/commands/myai-coordinate-content.md +347 -11
|
@@ -0,0 +1,2562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Production Coordinator
|
|
3
|
+
*
|
|
4
|
+
* State machine implementation for coordinating content verification and publishing.
|
|
5
|
+
* Provides checkpoint/resume capability, error isolation, progress tracking,
|
|
6
|
+
* multi-platform publishing, analytics, webhooks, and queue management.
|
|
7
|
+
*
|
|
8
|
+
* @module content-coordinator
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import https from 'https';
|
|
14
|
+
import http from 'http';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Workflow phases for the content production coordinator
|
|
19
|
+
*/
|
|
20
|
+
const PHASES = {
|
|
21
|
+
INITIALIZE: 'initialize',
|
|
22
|
+
VERIFY: 'verify',
|
|
23
|
+
CATEGORIZE: 'categorize',
|
|
24
|
+
REPORT: 'report',
|
|
25
|
+
NOTIFY: 'notify',
|
|
26
|
+
PUBLISH: 'publish',
|
|
27
|
+
COMPLETE: 'complete'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Content item status values
|
|
32
|
+
*/
|
|
33
|
+
const ITEM_STATUS = {
|
|
34
|
+
PENDING: 'pending',
|
|
35
|
+
VERIFYING: 'verifying',
|
|
36
|
+
VERIFIED: 'verified',
|
|
37
|
+
READY: 'ready',
|
|
38
|
+
NEEDS_REVIEW: 'needs_review',
|
|
39
|
+
PUBLISHING: 'publishing',
|
|
40
|
+
PUBLISHED: 'published',
|
|
41
|
+
FAILED: 'failed',
|
|
42
|
+
SKIPPED: 'skipped'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Redundancy score thresholds
|
|
47
|
+
*/
|
|
48
|
+
const REDUNDANCY_THRESHOLDS = {
|
|
49
|
+
MINIMAL: 'minimal',
|
|
50
|
+
LOW: 'low',
|
|
51
|
+
MEDIUM: 'medium',
|
|
52
|
+
HIGH: 'high'
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Supported publishing platforms
|
|
57
|
+
*/
|
|
58
|
+
const PLATFORMS = {
|
|
59
|
+
WORDPRESS: 'wordpress',
|
|
60
|
+
PAYLOADCMS: 'payloadcms',
|
|
61
|
+
STATIC: 'static',
|
|
62
|
+
DOCUSAURUS: 'docusaurus',
|
|
63
|
+
MINTLIFY: 'mintlify',
|
|
64
|
+
ASTRO: 'astro'
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Default configuration for the coordinator
|
|
69
|
+
*/
|
|
70
|
+
const DEFAULT_CONFIG = {
|
|
71
|
+
concurrency: 3,
|
|
72
|
+
retryAttempts: 2,
|
|
73
|
+
retryDelay: 1000,
|
|
74
|
+
checkpointInterval: 5000,
|
|
75
|
+
stateFileName: '.content-coordinator-state.json',
|
|
76
|
+
queueFileName: '.content-queue.json',
|
|
77
|
+
outputDir: '.',
|
|
78
|
+
dryRun: false,
|
|
79
|
+
force: false,
|
|
80
|
+
verbose: false,
|
|
81
|
+
// Content rules
|
|
82
|
+
contentRulesPath: null,
|
|
83
|
+
// Webhooks
|
|
84
|
+
webhookUrl: null,
|
|
85
|
+
webhookEvents: ['complete', 'error'],
|
|
86
|
+
// Analytics
|
|
87
|
+
enableAnalytics: true,
|
|
88
|
+
// Platform
|
|
89
|
+
defaultPlatform: PLATFORMS.WORDPRESS,
|
|
90
|
+
// Cron/Scheduling
|
|
91
|
+
cronMode: false,
|
|
92
|
+
lockFileName: '.content-coordinator.lock',
|
|
93
|
+
lastRunFileName: '.content-coordinator-lastrun.json',
|
|
94
|
+
minRunInterval: 0, // Minimum seconds between runs (0 = no limit)
|
|
95
|
+
quietMode: false, // Suppress non-error output for cron
|
|
96
|
+
// WordPress Scheduling
|
|
97
|
+
useWordPressScheduling: true, // Use WP native scheduling for future posts
|
|
98
|
+
defaultPublishDelay: 0, // Hours to delay publication (0 = immediate)
|
|
99
|
+
publishSpreadInterval: 0 // Hours between scheduled posts (0 = no spreading)
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* ContentRulesManager - Handles loading and applying content rules
|
|
104
|
+
*/
|
|
105
|
+
class ContentRulesManager {
|
|
106
|
+
constructor(rulesPath) {
|
|
107
|
+
this.rulesPath = rulesPath;
|
|
108
|
+
this.rules = null;
|
|
109
|
+
this.loaded = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load content rules from file
|
|
114
|
+
* @returns {Promise<Object|null>} Parsed rules or null
|
|
115
|
+
*/
|
|
116
|
+
async load() {
|
|
117
|
+
if (!this.rulesPath) return null;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const content = await fs.readFile(this.rulesPath, 'utf8');
|
|
121
|
+
this.rules = this.parseContentRules(content);
|
|
122
|
+
this.loaded = true;
|
|
123
|
+
return this.rules;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.code !== 'ENOENT') {
|
|
126
|
+
console.error('[ContentRules] Error loading rules:', err.message);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse content-rules.md file
|
|
134
|
+
* @param {string} content - Raw file content
|
|
135
|
+
* @returns {Object} Parsed rules
|
|
136
|
+
*/
|
|
137
|
+
parseContentRules(content) {
|
|
138
|
+
const rules = {
|
|
139
|
+
brandIdentity: {},
|
|
140
|
+
voiceTone: {},
|
|
141
|
+
writingStyle: {},
|
|
142
|
+
seoGuidelines: {},
|
|
143
|
+
formatting: {},
|
|
144
|
+
contentBoundaries: {}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Extract sections using markdown headers
|
|
148
|
+
const sections = content.split(/^## /m).slice(1);
|
|
149
|
+
|
|
150
|
+
for (const section of sections) {
|
|
151
|
+
const lines = section.split('\n');
|
|
152
|
+
const header = lines[0].trim().toLowerCase();
|
|
153
|
+
const body = lines.slice(1).join('\n').trim();
|
|
154
|
+
|
|
155
|
+
if (header.includes('brand') || header.includes('identity')) {
|
|
156
|
+
rules.brandIdentity = this.parseSection(body);
|
|
157
|
+
} else if (header.includes('voice') || header.includes('tone')) {
|
|
158
|
+
rules.voiceTone = this.parseSection(body);
|
|
159
|
+
} else if (header.includes('writing') || header.includes('style')) {
|
|
160
|
+
rules.writingStyle = this.parseSection(body);
|
|
161
|
+
} else if (header.includes('seo')) {
|
|
162
|
+
rules.seoGuidelines = this.parseSection(body);
|
|
163
|
+
} else if (header.includes('format')) {
|
|
164
|
+
rules.formatting = this.parseSection(body);
|
|
165
|
+
} else if (header.includes('boundar') || header.includes('avoid')) {
|
|
166
|
+
rules.contentBoundaries = this.parseSection(body);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return rules;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse a section into key-value pairs
|
|
175
|
+
* @param {string} body - Section body
|
|
176
|
+
* @returns {Object} Parsed key-values
|
|
177
|
+
*/
|
|
178
|
+
parseSection(body) {
|
|
179
|
+
const result = {};
|
|
180
|
+
const lines = body.split('\n');
|
|
181
|
+
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
// Parse list items: - **Key**: Value
|
|
184
|
+
const match = line.match(/^[-*]\s*\*\*([^*]+)\*\*:\s*(.+)$/);
|
|
185
|
+
if (match) {
|
|
186
|
+
const key = match[1].trim().toLowerCase().replace(/\s+/g, '_');
|
|
187
|
+
result[key] = match[2].trim();
|
|
188
|
+
}
|
|
189
|
+
// Parse simple list items: - Value
|
|
190
|
+
const simpleMatch = line.match(/^[-*]\s+(.+)$/);
|
|
191
|
+
if (simpleMatch && !match) {
|
|
192
|
+
if (!result.items) result.items = [];
|
|
193
|
+
result.items.push(simpleMatch[1].trim());
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get rules for verification context
|
|
202
|
+
* @returns {Object} Rules relevant for verification
|
|
203
|
+
*/
|
|
204
|
+
getVerificationContext() {
|
|
205
|
+
if (!this.rules) return null;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
brandVoice: this.rules.voiceTone,
|
|
209
|
+
writingStyle: this.rules.writingStyle,
|
|
210
|
+
contentBoundaries: this.rules.contentBoundaries,
|
|
211
|
+
checkBrandAlignment: true
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get rules for publishing context
|
|
217
|
+
* @returns {Object} Rules relevant for publishing
|
|
218
|
+
*/
|
|
219
|
+
getPublishingContext() {
|
|
220
|
+
if (!this.rules) return null;
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
brandIdentity: this.rules.brandIdentity,
|
|
224
|
+
seoGuidelines: this.rules.seoGuidelines,
|
|
225
|
+
formatting: this.rules.formatting
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* AnalyticsTracker - Tracks timing, success rates, and quality metrics
|
|
232
|
+
*/
|
|
233
|
+
class AnalyticsTracker {
|
|
234
|
+
constructor() {
|
|
235
|
+
this.metrics = {
|
|
236
|
+
workflow: {
|
|
237
|
+
startTime: null,
|
|
238
|
+
endTime: null,
|
|
239
|
+
duration: null,
|
|
240
|
+
phases: {}
|
|
241
|
+
},
|
|
242
|
+
items: {
|
|
243
|
+
total: 0,
|
|
244
|
+
verified: 0,
|
|
245
|
+
published: 0,
|
|
246
|
+
failed: 0,
|
|
247
|
+
skipped: 0
|
|
248
|
+
},
|
|
249
|
+
performance: {
|
|
250
|
+
avgVerificationTime: 0,
|
|
251
|
+
avgPublishTime: 0,
|
|
252
|
+
totalVerificationTime: 0,
|
|
253
|
+
totalPublishTime: 0
|
|
254
|
+
},
|
|
255
|
+
quality: {
|
|
256
|
+
avgRedundancyScore: 0,
|
|
257
|
+
avgUniquenessScore: 0,
|
|
258
|
+
contentQualityTrend: []
|
|
259
|
+
},
|
|
260
|
+
errors: []
|
|
261
|
+
};
|
|
262
|
+
this.phaseTimers = {};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Start workflow tracking
|
|
267
|
+
*/
|
|
268
|
+
startWorkflow() {
|
|
269
|
+
this.metrics.workflow.startTime = Date.now();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* End workflow tracking
|
|
274
|
+
*/
|
|
275
|
+
endWorkflow() {
|
|
276
|
+
this.metrics.workflow.endTime = Date.now();
|
|
277
|
+
this.metrics.workflow.duration = this.metrics.workflow.endTime - this.metrics.workflow.startTime;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Start phase timing
|
|
282
|
+
* @param {string} phase - Phase name
|
|
283
|
+
*/
|
|
284
|
+
startPhase(phase) {
|
|
285
|
+
this.phaseTimers[phase] = Date.now();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* End phase timing
|
|
290
|
+
* @param {string} phase - Phase name
|
|
291
|
+
*/
|
|
292
|
+
endPhase(phase) {
|
|
293
|
+
if (this.phaseTimers[phase]) {
|
|
294
|
+
this.metrics.workflow.phases[phase] = {
|
|
295
|
+
duration: Date.now() - this.phaseTimers[phase],
|
|
296
|
+
completedAt: new Date().toISOString()
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Track item verification
|
|
303
|
+
* @param {Object} item - Content item
|
|
304
|
+
* @param {number} duration - Verification duration in ms
|
|
305
|
+
*/
|
|
306
|
+
trackVerification(item, duration) {
|
|
307
|
+
this.metrics.items.verified++;
|
|
308
|
+
this.metrics.performance.totalVerificationTime += duration;
|
|
309
|
+
this.metrics.performance.avgVerificationTime =
|
|
310
|
+
this.metrics.performance.totalVerificationTime / this.metrics.items.verified;
|
|
311
|
+
|
|
312
|
+
// Track quality metrics
|
|
313
|
+
if (item.verification) {
|
|
314
|
+
const scores = { minimal: 1, low: 2, medium: 3, high: 4 };
|
|
315
|
+
const score = (item.verification.redundancyScore || '').toLowerCase();
|
|
316
|
+
if (scores[score]) {
|
|
317
|
+
this.metrics.quality.contentQualityTrend.push({
|
|
318
|
+
item: item.metadata.title,
|
|
319
|
+
redundancyScore: scores[score],
|
|
320
|
+
uniquenessScore: item.verification.uniquenessScore || 0,
|
|
321
|
+
timestamp: new Date().toISOString()
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Update averages
|
|
325
|
+
const trend = this.metrics.quality.contentQualityTrend;
|
|
326
|
+
this.metrics.quality.avgRedundancyScore =
|
|
327
|
+
trend.reduce((sum, t) => sum + t.redundancyScore, 0) / trend.length;
|
|
328
|
+
this.metrics.quality.avgUniquenessScore =
|
|
329
|
+
trend.reduce((sum, t) => sum + (t.uniquenessScore || 0), 0) / trend.length;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Track item publishing
|
|
336
|
+
* @param {Object} item - Content item
|
|
337
|
+
* @param {number} duration - Publishing duration in ms
|
|
338
|
+
*/
|
|
339
|
+
trackPublishing(item, duration) {
|
|
340
|
+
this.metrics.items.published++;
|
|
341
|
+
this.metrics.performance.totalPublishTime += duration;
|
|
342
|
+
this.metrics.performance.avgPublishTime =
|
|
343
|
+
this.metrics.performance.totalPublishTime / this.metrics.items.published;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Track error
|
|
348
|
+
* @param {Object} item - Content item
|
|
349
|
+
* @param {Error} error - Error object
|
|
350
|
+
* @param {string} phase - Phase where error occurred
|
|
351
|
+
*/
|
|
352
|
+
trackError(item, error, phase) {
|
|
353
|
+
this.metrics.items.failed++;
|
|
354
|
+
this.metrics.errors.push({
|
|
355
|
+
item: item.metadata?.title || 'Unknown',
|
|
356
|
+
error: error.message,
|
|
357
|
+
phase,
|
|
358
|
+
timestamp: new Date().toISOString()
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Set total items count
|
|
364
|
+
* @param {number} count - Total items
|
|
365
|
+
*/
|
|
366
|
+
setTotalItems(count) {
|
|
367
|
+
this.metrics.items.total = count;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Get analytics summary
|
|
372
|
+
* @returns {Object} Analytics summary
|
|
373
|
+
*/
|
|
374
|
+
getSummary() {
|
|
375
|
+
const successRate = this.metrics.items.total > 0
|
|
376
|
+
? ((this.metrics.items.published / this.metrics.items.total) * 100).toFixed(1)
|
|
377
|
+
: 0;
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
...this.metrics,
|
|
381
|
+
summary: {
|
|
382
|
+
successRate: `${successRate}%`,
|
|
383
|
+
totalDuration: this.formatDuration(this.metrics.workflow.duration),
|
|
384
|
+
avgVerificationTime: this.formatDuration(this.metrics.performance.avgVerificationTime),
|
|
385
|
+
avgPublishTime: this.formatDuration(this.metrics.performance.avgPublishTime),
|
|
386
|
+
avgQualityScore: (5 - this.metrics.quality.avgRedundancyScore).toFixed(2) + '/4'
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Format duration to human-readable string
|
|
393
|
+
* @param {number} ms - Duration in milliseconds
|
|
394
|
+
* @returns {string} Formatted duration
|
|
395
|
+
*/
|
|
396
|
+
formatDuration(ms) {
|
|
397
|
+
if (!ms) return 'N/A';
|
|
398
|
+
const seconds = Math.floor(ms / 1000);
|
|
399
|
+
const minutes = Math.floor(seconds / 60);
|
|
400
|
+
const hours = Math.floor(minutes / 60);
|
|
401
|
+
|
|
402
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
403
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
404
|
+
return `${seconds}s`;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* WebhookNotifier - Sends notifications to external systems
|
|
410
|
+
*/
|
|
411
|
+
class WebhookNotifier {
|
|
412
|
+
constructor(config) {
|
|
413
|
+
this.url = config.webhookUrl;
|
|
414
|
+
this.events = config.webhookEvents || ['complete', 'error'];
|
|
415
|
+
this.enabled = !!this.url;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Send webhook notification
|
|
420
|
+
* @param {string} event - Event type
|
|
421
|
+
* @param {Object} payload - Event payload
|
|
422
|
+
* @returns {Promise<boolean>} Success status
|
|
423
|
+
*/
|
|
424
|
+
async notify(event, payload) {
|
|
425
|
+
if (!this.enabled || !this.events.includes(event)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const data = JSON.stringify({
|
|
430
|
+
event,
|
|
431
|
+
timestamp: new Date().toISOString(),
|
|
432
|
+
payload
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
try {
|
|
437
|
+
const urlObj = new URL(this.url);
|
|
438
|
+
const options = {
|
|
439
|
+
hostname: urlObj.hostname,
|
|
440
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
441
|
+
path: urlObj.pathname + urlObj.search,
|
|
442
|
+
method: 'POST',
|
|
443
|
+
headers: {
|
|
444
|
+
'Content-Type': 'application/json',
|
|
445
|
+
'Content-Length': Buffer.byteLength(data),
|
|
446
|
+
'User-Agent': 'ContentCoordinator/1.0'
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
451
|
+
const req = protocol.request(options, (res) => {
|
|
452
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
req.on('error', (err) => {
|
|
456
|
+
console.error('[Webhook] Error:', err.message);
|
|
457
|
+
resolve(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
req.setTimeout(5000, () => {
|
|
461
|
+
req.destroy();
|
|
462
|
+
resolve(false);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
req.write(data);
|
|
466
|
+
req.end();
|
|
467
|
+
} catch (err) {
|
|
468
|
+
console.error('[Webhook] Error:', err.message);
|
|
469
|
+
resolve(false);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Notify workflow start
|
|
476
|
+
* @param {Object} info - Workflow info
|
|
477
|
+
*/
|
|
478
|
+
async notifyStart(info) {
|
|
479
|
+
await this.notify('start', info);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Notify workflow complete
|
|
484
|
+
* @param {Object} results - Final results
|
|
485
|
+
*/
|
|
486
|
+
async notifyComplete(results) {
|
|
487
|
+
await this.notify('complete', results);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Notify error
|
|
492
|
+
* @param {Object} error - Error info
|
|
493
|
+
*/
|
|
494
|
+
async notifyError(error) {
|
|
495
|
+
await this.notify('error', error);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Notify item published
|
|
500
|
+
* @param {Object} item - Published item info
|
|
501
|
+
*/
|
|
502
|
+
async notifyPublished(item) {
|
|
503
|
+
await this.notify('published', item);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* QueueManager - Manages persistent content queue
|
|
509
|
+
*/
|
|
510
|
+
class QueueManager {
|
|
511
|
+
constructor(queuePath) {
|
|
512
|
+
this.queuePath = queuePath;
|
|
513
|
+
this.queue = {
|
|
514
|
+
version: '1.0',
|
|
515
|
+
created: null,
|
|
516
|
+
updated: null,
|
|
517
|
+
items: []
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Load queue from file
|
|
523
|
+
* @returns {Promise<Object>} Queue data
|
|
524
|
+
*/
|
|
525
|
+
async load() {
|
|
526
|
+
try {
|
|
527
|
+
const data = await fs.readFile(this.queuePath, 'utf8');
|
|
528
|
+
this.queue = JSON.parse(data);
|
|
529
|
+
return this.queue;
|
|
530
|
+
} catch (err) {
|
|
531
|
+
if (err.code !== 'ENOENT') {
|
|
532
|
+
console.error('[QueueManager] Error loading queue:', err.message);
|
|
533
|
+
}
|
|
534
|
+
this.queue.created = new Date().toISOString();
|
|
535
|
+
return this.queue;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Save queue to file
|
|
541
|
+
* @returns {Promise<void>}
|
|
542
|
+
*/
|
|
543
|
+
async save() {
|
|
544
|
+
this.queue.updated = new Date().toISOString();
|
|
545
|
+
await fs.writeFile(this.queuePath, JSON.stringify(this.queue, null, 2));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Add item to queue
|
|
550
|
+
* @param {Object} item - Item to add
|
|
551
|
+
* @returns {Object} Added item with ID
|
|
552
|
+
*/
|
|
553
|
+
async addItem(item) {
|
|
554
|
+
const queueItem = {
|
|
555
|
+
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
556
|
+
addedAt: new Date().toISOString(),
|
|
557
|
+
status: 'pending',
|
|
558
|
+
priority: item.priority || 'normal',
|
|
559
|
+
filePath: item.filePath,
|
|
560
|
+
metadata: item.metadata || {},
|
|
561
|
+
...item
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
this.queue.items.push(queueItem);
|
|
565
|
+
await this.save();
|
|
566
|
+
return queueItem;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Remove item from queue
|
|
571
|
+
* @param {string} id - Item ID to remove
|
|
572
|
+
* @returns {boolean} Whether item was removed
|
|
573
|
+
*/
|
|
574
|
+
async removeItem(id) {
|
|
575
|
+
const index = this.queue.items.findIndex(item => item.id === id);
|
|
576
|
+
if (index === -1) return false;
|
|
577
|
+
|
|
578
|
+
this.queue.items.splice(index, 1);
|
|
579
|
+
await this.save();
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Update item status
|
|
585
|
+
* @param {string} id - Item ID
|
|
586
|
+
* @param {string} status - New status
|
|
587
|
+
* @param {Object} [extra] - Additional data to merge
|
|
588
|
+
*/
|
|
589
|
+
async updateItemStatus(id, status, extra = {}) {
|
|
590
|
+
const item = this.queue.items.find(i => i.id === id);
|
|
591
|
+
if (item) {
|
|
592
|
+
item.status = status;
|
|
593
|
+
item.updatedAt = new Date().toISOString();
|
|
594
|
+
Object.assign(item, extra);
|
|
595
|
+
await this.save();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Get pending items sorted by priority
|
|
601
|
+
* @returns {Object[]} Pending items
|
|
602
|
+
*/
|
|
603
|
+
getPendingItems() {
|
|
604
|
+
const priorityOrder = { high: 0, normal: 1, low: 2 };
|
|
605
|
+
return this.queue.items
|
|
606
|
+
.filter(item => item.status === 'pending')
|
|
607
|
+
.sort((a, b) => {
|
|
608
|
+
const pA = priorityOrder[a.priority] ?? 1;
|
|
609
|
+
const pB = priorityOrder[b.priority] ?? 1;
|
|
610
|
+
if (pA !== pB) return pA - pB;
|
|
611
|
+
return new Date(a.addedAt) - new Date(b.addedAt);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Get queue statistics
|
|
617
|
+
* @returns {Object} Queue stats
|
|
618
|
+
*/
|
|
619
|
+
getStats() {
|
|
620
|
+
const stats = {
|
|
621
|
+
total: this.queue.items.length,
|
|
622
|
+
pending: 0,
|
|
623
|
+
processing: 0,
|
|
624
|
+
completed: 0,
|
|
625
|
+
failed: 0,
|
|
626
|
+
byPriority: { high: 0, normal: 0, low: 0 }
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
for (const item of this.queue.items) {
|
|
630
|
+
if (item.status === 'pending') stats.pending++;
|
|
631
|
+
else if (item.status === 'processing') stats.processing++;
|
|
632
|
+
else if (item.status === 'completed' || item.status === 'published') stats.completed++;
|
|
633
|
+
else if (item.status === 'failed') stats.failed++;
|
|
634
|
+
|
|
635
|
+
if (stats.byPriority[item.priority] !== undefined) {
|
|
636
|
+
stats.byPriority[item.priority]++;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return stats;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Clear completed items from queue
|
|
645
|
+
* @returns {number} Number of items cleared
|
|
646
|
+
*/
|
|
647
|
+
async clearCompleted() {
|
|
648
|
+
const before = this.queue.items.length;
|
|
649
|
+
this.queue.items = this.queue.items.filter(
|
|
650
|
+
item => !['completed', 'published'].includes(item.status)
|
|
651
|
+
);
|
|
652
|
+
await this.save();
|
|
653
|
+
return before - this.queue.items.length;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* PlatformPublisher - Abstract publishing interface for multiple platforms
|
|
659
|
+
*/
|
|
660
|
+
class PlatformPublisher {
|
|
661
|
+
constructor(config) {
|
|
662
|
+
this.config = config;
|
|
663
|
+
this.platforms = new Map();
|
|
664
|
+
this.initializePlatforms();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Initialize platform handlers
|
|
669
|
+
*/
|
|
670
|
+
initializePlatforms() {
|
|
671
|
+
// WordPress publisher
|
|
672
|
+
this.platforms.set(PLATFORMS.WORDPRESS, {
|
|
673
|
+
name: 'WordPress',
|
|
674
|
+
publish: async (item, publishFn) => {
|
|
675
|
+
return publishFn(item, {
|
|
676
|
+
platform: PLATFORMS.WORDPRESS,
|
|
677
|
+
endpoint: 'wp-json/wp/v2/posts'
|
|
678
|
+
});
|
|
679
|
+
},
|
|
680
|
+
validateConfig: () => {
|
|
681
|
+
return !!(process.env.WORDPRESS_URL && process.env.WORDPRESS_APP_PASSWORD);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// PayloadCMS publisher
|
|
686
|
+
this.platforms.set(PLATFORMS.PAYLOADCMS, {
|
|
687
|
+
name: 'PayloadCMS',
|
|
688
|
+
publish: async (item, publishFn) => {
|
|
689
|
+
return publishFn(item, {
|
|
690
|
+
platform: PLATFORMS.PAYLOADCMS,
|
|
691
|
+
endpoint: 'api/posts'
|
|
692
|
+
});
|
|
693
|
+
},
|
|
694
|
+
validateConfig: () => {
|
|
695
|
+
return !!(process.env.PAYLOADCMS_URL && process.env.PAYLOADCMS_EMAIL);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// Static site generator (writes to file)
|
|
700
|
+
this.platforms.set(PLATFORMS.STATIC, {
|
|
701
|
+
name: 'Static',
|
|
702
|
+
publish: async (item, publishFn) => {
|
|
703
|
+
return publishFn(item, {
|
|
704
|
+
platform: PLATFORMS.STATIC,
|
|
705
|
+
outputDir: this.config.staticOutputDir || './content'
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
validateConfig: () => true
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Docusaurus
|
|
712
|
+
this.platforms.set(PLATFORMS.DOCUSAURUS, {
|
|
713
|
+
name: 'Docusaurus',
|
|
714
|
+
publish: async (item, publishFn) => {
|
|
715
|
+
return publishFn(item, {
|
|
716
|
+
platform: PLATFORMS.DOCUSAURUS,
|
|
717
|
+
outputDir: this.config.docusaurusDir || './docs'
|
|
718
|
+
});
|
|
719
|
+
},
|
|
720
|
+
validateConfig: () => true
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// Mintlify
|
|
724
|
+
this.platforms.set(PLATFORMS.MINTLIFY, {
|
|
725
|
+
name: 'Mintlify',
|
|
726
|
+
publish: async (item, publishFn) => {
|
|
727
|
+
return publishFn(item, {
|
|
728
|
+
platform: PLATFORMS.MINTLIFY,
|
|
729
|
+
outputDir: this.config.mintlifyDir || './docs'
|
|
730
|
+
});
|
|
731
|
+
},
|
|
732
|
+
validateConfig: () => true
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// Astro
|
|
736
|
+
this.platforms.set(PLATFORMS.ASTRO, {
|
|
737
|
+
name: 'Astro',
|
|
738
|
+
publish: async (item, publishFn) => {
|
|
739
|
+
return publishFn(item, {
|
|
740
|
+
platform: PLATFORMS.ASTRO,
|
|
741
|
+
outputDir: this.config.astroDir || './src/content'
|
|
742
|
+
});
|
|
743
|
+
},
|
|
744
|
+
validateConfig: () => true
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Get publisher for platform
|
|
750
|
+
* @param {string} platform - Platform name
|
|
751
|
+
* @returns {Object|null} Platform publisher
|
|
752
|
+
*/
|
|
753
|
+
getPublisher(platform) {
|
|
754
|
+
return this.platforms.get(platform) || this.platforms.get(this.config.defaultPlatform);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Validate platform configuration
|
|
759
|
+
* @param {string} platform - Platform name
|
|
760
|
+
* @returns {boolean} Whether config is valid
|
|
761
|
+
*/
|
|
762
|
+
validatePlatform(platform) {
|
|
763
|
+
const publisher = this.getPublisher(platform);
|
|
764
|
+
return publisher ? publisher.validateConfig() : false;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Get available platforms
|
|
769
|
+
* @returns {string[]} List of configured platforms
|
|
770
|
+
*/
|
|
771
|
+
getAvailablePlatforms() {
|
|
772
|
+
const available = [];
|
|
773
|
+
for (const [name, publisher] of this.platforms) {
|
|
774
|
+
if (publisher.validateConfig()) {
|
|
775
|
+
available.push(name);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return available;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* CronHelper - Manages cron-safe execution and crontab integration
|
|
784
|
+
*
|
|
785
|
+
* Makes the coordinator safe to run from Linux crontab by:
|
|
786
|
+
* - Using lock files to prevent concurrent runs
|
|
787
|
+
* - Tracking last run times
|
|
788
|
+
* - Providing quiet mode for cron output
|
|
789
|
+
* - Generating crontab entry suggestions
|
|
790
|
+
*/
|
|
791
|
+
class CronHelper {
|
|
792
|
+
constructor(config) {
|
|
793
|
+
this.config = config;
|
|
794
|
+
this.lockPath = path.join(config.outputDir, config.lockFileName);
|
|
795
|
+
this.lastRunPath = path.join(config.outputDir, config.lastRunFileName);
|
|
796
|
+
this.lockFd = null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Acquire exclusive lock to prevent concurrent runs
|
|
801
|
+
* @returns {Promise<boolean>} Whether lock was acquired
|
|
802
|
+
*/
|
|
803
|
+
async acquireLock() {
|
|
804
|
+
try {
|
|
805
|
+
// Check if lock file exists and is stale
|
|
806
|
+
try {
|
|
807
|
+
const lockData = await fs.readFile(this.lockPath, 'utf8');
|
|
808
|
+
const lock = JSON.parse(lockData);
|
|
809
|
+
|
|
810
|
+
// Check if lock is stale (older than 1 hour)
|
|
811
|
+
const lockAge = Date.now() - new Date(lock.timestamp).getTime();
|
|
812
|
+
const maxAge = 60 * 60 * 1000; // 1 hour
|
|
813
|
+
|
|
814
|
+
if (lockAge > maxAge) {
|
|
815
|
+
console.error('[CronHelper] Stale lock detected, removing...');
|
|
816
|
+
await fs.unlink(this.lockPath);
|
|
817
|
+
} else {
|
|
818
|
+
// Lock is held by another process
|
|
819
|
+
if (!this.config.quietMode) {
|
|
820
|
+
console.log(`[CronHelper] Lock held by PID ${lock.pid} since ${lock.timestamp}`);
|
|
821
|
+
}
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
} catch (err) {
|
|
825
|
+
// Lock file doesn't exist, we can proceed
|
|
826
|
+
if (err.code !== 'ENOENT') throw err;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Create lock file
|
|
830
|
+
const lockData = {
|
|
831
|
+
pid: process.pid,
|
|
832
|
+
timestamp: new Date().toISOString(),
|
|
833
|
+
hostname: os.hostname()
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
await fs.writeFile(this.lockPath, JSON.stringify(lockData, null, 2), { flag: 'wx' });
|
|
837
|
+
return true;
|
|
838
|
+
} catch (err) {
|
|
839
|
+
if (err.code === 'EEXIST') {
|
|
840
|
+
// Another process created the lock first
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Release the lock
|
|
849
|
+
* @returns {Promise<void>}
|
|
850
|
+
*/
|
|
851
|
+
async releaseLock() {
|
|
852
|
+
try {
|
|
853
|
+
await fs.unlink(this.lockPath);
|
|
854
|
+
} catch (err) {
|
|
855
|
+
if (err.code !== 'ENOENT') {
|
|
856
|
+
console.error('[CronHelper] Error releasing lock:', err.message);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Check if minimum run interval has passed
|
|
863
|
+
* @returns {Promise<{canRun: boolean, nextRunTime: Date|null, lastRun: Date|null}>}
|
|
864
|
+
*/
|
|
865
|
+
async checkRunInterval() {
|
|
866
|
+
if (this.config.minRunInterval <= 0) {
|
|
867
|
+
return { canRun: true, nextRunTime: null, lastRun: null };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
const data = await fs.readFile(this.lastRunPath, 'utf8');
|
|
872
|
+
const lastRunInfo = JSON.parse(data);
|
|
873
|
+
const lastRun = new Date(lastRunInfo.timestamp);
|
|
874
|
+
const elapsed = (Date.now() - lastRun.getTime()) / 1000;
|
|
875
|
+
|
|
876
|
+
if (elapsed < this.config.minRunInterval) {
|
|
877
|
+
const nextRunTime = new Date(lastRun.getTime() + this.config.minRunInterval * 1000);
|
|
878
|
+
return { canRun: false, nextRunTime, lastRun };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return { canRun: true, nextRunTime: null, lastRun };
|
|
882
|
+
} catch (err) {
|
|
883
|
+
if (err.code === 'ENOENT') {
|
|
884
|
+
// No last run file, first run
|
|
885
|
+
return { canRun: true, nextRunTime: null, lastRun: null };
|
|
886
|
+
}
|
|
887
|
+
throw err;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Record successful run
|
|
893
|
+
* @param {Object} results - Run results summary
|
|
894
|
+
* @returns {Promise<void>}
|
|
895
|
+
*/
|
|
896
|
+
async recordRun(results) {
|
|
897
|
+
const runInfo = {
|
|
898
|
+
timestamp: new Date().toISOString(),
|
|
899
|
+
pid: process.pid,
|
|
900
|
+
results: {
|
|
901
|
+
total: results.stats?.totalItems || 0,
|
|
902
|
+
published: results.stats?.published || 0,
|
|
903
|
+
failed: results.stats?.failed || 0,
|
|
904
|
+
duration: results.duration
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
await fs.writeFile(this.lastRunPath, JSON.stringify(runInfo, null, 2));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Get last run information
|
|
913
|
+
* @returns {Promise<Object|null>}
|
|
914
|
+
*/
|
|
915
|
+
async getLastRun() {
|
|
916
|
+
try {
|
|
917
|
+
const data = await fs.readFile(this.lastRunPath, 'utf8');
|
|
918
|
+
return JSON.parse(data);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
if (err.code === 'ENOENT') return null;
|
|
921
|
+
throw err;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Generate crontab entry for scheduling
|
|
927
|
+
* @param {Object} options - Scheduling options
|
|
928
|
+
* @returns {string} Crontab entry
|
|
929
|
+
*/
|
|
930
|
+
generateCrontabEntry(options = {}) {
|
|
931
|
+
const {
|
|
932
|
+
schedule = '0 */6 * * *', // Default: every 6 hours
|
|
933
|
+
workDir = process.cwd(),
|
|
934
|
+
contentDir = './content-queue',
|
|
935
|
+
logFile = '/var/log/content-coordinator.log',
|
|
936
|
+
user = process.env.USER || 'ubuntu',
|
|
937
|
+
extraFlags = ''
|
|
938
|
+
} = options;
|
|
939
|
+
|
|
940
|
+
const command = `cd ${workDir} && /usr/bin/npx myaidev-method coordinate-content ${contentDir} --cron --force ${extraFlags}`.trim();
|
|
941
|
+
|
|
942
|
+
return `# Content Coordinator - Automated publishing
|
|
943
|
+
# Schedule: ${this.describeCronSchedule(schedule)}
|
|
944
|
+
# Added: ${new Date().toISOString()}
|
|
945
|
+
${schedule} ${user} ${command} >> ${logFile} 2>&1
|
|
946
|
+
|
|
947
|
+
# Alternative with flock for extra safety:
|
|
948
|
+
# ${schedule} ${user} /usr/bin/flock -n /tmp/content-coordinator.lock ${command} >> ${logFile} 2>&1
|
|
949
|
+
`;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Describe cron schedule in human-readable format
|
|
954
|
+
* @param {string} schedule - Cron schedule expression
|
|
955
|
+
* @returns {string} Human-readable description
|
|
956
|
+
*/
|
|
957
|
+
describeCronSchedule(schedule) {
|
|
958
|
+
const parts = schedule.split(' ');
|
|
959
|
+
if (parts.length !== 5) return schedule;
|
|
960
|
+
|
|
961
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
962
|
+
|
|
963
|
+
// Common patterns
|
|
964
|
+
if (schedule === '0 * * * *') return 'Every hour';
|
|
965
|
+
if (schedule === '*/15 * * * *') return 'Every 15 minutes';
|
|
966
|
+
if (schedule === '*/30 * * * *') return 'Every 30 minutes';
|
|
967
|
+
if (schedule === '0 */2 * * *') return 'Every 2 hours';
|
|
968
|
+
if (schedule === '0 */6 * * *') return 'Every 6 hours';
|
|
969
|
+
if (schedule === '0 */12 * * *') return 'Every 12 hours';
|
|
970
|
+
if (schedule === '0 0 * * *') return 'Daily at midnight';
|
|
971
|
+
if (schedule === '0 9 * * *') return 'Daily at 9:00 AM';
|
|
972
|
+
if (schedule === '0 9 * * 1-5') return 'Weekdays at 9:00 AM';
|
|
973
|
+
if (schedule === '0 0 * * 0') return 'Weekly on Sunday at midnight';
|
|
974
|
+
if (schedule === '0 0 1 * *') return 'Monthly on the 1st at midnight';
|
|
975
|
+
|
|
976
|
+
return schedule;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Get suggested cron schedules for different use cases
|
|
981
|
+
* @returns {Object[]} Array of schedule suggestions
|
|
982
|
+
*/
|
|
983
|
+
getSuggestedSchedules() {
|
|
984
|
+
return [
|
|
985
|
+
{
|
|
986
|
+
name: 'High frequency',
|
|
987
|
+
schedule: '*/30 * * * *',
|
|
988
|
+
description: 'Every 30 minutes - for time-sensitive content'
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
name: 'Regular',
|
|
992
|
+
schedule: '0 */6 * * *',
|
|
993
|
+
description: 'Every 6 hours - balanced approach'
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
name: 'Daily morning',
|
|
997
|
+
schedule: '0 9 * * *',
|
|
998
|
+
description: 'Daily at 9:00 AM - publish during business hours'
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
name: 'Weekdays only',
|
|
1002
|
+
schedule: '0 9 * * 1-5',
|
|
1003
|
+
description: 'Weekdays at 9:00 AM - avoid weekend publishing'
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
name: 'Twice daily',
|
|
1007
|
+
schedule: '0 9,17 * * *',
|
|
1008
|
+
description: '9:00 AM and 5:00 PM - morning and evening'
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
name: 'Weekly',
|
|
1012
|
+
schedule: '0 9 * * 1',
|
|
1013
|
+
description: 'Every Monday at 9:00 AM - weekly content drops'
|
|
1014
|
+
}
|
|
1015
|
+
];
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Validate cron expression
|
|
1020
|
+
* @param {string} expression - Cron expression to validate
|
|
1021
|
+
* @returns {{valid: boolean, error?: string}}
|
|
1022
|
+
*/
|
|
1023
|
+
validateCronExpression(expression) {
|
|
1024
|
+
const parts = expression.trim().split(/\s+/);
|
|
1025
|
+
|
|
1026
|
+
if (parts.length !== 5) {
|
|
1027
|
+
return {
|
|
1028
|
+
valid: false,
|
|
1029
|
+
error: `Expected 5 fields (minute hour day month weekday), got ${parts.length}`
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const ranges = [
|
|
1034
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
1035
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
1036
|
+
{ name: 'day of month', min: 1, max: 31 },
|
|
1037
|
+
{ name: 'month', min: 1, max: 12 },
|
|
1038
|
+
{ name: 'day of week', min: 0, max: 7 }
|
|
1039
|
+
];
|
|
1040
|
+
|
|
1041
|
+
for (let i = 0; i < 5; i++) {
|
|
1042
|
+
const part = parts[i];
|
|
1043
|
+
const range = ranges[i];
|
|
1044
|
+
|
|
1045
|
+
// Skip wildcards
|
|
1046
|
+
if (part === '*') continue;
|
|
1047
|
+
|
|
1048
|
+
// Check step values (*/n)
|
|
1049
|
+
if (part.startsWith('*/')) {
|
|
1050
|
+
const step = parseInt(part.slice(2), 10);
|
|
1051
|
+
if (isNaN(step) || step < 1) {
|
|
1052
|
+
return { valid: false, error: `Invalid step value in ${range.name}: ${part}` };
|
|
1053
|
+
}
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Check ranges (n-m)
|
|
1058
|
+
if (part.includes('-')) {
|
|
1059
|
+
const [start, end] = part.split('-').map(n => parseInt(n, 10));
|
|
1060
|
+
if (isNaN(start) || isNaN(end) || start < range.min || end > range.max || start > end) {
|
|
1061
|
+
return { valid: false, error: `Invalid range in ${range.name}: ${part}` };
|
|
1062
|
+
}
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Check lists (n,m,...)
|
|
1067
|
+
if (part.includes(',')) {
|
|
1068
|
+
const values = part.split(',').map(n => parseInt(n, 10));
|
|
1069
|
+
for (const val of values) {
|
|
1070
|
+
if (isNaN(val) || val < range.min || val > range.max) {
|
|
1071
|
+
return { valid: false, error: `Invalid value in ${range.name} list: ${val}` };
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Check single value
|
|
1078
|
+
const val = parseInt(part, 10);
|
|
1079
|
+
if (isNaN(val) || val < range.min || val > range.max) {
|
|
1080
|
+
return { valid: false, error: `Invalid ${range.name}: ${part} (must be ${range.min}-${range.max})` };
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return { valid: true };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* ScheduleManager - Manages WordPress native scheduling and content spreading
|
|
1090
|
+
*
|
|
1091
|
+
* Uses WordPress REST API to schedule posts for future publication:
|
|
1092
|
+
* - status: 'future' with date parameter
|
|
1093
|
+
* - Spreads posts across time to avoid publishing everything at once
|
|
1094
|
+
* - Respects publish_date from content front matter
|
|
1095
|
+
*/
|
|
1096
|
+
class ScheduleManager {
|
|
1097
|
+
constructor(config) {
|
|
1098
|
+
this.config = config;
|
|
1099
|
+
this.scheduledPosts = [];
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Calculate publish date for an item
|
|
1104
|
+
* @param {Object} item - Content item
|
|
1105
|
+
* @param {number} index - Item index in batch
|
|
1106
|
+
* @param {number} totalItems - Total items in batch
|
|
1107
|
+
* @returns {Date} Scheduled publish date
|
|
1108
|
+
*/
|
|
1109
|
+
calculatePublishDate(item, index, totalItems) {
|
|
1110
|
+
// Check if item has explicit publish date in front matter
|
|
1111
|
+
if (item.metadata.publish_date || item.metadata.scheduled_date) {
|
|
1112
|
+
const explicitDate = new Date(item.metadata.publish_date || item.metadata.scheduled_date);
|
|
1113
|
+
if (!isNaN(explicitDate.getTime())) {
|
|
1114
|
+
return explicitDate;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// If publish immediately
|
|
1119
|
+
if (this.config.defaultPublishDelay === 0 && this.config.publishSpreadInterval === 0) {
|
|
1120
|
+
return new Date(); // Now
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Calculate base time (now + default delay)
|
|
1124
|
+
const baseTime = new Date();
|
|
1125
|
+
baseTime.setHours(baseTime.getHours() + this.config.defaultPublishDelay);
|
|
1126
|
+
|
|
1127
|
+
// Apply spread interval if configured
|
|
1128
|
+
if (this.config.publishSpreadInterval > 0) {
|
|
1129
|
+
baseTime.setHours(baseTime.getHours() + (index * this.config.publishSpreadInterval));
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return baseTime;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Get WordPress post status based on publish date
|
|
1137
|
+
* @param {Date} publishDate - Scheduled publish date
|
|
1138
|
+
* @returns {string} WordPress post status ('publish' or 'future')
|
|
1139
|
+
*/
|
|
1140
|
+
getWordPressStatus(publishDate) {
|
|
1141
|
+
const now = new Date();
|
|
1142
|
+
// If publish date is in the future (more than 1 minute from now), use 'future' status
|
|
1143
|
+
if (publishDate.getTime() > now.getTime() + 60000) {
|
|
1144
|
+
return 'future';
|
|
1145
|
+
}
|
|
1146
|
+
return 'publish';
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Format date for WordPress REST API (ISO 8601)
|
|
1151
|
+
* @param {Date} date - Date to format
|
|
1152
|
+
* @returns {string} ISO 8601 formatted date
|
|
1153
|
+
*/
|
|
1154
|
+
formatForWordPress(date) {
|
|
1155
|
+
return date.toISOString();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Get scheduling context for publishing
|
|
1160
|
+
* @param {Object} item - Content item
|
|
1161
|
+
* @param {number} index - Item index
|
|
1162
|
+
* @param {number} totalItems - Total items
|
|
1163
|
+
* @returns {Object} Scheduling context for publisher
|
|
1164
|
+
*/
|
|
1165
|
+
getSchedulingContext(item, index, totalItems) {
|
|
1166
|
+
const publishDate = this.calculatePublishDate(item, index, totalItems);
|
|
1167
|
+
const status = this.getWordPressStatus(publishDate);
|
|
1168
|
+
|
|
1169
|
+
return {
|
|
1170
|
+
useWordPressScheduling: this.config.useWordPressScheduling,
|
|
1171
|
+
publishDate: this.formatForWordPress(publishDate),
|
|
1172
|
+
status: status,
|
|
1173
|
+
isScheduled: status === 'future',
|
|
1174
|
+
scheduledFor: publishDate.toLocaleString()
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Track scheduled post
|
|
1180
|
+
* @param {Object} item - Content item
|
|
1181
|
+
* @param {Object} result - Publishing result
|
|
1182
|
+
* @param {Object} scheduleContext - Scheduling context used
|
|
1183
|
+
*/
|
|
1184
|
+
trackScheduledPost(item, result, scheduleContext) {
|
|
1185
|
+
this.scheduledPosts.push({
|
|
1186
|
+
title: item.metadata.title,
|
|
1187
|
+
url: result.url,
|
|
1188
|
+
scheduledFor: scheduleContext.publishDate,
|
|
1189
|
+
status: scheduleContext.status,
|
|
1190
|
+
platform: item.metadata.target_platform
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Get summary of scheduled posts
|
|
1196
|
+
* @returns {Object} Scheduling summary
|
|
1197
|
+
*/
|
|
1198
|
+
getSummary() {
|
|
1199
|
+
const immediate = this.scheduledPosts.filter(p => p.status === 'publish');
|
|
1200
|
+
const future = this.scheduledPosts.filter(p => p.status === 'future');
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
total: this.scheduledPosts.length,
|
|
1204
|
+
publishedImmediately: immediate.length,
|
|
1205
|
+
scheduledForFuture: future.length,
|
|
1206
|
+
posts: this.scheduledPosts,
|
|
1207
|
+
nextScheduled: future.length > 0
|
|
1208
|
+
? future.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))[0]
|
|
1209
|
+
: null
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Generate scheduling report
|
|
1215
|
+
* @returns {string} Markdown report of scheduled posts
|
|
1216
|
+
*/
|
|
1217
|
+
generateScheduleReport() {
|
|
1218
|
+
const summary = this.getSummary();
|
|
1219
|
+
|
|
1220
|
+
let report = `# Content Scheduling Report
|
|
1221
|
+
Generated: ${new Date().toISOString()}
|
|
1222
|
+
|
|
1223
|
+
## Summary
|
|
1224
|
+
- Total Posts: ${summary.total}
|
|
1225
|
+
- Published Immediately: ${summary.publishedImmediately}
|
|
1226
|
+
- Scheduled for Future: ${summary.scheduledForFuture}
|
|
1227
|
+
|
|
1228
|
+
`;
|
|
1229
|
+
|
|
1230
|
+
if (summary.scheduledForFuture > 0) {
|
|
1231
|
+
report += `## Scheduled Posts
|
|
1232
|
+
|
|
1233
|
+
| Title | Scheduled For | Platform |
|
|
1234
|
+
|-------|---------------|----------|
|
|
1235
|
+
`;
|
|
1236
|
+
const futurePosts = this.scheduledPosts
|
|
1237
|
+
.filter(p => p.status === 'future')
|
|
1238
|
+
.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor));
|
|
1239
|
+
|
|
1240
|
+
for (const post of futurePosts) {
|
|
1241
|
+
const date = new Date(post.scheduledFor).toLocaleString();
|
|
1242
|
+
report += `| ${post.title} | ${date} | ${post.platform} |\n`;
|
|
1243
|
+
}
|
|
1244
|
+
report += '\n';
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (summary.publishedImmediately > 0) {
|
|
1248
|
+
report += `## Published Immediately
|
|
1249
|
+
|
|
1250
|
+
| Title | URL | Platform |
|
|
1251
|
+
|-------|-----|----------|
|
|
1252
|
+
`;
|
|
1253
|
+
const immediatePosts = this.scheduledPosts.filter(p => p.status === 'publish');
|
|
1254
|
+
|
|
1255
|
+
for (const post of immediatePosts) {
|
|
1256
|
+
report += `| ${post.title} | ${post.url} | ${post.platform} |\n`;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return report;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* ContentCoordinator class
|
|
1266
|
+
* Manages the state machine for content production workflow
|
|
1267
|
+
*/
|
|
1268
|
+
class ContentCoordinator {
|
|
1269
|
+
/**
|
|
1270
|
+
* Create a new ContentCoordinator instance
|
|
1271
|
+
* @param {Object} options - Configuration options
|
|
1272
|
+
*/
|
|
1273
|
+
constructor(options = {}) {
|
|
1274
|
+
this.config = { ...DEFAULT_CONFIG, ...options };
|
|
1275
|
+
this.workDir = options.workDir;
|
|
1276
|
+
this.state = this.createInitialState();
|
|
1277
|
+
this.checkpointTimer = null;
|
|
1278
|
+
|
|
1279
|
+
// Initialize managers
|
|
1280
|
+
this.contentRules = new ContentRulesManager(this.config.contentRulesPath);
|
|
1281
|
+
this.analytics = new AnalyticsTracker();
|
|
1282
|
+
this.webhook = new WebhookNotifier(this.config);
|
|
1283
|
+
this.queue = new QueueManager(
|
|
1284
|
+
path.join(this.config.outputDir, this.config.queueFileName)
|
|
1285
|
+
);
|
|
1286
|
+
this.publisher = new PlatformPublisher(this.config);
|
|
1287
|
+
this.cronHelper = new CronHelper(this.config);
|
|
1288
|
+
this.scheduler = new ScheduleManager(this.config);
|
|
1289
|
+
|
|
1290
|
+
this.callbacks = {
|
|
1291
|
+
onProgress: null,
|
|
1292
|
+
onPhaseChange: null,
|
|
1293
|
+
onItemComplete: null,
|
|
1294
|
+
onError: null,
|
|
1295
|
+
onConfirmationNeeded: null
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Create initial state object
|
|
1301
|
+
* @returns {Object} Initial state
|
|
1302
|
+
*/
|
|
1303
|
+
createInitialState() {
|
|
1304
|
+
return {
|
|
1305
|
+
phase: PHASES.INITIALIZE,
|
|
1306
|
+
startedAt: null,
|
|
1307
|
+
lastUpdated: null,
|
|
1308
|
+
items: [],
|
|
1309
|
+
results: {
|
|
1310
|
+
ready: [],
|
|
1311
|
+
needsReview: [],
|
|
1312
|
+
failed: [],
|
|
1313
|
+
published: []
|
|
1314
|
+
},
|
|
1315
|
+
reports: {
|
|
1316
|
+
readyForPublishing: null,
|
|
1317
|
+
needsReview: null,
|
|
1318
|
+
analytics: null
|
|
1319
|
+
},
|
|
1320
|
+
stats: {
|
|
1321
|
+
totalItems: 0,
|
|
1322
|
+
verified: 0,
|
|
1323
|
+
ready: 0,
|
|
1324
|
+
needsReview: 0,
|
|
1325
|
+
published: 0,
|
|
1326
|
+
failed: 0
|
|
1327
|
+
},
|
|
1328
|
+
errors: [],
|
|
1329
|
+
checkpoints: [],
|
|
1330
|
+
contentRulesLoaded: false
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Set callback functions
|
|
1336
|
+
* @param {Object} callbacks - Callback functions
|
|
1337
|
+
*/
|
|
1338
|
+
setCallbacks(callbacks) {
|
|
1339
|
+
this.callbacks = { ...this.callbacks, ...callbacks };
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Load state from checkpoint file
|
|
1344
|
+
* @returns {Promise<boolean>} Whether state was loaded
|
|
1345
|
+
*/
|
|
1346
|
+
async loadCheckpoint() {
|
|
1347
|
+
const statePath = path.join(this.config.outputDir, this.config.stateFileName);
|
|
1348
|
+
try {
|
|
1349
|
+
const data = await fs.readFile(statePath, 'utf8');
|
|
1350
|
+
this.state = JSON.parse(data);
|
|
1351
|
+
this.log('Loaded checkpoint from', statePath);
|
|
1352
|
+
return true;
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
if (err.code !== 'ENOENT') {
|
|
1355
|
+
this.logError('Error loading checkpoint:', err.message);
|
|
1356
|
+
}
|
|
1357
|
+
return false;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Save state to checkpoint file
|
|
1363
|
+
* @returns {Promise<void>}
|
|
1364
|
+
*/
|
|
1365
|
+
async saveCheckpoint() {
|
|
1366
|
+
const statePath = path.join(this.config.outputDir, this.config.stateFileName);
|
|
1367
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
1368
|
+
this.state.checkpoints.push({
|
|
1369
|
+
phase: this.state.phase,
|
|
1370
|
+
timestamp: this.state.lastUpdated,
|
|
1371
|
+
stats: { ...this.state.stats }
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
try {
|
|
1375
|
+
await fs.writeFile(statePath, JSON.stringify(this.state, null, 2));
|
|
1376
|
+
this.log('Saved checkpoint to', statePath);
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
this.logError('Error saving checkpoint:', err.message);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Clear checkpoint file after successful completion
|
|
1384
|
+
* @returns {Promise<void>}
|
|
1385
|
+
*/
|
|
1386
|
+
async clearCheckpoint() {
|
|
1387
|
+
const statePath = path.join(this.config.outputDir, this.config.stateFileName);
|
|
1388
|
+
try {
|
|
1389
|
+
await fs.unlink(statePath);
|
|
1390
|
+
this.log('Cleared checkpoint file');
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
if (err.code !== 'ENOENT') {
|
|
1393
|
+
this.logError('Error clearing checkpoint:', err.message);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Load content rules
|
|
1400
|
+
* @returns {Promise<void>}
|
|
1401
|
+
*/
|
|
1402
|
+
async loadContentRules() {
|
|
1403
|
+
// Try default locations if not specified
|
|
1404
|
+
const possiblePaths = [
|
|
1405
|
+
this.config.contentRulesPath,
|
|
1406
|
+
path.join(this.workDir, 'content-rules.md'),
|
|
1407
|
+
path.join(this.workDir, '..', 'content-rules.md'),
|
|
1408
|
+
path.join(process.cwd(), 'content-rules.md')
|
|
1409
|
+
].filter(Boolean);
|
|
1410
|
+
|
|
1411
|
+
for (const rulesPath of possiblePaths) {
|
|
1412
|
+
this.contentRules = new ContentRulesManager(rulesPath);
|
|
1413
|
+
const rules = await this.contentRules.load();
|
|
1414
|
+
if (rules) {
|
|
1415
|
+
this.log('Loaded content rules from', rulesPath);
|
|
1416
|
+
this.state.contentRulesLoaded = true;
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
this.log('No content-rules.md found, proceeding without brand voice rules');
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Parse content file front matter
|
|
1426
|
+
* @param {string} filePath - Path to content file
|
|
1427
|
+
* @returns {Promise<Object>} Parsed content item
|
|
1428
|
+
*/
|
|
1429
|
+
async parseContentFile(filePath) {
|
|
1430
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
1431
|
+
const item = {
|
|
1432
|
+
id: path.basename(filePath, path.extname(filePath)),
|
|
1433
|
+
filePath,
|
|
1434
|
+
status: ITEM_STATUS.PENDING,
|
|
1435
|
+
metadata: {},
|
|
1436
|
+
content: '',
|
|
1437
|
+
verification: null,
|
|
1438
|
+
publishedUrl: null,
|
|
1439
|
+
errors: [],
|
|
1440
|
+
timing: {}
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
// Parse YAML front matter
|
|
1444
|
+
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1445
|
+
|
|
1446
|
+
if (frontMatterMatch) {
|
|
1447
|
+
const frontMatter = frontMatterMatch[1];
|
|
1448
|
+
item.content = frontMatterMatch[2].trim();
|
|
1449
|
+
|
|
1450
|
+
// Parse front matter fields
|
|
1451
|
+
const lines = frontMatter.split('\n');
|
|
1452
|
+
let currentKey = null;
|
|
1453
|
+
let arrayMode = false;
|
|
1454
|
+
|
|
1455
|
+
for (const line of lines) {
|
|
1456
|
+
// Check for array continuation
|
|
1457
|
+
if (arrayMode && line.match(/^\s+-\s+/)) {
|
|
1458
|
+
const value = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
|
|
1459
|
+
if (!Array.isArray(item.metadata[currentKey])) {
|
|
1460
|
+
item.metadata[currentKey] = [];
|
|
1461
|
+
}
|
|
1462
|
+
item.metadata[currentKey].push(value);
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
arrayMode = false;
|
|
1467
|
+
const colonIndex = line.indexOf(':');
|
|
1468
|
+
if (colonIndex > 0) {
|
|
1469
|
+
const key = line.slice(0, colonIndex).trim().toLowerCase().replace(/\s+/g, '_');
|
|
1470
|
+
let value = line.slice(colonIndex + 1).trim();
|
|
1471
|
+
|
|
1472
|
+
// Check if this starts an array
|
|
1473
|
+
if (value === '' || value === '[]') {
|
|
1474
|
+
currentKey = key;
|
|
1475
|
+
arrayMode = true;
|
|
1476
|
+
item.metadata[key] = [];
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Remove quotes if present
|
|
1481
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
1482
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
1483
|
+
value = value.slice(1, -1);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
item.metadata[key] = value;
|
|
1487
|
+
currentKey = key;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
} else {
|
|
1491
|
+
// No front matter, treat entire content as body
|
|
1492
|
+
item.content = content.trim();
|
|
1493
|
+
|
|
1494
|
+
// Try to extract title from first heading
|
|
1495
|
+
const headingMatch = content.match(/^#\s+(.+)$/m);
|
|
1496
|
+
if (headingMatch) {
|
|
1497
|
+
item.metadata.title = headingMatch[1];
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Set defaults for required fields
|
|
1502
|
+
item.metadata.title = item.metadata.title || path.basename(filePath, path.extname(filePath));
|
|
1503
|
+
item.metadata.status = item.metadata.status || 'pending';
|
|
1504
|
+
item.metadata.target_platform = item.metadata.target_platform || this.config.defaultPlatform;
|
|
1505
|
+
item.metadata.priority = item.metadata.priority || 'normal';
|
|
1506
|
+
|
|
1507
|
+
return item;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* Discover content files in work directory
|
|
1512
|
+
* @returns {Promise<string[]>} Array of file paths
|
|
1513
|
+
*/
|
|
1514
|
+
async discoverContentFiles() {
|
|
1515
|
+
const files = [];
|
|
1516
|
+
const entries = await fs.readdir(this.workDir, { withFileTypes: true });
|
|
1517
|
+
|
|
1518
|
+
for (const entry of entries) {
|
|
1519
|
+
if (entry.isFile() && /\.(md|markdown)$/i.test(entry.name)) {
|
|
1520
|
+
// Skip system files
|
|
1521
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
1522
|
+
files.push(path.join(this.workDir, entry.name));
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
return files.sort();
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Phase 1: Initialize - Load and parse content files
|
|
1531
|
+
* @returns {Promise<void>}
|
|
1532
|
+
*/
|
|
1533
|
+
async phaseInitialize() {
|
|
1534
|
+
this.setPhase(PHASES.INITIALIZE);
|
|
1535
|
+
this.analytics.startWorkflow();
|
|
1536
|
+
this.analytics.startPhase(PHASES.INITIALIZE);
|
|
1537
|
+
this.state.startedAt = new Date().toISOString();
|
|
1538
|
+
|
|
1539
|
+
// Load content rules
|
|
1540
|
+
await this.loadContentRules();
|
|
1541
|
+
|
|
1542
|
+
// Load queue if exists
|
|
1543
|
+
await this.queue.load();
|
|
1544
|
+
|
|
1545
|
+
const files = await this.discoverContentFiles();
|
|
1546
|
+
this.log(`Found ${files.length} content files in ${this.workDir}`);
|
|
1547
|
+
|
|
1548
|
+
for (const filePath of files) {
|
|
1549
|
+
try {
|
|
1550
|
+
const item = await this.parseContentFile(filePath);
|
|
1551
|
+
this.state.items.push(item);
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
this.logError(`Error parsing ${filePath}:`, err.message);
|
|
1554
|
+
this.state.items.push({
|
|
1555
|
+
id: path.basename(filePath),
|
|
1556
|
+
filePath,
|
|
1557
|
+
status: ITEM_STATUS.FAILED,
|
|
1558
|
+
metadata: { title: path.basename(filePath) },
|
|
1559
|
+
errors: [err.message],
|
|
1560
|
+
timing: {}
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
this.state.stats.totalItems = this.state.items.length;
|
|
1566
|
+
this.analytics.setTotalItems(this.state.items.length);
|
|
1567
|
+
this.analytics.endPhase(PHASES.INITIALIZE);
|
|
1568
|
+
|
|
1569
|
+
// Send webhook notification
|
|
1570
|
+
await this.webhook.notifyStart({
|
|
1571
|
+
totalItems: this.state.stats.totalItems,
|
|
1572
|
+
workDir: this.workDir,
|
|
1573
|
+
contentRulesLoaded: this.state.contentRulesLoaded
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
await this.saveCheckpoint();
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Phase 2: Verify - Run content verification
|
|
1581
|
+
* @param {Function} verifyFn - Function to verify a single item
|
|
1582
|
+
* @returns {Promise<void>}
|
|
1583
|
+
*/
|
|
1584
|
+
async phaseVerify(verifyFn) {
|
|
1585
|
+
this.setPhase(PHASES.VERIFY);
|
|
1586
|
+
this.analytics.startPhase(PHASES.VERIFY);
|
|
1587
|
+
|
|
1588
|
+
const pendingItems = this.state.items.filter(
|
|
1589
|
+
item => item.status === ITEM_STATUS.PENDING
|
|
1590
|
+
);
|
|
1591
|
+
|
|
1592
|
+
this.log(`Verifying ${pendingItems.length} items with concurrency ${this.config.concurrency}`);
|
|
1593
|
+
|
|
1594
|
+
// Get content rules context for verification
|
|
1595
|
+
const rulesContext = this.contentRules.getVerificationContext();
|
|
1596
|
+
|
|
1597
|
+
// Process in batches based on concurrency
|
|
1598
|
+
for (let i = 0; i < pendingItems.length; i += this.config.concurrency) {
|
|
1599
|
+
const batch = pendingItems.slice(i, i + this.config.concurrency);
|
|
1600
|
+
|
|
1601
|
+
await Promise.all(batch.map(async (item) => {
|
|
1602
|
+
item.status = ITEM_STATUS.VERIFYING;
|
|
1603
|
+
const startTime = Date.now();
|
|
1604
|
+
|
|
1605
|
+
try {
|
|
1606
|
+
// Pass content rules context to verifier
|
|
1607
|
+
const result = await this.withRetry(() => verifyFn(item, rulesContext));
|
|
1608
|
+
const duration = Date.now() - startTime;
|
|
1609
|
+
|
|
1610
|
+
item.verification = result;
|
|
1611
|
+
item.status = ITEM_STATUS.VERIFIED;
|
|
1612
|
+
item.timing.verification = duration;
|
|
1613
|
+
this.state.stats.verified++;
|
|
1614
|
+
|
|
1615
|
+
this.analytics.trackVerification(item, duration);
|
|
1616
|
+
this.notifyItemComplete(item, 'verified');
|
|
1617
|
+
} catch (err) {
|
|
1618
|
+
item.status = ITEM_STATUS.FAILED;
|
|
1619
|
+
item.errors.push(err.message);
|
|
1620
|
+
item.timing.verification = Date.now() - startTime;
|
|
1621
|
+
this.state.stats.failed++;
|
|
1622
|
+
|
|
1623
|
+
this.analytics.trackError(item, err, PHASES.VERIFY);
|
|
1624
|
+
this.notifyError(item, err);
|
|
1625
|
+
await this.webhook.notifyError({
|
|
1626
|
+
item: item.metadata.title,
|
|
1627
|
+
error: err.message,
|
|
1628
|
+
phase: PHASES.VERIFY
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
}));
|
|
1632
|
+
|
|
1633
|
+
await this.saveCheckpoint();
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
this.analytics.endPhase(PHASES.VERIFY);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Phase 3: Categorize - Sort items by verification results
|
|
1641
|
+
* @returns {Promise<void>}
|
|
1642
|
+
*/
|
|
1643
|
+
async phaseCategorize() {
|
|
1644
|
+
this.setPhase(PHASES.CATEGORIZE);
|
|
1645
|
+
this.analytics.startPhase(PHASES.CATEGORIZE);
|
|
1646
|
+
|
|
1647
|
+
for (const item of this.state.items) {
|
|
1648
|
+
if (item.status === ITEM_STATUS.FAILED) {
|
|
1649
|
+
this.state.results.failed.push(item);
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
if (item.status !== ITEM_STATUS.VERIFIED) {
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
const verification = item.verification || {};
|
|
1658
|
+
const score = (verification.redundancyScore || '').toLowerCase();
|
|
1659
|
+
const recommendation = (verification.recommendation || '').toLowerCase();
|
|
1660
|
+
|
|
1661
|
+
// Check if ready for publishing
|
|
1662
|
+
const isLowRedundancy = [REDUNDANCY_THRESHOLDS.MINIMAL, REDUNDANCY_THRESHOLDS.LOW].includes(score);
|
|
1663
|
+
const isProceedRecommended = recommendation.includes('proceed');
|
|
1664
|
+
|
|
1665
|
+
if (isLowRedundancy && isProceedRecommended) {
|
|
1666
|
+
item.status = ITEM_STATUS.READY;
|
|
1667
|
+
this.state.results.ready.push(item);
|
|
1668
|
+
this.state.stats.ready++;
|
|
1669
|
+
} else {
|
|
1670
|
+
item.status = ITEM_STATUS.NEEDS_REVIEW;
|
|
1671
|
+
this.state.results.needsReview.push(item);
|
|
1672
|
+
this.state.stats.needsReview++;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
this.analytics.endPhase(PHASES.CATEGORIZE);
|
|
1677
|
+
await this.saveCheckpoint();
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Phase 4: Report - Generate output reports
|
|
1682
|
+
* @returns {Promise<void>}
|
|
1683
|
+
*/
|
|
1684
|
+
async phaseReport() {
|
|
1685
|
+
this.setPhase(PHASES.REPORT);
|
|
1686
|
+
this.analytics.startPhase(PHASES.REPORT);
|
|
1687
|
+
|
|
1688
|
+
const timestamp = this.getTimestamp();
|
|
1689
|
+
|
|
1690
|
+
// Generate Ready for Publishing report
|
|
1691
|
+
if (this.state.results.ready.length > 0) {
|
|
1692
|
+
const readyReport = this.generateReadyReport(timestamp);
|
|
1693
|
+
const readyPath = path.join(
|
|
1694
|
+
this.config.outputDir,
|
|
1695
|
+
`ready-for-publishing-${timestamp}.md`
|
|
1696
|
+
);
|
|
1697
|
+
await fs.writeFile(readyPath, readyReport);
|
|
1698
|
+
this.state.reports.readyForPublishing = readyPath;
|
|
1699
|
+
this.log('Generated report:', readyPath);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// Generate Needs Review report
|
|
1703
|
+
if (this.state.results.needsReview.length > 0 || this.state.results.failed.length > 0) {
|
|
1704
|
+
const reviewReport = this.generateNeedsReviewReport(timestamp);
|
|
1705
|
+
const reviewPath = path.join(
|
|
1706
|
+
this.config.outputDir,
|
|
1707
|
+
`needs-review-${timestamp}.md`
|
|
1708
|
+
);
|
|
1709
|
+
await fs.writeFile(reviewPath, reviewReport);
|
|
1710
|
+
this.state.reports.needsReview = reviewPath;
|
|
1711
|
+
this.log('Generated report:', reviewPath);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Generate analytics report if enabled
|
|
1715
|
+
if (this.config.enableAnalytics) {
|
|
1716
|
+
const analyticsReport = this.generateAnalyticsReport(timestamp);
|
|
1717
|
+
const analyticsPath = path.join(
|
|
1718
|
+
this.config.outputDir,
|
|
1719
|
+
`analytics-${timestamp}.md`
|
|
1720
|
+
);
|
|
1721
|
+
await fs.writeFile(analyticsPath, analyticsReport);
|
|
1722
|
+
this.state.reports.analytics = analyticsPath;
|
|
1723
|
+
this.log('Generated analytics report:', analyticsPath);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
this.analytics.endPhase(PHASES.REPORT);
|
|
1727
|
+
await this.saveCheckpoint();
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Phase 5: Notify - Inform user of results
|
|
1732
|
+
* @returns {Promise<Object>} Summary for user notification
|
|
1733
|
+
*/
|
|
1734
|
+
async phaseNotify() {
|
|
1735
|
+
this.setPhase(PHASES.NOTIFY);
|
|
1736
|
+
|
|
1737
|
+
return {
|
|
1738
|
+
summary: {
|
|
1739
|
+
total: this.state.stats.totalItems,
|
|
1740
|
+
ready: this.state.stats.ready,
|
|
1741
|
+
needsReview: this.state.stats.needsReview,
|
|
1742
|
+
failed: this.state.stats.failed
|
|
1743
|
+
},
|
|
1744
|
+
reports: this.state.reports,
|
|
1745
|
+
readyItems: this.state.results.ready.map(item => ({
|
|
1746
|
+
title: item.metadata.title,
|
|
1747
|
+
score: item.verification?.redundancyScore,
|
|
1748
|
+
recommendation: item.verification?.recommendation,
|
|
1749
|
+
platform: item.metadata.target_platform
|
|
1750
|
+
})),
|
|
1751
|
+
needsReviewItems: this.state.results.needsReview.map(item => ({
|
|
1752
|
+
title: item.metadata.title,
|
|
1753
|
+
score: item.verification?.redundancyScore,
|
|
1754
|
+
reason: item.verification?.feedback || 'Requires manual review'
|
|
1755
|
+
})),
|
|
1756
|
+
contentRulesApplied: this.state.contentRulesLoaded
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Phase 6: Publish - Publish approved content
|
|
1762
|
+
* @param {Function} publishFn - Function to publish a single item
|
|
1763
|
+
* @returns {Promise<void>}
|
|
1764
|
+
*/
|
|
1765
|
+
async phasePublish(publishFn) {
|
|
1766
|
+
if (this.config.dryRun) {
|
|
1767
|
+
this.log('Dry run mode - skipping publish phase');
|
|
1768
|
+
this.setPhase(PHASES.COMPLETE);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
this.setPhase(PHASES.PUBLISH);
|
|
1773
|
+
this.analytics.startPhase(PHASES.PUBLISH);
|
|
1774
|
+
|
|
1775
|
+
const readyItems = this.state.results.ready.filter(
|
|
1776
|
+
item => item.status === ITEM_STATUS.READY
|
|
1777
|
+
);
|
|
1778
|
+
|
|
1779
|
+
// Get publishing context from content rules
|
|
1780
|
+
const publishContext = this.contentRules.getPublishingContext();
|
|
1781
|
+
|
|
1782
|
+
this.log(`Publishing ${readyItems.length} items with concurrency ${this.config.concurrency}`);
|
|
1783
|
+
|
|
1784
|
+
// Process in batches based on concurrency
|
|
1785
|
+
for (let i = 0; i < readyItems.length; i += this.config.concurrency) {
|
|
1786
|
+
const batch = readyItems.slice(i, i + this.config.concurrency);
|
|
1787
|
+
const batchStartIndex = i;
|
|
1788
|
+
|
|
1789
|
+
await Promise.all(batch.map(async (item, batchIndex) => {
|
|
1790
|
+
item.status = ITEM_STATUS.PUBLISHING;
|
|
1791
|
+
const startTime = Date.now();
|
|
1792
|
+
const itemIndex = batchStartIndex + batchIndex;
|
|
1793
|
+
|
|
1794
|
+
try {
|
|
1795
|
+
// Get platform-specific publisher
|
|
1796
|
+
const platform = item.metadata.target_platform || this.config.defaultPlatform;
|
|
1797
|
+
const platformPublisher = this.publisher.getPublisher(platform);
|
|
1798
|
+
|
|
1799
|
+
// Get scheduling context for WordPress native scheduling
|
|
1800
|
+
const scheduleContext = this.scheduler.getSchedulingContext(
|
|
1801
|
+
item,
|
|
1802
|
+
itemIndex,
|
|
1803
|
+
readyItems.length
|
|
1804
|
+
);
|
|
1805
|
+
|
|
1806
|
+
// Publish with platform context, content rules, and scheduling
|
|
1807
|
+
const result = await this.withRetry(() =>
|
|
1808
|
+
platformPublisher.publish(item, (item, platformContext) =>
|
|
1809
|
+
publishFn(item, {
|
|
1810
|
+
...platformContext,
|
|
1811
|
+
contentRules: publishContext,
|
|
1812
|
+
scheduling: scheduleContext
|
|
1813
|
+
})
|
|
1814
|
+
)
|
|
1815
|
+
);
|
|
1816
|
+
|
|
1817
|
+
const duration = Date.now() - startTime;
|
|
1818
|
+
|
|
1819
|
+
item.publishedUrl = result.url;
|
|
1820
|
+
item.publishedAt = new Date().toISOString();
|
|
1821
|
+
item.scheduledFor = scheduleContext.isScheduled ? scheduleContext.publishDate : null;
|
|
1822
|
+
item.status = scheduleContext.isScheduled ? ITEM_STATUS.PUBLISHED : ITEM_STATUS.PUBLISHED;
|
|
1823
|
+
item.timing.publishing = duration;
|
|
1824
|
+
this.state.results.published.push(item);
|
|
1825
|
+
this.state.stats.published++;
|
|
1826
|
+
|
|
1827
|
+
// Track in scheduler
|
|
1828
|
+
this.scheduler.trackScheduledPost(item, result, scheduleContext);
|
|
1829
|
+
|
|
1830
|
+
this.analytics.trackPublishing(item, duration);
|
|
1831
|
+
this.notifyItemComplete(item, scheduleContext.isScheduled ? 'scheduled' : 'published');
|
|
1832
|
+
|
|
1833
|
+
// Update queue status
|
|
1834
|
+
await this.queue.updateItemStatus(item.id, 'published', {
|
|
1835
|
+
publishedUrl: result.url,
|
|
1836
|
+
publishedAt: item.publishedAt,
|
|
1837
|
+
scheduledFor: item.scheduledFor
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
// Webhook notification
|
|
1841
|
+
await this.webhook.notifyPublished({
|
|
1842
|
+
title: item.metadata.title,
|
|
1843
|
+
url: result.url,
|
|
1844
|
+
platform,
|
|
1845
|
+
scheduled: scheduleContext.isScheduled,
|
|
1846
|
+
scheduledFor: scheduleContext.scheduledFor
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
} catch (err) {
|
|
1850
|
+
item.status = ITEM_STATUS.FAILED;
|
|
1851
|
+
item.errors.push(err.message);
|
|
1852
|
+
item.timing.publishing = Date.now() - startTime;
|
|
1853
|
+
this.state.stats.failed++;
|
|
1854
|
+
|
|
1855
|
+
this.analytics.trackError(item, err, PHASES.PUBLISH);
|
|
1856
|
+
this.notifyError(item, err);
|
|
1857
|
+
|
|
1858
|
+
await this.queue.updateItemStatus(item.id, 'failed', {
|
|
1859
|
+
error: err.message
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
await this.webhook.notifyError({
|
|
1863
|
+
item: item.metadata.title,
|
|
1864
|
+
error: err.message,
|
|
1865
|
+
phase: PHASES.PUBLISH
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
}));
|
|
1869
|
+
|
|
1870
|
+
await this.saveCheckpoint();
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
this.analytics.endPhase(PHASES.PUBLISH);
|
|
1874
|
+
this.setPhase(PHASES.COMPLETE);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Run the complete workflow
|
|
1879
|
+
* @param {Object} handlers - Phase handlers
|
|
1880
|
+
* @returns {Promise<Object>} Final results
|
|
1881
|
+
*/
|
|
1882
|
+
async run(handlers) {
|
|
1883
|
+
const { verify, publish, confirm } = handlers;
|
|
1884
|
+
|
|
1885
|
+
// Cron mode: acquire lock and check run interval
|
|
1886
|
+
if (this.config.cronMode) {
|
|
1887
|
+
const lockAcquired = await this.cronHelper.acquireLock();
|
|
1888
|
+
if (!lockAcquired) {
|
|
1889
|
+
if (!this.config.quietMode) {
|
|
1890
|
+
console.log('[ContentCoordinator] Another instance is running, exiting');
|
|
1891
|
+
}
|
|
1892
|
+
return { skipped: true, reason: 'lock_held' };
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
const intervalCheck = await this.cronHelper.checkRunInterval();
|
|
1896
|
+
if (!intervalCheck.canRun) {
|
|
1897
|
+
await this.cronHelper.releaseLock();
|
|
1898
|
+
if (!this.config.quietMode) {
|
|
1899
|
+
console.log(`[ContentCoordinator] Minimum interval not met, next run at ${intervalCheck.nextRunTime}`);
|
|
1900
|
+
}
|
|
1901
|
+
return { skipped: true, reason: 'interval_not_met', nextRunTime: intervalCheck.nextRunTime };
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
try {
|
|
1906
|
+
// Try to resume from checkpoint
|
|
1907
|
+
const resumed = await this.loadCheckpoint();
|
|
1908
|
+
if (resumed && this.state.phase !== PHASES.INITIALIZE) {
|
|
1909
|
+
this.log(`Resuming from phase: ${this.state.phase}`);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// Run phases based on current state
|
|
1913
|
+
if (this.state.phase === PHASES.INITIALIZE) {
|
|
1914
|
+
await this.phaseInitialize();
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
if (this.state.phase === PHASES.INITIALIZE || this.state.phase === PHASES.VERIFY) {
|
|
1918
|
+
await this.phaseVerify(verify);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
if ([PHASES.INITIALIZE, PHASES.VERIFY, PHASES.CATEGORIZE].includes(this.state.phase)) {
|
|
1922
|
+
await this.phaseCategorize();
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if ([PHASES.INITIALIZE, PHASES.VERIFY, PHASES.CATEGORIZE, PHASES.REPORT].includes(this.state.phase)) {
|
|
1926
|
+
await this.phaseReport();
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const notification = await this.phaseNotify();
|
|
1930
|
+
|
|
1931
|
+
// Request confirmation if not in force mode (skip in cron mode)
|
|
1932
|
+
if (!this.config.force && !this.config.cronMode && this.state.results.ready.length > 0) {
|
|
1933
|
+
if (confirm) {
|
|
1934
|
+
const confirmed = await confirm(notification);
|
|
1935
|
+
if (!confirmed) {
|
|
1936
|
+
this.log('Publishing cancelled by user');
|
|
1937
|
+
this.analytics.endWorkflow();
|
|
1938
|
+
if (this.config.cronMode) {
|
|
1939
|
+
await this.cronHelper.releaseLock();
|
|
1940
|
+
}
|
|
1941
|
+
return this.getFinalResults();
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
if (this.state.results.ready.length > 0) {
|
|
1947
|
+
await this.phasePublish(publish);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// End workflow tracking
|
|
1951
|
+
this.analytics.endWorkflow();
|
|
1952
|
+
|
|
1953
|
+
// Clear checkpoint on successful completion
|
|
1954
|
+
await this.clearCheckpoint();
|
|
1955
|
+
|
|
1956
|
+
// Final webhook notification
|
|
1957
|
+
const results = this.getFinalResults();
|
|
1958
|
+
|
|
1959
|
+
// Add scheduling summary to results
|
|
1960
|
+
results.scheduling = this.scheduler.getSummary();
|
|
1961
|
+
|
|
1962
|
+
await this.webhook.notifyComplete(results);
|
|
1963
|
+
|
|
1964
|
+
// Cron mode: record run and release lock
|
|
1965
|
+
if (this.config.cronMode) {
|
|
1966
|
+
await this.cronHelper.recordRun(results);
|
|
1967
|
+
await this.cronHelper.releaseLock();
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
return results;
|
|
1971
|
+
|
|
1972
|
+
} catch (err) {
|
|
1973
|
+
this.logError('Workflow error:', err.message);
|
|
1974
|
+
this.analytics.endWorkflow();
|
|
1975
|
+
await this.saveCheckpoint();
|
|
1976
|
+
await this.webhook.notifyError({
|
|
1977
|
+
error: err.message,
|
|
1978
|
+
phase: this.state.phase
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
// Cron mode: release lock on error
|
|
1982
|
+
if (this.config.cronMode) {
|
|
1983
|
+
await this.cronHelper.releaseLock();
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
throw err;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Get final results summary
|
|
1992
|
+
* @returns {Object} Final results
|
|
1993
|
+
*/
|
|
1994
|
+
getFinalResults() {
|
|
1995
|
+
return {
|
|
1996
|
+
success: this.state.stats.failed === 0,
|
|
1997
|
+
stats: this.state.stats,
|
|
1998
|
+
reports: this.state.reports,
|
|
1999
|
+
published: this.state.results.published.map(item => ({
|
|
2000
|
+
title: item.metadata.title,
|
|
2001
|
+
url: item.publishedUrl,
|
|
2002
|
+
platform: item.metadata.target_platform
|
|
2003
|
+
})),
|
|
2004
|
+
needsReview: this.state.results.needsReview.map(item => ({
|
|
2005
|
+
title: item.metadata.title,
|
|
2006
|
+
reason: item.verification?.feedback || 'Requires review'
|
|
2007
|
+
})),
|
|
2008
|
+
failed: this.state.results.failed.map(item => ({
|
|
2009
|
+
title: item.metadata.title,
|
|
2010
|
+
errors: item.errors
|
|
2011
|
+
})),
|
|
2012
|
+
duration: this.calculateDuration(),
|
|
2013
|
+
analytics: this.config.enableAnalytics ? this.analytics.getSummary() : null,
|
|
2014
|
+
contentRulesApplied: this.state.contentRulesLoaded
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* Generate ready for publishing report
|
|
2020
|
+
* @param {string} timestamp - Report timestamp
|
|
2021
|
+
* @returns {string} Markdown report
|
|
2022
|
+
*/
|
|
2023
|
+
generateReadyReport(timestamp) {
|
|
2024
|
+
const items = this.state.results.ready;
|
|
2025
|
+
const totalWords = items.reduce((sum, item) => {
|
|
2026
|
+
return sum + (item.content?.split(/\s+/).length || 0);
|
|
2027
|
+
}, 0);
|
|
2028
|
+
|
|
2029
|
+
let report = `# Content Ready for Publishing
|
|
2030
|
+
Generated: ${this.formatTimestamp(timestamp)}
|
|
2031
|
+
|
|
2032
|
+
## Summary
|
|
2033
|
+
- Total Items: ${items.length}
|
|
2034
|
+
- Total Word Count: ~${Math.round(totalWords / 1000)}K words
|
|
2035
|
+
- Average Redundancy Score: ${this.calculateAverageScore(items)}
|
|
2036
|
+
- Content Rules Applied: ${this.state.contentRulesLoaded ? 'Yes' : 'No'}
|
|
2037
|
+
|
|
2038
|
+
## Items
|
|
2039
|
+
|
|
2040
|
+
`;
|
|
2041
|
+
|
|
2042
|
+
items.forEach((item, index) => {
|
|
2043
|
+
const v = item.verification || {};
|
|
2044
|
+
report += `### ${index + 1}. ${item.metadata.title}
|
|
2045
|
+
- **File**: ${path.basename(item.filePath)}
|
|
2046
|
+
- **Redundancy Score**: ${v.redundancyScore || 'N/A'}
|
|
2047
|
+
- **Recommendation**: ${v.recommendation || 'N/A'}
|
|
2048
|
+
- **Word Count**: ~${item.content?.split(/\s+/).length || 0} words
|
|
2049
|
+
- **Verifier Feedback**: ${v.feedback || 'No feedback provided'}
|
|
2050
|
+
- **Target Platform**: ${item.metadata.target_platform || 'wordpress'}
|
|
2051
|
+
- **Priority**: ${item.metadata.priority || 'normal'}
|
|
2052
|
+
|
|
2053
|
+
`;
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
return report;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* Generate needs review report
|
|
2061
|
+
* @param {string} timestamp - Report timestamp
|
|
2062
|
+
* @returns {string} Markdown report
|
|
2063
|
+
*/
|
|
2064
|
+
generateNeedsReviewReport(timestamp) {
|
|
2065
|
+
const reviewItems = this.state.results.needsReview;
|
|
2066
|
+
const failedItems = this.state.results.failed;
|
|
2067
|
+
|
|
2068
|
+
let report = `# Content Requiring Review
|
|
2069
|
+
Generated: ${this.formatTimestamp(timestamp)}
|
|
2070
|
+
|
|
2071
|
+
## Summary
|
|
2072
|
+
- Items Needing Review: ${reviewItems.length}
|
|
2073
|
+
- Failed Items: ${failedItems.length}
|
|
2074
|
+
- Total: ${reviewItems.length + failedItems.length}
|
|
2075
|
+
|
|
2076
|
+
`;
|
|
2077
|
+
|
|
2078
|
+
if (reviewItems.length > 0) {
|
|
2079
|
+
report += `## Items Needing Review
|
|
2080
|
+
|
|
2081
|
+
`;
|
|
2082
|
+
reviewItems.forEach((item, index) => {
|
|
2083
|
+
const v = item.verification || {};
|
|
2084
|
+
report += `### ${index + 1}. ${item.metadata.title}
|
|
2085
|
+
- **File**: ${path.basename(item.filePath)}
|
|
2086
|
+
- **Redundancy Score**: ${v.redundancyScore || 'N/A'}
|
|
2087
|
+
- **Recommendation**: ${v.recommendation || 'N/A'}
|
|
2088
|
+
- **Issue**: ${this.getReviewReason(item)}
|
|
2089
|
+
- **Verifier Feedback**: ${v.feedback || 'No feedback provided'}
|
|
2090
|
+
- **Suggested Actions**:
|
|
2091
|
+
- Review and revise proprietary content
|
|
2092
|
+
- Ensure content adds unique value
|
|
2093
|
+
- Re-submit for verification after changes
|
|
2094
|
+
|
|
2095
|
+
`;
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
if (failedItems.length > 0) {
|
|
2100
|
+
report += `## Failed Items
|
|
2101
|
+
|
|
2102
|
+
`;
|
|
2103
|
+
failedItems.forEach((item, index) => {
|
|
2104
|
+
report += `### ${index + 1}. ${item.metadata.title}
|
|
2105
|
+
- **File**: ${path.basename(item.filePath)}
|
|
2106
|
+
- **Errors**:
|
|
2107
|
+
${item.errors.map(e => ` - ${e}`).join('\n')}
|
|
2108
|
+
- **Suggested Actions**:
|
|
2109
|
+
- Review file format and content
|
|
2110
|
+
- Fix any parsing errors
|
|
2111
|
+
- Re-submit for processing
|
|
2112
|
+
|
|
2113
|
+
`;
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
return report;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* Generate analytics report
|
|
2122
|
+
* @param {string} timestamp - Report timestamp
|
|
2123
|
+
* @returns {string} Markdown report
|
|
2124
|
+
*/
|
|
2125
|
+
generateAnalyticsReport(timestamp) {
|
|
2126
|
+
const analytics = this.analytics.getSummary();
|
|
2127
|
+
|
|
2128
|
+
let report = `# Content Coordination Analytics
|
|
2129
|
+
Generated: ${this.formatTimestamp(timestamp)}
|
|
2130
|
+
|
|
2131
|
+
## Workflow Summary
|
|
2132
|
+
- **Total Duration**: ${analytics.summary.totalDuration}
|
|
2133
|
+
- **Success Rate**: ${analytics.summary.successRate}
|
|
2134
|
+
- **Average Quality Score**: ${analytics.summary.avgQualityScore}
|
|
2135
|
+
|
|
2136
|
+
## Item Statistics
|
|
2137
|
+
| Metric | Count |
|
|
2138
|
+
|--------|-------|
|
|
2139
|
+
| Total Items | ${analytics.items.total} |
|
|
2140
|
+
| Verified | ${analytics.items.verified} |
|
|
2141
|
+
| Published | ${analytics.items.published} |
|
|
2142
|
+
| Failed | ${analytics.items.failed} |
|
|
2143
|
+
|
|
2144
|
+
## Performance Metrics
|
|
2145
|
+
| Metric | Value |
|
|
2146
|
+
|--------|-------|
|
|
2147
|
+
| Avg Verification Time | ${analytics.summary.avgVerificationTime} |
|
|
2148
|
+
| Avg Publish Time | ${analytics.summary.avgPublishTime} |
|
|
2149
|
+
| Total Verification Time | ${this.analytics.formatDuration(analytics.performance.totalVerificationTime)} |
|
|
2150
|
+
| Total Publish Time | ${this.analytics.formatDuration(analytics.performance.totalPublishTime)} |
|
|
2151
|
+
|
|
2152
|
+
## Phase Timing
|
|
2153
|
+
`;
|
|
2154
|
+
|
|
2155
|
+
for (const [phase, data] of Object.entries(analytics.workflow.phases)) {
|
|
2156
|
+
report += `- **${phase}**: ${this.analytics.formatDuration(data.duration)}\n`;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
report += `
|
|
2160
|
+
## Quality Metrics
|
|
2161
|
+
- **Average Redundancy Score**: ${analytics.quality.avgRedundancyScore.toFixed(2)}/4 (lower is better)
|
|
2162
|
+
- **Average Uniqueness Score**: ${(analytics.quality.avgUniquenessScore * 100).toFixed(1)}%
|
|
2163
|
+
|
|
2164
|
+
`;
|
|
2165
|
+
|
|
2166
|
+
if (analytics.errors.length > 0) {
|
|
2167
|
+
report += `## Errors (${analytics.errors.length})
|
|
2168
|
+
`;
|
|
2169
|
+
analytics.errors.forEach((err, i) => {
|
|
2170
|
+
report += `${i + 1}. **${err.item}** (${err.phase}): ${err.error}\n`;
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
return report;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Get reason why item needs review
|
|
2179
|
+
* @param {Object} item - Content item
|
|
2180
|
+
* @returns {string} Review reason
|
|
2181
|
+
*/
|
|
2182
|
+
getReviewReason(item) {
|
|
2183
|
+
const v = item.verification || {};
|
|
2184
|
+
const score = (v.redundancyScore || '').toLowerCase();
|
|
2185
|
+
const rec = (v.recommendation || '').toLowerCase();
|
|
2186
|
+
|
|
2187
|
+
if (score === 'high') return 'High redundancy - content may duplicate existing knowledge';
|
|
2188
|
+
if (score === 'medium') return 'Medium redundancy - consider adding more unique insights';
|
|
2189
|
+
if (rec.includes('reject')) return 'Content rejected by verifier';
|
|
2190
|
+
if (rec.includes('review')) return 'Manual review recommended';
|
|
2191
|
+
|
|
2192
|
+
return 'Content requires attention before publishing';
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* Calculate average redundancy score
|
|
2197
|
+
* @param {Object[]} items - Items to calculate from
|
|
2198
|
+
* @returns {string} Average score label
|
|
2199
|
+
*/
|
|
2200
|
+
calculateAverageScore(items) {
|
|
2201
|
+
const scores = { minimal: 1, low: 2, medium: 3, high: 4 };
|
|
2202
|
+
let total = 0;
|
|
2203
|
+
let count = 0;
|
|
2204
|
+
|
|
2205
|
+
for (const item of items) {
|
|
2206
|
+
const score = (item.verification?.redundancyScore || '').toLowerCase();
|
|
2207
|
+
if (scores[score]) {
|
|
2208
|
+
total += scores[score];
|
|
2209
|
+
count++;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
if (count === 0) return 'N/A';
|
|
2214
|
+
|
|
2215
|
+
const avg = total / count;
|
|
2216
|
+
if (avg <= 1.5) return 'Minimal';
|
|
2217
|
+
if (avg <= 2.5) return 'Low';
|
|
2218
|
+
if (avg <= 3.5) return 'Medium';
|
|
2219
|
+
return 'High';
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Execute function with retry logic
|
|
2224
|
+
* @param {Function} fn - Function to execute
|
|
2225
|
+
* @returns {Promise<*>} Function result
|
|
2226
|
+
*/
|
|
2227
|
+
async withRetry(fn) {
|
|
2228
|
+
let lastError;
|
|
2229
|
+
|
|
2230
|
+
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
|
|
2231
|
+
try {
|
|
2232
|
+
return await fn();
|
|
2233
|
+
} catch (err) {
|
|
2234
|
+
lastError = err;
|
|
2235
|
+
if (attempt < this.config.retryAttempts) {
|
|
2236
|
+
this.log(`Retry attempt ${attempt}/${this.config.retryAttempts} after error:`, err.message);
|
|
2237
|
+
await this.sleep(this.config.retryDelay * attempt);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
throw lastError;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/**
|
|
2246
|
+
* Set current phase and notify
|
|
2247
|
+
* @param {string} phase - New phase
|
|
2248
|
+
*/
|
|
2249
|
+
setPhase(phase) {
|
|
2250
|
+
const previousPhase = this.state.phase;
|
|
2251
|
+
this.state.phase = phase;
|
|
2252
|
+
|
|
2253
|
+
if (this.callbacks.onPhaseChange) {
|
|
2254
|
+
this.callbacks.onPhaseChange(phase, previousPhase);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
this.log(`Phase: ${previousPhase} -> ${phase}`);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* Notify item completion
|
|
2262
|
+
* @param {Object} item - Completed item
|
|
2263
|
+
* @param {string} action - Action completed
|
|
2264
|
+
*/
|
|
2265
|
+
notifyItemComplete(item, action) {
|
|
2266
|
+
if (this.callbacks.onItemComplete) {
|
|
2267
|
+
this.callbacks.onItemComplete(item, action);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
if (this.callbacks.onProgress) {
|
|
2271
|
+
this.callbacks.onProgress(this.state.stats);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* Notify error
|
|
2277
|
+
* @param {Object} item - Item with error
|
|
2278
|
+
* @param {Error} error - Error object
|
|
2279
|
+
*/
|
|
2280
|
+
notifyError(item, error) {
|
|
2281
|
+
this.state.errors.push({
|
|
2282
|
+
item: item.metadata.title,
|
|
2283
|
+
error: error.message,
|
|
2284
|
+
timestamp: new Date().toISOString()
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
if (this.callbacks.onError) {
|
|
2288
|
+
this.callbacks.onError(item, error);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/**
|
|
2293
|
+
* Get timestamp string
|
|
2294
|
+
* @returns {string} Formatted timestamp
|
|
2295
|
+
*/
|
|
2296
|
+
getTimestamp() {
|
|
2297
|
+
const now = new Date();
|
|
2298
|
+
return now.toISOString()
|
|
2299
|
+
.replace(/T/, '-')
|
|
2300
|
+
.replace(/:/g, '-')
|
|
2301
|
+
.replace(/\..+/, '');
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/**
|
|
2305
|
+
* Format timestamp for display
|
|
2306
|
+
* @param {string} timestamp - Timestamp to format
|
|
2307
|
+
* @returns {string} Human-readable timestamp
|
|
2308
|
+
*/
|
|
2309
|
+
formatTimestamp(timestamp) {
|
|
2310
|
+
return timestamp.replace(/-/g, (m, i) => i > 9 ? ':' : '-').replace('-', ' ');
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Calculate workflow duration
|
|
2315
|
+
* @returns {string} Duration string
|
|
2316
|
+
*/
|
|
2317
|
+
calculateDuration() {
|
|
2318
|
+
if (!this.state.startedAt) return 'N/A';
|
|
2319
|
+
|
|
2320
|
+
const start = new Date(this.state.startedAt);
|
|
2321
|
+
const end = new Date();
|
|
2322
|
+
const ms = end - start;
|
|
2323
|
+
|
|
2324
|
+
const seconds = Math.floor(ms / 1000);
|
|
2325
|
+
const minutes = Math.floor(seconds / 60);
|
|
2326
|
+
const hours = Math.floor(minutes / 60);
|
|
2327
|
+
|
|
2328
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
2329
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
2330
|
+
return `${seconds}s`;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/**
|
|
2334
|
+
* Sleep for specified duration
|
|
2335
|
+
* @param {number} ms - Milliseconds to sleep
|
|
2336
|
+
* @returns {Promise<void>}
|
|
2337
|
+
*/
|
|
2338
|
+
sleep(ms) {
|
|
2339
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* Log message if verbose mode
|
|
2344
|
+
* @param {...*} args - Log arguments
|
|
2345
|
+
*/
|
|
2346
|
+
log(...args) {
|
|
2347
|
+
if (this.config.verbose) {
|
|
2348
|
+
console.log('[ContentCoordinator]', ...args);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* Log error
|
|
2354
|
+
* @param {...*} args - Error arguments
|
|
2355
|
+
*/
|
|
2356
|
+
logError(...args) {
|
|
2357
|
+
console.error('[ContentCoordinator Error]', ...args);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// ============================================
|
|
2361
|
+
// Queue Management Methods
|
|
2362
|
+
// ============================================
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* Add item to persistent queue
|
|
2366
|
+
* @param {Object} item - Item to add
|
|
2367
|
+
* @returns {Promise<Object>} Added item
|
|
2368
|
+
*/
|
|
2369
|
+
async addToQueue(item) {
|
|
2370
|
+
return this.queue.addItem(item);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* Remove item from queue
|
|
2375
|
+
* @param {string} id - Item ID
|
|
2376
|
+
* @returns {Promise<boolean>} Success
|
|
2377
|
+
*/
|
|
2378
|
+
async removeFromQueue(id) {
|
|
2379
|
+
return this.queue.removeItem(id);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/**
|
|
2383
|
+
* Get queue statistics
|
|
2384
|
+
* @returns {Object} Queue stats
|
|
2385
|
+
*/
|
|
2386
|
+
getQueueStats() {
|
|
2387
|
+
return this.queue.getStats();
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/**
|
|
2391
|
+
* Clear completed items from queue
|
|
2392
|
+
* @returns {Promise<number>} Number cleared
|
|
2393
|
+
*/
|
|
2394
|
+
async clearCompletedFromQueue() {
|
|
2395
|
+
return this.queue.clearCompleted();
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/**
|
|
2399
|
+
* Get available publishing platforms
|
|
2400
|
+
* @returns {string[]} Platform names
|
|
2401
|
+
*/
|
|
2402
|
+
getAvailablePlatforms() {
|
|
2403
|
+
return this.publisher.getAvailablePlatforms();
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// ============================================
|
|
2407
|
+
// Cron & Scheduling Methods
|
|
2408
|
+
// ============================================
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* Generate crontab entry for automated scheduling
|
|
2412
|
+
* @param {Object} options - Crontab options
|
|
2413
|
+
* @returns {string} Crontab entry
|
|
2414
|
+
*/
|
|
2415
|
+
generateCrontabEntry(options = {}) {
|
|
2416
|
+
return this.cronHelper.generateCrontabEntry({
|
|
2417
|
+
workDir: this.workDir || process.cwd(),
|
|
2418
|
+
...options
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* Get suggested cron schedules
|
|
2424
|
+
* @returns {Object[]} Array of schedule suggestions
|
|
2425
|
+
*/
|
|
2426
|
+
getSuggestedSchedules() {
|
|
2427
|
+
return this.cronHelper.getSuggestedSchedules();
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Validate a cron expression
|
|
2432
|
+
* @param {string} expression - Cron expression to validate
|
|
2433
|
+
* @returns {{valid: boolean, error?: string}}
|
|
2434
|
+
*/
|
|
2435
|
+
validateCronExpression(expression) {
|
|
2436
|
+
return this.cronHelper.validateCronExpression(expression);
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* Get last run information
|
|
2441
|
+
* @returns {Promise<Object|null>}
|
|
2442
|
+
*/
|
|
2443
|
+
async getLastRun() {
|
|
2444
|
+
return this.cronHelper.getLastRun();
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/**
|
|
2448
|
+
* Get scheduling summary
|
|
2449
|
+
* @returns {Object} Scheduling summary
|
|
2450
|
+
*/
|
|
2451
|
+
getSchedulingSummary() {
|
|
2452
|
+
return this.scheduler.getSummary();
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* Generate scheduling report
|
|
2457
|
+
* @returns {string} Markdown scheduling report
|
|
2458
|
+
*/
|
|
2459
|
+
generateScheduleReport() {
|
|
2460
|
+
return this.scheduler.generateScheduleReport();
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/**
|
|
2464
|
+
* Check if lock is currently held
|
|
2465
|
+
* @returns {Promise<boolean>}
|
|
2466
|
+
*/
|
|
2467
|
+
async isLocked() {
|
|
2468
|
+
try {
|
|
2469
|
+
await fs.access(this.cronHelper.lockPath);
|
|
2470
|
+
return true;
|
|
2471
|
+
} catch {
|
|
2472
|
+
return false;
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
/**
|
|
2477
|
+
* Force release lock (use with caution)
|
|
2478
|
+
* @returns {Promise<void>}
|
|
2479
|
+
*/
|
|
2480
|
+
async forceReleaseLock() {
|
|
2481
|
+
await this.cronHelper.releaseLock();
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/**
|
|
2485
|
+
* Setup crontab entry (prints instructions)
|
|
2486
|
+
* @param {Object} options - Setup options
|
|
2487
|
+
* @returns {string} Setup instructions
|
|
2488
|
+
*/
|
|
2489
|
+
getCrontabSetupInstructions(options = {}) {
|
|
2490
|
+
const entry = this.generateCrontabEntry(options);
|
|
2491
|
+
const schedules = this.getSuggestedSchedules();
|
|
2492
|
+
|
|
2493
|
+
return `
|
|
2494
|
+
# Content Coordinator Crontab Setup
|
|
2495
|
+
# ==================================
|
|
2496
|
+
|
|
2497
|
+
## Quick Setup
|
|
2498
|
+
|
|
2499
|
+
1. Open crontab for editing:
|
|
2500
|
+
crontab -e
|
|
2501
|
+
|
|
2502
|
+
2. Add one of the following entries:
|
|
2503
|
+
|
|
2504
|
+
${entry}
|
|
2505
|
+
|
|
2506
|
+
## Suggested Schedules
|
|
2507
|
+
|
|
2508
|
+
${schedules.map(s => `### ${s.name}
|
|
2509
|
+
# ${s.description}
|
|
2510
|
+
# Schedule: ${s.schedule}
|
|
2511
|
+
`).join('\n')}
|
|
2512
|
+
|
|
2513
|
+
## Environment Variables
|
|
2514
|
+
|
|
2515
|
+
Make sure these are set in your crontab or script:
|
|
2516
|
+
- WORDPRESS_URL
|
|
2517
|
+
- WORDPRESS_USERNAME
|
|
2518
|
+
- WORDPRESS_APP_PASSWORD
|
|
2519
|
+
|
|
2520
|
+
## Log Rotation (optional)
|
|
2521
|
+
|
|
2522
|
+
Add to /etc/logrotate.d/content-coordinator:
|
|
2523
|
+
\`\`\`
|
|
2524
|
+
/var/log/content-coordinator.log {
|
|
2525
|
+
weekly
|
|
2526
|
+
rotate 4
|
|
2527
|
+
compress
|
|
2528
|
+
missingok
|
|
2529
|
+
notifempty
|
|
2530
|
+
}
|
|
2531
|
+
\`\`\`
|
|
2532
|
+
|
|
2533
|
+
## Verify Setup
|
|
2534
|
+
|
|
2535
|
+
After adding, verify with:
|
|
2536
|
+
crontab -l
|
|
2537
|
+
|
|
2538
|
+
Check logs at:
|
|
2539
|
+
tail -f /var/log/content-coordinator.log
|
|
2540
|
+
`;
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Export for use in other modules
|
|
2545
|
+
export {
|
|
2546
|
+
ContentCoordinator,
|
|
2547
|
+
ContentRulesManager,
|
|
2548
|
+
AnalyticsTracker,
|
|
2549
|
+
WebhookNotifier,
|
|
2550
|
+
QueueManager,
|
|
2551
|
+
PlatformPublisher,
|
|
2552
|
+
CronHelper,
|
|
2553
|
+
ScheduleManager,
|
|
2554
|
+
PHASES,
|
|
2555
|
+
ITEM_STATUS,
|
|
2556
|
+
REDUNDANCY_THRESHOLDS,
|
|
2557
|
+
PLATFORMS,
|
|
2558
|
+
DEFAULT_CONFIG
|
|
2559
|
+
};
|
|
2560
|
+
|
|
2561
|
+
// Default export for convenience
|
|
2562
|
+
export default ContentCoordinator;
|