create-pmos 0.151.0 → 0.153.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.
@@ -0,0 +1,395 @@
1
+ /* pm/c3-md.js — markdown for the console3 document sheet (initiative c3-doc-sheet-rich): read it as rich text, edit it as rich
2
+ * text, and show a change as a rendered diff.
3
+ *
4
+ * parseMd(md) → blocks the subset PMOS documents use: headings, paragraphs, quotes, bulleted, numbered and
5
+ * checklist lists, tables, dividers and fenced code; inline bold, italic, code and links
6
+ * toMd(blocks) → md the canonical markdown of blocks; toMd(parseMd(toMd(b))) === toMd(b)
7
+ * canonMd(md) → md toMd(parseMd(md)): two texts that read the same compare equal
8
+ * diffMd(before, after) → md `after`, with the words removed and added since `before` wrapped in markers that
9
+ * toDom draws as <del> and <ins>; a line or sentence rewritten in most of its words is
10
+ * shown whole, removed then added
11
+ * toDom(blocks, doc?) → Node[] the blocks as elements (no innerHTML: every text is a text node); links are http(s),
12
+ * mailto or relative only
13
+ * fromDom(root) → blocks what the rich-text editor holds, read back as blocks
14
+ *
15
+ * parseMd, toMd, canonMd and diffMd are pure (Node-testable: c3-md.test.js); toDom and fromDom need a document.
16
+ */
17
+
18
+ export const INS = '\uE000';
19
+ export const INS_END = '\uE001';
20
+ export const DEL = '\uE002';
21
+ export const DEL_END = '\uE003';
22
+ const MARK = /[\uE000-\uE003]/g;
23
+
24
+ /* ── inline ── */
25
+
26
+ /** Inline markdown → [{ t:'text', s } | { t:'b'|'i', c:[…] } | { t:'code', s } | { t:'a', href, c:[…] }]. */
27
+ export function parseInline(src) {
28
+ const s = String(src == null ? '' : src);
29
+ const out = [];
30
+ let buf = '';
31
+ const flush = () => { if (buf) { out.push({ t: 'text', s: buf }); buf = ''; } };
32
+ let i = 0;
33
+ while (i < s.length) {
34
+ const c = s[i];
35
+ if (c === '\\' && i + 1 < s.length && /[\\`*_[\]()#|>-]/.test(s[i + 1])) { buf += s[i + 1]; i += 2; continue; }
36
+ if (c === '`') {
37
+ const j = s.indexOf('`', i + 1);
38
+ if (j > i) { flush(); out.push({ t: 'code', s: s.slice(i + 1, j) }); i = j + 1; continue; }
39
+ }
40
+ if (c === '[') {
41
+ const m = /^\[((?:[^\]\\]|\\.)+)\]\(([^)\s]+)\)/.exec(s.slice(i));
42
+ if (m) { flush(); out.push({ t: 'a', href: m[2], c: parseInline(m[1]) }); i += m[0].length; continue; }
43
+ }
44
+ if ((c === '*' || c === '_') && s[i + 1] === c) {
45
+ const j = s.indexOf(c + c, i + 2);
46
+ if (j > i + 2) { flush(); out.push({ t: 'b', c: parseInline(s.slice(i + 2, j)) }); i = j + 2; continue; }
47
+ }
48
+ if ((c === '*' || c === '_') && s[i + 1] !== c && s[i + 1] !== ' ' && (c === '*' || !/\w/.test(s[i - 1] || ''))) {
49
+ let j = i + 1;
50
+ while (j < s.length && !(s[j] === c && s[j + 1] !== c && s[j - 1] !== ' ' && s[j - 1] !== c)) j++;
51
+ if (j < s.length && j > i + 1 && (c === '*' || !/\w/.test(s[j + 1] || ''))) { flush(); out.push({ t: 'i', c: parseInline(s.slice(i + 1, j)) }); i = j + 1; continue; }
52
+ }
53
+ buf += c; i++;
54
+ }
55
+ flush();
56
+ return out;
57
+ }
58
+
59
+ // Text is escaped only where markdown would read it as syntax: a backslash before punctuation, `, *, [ and ], and an _ that
60
+ // is not inside a word (snake_case stays as written).
61
+ const escText = (s) => String(s).replace(/\\(?=[\\`*_[\]()#|>-])/g, '\\\\').replace(/[`*[\]]/g, '\\$&').replace(/(^|\W)_|_(?=\W|$)/g, (m) => m.replace('_', '\\_'));
62
+ /** Inline nodes → markdown. `raw`: text as it is, unescaped (for a plain-text label). */
63
+ export function inlineMd(nodes, raw) {
64
+ return (nodes || []).map((n) => {
65
+ if (n.t === 'text') return raw ? n.s : escText(n.s);
66
+ if (n.t === 'code') return raw ? n.s : '`' + n.s + '`';
67
+ const inner = inlineMd(n.c, raw);
68
+ if (raw || !inner.trim()) return inner;
69
+ const lead = inner.match(/^\s*/)[0]; const tail = inner.match(/\s*$/)[0]; const core = inner.trim();
70
+ if (n.t === 'b') return lead + '**' + core + '**' + tail;
71
+ if (n.t === 'i') return lead + '*' + core + '*' + tail;
72
+ if (n.t === 'a') return lead + '[' + core + '](' + n.href + ')' + tail;
73
+ return inner;
74
+ }).join('');
75
+ }
76
+ // a paragraph or list item whose text would start a heading, quote, list or rule is escaped at its start
77
+ const lineSafe = (s) => s.replace(/^(#{1,6}\s|>|[-+]\s|\d+[.)]\s|-{3,}\s*$)/, '\\$1');
78
+
79
+ /* ── blocks ── */
80
+
81
+ const LIST = /^(\s*)([-*+]|\d+[.)])\s+/;
82
+ const TASK = /^\[( |x|X)\]\s+/;
83
+ const cells = (r) => {
84
+ const t = r.trim().replace(/^\|/, '').replace(/\|\s*$/, '');
85
+ const out = []; let cur = ''; for (let i = 0; i < t.length; i++) { if (t[i] === '\\' && t[i + 1] === '|') { cur += '|'; i++; } else if (t[i] === '|') { out.push(cur.trim()); cur = ''; } else cur += t[i]; }
86
+ out.push(cur.trim()); return out;
87
+ };
88
+ const isRule = (l) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(l);
89
+ const startsBlock = (l) => /^(#{1,6}\s|>|```|~~~|\s*(-{3,}|\*{3,}|_{3,})\s*$)/.test(l) || LIST.test(l) || /^\s*\|/.test(l);
90
+
91
+ /** Markdown → blocks: { t:'h', level, c } · { t:'p', c } · { t:'quote', blocks } · { t:'list', ordered, items:[{ task, c }] } ·
92
+ * { t:'table', head:[c], rows:[[c]] } · { t:'hr' } · { t:'code', lang, s }. Marker characters (diffMd) pass through as text. */
93
+ export function parseMd(md) {
94
+ const L = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
95
+ const out = []; let i = 0;
96
+ const bare = (l) => l.replace(MARK, '');
97
+ while (i < L.length) {
98
+ const l = L[i]; const b = bare(l); let m;
99
+ if (!b.trim()) { i++; continue; }
100
+ if ((m = /^(```|~~~)\s*([\w-]*)/.exec(b))) {
101
+ const fence = m[1]; const body = []; i++;
102
+ while (i < L.length && !bare(L[i]).startsWith(fence)) body.push(L[i++]);
103
+ i++; out.push({ t: 'code', lang: m[2] || '', s: body.join('\n') }); continue;
104
+ }
105
+ if ((m = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l))) { out.push({ t: 'h', level: m[1].length, c: parseInline(m[2]) }); i++; continue; }
106
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(b)) { out.push({ t: 'hr' }); i++; continue; }
107
+ if (/^\s*>/.test(b)) {
108
+ const body = [];
109
+ while (i < L.length && /^\s*>/.test(bare(L[i]))) body.push(L[i++].replace(/^(\s*[\uE000-\uE003]*)\s*>\s?/, '$1'));
110
+ out.push({ t: 'quote', blocks: parseMd(body.join('\n')) }); continue;
111
+ }
112
+ if (/^\s*\|/.test(b) && i + 1 < L.length && isRule(bare(L[i + 1]))) {
113
+ const head = cells(l); i += 2; const rows = [];
114
+ while (i < L.length && /^\s*\|/.test(bare(L[i]))) rows.push(cells(L[i++]));
115
+ out.push({ t: 'table', head: head.map(parseInline), rows: rows.map((r) => head.map((_, k) => parseInline(r[k] || ''))) }); continue;
116
+ }
117
+ if (LIST.test(b)) {
118
+ const ordered = /^\s*\d/.test(b); const items = [];
119
+ while (i < L.length && LIST.test(bare(L[i])) && /^\s*\d/.test(bare(L[i])) === ordered) {
120
+ const lead = (/^[\uE000-\uE003]*/.exec(L[i]) || [''])[0];
121
+ let t = lead + L[i].slice(lead.length).replace(LIST, ''); i++;
122
+ while (i < L.length && /^\s{2,}\S/.test(L[i]) && !LIST.test(bare(L[i]))) t += ' ' + L[i++].trim();
123
+ const tk = TASK.exec(t.replace(MARK, ''));
124
+ if (tk) { const k = t.indexOf('['); t = t.slice(0, k) + t.slice(k).replace(TASK, ''); }
125
+ items.push({ task: tk ? tk[1] !== ' ' : null, c: parseInline(t) });
126
+ }
127
+ out.push({ t: 'list', ordered, items }); continue;
128
+ }
129
+ const body = [];
130
+ while (i < L.length && bare(L[i]).trim() && (!body.length || !startsBlock(bare(L[i])))) body.push(L[i++].trim());
131
+ out.push({ t: 'p', c: parseInline(body.join(' ')) });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** Blocks → markdown, one blank line between blocks. */
137
+ export function toMd(blocks) {
138
+ return (blocks || []).map((b) => {
139
+ if (b.t === 'h') return '#'.repeat(b.level || 2) + ' ' + inlineMd(b.c).trim();
140
+ if (b.t === 'p') return lineSafe(inlineMd(b.c).trim());
141
+ if (b.t === 'hr') return '---';
142
+ if (b.t === 'code') return '```' + (b.lang || '') + '\n' + b.s + '\n```';
143
+ if (b.t === 'quote') return toMd(b.blocks).split('\n').map((x) => (x ? '> ' + x : '>')).join('\n');
144
+ if (b.t === 'list') return b.items.map((it, k) => (b.ordered ? (k + 1) + '. ' : '- ') + (it.task == null ? '' : it.task ? '[x] ' : '[ ] ') + inlineMd(it.c).trim()).join('\n');
145
+ if (b.t === 'table') {
146
+ const cell = (c) => inlineMd(c).trim().replace(/\|/g, '\\|');
147
+ const w = Math.max(b.head.length, ...b.rows.map((r) => r.length));
148
+ const line = (r) => '| ' + Array.from({ length: w }, (_, k) => (r[k] ? cell(r[k]) : '')).join(' | ') + ' |';
149
+ return [line(b.head), '|' + Array.from({ length: w }, () => '---').join('|') + '|', ...b.rows.map(line)].join('\n');
150
+ }
151
+ return '';
152
+ }).filter((x) => x !== '').join('\n\n');
153
+ }
154
+
155
+ export const canonMd = (md) => toMd(parseMd(md));
156
+
157
+ /* ── the rendered diff ── */
158
+
159
+ function lcs(a, b) {
160
+ const n = a.length, m = b.length;
161
+ if (n * m > 400000) return [...a.map((x) => ['-', x]), ...b.map((x) => ['+', x])];
162
+ const T = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
163
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) T[i][j] = a[i] === b[j] ? T[i + 1][j + 1] + 1 : Math.max(T[i + 1][j], T[i][j + 1]);
164
+ const ops = []; let i = 0, j = 0;
165
+ while (i < n && j < m) { if (a[i] === b[j]) { ops.push(['=', a[i]]); i++; j++; } else if (T[i + 1][j] >= T[i][j + 1]) ops.push(['-', a[i++]]); else ops.push(['+', b[j++]]); }
166
+ while (i < n) ops.push(['-', a[i++]]); while (j < m) ops.push(['+', b[j++]]);
167
+ return ops;
168
+ }
169
+ /** Runs of ops: [{ eq:true, items } | { eq:false, del:[…], add:[…] }]. */
170
+ function runs(ops) {
171
+ const out = [];
172
+ for (const [op, x] of ops) {
173
+ const last = out[out.length - 1];
174
+ if (op === '=') { if (last && last.eq) last.items.push(x); else out.push({ eq: true, items: [x] }); }
175
+ else { if (!last || last.eq) out.push({ eq: false, del: [], add: [] }); out[out.length - 1][op === '-' ? 'del' : 'add'].push(x); }
176
+ }
177
+ return out;
178
+ }
179
+ const wrap = (s, a, z) => (s && s.trim() ? a + s + z : s);
180
+ function words(a, b) {
181
+ const tok = (x) => x.match(/\s+|[^\s]+/g) || [];
182
+ const A = tok(a), B = tok(b); const ops = lcs(A, B);
183
+ const real = (x) => /\S/.test(x);
184
+ const same = ops.filter(([o, x]) => o === '=' && real(x)).length;
185
+ const tot = Math.max(A.filter(real).length, B.filter(real).length);
186
+ if (tot > 4 && same / tot < 0.6) return wrap(a, DEL, DEL_END) + ' ' + wrap(b, INS, INS_END);
187
+ return runs(ops).map((r) => (r.eq ? r.items.join('') : wrap(r.del.join(''), DEL, DEL_END) + (r.del.length && r.add.length ? ' ' : '') + wrap(r.add.join(''), INS, INS_END))).join('');
188
+ }
189
+ function sentences(a, b) {
190
+ const cut = (x) => x.split(/(?<=[.;!?])\s+/);
191
+ const A = cut(a), B = cut(b);
192
+ if (A.length < 2 && B.length < 2) return words(a, b);
193
+ return runs(lcs(A, B)).map((r) => {
194
+ if (r.eq) return r.items.join(' ');
195
+ const n = Math.min(r.del.length, r.add.length); const parts = [];
196
+ for (let k = 0; k < n; k++) parts.push(words(r.del[k], r.add[k]));
197
+ r.del.slice(n).forEach((x) => parts.push(wrap(x, DEL, DEL_END)));
198
+ r.add.slice(n).forEach((x) => parts.push(wrap(x, INS, INS_END)));
199
+ return parts.join(' ');
200
+ }).join(' ');
201
+ }
202
+ const PREFIX = /^(\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?|\s*>\s?|#{1,6}\s+)/;
203
+ function wrapLine(l, a, z) {
204
+ if (!l.trim() || isRule(l)) return l;
205
+ if (/^\s*\|/.test(l)) return '| ' + cells(l).map((c) => wrap(c, a, z)).join(' | ') + ' |';
206
+ if (/^(```|~~~)/.test(l) || /^\s*(-{3,}|\*{3,})\s*$/.test(l)) return l;
207
+ const p = (PREFIX.exec(l) || [''])[0];
208
+ return p + wrap(l.slice(p.length), a, z);
209
+ }
210
+ function lineDiff(a, b) {
211
+ const pa = (PREFIX.exec(a) || [''])[0], pb = (PREFIX.exec(b) || [''])[0];
212
+ if (/^\s*\|/.test(a) && /^\s*\|/.test(b) && !isRule(a) && !isRule(b)) {
213
+ const ca = cells(a), cb = cells(b);
214
+ return '| ' + cb.map((c, k) => (ca[k] === c ? c : sentences(ca[k] || '', c))).join(' | ') + ' |';
215
+ }
216
+ if (pa === pb) return pb + sentences(a.slice(pa.length), b.slice(pb.length));
217
+ return null;
218
+ }
219
+
220
+ /** `after` with the change since `before` marked. `before` null: the whole of `after` is new. */
221
+ export function diffMd(before, after) {
222
+ const B = String(after == null ? '' : after).split('\n');
223
+ if (before == null) return B.map((l) => wrapLine(l, INS, INS_END)).join('\n');
224
+ const A = String(before).split('\n');
225
+ const out = [];
226
+ for (const r of runs(lcs(A, B))) {
227
+ if (r.eq) { out.push(...r.items); continue; }
228
+ const n = Math.min(r.del.length, r.add.length);
229
+ const removed = []; const added = [];
230
+ for (let k = 0; k < n; k++) {
231
+ const d = lineDiff(r.del[k], r.add[k]);
232
+ if (d == null) { removed.push(wrapLine(r.del[k], DEL, DEL_END)); added.push(wrapLine(r.add[k], INS, INS_END)); } else added.push(d);
233
+ }
234
+ r.del.slice(n).forEach((l) => removed.push(wrapLine(l, DEL, DEL_END)));
235
+ r.add.slice(n).forEach((l) => added.push(wrapLine(l, INS, INS_END)));
236
+ // a removed table row or list item stays in its table or list: put it with the rows after it
237
+ out.push(...removed.filter((l) => l.trim()), ...added);
238
+ }
239
+ return out.join('\n');
240
+ }
241
+
242
+ /** Words added and removed between two texts: [added, removed]. */
243
+ export function wordCounts(before, after) {
244
+ const A = String(before || '').match(/\S+/g) || [], B = String(after || '').match(/\S+/g) || [];
245
+ let add = 0, del = 0;
246
+ for (const [o] of lcs(A, B)) { if (o === '+') add++; else if (o === '-') del++; }
247
+ return [add, del];
248
+ }
249
+
250
+ /* ── DOM ── */
251
+
252
+ /** A safe href: http(s), mailto, a fragment or a relative path; anything else (javascript:, data:) is dropped. */
253
+ export function safeHref(h) {
254
+ const s = String(h || '').trim();
255
+ if (/^(https?:|mailto:)/i.test(s)) return s;
256
+ if (/^[a-z][\w+.-]*:/i.test(s)) return null;
257
+ return s;
258
+ }
259
+
260
+ /** Text with diff markers → nodes: the marked runs inside <ins> and <del>. `st` carries an open marker across inline nodes. */
261
+ function textNodes(doc, s, st) {
262
+ const out = []; let buf = '';
263
+ const push = () => {
264
+ if (!buf) return;
265
+ const t = doc.createTextNode(buf); buf = '';
266
+ if (st.mode) { const w = doc.createElement(st.mode); w.appendChild(t); out.push(w); } else out.push(t);
267
+ };
268
+ for (const ch of String(s)) {
269
+ if (ch === INS || ch === DEL) { push(); st.mode = ch === INS ? 'ins' : 'del'; }
270
+ else if (ch === INS_END || ch === DEL_END) { push(); st.mode = null; }
271
+ else buf += ch;
272
+ }
273
+ push();
274
+ return out;
275
+ }
276
+ function inlineDom(doc, nodes, st) {
277
+ const out = [];
278
+ for (const n of nodes || []) {
279
+ if (n.t === 'text') { out.push(...textNodes(doc, n.s, st)); continue; }
280
+ if (n.t === 'code') { const c = doc.createElement('code'); c.append(...textNodes(doc, n.s, st)); out.push(c); continue; }
281
+ const tag = n.t === 'b' ? 'strong' : n.t === 'i' ? 'em' : 'a';
282
+ const e = doc.createElement(tag);
283
+ if (n.t === 'a') {
284
+ const h = safeHref(n.href);
285
+ if (h != null) { e.setAttribute('href', h); if (/^https?:/i.test(h)) { e.setAttribute('target', '_blank'); e.setAttribute('rel', 'noopener'); } }
286
+ }
287
+ e.append(...inlineDom(doc, n.c, st));
288
+ out.push(e);
289
+ }
290
+ return out;
291
+ }
292
+
293
+ /** Blocks → elements. `opts.editable` draws checklist boxes that can be ticked (the editor). */
294
+ export function toDom(blocks, doc, opts = {}) {
295
+ const d = doc || document;
296
+ const mk = (tag, cls) => { const e = d.createElement(tag); if (cls) e.className = cls; return e; };
297
+ return (blocks || []).map((b) => {
298
+ const st = { mode: null };
299
+ if (b.t === 'h') { const e = mk('h' + Math.min(6, Math.max(1, b.level || 2))); e.append(...inlineDom(d, b.c, st)); return e; }
300
+ if (b.t === 'p') { const e = mk('p'); e.append(...inlineDom(d, b.c, st)); return e; }
301
+ if (b.t === 'hr') return mk('hr');
302
+ if (b.t === 'code') { const p = mk('pre'); if (b.lang) p.setAttribute('data-lang', b.lang); const c = mk('code'); c.append(...textNodes(d, b.s, st)); p.appendChild(c); return p; }
303
+ if (b.t === 'quote') { const q = mk('blockquote'); q.append(...toDom(b.blocks, d, opts)); return q; }
304
+ if (b.t === 'list') {
305
+ const l = mk(b.ordered ? 'ol' : 'ul', b.items.some((it) => it.task != null) ? 'tasks' : '');
306
+ for (const it of b.items) {
307
+ const li = mk('li');
308
+ if (it.task != null) {
309
+ li.className = 'task' + (it.task ? ' done' : '');
310
+ const box = mk('input'); box.type = 'checkbox'; box.checked = !!it.task; box.setAttribute('aria-label', 'Done');
311
+ if (opts.editable) box.setAttribute('contenteditable', 'false'); else box.disabled = true;
312
+ const sp = mk('span'); sp.append(...inlineDom(d, it.c, st));
313
+ li.append(box, sp);
314
+ } else li.append(...inlineDom(d, it.c, st));
315
+ l.appendChild(li);
316
+ }
317
+ return l;
318
+ }
319
+ if (b.t === 'table') {
320
+ const w = mk('div', 'tw'); const t = mk('table'); const th = mk('thead'); const tr = mk('tr');
321
+ for (const c of b.head) { const x = mk('th'); x.append(...inlineDom(d, c, st)); tr.appendChild(x); }
322
+ th.appendChild(tr); const tb = mk('tbody');
323
+ for (const r of b.rows) { const row = mk('tr'); for (const c of r) { const x = mk('td'); x.append(...inlineDom(d, c, st)); row.appendChild(x); } tb.appendChild(row); }
324
+ t.append(th, tb); w.appendChild(t); return w;
325
+ }
326
+ return d.createTextNode('');
327
+ });
328
+ }
329
+
330
+ /* reading the editor back */
331
+ function inlineOf(n) {
332
+ const out = [];
333
+ const text = (s) => { if (!s) return; const last = out[out.length - 1]; if (last && last.t === 'text') last.s += s; else out.push({ t: 'text', s }); };
334
+ for (const c of Array.from(n.childNodes || [])) {
335
+ if (c.nodeType === 3) { text(c.textContent.replace(/\u00a0/g, ' ').replace(/\u200b/g, '').replace(/\s+/g, ' ')); continue; }
336
+ if (c.nodeType !== 1) continue;
337
+ const t = c.tagName;
338
+ if (t === 'BR') { text(' '); continue; }
339
+ if (t === 'INPUT' || t === 'UL' || t === 'OL') continue;
340
+ if (t === 'CODE') { out.push({ t: 'code', s: c.textContent.replace(/\u200b/g, '') }); continue; }
341
+ if (t === 'STRONG' || t === 'B') { out.push({ t: 'b', c: inlineOf(c) }); continue; }
342
+ if (t === 'EM' || t === 'I') { out.push({ t: 'i', c: inlineOf(c) }); continue; }
343
+ if (t === 'A') { out.push({ t: 'a', href: c.getAttribute('href') || '', c: inlineOf(c) }); continue; }
344
+ if (t === 'SPAN' && /font-weight:\s*(bold|[6-9]00)/.test(c.getAttribute('style') || '')) { out.push({ t: 'b', c: inlineOf(c) }); continue; }
345
+ if (t === 'SPAN' && /font-style:\s*italic/.test(c.getAttribute('style') || '')) { out.push({ t: 'i', c: inlineOf(c) }); continue; }
346
+ for (const x of inlineOf(c)) { if (x.t === 'text') text(x.s); else out.push(x); }
347
+ }
348
+ return out;
349
+ }
350
+ const trimInline = (c) => {
351
+ const a = c.slice();
352
+ while (a.length && a[0].t === 'text' && !a[0].s.trim()) a.shift();
353
+ while (a.length && a[a.length - 1].t === 'text' && !a[a.length - 1].s.trim()) a.pop();
354
+ if (a.length && a[0].t === 'text') a[0] = { ...a[0], s: a[0].s.replace(/^\s+/, '') };
355
+ if (a.length && a[a.length - 1].t === 'text') a[a.length - 1] = { ...a[a.length - 1], s: a[a.length - 1].s.replace(/\s+$/, '') };
356
+ return a;
357
+ };
358
+ const BLOCK = /^(P|DIV|H[1-6]|BLOCKQUOTE|UL|OL|TABLE|PRE|HR|SECTION)$/;
359
+ /** The editor's children → blocks. Loose text and inline nodes between blocks become a paragraph. */
360
+ export function fromDom(root) {
361
+ const out = []; let loose = null;
362
+ const endLoose = () => { if (loose) { const c = trimInline(inlineOf(loose)); if (c.length) out.push({ t: 'p', c }); loose = null; } };
363
+ for (const n of Array.from(root.childNodes || [])) {
364
+ if (n.nodeType === 1 && BLOCK.test(n.tagName)) { endLoose(); out.push(...blockOf(n)); continue; }
365
+ if (n.nodeType === 3 && !n.textContent.trim() && !loose) continue;
366
+ if (!loose) loose = (root.ownerDocument || document).createElement('p');
367
+ loose.appendChild(n.cloneNode(true));
368
+ }
369
+ endLoose();
370
+ return out;
371
+ }
372
+ function blockOf(n) {
373
+ const t = n.tagName;
374
+ if (/^H[1-6]$/.test(t)) return [{ t: 'h', level: +t[1], c: trimInline(inlineOf(n)) }];
375
+ if (t === 'HR') return [{ t: 'hr' }];
376
+ if (t === 'PRE') return [{ t: 'code', lang: n.getAttribute('data-lang') || '', s: n.textContent.replace(/\n$/, '') }];
377
+ if (t === 'BLOCKQUOTE') { const b = fromDom(n); return b.length ? [{ t: 'quote', blocks: b }] : []; }
378
+ if (t === 'UL' || t === 'OL') {
379
+ const items = Array.from(n.children).filter((li) => li.tagName === 'LI').map((li) => {
380
+ const box = li.querySelector(':scope > input[type=checkbox]');
381
+ return { task: box ? !!box.checked : null, c: trimInline(inlineOf(li)) };
382
+ });
383
+ const nested = Array.from(n.querySelectorAll(':scope > li > ul, :scope > li > ol')).flatMap(blockOf);
384
+ return [{ t: 'list', ordered: t === 'OL', items }, ...nested];
385
+ }
386
+ if (t === 'TABLE' || (t === 'DIV' && n.querySelector(':scope > table'))) {
387
+ const tb = t === 'TABLE' ? n : n.querySelector(':scope > table');
388
+ const rows = Array.from(tb.querySelectorAll('tr')).map((r) => Array.from(r.children).map((c) => trimInline(inlineOf(c))));
389
+ if (!rows.length) return [];
390
+ return [{ t: 'table', head: rows[0], rows: rows.slice(1) }];
391
+ }
392
+ if ((t === 'DIV' || t === 'SECTION') && Array.from(n.children).some((c) => BLOCK.test(c.tagName))) return fromDom(n);
393
+ const c = trimInline(inlineOf(n));
394
+ return c.length ? [{ t: 'p', c }] : [];
395
+ }
@@ -3,8 +3,11 @@
3
3
  * c3-calendar.js draws it when the Calendar | Notes switch is on Notes: notesView(ui) returns one persistent box, which this
4
4
  * file redraws on its own (a read, a choice, a stage, a search), so the grid's data call is not repeated.
5
5
  *
6
- * the list "By initiative" with a count and a search field, one group per initiative and "No initiative" last (dest-notes.js
7
- * groupNotes). A row: the time or day, the length, recording or paused, "no meeting", and a dot for a conflict.
6
+ * the list "Notes" with a count and a search field, grouped by what a note is about: Portfolio first, then each board in the
7
+ * portfolio's order, then "Not linked" (scopeGroups). A row: the time or day, the length, its initiative or a "board"
8
+ * tag, recording or paused, "no meeting", and a dot for a conflict.
9
+ * the head the note's meeting, then what it is about (the Portfolio, a board, or an initiative on its board) with one picker
10
+ * (aboutMenu: /api/boards and each board's Now and next initiatives), the mockup the PM approved on 2026-10-12.
8
11
  * the note the Calendar stage's layout (calendar-views.js stageNode, the mockup the PM approved on 2026-10-12): a head with
9
12
  * Before, During and After, then the stage's cards.
10
13
  * Before the meeting context the note kept (meeting.context): agenda (an item heard is ticked), what it is for, to be ready,
@@ -20,13 +23,14 @@
20
23
  * noteOfEvent(e) the note linked to a calendar event, from the shared notes read (notes-links.js), or null
21
24
  */
22
25
  import { el } from './dom.js';
23
- import { button } from './primitives.js';
26
+ import { button, menu } from './primitives.js';
27
+ import { icon } from './icons.js';
24
28
  import { c3ic } from './pm-work-view.js';
25
29
  import { pmGet, pmPost } from './http.js';
26
30
  import { toast } from './c3-toast.js';
27
31
  import { onNotes, notesFor, refreshNotes } from './notes-links.js';
28
32
  import { agendaHeard, peopleOf } from './notes-context.js';
29
- import { groupNotes, partsOf, conflictsOf, whenShort } from './dest-notes.js';
33
+ import { partsOf, conflictsOf, whenShort } from './dest-notes.js';
30
34
  import * as dock from './note-dock.js';
31
35
  import * as V from './view-notes.js';
32
36
  import { makeTask } from './notes-task.js';
@@ -35,6 +39,10 @@ import { screenStrip } from './notes-screen-view.js';
35
39
  const PHASES = [['before', 'clip', 'Before'], ['during', 'mic', 'During'], ['after', 'notes', 'After']];
36
40
  const startOf = (n) => (n.meeting && n.meeting.start) || n.recorded_from || n.created_at || n.updated_at;
37
41
  const iniName = (i) => (i ? i.title || i.id : null);
42
+ const boardName = (b) => (b ? b.title || b.id : null);
43
+ /* what a note is about, as the head's path: "Portfolio", "IDPF", or "IDPF › Partner API rate limits" */
44
+ const pathOf = (n) => (n.initiative ? [boardName(n.board), iniName(n.initiative)].filter(Boolean) : n.board ? [boardName(n.board)] : n.portfolio ? ['Portfolio'] : []);
45
+ const boardKey = (b) => (b.ws || '') + '/' + b.id;
38
46
  const mins = (ms) => (ms >= 60000 ? Math.round(ms / 60000) + ' min' : '');
39
47
  const lenOf = (n) => n.meeting_ms || Math.max(0, ...(n.transcript || []).map((x) => x.t || 0));
40
48
  const dayOf = (iso) => { const t = new Date(iso); return Number.isFinite(t.getTime()) ? t.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : ''; };
@@ -81,6 +89,7 @@ export function notesView(ui) {
81
89
  const s = stateOf(ui.r);
82
90
  s.ui = ui;
83
91
  if (!s.unsub) s.unsub = onNotes(() => { if (s.box.isConnected) read(s); });
92
+ if (!s.boards) loadBoards(s);
84
93
  if (!s.rows || (s.open && (!s.note || s.note.id !== s.open))) read(s);
85
94
  draw(s);
86
95
  return s.box;
@@ -96,6 +105,13 @@ async function read(s) {
96
105
  draw(s);
97
106
  }
98
107
 
108
+ /* the portfolio's boards, for the list's group order and the board names; a board the read misses keeps the note's own title */
109
+ async function loadBoards(s) {
110
+ s.boards = [];
111
+ const r = await pmGet('/api/boards');
112
+ if (r && r.ok && Array.isArray(r.boards)) { s.boards = r.boards.filter((b) => b && b.id && !b.error); if (s.box.isConnected) draw(s); }
113
+ }
114
+
99
115
  function choose(s, id) {
100
116
  if (s.open === id) return;
101
117
  s.open = id; s.note = null; s.phase = null; s.shots = []; s.rec = null;
@@ -118,7 +134,7 @@ function draw(s) {
118
134
 
119
135
  function listCard(s) {
120
136
  const qtext = s.q.trim().toLowerCase();
121
- const shown = (s.rows || []).filter((r) => !qtext || String(r.title || '').toLowerCase().includes(qtext) || String(iniName(r.initiative) || '').toLowerCase().includes(qtext)
137
+ const shown = (s.rows || []).filter((r) => !qtext || String(r.title || '').toLowerCase().includes(qtext) || pathOf(r).some((x) => String(x).toLowerCase().includes(qtext))
122
138
  || (r.summary_lines || []).some((x) => String(x).toLowerCase().includes(qtext)));
123
139
  const search = el('input', { type: 'text', class: 'cal3-nsearch', placeholder: 'Search notes', 'aria-label': 'Search notes', value: s.q, 'data-cal3-notes': 'search' });
124
140
  search.addEventListener('input', () => { s.q = search.value; draw(s); });
@@ -126,13 +142,28 @@ function listCard(s) {
126
142
  if (!s.rows) body = fold('clock', 'Reading the notes', { role: 'status' });
127
143
  else if (!s.rows.length) body = fold('mic', s.err || 'No notes yet. Press Record at the top right to start one.', { 'data-cal3-notes': 'none' });
128
144
  else if (!shown.length) body = fold('search', 'No note matches “' + s.q.trim() + '”.', { 'data-cal3-notes': 'no-match' });
129
- else body = groupNotes(shown).map((g) => el('div', { class: 'cal3-ctx', 'data-cal3-notes-group': g.label }, el('h4', null, g.label),
145
+ else body = scopeGroups(s, shown).map((g) => el('div', { class: 'cal3-ctx', 'data-cal3-notes-group': g.label }, el('h4', null, g.label),
130
146
  el('ul', { class: 'list', role: 'list' }, g.rows.map((r) => noteRow(s, r)))));
131
147
  return el('section', { class: 'card sect', 'data-cal3-notes': 'list', 'aria-label': 'Notes' },
132
- el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), 'By initiative ', el('span', { class: 'bdg g', 'data-cal3-notes': 'count' }, String(shown.length)))),
148
+ el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), 'Notes ', el('span', { class: 'bdg g', 'data-cal3-notes': 'count' }, String(shown.length)))),
133
149
  el('div', { class: 'cal3-nsearchw' }, search), body);
134
150
  }
135
151
 
152
+ /* the list's groups: Portfolio, each board in the portfolio's order (a board it does not list goes after them by name), an
153
+ * initiative linked before boards were (no board on the note) under "Initiatives", then "Not linked" */
154
+ function scopeGroups(s, rows) {
155
+ const order = new Map((s.boards || []).map((b, i) => [boardKey(b), i]));
156
+ const known = new Map((s.boards || []).map((b) => [boardKey(b), b.name || b.id]));
157
+ const g = new Map();
158
+ for (const r of rows) {
159
+ const k = r.portfolio ? '@portfolio' : r.board ? boardKey(r.board) : r.initiative ? '@initiative' : '';
160
+ if (!g.has(k)) g.set(k, { key: k, label: k === '@portfolio' ? 'Portfolio' : k === '@initiative' ? 'Initiatives' : !k ? 'Not linked' : known.get(k) || boardName(r.board), rows: [] });
161
+ g.get(k).rows.push(r);
162
+ }
163
+ const rank = (x) => (x.key === '@portfolio' ? [0, 0] : x.key === '@initiative' ? [3, 0] : !x.key ? [4, 0] : order.has(x.key) ? [1, order.get(x.key)] : [2, 0]);
164
+ return [...g.values()].sort((a, b) => { const p = rank(a); const q = rank(b); return p[0] - q[0] || p[1] - q[1] || a.label.localeCompare(b.label); });
165
+ }
166
+
136
167
  function noteRow(s, r) {
137
168
  const mode = held(r.id) || (r.state === 'recording' || r.state === 'paused' ? r.state : null);
138
169
  const on = s.open === r.id;
@@ -142,7 +173,9 @@ function noteRow(s, r) {
142
173
  btn('cal3-nopen', { title: 'Open ' + (r.title || 'Untitled note'), 'aria-current': on ? 'true' : 'false' }, [
143
174
  el('span', { class: 'tt' }, r.title || 'Untitled note'),
144
175
  el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, [mode ? 'now' : whenShort(startOf(r)), len].filter(Boolean).join(' · ')),
145
- mode ? el('span', { class: 'tag', 'data-cal3-notes': 'live' }, mode === 'paused' ? 'paused' : 'recording') : null,
176
+ mode ? el('span', { class: 'tag', 'data-cal3-notes': 'live' }, mode === 'paused' ? 'paused' : 'recording')
177
+ : r.initiative ? el('span', { class: 'chip2', title: 'Initiative', 'data-cal3-notes': 'row-initiative' }, c3ic('init', 'sm'), iniName(r.initiative))
178
+ : r.board ? el('span', { class: 'tag', title: 'Linked to the board, not to one initiative', 'data-cal3-notes': 'row-board' }, 'board') : null,
146
179
  !linked(r) && !mode ? el('span', { class: 'tag', title: 'Not linked to a meeting', 'data-cal3-notes': 'no-meeting' }, 'no meeting') : null,
147
180
  r.conflicts ? el('span', { class: 'st warn', title: r.conflicts + (r.conflicts === 1 ? ' statement differs' : ' statements differ') + ' from a file' }, c3ic('alert', 'sm'), String(r.conflicts)) : null)],
148
181
  () => choose(s, r.id)),
@@ -172,7 +205,7 @@ function noteCol(s) {
172
205
  return el('div', { class: 'cal3-ncol', 'data-cal3-notes': 'note', 'data-cal3-note': n.id, 'data-cal3-stage': ph }, head, ...[].concat(body));
173
206
  }
174
207
 
175
- /* the note's meeting and initiative, each with its link: what it is linked to, then Link or Change (view-notes.js pickers) */
208
+ /* the note's meeting (view-notes.js picker) and what it is about (aboutMenu), each with what it is linked to, then Link or Change */
176
209
  function links(s, n, ev) {
177
210
  const adhoc = n.meeting && n.meeting.kind === 'adhoc';
178
211
  const when = ev ? [dayOf(ev.start), hm(ev.start) + (ev.end ? ' to ' + hm(ev.end) : '')].filter(Boolean).join(' ') : '';
@@ -183,9 +216,60 @@ function links(s, n, ev) {
183
216
  !ev && !adhoc ? el('span', { class: 'tag' }, 'no meeting') : null,
184
217
  btn('chip2 lk', { 'data-cal3-notes': 'meeting-link', title: ev || adhoc ? 'Link the note to another meeting' : 'Link the note to a meeting on the calendar' }, ev || adhoc ? 'Change' : 'Link to a meeting', () => V.linkMeeting(vctx(s), n.id)),
185
218
  ev ? btn('chip2 lk', { 'data-cal3-notes': 'show-meeting', title: 'Show the meeting in the calendar' }, [c3ic('next', 'sm'), 'Show in the calendar'], () => showMeeting(s, ev)) : null),
186
- el('span', { class: 'chips2' }, c3ic('init', 'sm'),
187
- n.initiative ? el('span', { 'data-cal3-notes': 'initiative' }, el('b', null, iniName(n.initiative))) : el('span', { class: 'dim', 'data-cal3-notes': 'initiative' }, 'No initiative'),
188
- btn('chip2 lk', { 'data-cal3-notes': 'initiative-link', title: n.initiative ? 'Link the note to another initiative' : 'Link the note to an initiative' }, n.initiative ? 'Change' : 'Link to an initiative', () => V.linkInitiative(vctx(s), n.id))));
219
+ aboutLine(s, n));
220
+ }
221
+ function aboutLine(s, n) {
222
+ const path = pathOf(n);
223
+ const b = btn('chip2 lk', { 'data-cal3-notes': 'about-link', title: 'Choose what the note is about: the Portfolio, a board or an initiative' }, path.length ? 'Change' : 'Choose what it is about', () => aboutMenu(s, n, b));
224
+ return el('span', { class: 'chips2' }, c3ic('init', 'sm'),
225
+ path.length ? el('span', { 'data-cal3-notes': 'about' }, el('b', null, path.join(' › '))) : el('span', { class: 'dim', 'data-cal3-notes': 'about' }, 'Not linked to the Portfolio, a board or an initiative'),
226
+ b);
227
+ }
228
+
229
+ /* each board with its Now and next initiatives (GET /api/boards/backlog), kept for a minute once the repository is read */
230
+ async function boardsWithInitiatives(s) {
231
+ if (s.about && Date.now() - s.about.at < 60000) return s.about.boards;
232
+ const r = await pmGet('/api/boards');
233
+ if (!r || !r.ok || !Array.isArray(r.boards)) throw new Error((r && r.error) || 'the boards could not be read');
234
+ const boards = r.boards.filter((b) => b && b.id && !b.error);
235
+ let pending = !!r.pending;
236
+ await Promise.all(boards.map(async (b) => {
237
+ const x = await pmGet('/api/boards/backlog?ws=' + encodeURIComponent(b.ws || '') + '&board=' + encodeURIComponent(b.id) + '&limit=100');
238
+ if (!x || !x.ok || x.pending || b.pending) pending = true;
239
+ b.inis = x && x.ok && Array.isArray(x.rows) ? x.rows : [];
240
+ }));
241
+ // while the repository is still being read, the initiatives are not all known: the next open reads them again
242
+ s.about = pending ? null : { at: Date.now(), boards };
243
+ return boards;
244
+ }
245
+
246
+ /* What is this note about? One menu: Portfolio (every board), each board (the whole board) with its initiatives under it, and
247
+ * Not linked when the note has a link. Choosing one saves it (POST /api/notes/link) and the note moves into its group. */
248
+ async function aboutMenu(s, n, anchor) {
249
+ let boards;
250
+ try { boards = await boardsWithInitiatives(s); } catch (e) { toast('The boards could not be read: ' + e.message); return; }
251
+ if (!anchor.isConnected) return;
252
+ const cur = n.initiative ? 'i:' + (n.initiative.ws || '') + '/' + n.initiative.id : n.board ? 'b:' + boardKey(n.board) : n.portfolio ? 'p' : '';
253
+ const save = async (body, said) => {
254
+ const r = await pmPost('/api/notes/link', { id: n.id, ...body });
255
+ if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return; }
256
+ toast(said);
257
+ refreshNotes();
258
+ read(s);
259
+ };
260
+ const item = (k, label, detail, ic, on, sub) => ({ label, detail, role: 'menuitemradio', iconNode: sub ? el('span', { class: 'cal3-mind' }, icon(ic)) : icon(ic),
261
+ attrs: { 'data-cal3-about': k, 'aria-checked': String(cur === k) }, onSelect: on });
262
+ const items = [item('p', 'Portfolio', 'every board', 'chart-column', () => save({ portfolio: true }, 'About the Portfolio. The note moved into its group.'))];
263
+ for (const b of boards) {
264
+ const board = { id: b.id, ws: b.ws || null, title: b.name || b.id };
265
+ items.push(item('b:' + boardKey(b), board.title, 'the whole board', 'square-kanban', () => save({ board, initiative: null }, 'About ' + board.title + '. The note moved into its group.')));
266
+ for (const i of b.inis || []) {
267
+ items.push(item('i:' + (b.ws || '') + '/' + i.id, i.title || i.id, null, 'target',
268
+ () => save({ board, initiative: { id: i.id, ws: b.ws || null, title: i.title || i.id } }, 'About ' + (i.title || i.id) + ' on ' + board.title + '. The note moved into its group.'), true));
269
+ }
270
+ }
271
+ if (cur) items.push({ sep: true }, item('none', 'Not linked', 'remove the link', 'x', () => save({ portfolio: false, board: null, initiative: null }, 'Not linked to the Portfolio, a board or an initiative.')));
272
+ menu({ anchor, items, ariaLabel: 'What is this note about?' });
189
273
  }
190
274
 
191
275
  /* the replay of the note's kept audio: one <audio> per note, kept across redraws so playback goes on (as dest-notes.js does) */
@@ -207,7 +291,7 @@ function seek(s, ms) {
207
291
  const vctx = (s) => ({ state: {}, setState: () => { if (s.box.isConnected) { read(s); } } });
208
292
  const linkButtons = (s, n) => el('div', { class: 'chips2' },
209
293
  btn('chip2 lk', { 'data-cal3-notes': 'link-meeting' }, [c3ic('cal', 'sm'), 'Link to a meeting'], () => V.linkMeeting(vctx(s), n.id)),
210
- btn('chip2 lk', { 'data-cal3-notes': 'link-initiative' }, [c3ic('init', 'sm'), 'Link to an initiative'], () => V.linkInitiative(vctx(s), n.id)));
294
+ btn('chip2 lk', { 'data-cal3-notes': 'link-about' }, [c3ic('init', 'sm'), 'Choose what it is about'], (e) => aboutMenu(s, n, e.currentTarget)));
211
295
 
212
296
  /* a row of the stage's prepared list: the icon, the label, what it holds */
213
297
  const prepRow = (icon, label, detail, done) => el('div', { class: 'chips2 cal3-prep', 'data-cal3-prep': label },