create-pmos 0.241.0 → 0.242.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/guide/console-calendar.md +14 -0
- package/workspace/guide/notes.md +22 -1
- package/workspace/lib/meeting-material.js +151 -0
- package/workspace/lib/meeting-replay.js +170 -0
- package/workspace/lib/notes-screen.js +128 -8
- 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/package-lock.json +2 -2
- package/workspace/package.json +1 -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 +311 -0
- package/workspace/public/pm/c3-notes.js +12 -4
- package/workspace/public/pm/c3-replay.css +127 -0
- package/workspace/public/pm/c3-replay.js +413 -0
- package/workspace/public/pm/dest-notes.js +4 -2
- 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/shell-recording.js +10 -2
- package/workspace/public/pm/view-notes.js +48 -8
- 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
|
@@ -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 };
|
|
@@ -69,6 +69,8 @@ const MODULES = Object.freeze([
|
|
|
69
69
|
'routes-hooks', // planning-flows run 7: CLI hook endpoints (session token, per-run JSONL)
|
|
70
70
|
'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
|
|
71
71
|
'routes-meeting-focus', // console3 Meeting focus: the meeting, its note's talk, the companion's notes (GET); topic corrections and Ask PMOS quietly (page press)
|
|
72
|
+
'routes-meeting-replay', // meeting-material-replay: the note, the Focus state and the shots joined for the Replay page (GET, read-only)
|
|
73
|
+
'routes-meeting-material', // meeting-material-replay: the material the PM links to a meeting (GET list and suggest; page-press POST add and remove; meeting-material.jsonl)
|
|
72
74
|
'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
|
|
73
75
|
'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
|
|
74
76
|
'routes-scan', // horizon-scan-portfolio: one scan for the workspace: the watch list and setting, Scan now and Stop, report documents and their proposals (approve, change, dismiss, undo), plus the first scan's per-board and Briefing reads (scan.jsonl)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* routes-meeting-focus.js — the Meeting focus page's server side (initiative c3-meeting-focus; lib/route-modules.js).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/meeting-focus?event=<id>&date=YYYY-MM-DD[&ws=]
|
|
5
|
-
* → { ok, now_ms, event, brief, note, topics, current, runs, tail, captured, gist, chips, notes, asks, companion }
|
|
5
|
+
* → { ok, now_ms, event, brief, material, note, topics, current, runs, tail, captured, gist, chips, notes, asks, companion }
|
|
6
6
|
* The meeting (the calendar read for that day), its brief (lib/calendar-layers.js briefOf), the note linked to it, the last
|
|
7
7
|
* minutes of that note's transcript with who said them, the decisions, actions and questions so far, and the companion's state.
|
|
8
8
|
* An unknown meeting is a 404. The read also starts the companion when lib/meeting-focus.js shouldRun says it may: the note
|
|
@@ -193,13 +193,15 @@ async function read(q, ctx) {
|
|
|
193
193
|
const url = l && typeof l.url === 'string' && /^https?:\/\//i.test(l.url) ? l.url : ''; if (!url || seen.has(url)) continue;
|
|
194
194
|
seen.add(url); links.push({ label: F.str(l.label, 120) || url, url });
|
|
195
195
|
}
|
|
196
|
+
let material = []; try { material = require('./meeting-material.js').forMeeting(ctx.runtimeDir, { id: ev.id, title: ev.title, series: ev.series || null }); } catch { material = []; }
|
|
196
197
|
const sessions = note && Array.isArray(note.sessions) && note.sessions.length ? note.sessions : [];
|
|
197
198
|
return { status: 200, body: {
|
|
198
199
|
ok: true, now_ms: nowMs, date: day,
|
|
199
200
|
talk,
|
|
200
201
|
event: { id: ev.id, title: ev.title, at: ev.at, end: ev.end, attendees: [].concat(ev.attendees || ev.people || []).map((p) => (p && typeof p === 'object' ? p.name || p.id : p)).filter(Boolean).map(String),
|
|
201
|
-
initiative: ev.initiative || null, purpose: ev.purpose || (brief && brief.purpose) || null, links },
|
|
202
|
+
initiative: ev.initiative || null, series: ev.series || null, purpose: ev.purpose || (brief && brief.purpose) || null, links },
|
|
202
203
|
brief: brief || null,
|
|
204
|
+
material,
|
|
203
205
|
note: note ? { id: note.id, title: note.title || ev.title, state: note.state, recorded_from: note.recorded_from || null, lines: (note.transcript || []).length, sessions } : null,
|
|
204
206
|
topics: fresh.topics, current: fresh.current, runs: fresh.runs,
|
|
205
207
|
tail: note ? F.tailOf(note, { now: nowMs }) : [], captured: F.capturedFrom(note),
|
|
@@ -317,7 +319,7 @@ const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true;
|
|
|
317
319
|
|
|
318
320
|
module.exports = {
|
|
319
321
|
prefixes: ['/api/meeting-focus'],
|
|
320
|
-
read, now: nowRoute, topic, ask, askState, settleAsk, settleRun, findEvent, keyOf,
|
|
322
|
+
read, now: nowRoute, topic, ask, askState, settleAsk, settleRun, findEvent, keyOf, linesOf, noteFor, noteEvent, realBrief,
|
|
321
323
|
handle: async (req, res, url, ctx) => {
|
|
322
324
|
const p = url.pathname;
|
|
323
325
|
const wsId = url.searchParams.get('ws');
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-meeting-material.js — the material of a meeting: what the PM linked to it (initiative meeting-material-replay, part 1; lib/route-modules.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/meeting-material?event=<id>&series=&title=[&date=][&topics=1][&ws=]
|
|
5
|
+
* → { ok, key, items: [item], topics? } item: { id, kind, ref, url, title, ws, topic, present, scope: meeting|series, from: 'you', by, at }
|
|
6
|
+
* The items of the meeting: the ones for every meeting of its series and the ones for this one. title is required (it names the series when
|
|
7
|
+
* series is empty). With topics=1 the answer also lists the topics Focus starts the meeting with, for the Add material sheet.
|
|
8
|
+
* POST /api/meeting-material { op?: 'add'|'remove', event: { id?, title, series? }, scope: 'meeting'|'series', item: { kind, ref|url, title?, ws? }, topic?, present? }
|
|
9
|
+
* → { ok, items } page press (lib/pm-guard.js requirePagePress). Appends one line to <runtime>/meeting-material.jsonl. The calendar is never written.
|
|
10
|
+
* GET /api/meeting-material/suggest?ws=&initiative=&board=&q=[&limit=]
|
|
11
|
+
* → { ok, suggested: [pick], found: [pick] } pick: { kind, ref, title, meta, why?, present, ws }
|
|
12
|
+
* suggested: the documents of the meeting's level (the initiative's, newest first) and the newest report cycles. found: a search over the
|
|
13
|
+
* titles and paths of PMOS documents and report cycles (two letters or more). Read-only.
|
|
14
|
+
*
|
|
15
|
+
* Handlers are pure over (req, body, ctx): ctx = { runtimeDir, getWs, stateDirFor?, initiativeIndex?, readEvents?, briefFor?, json } so a test needs no server. */
|
|
16
|
+
const store = require('./meeting-material.js');
|
|
17
|
+
const guard = require('./pm-guard.js');
|
|
18
|
+
|
|
19
|
+
const fail = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
|
|
20
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
21
|
+
const str = (v, n) => String(v == null ? '' : v).trim().slice(0, n || 300);
|
|
22
|
+
const eventOf = (q) => ({ id: str(q.event, 120), title: str(q.title, 300), series: str(q.series, 300) || null });
|
|
23
|
+
|
|
24
|
+
/** The topics Focus starts the meeting with (lib/meeting-focus.js topicsFrom). Best effort: no calendar read, no topics. */
|
|
25
|
+
async function topicsOf(q, ctx) {
|
|
26
|
+
try {
|
|
27
|
+
const F = require('./meeting-focus.js');
|
|
28
|
+
const R = require('./routes-meeting-focus.js');
|
|
29
|
+
const events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir, { now: Date.now() });
|
|
30
|
+
const date = q.date && DAY.test(String(q.date)) ? String(q.date) : '';
|
|
31
|
+
const ev = R.findEvent(events, str(q.event, 120), date);
|
|
32
|
+
if (!ev) return [];
|
|
33
|
+
const day = date || String(ev.at || '').slice(0, 10);
|
|
34
|
+
const w = ctx.getWs ? ctx.getWs(q.ws || null) : null;
|
|
35
|
+
const brief = typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await R.realBrief({ ...ctx, w }, ev, day);
|
|
36
|
+
return F.topicsFrom(ev, brief).map((t) => ({ id: t.id, text: t.text }));
|
|
37
|
+
} catch { return []; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function list(q, ctx) {
|
|
41
|
+
const e = eventOf(q);
|
|
42
|
+
if (!e.title) return fail(400, 'title is required');
|
|
43
|
+
if (!ctx.runtimeDir) return { status: 200, body: { ok: true, key: store.keyOf(e), items: [] } };
|
|
44
|
+
const body = { ok: true, key: store.keyOf(e), items: store.forMeeting(ctx.runtimeDir, e) };
|
|
45
|
+
if (q.topics === '1' || q.topics === 'true') body.topics = await topicsOf(q, ctx);
|
|
46
|
+
return { status: 200, body };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function post(req, body, ctx) {
|
|
50
|
+
const g = guard.requirePagePress(req, body);
|
|
51
|
+
if (!g.ok) return fail(g.status, g.error);
|
|
52
|
+
if (!ctx.runtimeDir) return fail(409, 'this server has no runtime folder, so the material cannot be saved');
|
|
53
|
+
const out = store.append(ctx.runtimeDir, body, g.actor && g.actor.by ? g.actor.by : null);
|
|
54
|
+
return out.ok ? { status: 200, body: out } : fail(400, out.error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ── what PMOS can offer ──────────────────────────────────────────────────────────────────────────────────────────── */
|
|
58
|
+
|
|
59
|
+
const dayText = (d) => {
|
|
60
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(d || '')); if (!m) return '';
|
|
61
|
+
const t = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
62
|
+
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][t.getUTCDay()] + ' ' + (+m[3]) + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][+m[2] - 1];
|
|
63
|
+
};
|
|
64
|
+
const isReportPath = (p) => /(^|\/)reports\//.test(p);
|
|
65
|
+
|
|
66
|
+
/** Every document and report cycle PMOS holds for a workspace, as picks. Each read is best effort: a failing source adds nothing. */
|
|
67
|
+
function candidates(ctx, w) {
|
|
68
|
+
const docs = []; const reports = [];
|
|
69
|
+
const root = w.root;
|
|
70
|
+
const owner = new Map();
|
|
71
|
+
try {
|
|
72
|
+
if (typeof ctx.initiativeIndex === 'function') {
|
|
73
|
+
for (const g of ctx.initiativeIndex(root).initiatives || []) for (const a of g.artifacts || []) { const rel = String(a.path).split('\\').join('/'); if (!a.shared && !owner.has(rel)) owner.set(rel, { id: g.id, title: g.title || g.id }); }
|
|
74
|
+
}
|
|
75
|
+
} catch { /* no attribution: a document has no initiative */ }
|
|
76
|
+
try {
|
|
77
|
+
const lib = require('./doc-library.js').library(root, { stateDir: typeof ctx.stateDirFor === 'function' ? ctx.stateDirFor(w) : undefined, owners: () => owner });
|
|
78
|
+
if (lib && lib.ok) {
|
|
79
|
+
const cat = new Map((lib.categories || []).map((c) => [c.key, c.name]));
|
|
80
|
+
for (const f of lib.files || []) {
|
|
81
|
+
if (f.node || isReportPath(f.path)) continue;
|
|
82
|
+
const o = owner.get(f.path) || (f.initiative ? { id: f.initiative } : null);
|
|
83
|
+
docs.push({ kind: 'pmos-doc', ref: f.path, title: f.title || f.path, date: f.date || '', ini: o ? o.id : null, ws: w.id,
|
|
84
|
+
meta: (o ? o.id + ' documents' : cat.get(f.category) || 'Documents') + (f.date ? ' · ' + dayText(f.date) : '') });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch { /* no library */ }
|
|
88
|
+
try {
|
|
89
|
+
const rs = require('./routes-report-setups.js');
|
|
90
|
+
for (const d of rs.documentsOf({ runtimeDir: ctx.runtimeDir, getWs: ctx.getWs }, w).docs || []) {
|
|
91
|
+
const deck = d.output === 'google-slides';
|
|
92
|
+
reports.push({ kind: deck ? 'pmos-deck' : 'report', ref: d.path, title: (d.name || d.id) + (d.slot ? ' · ' + d.slot : ''), date: String(d.slot || '').slice(0, 10), ini: null, ws: w.id,
|
|
93
|
+
meta: 'Reports · ' + (deck ? 'deck' : 'document') + (d.state === 'issued' ? ' · issued' : ' · draft'), present: deck });
|
|
94
|
+
}
|
|
95
|
+
} catch { /* no reports */ }
|
|
96
|
+
return { docs, reports };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const newest = (a, b) => String(b.date || '').localeCompare(String(a.date || ''));
|
|
100
|
+
const pick = (c, why) => ({ kind: c.kind, ref: c.ref, title: c.title, meta: c.meta, ...(why ? { why } : {}), present: c.present === true, ws: c.ws });
|
|
101
|
+
|
|
102
|
+
function suggest(q, ctx) {
|
|
103
|
+
const w = ctx.getWs ? ctx.getWs(q.ws || null) : null;
|
|
104
|
+
if (!w || !w.root) return fail(404, 'no such workspace');
|
|
105
|
+
const limit = Math.max(1, Math.min(20, Number(q.limit) || 6));
|
|
106
|
+
const ini = str(q.initiative, 120);
|
|
107
|
+
const c = candidates(ctx, w);
|
|
108
|
+
const suggested = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const add = (x, why) => { if (suggested.length >= limit || seen.has(x.ref)) return; seen.add(x.ref); suggested.push(pick(x, why)); };
|
|
111
|
+
if (ini) {
|
|
112
|
+
for (const d of c.docs.filter((x) => x.ini === ini).sort(newest).slice(0, 4)) add(d, 'In ' + ini + (d.date ? ', edited ' + dayText(d.date) : ''));
|
|
113
|
+
}
|
|
114
|
+
for (const r of c.reports.slice().sort(newest).slice(0, 2)) add(r, 'A report PMOS keeps' + (r.date ? ', ' + dayText(r.date) : ''));
|
|
115
|
+
const text = str(q.q, 120).toLowerCase();
|
|
116
|
+
let found = [];
|
|
117
|
+
if (text.length >= 2) {
|
|
118
|
+
const hit = (x) => (x.title + ' ' + x.ref).toLowerCase().includes(text);
|
|
119
|
+
found = [...c.reports.filter(hit), ...c.docs.filter(hit)].sort(newest).slice(0, 8).map((x) => pick(x));
|
|
120
|
+
}
|
|
121
|
+
return { status: 200, body: { ok: true, suggested, found } };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
prefixes: ['/api/meeting-material'],
|
|
128
|
+
list, post, suggest, candidates,
|
|
129
|
+
handle: async (req, res, url, ctx) => {
|
|
130
|
+
const p = url.pathname;
|
|
131
|
+
const q = Object.fromEntries(url.searchParams);
|
|
132
|
+
if (p === '/api/meeting-material/suggest') {
|
|
133
|
+
if (req.method !== 'GET') return respond(ctx, res, fail(405, 'GET only'));
|
|
134
|
+
return respond(ctx, res, suggest(q, ctx));
|
|
135
|
+
}
|
|
136
|
+
if (p !== '/api/meeting-material') return false;
|
|
137
|
+
if (req.method === 'GET') return respond(ctx, res, await list(q, ctx));
|
|
138
|
+
if (req.method !== 'POST') return respond(ctx, res, fail(405, 'GET or POST only'));
|
|
139
|
+
const got = await ctx.readJson(req);
|
|
140
|
+
if (!got.ok || !got.body || typeof got.body !== 'object' || Array.isArray(got.body)) return respond(ctx, res, fail(400, 'the body must be a JSON object'));
|
|
141
|
+
return respond(ctx, res, post(req, got.body, ctx));
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-meeting-replay.js — the meeting replay's server side (initiative meeting-material-replay, part 2; lib/route-modules.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/meeting-replay?note=<id>[&event=<id>&date=YYYY-MM-DD]
|
|
5
|
+
* GET /api/meeting-replay?event=<id>[&date=YYYY-MM-DD] the note linked to that meeting
|
|
6
|
+
* → { ok, note, meeting, audio, start_ms, length_ms, lines, keys, topics, runs, notes, shots, aside_count } (lib/meeting-replay.js)
|
|
7
|
+
* The note (transcript, decisions, next steps, questions), the Focus state file (topics, stretches, PMOS's notes) and the
|
|
8
|
+
* shots, joined. Read only: it writes nothing, starts no run and reaches nothing outside the machine. A private note is a
|
|
9
|
+
* 404 (it keeps no audio and no shots); an unknown note or a meeting with no note is a 404. A shot that was set aside
|
|
10
|
+
* (`aside`) is not in `shots`; `aside_count` says how many there are.
|
|
11
|
+
* Handlers are pure over (query, ctx): ctx = { runtimeDir, readEvents?, now? }, so a test needs no server.
|
|
12
|
+
*/
|
|
13
|
+
const focus = require('./routes-meeting-focus.js');
|
|
14
|
+
const notes = require('./notes.js');
|
|
15
|
+
const notesAudio = require('./notes-audio.js');
|
|
16
|
+
const notesScreen = require('./notes-screen.js');
|
|
17
|
+
const F = require('./meeting-focus.js');
|
|
18
|
+
const R = require('./meeting-replay.js');
|
|
19
|
+
|
|
20
|
+
const fail = (status, error) => ({ status, body: { ok: false, error } });
|
|
21
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
|
+
|
|
23
|
+
/** The note's shots, the kept ones and the set-aside ones, so the join can count the set-aside ones and leave them out.
|
|
24
|
+
* notesScreen.list(dir, id) lists the kept shots; { aside: true } lists only the set-aside ones. */
|
|
25
|
+
function framesOf(dir, id) {
|
|
26
|
+
let all = [];
|
|
27
|
+
try { all = [...(notesScreen.list(dir, id) || []), ...(notesScreen.list(dir, id, { aside: true }) || [])]; } catch { all = []; }
|
|
28
|
+
return all.filter((f) => f && typeof f.t === 'number').sort((a, b) => a.t - b.t);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function read(q, ctx) {
|
|
32
|
+
const dir = ctx.runtimeDir;
|
|
33
|
+
const noteId = String((q && q.note) || '').slice(0, 120);
|
|
34
|
+
const eventId = String((q && q.event) || '').slice(0, 120);
|
|
35
|
+
const date = q && q.date ? String(q.date) : '';
|
|
36
|
+
if (!noteId && !eventId) return fail(400, 'note or event is required');
|
|
37
|
+
if (date && !DAY.test(date)) return fail(400, 'date must be YYYY-MM-DD');
|
|
38
|
+
const nowMs = typeof ctx.now === 'function' ? ctx.now() : Date.now();
|
|
39
|
+
let events = [];
|
|
40
|
+
try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(dir, { now: nowMs }); } catch { events = []; }
|
|
41
|
+
let note = null; let ev = null;
|
|
42
|
+
if (noteId) {
|
|
43
|
+
const r = notes.read(dir, noteId);
|
|
44
|
+
if (!r.ok) return fail(404, 'no such note');
|
|
45
|
+
note = r.note;
|
|
46
|
+
if (eventId) ev = focus.findEvent(events, eventId, date);
|
|
47
|
+
const m = note.meeting;
|
|
48
|
+
if (!ev && m && m.kind === 'event' && m.event_id) ev = focus.findEvent(events, String(m.event_id), date || String(m.start || '').slice(0, 10));
|
|
49
|
+
} else {
|
|
50
|
+
ev = focus.findEvent(events, eventId, date);
|
|
51
|
+
if (!ev) return fail(404, 'no such meeting on the calendar');
|
|
52
|
+
note = focus.noteFor(dir, ev, null);
|
|
53
|
+
if (!note) return fail(404, 'this meeting has no note to replay');
|
|
54
|
+
}
|
|
55
|
+
if (note.private) return fail(404, 'This note is private. It keeps no audio and no shots, so it has no replay.');
|
|
56
|
+
const talk = !ev;
|
|
57
|
+
if (!ev) ev = focus.noteEvent(note);
|
|
58
|
+
const keys = [focus.keyOf(ev.id), focus.keyOf(note.id)];
|
|
59
|
+
const rec = notesAudio.info(dir, note.id);
|
|
60
|
+
const audio = rec ? { url: '/api/notes/recording?id=' + encodeURIComponent(note.id) + '&b=' + rec.bytes + (notesAudio.hasClean(dir, note.id) ? '&c=1' : ''), ms: rec.ms } : null;
|
|
61
|
+
const body = R.build({ note, event: ev, stateLines: focus.linesOf(dir, keys), frames: framesOf(dir, note.id), audio, topics: talk ? [] : F.topicsFrom(ev, null), now: nowMs });
|
|
62
|
+
return { status: 200, body };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
prefixes: ['/api/meeting-replay'],
|
|
69
|
+
read,
|
|
70
|
+
handle: async (req, res, url, ctx) => {
|
|
71
|
+
if (url.pathname !== '/api/meeting-replay') return false;
|
|
72
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
73
|
+
return respond(ctx, res, await read(Object.fromEntries(url.searchParams), { runtimeDir: ctx.runtimeDir, json: ctx.json }));
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
* Refused: a private note (409), a note not listening (409), "Capture the shared screen" switched off (409),
|
|
41
41
|
* a token that is not the live capture's (409), a bad t or picture (400), a full note (409)
|
|
42
42
|
* GET /api/notes/screen?id=…&t=… → image/jpeg|png|webp the shot kept at meeting time t; 404 for a private note or no such shot
|
|
43
|
+
* (a shot set aside is not served, unless aside=1 is added: the review sheet's thumbnails)
|
|
44
|
+
* POST /api/notes/screen also takes surface: 'window' | 'browser' | 'monitor', what the picture shows; a 'monitor' shot is checked for other apps
|
|
45
|
+
* GET /api/notes/screen-aside?id=… → { ok, frames: [{ t, w, h, bytes, app, text? }], days } the shots set aside as another app (meeting-material-replay)
|
|
46
|
+
* POST /api/notes/screen-aside { id, action: 'keep' | 'delete' | 'delete-all', t? } → { ok, frame? , deleted?, aside } the PM's answer; GET /api/notes/one
|
|
47
|
+
* also carries screen.aside, the count. Set-aside shots older than 7 days are deleted on their own.
|
|
43
48
|
* POST /api/notes/screen-delete { id } → { ok, deleted } deletes the note's screenshots and keeps the rest
|
|
44
49
|
* POST /api/notes/clean-audio { id } → { ok, started } makes the cleaned copy of the replay now (Run 3 of notes-audio-quality; needs ffmpeg)
|
|
45
50
|
* GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
|
|
@@ -111,7 +116,7 @@ const VOCAB_TTL_MS = 60 * 1000;
|
|
|
111
116
|
const prefixes = ['/api/notes'];
|
|
112
117
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
113
118
|
'/api/notes/private', '/api/notes/pause', '/api/notes/stop', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
114
|
-
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/screen', '/api/notes/screen-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch', '/api/notes/series-done', '/api/notes/series-bring', '/api/notes/series-unbring', '/api/notes/series-member']);
|
|
119
|
+
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/screen', '/api/notes/screen-delete', '/api/notes/screen-aside', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch', '/api/notes/series-done', '/api/notes/series-bring', '/api/notes/series-unbring', '/api/notes/series-member']);
|
|
115
120
|
|
|
116
121
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
117
122
|
const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
|
|
@@ -674,6 +679,13 @@ function resumeStuck(ctx) {
|
|
|
674
679
|
|
|
675
680
|
const send = (ctx, res, r) => ctx.json(res, r, r.ok ? 200 : (r.status || 400));
|
|
676
681
|
|
|
682
|
+
/** Delete the shots set aside for more than 7 days. At most once in ten minutes unless `now` is true. */
|
|
683
|
+
let asideSweptAt = 0;
|
|
684
|
+
function sweepAsideSoon(dir, now = false) {
|
|
685
|
+
if (!now && Date.now() - asideSweptAt < 10 * 60000) return;
|
|
686
|
+
asideSweptAt = Date.now();
|
|
687
|
+
try { notesScreen.sweepAside(dir); } catch { /* a folder that cannot be read stays as it was */ }
|
|
688
|
+
}
|
|
677
689
|
async function handle(req, res, url, ctx) {
|
|
678
690
|
const p = url.pathname;
|
|
679
691
|
const dir = ctx.runtimeDir;
|
|
@@ -683,6 +695,7 @@ async function handle(req, res, url, ctx) {
|
|
|
683
695
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
684
696
|
if (p === '/api/notes') {
|
|
685
697
|
const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) });
|
|
698
|
+
sweepAsideSoon(dir);
|
|
686
699
|
if (l.ok) { sweepExpired(ctx, l.notes); try { l.series_open = notesSeries.openCounts(dir, l.notes); } catch { l.series_open = {}; } }
|
|
687
700
|
send(ctx, res, l); return true;
|
|
688
701
|
}
|
|
@@ -697,7 +710,7 @@ async function handle(req, res, url, ctx) {
|
|
|
697
710
|
if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) });
|
|
698
711
|
if (r.ok) {
|
|
699
712
|
r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); if (r.recording) r.recording = { ...r.recording, cleaned: notesAudio.hasClean(dir, nid) };
|
|
700
|
-
r.screen = { frames: r.note.private ? [] : notesScreen.list(dir, nid) };
|
|
713
|
+
r.screen = { frames: r.note.private ? [] : notesScreen.list(dir, nid), aside: r.note.private ? 0 : notesScreen.asideCount(dir, nid) };
|
|
701
714
|
if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
|
|
702
715
|
r.bullets_state = bulletsState(ctx, r.note);
|
|
703
716
|
// where the note sits in its meeting series, and what changed since the meeting before (c3-notes-series)
|
|
@@ -717,7 +730,16 @@ async function handle(req, res, url, ctx) {
|
|
|
717
730
|
const r = notes.read(dir, nid);
|
|
718
731
|
if (!r.ok) { send(ctx, res, r); return true; }
|
|
719
732
|
if (r.note.private) { ctx.json(res, { ok: false, error: 'this note is private and keeps no screenshots' }, 404); return true; }
|
|
720
|
-
notesScreen.serve(req, res, dir, nid, url.searchParams.get('t'));
|
|
733
|
+
notesScreen.serve(req, res, dir, nid, url.searchParams.get('t'), { aside: url.searchParams.get('aside') === '1' });
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
if (p === '/api/notes/screen-aside' && req.method === 'GET') {
|
|
737
|
+
const nid = url.searchParams.get('id');
|
|
738
|
+
const r = notes.read(dir, nid);
|
|
739
|
+
if (!r.ok) { send(ctx, res, r); return true; }
|
|
740
|
+
if (r.note.private) { ctx.json(res, { ok: true, frames: [], days: notesScreen.ASIDE_DAYS }); return true; }
|
|
741
|
+
sweepAsideSoon(dir, true);
|
|
742
|
+
ctx.json(res, { ok: true, days: notesScreen.ASIDE_DAYS, frames: notesScreen.listAside(dir, nid).map((f) => ({ t: f.t, w: f.w, h: f.h, bytes: f.bytes, app: f.asideApp || '', text: f.text || '', at: f.asideAt || null })) });
|
|
721
743
|
return true;
|
|
722
744
|
}
|
|
723
745
|
if (p === '/api/notes/recording' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
@@ -915,7 +937,18 @@ async function handle(req, res, url, ctx) {
|
|
|
915
937
|
if (!own.ok) return done(own);
|
|
916
938
|
if (typeof body.t !== 'number' || !Number.isFinite(body.t) || body.t < 0) return done({ ok: false, status: 400, error: 'a screenshot needs its meeting time' });
|
|
917
939
|
if (typeof body.img !== 'string' || !body.img) return done({ ok: false, status: 400, error: 'there is no picture to keep' });
|
|
918
|
-
return done(notesScreen.add(dir, { id, t: body.t, buf: Buffer.from(body.img, 'base64'), w: body.w, h: body.h, why: body.why }));
|
|
940
|
+
return done(notesScreen.add(dir, { id, t: body.t, buf: Buffer.from(body.img, 'base64'), w: body.w, h: body.h, why: body.why, surface: body.surface }));
|
|
941
|
+
}
|
|
942
|
+
if (p === '/api/notes/screen-aside') {
|
|
943
|
+
const r = notes.read(dir, id);
|
|
944
|
+
if (!r.ok) return done(r);
|
|
945
|
+
const act = body.action;
|
|
946
|
+
let out;
|
|
947
|
+
if (act === 'keep') out = notesScreen.keep(dir, id, body.t);
|
|
948
|
+
else if (act === 'delete') out = notesScreen.discard(dir, id, body.t);
|
|
949
|
+
else if (act === 'delete-all') out = notesScreen.discardAll(dir, id);
|
|
950
|
+
else return done({ ok: false, status: 400, error: 'say action: keep, delete or delete-all' });
|
|
951
|
+
return done(out.ok ? { ...out, aside: notesScreen.asideCount(dir, id) } : out);
|
|
919
952
|
}
|
|
920
953
|
if (p === '/api/notes/screen-delete') {
|
|
921
954
|
const r = notes.read(dir, id);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.242.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.242.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED