create-pmos 0.197.0 → 0.198.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-report-design-real-test.js +225 -0
- package/workspace/c3-report-design-test.js +403 -0
- package/workspace/c3-report-setup-test.js +3 -2
- package/workspace/c3-work-test.js +1 -1
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/report-design.md +104 -0
- package/workspace/guide/report-slides.md +3 -1
- package/workspace/guide/reports-set-up.md +2 -1
- package/workspace/lib/headless.js +24 -6
- package/workspace/lib/report-design.js +741 -0
- package/workspace/lib/report-draft.js +8 -3
- package/workspace/lib/report-setups.js +1 -1
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-doc-sheet.js +8 -1
- package/workspace/lib/routes-report-design.js +481 -0
- package/workspace/lib/routes-reports.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-rd.js +426 -0
- package/workspace/public/pm/c3-demo-rep2.js +2 -0
- package/workspace/public/pm/c3-demo-rs.js +5 -1
- package/workspace/public/pm/c3-design-studio.js +308 -0
- package/workspace/public/pm/c3-doc-formats.js +3 -2
- package/workspace/public/pm/c3-doc-sheet.js +30 -5
- package/workspace/public/pm/c3-initiatives.js +2 -1
- package/workspace/public/pm/c3-report-deck.css +139 -0
- package/workspace/public/pm/c3-report-deck.js +558 -0
- package/workspace/public/pm/c3-report-frame.css +20 -0
- package/workspace/public/pm/c3-report-setup.js +18 -6
- package/workspace/server.js +17 -5
- package/workspace/skills/report-design.skill +61 -0
- package/workspace/test-lists/report-design-backend.json +5 -0
- package/workspace/test-lists/report-design-studio-ui.json +5 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-design.js — the report design studio's file store, fold, sanitizer and outline logic (initiative report-design-studio, PRD "Data").
|
|
3
|
+
*
|
|
4
|
+
* paths(root, cadence) → { folder, design, events, samples, versions, pending, brief, rel } <state>/reports/<cadence>/design/…
|
|
5
|
+
* append(root, cadence, line, { actor, now }) → the line written. THE SINGLE WRITER of design.jsonl. `at` and `actor` are set here.
|
|
6
|
+
* readEvents(root, cadence) · fold(events) → the current state (see FOLD)
|
|
7
|
+
* designAnswer(root, cadence, { running }) → the `design` object GET /api/report-design answers
|
|
8
|
+
* addSample(root, cadence, input, { actor }) → { ok, sample } | { ok: false, status, error } a file (20 MB, pdf/pptx/docx/png/jpg), a link or a report doc
|
|
9
|
+
* writeVersion(root, cadence, html, meta) → { ok, n, outline } sanitizes, writes versions/D<n>.html + D<n>.json
|
|
10
|
+
* writeBrief(root, cadence, setup, st) → brief.md (type Reference)
|
|
11
|
+
* sanitize(html) → the render with no script, no on* attribute, no javascript: and no external URL (data: images kept, one style block)
|
|
12
|
+
* outlineOf(html) → [{ id, kind, title }] the pmos-slide / pmos-sec sections, in order
|
|
13
|
+
* formatOf(html) → 'deck' | 'doc'
|
|
14
|
+
* diffOutline(reference, cycle) → [{ id, what, slide }] what differs from the design
|
|
15
|
+
* cyclePaths(root, rel) · readCycle · writeCycle <slot>.design.html / <slot>.design.json next to <slot>.md
|
|
16
|
+
* notesOf(root, rel) · setSpeaker · pinOp <slot>.notes.json: speaker notes, suggestions from the previous cycle, pins
|
|
17
|
+
* ooxmlText(buffer, kind) · ooxmlThumb(buffer) the words of a .pptx/.docx (its XML parts, read with node's zlib) and its embedded thumbnail
|
|
18
|
+
*
|
|
19
|
+
* THE LINES of design.jsonl. Every line is { ev, …, at, actor }. `actor` is the guard's actor for a press, or { by: 'pmos', via: 'job' } for a job:
|
|
20
|
+
* describe { text, format } the PM's plain words, and Deck or Document
|
|
21
|
+
* sample { id, name, kind, source, ext, readable } a sample was added (the file is samples/<id>.<ext>, its record samples/<id>.json)
|
|
22
|
+
* took { id, took, readable } what a job took from a sample (or that it could not read it)
|
|
23
|
+
* turn { text, points, base, job } a refine turn started a job; `base` is the version it builds on
|
|
24
|
+
* version { n, format, turn, answer, from } a job (or an adopted cycle) landed D<n>
|
|
25
|
+
* accept { n, rules } "This is it": D<n> is the design; brief.md is written
|
|
26
|
+
* revert { n } the next turn builds on D<n>
|
|
27
|
+
* error { text, mode, slot?, sha? } a job failed, timed out or wrote nothing usable
|
|
28
|
+
* cycle-decision { path, slot, diff, choice } keep | adopt | match on a cycle that differs
|
|
29
|
+
*
|
|
30
|
+
* FOLD. { description, format, samples: Map, log: [], versions: Map n → meta, current, saved, rules, errors: [] }. A torn line is skipped.
|
|
31
|
+
*
|
|
32
|
+
* THE SANITIZER parses the HTML with a small tokenizer (tags, attributes with their quoting, comments, raw-text elements) and rebuilds it from the
|
|
33
|
+
* tokens: nothing in the output is copied from a span the tokenizer did not understand, and text is re-escaped. It drops script, iframe, object, embed,
|
|
34
|
+
* frame, applet, noscript, template, base, link and meta with their content; every on* attribute; srcdoc, srcset, action and formaction; a
|
|
35
|
+
* javascript:/vbscript: value anywhere; and every URL in src/href/poster/background/data/xlink:href and CSS url() that is not a data: image (an href may
|
|
36
|
+
* also be a #fragment). CSS is de-commented and de-escaped before it is checked; @import and expression() go. Every <style> becomes ONE block.
|
|
37
|
+
*
|
|
38
|
+
* NOTHING HERE STARTS A JOB OR SENDS ANYTHING. lib/routes-report-design.js starts the report-design job and calls these writers.
|
|
39
|
+
*/
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const crypto = require('crypto');
|
|
43
|
+
const zlib = require('zlib');
|
|
44
|
+
const RD = require('./report-draft.js');
|
|
45
|
+
const issues = require('./issues.js');
|
|
46
|
+
|
|
47
|
+
const EVENTS = Object.freeze(['describe', 'sample', 'took', 'turn', 'version', 'accept', 'revert', 'error', 'cycle-decision']);
|
|
48
|
+
const FORMATS = Object.freeze(['deck', 'doc']);
|
|
49
|
+
const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'link', 'pmos-doc']);
|
|
50
|
+
const MAX_SAMPLE_BYTES = 20 * 1024 * 1024;
|
|
51
|
+
const MAX_THUMB_BYTES = 256 * 1024;
|
|
52
|
+
const MAX_TEXT = 4000;
|
|
53
|
+
const MAX_POINTS = 20;
|
|
54
|
+
const CHOICES = Object.freeze(['keep', 'adopt', 'match']);
|
|
55
|
+
const PIN_OPS = Object.freeze(['add', 'edit', 'remove', 'keep', 'dismiss']);
|
|
56
|
+
const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
57
|
+
const SAMPLE_ID_RE = /^s-[0-9a-f]{8}$/;
|
|
58
|
+
const SLIDE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/;
|
|
59
|
+
const SLOT_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
60
|
+
const PMOS_ACTOR = Object.freeze({ by: 'pmos', via: 'job' });
|
|
61
|
+
|
|
62
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
63
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
64
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
65
|
+
const iso = (now) => new Date(now == null ? Date.now() : now).toISOString();
|
|
66
|
+
const newId = (prefix, bytes = 4) => `${prefix}-${crypto.randomBytes(bytes).toString('hex')}`;
|
|
67
|
+
const sha1 = (buf) => crypto.createHash('sha1').update(buf).digest('hex');
|
|
68
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\r\n?/g, '\n').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
69
|
+
const oneLine = (s, n) => clip(String(s == null ? '' : s).replace(/\s+/g, ' '), n);
|
|
70
|
+
|
|
71
|
+
function writeAtomic(file, data) {
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
const tmp = `${file}.tmp-${crypto.randomBytes(4).toString('hex')}`;
|
|
74
|
+
fs.writeFileSync(tmp, data);
|
|
75
|
+
fs.renameSync(tmp, file);
|
|
76
|
+
}
|
|
77
|
+
function readJson(file) { try { const o = JSON.parse(fs.readFileSync(file, 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
|
|
78
|
+
|
|
79
|
+
// ── paths ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
function stateRel(root) { try { return RD.stateRel(root); } catch { return 'planning'; } }
|
|
81
|
+
function paths(root, cadence) {
|
|
82
|
+
if (!CADENCE_RE.test(String(cadence || ''))) throw new Error('report-design: not a report id: ' + JSON.stringify(cadence));
|
|
83
|
+
const abs = path.resolve(String(root));
|
|
84
|
+
const rel = `${stateRel(abs)}/reports/${cadence}`;
|
|
85
|
+
const folder = path.join(abs, rel);
|
|
86
|
+
const design = path.join(folder, 'design');
|
|
87
|
+
return { abs, rel, folder, design, events: path.join(design, 'design.jsonl'), samples: path.join(design, 'samples'), versions: path.join(design, 'versions'),
|
|
88
|
+
pending: path.join(design, 'pending'), brief: path.join(design, 'brief.md'), briefRel: `${rel}/design/brief.md` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── the one writer and the fold ───────────────────────────────────────────────────────────────────────────
|
|
92
|
+
function append(root, cadence, line, { actor, now } = {}) {
|
|
93
|
+
if (!isObj(line) || !EVENTS.includes(line.ev)) throw new Error(`report-design: unknown event ${JSON.stringify(line && line.ev)}`);
|
|
94
|
+
const p = paths(root, cadence);
|
|
95
|
+
const out = { ...line, at: iso(now), actor: isObj(actor) ? { by: str(actor.by), via: str(actor.via) } : { ...PMOS_ACTOR } };
|
|
96
|
+
fs.mkdirSync(p.design, { recursive: true });
|
|
97
|
+
fs.appendFileSync(p.events, JSON.stringify(out) + '\n');
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function readEvents(root, cadence) {
|
|
101
|
+
let text = '';
|
|
102
|
+
try { text = fs.readFileSync(paths(root, cadence).events, 'utf8'); } catch { return []; }
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const raw of text.split('\n')) {
|
|
105
|
+
if (!raw.trim()) continue;
|
|
106
|
+
try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev)) out.push(l); } catch { /* a torn line is skipped */ }
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
const whoOf = (l) => (l.actor && l.actor.by === 'pmos' ? 'pmos' : 'you');
|
|
111
|
+
|
|
112
|
+
function fold(events) {
|
|
113
|
+
const st = { description: null, format: null, samples: new Map(), log: [], versions: new Map(), current: null, saved: null, rules: null, errors: [], decisions: [], turns: 0 };
|
|
114
|
+
for (const l of events) {
|
|
115
|
+
if (l.ev === 'describe') {
|
|
116
|
+
st.description = str(l.text) || st.description;
|
|
117
|
+
if (FORMATS.includes(l.format)) st.format = l.format;
|
|
118
|
+
st.log.push({ at: l.at, who: 'you', kind: 'describe', text: str(l.text) });
|
|
119
|
+
} else if (l.ev === 'sample') {
|
|
120
|
+
st.samples.set(l.id, { id: l.id, name: str(l.name), kind: l.kind, source: str(l.source), ext: str(l.ext), took: null, readable: l.readable !== false, at: l.at });
|
|
121
|
+
st.log.push({ at: l.at, who: 'you', kind: 'sample', text: str(l.name), sample: l.id });
|
|
122
|
+
} else if (l.ev === 'took') {
|
|
123
|
+
const s = st.samples.get(l.id);
|
|
124
|
+
if (s) { if (typeof l.took === 'string' && l.took) s.took = l.took; if (typeof l.readable === 'boolean') s.readable = l.readable; }
|
|
125
|
+
} else if (l.ev === 'turn') {
|
|
126
|
+
st.turns++;
|
|
127
|
+
st.log.push({ at: l.at, who: 'you', kind: 'turn', text: str(l.text), ...(Array.isArray(l.points) && l.points.length ? { points: l.points } : {}) });
|
|
128
|
+
} else if (l.ev === 'version') {
|
|
129
|
+
st.versions.set(l.n, { n: l.n, format: l.format, made_at: l.at, turn: str(l.turn), from: l.from || 'turn' });
|
|
130
|
+
st.current = l.n;
|
|
131
|
+
if (FORMATS.includes(l.format) && !st.format) st.format = l.format;
|
|
132
|
+
st.log.push({ at: l.at, who: 'pmos', kind: 'version', text: str(l.answer) || `Made D${l.n}.`, version: l.n });
|
|
133
|
+
} else if (l.ev === 'accept') {
|
|
134
|
+
st.saved = { version: l.n, at: l.at };
|
|
135
|
+
if (isObj(l.rules)) st.rules = l.rules;
|
|
136
|
+
st.log.push({ at: l.at, who: whoOf(l), kind: 'accept', text: `Saved D${l.n} as the design.`, version: l.n });
|
|
137
|
+
} else if (l.ev === 'revert') {
|
|
138
|
+
if (st.versions.has(l.n)) st.current = l.n;
|
|
139
|
+
st.log.push({ at: l.at, who: 'you', kind: 'revert', text: `Back to D${l.n}. The next turn builds on it.`, version: l.n });
|
|
140
|
+
} else if (l.ev === 'error') {
|
|
141
|
+
st.errors.push({ at: l.at, text: str(l.text), mode: l.mode || null, slot: l.slot || null, sha: l.sha || null });
|
|
142
|
+
st.log.push({ at: l.at, who: 'pmos', kind: 'error', text: str(l.text) });
|
|
143
|
+
} else if (l.ev === 'cycle-decision') {
|
|
144
|
+
st.decisions.push({ path: l.path, slot: l.slot, diff: l.diff, choice: l.choice, at: l.at });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return st;
|
|
148
|
+
}
|
|
149
|
+
const stateOf = (root, cadence) => fold(readEvents(root, cadence));
|
|
150
|
+
|
|
151
|
+
// ── versions ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
function versionMeta(root, cadence, n) { return readJson(path.join(paths(root, cadence).versions, `D${n}.json`)); }
|
|
153
|
+
function versionHtml(root, cadence, n) { try { return fs.readFileSync(path.join(paths(root, cadence).versions, `D${n}.html`), 'utf8'); } catch { return null; } }
|
|
154
|
+
const nextN = (st) => (st.versions.size ? Math.max(...st.versions.keys()) : 0) + 1;
|
|
155
|
+
|
|
156
|
+
/** Sanitize, then write D<n>.html and D<n>.json and append the version line. → { ok, n, outline } | refusal */
|
|
157
|
+
function writeVersion(root, cadence, html, { turn, answer, rules, from, actor, now } = {}) {
|
|
158
|
+
const clean = sanitize(html);
|
|
159
|
+
const outline = outlineOf(clean);
|
|
160
|
+
if (!outline.length) return refuse(422, 'the render has no pmos-slide or pmos-sec section');
|
|
161
|
+
const p = paths(root, cadence);
|
|
162
|
+
const st = stateOf(root, cadence);
|
|
163
|
+
const n = nextN(st);
|
|
164
|
+
const format = formatOf(clean);
|
|
165
|
+
const madeAt = iso(now);
|
|
166
|
+
const meta = { n, format, made_at: madeAt, turn: clip(turn, MAX_TEXT), answer: clip(answer, MAX_TEXT), from: from || 'turn', outline, rules: cleanRules(rules) };
|
|
167
|
+
writeAtomic(path.join(p.versions, `D${n}.html`), clean);
|
|
168
|
+
writeAtomic(path.join(p.versions, `D${n}.json`), JSON.stringify(meta, null, 2) + '\n');
|
|
169
|
+
append(root, cadence, { ev: 'version', n, format, turn: meta.turn, answer: meta.answer, from: meta.from }, { actor, now });
|
|
170
|
+
return { ok: true, n, outline, format };
|
|
171
|
+
}
|
|
172
|
+
function cleanRules(r) {
|
|
173
|
+
if (!isObj(r)) return null;
|
|
174
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()).slice(0, 12).map((x) => oneLine(x, 300)) : []);
|
|
175
|
+
const out = { same: list(r.same), changes: list(r.changes), learned: list(r.learned) };
|
|
176
|
+
return out.same.length || out.changes.length || out.learned.length ? out : null;
|
|
177
|
+
}
|
|
178
|
+
/** The rules of D<n>, else of the newest earlier version that has rules. */
|
|
179
|
+
function rulesFor(root, cadence, n) {
|
|
180
|
+
for (let k = n; k >= 1; k--) { const m = versionMeta(root, cadence, k); if (m && m.rules) return m.rules; }
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── samples ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
function sniff(buf) {
|
|
186
|
+
if (buf.length >= 4 && buf.toString('latin1', 0, 4) === '%PDF') return { kind: 'pdf', ext: 'pdf' };
|
|
187
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') return { kind: 'image', ext: 'png' };
|
|
188
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { kind: 'image', ext: 'jpg' };
|
|
189
|
+
if (buf.length >= 4 && buf.readUInt32LE(0) === 0x04034b50) return { kind: 'zip', ext: 'zip' };
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
function sampleRecord(root, cadence, id) { return readJson(path.join(paths(root, cadence).samples, `${id}.json`)); }
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* input: { name, kind, data: <base64> } | { link } | { doc: <repo path of a report document> }. → { ok, sample } | refusal.
|
|
196
|
+
* A .pptx or .docx also gets samples/<id>.txt (its words), which is what the job reads.
|
|
197
|
+
*/
|
|
198
|
+
function addSample(root, cadence, input, { actor, now } = {}) {
|
|
199
|
+
const p = paths(root, cadence);
|
|
200
|
+
const id = newId('s');
|
|
201
|
+
let rec;
|
|
202
|
+
if (typeof input.link === 'string' && input.link.trim()) {
|
|
203
|
+
const link = input.link.trim();
|
|
204
|
+
let u; try { u = new URL(link); } catch { return refuse(400, 'link must be a full address, such as https://…'); }
|
|
205
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return refuse(400, 'link must be an http or https address');
|
|
206
|
+
if (link.length > 2000) return refuse(400, 'link is too long');
|
|
207
|
+
rec = { id, name: oneLine(input.name || u.hostname + u.pathname, 120), kind: 'link', source: link, ext: '', readable: true, thumb: null, text_file: null };
|
|
208
|
+
} else if (typeof input.doc === 'string' && input.doc.trim()) {
|
|
209
|
+
const rel = input.doc.trim().replace(/\\/g, '/');
|
|
210
|
+
if (!/(?:^|\/)reports\/(?!definitions\/)[a-z][a-z0-9-]{0,39}\/\d{4}-\d{2}-\d{2}\.md$/.test(rel)) return refuse(400, 'doc must be a report document: <state>/reports/<cadence>/<slot>.md');
|
|
211
|
+
const full = path.resolve(p.abs, rel);
|
|
212
|
+
if (!full.startsWith(p.abs + path.sep)) return refuse(404, 'outside workspace');
|
|
213
|
+
let text; try { const real = fs.realpathSync(full); if (!real.startsWith(fs.realpathSync(p.abs) + path.sep)) return refuse(404, 'outside workspace'); text = fs.readFileSync(real, 'utf8'); } catch { return refuse(404, 'no such report document'); }
|
|
214
|
+
fs.mkdirSync(p.samples, { recursive: true });
|
|
215
|
+
writeAtomic(path.join(p.samples, `${id}.md`), text);
|
|
216
|
+
const fm = /^---\r?\n[\s\S]*?\btitle:\s*["']?([^\n"']+)/.exec(text);
|
|
217
|
+
rec = { id, name: oneLine(input.name || (fm ? fm[1] : rel.split('/').slice(-2).join(' ')), 120), kind: 'pmos-doc', source: rel, ext: 'md', readable: true, thumb: null, text_file: null };
|
|
218
|
+
} else {
|
|
219
|
+
const kind = str(input.kind);
|
|
220
|
+
if (!['pdf', 'pptx', 'docx', 'image'].includes(kind)) return refuse(400, 'kind must be pdf, pptx, docx or image (or send a link, or a report doc)');
|
|
221
|
+
if (typeof input.data !== 'string' || !input.data) return refuse(400, 'data (the file, base64) is required');
|
|
222
|
+
const b64 = input.data.replace(/^data:[^,]*,/, '');
|
|
223
|
+
if (Math.floor(b64.length * 3 / 4) > MAX_SAMPLE_BYTES + 4) return refuse(413, 'a sample is at most 20 MB');
|
|
224
|
+
const buf = Buffer.from(b64, 'base64');
|
|
225
|
+
if (!buf.length) return refuse(400, 'data is empty or not base64');
|
|
226
|
+
if (buf.length > MAX_SAMPLE_BYTES) return refuse(413, 'a sample is at most 20 MB');
|
|
227
|
+
const s = sniff(buf);
|
|
228
|
+
const want = kind === 'image' ? 'image' : kind === 'pdf' ? 'pdf' : 'zip';
|
|
229
|
+
if (!s || s.kind !== want) return refuse(415, kind === 'image' ? 'an image sample must be a PNG or a JPG' : `the file is not a ${kind === 'pdf' ? 'PDF' : '.' + kind}`);
|
|
230
|
+
const ext = kind === 'image' ? s.ext : kind;
|
|
231
|
+
fs.mkdirSync(p.samples, { recursive: true });
|
|
232
|
+
writeAtomic(path.join(p.samples, `${id}.${ext}`), buf);
|
|
233
|
+
let thumb = null; let textFile = null; let readable = true;
|
|
234
|
+
if (kind === 'image' && buf.length <= MAX_THUMB_BYTES) thumb = `data:image/${ext === 'jpg' ? 'jpeg' : 'png'};base64,${buf.toString('base64')}`;
|
|
235
|
+
if (kind === 'pptx' || kind === 'docx') {
|
|
236
|
+
let words = '';
|
|
237
|
+
try { words = ooxmlText(buf, kind); } catch { words = ''; }
|
|
238
|
+
if (words.trim()) { writeAtomic(path.join(p.samples, `${id}.txt`), words); textFile = `${id}.txt`; } else readable = false;
|
|
239
|
+
try { thumb = ooxmlThumb(buf); } catch { thumb = null; }
|
|
240
|
+
}
|
|
241
|
+
rec = { id, name: oneLine(input.name || `${kind} sample`, 120), kind, source: 'upload', ext, bytes: buf.length, readable, thumb, text_file: textFile };
|
|
242
|
+
}
|
|
243
|
+
writeAtomic(path.join(p.samples, `${id}.json`), JSON.stringify({ ...rec, took: null, added_at: iso(now) }, null, 2) + '\n');
|
|
244
|
+
append(root, cadence, { ev: 'sample', id, name: rec.name, kind: rec.kind, source: rec.source, ext: rec.ext, readable: rec.readable }, { actor, now });
|
|
245
|
+
return { ok: true, sample: sampleOut(rec) };
|
|
246
|
+
}
|
|
247
|
+
function sampleOut(s) {
|
|
248
|
+
return { id: s.id, name: s.name, kind: s.kind, source: s.source, took: s.took || null, thumb: typeof s.thumb === 'string' && s.thumb.startsWith('data:image/') ? s.thumb : null, readable: s.readable !== false };
|
|
249
|
+
}
|
|
250
|
+
/** Record what a job took from a sample (also kept in samples/<id>.json). */
|
|
251
|
+
function recordTook(root, cadence, id, took, readable, { now } = {}) {
|
|
252
|
+
if (!SAMPLE_ID_RE.test(String(id))) return false;
|
|
253
|
+
const p = paths(root, cadence);
|
|
254
|
+
const rec = sampleRecord(root, cadence, id);
|
|
255
|
+
if (!rec) return false;
|
|
256
|
+
const t = typeof took === 'string' ? oneLine(took, 400) : null;
|
|
257
|
+
const rd = typeof readable === 'boolean' ? readable : undefined;
|
|
258
|
+
writeAtomic(path.join(p.samples, `${id}.json`), JSON.stringify({ ...rec, took: t || rec.took || null, ...(rd === undefined ? {} : { readable: rd }) }, null, 2) + '\n');
|
|
259
|
+
append(root, cadence, { ev: 'took', id, took: t, ...(rd === undefined ? {} : { readable: rd }) }, { now });
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
/** The file the job reads for a sample: the pdf or image itself, the extracted words of a .pptx/.docx, the copy of a report doc; null for a link. */
|
|
263
|
+
function sampleReadPath(root, cadence, s) {
|
|
264
|
+
const p = paths(root, cadence);
|
|
265
|
+
if (s.kind === 'link') return null;
|
|
266
|
+
if (s.kind === 'pptx' || s.kind === 'docx') { const f = path.join(p.samples, `${s.id}.txt`); return fs.existsSync(f) ? f : null; }
|
|
267
|
+
const f = path.join(p.samples, `${s.id}.${s.ext}`);
|
|
268
|
+
return fs.existsSync(f) ? f : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── the GET answer ────────────────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
function designAnswer(root, cadence, { running } = {}) {
|
|
273
|
+
const st = stateOf(root, cadence);
|
|
274
|
+
const samples = [...st.samples.values()].map((s) => {
|
|
275
|
+
const rec = sampleRecord(root, cadence, s.id) || {};
|
|
276
|
+
return sampleOut({ ...s, thumb: rec.thumb || null, took: s.took || rec.took || null });
|
|
277
|
+
});
|
|
278
|
+
const versions = [...st.versions.values()].sort((a, b) => a.n - b.n).map((v) => {
|
|
279
|
+
const m = versionMeta(root, cadence, v.n) || {};
|
|
280
|
+
return { n: v.n, format: m.format || v.format || 'deck', made_at: m.made_at || v.made_at, turn: m.turn != null ? m.turn : v.turn, outline: Array.isArray(m.outline) ? m.outline : [] };
|
|
281
|
+
});
|
|
282
|
+
const state = st.saved ? 'saved' : (st.description || st.samples.size || st.versions.size || st.turns) ? 'draft' : 'none';
|
|
283
|
+
const cur = st.current != null ? versions.find((v) => v.n === st.current) : null;
|
|
284
|
+
return {
|
|
285
|
+
state, format: (cur && cur.format) || st.format || 'deck',
|
|
286
|
+
description: st.description, samples, log: st.log, versions,
|
|
287
|
+
current: st.current, saved: st.saved, rules: st.rules || (st.saved ? rulesFor(root, cadence, st.saved.version) : null),
|
|
288
|
+
running: running || null,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── brief.md ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
293
|
+
const yq = (s) => JSON.stringify(String(s == null ? '' : s));
|
|
294
|
+
function writeBrief(root, cadence, setup, { now } = {}) {
|
|
295
|
+
const st = stateOf(root, cadence);
|
|
296
|
+
if (!st.saved) return refuse(409, 'no design is saved yet');
|
|
297
|
+
const n = st.saved.version;
|
|
298
|
+
const meta = versionMeta(root, cadence, n) || {};
|
|
299
|
+
const rules = st.rules || rulesFor(root, cadence, n) || { same: [], changes: [], learned: [] };
|
|
300
|
+
const name = (setup && setup.name) || cadence;
|
|
301
|
+
const day = iso(now).slice(0, 10);
|
|
302
|
+
const lines = ['---', 'type: Reference', `title: ${yq('Report design: ' + name)}`, `timestamp: ${day}`, '---', '', `# Report design: ${name}`, '',
|
|
303
|
+
`The saved design of the ${name} report. PMOS renders each cycle to match it: the same order, look and slide kinds, with that cycle's data. It is a brief, not a template.`, '',
|
|
304
|
+
`- Format: ${meta.format === 'doc' ? 'document' : 'slide deck'}`, `- Reference version: D${n} (versions/D${n}.html), saved ${st.saved.at.slice(0, 10)}`, '',
|
|
305
|
+
'## Description', '', st.description ? st.description : 'No description was given.', '', '## Samples', ''];
|
|
306
|
+
const samples = [...st.samples.values()];
|
|
307
|
+
if (!samples.length) lines.push('No samples.');
|
|
308
|
+
for (const s of samples) lines.push(`- ${s.name} (${s.kind}${s.kind === 'link' || s.kind === 'pmos-doc' ? ', ' + s.source : ''}): ${s.took ? 'PMOS took ' + s.took : (s.readable ? 'nothing taken yet' : 'could not be read')}`);
|
|
309
|
+
lines.push('', '## Reference outline', '');
|
|
310
|
+
for (const o of (Array.isArray(meta.outline) ? meta.outline : [])) lines.push(`- ${o.id} (${o.kind || 'slide'}): ${o.title || ''}`);
|
|
311
|
+
const sec = (h, list) => { lines.push('', `## ${h}`, ''); if (!list || !list.length) lines.push('None written yet.'); else for (const x of list) lines.push(`- ${x}`); };
|
|
312
|
+
sec('Keeps the same each cycle', rules.same);
|
|
313
|
+
sec('Changes each cycle', rules.changes);
|
|
314
|
+
sec('Learned from your turns', rules.learned);
|
|
315
|
+
lines.push('');
|
|
316
|
+
writeAtomic(paths(root, cadence).brief, lines.join('\n'));
|
|
317
|
+
return { ok: true, path: paths(root, cadence).briefRel };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── the sanitizer ─────────────────────────────────────────────────────────────────────────────────────────
|
|
321
|
+
const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext']);
|
|
322
|
+
const DROP_ALL = new Set(['script', 'iframe', 'object', 'embed', 'frame', 'frameset', 'applet', 'noscript', 'noembed', 'noframes', 'template', 'base', 'link', 'meta', 'portal', 'param', 'xmp', 'plaintext']);
|
|
323
|
+
const UNWRAP = new Set(['form', 'html', 'head', 'body']);
|
|
324
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', 'frame', 'keygen']);
|
|
325
|
+
const URL_ATTRS = new Set(['src', 'href', 'xlink:href', 'poster', 'background', 'data', 'cite', 'longdesc', 'codebase', 'archive', 'usemap', 'icon', 'profile', 'lowsrc', 'dynsrc', 'action', 'formaction']);
|
|
326
|
+
const DROP_ATTRS = new Set(['srcdoc', 'srcset', 'imagesrcset', 'action', 'formaction', 'ping', 'manifest', 'http-equiv', 'autofocus', 'codebase', 'archive', 'classid']);
|
|
327
|
+
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", colon: ':', tab: '\t', newline: '\n', nbsp: String.fromCharCode(0xa0), sol: '/', lpar: '(', rpar: ')', period: '.', comma: ',' };
|
|
328
|
+
|
|
329
|
+
function decodeEntities(s) {
|
|
330
|
+
return String(s).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (m, e) => {
|
|
331
|
+
if (e[0] === '#') { const cp = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : ''; }
|
|
332
|
+
const v = NAMED[e.toLowerCase()];
|
|
333
|
+
return v != null ? v : m;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const escText = (s) => String(s).replace(/&(?!(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);)/gi, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
337
|
+
const escAttr = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
338
|
+
/** A URL as a browser reads it: entities decoded, control characters and whitespace removed, lower-case. */
|
|
339
|
+
const invisible = (c) => c <= 0x20 || (c >= 0x7f && c <= 0x9f) || (c >= 0x200b && c <= 0x200f) || c === 0x2028 || c === 0x2029 || c === 0xfeff;
|
|
340
|
+
const normUrl = (v) => { let o = ""; for (const ch of decodeEntities(v)) if (!invisible(ch.codePointAt(0))) o += ch; return o.toLowerCase(); };
|
|
341
|
+
const DATA_IMAGE_RE = /^data:image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml)[;,]/;
|
|
342
|
+
const isDataImage = (u) => DATA_IMAGE_RE.test(normUrl(u));
|
|
343
|
+
|
|
344
|
+
/** Tokens: { t: 'text', v } | { t: 'start', name, attrs: [[name, value|null]], self } | { t: 'end', name } | { t: 'raw', name, v } (after a raw start). */
|
|
345
|
+
function tokenize(html) {
|
|
346
|
+
const s = String(html == null ? '' : html);
|
|
347
|
+
const n = s.length;
|
|
348
|
+
const out = [];
|
|
349
|
+
let i = 0;
|
|
350
|
+
const START = /<([a-zA-Z][a-zA-Z0-9:-]*)(?=[\s/>]|$)/y;
|
|
351
|
+
const END = /<\/([a-zA-Z][a-zA-Z0-9:-]*)[^>]*>/y;
|
|
352
|
+
const ws = (c) => c === ' ' || c === '\n' || c === '\t' || c === '\r' || c === '\f';
|
|
353
|
+
while (i < n) {
|
|
354
|
+
const lt = s.indexOf('<', i);
|
|
355
|
+
if (lt < 0) { out.push({ t: 'text', v: s.slice(i) }); break; }
|
|
356
|
+
if (lt > i) out.push({ t: 'text', v: s.slice(i, lt) });
|
|
357
|
+
i = lt;
|
|
358
|
+
if (s.startsWith('<!--', i)) { const e = s.indexOf('-->', i + 4); i = e < 0 ? n : e + 3; continue; }
|
|
359
|
+
if (s[i + 1] === '!' || s[i + 1] === '?') { const e = s.indexOf('>', i); i = e < 0 ? n : e + 1; continue; }
|
|
360
|
+
if (s[i + 1] === '/') {
|
|
361
|
+
END.lastIndex = i;
|
|
362
|
+
const m = END.exec(s);
|
|
363
|
+
if (m) { out.push({ t: 'end', name: m[1].toLowerCase(), raw: m[1] }); i = END.lastIndex; continue; }
|
|
364
|
+
const e = s.indexOf('>', i); // `</ junk>` is a bogus comment to a browser
|
|
365
|
+
if (s[i + 2] && !/[a-zA-Z]/.test(s[i + 2]) && e >= 0) { i = e + 1; continue; }
|
|
366
|
+
out.push({ t: 'text', v: '<' }); i++; continue;
|
|
367
|
+
}
|
|
368
|
+
START.lastIndex = i;
|
|
369
|
+
const m = START.exec(s);
|
|
370
|
+
if (!m) { out.push({ t: 'text', v: '<' }); i++; continue; }
|
|
371
|
+
let j = START.lastIndex;
|
|
372
|
+
const attrs = [];
|
|
373
|
+
let self = false; let closed = false;
|
|
374
|
+
while (j < n) {
|
|
375
|
+
while (j < n && (ws(s[j]) || s[j] === '/')) { if (s[j] === '/' && s[j + 1] === '>') self = true; j++; }
|
|
376
|
+
if (j >= n) break;
|
|
377
|
+
if (s[j] === '>') { j++; closed = true; break; }
|
|
378
|
+
let k = j;
|
|
379
|
+
while (k < n && !ws(s[k]) && s[k] !== '/' && s[k] !== '>' && (k === j || s[k] !== '=')) k++;
|
|
380
|
+
const an = s.slice(j, k);
|
|
381
|
+
j = k;
|
|
382
|
+
while (j < n && ws(s[j])) j++;
|
|
383
|
+
let val = null;
|
|
384
|
+
if (s[j] === '=') {
|
|
385
|
+
j++;
|
|
386
|
+
while (j < n && ws(s[j])) j++;
|
|
387
|
+
if (s[j] === '"' || s[j] === "'") { const q = s[j]; const e = s.indexOf(q, j + 1); val = s.slice(j + 1, e < 0 ? n : e); j = e < 0 ? n : e + 1; }
|
|
388
|
+
else { let k2 = j; while (k2 < n && !ws(s[k2]) && s[k2] !== '>') k2++; val = s.slice(j, k2); j = k2; }
|
|
389
|
+
}
|
|
390
|
+
attrs.push([an, val]);
|
|
391
|
+
}
|
|
392
|
+
if (!closed) break; // an unterminated tag at the end is dropped, with what follows it
|
|
393
|
+
const name = m[1].toLowerCase();
|
|
394
|
+
out.push({ t: 'start', name, raw: m[1], attrs, self });
|
|
395
|
+
i = j;
|
|
396
|
+
if (RAW_TEXT.has(name) && !self) {
|
|
397
|
+
const close = new RegExp(`</${name}(?=[\\s/>])`, 'ig');
|
|
398
|
+
close.lastIndex = i;
|
|
399
|
+
const c = close.exec(s);
|
|
400
|
+
const end = c ? c.index : n;
|
|
401
|
+
out.push({ t: 'raw', name, v: s.slice(i, end) });
|
|
402
|
+
if (c) { const gt = s.indexOf('>', end); out.push({ t: 'end', name, raw: name }); i = gt < 0 ? n : gt + 1; } else i = n;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** CSS with no external fetch, no script and no way out of its block. */
|
|
409
|
+
function cleanCss(css) {
|
|
410
|
+
let c = String(css == null ? '' : css);
|
|
411
|
+
c = c.replace(/\/\*[\s\S]*?(\*\/|$)/g, ' ');
|
|
412
|
+
c = c.replace(/\\([0-9a-fA-F]{1,6})[ \t\r\n\f]?/g, (m, h) => { const cp = parseInt(h, 16); return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : ''; }).replace(/\\(.)/g, '$1').replace(/\\$/g, '');
|
|
413
|
+
c = c.replace(/@import[^;{}]*;?/gi, '').replace(/@namespace[^;{}]*;?/gi, '');
|
|
414
|
+
c = c.replace(/expression\s*\(/gi, 'none(').replace(/behaviou?r\s*:/gi, 'x-behavior:').replace(/-moz-binding/gi, 'x-binding').replace(/javascript\s*:/gi, '').replace(/vbscript\s*:/gi, '');
|
|
415
|
+
c = c.replace(/url\s*\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi, (m, a, b, d) => { const u = a != null ? a : b != null ? b : d || ''; return isDataImage(u) ? `url("${u.trim().replace(/"/g, '%22')}")` : 'none'; });
|
|
416
|
+
c = c.replace(/(["'])\s*(?:https?:|\/\/|ftp:)[^"']*\1/gi, '""');
|
|
417
|
+
c = c.replace(/\b(?:src|image|image-set|-webkit-image-set|cross-fade|element)\s*\(\s*["']?\s*(?:https?:|\/\/)[^)]*\)/gi, 'none');
|
|
418
|
+
return c.replace(/</g, '\\3c ').replace(/>/g, '\\3e ');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function cleanAttrs(tag, attrs) {
|
|
422
|
+
const out = [];
|
|
423
|
+
const seen = new Set();
|
|
424
|
+
for (const [rawName, rawVal] of attrs) {
|
|
425
|
+
const name = String(rawName).toLowerCase();
|
|
426
|
+
if (!/^[a-z_:][-a-z0-9_:.]*$/.test(name) || seen.has(name)) continue;
|
|
427
|
+
if (name.startsWith('on') || DROP_ATTRS.has(name)) continue;
|
|
428
|
+
const val = rawVal == null ? '' : decodeEntities(rawVal);
|
|
429
|
+
const norm = normUrl(val);
|
|
430
|
+
if (/(?:^|[^a-z])(?:javascript|vbscript|livescript):/.test(norm) || norm.startsWith('javascript:')) continue;
|
|
431
|
+
let keep = val;
|
|
432
|
+
if (name === 'style') { keep = cleanCss(val); }
|
|
433
|
+
else if (URL_ATTRS.has(name)) {
|
|
434
|
+
if (name === 'href' || name === 'xlink:href' || name === 'usemap') { if (!(norm.startsWith('#') || DATA_IMAGE_RE.test(norm))) continue; }
|
|
435
|
+
else if (!DATA_IMAGE_RE.test(norm)) continue;
|
|
436
|
+
} else if (!name.startsWith('data-') && /^(?:https?:|\/\/)/.test(norm) && ['to', 'from', 'values', 'by', 'filter', 'mask', 'clip-path', 'fill', 'stroke', 'marker-start', 'marker-mid', 'marker-end', 'cursor'].includes(name)) continue;
|
|
437
|
+
else if (!name.startsWith('data-') && /url\s*\(/i.test(val)) keep = cleanCss(val);
|
|
438
|
+
seen.add(name);
|
|
439
|
+
out.push(rawVal == null ? ` ${rawName}` : ` ${rawName}="${escAttr(keep)}"`);
|
|
440
|
+
}
|
|
441
|
+
return out.join('');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function sanitize(html) {
|
|
445
|
+
const toks = tokenize(html);
|
|
446
|
+
const out = [];
|
|
447
|
+
const css = [];
|
|
448
|
+
let styleAt = -1;
|
|
449
|
+
let skip = null; // { name, depth }: inside a dropped element
|
|
450
|
+
for (let k = 0; k < toks.length; k++) {
|
|
451
|
+
const t = toks[k];
|
|
452
|
+
if (skip) {
|
|
453
|
+
if (t.t === 'start' && t.name === skip.name && !t.self && !VOID.has(t.name)) skip.depth++;
|
|
454
|
+
else if (t.t === 'end' && t.name === skip.name) { skip.depth--; if (skip.depth === 0) skip = null; }
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (t.t === 'text') { out.push(escText(t.v)); continue; }
|
|
458
|
+
if (t.t === 'raw') {
|
|
459
|
+
if (t.name === 'style') css.push(t.v);
|
|
460
|
+
else if (t.name === 'textarea' || t.name === 'title') out.push(escText(decodeEntities(t.v)));
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (t.t === 'start') {
|
|
464
|
+
if (DROP_ALL.has(t.name)) {
|
|
465
|
+
if (!t.self && !VOID.has(t.name) && !RAW_TEXT.has(t.name)) skip = { name: t.name, depth: 1 };
|
|
466
|
+
else if (RAW_TEXT.has(t.name) && toks[k + 1] && toks[k + 1].t === 'raw') { k++; if (toks[k + 1] && toks[k + 1].t === 'end' && toks[k + 1].name === t.name) k++; }
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (t.name === 'style') { if (styleAt < 0) { styleAt = out.length; out.push(''); } continue; }
|
|
470
|
+
if (UNWRAP.has(t.name)) continue;
|
|
471
|
+
out.push(`<${t.raw}${cleanAttrs(t.name, t.attrs)}${t.self ? ' /' : ''}>`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (t.t === 'end') {
|
|
475
|
+
if (DROP_ALL.has(t.name) || UNWRAP.has(t.name) || t.name === 'style' || VOID.has(t.name)) continue;
|
|
476
|
+
out.push(`</${t.raw}>`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const block = css.length ? `<style>${cleanCss(css.join('\n'))}</style>` : '';
|
|
480
|
+
if (styleAt >= 0) out[styleAt] = block;
|
|
481
|
+
else if (block) out.unshift(block);
|
|
482
|
+
return out.join('');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── the outline and its diff ──────────────────────────────────────────────────────────────────────────────
|
|
486
|
+
function outlineOf(html) {
|
|
487
|
+
const out = [];
|
|
488
|
+
const seen = new Set();
|
|
489
|
+
for (const t of tokenize(html)) {
|
|
490
|
+
if (t.t !== 'start' || t.name !== 'section') continue;
|
|
491
|
+
const a = {};
|
|
492
|
+
for (const [n, v] of t.attrs) { const k = String(n).toLowerCase(); if (!(k in a)) a[k] = v == null ? '' : decodeEntities(v); }
|
|
493
|
+
const cls = String(a.class || '').split(/\s+/);
|
|
494
|
+
const isSlide = cls.includes('pmos-slide'); const isSec = cls.includes('pmos-sec');
|
|
495
|
+
if (!isSlide && !isSec) continue;
|
|
496
|
+
let id = oneLine(isSlide ? a['data-slide-id'] : a['data-sec-id'], 80) || `${isSlide ? 'slide' : 'sec'}-${out.length + 1}`;
|
|
497
|
+
while (seen.has(id)) id += '-2';
|
|
498
|
+
seen.add(id);
|
|
499
|
+
out.push({ id, kind: oneLine(a['data-kind'], 40) || (isSlide ? 'slide' : 'section'), title: oneLine(a['data-title'], 160) });
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
function formatOf(html) { return /class\s*=\s*["']?[^"'>]*\bpmos-slide\b/i.test(String(html || '')) ? 'deck' : 'doc'; }
|
|
504
|
+
|
|
505
|
+
/** What differs between the reference outline and a cycle's: a slide added or missing, a kind changed, the order changed. Titles carry data and are not compared. */
|
|
506
|
+
function diffOutline(reference, cycle) {
|
|
507
|
+
const ref = Array.isArray(reference) ? reference : [];
|
|
508
|
+
const cyc = Array.isArray(cycle) ? cycle : [];
|
|
509
|
+
const R = new Map(ref.map((o) => [o.id, o]));
|
|
510
|
+
const C = new Map(cyc.map((o) => [o.id, o]));
|
|
511
|
+
const out = [];
|
|
512
|
+
const label = (o) => (o.title ? `"${o.title}"` : o.id);
|
|
513
|
+
for (const o of cyc) if (!R.has(o.id)) out.push({ id: `added:${o.id}`, what: `${label(o)} (${o.kind}) is new this cycle; the design has no such slide.`, slide: o.id });
|
|
514
|
+
for (const o of ref) if (!C.has(o.id)) out.push({ id: `missing:${o.id}`, what: `${label(o)} (${o.kind}) from the design is not in this cycle.`, slide: o.id });
|
|
515
|
+
for (const o of cyc) { const r = R.get(o.id); if (r && r.kind !== o.kind) out.push({ id: `kind:${o.id}`, what: `${label(o)} is a ${o.kind} slide; the design has a ${r.kind} slide here.`, slide: o.id }); }
|
|
516
|
+
const a = ref.filter((o) => C.has(o.id)).map((o) => o.id);
|
|
517
|
+
const b = cyc.filter((o) => R.has(o.id)).map((o) => o.id);
|
|
518
|
+
if (a.join('\u0000') !== b.join('\u0000')) {
|
|
519
|
+
const first = b.find((id, i) => id !== a[i]);
|
|
520
|
+
out.push({ id: 'order', what: 'The order of the slides differs from the design.', slide: first || null });
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── a cycle's render ──────────────────────────────────────────────────────────────────────────────────────
|
|
526
|
+
/** rel = <state>/reports/<cadence>/<slot>.md → { abs, cadence, slot, html, json, notes, md } | null */
|
|
527
|
+
function cyclePaths(root, rel) {
|
|
528
|
+
const r = String(rel || '').replace(/\\/g, '/');
|
|
529
|
+
const m = /(?:^|\/)reports\/([a-z][a-z0-9-]{0,39})\/(\d{4}-\d{2}-\d{2})\.md$/.exec(r);
|
|
530
|
+
if (!m || m[1] === 'definitions') return null;
|
|
531
|
+
const abs = path.resolve(String(root));
|
|
532
|
+
const md = path.resolve(abs, r);
|
|
533
|
+
if (!md.startsWith(abs + path.sep)) return null;
|
|
534
|
+
const base = md.slice(0, -3);
|
|
535
|
+
return { abs, rel: path.relative(abs, md).split(path.sep).join('/'), cadence: m[1], slot: m[2], md, html: base + '.design.html', json: base + '.design.json', notes: base + '.notes.json' };
|
|
536
|
+
}
|
|
537
|
+
function readCycle(root, rel) { const c = cyclePaths(root, rel); return c ? readJson(c.json) : null; }
|
|
538
|
+
function isIssuedSlot(root, cadence, slot) { try { return issues.isIssued(issues.readIssues(path.resolve(String(root))), cadence, slot); } catch { return false; } }
|
|
539
|
+
function docSha(file) { try { return sha1(fs.readFileSync(file)); } catch { return null; } }
|
|
540
|
+
|
|
541
|
+
/** Write a cycle's render: sanitize, outline, compare with the design version it was rendered for. Keeps the decisions already made. */
|
|
542
|
+
function writeCycle(root, rel, html, { designVersion, mode, sha, now } = {}) {
|
|
543
|
+
const c = cyclePaths(root, rel);
|
|
544
|
+
if (!c) return refuse(400, 'not a report document');
|
|
545
|
+
const clean = sanitize(html);
|
|
546
|
+
const outline = outlineOf(clean);
|
|
547
|
+
if (!outline.length) return refuse(422, 'the render has no pmos-slide or pmos-sec section');
|
|
548
|
+
const ref = versionMeta(root, c.cadence, designVersion) || {};
|
|
549
|
+
const differences = diffOutline(ref.outline || [], outline);
|
|
550
|
+
const prev = readJson(c.json) || {};
|
|
551
|
+
const rec = { design_version: designVersion, format: formatOf(clean), outline, differences, decisions: Array.isArray(prev.decisions) ? prev.decisions : [],
|
|
552
|
+
rendered_at: iso(now), mode: mode || 'cycle', source_sha: sha || docSha(c.md) };
|
|
553
|
+
writeAtomic(c.html, clean);
|
|
554
|
+
writeAtomic(c.json, JSON.stringify(rec, null, 2) + '\n');
|
|
555
|
+
return { ok: true, cycle: rec };
|
|
556
|
+
}
|
|
557
|
+
/** Record a decision on the cycle file (and, for adopt, point it at the new version and recompute what differs). */
|
|
558
|
+
function decideCycle(root, rel, { diff, choice, newVersion, now } = {}) {
|
|
559
|
+
const c = cyclePaths(root, rel);
|
|
560
|
+
const rec = c ? readJson(c.json) : null;
|
|
561
|
+
if (!rec) return refuse(404, 'this cycle has no render yet');
|
|
562
|
+
const at = iso(now);
|
|
563
|
+
rec.decisions = [...(Array.isArray(rec.decisions) ? rec.decisions : []), { diff, choice, at }];
|
|
564
|
+
if (choice === 'adopt' && newVersion) {
|
|
565
|
+
rec.design_version = newVersion;
|
|
566
|
+
const ref = versionMeta(root, c.cadence, newVersion) || {};
|
|
567
|
+
rec.differences = diffOutline(ref.outline || [], rec.outline || []);
|
|
568
|
+
}
|
|
569
|
+
writeAtomic(c.json, JSON.stringify(rec, null, 2) + '\n');
|
|
570
|
+
return { ok: true, cycle: rec };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── notes ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
574
|
+
function readNotesFile(file) {
|
|
575
|
+
const o = readJson(file) || {};
|
|
576
|
+
return { speaker: isObj(o.speaker) ? o.speaker : {}, pins: Array.isArray(o.pins) ? o.pins : [], cleared: Array.isArray(o.cleared) ? o.cleared : [] };
|
|
577
|
+
}
|
|
578
|
+
/** The nearest earlier cycle of the same report that has notes. */
|
|
579
|
+
function previousCycle(root, rel) {
|
|
580
|
+
const c = cyclePaths(root, rel);
|
|
581
|
+
if (!c) return null;
|
|
582
|
+
let files = [];
|
|
583
|
+
try { files = fs.readdirSync(path.dirname(c.md)); } catch { return null; }
|
|
584
|
+
const slots = files.map((f) => /^(\d{4}-\d{2}-\d{2})\.md$/.exec(f)).filter(Boolean).map((m) => m[1]).filter((s) => s < c.slot).sort().reverse();
|
|
585
|
+
if (!slots.length) return null;
|
|
586
|
+
const relDir = path.posix.dirname(c.rel);
|
|
587
|
+
return { slot: slots[0], path: `${relDir}/${slots[0]}.md`, slots };
|
|
588
|
+
}
|
|
589
|
+
function notesOf(root, rel) {
|
|
590
|
+
const c = cyclePaths(root, rel);
|
|
591
|
+
if (!c) return refuse(400, 'path must be a report document: <state>/reports/<cadence>/<slot>.md');
|
|
592
|
+
const mine = readNotesFile(c.notes);
|
|
593
|
+
const suggestions = {};
|
|
594
|
+
const prev = previousCycle(root, rel);
|
|
595
|
+
if (prev) {
|
|
596
|
+
for (const slot of prev.slots) {
|
|
597
|
+
const other = readNotesFile(path.join(path.dirname(c.md), `${slot}.notes.json`));
|
|
598
|
+
for (const [id, v] of Object.entries(other.speaker)) {
|
|
599
|
+
if (!v || !str(v.text) || mine.speaker[id] || mine.cleared.includes(id) || suggestions[id]) continue;
|
|
600
|
+
suggestions[id] = { text: v.text, from_slot: slot };
|
|
601
|
+
}
|
|
602
|
+
if (Object.values(other.speaker).some((v) => v && str(v.text))) break; // the nearest cycle that has notes is the one suggested from
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { ok: true, speaker: mine.speaker, suggestions, pins: mine.pins.filter((p) => p && p.state !== 'removed') };
|
|
606
|
+
}
|
|
607
|
+
function writeNotes(c, n) { writeAtomic(c.notes, JSON.stringify({ speaker: n.speaker, pins: n.pins, cleared: n.cleared }, null, 2) + '\n'); }
|
|
608
|
+
|
|
609
|
+
/** { slide, text } ('' clears: an explicit empty note, which also hides the suggestion) | { slide, keep: true } (adopts the suggestion). */
|
|
610
|
+
function setSpeaker(root, rel, { slide, text, keep } = {}, { now } = {}) {
|
|
611
|
+
const c = cyclePaths(root, rel);
|
|
612
|
+
if (!c) return refuse(400, 'path must be a report document: <state>/reports/<cadence>/<slot>.md');
|
|
613
|
+
if (!SLIDE_ID_RE.test(str(slide))) return refuse(400, 'slide must be a slide id');
|
|
614
|
+
const n = readNotesFile(c.notes);
|
|
615
|
+
if (keep === true) {
|
|
616
|
+
const sug = notesOf(root, rel).suggestions[slide];
|
|
617
|
+
if (!sug) return refuse(404, 'there is no suggestion for that slide');
|
|
618
|
+
n.speaker[slide] = { text: sug.text, at: iso(now), from_slot: sug.from_slot };
|
|
619
|
+
} else {
|
|
620
|
+
if (typeof text !== 'string') return refuse(400, 'text is required (an empty text clears the note)');
|
|
621
|
+
if (text.length > MAX_TEXT) return refuse(400, `a speaker note is at most ${MAX_TEXT} characters`);
|
|
622
|
+
// An empty text is kept as an explicit empty note: it clears the note and hides the suggestion from the previous cycle.
|
|
623
|
+
n.speaker[slide] = { text: text.trim() ? text.replace(/\r\n?/g, '\n') : '', at: iso(now) };
|
|
624
|
+
}
|
|
625
|
+
writeNotes(c, n);
|
|
626
|
+
return { ok: true, speaker: n.speaker[slide] || null };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** One pin operation. → { ok, pin, op } | refusal. `by` is 'you' for a press, 'pmos' for a job's proposal. */
|
|
630
|
+
function pinOp(root, rel, body, { by = 'you', now } = {}) {
|
|
631
|
+
const c = cyclePaths(root, rel);
|
|
632
|
+
if (!c) return refuse(400, 'path must be a report document: <state>/reports/<cadence>/<slot>.md');
|
|
633
|
+
const op = str(body.op);
|
|
634
|
+
if (!PIN_OPS.includes(op)) return refuse(400, `op must be one of ${PIN_OPS.join(', ')}`);
|
|
635
|
+
const n = readNotesFile(c.notes);
|
|
636
|
+
const coord = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100 ? v : null); // percent of the slide (or section) box
|
|
637
|
+
if (op === 'add') {
|
|
638
|
+
if (!SLIDE_ID_RE.test(str(body.slide))) return refuse(400, 'slide must be a slide id');
|
|
639
|
+
const x = coord(body.x); const y = coord(body.y);
|
|
640
|
+
if (x == null || y == null) return refuse(400, 'x and y must be numbers');
|
|
641
|
+
const text = str(body.text).trim();
|
|
642
|
+
if (!text || text.length > 1000) return refuse(400, 'text is required, at most 1000 characters');
|
|
643
|
+
const pin = { id: newId('pin'), slide: body.slide, x, y, text, by: by === 'pmos' ? 'pmos' : 'you', state: 'open', at: iso(now), ...(str(body.section).trim() ? { section: oneLine(body.section, 120) } : {}) };
|
|
644
|
+
n.pins.push(pin);
|
|
645
|
+
writeNotes(c, n);
|
|
646
|
+
return { ok: true, pin, op };
|
|
647
|
+
}
|
|
648
|
+
const pin = n.pins.find((p) => p && p.id === str(body.id) && p.state !== 'removed');
|
|
649
|
+
if (!pin) return refuse(404, 'no such pin');
|
|
650
|
+
if (op === 'edit') {
|
|
651
|
+
if (body.text !== undefined) { const text = str(body.text).trim(); if (!text || text.length > 1000) return refuse(400, 'text is required, at most 1000 characters'); pin.text = text; }
|
|
652
|
+
if (body.x !== undefined) { const x = coord(body.x); if (x == null) return refuse(400, 'x must be a number'); pin.x = x; }
|
|
653
|
+
if (body.y !== undefined) { const y = coord(body.y); if (y == null) return refuse(400, 'y must be a number'); pin.y = y; }
|
|
654
|
+
if (body.slide !== undefined) { if (!SLIDE_ID_RE.test(str(body.slide))) return refuse(400, 'slide must be a slide id'); pin.slide = body.slide; }
|
|
655
|
+
pin.edited_at = iso(now);
|
|
656
|
+
} else if (op === 'remove') pin.state = 'removed';
|
|
657
|
+
else if (op === 'keep') pin.state = 'kept';
|
|
658
|
+
else if (op === 'dismiss') pin.state = 'dismissed';
|
|
659
|
+
pin.changed_at = iso(now);
|
|
660
|
+
writeNotes(c, n);
|
|
661
|
+
return { ok: true, pin, op };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── .pptx / .docx ─────────────────────────────────────────────────────────────────────────────────────────
|
|
665
|
+
/** The entries of a zip whose names pass `want`, each inflated to at most `cap` bytes. A malformed zip throws. */
|
|
666
|
+
function unzipSome(buf, want, cap = 8 * 1024 * 1024) {
|
|
667
|
+
let eocd = -1;
|
|
668
|
+
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 65557; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
669
|
+
if (eocd < 0) throw new Error('not a zip');
|
|
670
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
671
|
+
let p = buf.readUInt32LE(eocd + 16);
|
|
672
|
+
const out = [];
|
|
673
|
+
let total = 0;
|
|
674
|
+
for (let k = 0; k < count && p + 46 <= buf.length; k++) {
|
|
675
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('bad central directory');
|
|
676
|
+
const method = buf.readUInt16LE(p + 10); const csize = buf.readUInt32LE(p + 20);
|
|
677
|
+
const nlen = buf.readUInt16LE(p + 28); const xlen = buf.readUInt16LE(p + 30); const clen = buf.readUInt16LE(p + 32); const lho = buf.readUInt32LE(p + 42);
|
|
678
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nlen);
|
|
679
|
+
p += 46 + nlen + xlen + clen;
|
|
680
|
+
if (!want(name)) continue;
|
|
681
|
+
if (lho + 30 > buf.length || buf.readUInt32LE(lho) !== 0x04034b50) continue;
|
|
682
|
+
const start = lho + 30 + buf.readUInt16LE(lho + 26) + buf.readUInt16LE(lho + 28);
|
|
683
|
+
const body = buf.subarray(start, start + csize);
|
|
684
|
+
let data;
|
|
685
|
+
try { data = method === 8 ? zlib.inflateRawSync(body, { maxOutputLength: cap }) : method === 0 ? Buffer.from(body) : null; } catch { data = null; }
|
|
686
|
+
if (!data) continue;
|
|
687
|
+
total += data.length;
|
|
688
|
+
if (total > 4 * cap) break;
|
|
689
|
+
out.push({ name, data });
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
const xmlText = (s) => decodeEntities(String(s).replace(/<[^>]*>/g, ''));
|
|
694
|
+
/** The words of a .pptx (slide by slide) or .docx (paragraph by paragraph). */
|
|
695
|
+
function ooxmlText(buf, kind) {
|
|
696
|
+
if (kind === 'pptx') {
|
|
697
|
+
const slides = unzipSome(buf, (n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
|
|
698
|
+
.sort((a, b) => Number(/(\d+)\.xml$/.exec(a.name)[1]) - Number(/(\d+)\.xml$/.exec(b.name)[1]));
|
|
699
|
+
return slides.map((s, i) => {
|
|
700
|
+
const x = s.data.toString('utf8');
|
|
701
|
+
const paras = x.split(/<\/a:p>/).map((p) => (p.match(/<a:t>([\s\S]*?)<\/a:t>/g) || []).map(xmlText).join('')).filter((t) => t.trim());
|
|
702
|
+
return `--- Slide ${i + 1} ---\n${paras.join('\n')}`;
|
|
703
|
+
}).join('\n\n');
|
|
704
|
+
}
|
|
705
|
+
const doc = unzipSome(buf, (n) => n === 'word/document.xml')[0];
|
|
706
|
+
if (!doc) return '';
|
|
707
|
+
const x = doc.data.toString('utf8');
|
|
708
|
+
return x.split(/<\/w:p>/).map((p) => {
|
|
709
|
+
const t = (p.match(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g) || []).map(xmlText).join('');
|
|
710
|
+
const h = /<w:pStyle w:val="(Heading\d|Title)"/.exec(p);
|
|
711
|
+
return t.trim() ? (h ? `# ${t}` : t) : '';
|
|
712
|
+
}).filter(Boolean).join('\n');
|
|
713
|
+
}
|
|
714
|
+
/** The thumbnail an Office file carries (docProps/thumbnail.jpeg or .png), as a data: URL, or null. */
|
|
715
|
+
function ooxmlThumb(buf) {
|
|
716
|
+
const t = unzipSome(buf, (n) => /^docProps\/thumbnail\.(jpe?g|png)$/i.test(n), MAX_THUMB_BYTES + 1)[0];
|
|
717
|
+
if (!t || t.data.length > MAX_THUMB_BYTES) return null;
|
|
718
|
+
const s = sniff(t.data);
|
|
719
|
+
if (!s || s.kind !== 'image') return null;
|
|
720
|
+
return `data:image/${s.ext === 'jpg' ? 'jpeg' : 'png'};base64,${t.data.toString('base64')}`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** The newest report document of a cadence folder (the current cycle), repository-relative, or null. */
|
|
724
|
+
function currentCycle(root, cadence) {
|
|
725
|
+
const p = paths(root, cadence);
|
|
726
|
+
let files = [];
|
|
727
|
+
try { files = fs.readdirSync(p.folder); } catch { return null; }
|
|
728
|
+
const slots = files.map((f) => /^(\d{4}-\d{2}-\d{2})\.md$/.exec(f)).filter(Boolean).map((m) => m[1]).sort();
|
|
729
|
+
if (!slots.length) return null;
|
|
730
|
+
const slot = slots[slots.length - 1];
|
|
731
|
+
return { slot, path: `${p.rel}/${slot}.md`, abs: path.join(p.folder, `${slot}.md`) };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
module.exports = {
|
|
735
|
+
EVENTS, FORMATS, SAMPLE_KINDS, MAX_SAMPLE_BYTES, CHOICES, PIN_OPS, CADENCE_RE, SLOT_RE, SLIDE_ID_RE, PMOS_ACTOR,
|
|
736
|
+
paths, append, readEvents, fold, stateOf, designAnswer, versionMeta, versionHtml, writeVersion, rulesFor, cleanRules,
|
|
737
|
+
addSample, sampleOut, sampleRecord, recordTook, sampleReadPath, writeBrief,
|
|
738
|
+
tokenize, sanitize, cleanCss, outlineOf, formatOf, diffOutline,
|
|
739
|
+
cyclePaths, readCycle, writeCycle, decideCycle, isIssuedSlot, docSha, currentCycle, previousCycle,
|
|
740
|
+
notesOf, setSpeaker, pinOp, ooxmlText, ooxmlThumb, unzipSome,
|
|
741
|
+
};
|