create-pmos 0.150.0 → 0.152.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
+ }
@@ -282,20 +282,26 @@ function after(s, n, mode) {
282
282
  const p = partsOf(n);
283
283
  const names = n.meeting && n.meeting.context ? peopleOf(n.meeting.context) : [];
284
284
  const cf = conflictsOf(n);
285
- const td = (text) => el('td', null, text);
286
285
  const kids = [
287
286
  el('div', { class: 'c3cv-head' }, el('h3', null, c3ic('notes'), 'Notes'), recordButton(s, n, mode)),
288
287
  el('div', { class: 'chips2' }, el('b', null, n.title || 'Untitled note'), el('span', { class: 'dim mono cal3-sm' }, [dayOf(startOf(n)), mins(lenOf(n))].filter(Boolean).join(' · ')), ...names.map(avatar)),
289
288
  ...(() => { const rp = mode ? null : replayOf(s, n); return rp ? [el('div', { class: 'cal3-replay', 'data-cal3-notes': 'replay' }, el('h4', null, 'Replay'), rp, el('p', { class: 'dim cal3-sm' }, 'Kept on this machine.'))] : []; })(),
290
289
  ...cf.map((c) => el('div', { class: 'fold w', 'data-cal3-notes': 'conflict' }, c3ic('alert', 'sm'), '“' + c.text + '” differs from ', el('span', { class: 'mono' }, c.doc + (c.line ? ':' + c.line : '')))),
291
- p.overview ? editable(p.from === 'final', 'p', p.overview, 'Overview', (t) => saveFinal(s, n, 'overview', 0, t), { class: 'cal3-ov', 'data-cal3-notes': 'overview' })
292
- : el('p', { class: 'dim cal3-ov', 'data-cal3-notes': 'no-overview' }, mode ? 'The overview is written when you stop recording.' : 'No overview yet.'),
290
+ p.overview || !mode ? editable(true, 'p', p.overview, 'Summary', (t) => saveFinal(s, n, 'overview', 0, t), { class: 'cal3-ov', 'data-cal3-notes': p.overview ? 'overview' : 'no-overview' }, p.from === 'final' ? null : 'Write a summary of the meeting')
291
+ : el('p', { class: 'dim cal3-ov', 'data-cal3-notes': 'no-overview' }, 'The overview is written when you stop recording.'),
293
292
  ];
294
- if (p.decisions.length) kids.push(el('table', { class: 'tbl', 'data-cv': 'decisions' }, el('thead', null, el('tr', null, el('th', null, 'Decision'), el('th', null, 'Why'))),
295
- el('tbody', null, p.decisions.map((d) => el('tr', { 'data-cal3-notes': 'decision' }, el('td', null, editable(p.from === 'final', 'span', d.text, 'Decision', (t) => saveFinal(s, n, d.sec, d.i, t))), el('td', { class: 'dim' }, d.why || d.by || ''))))));
293
+ // each part of a decision or a next step is corrected in place: in the final notes (/api/notes/final-edit), or in the bullet
294
+ // it was caught from (/api/notes/bullet). A bullet has no why, so a caught decision's why is not shown.
295
+ const fin = p.from === 'final';
296
+ const cell = (x, field, label, ph, cls) => {
297
+ const on = fin || (x.bullet !== undefined && field !== 'why');
298
+ return el('td', cls ? { class: cls } : null, editable(on, 'span', x[field] || '', label, (t) => saveItem(s, n, x, field, t), { 'data-cal3-notes-field': field }, ph));
299
+ };
300
+ if (p.decisions.length) kids.push(el('table', { class: 'tbl', 'data-cv': 'decisions' }, el('thead', null, el('tr', null, el('th', null, 'Decision'), fin ? el('th', null, 'Why') : null)),
301
+ el('tbody', null, p.decisions.map((d) => el('tr', { 'data-cal3-notes': 'decision' }, cell(d, 'text', 'Decision'), fin ? cell(d, 'why', 'Why', 'Add why', 'dim') : null)))));
296
302
  if (p.actions.length) kids.push(el('table', { class: 'tbl', 'data-cv': 'actions' }, el('thead', null, el('tr', null, el('th', null, 'Next step'), el('th', null, 'Owner'), el('th', null, 'Due'), el('th'))),
297
- el('tbody', null, p.actions.map((a) => el('tr', { 'data-cal3-notes': 'action' }, el('td', null, editable(p.from === 'final', 'span', a.text, 'Next step', (t) => saveFinal(s, n, a.sec, a.i, t))),
298
- td(a.owner || el('span', { class: 'dim' }, 'No owner')), td(a.due || el('span', { class: 'dim' }, '—')),
303
+ el('tbody', null, p.actions.map((a) => el('tr', { 'data-cal3-notes': 'action' }, cell(a, 'text', 'Next step'),
304
+ cell(a, 'owner', 'Owner', 'No owner'), cell(a, 'due', 'Due', 'No date'),
299
305
  el('td', { class: 'cal3-tdr' }, btn('chip2 lk', { 'data-cal3-notes': 'make-task', title: n.initiative ? 'Add this step to ' + iniName(n.initiative) + ' as a plan item' : 'Link the note to an initiative first' },
300
306
  [c3ic('plus', 'sm'), 'Make task'], (e) => makeTask(n, a, { btn: e.currentTarget }))))))));
301
307
  if (p.questions.length) kids.push(el('div', { class: 'cal3-ctx', 'data-cal3-notes': 'questions' }, el('h4', null, 'Open questions'),
@@ -313,10 +319,11 @@ function after(s, n, mode) {
313
319
  return el('section', { class: 'card cal3-ncard', 'data-cv': 'notes' }, kids);
314
320
  }
315
321
 
316
- /** A text the PM corrects in place once the notes are final: a click puts the cursor in it, leaving it saves, Esc puts it back. */
317
- function editable(on, tag, text, label, save, attrs) {
318
- if (!on) return el(tag, attrs || null, text);
319
- const node = el(tag, { ...(attrs || {}), contenteditable: 'true', spellcheck: 'true', role: 'textbox', 'aria-label': 'Edit ' + label, 'data-cal3-notes-edit': '' }, text);
322
+ /** A text the PM corrects in place: a click puts the cursor in it, leaving it saves, Esc puts it back. With a placeholder
323
+ * (`ph`), the text may be empty: the placeholder shows, and emptying the text clears it; without one, an empty text is refused. */
324
+ function editable(on, tag, text, label, save, attrs, ph) {
325
+ if (!on) return el(tag, attrs || null, text || (ph ? el('span', { class: 'dim' }, ph) : ''));
326
+ const node = el(tag, { ...(attrs || {}), class: [attrs && attrs.class, ph ? 'cal3-ed' : null].filter(Boolean).join(' ') || null, contenteditable: 'true', spellcheck: 'true', role: 'textbox', 'aria-label': 'Edit ' + label, 'data-cal3-notes-edit': '', 'data-ph': ph || null }, text);
320
327
  let last = text;
321
328
  node.addEventListener('keydown', (e) => {
322
329
  if (e.key === 'Enter' && tag !== 'p') { e.preventDefault(); node.blur(); }
@@ -324,15 +331,27 @@ function editable(on, tag, text, label, save, attrs) {
324
331
  });
325
332
  node.addEventListener('blur', async () => {
326
333
  const v = node.textContent.replace(/\s+/g, ' ').trim();
327
- if (!v || v === last) { node.textContent = last; return; }
328
- if (await save(v)) last = v; else node.textContent = last;
334
+ if ((!v && !ph) || v === last) { node.textContent = last; return; }
335
+ if (await save(v)) { last = v; node.textContent = v; } else node.textContent = last;
329
336
  });
330
337
  return node;
331
338
  }
332
- async function saveFinal(s, n, sec, i, text) {
333
- const r = await pmPost('/api/notes/final-edit', { id: n.id, sec, i, text });
339
+ /* one part of a decision or a next step: in the final notes, or in the bullet it was caught from */
340
+ async function saveItem(s, n, x, field, text) {
341
+ if (x.sec) return saveFinal(s, n, x.sec, x.i, text, field === 'text' ? undefined : field);
342
+ const r = await pmPost('/api/notes/bullet', { id: n.id, bullet: x.bullet, [field]: text });
334
343
  if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return false; }
335
- n.final_notes = r.final_notes;
344
+ const b = (n.bullets || []).find((y) => y && y.id === x.bullet);
345
+ if (b) Object.assign(b, r.bullet);
346
+ x[field] = text;
347
+ toast('Saved');
348
+ return true;
349
+ }
350
+ async function saveFinal(s, n, sec, i, text, field) {
351
+ const r = await pmPost('/api/notes/final-edit', field ? { id: n.id, sec, i, text, field } : { id: n.id, sec, i, text });
352
+ if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return false; }
353
+ if (r.final_notes) n.final_notes = r.final_notes;
354
+ if ('overview_pm' in r) { if (r.overview_pm) n.overview_pm = r.overview_pm; else delete n.overview_pm; }
336
355
  toast('Saved');
337
356
  return true;
338
357
  }