claude-plugin-wordpress-manager 2.12.2 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +8 -3
- package/CHANGELOG.md +94 -2
- package/agents/wp-accessibility-auditor.md +1 -1
- package/agents/wp-content-strategist.md +2 -2
- package/agents/wp-deployment-engineer.md +1 -1
- package/agents/wp-distribution-manager.md +1 -1
- package/agents/wp-monitoring-agent.md +1 -1
- package/agents/wp-performance-optimizer.md +1 -1
- package/agents/wp-security-auditor.md +1 -1
- package/agents/wp-site-manager.md +3 -3
- package/commands/wp-setup.md +2 -2
- package/docs/GUIDE.md +260 -21
- package/docs/VALIDATION.md +341 -0
- package/docs/guides/wp-ecommerce.md +4 -4
- package/docs/plans/2026-03-01-tier3-wcop-implementation.md +1 -1
- package/docs/plans/2026-03-01-tier4-5-implementation.md +1 -1
- package/docs/plans/2026-03-02-content-framework-architecture.md +612 -0
- package/docs/plans/2026-03-02-content-framework-strategic-reflections.md +228 -0
- package/docs/plans/2026-03-02-content-intelligence-phase2.md +560 -0
- package/docs/plans/2026-03-02-content-pipeline-phase1.md +456 -0
- package/docs/plans/2026-03-02-dashboard-kanban-design.md +761 -0
- package/docs/plans/2026-03-02-dashboard-kanban-implementation.md +598 -0
- package/docs/plans/2026-03-02-dashboard-strategy.md +363 -0
- package/docs/plans/2026-03-02-editorial-calendar-phase3.md +490 -0
- package/docs/validation/.gitkeep +0 -0
- package/docs/validation/dashboard.html +286 -0
- package/docs/validation/results.json +1705 -0
- package/package.json +16 -3
- package/scripts/context-scanner.mjs +446 -0
- package/scripts/dashboard-renderer.mjs +553 -0
- package/scripts/run-validation.mjs +1132 -0
- package/servers/wp-rest-bridge/build/server.js +17 -6
- package/servers/wp-rest-bridge/build/tools/index.js +0 -9
- package/servers/wp-rest-bridge/build/tools/plugin-repository.js +23 -31
- package/servers/wp-rest-bridge/build/tools/schema.js +10 -2
- package/servers/wp-rest-bridge/build/tools/unified-content.js +10 -2
- package/servers/wp-rest-bridge/build/wordpress.d.ts +0 -3
- package/servers/wp-rest-bridge/build/wordpress.js +16 -98
- package/servers/wp-rest-bridge/package.json +1 -0
- package/skills/wp-analytics/SKILL.md +153 -0
- package/skills/wp-analytics/references/signals-feed-schema.md +417 -0
- package/skills/wp-content/references/content-templates.md +1 -1
- package/skills/wp-content/references/seo-optimization.md +8 -8
- package/skills/wp-content-attribution/references/roi-calculation.md +1 -1
- package/skills/wp-content-attribution/references/utm-tracking-setup.md +5 -5
- package/skills/wp-content-generation/references/generation-workflow.md +2 -2
- package/skills/wp-content-pipeline/SKILL.md +461 -0
- package/skills/wp-content-pipeline/references/content-brief-schema.md +377 -0
- package/skills/wp-content-pipeline/references/site-config-schema.md +431 -0
- package/skills/wp-content-repurposing/references/auto-transform-pipeline.md +1 -1
- package/skills/wp-content-repurposing/references/email-newsletter.md +1 -1
- package/skills/wp-content-repurposing/references/platform-specs.md +2 -2
- package/skills/wp-content-repurposing/references/transform-templates.md +27 -27
- package/skills/wp-dashboard/SKILL.md +121 -0
- package/skills/wp-deploy/references/ssh-deploy.md +2 -2
- package/skills/wp-editorial-planner/SKILL.md +262 -0
- package/skills/wp-editorial-planner/references/editorial-schema.md +268 -0
- package/skills/wp-multilang-network/references/content-sync.md +3 -3
- package/skills/wp-multilang-network/references/network-architecture.md +1 -1
- package/skills/wp-multilang-network/references/seo-international.md +7 -7
- package/skills/wp-structured-data/references/schema-types.md +4 -4
- package/skills/wp-webhooks/references/payload-formats.md +3 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-plugin-wordpress-manager",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Unified WordPress management and development plugin for Claude Code. Orchestrates Hostinger MCP, WP REST API bridge (148 tools incl. 30 WooCommerce + 10 Multisite + 4 Webhooks + 7 Mailchimp + 5 Buffer + 6 SendGrid + 8 GSC + 6 GA4 + 4 Plausible + 4 CWV + 3 Slack + 4 Workflows + 5 LinkedIn + 5 Twitter + 3 Schema), and WordPress.com MCP with
|
|
3
|
+
"version": "2.14.0",
|
|
4
|
+
"description": "Unified WordPress management and development plugin for Claude Code. Orchestrates Hostinger MCP, WP REST API bridge (148 tools incl. 30 WooCommerce + 10 Multisite + 4 Webhooks + 7 Mailchimp + 5 Buffer + 6 SendGrid + 8 GSC + 6 GA4 + 4 Plausible + 4 CWV + 3 Slack + 4 Workflows + 5 LinkedIn + 5 Twitter + 3 Schema), and WordPress.com MCP with 46 skills, 12 agents, and security hooks. v2.14.0 adds Editorial Kanban Dashboard: self-contained HTML report generated from .content-state/ files.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "vinmor",
|
|
7
7
|
"email": "morreale.v@gmail.com"
|
|
@@ -66,7 +66,14 @@
|
|
|
66
66
|
"cron-triggers",
|
|
67
67
|
"linkedin",
|
|
68
68
|
"twitter",
|
|
69
|
-
"direct-social"
|
|
69
|
+
"direct-social",
|
|
70
|
+
"content-framework",
|
|
71
|
+
"content-pipeline",
|
|
72
|
+
"editorial-calendar",
|
|
73
|
+
"signals-intelligence",
|
|
74
|
+
"dashboard",
|
|
75
|
+
"kanban",
|
|
76
|
+
"editorial-dashboard"
|
|
70
77
|
],
|
|
71
78
|
"repository": {
|
|
72
79
|
"type": "git",
|
|
@@ -76,6 +83,12 @@
|
|
|
76
83
|
"bugs": {
|
|
77
84
|
"url": "https://github.com/morrealev/wordpress-manager/issues"
|
|
78
85
|
},
|
|
86
|
+
"scripts": {
|
|
87
|
+
"validate": "node scripts/run-validation.mjs",
|
|
88
|
+
"validate:module": "node scripts/run-validation.mjs --module",
|
|
89
|
+
"validate:all": "node scripts/run-validation.mjs --include-writes",
|
|
90
|
+
"dashboard": "node scripts/dashboard-renderer.mjs"
|
|
91
|
+
},
|
|
79
92
|
"files": [
|
|
80
93
|
".claude-plugin/",
|
|
81
94
|
".mcp.json",
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/context-scanner.mjs — Shared SCAN + AGGREGATE module for dashboard system
|
|
3
|
+
// Reads .content-state/ files, parses YAML frontmatter and editorial tables,
|
|
4
|
+
// computes aggregate metrics for rendering.
|
|
5
|
+
//
|
|
6
|
+
// Exports:
|
|
7
|
+
// scanContentState(contentStatePath, siteId, month?)
|
|
8
|
+
// aggregateMetrics(rawData, viewType)
|
|
9
|
+
// renderContextSnippet(metrics, sliceType)
|
|
10
|
+
// parseFrontmatter(content)
|
|
11
|
+
// parseEditorialTable(markdownBody, calendarPeriod)
|
|
12
|
+
|
|
13
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
14
|
+
import { resolve, dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PROJECT_ROOT = resolve(__dirname, '..');
|
|
19
|
+
|
|
20
|
+
// ── YAML Frontmatter Parser ────────────────────────────────────────
|
|
21
|
+
// Handles the subset of YAML used in .content-state/ files:
|
|
22
|
+
// scalars, inline arrays [a, b], multi-line arrays (- item), nested objects (1-2 levels)
|
|
23
|
+
|
|
24
|
+
function parseYamlValue(raw) {
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
if (trimmed === '' || trimmed === 'null' || trimmed === '~') return null;
|
|
27
|
+
if (trimmed === 'true') return true;
|
|
28
|
+
if (trimmed === 'false') return false;
|
|
29
|
+
if (/^-?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
|
|
30
|
+
if (/^-?\d+\.\d+$/.test(trimmed)) return parseFloat(trimmed);
|
|
31
|
+
// Inline array: [a, b, c]
|
|
32
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
33
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
34
|
+
if (inner === '') return [];
|
|
35
|
+
return inner.split(',').map(s => parseYamlValue(s));
|
|
36
|
+
}
|
|
37
|
+
// Quoted string
|
|
38
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
39
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
40
|
+
return trimmed.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
return trimmed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseYamlBlock(lines) {
|
|
46
|
+
const result = {};
|
|
47
|
+
let i = 0;
|
|
48
|
+
|
|
49
|
+
while (i < lines.length) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
|
|
52
|
+
// Skip empty lines and comments
|
|
53
|
+
if (line.trim() === '' || line.trim().startsWith('#')) { i++; continue; }
|
|
54
|
+
|
|
55
|
+
// Detect indentation level
|
|
56
|
+
const indent = line.search(/\S/);
|
|
57
|
+
|
|
58
|
+
// Top-level key: value
|
|
59
|
+
const kvMatch = line.match(/^(\w[\w_-]*):\s*(.*)/);
|
|
60
|
+
if (!kvMatch) { i++; continue; }
|
|
61
|
+
|
|
62
|
+
const key = kvMatch[1];
|
|
63
|
+
const valueStr = kvMatch[2].trim();
|
|
64
|
+
|
|
65
|
+
// Multi-line block scalar (|)
|
|
66
|
+
if (valueStr === '|') {
|
|
67
|
+
const blockLines = [];
|
|
68
|
+
i++;
|
|
69
|
+
while (i < lines.length) {
|
|
70
|
+
const nextIndent = lines[i].search(/\S/);
|
|
71
|
+
if (nextIndent <= indent && lines[i].trim() !== '') break;
|
|
72
|
+
blockLines.push(lines[i].trimStart());
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
result[key] = blockLines.join('\n').trimEnd();
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Value on same line
|
|
80
|
+
if (valueStr !== '') {
|
|
81
|
+
result[key] = parseYamlValue(valueStr);
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Value is on next lines (nested object or multi-line array)
|
|
87
|
+
i++;
|
|
88
|
+
const children = [];
|
|
89
|
+
while (i < lines.length) {
|
|
90
|
+
if (lines[i].trim() === '' || lines[i].trim().startsWith('#')) { i++; continue; }
|
|
91
|
+
const nextIndent = lines[i].search(/\S/);
|
|
92
|
+
if (nextIndent <= indent) break;
|
|
93
|
+
children.push(lines[i]);
|
|
94
|
+
i++;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (children.length === 0) {
|
|
98
|
+
result[key] = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Multi-line array (- item)
|
|
103
|
+
if (children[0].trim().startsWith('- ')) {
|
|
104
|
+
result[key] = children
|
|
105
|
+
.filter(c => c.trim().startsWith('- '))
|
|
106
|
+
.map(c => parseYamlValue(c.trim().slice(2)));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Nested object — recurse with dedented lines
|
|
111
|
+
const minIndent = children[0].search(/\S/);
|
|
112
|
+
const dedented = children.map(c => c.slice(minIndent));
|
|
113
|
+
result[key] = parseYamlBlock(dedented);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseFrontmatter(content) {
|
|
120
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
121
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
122
|
+
|
|
123
|
+
const yamlStr = match[1];
|
|
124
|
+
const body = content.slice(match[0].length).trim();
|
|
125
|
+
const lines = yamlStr.split('\n');
|
|
126
|
+
const frontmatter = parseYamlBlock(lines);
|
|
127
|
+
|
|
128
|
+
return { frontmatter, body };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Editorial Table Parser ─────────────────────────────────────────
|
|
132
|
+
// Parses Markdown tables from editorial calendar .state.md files.
|
|
133
|
+
// Tables have fixed columns: | Data | Titolo | Tipo | Status | Brief ID | Post ID | Canali |
|
|
134
|
+
|
|
135
|
+
const MONTH_MAP = {
|
|
136
|
+
'gen': '01', 'feb': '02', 'mar': '03', 'apr': '04', 'mag': '05', 'giu': '06',
|
|
137
|
+
'lug': '07', 'ago': '08', 'set': '09', 'ott': '10', 'nov': '11', 'dic': '12',
|
|
138
|
+
'jan': '01', 'feb': '02', 'mar': '03', 'apr': '04', 'may': '05', 'jun': '06',
|
|
139
|
+
'jul': '07', 'aug': '08', 'sep': '09', 'oct': '10', 'nov': '11', 'dec': '12'
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function resolveDate(dateStr, calendarPeriod) {
|
|
143
|
+
// dateStr: "Mar 18", "Apr 3", etc.
|
|
144
|
+
// calendarPeriod: "2026-03-01..2026-03-31"
|
|
145
|
+
if (!dateStr || !calendarPeriod) return null;
|
|
146
|
+
|
|
147
|
+
const year = calendarPeriod.slice(0, 4);
|
|
148
|
+
const parts = dateStr.trim().split(/\s+/);
|
|
149
|
+
if (parts.length < 2) return null;
|
|
150
|
+
|
|
151
|
+
const monthKey = parts[0].toLowerCase().slice(0, 3);
|
|
152
|
+
const day = parseInt(parts[1], 10);
|
|
153
|
+
const month = MONTH_MAP[monthKey];
|
|
154
|
+
if (!month || isNaN(day)) return null;
|
|
155
|
+
|
|
156
|
+
return `${year}-${month}-${String(day).padStart(2, '0')}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseCellValue(cell) {
|
|
160
|
+
const trimmed = cell.trim();
|
|
161
|
+
if (trimmed === '' || trimmed === '—' || trimmed === '-') return null;
|
|
162
|
+
return trimmed;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function parseEditorialTable(markdownBody, calendarPeriod) {
|
|
166
|
+
const entries = [];
|
|
167
|
+
const lines = markdownBody.split('\n');
|
|
168
|
+
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
// Only process table data rows (start with |, contain data)
|
|
171
|
+
if (!line.trim().startsWith('|')) continue;
|
|
172
|
+
|
|
173
|
+
const cells = line.split('|').map(c => c.trim()).filter(c => c !== '');
|
|
174
|
+
|
|
175
|
+
// Skip header rows and separator rows
|
|
176
|
+
if (cells.length < 7) continue;
|
|
177
|
+
if (cells[0] === 'Data' || cells[0].startsWith('---') || cells[0].match(/^-+$/)) continue;
|
|
178
|
+
if (cells.every(c => c.match(/^-+$/))) continue;
|
|
179
|
+
|
|
180
|
+
const title = parseCellValue(cells[1]);
|
|
181
|
+
const channels = parseCellValue(cells[6]);
|
|
182
|
+
|
|
183
|
+
entries.push({
|
|
184
|
+
date: resolveDate(cells[0], calendarPeriod),
|
|
185
|
+
title: title === '[da assegnare]' ? null : title,
|
|
186
|
+
type: parseCellValue(cells[2]) || 'post',
|
|
187
|
+
status: parseCellValue(cells[3]) || 'planned',
|
|
188
|
+
briefId: parseCellValue(cells[4]),
|
|
189
|
+
postId: parseCellValue(cells[5]) ? parseInt(cells[5], 10) || parseCellValue(cells[5]) : null,
|
|
190
|
+
channels: channels ? channels.split(',').map(c => c.trim()).filter(Boolean) : []
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return entries;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Anomaly Table Parser ───────────────────────────────────────────
|
|
198
|
+
// Parses the Anomalies & Patterns table from signals-feed.md
|
|
199
|
+
// | Entity | Metric | Delta | Pattern Match | Action |
|
|
200
|
+
|
|
201
|
+
function parseAnomalyTable(markdownBody) {
|
|
202
|
+
const anomalies = [];
|
|
203
|
+
const lines = markdownBody.split('\n');
|
|
204
|
+
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
if (!line.trim().startsWith('|')) continue;
|
|
207
|
+
|
|
208
|
+
const cells = line.split('|').map(c => c.trim()).filter(c => c !== '');
|
|
209
|
+
if (cells.length < 5) continue;
|
|
210
|
+
if (cells[0] === 'Entity' || cells[0].match(/^-+$/)) continue;
|
|
211
|
+
if (cells.every(c => c.match(/^-+$/))) continue;
|
|
212
|
+
|
|
213
|
+
anomalies.push({
|
|
214
|
+
entity: cells[0],
|
|
215
|
+
metric: cells[1],
|
|
216
|
+
delta: cells[2],
|
|
217
|
+
pattern: cells[3],
|
|
218
|
+
action: cells[4]
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return anomalies;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── File Glob Helper ───────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
async function globFiles(dirPath, suffix) {
|
|
228
|
+
try {
|
|
229
|
+
const files = await readdir(dirPath);
|
|
230
|
+
return files.filter(f => f.endsWith(suffix)).sort();
|
|
231
|
+
} catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── SCAN: scanContentState ─────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
export async function scanContentState(contentStatePath, siteId, month) {
|
|
239
|
+
const absPath = resolve(PROJECT_ROOT, contentStatePath);
|
|
240
|
+
|
|
241
|
+
// 1. Read site config
|
|
242
|
+
const configPath = join(absPath, `${siteId}.config.md`);
|
|
243
|
+
let site;
|
|
244
|
+
try {
|
|
245
|
+
const configContent = await readFile(configPath, 'utf8');
|
|
246
|
+
const { frontmatter, body } = parseFrontmatter(configContent);
|
|
247
|
+
site = {
|
|
248
|
+
id: frontmatter.site_id || siteId,
|
|
249
|
+
url: frontmatter.site_url || null,
|
|
250
|
+
brand: frontmatter.brand || {},
|
|
251
|
+
defaults: frontmatter.defaults || {},
|
|
252
|
+
channels: frontmatter.channels || {},
|
|
253
|
+
seo: frontmatter.seo || {},
|
|
254
|
+
cadence: frontmatter.cadence || {},
|
|
255
|
+
brandContext: body
|
|
256
|
+
};
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err.code === 'ENOENT') {
|
|
259
|
+
throw new Error(`Site config not found: ${configPath}. Run wp-editorial-planner first.`);
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 2. Find and read editorial calendar
|
|
265
|
+
let calendar = null;
|
|
266
|
+
const calendarFiles = await globFiles(absPath, '-editorial.state.md');
|
|
267
|
+
|
|
268
|
+
if (calendarFiles.length > 0) {
|
|
269
|
+
// If month specified, find matching file; otherwise use most recent
|
|
270
|
+
let calFile;
|
|
271
|
+
if (month) {
|
|
272
|
+
calFile = calendarFiles.find(f => f.includes(month));
|
|
273
|
+
}
|
|
274
|
+
if (!calFile) {
|
|
275
|
+
calFile = calendarFiles[calendarFiles.length - 1]; // most recent by name sort
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const calContent = await readFile(join(absPath, calFile), 'utf8');
|
|
279
|
+
const { frontmatter, body } = parseFrontmatter(calContent);
|
|
280
|
+
const entries = parseEditorialTable(body, frontmatter.period);
|
|
281
|
+
|
|
282
|
+
calendar = {
|
|
283
|
+
id: frontmatter.calendar_id,
|
|
284
|
+
period: frontmatter.period,
|
|
285
|
+
goals: frontmatter.goals || {},
|
|
286
|
+
status: frontmatter.status,
|
|
287
|
+
entries
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 3. Read active briefs
|
|
292
|
+
const activeBriefs = [];
|
|
293
|
+
const activeDir = join(absPath, 'pipeline-active');
|
|
294
|
+
const activeFiles = await globFiles(activeDir, '.brief.md');
|
|
295
|
+
for (const file of activeFiles) {
|
|
296
|
+
const content = await readFile(join(activeDir, file), 'utf8');
|
|
297
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
298
|
+
activeBriefs.push({
|
|
299
|
+
briefId: frontmatter.brief_id,
|
|
300
|
+
status: frontmatter.status,
|
|
301
|
+
title: frontmatter.content?.title || null,
|
|
302
|
+
siteId: frontmatter.target?.site_id || null,
|
|
303
|
+
channels: frontmatter.distribution?.channels || [],
|
|
304
|
+
signalRef: frontmatter.source?.signal_ref || null,
|
|
305
|
+
postId: frontmatter.post_id || null,
|
|
306
|
+
postUrl: frontmatter.post_url || null
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 4. Read archived briefs
|
|
311
|
+
const archivedBriefs = [];
|
|
312
|
+
const archiveDir = join(absPath, 'pipeline-archive');
|
|
313
|
+
const archiveFiles = await globFiles(archiveDir, '.brief.md');
|
|
314
|
+
for (const file of archiveFiles) {
|
|
315
|
+
const content = await readFile(join(archiveDir, file), 'utf8');
|
|
316
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
317
|
+
archivedBriefs.push({
|
|
318
|
+
briefId: frontmatter.brief_id,
|
|
319
|
+
status: frontmatter.status,
|
|
320
|
+
title: frontmatter.content?.title || null,
|
|
321
|
+
postId: frontmatter.post_id || null,
|
|
322
|
+
postUrl: frontmatter.post_url || null
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 5. Read signals feed
|
|
327
|
+
let signals = null;
|
|
328
|
+
const signalsPath = join(absPath, 'signals-feed.md');
|
|
329
|
+
try {
|
|
330
|
+
const sigContent = await readFile(signalsPath, 'utf8');
|
|
331
|
+
const { frontmatter, body } = parseFrontmatter(sigContent);
|
|
332
|
+
const anomalies = parseAnomalyTable(body);
|
|
333
|
+
signals = {
|
|
334
|
+
feedId: frontmatter.feed_id,
|
|
335
|
+
period: frontmatter.period,
|
|
336
|
+
anomalies
|
|
337
|
+
};
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (err.code !== 'ENOENT') throw err;
|
|
340
|
+
// signals-feed.md not found — signals stays null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
site,
|
|
345
|
+
calendar,
|
|
346
|
+
briefs: { active: activeBriefs, archived: archivedBriefs },
|
|
347
|
+
signals
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── AGGREGATE: aggregateMetrics ────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
export function aggregateMetrics(rawData, viewType = 'kanban') {
|
|
354
|
+
const { site, calendar, briefs, signals } = rawData;
|
|
355
|
+
|
|
356
|
+
const entries = calendar?.entries || [];
|
|
357
|
+
const postsTarget = calendar?.goals?.posts_target || entries.length || 0;
|
|
358
|
+
|
|
359
|
+
// Column counts
|
|
360
|
+
const columns = { planned: 0, draft: 0, ready: 0, scheduled: 0, published: 0 };
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
if (columns[entry.status] !== undefined) {
|
|
363
|
+
columns[entry.status]++;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const postsPublished = columns.published;
|
|
368
|
+
const progressPercent = postsTarget > 0 ? Math.round((postsPublished / postsTarget) * 100) : 0;
|
|
369
|
+
|
|
370
|
+
// Next deadline: first non-published entry with a title, sorted by date
|
|
371
|
+
const upcoming = entries
|
|
372
|
+
.filter(e => e.status !== 'published' && e.title && e.date)
|
|
373
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
374
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
375
|
+
const nextDeadline = upcoming.length > 0 ? {
|
|
376
|
+
date: upcoming[0].date,
|
|
377
|
+
title: upcoming[0].title,
|
|
378
|
+
status: upcoming[0].status,
|
|
379
|
+
daysFromNow: Math.ceil((new Date(upcoming[0].date) - new Date(today)) / 86400000)
|
|
380
|
+
} : null;
|
|
381
|
+
|
|
382
|
+
// Channel usage
|
|
383
|
+
const channelUsage = {};
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
for (const ch of entry.channels) {
|
|
386
|
+
channelUsage[ch] = (channelUsage[ch] || 0) + 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Fill rate: entries with title / total entries
|
|
391
|
+
const withTitle = entries.filter(e => e.title !== null).length;
|
|
392
|
+
const fillRate = entries.length > 0 ? Math.round((withTitle / entries.length) * 100 * 10) / 10 : 0;
|
|
393
|
+
|
|
394
|
+
// Signals summary
|
|
395
|
+
const anomalies = signals?.anomalies || [];
|
|
396
|
+
const signalsCount = anomalies.length;
|
|
397
|
+
const signalsHighest = anomalies.length > 0
|
|
398
|
+
? anomalies.reduce((best, a) => {
|
|
399
|
+
const delta = parseFloat(a.delta) || 0;
|
|
400
|
+
const bestDelta = parseFloat(best.delta) || 0;
|
|
401
|
+
return Math.abs(delta) > Math.abs(bestDelta) ? a : best;
|
|
402
|
+
})
|
|
403
|
+
: null;
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
siteId: site.id,
|
|
407
|
+
siteUrl: site.url,
|
|
408
|
+
calendarId: calendar?.id || null,
|
|
409
|
+
calendarPeriod: calendar?.period || null,
|
|
410
|
+
postsPublished,
|
|
411
|
+
postsTarget,
|
|
412
|
+
progressPercent,
|
|
413
|
+
columns,
|
|
414
|
+
nextDeadline,
|
|
415
|
+
channelUsage,
|
|
416
|
+
fillRate,
|
|
417
|
+
signalsCount,
|
|
418
|
+
signalsHighest,
|
|
419
|
+
generatedAt: new Date().toISOString(),
|
|
420
|
+
generatorVersion: '1.0.0'
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── RENDER: Context Snippet (Fase B stub) ──────────────────────────
|
|
425
|
+
|
|
426
|
+
export function renderContextSnippet(metrics, sliceType = 'pipeline') {
|
|
427
|
+
const m = metrics;
|
|
428
|
+
const period = m.calendarPeriod ? m.calendarPeriod.replace('..', ' → ') : 'no calendar';
|
|
429
|
+
const lines = [
|
|
430
|
+
`── Editorial Context ──────────────────────`,
|
|
431
|
+
` ${m.siteId || '?'} | ${period}`,
|
|
432
|
+
` Pipeline: ${m.columns?.draft ?? 0} draft → ${m.columns?.ready ?? 0} ready → ${m.columns?.scheduled ?? 0} scheduled`,
|
|
433
|
+
` Posts: ${m.postsPublished ?? 0}/${m.postsTarget ?? '?'} pubblicati`,
|
|
434
|
+
`───────────────────────────────────────────`,
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
if (sliceType === 'calendar' && m.nextDeadline) {
|
|
438
|
+
lines.splice(3, 0, ` Next: ${m.nextDeadline.date} — "${m.nextDeadline.title?.substring(0, 40)}..."`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (sliceType === 'signals' && m.signalsCount > 0) {
|
|
442
|
+
lines.splice(3, 0, ` Signals: ${m.signalsCount} anomalie | Top: ${m.signalsHighest?.entity} ${m.signalsHighest?.delta}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return lines.join('\n');
|
|
446
|
+
}
|