create-pmos 0.179.0 → 0.180.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-setup-test.js +190 -0
- package/workspace/c3-reports-test.js +30 -97
- package/workspace/guide/document-sheet.md +14 -0
- package/workspace/guide/index.md +3 -2
- package/workspace/guide/reports-dashboards.md +17 -28
- package/workspace/guide/reports-page.md +10 -5
- package/workspace/guide/reports-set-up.md +56 -0
- package/workspace/guide/reports.md +13 -1
- package/workspace/guide/settings.md +11 -2
- package/workspace/lib/doc-sync.js +28 -6
- package/workspace/lib/docs-cli.js +7 -11
- package/workspace/lib/four-panel.js +1 -1
- package/workspace/lib/headless.js +21 -2
- package/workspace/lib/report-checks.js +103 -0
- package/workspace/lib/report-cli.js +89 -4
- package/workspace/lib/report-export.js +347 -0
- package/workspace/lib/report-finder.js +156 -0
- package/workspace/lib/report-needs.js +1 -1
- package/workspace/lib/report-setups.js +743 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-c3-reports.js +35 -17
- package/workspace/lib/routes-doc-health.js +15 -1
- package/workspace/lib/routes-report-setups.js +368 -0
- package/workspace/lib/schedule-logic.js +5 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-rep2.js +15 -0
- package/workspace/public/pm/c3-demo-rs.js +281 -0
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-doc-formats.js +152 -0
- package/workspace/public/pm/c3-doc-sheet.js +115 -25
- package/workspace/public/pm/c3-quiet.js +2 -2
- package/workspace/public/pm/c3-report-print.css +20 -0
- package/workspace/public/pm/c3-report-setup.css +62 -0
- package/workspace/public/pm/c3-report-setup.js +260 -0
- package/workspace/public/pm/c3-report-store.js +208 -0
- package/workspace/public/pm/c3-reports.css +4 -0
- package/workspace/public/pm/c3-reports.js +47 -181
- package/workspace/public/pm/c3-settings.js +4 -3
- package/workspace/public/pm/c3-shell.js +15 -31
- package/workspace/server.js +66 -5
- package/workspace/skills/doc-sync.skill +17 -8
- package/workspace/skills/report-find.skill +75 -0
- package/workspace/skills/report-setup.skill +67 -0
- package/workspace/test-files.json +10 -0
- package/workspace/test-lists/report-workspace-ui.json +5 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-export.js — a report document as a file on this machine: .docx, .pptx, .xlsx or .csv (initiative report-workspace, PRD section 6).
|
|
3
|
+
*
|
|
4
|
+
* exportOf(markdown, fmt) → { ok, fmt, name, mime, buffer } | { ok: false, error } fmt: docx | pptx | xlsx | csv
|
|
5
|
+
* parseReport(markdown) → { title, subtitle, blocks: [{ t: 'h', level, text } | { t: 'p', text } | { t: 'li', text } | { t: 'table', rows }] }
|
|
6
|
+
* sectionsOf(report) → [{ heading, blocks }] the blocks under each level-2 heading
|
|
7
|
+
* docx(report) · pptx(report) · xlsx(report) · csv(report) → Buffer (csv: string)
|
|
8
|
+
* zip(entries) · unzip(buffer) → Buffer · [{ name, data }] a minimal zip writer and reader
|
|
9
|
+
*
|
|
10
|
+
* NO DEPENDENCY, NO NETWORK. The Office files are ZIP packages of small XML parts, built here from the report's markdown.
|
|
11
|
+
* `zip` writes stored or deflated entries (node's zlib), a CRC-32, and the central directory; `unzip` reads them back so a test
|
|
12
|
+
* can open what was written. The files carry the report's words and tables only: no macro, no link to the outside, no embedded
|
|
13
|
+
* object. Nothing is uploaded; the route that serves the file (lib/routes-report-setups.js) writes it to the response.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THE MARKDOWN BECOMES. The frontmatter gives the title. A level-2 heading starts a section; a level-3 heading is a
|
|
16
|
+
* sub-heading in it. A pipe table becomes rows. A `- ` line is a bullet, any other line a paragraph. A note marker, the slide data
|
|
17
|
+
* comment and any other HTML comment are dropped: they are for PMOS, not for the reader.
|
|
18
|
+
* .docx headings, paragraphs and bullets .pptx a title slide, then one slide per section (title and bullets)
|
|
19
|
+
* .xlsx one sheet: a table is its rows; otherwise one row for each line, with its section beside it .csv the same rows
|
|
20
|
+
*/
|
|
21
|
+
const zlib = require('zlib');
|
|
22
|
+
|
|
23
|
+
// ── the zip ────────────────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
const CRC_TABLE = (() => {
|
|
25
|
+
const t = new Int32Array(256);
|
|
26
|
+
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
|
|
27
|
+
return t;
|
|
28
|
+
})();
|
|
29
|
+
function crc32(buf) {
|
|
30
|
+
let c = -1;
|
|
31
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
32
|
+
return (c ^ -1) >>> 0;
|
|
33
|
+
}
|
|
34
|
+
const DOS_TIME = 0;
|
|
35
|
+
const DOS_DATE = (46 << 9) | (10 << 5) | 12; // 2026-10-12: a fixed stamp, so the same report gives the same bytes
|
|
36
|
+
|
|
37
|
+
/** entries: [{ name, data: string | Buffer }]. An entry is deflated when that makes it smaller, else stored. */
|
|
38
|
+
function zip(entries) {
|
|
39
|
+
const locals = []; const central = [];
|
|
40
|
+
let offset = 0;
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
const name = Buffer.from(String(e.name), 'utf8');
|
|
43
|
+
const raw = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), 'utf8');
|
|
44
|
+
const deflated = zlib.deflateRawSync(raw);
|
|
45
|
+
const useDeflate = deflated.length < raw.length;
|
|
46
|
+
const body = useDeflate ? deflated : raw;
|
|
47
|
+
const method = useDeflate ? 8 : 0;
|
|
48
|
+
const crc = crc32(raw);
|
|
49
|
+
const lh = Buffer.alloc(30);
|
|
50
|
+
lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6); lh.writeUInt16LE(method, 8);
|
|
51
|
+
lh.writeUInt16LE(DOS_TIME, 10); lh.writeUInt16LE(DOS_DATE, 12); lh.writeUInt32LE(crc, 14);
|
|
52
|
+
lh.writeUInt32LE(body.length, 18); lh.writeUInt32LE(raw.length, 22); lh.writeUInt16LE(name.length, 26); lh.writeUInt16LE(0, 28);
|
|
53
|
+
locals.push(lh, name, body);
|
|
54
|
+
const ch = Buffer.alloc(46);
|
|
55
|
+
ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6); ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(method, 10);
|
|
56
|
+
ch.writeUInt16LE(DOS_TIME, 12); ch.writeUInt16LE(DOS_DATE, 14); ch.writeUInt32LE(crc, 16);
|
|
57
|
+
ch.writeUInt32LE(body.length, 20); ch.writeUInt32LE(raw.length, 24); ch.writeUInt16LE(name.length, 28);
|
|
58
|
+
ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32); ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38); ch.writeUInt32LE(offset, 42);
|
|
59
|
+
central.push(ch, name);
|
|
60
|
+
offset += lh.length + name.length + body.length;
|
|
61
|
+
}
|
|
62
|
+
const cd = Buffer.concat(central);
|
|
63
|
+
const end = Buffer.alloc(22);
|
|
64
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6);
|
|
65
|
+
end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20);
|
|
66
|
+
return Buffer.concat([...locals, cd, end]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read the entries of a zip this module (or any plain zip) wrote. Throws on a damaged archive or a bad CRC. */
|
|
70
|
+
function unzip(buf) {
|
|
71
|
+
let eocd = -1;
|
|
72
|
+
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 65557; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
73
|
+
if (eocd < 0) throw new Error('not a zip: no end record');
|
|
74
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
75
|
+
let p = buf.readUInt32LE(eocd + 16);
|
|
76
|
+
const out = [];
|
|
77
|
+
for (let n = 0; n < count; n++) {
|
|
78
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('not a zip: bad central directory');
|
|
79
|
+
const method = buf.readUInt16LE(p + 10); const crc = buf.readUInt32LE(p + 16); const csize = buf.readUInt32LE(p + 20);
|
|
80
|
+
const nlen = buf.readUInt16LE(p + 28); const xlen = buf.readUInt16LE(p + 30); const clen = buf.readUInt16LE(p + 32); const lho = buf.readUInt32LE(p + 42);
|
|
81
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nlen);
|
|
82
|
+
if (buf.readUInt32LE(lho) !== 0x04034b50) throw new Error('not a zip: bad local header for ' + name);
|
|
83
|
+
const start = lho + 30 + buf.readUInt16LE(lho + 26) + buf.readUInt16LE(lho + 28);
|
|
84
|
+
const body = buf.subarray(start, start + csize);
|
|
85
|
+
const data = method === 8 ? zlib.inflateRawSync(body) : Buffer.from(body);
|
|
86
|
+
if (crc32(data) !== crc) throw new Error('bad CRC for ' + name);
|
|
87
|
+
out.push({ name, data });
|
|
88
|
+
p += 46 + nlen + xlen + clen;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── the markdown ───────────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
const xml = (s) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
95
|
+
/** Plain words: no emphasis marks, no code ticks, a link as its text. */
|
|
96
|
+
const plain = (s) => String(s == null ? '' : s).replace(/\*\*([^*]+)\*\*/g, '$1').replace(/__([^_]+)__/g, '$1').replace(/`([^`]*)`/g, '$1')
|
|
97
|
+
.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, (m, t, u) => (t || u)).replace(/\\\|/g, '|').replace(/\s+/g, ' ').trim();
|
|
98
|
+
|
|
99
|
+
function frontmatterOf(text) {
|
|
100
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
101
|
+
const map = {};
|
|
102
|
+
if (!m) return { map, body: text };
|
|
103
|
+
for (const line of m[1].split(/\r?\n/)) { const kv = /^([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line); if (kv) map[kv[1]] = kv[2].replace(/^["']|["']$/g, ''); }
|
|
104
|
+
return { map, body: text.slice(m[0].length) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseReport(markdown) {
|
|
108
|
+
const { map, body } = frontmatterOf(String(markdown == null ? '' : markdown).replace(/\r\n?/g, '\n'));
|
|
109
|
+
const clean = body.replace(/<!--[\s\S]*?-->/g, '');
|
|
110
|
+
const blocks = [];
|
|
111
|
+
let table = null;
|
|
112
|
+
const flush = () => { if (table) { blocks.push({ t: 'table', rows: table }); table = null; } };
|
|
113
|
+
for (const raw of clean.split('\n')) {
|
|
114
|
+
const line = raw.replace(/\s+$/, '');
|
|
115
|
+
if (!line.trim()) { flush(); continue; }
|
|
116
|
+
if (/^\s*\|/.test(line)) {
|
|
117
|
+
const cells = line.trim().replace(/^\||\|$/g, '').split(/(?<!\\)\|/).map((c) => plain(c));
|
|
118
|
+
if (cells.every((c) => /^:?-{2,}:?$/.test(c))) continue; // the separator row
|
|
119
|
+
(table || (table = [])).push(cells);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
flush();
|
|
123
|
+
const h = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
124
|
+
if (h) { blocks.push({ t: 'h', level: h[1].length, text: plain(h[2]) }); continue; }
|
|
125
|
+
const li = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
126
|
+
if (li) { const t = plain(li[1]); if (t) blocks.push({ t: 'li', text: t }); continue; }
|
|
127
|
+
const p = plain(line);
|
|
128
|
+
if (p) blocks.push({ t: 'p', text: p });
|
|
129
|
+
}
|
|
130
|
+
flush();
|
|
131
|
+
const first = blocks.find((b) => b.t === 'h' && b.level === 1);
|
|
132
|
+
const title = plain(map.title || (first && first.text) || 'Report');
|
|
133
|
+
const rest = first ? blocks.filter((b) => b !== first) : blocks;
|
|
134
|
+
return { title, subtitle: map.slot ? String(map.slot) : '', blocks: rest };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The blocks under each level-2 heading; blocks before the first one go under the title. */
|
|
138
|
+
function sectionsOf(report) {
|
|
139
|
+
const out = [];
|
|
140
|
+
let cur = null;
|
|
141
|
+
for (const b of report.blocks) {
|
|
142
|
+
if (b.t === 'h' && b.level === 2) { cur = { heading: b.text, blocks: [] }; out.push(cur); continue; }
|
|
143
|
+
if (!cur) { cur = { heading: report.title, blocks: [] }; out.push(cur); }
|
|
144
|
+
cur.blocks.push(b);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── .docx ──────────────────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
151
|
+
const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
152
|
+
const NS_PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
153
|
+
const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
154
|
+
const DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
|
|
155
|
+
|
|
156
|
+
function docx(report) {
|
|
157
|
+
const para = (text, { style, bold, size, indent, hanging } = {}) => {
|
|
158
|
+
const ppr = (style ? `<w:pStyle w:val="${style}"/>` : '') + (indent ? `<w:ind w:left="${indent}" w:hanging="${hanging || 0}"/>` : '');
|
|
159
|
+
const rpr = (bold ? '<w:b/>' : '') + (size ? `<w:sz w:val="${size}"/>` : '');
|
|
160
|
+
return `<w:p>${ppr ? `<w:pPr>${ppr}</w:pPr>` : ''}<w:r>${rpr ? `<w:rPr>${rpr}</w:rPr>` : ''}<w:t xml:space="preserve">${xml(text)}</w:t></w:r></w:p>`;
|
|
161
|
+
};
|
|
162
|
+
const cell = (text, head) => `<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${para(text, { bold: head })}</w:tc>`;
|
|
163
|
+
const table = (rows) => `<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/><w:tblBorders>`
|
|
164
|
+
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'].map((s) => `<w:${s} w:val="single" w:sz="4" w:space="0" w:color="999999"/>`).join('')
|
|
165
|
+
+ `</w:tblBorders></w:tblPr>${rows.map((r, i) => `<w:tr>${r.map((c) => cell(c, i === 0)).join('')}</w:tr>`).join('')}</w:tbl>${para('')}`;
|
|
166
|
+
const body = [para(report.title, { style: 'Title' })];
|
|
167
|
+
if (report.subtitle) body.push(para(report.subtitle, { style: 'Subtitle' }));
|
|
168
|
+
for (const b of report.blocks) {
|
|
169
|
+
if (b.t === 'h') body.push(para(b.text, { style: 'Heading' + Math.min(3, b.level) }));
|
|
170
|
+
else if (b.t === 'li') body.push(para('• ' + b.text, { indent: 540, hanging: 270 }));
|
|
171
|
+
else if (b.t === 'table') body.push(table(b.rows.length ? b.rows : [['']]));
|
|
172
|
+
else body.push(para(b.text));
|
|
173
|
+
}
|
|
174
|
+
const document = `${DECL}<w:document xmlns:w="${NS_W}" xmlns:r="${NS_R}"><w:body>${body.join('')}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="567" w:footer="567" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
|
175
|
+
const style = (id, name, size, bold, extra = '') => `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:spacing w:before="240" w:after="80"/>${extra}</w:pPr><w:rPr>${bold ? '<w:b/>' : ''}<w:sz w:val="${size}"/></w:rPr></w:style>`;
|
|
176
|
+
const styles = `${DECL}<w:styles xmlns:w="${NS_W}"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/><w:sz w:val="22"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="100"/></w:pPr></w:pPrDefault></w:docDefaults>`
|
|
177
|
+
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
|
|
178
|
+
+ style('Title', 'Title', 44, true) + style('Subtitle', 'Subtitle', 26, false)
|
|
179
|
+
+ style('Heading1', 'heading 1', 32, true) + style('Heading2', 'heading 2', 28, true) + style('Heading3', 'heading 3', 24, true)
|
|
180
|
+
+ '<w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:tblBorders></w:tblPr></w:style>'
|
|
181
|
+
+ '</w:styles>';
|
|
182
|
+
return zip([
|
|
183
|
+
{ name: '[Content_Types].xml', data: `${DECL}<Types xmlns="${NS_CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>` },
|
|
184
|
+
{ name: '_rels/.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${NS_R}/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>` },
|
|
185
|
+
{ name: 'docProps/core.xml', data: coreProps(report.title) },
|
|
186
|
+
{ name: 'word/document.xml', data: document },
|
|
187
|
+
{ name: 'word/styles.xml', data: styles },
|
|
188
|
+
{ name: 'word/_rels/document.xml.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${NS_R}/styles" Target="styles.xml"/></Relationships>` },
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
function coreProps(title) {
|
|
192
|
+
return `${DECL}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xml(title)}</dc:title><dc:creator>PMOS</dc:creator></cp:coreProperties>`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── .pptx ──────────────────────────────────────────────────────────────────────────────────────────
|
|
196
|
+
const NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
|
|
197
|
+
const NS_P = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
198
|
+
const EMU_W = 12192000; const EMU_H = 6858000; // 16:9
|
|
199
|
+
const PER_SLIDE = 9;
|
|
200
|
+
|
|
201
|
+
/** The lines a section puts on slides: bullets and paragraphs as they are, a table row as "a | b | c", a sub-heading as a bold-less line. */
|
|
202
|
+
function slideLines(blocks) {
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const b of blocks) {
|
|
205
|
+
if (b.t === 'table') for (const r of b.rows) out.push(r.join(' | '));
|
|
206
|
+
else if (b.t === 'h') out.push(b.text);
|
|
207
|
+
else out.push(b.text);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pptx(report) {
|
|
213
|
+
const slides = []; // { title, lines, kind }
|
|
214
|
+
slides.push({ kind: 'title', title: report.title, lines: report.subtitle ? [report.subtitle] : [] });
|
|
215
|
+
for (const s of sectionsOf(report)) {
|
|
216
|
+
const lines = slideLines(s.blocks);
|
|
217
|
+
if (!lines.length) { slides.push({ kind: 'body', title: s.heading, lines: [] }); continue; }
|
|
218
|
+
for (let i = 0; i < lines.length; i += PER_SLIDE) slides.push({ kind: 'body', title: i ? s.heading + ' (continued)' : s.heading, lines: lines.slice(i, i + PER_SLIDE) });
|
|
219
|
+
}
|
|
220
|
+
const sp = (id, name, ph, x, y, cx, cy, paras) => `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${name}"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="${ph}"${ph === 'body' ? ' idx="1"' : ''}/></p:nvPr></p:nvSpPr><p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/>${paras}</p:txBody></p:sp>`;
|
|
221
|
+
const run = (t, sz, b) => `<a:r><a:rPr lang="en-GB" sz="${sz}"${b ? ' b="1"' : ''} dirty="0"/><a:t>${xml(t)}</a:t></a:r>`;
|
|
222
|
+
const slideXml = (s) => {
|
|
223
|
+
const title = sp(2, 'Title', s.kind === 'title' ? 'ctrTitle' : 'title', s.kind === 'title' ? 914400 : 609600, s.kind === 'title' ? 2286000 : 365760, s.kind === 'title' ? 10363200 : 10972800, s.kind === 'title' ? 1371600 : 914400,
|
|
224
|
+
`<a:p>${run(s.title, s.kind === 'title' ? 4400 : 3200, true)}</a:p>`);
|
|
225
|
+
const bodyParas = s.lines.map((l) => (s.kind === 'title' ? `<a:p>${run(l, 2400, false)}</a:p>` : `<a:p><a:pPr marL="285750" indent="-285750"><a:buFont typeface="Arial"/><a:buChar char="•"/></a:pPr>${run(l, 1800, false)}</a:p>`)).join('');
|
|
226
|
+
const body = s.lines.length ? sp(3, 'Content', s.kind === 'title' ? 'subTitle' : 'body', s.kind === 'title' ? 914400 : 609600, s.kind === 'title' ? 3810000 : 1371600, s.kind === 'title' ? 10363200 : 10972800, s.kind === 'title' ? 914400 : 4846320, bodyParas) : '';
|
|
227
|
+
return `${DECL}<p:sld xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>${title}${body}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`;
|
|
228
|
+
};
|
|
229
|
+
const layoutXml = `${DECL}<p:sldLayout xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}" type="obj" preserve="1"><p:cSld name="Title and Content"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>`
|
|
230
|
+
+ sp(2, 'Title 1', 'title', 609600, 365760, 10972800, 914400, '<a:p><a:endParaRPr lang="en-GB"/></a:p>')
|
|
231
|
+
+ sp(3, 'Content Placeholder 2', 'body', 609600, 1371600, 10972800, 4846320, '<a:p><a:endParaRPr lang="en-GB"/></a:p>')
|
|
232
|
+
+ '</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>';
|
|
233
|
+
const masterXml = `${DECL}<p:sldMaster xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>`
|
|
234
|
+
+ sp(2, 'Title Placeholder 1', 'title', 609600, 365760, 10972800, 914400, '<a:p><a:endParaRPr lang="en-GB"/></a:p>')
|
|
235
|
+
+ sp(3, 'Text Placeholder 2', 'body', 609600, 1371600, 10972800, 4846320, '<a:p><a:endParaRPr lang="en-GB"/></a:p>')
|
|
236
|
+
+ '</p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>'
|
|
237
|
+
+ '<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst><p:txStyles><p:titleStyle><a:lvl1pPr algn="l"><a:defRPr sz="3200" b="1"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mj-lt"/></a:defRPr></a:lvl1pPr></p:titleStyle>'
|
|
238
|
+
+ '<p:bodyStyle><a:lvl1pPr marL="285750" indent="-285750"><a:defRPr sz="1800"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/></a:defRPr></a:lvl1pPr></p:bodyStyle><p:otherStyle><a:defPPr><a:defRPr lang="en-GB"/></a:defPPr></p:otherStyle></p:txStyles></p:sldMaster>';
|
|
239
|
+
const themeXml = `${DECL}<a:theme xmlns:a="${NS_A}" name="PMOS"><a:themeElements><a:clrScheme name="PMOS"><a:dk1><a:srgbClr val="1F2328"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="2F5FD0"/></a:accent1><a:accent2><a:srgbClr val="7A8CA5"/></a:accent2><a:accent3><a:srgbClr val="2E8B57"/></a:accent3><a:accent4><a:srgbClr val="C58A00"/></a:accent4><a:accent5><a:srgbClr val="B3412F"/></a:accent5><a:accent6><a:srgbClr val="6B5BB0"/></a:accent6><a:hlink><a:srgbClr val="2F5FD0"/></a:hlink><a:folHlink><a:srgbClr val="6B5BB0"/></a:folHlink></a:clrScheme>`
|
|
240
|
+
+ '<a:fontScheme name="PMOS"><a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme>'
|
|
241
|
+
+ '<a:fmtScheme name="PMOS"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>'
|
|
242
|
+
+ '<a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="28575"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>'
|
|
243
|
+
+ '<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>'
|
|
244
|
+
+ '<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>';
|
|
245
|
+
const n = slides.length;
|
|
246
|
+
const presXml = `${DECL}<p:presentation xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst>`
|
|
247
|
+
+ slides.map((s, i) => `<p:sldId id="${256 + i}" r:id="rId${i + 2}"/>`).join('') + `</p:sldIdLst><p:sldSz cx="${EMU_W}" cy="${EMU_H}"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`;
|
|
248
|
+
const rel = (id, type, target) => `<Relationship Id="${id}" Type="${NS_R}/${type}" Target="${target}"/>`;
|
|
249
|
+
const entries = [
|
|
250
|
+
{ name: '[Content_Types].xml', data: `${DECL}<Types xmlns="${NS_CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/><Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>`
|
|
251
|
+
+ slides.map((s, i) => `<Override PartName="/ppt/slides/slide${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`).join('') + '</Types>' },
|
|
252
|
+
{ name: '_rels/.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}">${rel('rId1', 'officeDocument', 'ppt/presentation.xml')}<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>` },
|
|
253
|
+
{ name: 'docProps/core.xml', data: coreProps(report.title) },
|
|
254
|
+
{ name: 'ppt/presentation.xml', data: presXml },
|
|
255
|
+
{ name: 'ppt/_rels/presentation.xml.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}">${rel('rId1', 'slideMaster', 'slideMasters/slideMaster1.xml')}${slides.map((s, i) => rel('rId' + (i + 2), 'slide', `slides/slide${i + 1}.xml`)).join('')}${rel('rId' + (n + 2), 'theme', 'theme/theme1.xml')}</Relationships>` },
|
|
256
|
+
{ name: 'ppt/slideMasters/slideMaster1.xml', data: masterXml },
|
|
257
|
+
{ name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}">${rel('rId1', 'slideLayout', '../slideLayouts/slideLayout1.xml')}${rel('rId2', 'theme', '../theme/theme1.xml')}</Relationships>` },
|
|
258
|
+
{ name: 'ppt/slideLayouts/slideLayout1.xml', data: layoutXml },
|
|
259
|
+
{ name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}">${rel('rId1', 'slideMaster', '../slideMasters/slideMaster1.xml')}</Relationships>` },
|
|
260
|
+
{ name: 'ppt/theme/theme1.xml', data: themeXml },
|
|
261
|
+
];
|
|
262
|
+
slides.forEach((s, i) => {
|
|
263
|
+
entries.push({ name: `ppt/slides/slide${i + 1}.xml`, data: slideXml(s) });
|
|
264
|
+
entries.push({ name: `ppt/slides/_rels/slide${i + 1}.xml.rels`, data: `${DECL}<Relationships xmlns="${NS_PKG_REL}">${rel('rId1', 'slideLayout', '../slideLayouts/slideLayout1.xml')}</Relationships>` });
|
|
265
|
+
});
|
|
266
|
+
return zip(entries);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── rows: the sheet and the CSV ─────────────────────────────────────────────────────────────────────
|
|
270
|
+
/** Rows of cells. A table is its own rows (the first row is its header). Any other line is [section, line]. */
|
|
271
|
+
function rowsOf(report) {
|
|
272
|
+
const rows = [];
|
|
273
|
+
let tables = 0;
|
|
274
|
+
for (const s of sectionsOf(report)) {
|
|
275
|
+
for (const b of s.blocks) {
|
|
276
|
+
if (b.t === 'table') {
|
|
277
|
+
if (tables++) rows.push([]);
|
|
278
|
+
b.rows.forEach((r) => rows.push(r));
|
|
279
|
+
rows.push([]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (tables) {
|
|
284
|
+
while (rows.length && !rows[rows.length - 1].length) rows.pop();
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
rows.push(['Section', 'Line']);
|
|
288
|
+
for (const s of sectionsOf(report)) for (const b of s.blocks) {
|
|
289
|
+
if (b.t === 'h') rows.push([s.heading, b.text]);
|
|
290
|
+
else if (b.t !== 'table') rows.push([s.heading, b.text]);
|
|
291
|
+
}
|
|
292
|
+
return rows;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const colName = (i) => { let s = ''; for (let n = i + 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s; return s; };
|
|
296
|
+
const isNumber = (s) => /^-?\d+(?:\.\d+)?$/.test(s) && s.replace(/^-/, '').replace(/\.\d+$/, '').length <= 12 && !/^-?0\d/.test(s);
|
|
297
|
+
|
|
298
|
+
function xlsx(report) {
|
|
299
|
+
const rows = rowsOf(report);
|
|
300
|
+
const width = rows.reduce((m, r) => Math.max(m, r.length), 1);
|
|
301
|
+
const sheetRows = rows.map((r, ri) => {
|
|
302
|
+
if (!r.length) return `<row r="${ri + 1}"/>`;
|
|
303
|
+
return `<row r="${ri + 1}">${r.map((c, ci) => {
|
|
304
|
+
const ref = colName(ci) + (ri + 1);
|
|
305
|
+
const v = String(c == null ? '' : c);
|
|
306
|
+
if (isNumber(v)) return `<c r="${ref}"><v>${v}</v></c>`;
|
|
307
|
+
return `<c r="${ref}" t="inlineStr"${ri === 0 ? ' s="1"' : ''}><is><t xml:space="preserve">${xml(v)}</t></is></c>`;
|
|
308
|
+
}).join('')}</row>`;
|
|
309
|
+
}).join('');
|
|
310
|
+
const cols = Array.from({ length: width }, (_, i) => `<col min="${i + 1}" max="${i + 1}" width="${i === width - 1 ? 60 : 28}" customWidth="1"/>`).join('');
|
|
311
|
+
const sheet = `${DECL}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><dimension ref="A1:${colName(width - 1)}${Math.max(1, rows.length)}"/><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="15"/><cols>${cols}</cols><sheetData>${sheetRows}</sheetData></worksheet>`;
|
|
312
|
+
const name = xml(plain(report.title).replace(/[\\/?*[\]:]/g, ' ').slice(0, 31) || 'Report');
|
|
313
|
+
return zip([
|
|
314
|
+
{ name: '[Content_Types].xml', data: `${DECL}<Types xmlns="${NS_CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>` },
|
|
315
|
+
{ name: '_rels/.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${NS_R}/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>` },
|
|
316
|
+
{ name: 'docProps/core.xml', data: coreProps(report.title) },
|
|
317
|
+
{ name: 'xl/workbook.xml', data: `${DECL}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="${NS_R}"><sheets><sheet name="${name}" sheetId="1" r:id="rId1"/></sheets></workbook>` },
|
|
318
|
+
{ name: 'xl/_rels/workbook.xml.rels', data: `${DECL}<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${NS_R}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="${NS_R}/styles" Target="styles.xml"/></Relationships>` },
|
|
319
|
+
{ name: 'xl/styles.xml', data: `${DECL}<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>` },
|
|
320
|
+
{ name: 'xl/worksheets/sheet1.xml', data: sheet },
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function csv(report) {
|
|
325
|
+
const q = (c) => { const v = String(c == null ? '' : c); return /[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
|
|
326
|
+
return rowsOf(report).map((r) => r.map(q).join(',')).join('\r\n') + '\r\n';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── the entry point ────────────────────────────────────────────────────────────────────────────────
|
|
330
|
+
const FORMATS = Object.freeze({
|
|
331
|
+
docx: Object.freeze({ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }),
|
|
332
|
+
pptx: Object.freeze({ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }),
|
|
333
|
+
xlsx: Object.freeze({ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
|
334
|
+
csv: Object.freeze({ mime: 'text/csv; charset=utf-8' }),
|
|
335
|
+
});
|
|
336
|
+
const BUILD = { docx, pptx, xlsx, csv: (r) => Buffer.from(csv(r), 'utf8') };
|
|
337
|
+
|
|
338
|
+
function exportOf(markdown, fmt) {
|
|
339
|
+
if (!Object.prototype.hasOwnProperty.call(FORMATS, fmt)) return { ok: false, error: `fmt must be one of ${Object.keys(FORMATS).join(', ')}` };
|
|
340
|
+
const report = parseReport(markdown);
|
|
341
|
+
let buffer;
|
|
342
|
+
try { buffer = BUILD[fmt](report); } catch (e) { return { ok: false, error: 'the file could not be built: ' + String((e && e.message) || e).slice(0, 160) }; }
|
|
343
|
+
const stem = plain(report.title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'report';
|
|
344
|
+
return { ok: true, fmt, name: `${stem}.${fmt}`, mime: FORMATS[fmt].mime, buffer };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = { exportOf, parseReport, sectionsOf, rowsOf, docx, pptx, xlsx, csv, zip, unzip, crc32, FORMATS };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-finder.js — the weekly look for the reports a PM already sends (initiative report-workspace, PRD section 4).
|
|
3
|
+
*
|
|
4
|
+
* off(env) → true when PMOS_REPORT_FIND=off (or 0, false, no)
|
|
5
|
+
* weekStart(ms) → the Monday 00:00 (local) of the week that holds `ms`
|
|
6
|
+
* dueNow(state, now, { open }) → { ok } | { ok: false, why } may the run start now?
|
|
7
|
+
* nextRun(state, now, { dayEnd }) → ms | null when the next run starts (null: off, or no source on)
|
|
8
|
+
* findPrompt(skill, run, ctx) → the prompt of the run
|
|
9
|
+
* create(deps) → { tick(opts), active() }
|
|
10
|
+
*
|
|
11
|
+
* THE RULE. Weekly means Monday 09:00 on this machine's clock, inside the working hours (lib/driver-run.js dayCheckOpen: the day-check window and
|
|
12
|
+
* the quiet hours). A run that missed 09:00 starts at the first tick inside the working hours of that Monday. A Monday the server did not run is
|
|
13
|
+
* not caught up. AT MOST ONE RUN PER WEEK: any run that started this week, whether it ended well or not, ends the week's turn. With every source
|
|
14
|
+
* off, or the finder set to Off, nothing runs. `PMOS_REPORT_FIND=off` does none of it. The run is headless and never a pane.
|
|
15
|
+
*
|
|
16
|
+
* ONE RUN FOR THE MACHINE. The PM's calendar, Slack and mail are the same whichever repository is open, so the run belongs to the HOME workspace
|
|
17
|
+
* (deps.homeId) and files its findings in that repository's report-setups.jsonl. The run reads (kind report-find, lib/headless.js) and files with
|
|
18
|
+
* `pmos report propose`; it has no tool that sends, posts or fetches the web. The server gives it PMOS_REPORT_FILE and PMOS_REPORT_RUN, which the
|
|
19
|
+
* model cannot change, so it files as the find run it is and nothing else.
|
|
20
|
+
*
|
|
21
|
+
* deps: { workspaces() → [w], isLead(w), homeId() → id | null, settings() → proactive settings, inQuiet(now, quiet) → bool, headlessOff() → bool, env,
|
|
22
|
+
* spawn(spec) → session row, stop(session), promptFor(skillId) → skill body | null, warn(what, err) }
|
|
23
|
+
* `spawn(spec)` gets { name, ws, origin, initialInput, env, onDone }; it returns the session row ({ id, state, headless, note }).
|
|
24
|
+
*/
|
|
25
|
+
const RS = require('./report-setups.js');
|
|
26
|
+
const driverRun = require('./driver-run.js');
|
|
27
|
+
|
|
28
|
+
const flagOff = (v) => /^(off|0|false|no)$/i.test(String(v || '').trim());
|
|
29
|
+
const off = (env) => flagOff((env || process.env).PMOS_REPORT_FIND);
|
|
30
|
+
const SLOT = '09:00';
|
|
31
|
+
const oneLine = (s, max) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, max);
|
|
32
|
+
const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
33
|
+
const slotMs = (ms) => { const d = new Date(ms); const [h, m] = SLOT.split(':').map(Number); d.setHours(h, m, 0, 0); return d.getTime(); };
|
|
34
|
+
|
|
35
|
+
/** The Monday 00:00 (local) of the week that holds `ms`. */
|
|
36
|
+
function weekStart(ms) {
|
|
37
|
+
const d = new Date(ms);
|
|
38
|
+
d.setHours(0, 0, 0, 0);
|
|
39
|
+
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
|
|
40
|
+
return d.getTime();
|
|
41
|
+
}
|
|
42
|
+
const sourcesOn = (state) => RS.FINDER_SOURCES.filter((k) => state.finder.sources[k]);
|
|
43
|
+
const ranThisWeek = (state, now) => state.finds.some((f) => f.start && Date.parse(f.start) >= weekStart(now));
|
|
44
|
+
const isOpenRun = (state) => state.finds.some((f) => !f.end);
|
|
45
|
+
|
|
46
|
+
/** May a run start now? `open` is the day-check window and quiet hours as one boolean (lib/driver-run.js dayCheckOpen). */
|
|
47
|
+
function dueNow(state, now, { open = true } = {}) {
|
|
48
|
+
if (state.finder.cadence === 'off') return { ok: false, why: 'off' };
|
|
49
|
+
if (!sourcesOn(state).length) return { ok: false, why: 'no-sources' };
|
|
50
|
+
if (new Date(now).getDay() !== 1) return { ok: false, why: 'not-monday' };
|
|
51
|
+
if (now < slotMs(now)) return { ok: false, why: 'before-09' };
|
|
52
|
+
if (!open) return { ok: false, why: 'outside-working-hours' };
|
|
53
|
+
if (isOpenRun(state)) return { ok: false, why: 'running' };
|
|
54
|
+
if (ranThisWeek(state, now)) return { ok: false, why: 'done-this-week' };
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** When the next run starts, in ms, or null. A run that is due now and has not started starts at the next tick, so the answer is `now`. */
|
|
59
|
+
function nextRun(state, now, { dayEnd = '18:00' } = {}) {
|
|
60
|
+
if (state.finder.cadence === 'off' || !sourcesOn(state).length) return null;
|
|
61
|
+
const done = ranThisWeek(state, now);
|
|
62
|
+
for (let k = 0; k < 15; k++) {
|
|
63
|
+
const d = new Date(now); d.setDate(d.getDate() + k);
|
|
64
|
+
const day = d.getTime();
|
|
65
|
+
if (d.getDay() !== 1) continue;
|
|
66
|
+
if (k === 0) {
|
|
67
|
+
if (done) continue;
|
|
68
|
+
if (now < slotMs(day)) return slotMs(day);
|
|
69
|
+
if (hhmm(now) < dayEnd) return now;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
return slotMs(day);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The prompt of the run: the skill, the run id, the sources that are on, and what the run must skip. Nothing of the repository's private text. */
|
|
78
|
+
function findPrompt(skill, run, { sources, setups = [], dismissed = [], now = Date.now() } = {}) {
|
|
79
|
+
const on = RS.FINDER_SOURCES.filter((k) => sources && sources[k]);
|
|
80
|
+
const offList = RS.FINDER_SOURCES.filter((k) => !(sources && sources[k]));
|
|
81
|
+
const list = (a, none) => (a.length ? a.map((x) => `- ${oneLine(x, 120)}`).join('\n') : `- ${none}`);
|
|
82
|
+
return `${skill}\n\nThis run:\n- Run: ${run}\n- Today: ${new Date(now).toISOString().slice(0, 10)}\n- Sources that are ON (read these only): ${on.join(', ') || 'none'}\n- Sources that are OFF (do not read them): ${offList.join(', ') || 'none'}\n`
|
|
83
|
+
+ `- Reports already set up (do not propose them):\n${list(setups, 'none')}\n- Not a report (the PM said so; skip these patterns and names):\n${list(dismissed, 'none')}\n\n`
|
|
84
|
+
+ 'File each proposal with `pmos report propose --json -`. File at most 5. Send nothing.';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function create(deps) {
|
|
88
|
+
const active = new Map(); // run id → { w, file, session }
|
|
89
|
+
const warn = deps.warn || (() => {});
|
|
90
|
+
|
|
91
|
+
/** The run ended (or never started): write its find-end line. */
|
|
92
|
+
function ended(run, file, r, now) {
|
|
93
|
+
const res = (r && r.result) || {};
|
|
94
|
+
const ok = !!(r && !r.timedOut && !r.stopped && r.code === 0 && res.ok && !res.isError);
|
|
95
|
+
try { RS.findEnd(file, { run, ok, error: ok ? null : (r && r.note) || 'the run failed', now }); } catch (e) { warn('report find end', e); }
|
|
96
|
+
active.delete(run);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function start(w, file, state, now) {
|
|
100
|
+
const skill = deps.promptFor('report-find');
|
|
101
|
+
const run = RS.findStart(file, { now });
|
|
102
|
+
active.set(run, { w, file, session: null });
|
|
103
|
+
if (!skill) { ended(run, file, { code: 1, note: 'the report-find skill is not installed', result: {} }, now); return; }
|
|
104
|
+
const st = RS.fold(RS.readLines(file));
|
|
105
|
+
const prompt = findPrompt(skill, run, { sources: st.finder.sources, now,
|
|
106
|
+
setups: [...st.setups.values()].map((s) => s.name),
|
|
107
|
+
dismissed: [...st.dismissed.patterns, ...[...st.dismissed.names].map((n) => `name:${n}`)] });
|
|
108
|
+
let session = null;
|
|
109
|
+
try {
|
|
110
|
+
session = deps.spawn({ name: 'Report finder', ws: w.id, origin: `report-find:${run}`, initialInput: prompt + '\r',
|
|
111
|
+
env: { PMOS_REPORT_FILE: file, PMOS_REPORT_RUN: `find:${run}` }, onDone: (r) => ended(run, file, r) });
|
|
112
|
+
} catch (e) { warn('report find', e); }
|
|
113
|
+
if (!session || session.state === 'exited' || session.headless !== 'report-find') {
|
|
114
|
+
// A start that did not become a headless run (no `claude` on the PATH, a refused model choice, a pane because headless is off) is a failed run.
|
|
115
|
+
if (session && session.headless !== 'report-find' && session.state !== 'exited' && deps.stop) { try { deps.stop(session); } catch { /* best effort */ } }
|
|
116
|
+
if (active.has(run)) ended(run, file, { code: 1, note: (session && session.note) || 'the run could not start as a background run', result: {} }, now);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const cur = active.get(run);
|
|
120
|
+
if (cur) cur.session = session.id;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function tick({ now = Date.now(), env } = {}) {
|
|
124
|
+
if (off(env || deps.env)) return { off: true };
|
|
125
|
+
const summary = { closed: 0, started: 0 };
|
|
126
|
+
const settings = deps.settings();
|
|
127
|
+
const home = deps.homeId ? deps.homeId() : null;
|
|
128
|
+
for (const w of deps.workspaces()) {
|
|
129
|
+
let lead = false;
|
|
130
|
+
try { lead = !!deps.isLead(w) && (!home || w.id === home); } catch { lead = false; }
|
|
131
|
+
if (!lead) continue;
|
|
132
|
+
let file = null;
|
|
133
|
+
try { file = RS.ledgerFile(w.root); } catch { file = null; }
|
|
134
|
+
if (!file) continue;
|
|
135
|
+
try {
|
|
136
|
+
let state = RS.fold(RS.readLines(file));
|
|
137
|
+
// A run that was alive when the server stopped is over.
|
|
138
|
+
for (const f of state.finds) {
|
|
139
|
+
if (f.end || active.has(f.run)) continue;
|
|
140
|
+
RS.findEnd(file, { run: f.run, ok: false, error: 'interrupted: the workspace restarted', now });
|
|
141
|
+
summary.closed++;
|
|
142
|
+
}
|
|
143
|
+
if (deps.headlessOff()) continue; // the finder never runs in a pane
|
|
144
|
+
if (active.size) continue;
|
|
145
|
+
state = RS.fold(RS.readLines(file));
|
|
146
|
+
const open = driverRun.dayCheckOpen(settings, now, deps.inQuiet);
|
|
147
|
+
if (dueNow(state, now, { open }).ok) { start(w, file, state, now); summary.started++; break; }
|
|
148
|
+
} catch (e) { warn('report find tick', e); }
|
|
149
|
+
}
|
|
150
|
+
return summary;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { tick, active: () => [...active.keys()] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { create, off, weekStart, dueNow, nextRun, findPrompt, sourcesOn, SLOT };
|
|
@@ -216,4 +216,4 @@ function sig(w) {
|
|
|
216
216
|
return [reportsOf(w.root).sig, schedulesStamp(), kill, hour].join(':');
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, readSchedules, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
|
|
219
|
+
module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, frontmatter, readSchedules, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
|