create-pmos 0.241.0 → 0.243.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 +2 -2
- 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/payload/state/log.md +1 -1
- package/workspace/c3-report-design-real-test.js +13 -2
- package/workspace/c3-report-design-test.js +25 -3
- package/workspace/c3-report-structure-real-test.js +38 -1
- package/workspace/c3-report-structure-test.js +21 -6
- package/workspace/guide/console-calendar.md +15 -1
- package/workspace/guide/notes.md +26 -1
- package/workspace/guide/report-structure.md +3 -3
- package/workspace/guide/reports-page.md +3 -1
- package/workspace/guide/reports-set-up.md +5 -1
- package/workspace/lib/headless.js +7 -2
- package/workspace/lib/meeting-material.js +151 -0
- package/workspace/lib/meeting-replay.js +184 -0
- package/workspace/lib/notes-screen.js +128 -8
- package/workspace/lib/report-structure.js +49 -5
- package/workspace/lib/route-modules.js +2 -0
- package/workspace/lib/routes-meeting-focus.js +5 -3
- package/workspace/lib/routes-meeting-material.js +143 -0
- package/workspace/lib/routes-meeting-replay.js +75 -0
- package/workspace/lib/routes-notes.js +37 -4
- package/workspace/lib/routes-report-setups.js +16 -2
- package/workspace/lib/routes-report-structure.js +176 -5
- package/workspace/lib/routes-reports.js +10 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-audience-link.js +21 -0
- package/workspace/public/pm/c3-audience.js +46 -0
- package/workspace/public/pm/c3-boot.js +2 -1
- package/workspace/public/pm/c3-calendar.js +15 -3
- package/workspace/public/pm/c3-demo-material.js +58 -0
- package/workspace/public/pm/c3-demo.js +5 -1
- package/workspace/public/pm/c3-focus.js +83 -16
- package/workspace/public/pm/c3-listen.css +10 -0
- package/workspace/public/pm/c3-material.css +22 -0
- package/workspace/public/pm/c3-material.js +321 -0
- package/workspace/public/pm/c3-notes.js +12 -4
- package/workspace/public/pm/c3-replay.css +133 -0
- package/workspace/public/pm/c3-replay.js +429 -0
- package/workspace/public/pm/c3-report-deck.css +5 -0
- package/workspace/public/pm/c3-report-deck.js +41 -5
- package/workspace/public/pm/c3-report-setup.css +5 -0
- package/workspace/public/pm/c3-report-setup.js +20 -2
- package/workspace/public/pm/c3-report-store.js +24 -2
- package/workspace/public/pm/c3-report-structure.css +4 -0
- package/workspace/public/pm/c3-report-structure.js +26 -8
- package/workspace/public/pm/c3-shell.js +2 -0
- package/workspace/public/pm/dest-notes.js +4 -2
- package/workspace/public/pm/dest-reports.js +10 -1
- package/workspace/public/pm/listen-sheet.js +94 -0
- package/workspace/public/pm/listen-sources.js +82 -0
- package/workspace/public/pm/notes-aside.js +62 -0
- package/workspace/public/pm/notes-screen-capture.js +23 -10
- package/workspace/public/pm/notes-screen-view.js +18 -5
- package/workspace/public/pm/notes-shots.js +55 -0
- package/workspace/public/pm/report-view.js +23 -1
- package/workspace/public/pm/reports-copy.js +5 -0
- package/workspace/public/pm/shell-recording.js +10 -2
- package/workspace/public/pm/view-notes.js +48 -8
- package/workspace/server.js +8 -1
- package/workspace/skills/report-structure-pull.skill +45 -0
- package/workspace/test-lists/meeting-material.json +6 -0
- package/workspace/test-lists/meeting-replay.json +6 -0
- package/workspace/test-lists/mm-capture.json +15 -0
- package/workspace/test-lists/report-structure-gaps.json +5 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/meeting-material.js — the material the PM linked to a meeting (initiative meeting-material-replay, part 1).
|
|
3
|
+
*
|
|
4
|
+
* keyOf(event) → 's:<series>' or 't:<normalized title>' (calendar-memory.js seriesKey), as lib/calendar-links.js
|
|
5
|
+
* append(runtimeDir, body, by) → { ok, items } | { ok: false, error } op 'add' (the default) or 'remove'
|
|
6
|
+
* forMeeting(runtimeDir, event) → [item] the series items of the meeting and the items for this one meeting, in the order they were added
|
|
7
|
+
* read(runtimeDir) → Map<identity, row> the rows that hold now (a removed item is gone)
|
|
8
|
+
* check(body) → null | the reason the body is refused
|
|
9
|
+
* classifyUrl(url) → { kind: slides|gdoc|conf|link, url } | null
|
|
10
|
+
*
|
|
11
|
+
* The calendar is read-only (PMOS never edits an invite), so the material lives beside it: <runtime>/meeting-material.jsonl, append-only.
|
|
12
|
+
* A row: { at, by, key, series, event?, scope: 'meeting'|'series', item: { kind, ref?, title, url?, ws? }, topic?, present, removed? }.
|
|
13
|
+
* An item is named by its key, its event ("this meeting"; empty for the series) and its kind and ref or url. The last row of a name wins,
|
|
14
|
+
* and a row with removed: true takes the item off. Nothing is ever rewritten.
|
|
15
|
+
*
|
|
16
|
+
* Kinds. PMOS items name a file of the repository by ref: pmos-doc (a document), pmos-deck (a report cycle that is a slide deck), report
|
|
17
|
+
* (any other report cycle). Links name an address: slides, gdoc, conf, link. The server decides a link's kind from its address. */
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const calMem = require('./calendar-memory.js');
|
|
21
|
+
|
|
22
|
+
const FILE = 'meeting-material.jsonl';
|
|
23
|
+
const PMOS_KINDS = ['pmos-deck', 'pmos-doc', 'report'];
|
|
24
|
+
const LINK_KINDS = ['slides', 'gdoc', 'conf', 'link'];
|
|
25
|
+
const KINDS = [...PMOS_KINDS, ...LINK_KINDS];
|
|
26
|
+
const SCOPES = ['meeting', 'series'];
|
|
27
|
+
const OPS = ['add', 'remove'];
|
|
28
|
+
const MAX_ITEMS = 60;
|
|
29
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
30
|
+
|
|
31
|
+
const keyOf = (e) => calMem.seriesKey({ series: e && e.series ? String(e.series) : null, title: e && e.title });
|
|
32
|
+
const fileOf = (runtimeDir) => path.join(runtimeDir, FILE);
|
|
33
|
+
|
|
34
|
+
/** What an address is. Only https addresses are kept; the server and the page agree on the kind. */
|
|
35
|
+
function classifyUrl(raw) {
|
|
36
|
+
const s = str(raw);
|
|
37
|
+
if (!s || s.length > 1000 || /\s/.test(s)) return null;
|
|
38
|
+
let u; try { u = new URL(/^[a-z][a-z0-9+.-]*:/i.test(s) ? s : 'https://' + s); } catch { return null; }
|
|
39
|
+
if (u.protocol !== 'https:' || !u.hostname.includes('.')) return null;
|
|
40
|
+
if (u.hostname === 'docs.google.com' && /^\/presentation\/d\//.test(u.pathname)) return { kind: 'slides', url: u.href };
|
|
41
|
+
if (u.hostname === 'docs.google.com' && /^\/document\/d\//.test(u.pathname)) return { kind: 'gdoc', url: u.href };
|
|
42
|
+
if (/\.atlassian\.net$/.test(u.hostname) && u.pathname.startsWith('/wiki')) return { kind: 'conf', url: u.href };
|
|
43
|
+
return { kind: 'link', url: u.href };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A repository-relative file path: no leading slash, no "..", no backslash, no control character. */
|
|
47
|
+
const refOk = (r) => typeof r === 'string' && r.length > 0 && r.length <= 300 && !r.startsWith('/') && !r.includes('\\') && !/(^|\/)\.\.(\/|$)/.test(r) && !/[\u0000-\u001f]/.test(r);
|
|
48
|
+
|
|
49
|
+
/** The name of an item inside one meeting or series. */
|
|
50
|
+
const idOf = (item) => item.kind + '|' + (PMOS_KINDS.includes(item.kind) ? item.ref : item.url);
|
|
51
|
+
const identity = (row) => [row.key, row.scope === 'series' ? '' : row.event || '', idOf(row.item)].join('\u0000');
|
|
52
|
+
|
|
53
|
+
/** The reason a POST body is refused, or null.
|
|
54
|
+
* body: { op?: 'add'|'remove', event: { id?, title, series? }, scope: 'meeting'|'series', item: { kind, ref | url, title?, ws? }, topic?, present? } */
|
|
55
|
+
function check(body) {
|
|
56
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'the body must be a JSON object';
|
|
57
|
+
if (body.op != null && !OPS.includes(body.op)) return 'op is one of ' + OPS.join(', ');
|
|
58
|
+
const e = body.event;
|
|
59
|
+
if (!e || typeof e !== 'object' || !str(e.title) || str(e.title).length > 300) return 'event.title is required (one line, at most 300 characters)';
|
|
60
|
+
if (e.series != null && (typeof e.series !== 'string' || e.series.length > 300)) return 'event.series must be text';
|
|
61
|
+
if (e.id != null && (typeof e.id !== 'string' || e.id.length > 120)) return 'event.id must be text';
|
|
62
|
+
const scope = body.scope == null ? 'meeting' : body.scope;
|
|
63
|
+
if (!SCOPES.includes(scope)) return 'scope is one of ' + SCOPES.join(', ');
|
|
64
|
+
if (scope === 'meeting' && !str(e.id)) return 'a link for one meeting needs event.id';
|
|
65
|
+
const it = body.item;
|
|
66
|
+
if (!it || typeof it !== 'object') return 'item is required';
|
|
67
|
+
if (PMOS_KINDS.includes(it.kind)) { if (!refOk(it.ref)) return 'a PMOS item needs ref, the path of the file in the repository'; }
|
|
68
|
+
else if (it.kind == null || LINK_KINDS.includes(it.kind)) { if (!classifyUrl(it.url)) return 'a link must be an https address'; }
|
|
69
|
+
else return 'item.kind is one of ' + KINDS.join(', ');
|
|
70
|
+
if (it.title != null && (typeof it.title !== 'string' || it.title.length > 300 || /[\r\n]/.test(it.title))) return 'item.title is one line of at most 300 characters';
|
|
71
|
+
if (it.ws != null && (typeof it.ws !== 'string' || it.ws.length > 120)) return 'item.ws must be text';
|
|
72
|
+
if (body.topic != null && (typeof body.topic !== 'string' || body.topic.length > 160 || /[\r\n]/.test(body.topic))) return 'topic is one line of at most 160 characters';
|
|
73
|
+
if (body.present != null && typeof body.present !== 'boolean') return 'present must be true or false';
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The item as it is kept: the kind a link has, its address in full, a title. */
|
|
78
|
+
function cleanItem(it) {
|
|
79
|
+
if (PMOS_KINDS.includes(it.kind)) return { kind: it.kind, ref: it.ref, title: str(it.title) || it.ref, ...(str(it.ws) ? { ws: str(it.ws) } : {}) };
|
|
80
|
+
const c = classifyUrl(it.url);
|
|
81
|
+
return { kind: c.kind, url: c.url, title: str(it.title) || c.url };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lines(runtimeDir) {
|
|
85
|
+
if (!runtimeDir) return [];
|
|
86
|
+
let text = '';
|
|
87
|
+
try { text = fs.readFileSync(fileOf(runtimeDir), 'utf8'); } catch { return []; }
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const l of text.split('\n')) {
|
|
90
|
+
if (!l.trim()) continue;
|
|
91
|
+
let x; try { x = JSON.parse(l); } catch { continue; }
|
|
92
|
+
if (!x || typeof x.key !== 'string' || !SCOPES.includes(x.scope) || !x.item || !KINDS.includes(x.item.kind)) continue;
|
|
93
|
+
if (PMOS_KINDS.includes(x.item.kind) ? !refOk(x.item.ref) : !x.item.url) continue;
|
|
94
|
+
out.push(x);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The rows that hold now, in the order their items were first added (a changed item keeps its place). */
|
|
100
|
+
function read(runtimeDir) {
|
|
101
|
+
const out = new Map();
|
|
102
|
+
for (const row of lines(runtimeDir)) {
|
|
103
|
+
const id = identity(row);
|
|
104
|
+
if (row.removed) { out.delete(id); continue; }
|
|
105
|
+
out.set(id, out.has(id) ? { ...row, firstAt: out.get(id).firstAt } : { ...row, firstAt: row.at || null });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const view = (row) => ({
|
|
111
|
+
id: crypto16(identity(row)), kind: row.item.kind, ref: row.item.ref || null, url: row.item.url || null, title: row.item.title || row.item.ref || row.item.url,
|
|
112
|
+
ws: row.item.ws || null, topic: row.topic || null, present: row.present === true, scope: row.scope, from: 'you', by: row.by || null, at: row.at || null,
|
|
113
|
+
});
|
|
114
|
+
/** A short stable id for a row, so the page can name an item without sending its path back and forth. */
|
|
115
|
+
function crypto16(s) { return require('crypto').createHash('sha1').update(s).digest('hex').slice(0, 12); }
|
|
116
|
+
|
|
117
|
+
/** The material of one meeting: its series items and its own, in the order they were added. event: { id?, title, series? } */
|
|
118
|
+
function forMeeting(runtimeDir, event) {
|
|
119
|
+
if (!event || !str(event.title)) return [];
|
|
120
|
+
const key = keyOf(event);
|
|
121
|
+
const id = str(event.id);
|
|
122
|
+
const mine = [];
|
|
123
|
+
for (const row of read(runtimeDir).values()) {
|
|
124
|
+
if (row.key !== key) continue;
|
|
125
|
+
if (row.scope === 'meeting' && !(id && row.event === id)) continue;
|
|
126
|
+
mine.push(row);
|
|
127
|
+
}
|
|
128
|
+
return mine.sort((a, b) => String(a.firstAt).localeCompare(String(b.firstAt))).map(view);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function append(runtimeDir, body, by) {
|
|
132
|
+
const bad = check(body);
|
|
133
|
+
if (bad) return { ok: false, error: bad };
|
|
134
|
+
if (!runtimeDir) return { ok: false, error: 'this server has no runtime folder, so the material cannot be saved' };
|
|
135
|
+
const op = body.op || 'add';
|
|
136
|
+
const scope = body.scope || 'meeting';
|
|
137
|
+
const item = cleanItem(body.item);
|
|
138
|
+
const line = { at: new Date().toISOString(), by: by || null, key: keyOf(body.event), series: body.event.series ? String(body.event.series) : null, event_title: str(body.event.title), scope, item };
|
|
139
|
+
if (scope === 'meeting') line.event = str(body.event.id);
|
|
140
|
+
if (op === 'remove') line.removed = true;
|
|
141
|
+
else {
|
|
142
|
+
if (str(body.topic)) line.topic = str(body.topic);
|
|
143
|
+
line.present = body.present === true && ['pmos-deck', 'slides', 'report'].includes(item.kind);
|
|
144
|
+
if (forMeeting(runtimeDir, body.event).length >= MAX_ITEMS && !read(runtimeDir).has(identity(line))) return { ok: false, error: 'a meeting holds at most ' + MAX_ITEMS + ' items' };
|
|
145
|
+
}
|
|
146
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
147
|
+
fs.appendFileSync(fileOf(runtimeDir), JSON.stringify(line) + '\n');
|
|
148
|
+
return { ok: true, items: forMeeting(runtimeDir, body.event) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { keyOf, read, forMeeting, append, check, classifyUrl, KINDS, PMOS_KINDS, LINK_KINDS, FILE };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* meeting-replay.js — the pure join behind the meeting replay page (initiative meeting-material-replay, part 2).
|
|
3
|
+
*
|
|
4
|
+
* build({ note, event?, stateLines?, frames?, audio?, topics?, now? }) → the replay
|
|
5
|
+
*
|
|
6
|
+
* The replay is Focus played back, so it joins three records PMOS already keeps and invents nothing:
|
|
7
|
+
* the note transcript lines (with who said them), decisions, next steps and questions, the audio's length
|
|
8
|
+
* the Focus file <runtime>/focus/<note or event id>.jsonl, folded by lib/meeting-focus.js: topics, the stretches of talk, PMOS's notes
|
|
9
|
+
* the shots lib/notes-screen.js frames; a frame with `aside` truthy was set aside (it looked like another app) and is NEVER in the replay
|
|
10
|
+
*
|
|
11
|
+
* Every time is MEETING time in ms: 0 is where the recording starts, the same clock the audio, the transcript and the shots use
|
|
12
|
+
* (the audio's currentTime * 1000). `start_ms` is the wall clock (epoch ms) of meeting time 0, so the page can show 11:18.
|
|
13
|
+
*
|
|
14
|
+
* A KEY POINT'S TIME is the time of the transcript line it was caught from: the item's own `t` (the final notes and the minute
|
|
15
|
+
* bullets keep it), else the line its quote (or, failing that, its text) is found in. When none is known `t` is null and the page
|
|
16
|
+
* shows no play button. Nothing here reads a file or starts a process; lib/routes-meeting-replay.js reads and calls build().
|
|
17
|
+
*
|
|
18
|
+
* Output:
|
|
19
|
+
* { ok, note: { id, title, state, initiative, ws }, meeting: { title, at, end, attendees },
|
|
20
|
+
* audio: { url, ms } | null, start_ms, length_ms,
|
|
21
|
+
* lines: [{ i, t, who, text, source, key: { k, id } | null }] by t
|
|
22
|
+
* keys: [{ id, k: 'decision'|'action'|'question', text, t, owner, due, by, state: 'draft'|'owner'|'open'|'answered', warn }]
|
|
23
|
+
* topics: [{ id, text, src, state, minutes, n, first_t, stretches, parked, by }] visited ones first, in the order they came up
|
|
24
|
+
* runs: [{ topic, n, from_ms, to_ms }]
|
|
25
|
+
* notes: [{ id, t, cat, body, heard, why, src }] PMOS's notes with the time they were made
|
|
26
|
+
* shots: [{ t, w, h, why?, text? }] kept shots only
|
|
27
|
+
* recordings: how many times the recording was started (1 for most notes)
|
|
28
|
+
* gaps: [{ from_ms, to_ms }] where the recording was off between two starts (empty when there was one)
|
|
29
|
+
* aside_count }
|
|
30
|
+
*/
|
|
31
|
+
const N = require('./notes.js');
|
|
32
|
+
const F = require('./meeting-focus.js');
|
|
33
|
+
|
|
34
|
+
const SHOT_TEXT_MAX = 400;
|
|
35
|
+
const NEAR_LINE_MS = 3000; // a key point whose time is not exactly a line's belongs to the line this close
|
|
36
|
+
const ANSWER_OVERLAP = 0.6;
|
|
37
|
+
|
|
38
|
+
const str = (v, n) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, n) : '');
|
|
39
|
+
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
40
|
+
const words = (t) => String(t == null ? '' : t).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').match(/[\p{L}\p{N}]+/gu) || [];
|
|
41
|
+
const share = (a, b) => {
|
|
42
|
+
const wa = words(a); const hb = new Set(words(b));
|
|
43
|
+
return wa.length ? wa.filter((w) => hb.has(w)).length / wa.length : 0;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The wall clock (epoch ms) of meeting time 0: a transcript line's `at` minus its `t`, else when recording began, else the meeting's start. */
|
|
47
|
+
function startOf(note, event) {
|
|
48
|
+
for (const x of (note && note.transcript) || []) {
|
|
49
|
+
const at = Date.parse(x && x.at);
|
|
50
|
+
if (x && finite(x.t) && Number.isFinite(at)) return at - x.t;
|
|
51
|
+
}
|
|
52
|
+
const rec = Date.parse(note && note.recorded_from);
|
|
53
|
+
if (Number.isFinite(rec)) return rec;
|
|
54
|
+
const ev = Date.parse(event && event.at);
|
|
55
|
+
return Number.isFinite(ev) ? ev : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The lines of the talk with a time: the final pass's segments when there are some, else the live lines; by t. */
|
|
59
|
+
function linesOf(note) {
|
|
60
|
+
let raw = [];
|
|
61
|
+
try { raw = N.transcriptLines(note); } catch { raw = []; }
|
|
62
|
+
return raw.filter((x) => x && finite(x.t) && typeof x.text === 'string' && x.text.trim())
|
|
63
|
+
.map((x) => ({ t: Math.round(x.t), who: str(x.who, 60), text: str(x.text, 600), source: x.source || 'speech' }))
|
|
64
|
+
.sort((a, b) => a.t - b.t)
|
|
65
|
+
.map((x, i) => ({ i, ...x, key: null }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The decisions, next steps and questions of the note, as the Notes page shows them: the final notes when written, else the minute
|
|
69
|
+
* bullets, else the local summary. → { decision: [item], action: [item], question: [item] }, item = { text, quote?, t?, owner?, due?, by? } */
|
|
70
|
+
function caughtOf(note) {
|
|
71
|
+
const out = { decision: [], action: [], question: [] };
|
|
72
|
+
const f = note && note.final_notes;
|
|
73
|
+
const fromList = (kind, list) => {
|
|
74
|
+
for (const x of Array.isArray(list) ? list : []) {
|
|
75
|
+
const text = str(x && x.text, 300);
|
|
76
|
+
if (text) out[kind].push({ text, quote: str(x.quote, 400), t: finite(x.t) ? x.t : null, owner: str(x.owner, 60) || null, due: str(x.due, 40) || null, by: str(x.by, 120) || null });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
if (f && ((f.decisions || []).length || (f.actions || []).length || (f.questions || []).length)) {
|
|
80
|
+
fromList('decision', f.decisions); fromList('action', f.actions); fromList('question', f.questions);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
const bl = ((note && note.bullets) || []).filter((b) => b && b.text && !b.dropped && !b.open);
|
|
84
|
+
if (bl.length) {
|
|
85
|
+
for (const b of bl) if (out[b.cat]) out[b.cat].push({ text: str(b.text, 300), quote: '', t: finite(b.t) ? b.t : null, owner: str(b.owner, 60) || null, due: str(b.due, 40) || null, by: null });
|
|
86
|
+
if (out.decision.length || out.action.length || out.question.length) return out;
|
|
87
|
+
}
|
|
88
|
+
const sec = (note && note.summary && note.summary.sections) || {};
|
|
89
|
+
const at = (x) => { const m = /^\s*\[?(\d+:\d{2}(?::\d{2})?)\]?\s*$/.exec(String((x && x.at) || '')); return m ? N.parseTime(m[1]) : null; };
|
|
90
|
+
const fromSec = (kind, list) => { for (const x of Array.isArray(list) ? list : []) { const text = str(x && x.text, 300); if (text) out[kind].push({ text, quote: '', t: at(x), owner: str(x.owner, 60) || null, due: str(x.due, 40) || null, by: null }); } };
|
|
91
|
+
fromSec('decision', sec.decisions); fromSec('action', sec.actions); fromSec('question', sec.questions);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The time of the line a key point was caught from: its own t, else the line its quote (or its text) is in; null when unknown. */
|
|
96
|
+
function timeOfKey(item, lines) {
|
|
97
|
+
if (finite(item.t)) return Math.max(0, Math.round(item.t));
|
|
98
|
+
const timed = lines.map((l) => ({ t: l.t, text: l.text }));
|
|
99
|
+
for (const probe of [item.quote, item.text]) {
|
|
100
|
+
if (!probe) continue;
|
|
101
|
+
let at = null;
|
|
102
|
+
try { at = N.groundQuote(timed, probe, null); } catch { at = null; }
|
|
103
|
+
if (finite(at)) return Math.max(0, Math.round(at));
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Join the three records. */
|
|
109
|
+
function build({ note, event = null, stateLines = [], frames = [], audio = null, topics = [], now = Date.now() } = {}) {
|
|
110
|
+
const n = note || {};
|
|
111
|
+
const lines = linesOf(n);
|
|
112
|
+
const start = startOf(n, event);
|
|
113
|
+
const shots = (frames || []).filter((f) => f && finite(f.t) && !f.aside).sort((a, b) => a.t - b.t)
|
|
114
|
+
.map((f) => ({ t: f.t, w: f.w || 0, h: f.h || 0, ...(f.why ? { why: f.why } : {}), ...(f.text ? { text: str(f.text, SHOT_TEXT_MAX) } : {}) }));
|
|
115
|
+
const asideCount = (frames || []).filter((f) => f && f.aside).length;
|
|
116
|
+
|
|
117
|
+
// Focus's state: topics, stretches, PMOS's notes. Stretches are measured from meeting time 0.
|
|
118
|
+
const fold = F.foldState(stateLines || [], { topics, startMs: start, nowMs: start == null ? now : start + 0 });
|
|
119
|
+
const pmos = start == null ? [] : fold.notes.filter((x) => x && x.body && finite(x.at)).map((x) => ({ id: x.id, t: Math.max(0, Math.round(x.at - start)), cat: str(x.cat, 30), body: str(x.body, 700), heard: str(x.heard, 300), why: str(x.why, 300), src: str(x.src, 80) }))
|
|
120
|
+
.sort((a, b) => a.t - b.t);
|
|
121
|
+
|
|
122
|
+
// length: the audio, the clock of the note, else the last thing that has a time
|
|
123
|
+
let heard = 0;
|
|
124
|
+
if (n.ended_at || n.paused_at) { try { heard = N.heardMs(n, now) || 0; } catch { heard = 0; } } // a note still recording has no end to trust
|
|
125
|
+
const caught = caughtOf(n);
|
|
126
|
+
const keys = [];
|
|
127
|
+
for (const [kind, list] of [['decision', caught.decision], ['action', caught.action], ['question', caught.question]]) {
|
|
128
|
+
list.forEach((it, i) => {
|
|
129
|
+
const t = timeOfKey(it, lines);
|
|
130
|
+
const answered = kind === 'question' && t != null && pmos.some((p) => p.cat === 'Answer' && p.t >= t && share(it.text, p.heard || p.body) >= ANSWER_OVERLAP);
|
|
131
|
+
const state = kind === 'action' ? (it.owner ? 'draft' : 'owner') : kind === 'question' ? (answered ? 'answered' : 'open') : 'draft';
|
|
132
|
+
keys.push({ id: kind[0] + (i + 1), k: kind, text: it.text, t, owner: it.owner, due: it.due, by: it.by, state, warn: state === 'owner' || state === 'open' });
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
keys.sort((a, b) => (a.t == null) - (b.t == null) || (a.t || 0) - (b.t || 0));
|
|
136
|
+
// the line a key point was caught from carries its mark
|
|
137
|
+
for (const k of keys) {
|
|
138
|
+
if (k.t == null || !lines.length) continue;
|
|
139
|
+
let best = null;
|
|
140
|
+
for (const l of lines) if (!l.key && (best === null || Math.abs(l.t - k.t) < Math.abs(best.t - k.t))) best = l;
|
|
141
|
+
if (best && Math.abs(best.t - k.t) <= NEAR_LINE_MS) best.key = { k: k.k, id: k.id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const lastT = (xs) => xs.reduce((m, x) => (finite(x.t) && x.t > m ? x.t : m), 0);
|
|
145
|
+
const length = Math.max(finite(audio && audio.ms) ? audio.ms : 0, heard, lastT(lines), lastT(shots), lastT(keys), lastT(pmos));
|
|
146
|
+
|
|
147
|
+
// stretches, in ms; a stretch still open runs to the end
|
|
148
|
+
const runs = fold.runs.map((r) => ({ topic: r.topic, from_ms: Math.round(r.from_min * 60000), to_ms: r.to_min == null ? length : Math.round(r.to_min * 60000) }))
|
|
149
|
+
.filter((r) => r.to_ms > r.from_ms || r.to_ms === length);
|
|
150
|
+
const firstOf = new Map();
|
|
151
|
+
for (const r of runs) if (!firstOf.has(r.topic)) firstOf.set(r.topic, r.from_ms);
|
|
152
|
+
const visited = fold.topics.filter((t) => firstOf.has(t.id)).sort((a, b) => firstOf.get(a.id) - firstOf.get(b.id));
|
|
153
|
+
const numOf = new Map(visited.map((t, i) => [t.id, i + 1]));
|
|
154
|
+
const stretches = (id) => runs.filter((r) => r.topic === id).length;
|
|
155
|
+
const minutesOf = (id) => Math.round(runs.filter((r) => r.topic === id).reduce((s, r) => s + (r.to_ms - r.from_ms), 0) / 60000);
|
|
156
|
+
const topicRows = [...visited, ...fold.topics.filter((t) => !firstOf.has(t.id))].map((t) => ({
|
|
157
|
+
id: t.id, text: t.text, src: t.src, state: t.state, minutes: minutesOf(t.id), n: numOf.get(t.id) || null, first_t: firstOf.has(t.id) ? firstOf.get(t.id) : null,
|
|
158
|
+
stretches: stretches(t.id), parked: t.state === 'parked', by: t.by || null }));
|
|
159
|
+
// A recording that was stopped and started again (a Pause then Listen, or a new Listen on the same meeting) has one stretch per start. The audio keeps one clock
|
|
160
|
+
// (lib/notes-audio.js places a late chunk at its own time and leaves the gap silent), so the replay needs only to show where the recording was off.
|
|
161
|
+
const sessions = (Array.isArray(n.sessions) ? n.sessions : []).filter((x) => x && Number.isFinite(Date.parse(x.from))).sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
|
|
162
|
+
const gaps = [];
|
|
163
|
+
if (start != null) {
|
|
164
|
+
for (let i = 1; i < sessions.length; i++) {
|
|
165
|
+
const off = Date.parse(sessions[i - 1].to); const on = Date.parse(sessions[i].from);
|
|
166
|
+
if (!Number.isFinite(off) || !Number.isFinite(on) || on <= off) continue;
|
|
167
|
+
const from_ms = Math.max(0, Math.round(off - start)); const to_ms = Math.min(length, Math.round(on - start));
|
|
168
|
+
if (to_ms > from_ms) gaps.push({ from_ms, to_ms });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const ev = event || {};
|
|
172
|
+
const m = n.meeting || {};
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
note: { id: n.id, title: n.title || m.title || ev.title || 'Untitled note', state: n.state || null, initiative: n.initiative || null, ws: n.ws || (n.initiative && n.initiative.ws) || null },
|
|
176
|
+
meeting: { title: ev.title || m.title || n.title || 'Untitled note', at: ev.at || m.start || n.recorded_from || n.created_at || null, end: ev.end || m.end || null,
|
|
177
|
+
attendees: [].concat(ev.attendees || ev.people || []).map((p) => (p && typeof p === 'object' ? p.name || p.id : p)).filter(Boolean).map(String) },
|
|
178
|
+
audio: audio && audio.url ? { url: audio.url, ms: finite(audio.ms) ? audio.ms : null } : null,
|
|
179
|
+
start_ms: start, length_ms: length, recordings: Math.max(1, sessions.length), gaps,
|
|
180
|
+
lines, keys, topics: topicRows, runs: runs.map((r) => ({ ...r, n: numOf.get(r.topic) || null })), notes: pmos, shots, aside_count: asideCount,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { build, caughtOf, timeOfKey, linesOf, startOf };
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* getOn(runtimeDir) · setOn(runtimeDir, bool) "Capture the shared screen", on by default, for this machine
|
|
9
9
|
* ocrDetect({ env? }) → { bin } | null the tesseract the PM installed
|
|
10
10
|
* ocrIdle() → Promise resolves when the text queue is empty (tests)
|
|
11
|
+
* classifyShot(text, { hints? }) → { aside, app, call } does the text of a shot look like another app and not like the call? Pure.
|
|
12
|
+
* listAside(runtimeDir, id) · asideCount(runtimeDir, id) the shots set aside for review
|
|
13
|
+
* keep(runtimeDir, id, t) · discard(runtimeDir, id, t) · discardAll(runtimeDir, id) the PM's answer to a set-aside shot
|
|
14
|
+
* sweepAside(runtimeDir, { now?, days? }) → n delete the shots set aside for more than 7 days
|
|
11
15
|
*
|
|
12
16
|
* WHAT IT KEEPS. A folder next to the note's audio, `<runtime>/notes/recordings/<id>.screen/`: one image file per shot, named by
|
|
13
17
|
* its meeting time in milliseconds (`000123456.jpg`), and `index.json`, an array of { t, file, w, h, bytes, why?, text? } sorted by t,
|
|
@@ -22,6 +26,13 @@
|
|
|
22
26
|
* kept shot is read for its text, one at a time and in the background, and the text is added to that shot's row in the index.
|
|
23
27
|
* A failed or timed-out read leaves the shot with no text and is not an error. Without tesseract nothing is read.
|
|
24
28
|
*
|
|
29
|
+
* SET ASIDE. When the picture is the whole screen (the shot's `surface` is 'monitor'), every app the PM switches to is on it. After the
|
|
30
|
+
* text is read, a shot whose text looks like another app (Slack, a mailbox, a chat) and not like the call (Zoom's or Meet's controls) or
|
|
31
|
+
* what is shared in it is marked `aside: true` in the index. A set-aside shot is not listed and not served by default, so the note, the
|
|
32
|
+
* replay and the agent never see it. The PM reviews them (listAside) and answers each one: Keep it (the shot joins the note), Delete
|
|
33
|
+
* (file and row go) or Delete all. sweepAside deletes the shots set aside for more than 7 days. A shot with no text, a shot from a
|
|
34
|
+
* window or a tab, and every shot on a machine with no tesseract are kept, as before.
|
|
35
|
+
*
|
|
25
36
|
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The only program it runs is the tesseract the PM installed,
|
|
26
37
|
* on a file in the note's own folder, with no shell and a time limit. The routes (lib/routes-notes.js) keep no shot for a private
|
|
27
38
|
* note, for a note that is not listening, or when the PM switched capturing off, and delete the folder when a note is made
|
|
@@ -37,6 +48,8 @@ const MAX_BYTES = 500 * 1024 * 1024;
|
|
|
37
48
|
const MAX_T = 999999999; // the file name holds nine digits of milliseconds
|
|
38
49
|
const OCR_TIMEOUT_MS = 30000;
|
|
39
50
|
const OCR_MAX_CHARS = 4000;
|
|
51
|
+
const ASIDE_DAYS = 7;
|
|
52
|
+
const SURFACES = ['window', 'browser', 'monitor'];
|
|
40
53
|
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
41
54
|
const FILE_RE = /^\d{9}\.(jpg|png|webp)$/;
|
|
42
55
|
const TYPES = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
|
|
@@ -80,7 +93,7 @@ const writeIndex = (runtimeDir, id, rows) => writeAtomic(indexOf(runtimeDir, id)
|
|
|
80
93
|
const dimension = (n) => (Number.isFinite(Number(n)) && Number(n) > 0 ? Math.min(20000, Math.round(Number(n))) : 0);
|
|
81
94
|
|
|
82
95
|
/** Keep one shot. `t` is the meeting time in ms, `buf` the image bytes, `w` and `h` its size in pixels. */
|
|
83
|
-
function add(runtimeDir, { id, t, buf, w, h, why } = {}) {
|
|
96
|
+
function add(runtimeDir, { id, t, buf, w, h, why, surface } = {}) {
|
|
84
97
|
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
85
98
|
const time = typeof t === 'number' ? t : NaN;
|
|
86
99
|
if (!Number.isFinite(time) || time < 0 || time > MAX_T) return { ok: false, status: 400, error: 'a screenshot needs its meeting time' };
|
|
@@ -106,27 +119,36 @@ function add(runtimeDir, { id, t, buf, w, h, why } = {}) {
|
|
|
106
119
|
// why the page kept it (the screen changed, a line pointed at it, a highlight): plain words, kept short
|
|
107
120
|
const reason = typeof why === 'string' ? why.replace(/\s+/g, ' ').trim().slice(0, 120) : '';
|
|
108
121
|
if (reason) frame.why = reason;
|
|
122
|
+
// what the picture shows: the meeting's window, a browser tab or the whole screen; only the whole screen is checked for other apps
|
|
123
|
+
if (SURFACES.includes(surface)) frame.surface = surface;
|
|
109
124
|
writeIndex(runtimeDir, id, others.concat([frame]).sort((x, y) => x.t - y.t));
|
|
110
125
|
queueOcr(runtimeDir, id, frame);
|
|
111
126
|
return { ok: true, frame };
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
|
|
129
|
+
/** The note's shots, by meeting time. A set-aside shot is not in this list. */
|
|
130
|
+
function list(runtimeDir, id, { aside = false } = {}) {
|
|
131
|
+
if (!ID_RE.test(String(id || ''))) return [];
|
|
132
|
+
return readIndex(runtimeDir, id).filter((f) => !!f.aside === !!aside); // { aside: true } lists the set-aside shots instead
|
|
133
|
+
}
|
|
134
|
+
/** The shots set aside for review, by meeting time. */
|
|
135
|
+
function listAside(runtimeDir, id) {
|
|
115
136
|
if (!ID_RE.test(String(id || ''))) return [];
|
|
116
|
-
return readIndex(runtimeDir, id);
|
|
137
|
+
return readIndex(runtimeDir, id).filter((f) => f.aside);
|
|
117
138
|
}
|
|
139
|
+
const asideCount = (runtimeDir, id) => listAside(runtimeDir, id).length;
|
|
118
140
|
|
|
119
141
|
/** The shot at meeting time `t` as its image. A shot is never changed once kept, so the browser may keep it for good. */
|
|
120
|
-
function serve(req, res, runtimeDir, id, t) {
|
|
142
|
+
function serve(req, res, runtimeDir, id, t, { aside = false } = {}) {
|
|
121
143
|
const miss = () => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no screenshot at that time' })); };
|
|
122
144
|
if (!ID_RE.test(String(id || '')) || t === null || t === undefined || t === '' || !Number.isFinite(Number(t))) return miss();
|
|
123
145
|
const at = Math.round(Number(t));
|
|
124
|
-
const f = readIndex(runtimeDir, id).find((x) => x.t === at);
|
|
146
|
+
const f = readIndex(runtimeDir, id).find((x) => x.t === at && !!x.aside === !!aside); // a set-aside shot is served only for the review
|
|
125
147
|
if (!f) return miss();
|
|
126
148
|
const file = path.join(dirOf(runtimeDir, id), f.file);
|
|
127
149
|
let size;
|
|
128
150
|
try { size = fs.statSync(file).size; } catch { return miss(); }
|
|
129
|
-
res.writeHead(200, { 'Content-Type': TYPES[path.extname(f.file).slice(1)] || 'application/octet-stream', 'Content-Length': size, 'Cache-Control': 'private, max-age=31536000, immutable' });
|
|
151
|
+
res.writeHead(200, { 'Content-Type': TYPES[path.extname(f.file).slice(1)] || 'application/octet-stream', 'Content-Length': size, 'Cache-Control': aside ? 'private, no-store' : 'private, max-age=31536000, immutable' });
|
|
130
152
|
if (req.method === 'HEAD') { res.end(); return; }
|
|
131
153
|
fs.createReadStream(file).on('error', () => res.end()).pipe(res);
|
|
132
154
|
}
|
|
@@ -140,6 +162,95 @@ function remove(runtimeDir, id) {
|
|
|
140
162
|
return had;
|
|
141
163
|
}
|
|
142
164
|
|
|
165
|
+
/* ── set aside: the PM's answer ──────────────────────────────────────────────────────────────────── */
|
|
166
|
+
const rmFile = (runtimeDir, id, f) => { try { fs.unlinkSync(path.join(dirOf(runtimeDir, id), f.file)); } catch { /* none */ } };
|
|
167
|
+
const asideRow = (rows, t) => rows.find((f) => f.t === Math.round(Number(t)) && f.aside);
|
|
168
|
+
/** Keep it: the shot joins the note, and it is not checked again. */
|
|
169
|
+
function keep(runtimeDir, id, t) {
|
|
170
|
+
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
171
|
+
const rows = readIndex(runtimeDir, id);
|
|
172
|
+
const row = asideRow(rows, t);
|
|
173
|
+
if (!row) return { ok: false, status: 404, error: 'that shot is not set aside' };
|
|
174
|
+
delete row.aside; delete row.asideApp; delete row.asideAt;
|
|
175
|
+
row.kept = true;
|
|
176
|
+
writeIndex(runtimeDir, id, rows);
|
|
177
|
+
return { ok: true, frame: row };
|
|
178
|
+
}
|
|
179
|
+
/** Delete: the file and its row go. Only a set-aside shot can be deleted here. */
|
|
180
|
+
function discard(runtimeDir, id, t) {
|
|
181
|
+
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
182
|
+
const rows = readIndex(runtimeDir, id);
|
|
183
|
+
const row = asideRow(rows, t);
|
|
184
|
+
if (!row) return { ok: false, status: 404, error: 'that shot is not set aside' };
|
|
185
|
+
rmFile(runtimeDir, id, row);
|
|
186
|
+
writeIndex(runtimeDir, id, rows.filter((f) => f !== row));
|
|
187
|
+
return { ok: true, deleted: 1 };
|
|
188
|
+
}
|
|
189
|
+
/** Delete all: every set-aside shot of the note. The kept shots stay. */
|
|
190
|
+
function discardAll(runtimeDir, id) {
|
|
191
|
+
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
192
|
+
const rows = readIndex(runtimeDir, id);
|
|
193
|
+
const gone = rows.filter((f) => f.aside);
|
|
194
|
+
if (!gone.length) return { ok: true, deleted: 0 };
|
|
195
|
+
for (const f of gone) rmFile(runtimeDir, id, f);
|
|
196
|
+
writeIndex(runtimeDir, id, rows.filter((f) => !f.aside));
|
|
197
|
+
return { ok: true, deleted: gone.length };
|
|
198
|
+
}
|
|
199
|
+
/** Delete every shot set aside for more than `days` days, in every note. Returns how many went. */
|
|
200
|
+
function sweepAside(runtimeDir, { now = Date.now(), days = ASIDE_DAYS } = {}) {
|
|
201
|
+
let names = [];
|
|
202
|
+
try { names = fs.readdirSync(path.join(runtimeDir, 'notes', 'recordings')); } catch { return 0; }
|
|
203
|
+
let n = 0;
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
if (!name.endsWith('.screen')) continue;
|
|
206
|
+
const id = name.slice(0, -'.screen'.length);
|
|
207
|
+
if (!ID_RE.test(id)) continue;
|
|
208
|
+
const rows = readIndex(runtimeDir, id);
|
|
209
|
+
if (!rows.some((f) => f.aside)) continue;
|
|
210
|
+
const left = rows.filter((f) => {
|
|
211
|
+
if (!f.aside || !Number.isFinite(f.asideAt) || now - f.asideAt < days * 864e5) return true;
|
|
212
|
+
rmFile(runtimeDir, id, f); n++; return false;
|
|
213
|
+
});
|
|
214
|
+
if (left.length !== rows.length) { try { writeIndex(runtimeDir, id, left); } catch { /* the folder went away */ } }
|
|
215
|
+
}
|
|
216
|
+
return n;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ── what looks like another app ─────────────────────────────────────────────────────────────────── */
|
|
220
|
+
// The call: the controls of Zoom, Meet and Teams, and what each says while someone shares. Any one of these keeps the shot.
|
|
221
|
+
const CALL = /\b(unmute|mute|stop video|start video|participants?|share screen|stop share(?:ing)?|new share|you are screen sharing|you are viewing|you'?re presenting|stop presenting|present now|presenting|raise hand|lower hand|breakout rooms?|leave meeting|leave call|end meeting|meeting controls|zoom meeting|zoom workplace|meet\.google\.com|google meet|turn (?:on|off) camera|turn (?:on|off) microphone|live captions|waiting room|in this meeting|hide self view|gallery view|speaker view)\b/i;
|
|
222
|
+
// Another app. `strong`: one is enough. `weak`: two different ones are needed.
|
|
223
|
+
const OTHER = [
|
|
224
|
+
{ app: 'Slack', strong: [/\bslack\b/i], weak: [/\bdirect messages\b/i, /\bchannels\b/i, /\bthreads\b/i, /\bhuddles?\b/i, /\bmentions (?:&|and) reactions\b/i, /\bmessage #[a-z0-9_-]+/i] },
|
|
225
|
+
{ app: 'Gmail', strong: [/\bgmail\b/i, /\binbox\b/i], weak: [/\bcompose\b/i, /\bstarred\b/i, /\bsnoozed\b/i, /\bsent mail\b/i, /\bdrafts\b/i, /\bprimary\b/i, /\bpromotions\b/i] },
|
|
226
|
+
{ app: 'Outlook', strong: [/\boutlook\b/i], weak: [/\bnew mail\b/i, /\bjunk email\b/i, /\bsent items\b/i, /\bfocused\b/i] },
|
|
227
|
+
{ app: 'WhatsApp', strong: [/\bwhatsapp\b/i], weak: [] },
|
|
228
|
+
{ app: 'Telegram', strong: [/\btelegram\b/i], weak: [] },
|
|
229
|
+
{ app: 'Discord', strong: [/\bdiscord\b/i], weak: [] },
|
|
230
|
+
{ app: 'Messages', strong: [/\bimessage\b/i, /\bmessenger\b/i], weak: [] },
|
|
231
|
+
{ app: 'Social media', strong: [/\bfacebook\b/i, /\binstagram\b/i, /\btwitter\b/i, /\blinkedin\b/i, /\btiktok\b/i], weak: [] },
|
|
232
|
+
{ app: 'Video or music', strong: [/\byoutube\b/i, /\bnetflix\b/i, /\bspotify\b/i], weak: [] },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
/** Does the text of a shot look like another app and not like the call? Pure.
|
|
236
|
+
* → { aside, app, call }. `hints` are words from what is shared in the meeting (a deck's title, say): a shot that names one is kept.
|
|
237
|
+
* Text that is empty, or that names no app, is never set aside. */
|
|
238
|
+
function classifyShot(text, { hints = [] } = {}) {
|
|
239
|
+
const t = String(text || '');
|
|
240
|
+
if (!t.trim()) return { aside: false, app: null, call: false };
|
|
241
|
+
if (CALL.test(t)) return { aside: false, app: null, call: true };
|
|
242
|
+
const low = t.toLowerCase();
|
|
243
|
+
for (const h of hints || []) { const w = String(h || '').trim().toLowerCase(); if (w.length >= 4 && low.includes(w)) return { aside: false, app: null, call: false }; }
|
|
244
|
+
for (const o of OTHER) {
|
|
245
|
+
if (o.strong.some((re) => re.test(t)) || o.weak.filter((re) => re.test(t)).length >= 2) return { aside: true, app: o.app, call: false };
|
|
246
|
+
}
|
|
247
|
+
return { aside: false, app: null, call: false };
|
|
248
|
+
}
|
|
249
|
+
// the words of what is shared in the meeting, for classifyShot: set by whoever knows them (the material of the meeting), else none
|
|
250
|
+
let hintsOf = null;
|
|
251
|
+
/** fn(runtimeDir, id) → [word]: what is shared in this note's meeting. Optional. */
|
|
252
|
+
function setHints(fn) { hintsOf = typeof fn === 'function' ? fn : null; }
|
|
253
|
+
|
|
143
254
|
/* ── on-screen text ──────────────────────────────────────────────────────────────────────────────── */
|
|
144
255
|
const isExec = (f) => { try { fs.accessSync(f, fs.constants.X_OK); return fs.statSync(f).isFile(); } catch { return false; } };
|
|
145
256
|
/** The tesseract on this machine: PMOS_TESSERACT, else the PATH, else where Homebrew puts it. */
|
|
@@ -168,7 +279,16 @@ function readText(bin, runtimeDir, id, frame) {
|
|
|
168
279
|
// patch the row only if the same shot is still there (it may have been replaced or deleted while it was read)
|
|
169
280
|
const rows = readIndex(runtimeDir, id);
|
|
170
281
|
const row = rows.find((f) => f.t === frame.t && f.file === frame.file && f.bytes === frame.bytes);
|
|
171
|
-
if (row) {
|
|
282
|
+
if (row) {
|
|
283
|
+
row.text = text;
|
|
284
|
+
// the whole screen was the picture: a shot that looks like another app is set aside, for the PM to keep or delete
|
|
285
|
+
if (row.surface === 'monitor' && !row.kept) {
|
|
286
|
+
let hints = []; try { hints = hintsOf ? hintsOf(runtimeDir, id) || [] : []; } catch { hints = []; }
|
|
287
|
+
const c = classifyShot(text, { hints });
|
|
288
|
+
if (c.aside) { row.aside = true; row.asideApp = c.app; row.asideAt = Date.now(); }
|
|
289
|
+
}
|
|
290
|
+
try { writeIndex(runtimeDir, id, rows); } catch { /* the folder went away */ }
|
|
291
|
+
}
|
|
172
292
|
}
|
|
173
293
|
resolve();
|
|
174
294
|
});
|
|
@@ -177,4 +297,4 @@ function readText(bin, runtimeDir, id, frame) {
|
|
|
177
297
|
/** Resolves when every text read queued so far has ended. */
|
|
178
298
|
function ocrIdle() { return queue.then(() => (waiting > 0 ? ocrIdle() : undefined)); }
|
|
179
299
|
|
|
180
|
-
module.exports = { add, list, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|
|
300
|
+
module.exports = { add, list, listAside, asideCount, keep, discard, discardAll, sweepAside, classifyShot, setHints, ASIDE_DAYS, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|
|
@@ -36,7 +36,7 @@ const crypto = require('crypto');
|
|
|
36
36
|
const RD = require('./report-draft.js');
|
|
37
37
|
const CS = require('./charts-store.js');
|
|
38
38
|
|
|
39
|
-
const EVENTS = Object.freeze(['describe', 'turn', 'version', 'accept', 'revert', 'source', 'remove', 'dry', 'check', 'error']);
|
|
39
|
+
const EVENTS = Object.freeze(['describe', 'turn', 'version', 'accept', 'revert', 'source', 'remove', 'dry', 'pull', 'check', 'error']);
|
|
40
40
|
const PMOS_KINDS = Object.freeze(['pmos-level', 'dashboard', 'chart', 'document', 'past-report']);
|
|
41
41
|
const OUTSIDE_KINDS = Object.freeze(['connector', 'link', 'upload', 'person', 'chrome', 'computer']);
|
|
42
42
|
const KINDS = Object.freeze([...PMOS_KINDS, ...OUTSIDE_KINDS]);
|
|
@@ -79,7 +79,7 @@ function paths(root, id) {
|
|
|
79
79
|
const folder = path.join(abs, rel);
|
|
80
80
|
const dir = path.join(folder, 'structure');
|
|
81
81
|
return { abs, rel, folder, dir, relDir: `${rel}/structure`, events: path.join(dir, 'structure.jsonl'), versions: path.join(dir, 'versions'), pending: path.join(dir, 'pending'),
|
|
82
|
-
dry: path.join(dir, 'dry'), checks: path.join(dir, 'checks'), checksPending: path.join(dir, 'checks', 'pending'), brief: path.join(dir, 'brief.md'), briefRel: `${rel}/structure/brief.md` };
|
|
82
|
+
dry: path.join(dir, 'dry'), pull: path.join(dir, 'pull'), pullPending: path.join(dir, 'pull', 'pending'), checks: path.join(dir, 'checks'), checksPending: path.join(dir, 'checks', 'pending'), brief: path.join(dir, 'brief.md'), briefRel: `${rel}/structure/brief.md` };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// ── the one writer and the fold ───────────────────────────────────────────────────────────────────────────
|
|
@@ -440,6 +440,50 @@ function landVersion(root, id, plan, { turn, answer, questions, options, from, b
|
|
|
440
440
|
return { ok: true, n, plan: v.plan };
|
|
441
441
|
}
|
|
442
442
|
|
|
443
|
+
// ── test pull (report-structure-gaps): ONE source read for the last complete period ──────────────────────────
|
|
444
|
+
/** What a pull job wrote for one source of a plan. → { ok, pull } | refusal. `reached` false is a valid answer: the source returned nothing. */
|
|
445
|
+
function validatePull(data, plan, sourceId) {
|
|
446
|
+
if (!isObj(data)) return refuse(422, 'the pull result must be a JSON object');
|
|
447
|
+
const src = listOf(plan && plan.sources).find((s) => s.id === sourceId);
|
|
448
|
+
if (!src) return refuse(422, 'the source is not in the plan');
|
|
449
|
+
const chartIds = new Set(listOf(plan.charts).filter((c) => c.source === sourceId).map((c) => c.id));
|
|
450
|
+
if (str(data.source) !== sourceId) return refuse(422, 'source must be the source that was pulled');
|
|
451
|
+
if (!Number.isInteger(data.n) || data.n < 1) return refuse(422, 'n must be the version number');
|
|
452
|
+
const per = badText(data.period, 'period', { max: 40 });
|
|
453
|
+
if (per || !data.period.trim()) return refuse(422, per || 'period is required');
|
|
454
|
+
if (typeof data.reached !== 'boolean') return refuse(422, 'reached must be true or false');
|
|
455
|
+
const rs = badText(data.reason === undefined ? '' : data.reason, 'reason', { max: 300, allowEmpty: true });
|
|
456
|
+
if (rs) return refuse(422, rs);
|
|
457
|
+
const values = data.values === undefined ? [] : data.values;
|
|
458
|
+
if (!Array.isArray(values) || values.length > LIMITS.charts) return refuse(422, `values is a list of at most ${LIMITS.charts}`);
|
|
459
|
+
const out = [];
|
|
460
|
+
for (const [i, v] of values.entries()) {
|
|
461
|
+
const w = `values[${i}]`;
|
|
462
|
+
if (!isObj(v)) return refuse(422, `${w} must be an object`);
|
|
463
|
+
if (!chartIds.has(str(v.chart))) return refuse(422, `${w}.chart ${JSON.stringify(str(v.chart)).slice(0, 40)} is not a chart of the plan that uses this source`);
|
|
464
|
+
const c = {};
|
|
465
|
+
for (const k of ['value', 'was', 'delta', 'note']) { const r = textField(v, k, w); if (r.error) return refuse(422, r.error); c[k] = r.v; }
|
|
466
|
+
if (v.flag !== undefined && typeof v.flag !== 'boolean') return refuse(422, `${w}.flag must be true or false`);
|
|
467
|
+
out.push({ chart: v.chart, value: c.value, was: c.was, delta: c.delta, flag: v.flag === true, note: c.note });
|
|
468
|
+
}
|
|
469
|
+
const reached = data.reached === true;
|
|
470
|
+
if (!reached && out.length) return refuse(422, 'a source that was not reached has no values');
|
|
471
|
+
const got = out.filter((v) => v.value).length;
|
|
472
|
+
const reason = str(data.reason).trim() || (reached && !got ? 'The source answered, but no chart returned a number.' : '');
|
|
473
|
+
return { ok: true, pull: { source: sourceId, n: data.n, period: data.period.trim(), reached, ok: reached && got > 0, reason, values: out, charts: chartIds.size, got } };
|
|
474
|
+
}
|
|
475
|
+
function writePull(root, id, pull) {
|
|
476
|
+
if (!SLUG_RE.test(String(pull.source))) throw new Error('report-structure: bad source id');
|
|
477
|
+
writeAtomic(path.join(paths(root, id).pull, `${pull.source}.json`), JSON.stringify(pull, null, 2) + '\n');
|
|
478
|
+
}
|
|
479
|
+
function readPull(root, id, source) { return SLUG_RE.test(String(source)) ? readJson(path.join(paths(root, id).pull, `${source}.json`)) : null; }
|
|
480
|
+
function readPulls(root, id) {
|
|
481
|
+
const out = {};
|
|
482
|
+
let names = []; try { names = fs.readdirSync(paths(root, id).pull); } catch { return out; }
|
|
483
|
+
for (const f of names) { const m = /^([a-z0-9][a-z0-9-]*)\.json$/.exec(f); if (m && SLUG_RE.test(m[1])) { const o = readPull(root, id, m[1]); if (o) out[m[1]] = o; } }
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
443
487
|
// ── the GET answer ────────────────────────────────────────────────────────────────────────────────────────
|
|
444
488
|
/** Chrome and computer sources show what their last check found: a passing check is Reached, a failing one Needs you. */
|
|
445
489
|
function overlay(plan, checks) {
|
|
@@ -450,7 +494,7 @@ function overlay(plan, checks) {
|
|
|
450
494
|
return { ...s, state: c.ok ? 'reached' : 'needs-you' };
|
|
451
495
|
}) };
|
|
452
496
|
}
|
|
453
|
-
function structureAnswer(root, id, { running } = {}) {
|
|
497
|
+
function structureAnswer(root, id, { running, pulling } = {}) {
|
|
454
498
|
const st = stateOf(root, id);
|
|
455
499
|
const checks = readChecks(root, id);
|
|
456
500
|
const versions = [...st.versions.values()].sort((a, b) => a.n - b.n).map((v) => ({ n: v.n, at: v.at, turn: v.turn }));
|
|
@@ -458,7 +502,7 @@ function structureAnswer(root, id, { running } = {}) {
|
|
|
458
502
|
running: !!running, job: running ? { since: running.since, turn: running.turn, kind: running.kind } : null,
|
|
459
503
|
current: st.current, saved: st.saved ? st.saved.n : null, versions, log: st.log,
|
|
460
504
|
plan: st.current != null ? overlay(readVersion(root, id, st.current), checks) : null,
|
|
461
|
-
dry: st.dry, checks, errors: st.errors.map((e) => ({ text: e.text, at: e.at })),
|
|
505
|
+
dry: st.dry, checks, pulls: readPulls(root, id), pulling: Array.isArray(pulling) ? pulling : [], errors: st.errors.map((e) => ({ text: e.text, at: e.at })),
|
|
462
506
|
};
|
|
463
507
|
}
|
|
464
508
|
/** What the setups GET carries for one report: { saved, at, charts, sections } of the accepted version, else null. */
|
|
@@ -512,4 +556,4 @@ function writeBrief(root, id, setup, { now } = {}) {
|
|
|
512
556
|
|
|
513
557
|
module.exports = { EVENTS, KINDS, PMOS_KINDS, OUTSIDE_KINDS, STATES, FIXED, LIMITS, CHECK_STEPS, CHECK_STATES, CADENCE_RE, SLUG_RE, RUN_RE,
|
|
514
558
|
paths, append, readEvents, fold, stateOf, nextN, badText, validatePlan, validateSource, validateDry, validateCheck, cleanQuestions, cleanOptions, addSourceTo, removeFrom,
|
|
515
|
-
landVersion, readVersion, rewriteVersion, writeDry, readDry, writeCheck, readCheck, readChecks, writeBrief, structureAnswer, savedOf, overlay, defaultState, newRun };
|
|
559
|
+
landVersion, readVersion, rewriteVersion, writeDry, readDry, validatePull, writePull, readPull, readPulls, writeCheck, readCheck, readChecks, writeBrief, structureAnswer, savedOf, overlay, defaultState, newRun };
|