create-pmos 0.64.0 → 0.65.1
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/guide/docs.md +59 -6
- package/workspace/guide/initiatives-screen.md +8 -0
- package/workspace/guide/notes.md +35 -6
- package/workspace/lib/doc-library.js +131 -0
- package/workspace/lib/notes-audio.js +111 -0
- package/workspace/lib/notes.js +105 -12
- package/workspace/lib/routes-notes.js +33 -9
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +389 -0
- package/workspace/public/pm/dest-docs.js +144 -44
- package/workspace/public/pm/doc-graph.js +272 -0
- package/workspace/public/pm/initiative-sheet.js +24 -2
- package/workspace/public/pm/initiatives-copy.js +8 -0
- package/workspace/public/pm/view-notes.js +228 -19
- package/workspace/server.js +21 -0
- package/workspace/test-lists/docs-library-graph.json +5 -0
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* About 70% of the width on a desktop and the whole screen on a phone. Top to bottom:
|
|
4
4
|
* line 1 the title, the horizon, the health reason (only when not on track), and a meta line: lane · anchor · id
|
|
5
5
|
* line 2 the stage stepper, then "Waiting on you: approval [Open gate ▸]" or "Waiting on agent: build [Hand to agent ▸]"
|
|
6
|
-
* tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files
|
|
6
|
+
* tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files (List · Graph:
|
|
7
|
+
* the graph is doc-graph.js, initiative docs-library-graph), History,
|
|
7
8
|
* Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
|
|
8
9
|
* People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
|
|
9
10
|
* ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
|
|
@@ -22,6 +23,7 @@ import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
|
|
|
22
23
|
import { mountJira, unmountJira } from './sheet-jira.js';
|
|
23
24
|
import { initiativePeople } from './people-link.js';
|
|
24
25
|
import { openDoc } from './panel-initiatives.js';
|
|
26
|
+
import { getLibrary, initiativeGraph } from './doc-graph.js';
|
|
25
27
|
import { openNewSession } from './panel-newsess.js';
|
|
26
28
|
import { wsOf, readDoc, saveDoc } from './data.js';
|
|
27
29
|
import { state, setState } from './state.js';
|
|
@@ -67,6 +69,8 @@ export function openSheet(repo, slug) {
|
|
|
67
69
|
ensureSprite();
|
|
68
70
|
let data = null;
|
|
69
71
|
let tab = 'breakdown';
|
|
72
|
+
let fview = 'list'; // the Files tab: 'list' or 'graph' (docs-library-graph R-N1)
|
|
73
|
+
let lib = null; // the library, read on the first press on Graph
|
|
70
74
|
let closed = false;
|
|
71
75
|
const tabId = 'initiative:' + slug;
|
|
72
76
|
const crumb = el('div', { class: 'in-sheet__crumb' });
|
|
@@ -277,9 +281,27 @@ export function openSheet(repo, slug) {
|
|
|
277
281
|
onOpen: () => { s.close(); openDoc(slug, f.path, f.label, null, f.source === 'git' ? { readonly: true } : undefined); },
|
|
278
282
|
attrs: { 'data-in-file': f.kind, 'data-in-file-source': f.source || 'record' }, openAttrs: { 'data-in-file-open': f.kind },
|
|
279
283
|
}));
|
|
280
|
-
|
|
284
|
+
const listView = () => (files.length ? el('div', { class: 'in-files' }, groups.map(([src, list]) => el('section', { class: 'in-files__group', 'data-in-files': src },
|
|
281
285
|
el('h3', { class: 'in-files__title' }, COPY.sheet.fileSource[src] || src),
|
|
282
286
|
el('div', { class: 'in-sheet__rows', role: 'list' }, list.map(fileRow))))) : empty({ text: COPY.sheet.noFiles }));
|
|
287
|
+
// List · Graph (docs-library-graph R-N1): the graph is this initiative's markdown files (the ones its record names
|
|
288
|
+
// and the ones the library's index attributes to it; a file git changed for it is evidence, not one of its files)
|
|
289
|
+
// and the outside files they link to. On a phone initiativeGraph draws nothing and the list shows (R-N5).
|
|
290
|
+
const paths = files.filter((f) => f.kind !== 'note' && (f.source || 'record') !== 'git' && /\.md$/i.test(f.path || '')).map((f) => f.path);
|
|
291
|
+
const seg = tabs({ variant: 'segmented', ariaLabel: COPY.sheet.filesAs, value: fview,
|
|
292
|
+
items: [{ id: 'list', label: COPY.sheet.filesList }, { id: 'graph', label: COPY.sheet.filesGraph }], attrs: { 'data-in-files-view': '' },
|
|
293
|
+
onChange: (id) => { if (id === fview) return; fview = id; drawPanel(); const t = panel.querySelector('[data-in-files-view] [data-tab="' + id + '"]'); if (t) t.focus({ preventScroll: true }); } });
|
|
294
|
+
const bar = el('div', { class: 'in-files__bar' }, seg, el('span', { class: 'in-files__hint' }, fview === 'graph' ? COPY.sheet.graphHint : COPY.sheet.listHint));
|
|
295
|
+
if (fview === 'list' || !paths.length) { panel.replaceChildren(bar, listView()); return; }
|
|
296
|
+
const open = (p, title) => { s.close(); openDoc(slug, p, title); };
|
|
297
|
+
const graphNow = () => { const g = lib ? initiativeGraph(lib, paths, { onOpen: open }) : null; return g || el('div', null, el('p', { class: 'in-note' }, COPY.sheet.graphPhone), listView()); };
|
|
298
|
+
if (lib) { panel.replaceChildren(bar, graphNow()); return; }
|
|
299
|
+
panel.replaceChildren(bar, el('p', { class: 'in-note' }, COPY.sheet.graphReading));
|
|
300
|
+
getLibrary(ws).then((l) => {
|
|
301
|
+
if (tab !== 'files' || fview !== 'graph') return;
|
|
302
|
+
if (!l) { panel.replaceChildren(bar, el('p', { class: 'in-note' }, COPY.sheet.graphError), listView()); return; }
|
|
303
|
+
lib = l; drawPanel();
|
|
304
|
+
});
|
|
283
305
|
return;
|
|
284
306
|
}
|
|
285
307
|
const hist = data.history || [];
|
|
@@ -134,6 +134,14 @@ export const COPY = {
|
|
|
134
134
|
openGate: 'Open gate',
|
|
135
135
|
handToAgent: 'Hand to agent',
|
|
136
136
|
noFiles: 'No files yet.',
|
|
137
|
+
filesAs: 'Show files as',
|
|
138
|
+
filesList: 'List',
|
|
139
|
+
filesGraph: 'Graph',
|
|
140
|
+
listHint: 'Every file this initiative owns.',
|
|
141
|
+
graphHint: 'This initiative’s files, and the files outside it that they link to.',
|
|
142
|
+
graphReading: 'Reading the links…',
|
|
143
|
+
graphError: 'The links could not be read. The list is below.',
|
|
144
|
+
graphPhone: 'On a small screen the files show as a list.',
|
|
137
145
|
fileSource: { record: 'Named by the record', 'knowledge-graph': 'Linked documents', git: 'Changed for it in git' },
|
|
138
146
|
commits: (n) => (n === 1 ? '1 commit' : `${n} commits`),
|
|
139
147
|
shared: 'shared',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1
|
|
1
|
+
/* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 3).
|
|
2
2
|
*
|
|
3
3
|
* The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
|
|
4
4
|
* header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
|
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
* LABELS AND CHECKS. The server labels each line locally; behind the D91 gates Jev labels it and compares a captured line
|
|
18
18
|
* with the closest repository passage. The page shows the result in words and reads it by polling the note.
|
|
19
19
|
*
|
|
20
|
+
* CORRECT, TRACK AND REPLAY (Run 3, D92). The ellipsis on a transcript line opens its editor: the words (the words that
|
|
21
|
+
* were heard stay kept and the line shows "edited"), the label, whether it is in the summary, tags and people. Tags and
|
|
22
|
+
* people show on the line, on its item and under Tagged. Each save of the notes keeps the text before it, and History
|
|
23
|
+
* shows every version as a line diff, the original first. When the note kept its audio, Replay plays it; while it plays,
|
|
24
|
+
* the transcript line, its item and the note line written at that moment are highlighted, and a time in the transcript
|
|
25
|
+
* jumps there. Your notes are shown read-only while the meeting plays, so their lines can be highlighted.
|
|
26
|
+
*
|
|
20
27
|
* Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
|
|
21
|
-
* GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=…
|
|
28
|
+
* GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
|
|
22
29
|
* POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
|
|
23
|
-
* · summarize · summarize-agent · delete
|
|
30
|
+
* · summarize · summarize-agent · delete · line · recording-delete
|
|
24
31
|
*/
|
|
25
32
|
import { el } from './dom.js';
|
|
26
33
|
import { askConfirm, askText } from './dialogs.js';
|
|
@@ -49,6 +56,9 @@ let engine = null; // { ws, whisper, jev, at }
|
|
|
49
56
|
let showList = false;
|
|
50
57
|
let pollTimer = null; let agentTimer = null;
|
|
51
58
|
let busy = '';
|
|
59
|
+
let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
|
|
60
|
+
let people = null; // { ws, names } — the repository's people, offered in the line editor
|
|
61
|
+
const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
|
|
52
62
|
|
|
53
63
|
const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|
|
54
64
|
const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
|
|
@@ -107,6 +117,42 @@ export function caught(n) {
|
|
|
107
117
|
return out;
|
|
108
118
|
}
|
|
109
119
|
const checkWords = (c) => (!c ? '' : c.verdict === 'differs' ? 'differs from ' + c.doc : c.verdict === 'agrees' ? 'consistent with ' + c.doc : 'see ' + c.doc);
|
|
120
|
+
/** " #tag @Person" for a line, or ''. Pure. */
|
|
121
|
+
export const tagWords = (x) => [...((x && x.tags) || []).map((t) => ' #' + t), ...((x && x.people) || []).map((p) => ' @' + p)].join('');
|
|
122
|
+
/** The lines with tags or people that are not already a caught item, in the order they were said. Pure. */
|
|
123
|
+
export function tagged(n) {
|
|
124
|
+
const c = caught(n);
|
|
125
|
+
const inItems = new Set(Object.values(c).flat().map((x) => x.index));
|
|
126
|
+
return (n.transcript || []).map((x, index) => ({ ...x, index }))
|
|
127
|
+
.filter((x) => x && x.text && !x.dropped && !inItems.has(x.index) && ((x.tags && x.tags.length) || (x.people && x.people.length)));
|
|
128
|
+
}
|
|
129
|
+
/** The index of the last item whose time is at or before `ms`, or -1. `times` is in meeting order. Pure. */
|
|
130
|
+
export function nowIndex(times, ms) {
|
|
131
|
+
let best = -1;
|
|
132
|
+
for (let i = 0; i < times.length; i++) if (typeof times[i] === 'number' && times[i] <= ms && (best < 0 || times[i] >= times[best])) best = i;
|
|
133
|
+
return best;
|
|
134
|
+
}
|
|
135
|
+
/** The note line written at meeting time `ms`: of the lines that have a time at or before it, the latest. Pure. */
|
|
136
|
+
export function noteLineAt(body, lineTimes, ms) {
|
|
137
|
+
const lines = String(body || '').split(/\r?\n/);
|
|
138
|
+
return nowIndex(lines.map((l) => (l.trim() && lineTimes ? lineTimes[l.trim()] : undefined)), ms);
|
|
139
|
+
}
|
|
140
|
+
/** A line diff from `a` to `b`: [{ op: 'same'|'del'|'add', text }]. Longest common subsequence of lines. Pure. */
|
|
141
|
+
export function lineDiff(a, b) {
|
|
142
|
+
const x = String(a || '').split(/\r?\n/); const y = String(b || '').split(/\r?\n/);
|
|
143
|
+
if (x.length * y.length > 250000) return [...x.map((t) => ({ op: 'del', text: t })), ...y.map((t) => ({ op: 'add', text: t }))];
|
|
144
|
+
const L = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0));
|
|
145
|
+
for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
146
|
+
const out = []; let i = 0; let j = 0;
|
|
147
|
+
while (i < x.length && j < y.length) {
|
|
148
|
+
if (x[i] === y[j]) { out.push({ op: 'same', text: x[i] }); i++; j++; }
|
|
149
|
+
else if (L[i + 1][j] >= L[i][j + 1]) out.push({ op: 'del', text: x[i++] });
|
|
150
|
+
else out.push({ op: 'add', text: y[j++] });
|
|
151
|
+
}
|
|
152
|
+
while (i < x.length) out.push({ op: 'del', text: x[i++] });
|
|
153
|
+
while (j < y.length) out.push({ op: 'add', text: y[j++] });
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
110
156
|
/** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
|
|
111
157
|
export function noteMarkdown(n) {
|
|
112
158
|
const out = ['# ' + (n.title || 'Untitled note'), ''];
|
|
@@ -118,9 +164,11 @@ export function noteMarkdown(n) {
|
|
|
118
164
|
for (const [k, title] of [...SECTIONS, ['fact', 'Facts mentioned']]) {
|
|
119
165
|
if (!c[k].length) continue;
|
|
120
166
|
out.push('## ' + title, '', ...c[k].map((x) => '- ' + (k === 'action' && x.source && x.source !== 'speech' ? speakerOf(n, x.source) + ': ' : '') + x.text
|
|
121
|
-
+ (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
|
|
167
|
+
+ tagWords(x) + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
|
|
122
168
|
}
|
|
123
|
-
|
|
169
|
+
const tg = tagged(n);
|
|
170
|
+
if (tg.length) out.push('## Tagged', '', ...tg.map((x) => '- ' + x.text + tagWords(x) + ' [' + fmtTime(x.t) + ']'), '');
|
|
171
|
+
if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text + tagWords(x)), '');
|
|
124
172
|
return out.join('\n');
|
|
125
173
|
}
|
|
126
174
|
/** Float samples at `inRate` as 16 kHz 16-bit samples. Each output sample averages the input samples it covers. Pure. */
|
|
@@ -151,7 +199,7 @@ async function loadList(ctx) {
|
|
|
151
199
|
if (!currentId && list.notes.length) currentId = list.notes[0].id;
|
|
152
200
|
ctx.setState({});
|
|
153
201
|
}
|
|
154
|
-
const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title]);
|
|
202
|
+
const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes]);
|
|
155
203
|
async function loadNote(ctx, id) {
|
|
156
204
|
const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
|
|
157
205
|
if (r && r.ok) {
|
|
@@ -159,6 +207,7 @@ async function loadNote(ctx, id) {
|
|
|
159
207
|
// what the PM is typing wins over what the server last saved
|
|
160
208
|
if (editor && editor.id === id && dirty) { r.note.title = editor.title.value; r.note.body = editor.body.value; }
|
|
161
209
|
r.note.__audio = r.audio || null;
|
|
210
|
+
r.note.__recording = r.recording || null;
|
|
162
211
|
cache.set(id, r.note);
|
|
163
212
|
if (!had || sig(had) !== sig(r.note)) ctx.setState({});
|
|
164
213
|
} else if (r && r.status === 404) { cache.delete(id); if (currentId === id) currentId = null; loadList(ctx); }
|
|
@@ -210,6 +259,8 @@ async function flushSave(ctx) {
|
|
|
210
259
|
if (r && r.ok) {
|
|
211
260
|
savedAt = r.updated_at; saveError = null;
|
|
212
261
|
const n = cache.get(id); if (n) { n.title = title; n.body = body; n.updated_at = r.updated_at; touchRow(n); }
|
|
262
|
+
// a new version was kept: read it, so History shows it
|
|
263
|
+
if (n && typeof r.versions === 'number' && r.versions !== (n.versions || []).length) loadNote(ctx, id);
|
|
213
264
|
} else { dirty = true; saveError = (r && r.error) || 'not saved'; }
|
|
214
265
|
if (dirty && !saveError) scheduleSave(ctx); // typed while the save was in flight
|
|
215
266
|
paintSaveState();
|
|
@@ -237,14 +288,14 @@ async function selectNote(ctx, id) {
|
|
|
237
288
|
showList = false;
|
|
238
289
|
if (id === currentId) { ctx.setState({}); return; }
|
|
239
290
|
await stopAll(ctx); await flushSave(ctx);
|
|
240
|
-
currentId = id; editor = null; savedAt = null; saveError = null; banner = null;
|
|
291
|
+
currentId = id; editor = null; savedAt = null; saveError = null; banner = null; dropPlayer();
|
|
241
292
|
ctx.setState({});
|
|
242
293
|
loadNote(ctx, id);
|
|
243
294
|
}
|
|
244
295
|
async function deleteNote(ctx, n) {
|
|
245
296
|
const sure = await askConfirm({ title: 'Delete this note?', body: '"' + (n.title || 'Untitled note') + '" and its transcript and summaries are deleted from this machine. This cannot be undone.', confirmLabel: 'Delete note', danger: true });
|
|
246
297
|
if (!sure) return;
|
|
247
|
-
await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false;
|
|
298
|
+
await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false; dropPlayer();
|
|
248
299
|
const r = await pmPost('/api/notes/delete', { id: n.id });
|
|
249
300
|
if (r && r.ok) { cache.delete(n.id); currentId = null; editor = null; if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id); if (list && list.notes.length) currentId = list.notes[0].id; banner = { tone: 'ok', text: 'The note was deleted.' }; }
|
|
250
301
|
else banner = { tone: 'bad', text: (r && r.error) || 'the note was not deleted' };
|
|
@@ -254,7 +305,7 @@ async function setPrivate(ctx, n, priv) {
|
|
|
254
305
|
// a private press drops what is still buffered: nothing more is sent, transcribed or summarized
|
|
255
306
|
if (priv) await stopAll(ctx, { discard: true });
|
|
256
307
|
const r = await pmPost('/api/notes/private', { id: n.id, private: priv });
|
|
257
|
-
if (r && r.ok) { n.private = r.private; touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this note goes to Jev or to any agent.' } : null; }
|
|
308
|
+
if (r && r.ok) { n.private = r.private; if (priv) { n.__recording = null; dropPlayer(); } touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, its audio was deleted, and nothing from this note goes to Jev or to any agent.' } : null; }
|
|
258
309
|
else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
|
|
259
310
|
ctx.setState({});
|
|
260
311
|
}
|
|
@@ -310,6 +361,133 @@ async function renameSpeaker(ctx, n, source) {
|
|
|
310
361
|
ctx.setState({});
|
|
311
362
|
}
|
|
312
363
|
|
|
364
|
+
/* ── corrections, history and replay (Run 3, D92) ─────────────────────────────────────────────────── */
|
|
365
|
+
async function peopleNames(ctx) {
|
|
366
|
+
const ws = wsOf(ctx);
|
|
367
|
+
if (people && people.ws === ws) return people.names;
|
|
368
|
+
const r = await pmGet('/api/people' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
|
|
369
|
+
const names = r && r.ok && Array.isArray(r.people) ? [...new Set(r.people.map((x) => x && x.name).filter(Boolean))].slice(0, 24) : [];
|
|
370
|
+
people = { ws, names };
|
|
371
|
+
return names;
|
|
372
|
+
}
|
|
373
|
+
const splitTags = (v) => String(v || '').split(/[\s,]+/).map((t) => t.replace(/^#+/, '')).filter(Boolean);
|
|
374
|
+
const splitPeople = (v) => String(v || '').split(',').map((t) => t.replace(/^\s*@/, '').trim()).filter(Boolean);
|
|
375
|
+
/** The editor for one transcript line: its words, label, place in the summary, tags and people. */
|
|
376
|
+
async function editLine(ctx, n, index) {
|
|
377
|
+
const x = (n.transcript || [])[index];
|
|
378
|
+
if (!x) return;
|
|
379
|
+
const text = field({ label: 'Words', kind: 'textarea', value: x.text, attrs: { 'data-nt-ed-text': '' } });
|
|
380
|
+
const cat = field({ label: 'Label', kind: 'select', value: LABELS.some(([v]) => v === x.cat) ? x.cat : 'statement', options: LABELS.map(([value, label]) => ({ value, label })), attrs: { 'data-nt-ed-cat': '' } });
|
|
381
|
+
const sumv = x.in_summary === true ? 'in' : x.in_summary === false ? 'out' : 'auto';
|
|
382
|
+
const sum = field({ label: 'Summary', kind: 'select', value: sumv, options: [{ value: 'auto', label: 'As picked' }, { value: 'in', label: 'Always in the summary' }, { value: 'out', label: 'Never in the summary' }], attrs: { 'data-nt-ed-summary': '' } });
|
|
383
|
+
const tags = field({ label: 'Tags', value: (x.tags || []).map((t) => '#' + t).join(' '), placeholder: '#pricing #trial', attrs: { 'data-nt-ed-tags': '' } });
|
|
384
|
+
const ppl = field({ label: 'People', value: (x.people || []).join(', '), placeholder: 'Names, separated by commas', attrs: { 'data-nt-ed-people': '' } });
|
|
385
|
+
const sugg = el('div', { class: 'nt-row', 'data-nt-ed-suggest': '' });
|
|
386
|
+
const addPerson = (name) => {
|
|
387
|
+
const have = splitPeople(ppl.control.value);
|
|
388
|
+
if (!have.some((h) => h.toLowerCase() === name.toLowerCase())) ppl.control.value = [...have, name].join(', ');
|
|
389
|
+
};
|
|
390
|
+
const heard = x.orig_text !== undefined
|
|
391
|
+
? el('div', { class: 'nt-heard', 'data-nt-ed-orig': '' }, el('p', { class: 'nt-hint' }, 'Heard as: ' + x.orig_text),
|
|
392
|
+
button({ label: 'Use what was heard', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => { text.control.value = x.orig_text; }, attrs: { 'data-nt-ed-restore': '' } }))
|
|
393
|
+
: null;
|
|
394
|
+
let h = null;
|
|
395
|
+
const save = async () => {
|
|
396
|
+
const patch = { id: n.id, index, tags: splitTags(tags.control.value), people: splitPeople(ppl.control.value) };
|
|
397
|
+
const t = text.control.value.replace(/\s+/g, ' ').trim();
|
|
398
|
+
if (t !== x.text) patch.text = t;
|
|
399
|
+
if (cat.control.value !== x.cat) patch.cat = cat.control.value;
|
|
400
|
+
if (sum.control.value !== sumv) patch.summary = sum.control.value === 'in' ? true : sum.control.value === 'out' ? false : null;
|
|
401
|
+
const r = await pmPost('/api/notes/line', patch);
|
|
402
|
+
if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; banner = null; h.close(); }
|
|
403
|
+
else banner = { tone: 'bad', text: (r && r.error) || 'the line was not changed' };
|
|
404
|
+
ctx.setState({});
|
|
405
|
+
};
|
|
406
|
+
h = sheet({ title: 'Correct this line', sub: speakerOf(n, x.source) + ', ' + fmtTime(x.t), body: [text, heard, cat, sum, tags, ppl, sugg], attrs: { 'data-nt-line-sheet': '' },
|
|
407
|
+
actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', onClick: save, attrs: { 'data-nt-ed-save': '' } })] });
|
|
408
|
+
const names = await peopleNames(ctx);
|
|
409
|
+
if (h.open && names.length) sugg.replaceChildren(...names.map((name) => button({ label: name, icon: 'plus', variant: 'ghost', size: 'sm', onClick: () => addPerson(name), attrs: { 'data-nt-ed-person': name } })));
|
|
410
|
+
}
|
|
411
|
+
async function unsummarize(ctx, n, index) {
|
|
412
|
+
const r = await pmPost('/api/notes/line', { id: n.id, index, summary: false });
|
|
413
|
+
if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; } else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
|
|
414
|
+
ctx.setState({});
|
|
415
|
+
}
|
|
416
|
+
/** Every kept version of the notes, newest first, each as the lines it changed from the one before. */
|
|
417
|
+
function openHistory(ctx, n) {
|
|
418
|
+
const vs = n.versions || [];
|
|
419
|
+
const now = { at: n.updated_at, title: editor && editor.id === n.id ? editor.title.value : n.title, body: editor && editor.id === n.id ? editor.body.value : n.body, current: true };
|
|
420
|
+
const all = [...vs, now];
|
|
421
|
+
const when = (iso) => dayOf(iso) + ', ' + nowClock(iso);
|
|
422
|
+
let h = null;
|
|
423
|
+
const restore = (v) => {
|
|
424
|
+
h.close();
|
|
425
|
+
if (!editor || editor.id !== n.id) return;
|
|
426
|
+
editor.title.value = v.title || ''; editor.body.value = v.body || '';
|
|
427
|
+
banner = { tone: 'ok', text: 'The version from ' + when(v.at) + ' is back in your notes. The text before it is kept in History.' };
|
|
428
|
+
scheduleSave(ctx); ctx.setState({});
|
|
429
|
+
};
|
|
430
|
+
const items = all.map((v, i) => {
|
|
431
|
+
const diff = i === 0 ? String(v.body || '').split(/\r?\n/).map((t) => ({ op: 'same', text: t })) : lineDiff(all[i - 1].body, v.body);
|
|
432
|
+
const head = v.current ? 'Now' : i === 0 ? 'Original, ' + when(v.at) : 'Version ' + (i + 1) + ', ' + when(v.at);
|
|
433
|
+
return el('div', { class: 'nt-ver', 'data-nt-version': v.current ? 'now' : String(i) },
|
|
434
|
+
el('div', { class: 'nt-set__head' }, head, i > 0 && all[i - 1].title !== v.title ? el('span', { class: 'nt-hint' }, ' · title: ' + (v.title || 'Untitled')) : null),
|
|
435
|
+
el('div', { class: 'nt-diff' }, diff.map((d) => el('div', { class: 'nt-diff__ln nt-diff__ln--' + d.op, 'data-nt-diff': d.op },
|
|
436
|
+
el('span', { class: 'nt-sr' }, d.op === 'add' ? 'added: ' : d.op === 'del' ? 'removed: ' : ''), d.text || ' '))),
|
|
437
|
+
v.current ? null : button({ label: 'Use this version', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => restore(v), attrs: { 'data-nt-restore': String(i) } }));
|
|
438
|
+
}).reverse();
|
|
439
|
+
h = sheet({ title: 'History of your notes', sub: vs.length + ' earlier version' + (vs.length === 1 ? '' : 's') + '. The original is kept.', body: items, attrs: { 'data-nt-history-sheet': '' } });
|
|
440
|
+
}
|
|
441
|
+
function dropPlayer() { if (player) { try { player.el.pause(); } catch { /* gone */ } player = null; } }
|
|
442
|
+
/** The replay of the note's kept audio. One <audio> per note, kept across redraws so playback goes on. */
|
|
443
|
+
function playerFor(ctx, n) {
|
|
444
|
+
const rec = n.__recording;
|
|
445
|
+
if (!rec || n.private) { if (player && player.id === n.id) dropPlayer(); return null; }
|
|
446
|
+
if (player && (player.id !== n.id || (player.bytes !== rec.bytes && !player.playing))) dropPlayer();
|
|
447
|
+
if (player) return player;
|
|
448
|
+
const a = el('audio', { controls: true, preload: 'metadata', class: 'nt-audio', 'data-nt-audio': '', src: '/api/notes/recording?id=' + encodeURIComponent(n.id) + '&b=' + rec.bytes });
|
|
449
|
+
const p = { id: n.id, bytes: rec.bytes, el: a, playing: false };
|
|
450
|
+
a.addEventListener('play', () => { p.playing = true; ctx.setState({}); });
|
|
451
|
+
a.addEventListener('pause', () => { p.playing = false; ctx.setState({}); });
|
|
452
|
+
a.addEventListener('ended', () => { p.playing = false; ctx.setState({}); });
|
|
453
|
+
a.addEventListener('timeupdate', paintNow);
|
|
454
|
+
a.addEventListener('seeked', paintNow);
|
|
455
|
+
player = p;
|
|
456
|
+
return p;
|
|
457
|
+
}
|
|
458
|
+
function seek(ctx, n, ms) {
|
|
459
|
+
const p = playerFor(ctx, n);
|
|
460
|
+
if (!p) return;
|
|
461
|
+
try { p.el.currentTime = Math.max(0, ms / 1000); p.el.play().catch(() => {}); } catch { /* not loaded yet */ }
|
|
462
|
+
paintNow();
|
|
463
|
+
}
|
|
464
|
+
/** Highlight the transcript line, its item and the note line at the replay's time. Classes only: no redraw. */
|
|
465
|
+
function paintNow() {
|
|
466
|
+
if (typeof document === 'undefined') return;
|
|
467
|
+
for (const x of document.querySelectorAll('.nt-now')) x.classList.remove('nt-now');
|
|
468
|
+
const n = currentId ? cache.get(currentId) : null;
|
|
469
|
+
if (!player || !n || player.id !== n.id) return;
|
|
470
|
+
const ms = player.el.currentTime * 1000;
|
|
471
|
+
if (!player.playing && !(ms > 0)) return;
|
|
472
|
+
const i = nowIndex((n.transcript || []).map((x) => x.t), ms);
|
|
473
|
+
if (i >= 0) {
|
|
474
|
+
for (const x of document.querySelectorAll('[data-nt-seg="' + i + '"], [data-nt-item="' + i + '"], [data-nt-ov="' + i + '"]')) x.classList.add('nt-now');
|
|
475
|
+
const ln = document.querySelector('[data-nt-seg="' + i + '"]');
|
|
476
|
+
if (ln && player.playing && ln.scrollIntoView) ln.scrollIntoView({ block: 'nearest' });
|
|
477
|
+
}
|
|
478
|
+
const k = noteLineAt(editor && editor.id === n.id ? editor.body.value : n.body, n.line_times, ms);
|
|
479
|
+
if (k >= 0) { const nl = document.querySelector('[data-nt-noteline="' + k + '"]'); if (nl) nl.classList.add('nt-now'); }
|
|
480
|
+
}
|
|
481
|
+
async function deleteAudio(ctx, n) {
|
|
482
|
+
const sure = await askConfirm({ title: 'Delete this note\'s audio?', body: 'The recording of "' + (n.title || 'Untitled note') + '" is deleted from this machine. The notes and the transcript stay. This cannot be undone.', confirmLabel: 'Delete audio', danger: true });
|
|
483
|
+
if (!sure) return;
|
|
484
|
+
if (player && player.id === n.id) dropPlayer();
|
|
485
|
+
const r = await pmPost('/api/notes/recording-delete', { id: n.id });
|
|
486
|
+
if (r && r.ok) { n.__recording = null; banner = { tone: 'ok', text: 'The audio was deleted. The notes and the transcript are kept.' }; }
|
|
487
|
+
else banner = { tone: 'bad', text: (r && r.error) || 'the audio was not deleted' };
|
|
488
|
+
ctx.setState({});
|
|
489
|
+
}
|
|
490
|
+
|
|
313
491
|
/* ── listening with local Whisper ─────────────────────────────────────────────────────────────── */
|
|
314
492
|
async function openSource(kind) {
|
|
315
493
|
const md = navigator.mediaDevices;
|
|
@@ -491,7 +669,9 @@ async function chooseSources(ctx, n) {
|
|
|
491
669
|
const meet = field({ label: 'The meeting', kind: 'check', value: local, attrs: { 'data-nt-src-meeting': '', disabled: !local } });
|
|
492
670
|
const body = [mic, meet,
|
|
493
671
|
el('p', { class: 'nt-hint' }, local ? 'Pick the meeting\'s tab or window next, and tick "Share tab audio".' : 'The meeting can be heard once local transcription is set up in Settings. Until then the microphone is recognised by the browser on this device.'),
|
|
494
|
-
el('p', { class: 'nt-consent', 'data-nt-consent': '' },
|
|
672
|
+
el('p', { class: 'nt-consent', 'data-nt-consent': '' }, local
|
|
673
|
+
? 'Speech is transcribed on this machine' + (eng.whisper.keep_audio === false ? ', and no audio is kept' : ', and the audio is kept on this machine so you can replay the meeting') + '. Let people know you\'re taking notes.'
|
|
674
|
+
: 'Speech is recognised by the browser on this device, and no audio is kept. Let people know you\'re taking notes.')];
|
|
495
675
|
let h = null;
|
|
496
676
|
const go = () => {
|
|
497
677
|
const want = [mic.control.checked && 'mic', local && meet.control.checked && 'meeting'].filter(Boolean);
|
|
@@ -504,7 +684,7 @@ async function chooseSources(ctx, n) {
|
|
|
504
684
|
}
|
|
505
685
|
async function setSwitch(ctx, patch) {
|
|
506
686
|
const ws = wsOf(ctx);
|
|
507
|
-
if (!ws && patch.language === undefined && patch.model === undefined) return;
|
|
687
|
+
if (!ws && patch.language === undefined && patch.model === undefined && patch.keep_audio === undefined) return;
|
|
508
688
|
const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
|
|
509
689
|
if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
|
|
510
690
|
ctx.setState({});
|
|
@@ -531,7 +711,12 @@ function openSettings(ctx, n) {
|
|
|
531
711
|
onCommit: (v) => setSwitch(ctx, { model: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-model': '' } }) : null,
|
|
532
712
|
w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null,
|
|
533
713
|
w && w.ready && w.languages ? field({ label: 'Language spoken in the meeting', kind: 'select', value: w.language, options: w.languages,
|
|
534
|
-
onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null
|
|
714
|
+
onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null,
|
|
715
|
+
w ? field({ label: 'Keep the audio for replay', kind: 'check', value: w.keep_audio !== false,
|
|
716
|
+
onCommit: (v) => setSwitch(ctx, { keep_audio: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-keep-audio': '' } }) : null,
|
|
717
|
+
w ? el('p', { class: 'nt-hint', 'data-nt-keep-hint': '' }, w.keep_audio !== false
|
|
718
|
+
? 'The meeting audio is kept on this machine, outside every repository, until you delete it. It is never sent anywhere. Browser recognition keeps no audio.'
|
|
719
|
+
: 'No audio is kept: each clip is deleted once it is transcribed, and a meeting cannot be replayed.') : null));
|
|
535
720
|
if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
|
|
536
721
|
else {
|
|
537
722
|
const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });
|
|
@@ -546,6 +731,7 @@ function openSettings(ctx, n) {
|
|
|
546
731
|
el('div', { class: 'nt-row' },
|
|
547
732
|
button({ label: n.private ? 'Mark not private' : 'Mark private', onClick: () => { h.close(); setPrivate(ctx, n, !n.private); }, attrs: { [n.private ? 'data-nt-unprivate' : 'data-nt-mark-private']: '' } }),
|
|
548
733
|
button({ label: busy === 'agent' ? 'Starting …' : 'Summary by agent', disabled: n.private || busy === 'agent', onClick: () => { h.close(); summarizeAgent(ctx, n); }, attrs: { 'data-nt-summarize-agent': '' } }),
|
|
734
|
+
n.__recording ? button({ label: 'Delete this note\'s audio', variant: 'ghost', onClick: () => { h.close(); deleteAudio(ctx, n); }, attrs: { 'data-nt-delete-audio': '' } }) : null,
|
|
549
735
|
button({ label: 'Delete', variant: 'ghost', onClick: () => { h.close(); deleteNote(ctx, n); }, attrs: { 'data-nt-delete': '' } }))));
|
|
550
736
|
return parts;
|
|
551
737
|
};
|
|
@@ -584,7 +770,9 @@ function listening(n) {
|
|
|
584
770
|
function header(ctx, n, ed) {
|
|
585
771
|
const live = listening(n);
|
|
586
772
|
const when = el('span', { class: 'nt-when' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at));
|
|
587
|
-
const
|
|
773
|
+
const nv = (n.versions || []).length;
|
|
774
|
+
const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()),
|
|
775
|
+
nv ? button({ label: 'Edited', icon: 'history', variant: 'ghost', size: 'sm', onClick: () => openHistory(ctx, n), attrs: { 'data-nt-history': String(nv), title: nv + ' earlier version' + (nv === 1 ? '' : 's') + ' kept' } }) : null);
|
|
588
776
|
const acts = [];
|
|
589
777
|
if (live) {
|
|
590
778
|
acts.push(el('span', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
|
|
@@ -622,6 +810,11 @@ function checkMark(x) {
|
|
|
622
810
|
if (c.verdict === 'agrees') return el('span', { class: 'nt-ok', 'data-nt-check': 'agrees', title: 'Jev rates it ' + (c.words || 'likely') + ' that ' + where + ' already says this', role: 'img', 'aria-label': 'consistent with ' + baseName(c.doc) }, icon('check'));
|
|
623
811
|
return el('span', { class: 'nt-rel', 'data-nt-check': 'related', title: 'The closest passage in this repository: ' + where }, 'see ' + baseName(c.doc));
|
|
624
812
|
}
|
|
813
|
+
function chips(x) {
|
|
814
|
+
return [...(x.tags || []).map((t) => el('span', { class: 'nt-tag', 'data-nt-tag': t }, '#' + t)),
|
|
815
|
+
...(x.people || []).map((p) => el('span', { class: 'nt-tag nt-tag--person', 'data-nt-person': p }, '@' + p))];
|
|
816
|
+
}
|
|
817
|
+
const editedMark = (x) => (x.orig_text !== undefined ? el('span', { class: 'nt-edited', 'data-nt-edited': '', title: 'Heard as: ' + x.orig_text }, 'edited') : null);
|
|
625
818
|
function sectionOf(ctx, n, key, title, items) {
|
|
626
819
|
if (!items.length) return null;
|
|
627
820
|
return el('section', { class: 'nt-sec', 'data-nt-sec': key },
|
|
@@ -629,9 +822,9 @@ function sectionOf(ctx, n, key, title, items) {
|
|
|
629
822
|
el('ul', { class: 'nt-items' }, items.map((x) => el('li', { class: 'nt-item', 'data-nt-item': String(x.index) },
|
|
630
823
|
el('span', { class: 'nt-item__text' },
|
|
631
824
|
key === 'action' && x.source && x.source !== 'speech' ? el('span', { class: 'nt-who' }, speakerOf(n, x.source) + ': ') : null,
|
|
632
|
-
x.text, ' ', checkMark(x)),
|
|
825
|
+
x.text, ' ', checkMark(x), ' ', editedMark(x), chips(x)),
|
|
633
826
|
el('span', { class: 'nt-at' }, fmtTime(x.t)),
|
|
634
|
-
key === 'fact' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
|
|
827
|
+
key === 'fact' || key === 'tagged' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
|
|
635
828
|
}
|
|
636
829
|
function summarySection(ctx, n) {
|
|
637
830
|
const a = n.agent;
|
|
@@ -646,13 +839,19 @@ function summarySection(ctx, n) {
|
|
|
646
839
|
else if (a && a.started === false && a.reason) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-failed': '' }, 'No agent session started: ' + a.reason + '.'));
|
|
647
840
|
if (!ov || !ov.length) return out.length ? el('section', { class: 'nt-sec' }, out) : null;
|
|
648
841
|
return el('section', { class: 'nt-sec', 'data-nt-summary': 'local' }, el('h3', { class: 'nt-sec__h' }, 'Summary'),
|
|
649
|
-
el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain' }, el('span', { class: 'nt-item__text' }, x.text)
|
|
842
|
+
el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain', 'data-nt-ov': x.index === undefined ? null : String(x.index) }, el('span', { class: 'nt-item__text' }, x.text),
|
|
843
|
+
x.index === undefined ? null : iconButton({ icon: 'x', ariaLabel: 'Take out of the summary', onClick: () => unsummarize(ctx, n, x.index), size: 'sm', attrs: { 'data-nt-unsum': String(x.index) } })))),
|
|
650
844
|
el('p', { class: 'nt-hint' }, 'Picked from what was said, on this machine.'), ...out);
|
|
651
845
|
}
|
|
652
846
|
function noteColumn(ctx, n, ed) {
|
|
653
847
|
const live = listening(n);
|
|
654
848
|
const c = caught(n);
|
|
655
|
-
const
|
|
849
|
+
const replaying = player && player.id === n.id && player.playing;
|
|
850
|
+
const notes = replaying
|
|
851
|
+
? el('div', { class: 'nt-notelines', 'data-nt-notelines': '' }, String(ed.body.value || '').split(/\r?\n/).map((l, k) => el('div', { class: 'nt-noteline', 'data-nt-noteline': String(k) }, l || ' ')),
|
|
852
|
+
el('p', { class: 'nt-hint' }, 'Your notes, as the meeting plays. Pause to edit them.'))
|
|
853
|
+
: ed.bodyWrap;
|
|
854
|
+
const parts = [summarySection(ctx, n), notes];
|
|
656
855
|
const sp = speech && speech.id === n.id ? speech : null;
|
|
657
856
|
const bar = [];
|
|
658
857
|
if (sp && sp.mode === 'dictate' && sp.state === 'on') bar.push(el('span', { class: 'nt-hint', 'data-nt-listening': 'dictate', role: 'status' }, 'Dictating into your notes.'), button({ label: 'Stop dictating', variant: 'ghost', onClick: () => { stopSpeech(); ctx.setState({}); }, attrs: { 'data-nt-stop-dictate': '' } }));
|
|
@@ -663,6 +862,7 @@ function noteColumn(ctx, n, ed) {
|
|
|
663
862
|
if (n.__audio && n.__audio.error && live) parts.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-audio-error': '' }, n.__audio.error));
|
|
664
863
|
const secs = SECTIONS.map(([k, t]) => sectionOf(ctx, n, k, t, c[k])).filter(Boolean);
|
|
665
864
|
if (!live && (n.transcript || []).length) { const f = sectionOf(ctx, n, 'fact', 'Facts mentioned', c.fact); if (f) secs.push(f); }
|
|
865
|
+
const tg = sectionOf(ctx, n, 'tagged', 'Tagged', tagged(n)); if (tg) secs.push(tg);
|
|
666
866
|
if (secs.length) parts.push(...secs);
|
|
667
867
|
else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening …' : (n.transcript || []).length ? 'Nothing was caught as a decision, action or question.'
|
|
668
868
|
: 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
|
|
@@ -677,8 +877,13 @@ function transcriptColumn(ctx, n) {
|
|
|
677
877
|
const spk = x.source === 'meeting'
|
|
678
878
|
? button({ label: who, variant: 'ghost', size: 'sm', onClick: () => renameSpeaker(ctx, n, 'meeting'), attrs: { 'data-nt-spk': 'meeting', title: 'Name this speaker' } })
|
|
679
879
|
: el('span', { class: 'nt-spk', 'data-nt-spk': x.source || 'speech' }, who);
|
|
680
|
-
|
|
681
|
-
|
|
880
|
+
const at = n.__recording && !live && typeof x.t === 'number'
|
|
881
|
+
? button({ label: fmtTime(x.t), variant: 'ghost', size: 'sm', onClick: () => seek(ctx, n, x.t), attrs: { 'data-nt-seek': String(i), title: 'Play from here' } })
|
|
882
|
+
: el('span', { class: 'nt-at' }, fmtTime(x.t));
|
|
883
|
+
return el('div', { class: 'nt-ln' + (['decision', 'action', 'question', 'fact'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
|
|
884
|
+
el('div', { class: 'nt-ln__meta' }, spk, at, lab, editedMark(x),
|
|
885
|
+
iconButton({ icon: 'ellipsis', ariaLabel: 'Correct this line', onClick: () => editLine(ctx, n, i), size: 'sm', attrs: { 'data-nt-edit': String(i) } })),
|
|
886
|
+
el('div', null, x.text), x.tags || x.people ? el('div', { class: 'nt-tags' }, chips(x)) : null);
|
|
682
887
|
});
|
|
683
888
|
const sp = speech && speech.id === n.id && speech.mode === 'record' && speech.state === 'on' ? speech : null;
|
|
684
889
|
if (sp) lines.push(el('div', { class: 'nt-ln nt-ln--interim', 'data-nt-interim': '', 'aria-live': 'polite' }, sp.interim || ''));
|
|
@@ -716,7 +921,11 @@ export function renderBoard(inner, ctx) {
|
|
|
716
921
|
wrap.appendChild(header(ctx, n, ed));
|
|
717
922
|
if (showList) wrap.appendChild(listPane(ctx));
|
|
718
923
|
if (n.private) wrap.appendChild(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
|
|
924
|
+
const pl = !listening(n) ? playerFor(ctx, n) : null;
|
|
925
|
+
if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), pl.el,
|
|
926
|
+
el('span', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the transcript to play from there.')));
|
|
719
927
|
wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
|
|
928
|
+
if (pl) queueMicrotask(paintNow);
|
|
720
929
|
if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
|
|
721
930
|
requestAnimationFrame(fitBody);
|
|
722
931
|
}
|
package/workspace/server.js
CHANGED
|
@@ -131,6 +131,7 @@ const focus = require('./lib/focus'); // buildFocus(input) →
|
|
|
131
131
|
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
132
132
|
const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
|
|
133
133
|
const docSyncStore = require('./lib/doc-sync-store');
|
|
134
|
+
const docLibrary = require('./lib/doc-library'); // every repository file by category, and the links the files write (docs-library-graph)
|
|
134
135
|
const peopleStore = require('./lib/people-store'); // people-directory (D90) — people.jsonl, folded on read
|
|
135
136
|
// The session each repository's Check now started (workspace id → session id), so a second press while it runs refuses.
|
|
136
137
|
const docCheckSessions = new Map();
|
|
@@ -6549,6 +6550,7 @@ async function handleRequest(req, res) {
|
|
|
6549
6550
|
const docs = v.docs.map((d) => ({
|
|
6550
6551
|
...d,
|
|
6551
6552
|
initiative: owner.get(d.path) || (d.initiative_id ? { id: d.initiative_id, title: d.initiative_id } : null),
|
|
6553
|
+
category: docLibrary.categoryOf(d.path, d.type),
|
|
6552
6554
|
links: d.links.map((l) => {
|
|
6553
6555
|
if (l.syncing) return l;
|
|
6554
6556
|
const ps = docSyncSessions.get(`${wid}|${d.path}|${l.kind}`);
|
|
@@ -6557,6 +6559,25 @@ async function handleRequest(req, res) {
|
|
|
6557
6559
|
}));
|
|
6558
6560
|
return json(res, { ...v, docs, ws: w ? w.id : null, session: live, connectors: fourPanel.connectors(os.homedir()) });
|
|
6559
6561
|
}
|
|
6562
|
+
// All files (docs-library-graph): every tracked markdown file by category, its initiative, and the links the files
|
|
6563
|
+
// write (lib/doc-library.js over pmos kg). Kept in memory per repository state; nothing is written into the repository.
|
|
6564
|
+
if (p === '/api/docs/library' && req.method === 'GET') {
|
|
6565
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6566
|
+
const root = w ? w.root : SELF_ROOT;
|
|
6567
|
+
const owners = () => {
|
|
6568
|
+
const own = new Map();
|
|
6569
|
+
if (wsShape(root) !== 'pmos') return own;
|
|
6570
|
+
const real = (id) => fs.existsSync(path.join(root, 'planning', 'initiatives', id + '.md'));
|
|
6571
|
+
for (const g of initiativeIndex(root).initiatives) {
|
|
6572
|
+
if (!real(g.id)) continue;
|
|
6573
|
+
for (const a of g.artifacts) { const rel = String(a.path).split(path.sep).join('/'); if (!a.shared && !own.has(rel)) own.set(rel, { id: g.id, title: g.title || g.id }); }
|
|
6574
|
+
}
|
|
6575
|
+
return own;
|
|
6576
|
+
};
|
|
6577
|
+
const lib = docLibrary.library(root, { stateDir: STATE, owners });
|
|
6578
|
+
if (!lib.ok) return json(res, lib, 400);
|
|
6579
|
+
return json(res, { ...lib, ws: w ? w.id : null });
|
|
6580
|
+
}
|
|
6560
6581
|
if (p === '/api/docs/files' && req.method === 'GET') {
|
|
6561
6582
|
const w = getWs(url.searchParams.get('ws'));
|
|
6562
6583
|
const root = w ? w.root : SELF_ROOT;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "docs-library-graph: every repository file by category in Docs, the links the files write, and an initiative's files as a network. The browser half runs in pm-docs-test.js (docs-three-way-sync's fragment).",
|
|
3
|
+
"modules": ["doc-library.test.js"],
|
|
4
|
+
"seconds": { "doc-library.test.js": 2 }
|
|
5
|
+
}
|