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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/ask-the-agent.md +5 -5
- package/workspace/guide/console-calendar.md +1 -1
- package/workspace/guide/document-sheet.md +58 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/engine/doc-health.js +4 -2
- package/workspace/lib/engine/doc-sheet.js +49 -1
- package/workspace/lib/notes.js +31 -6
- package/workspace/lib/routes-doc-health.js +15 -4
- package/workspace/lib/routes-doc-sheet.js +154 -13
- package/workspace/lib/routes-notes.js +4 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +6 -1
- package/workspace/public/pm/c3-demo-init.js +44 -3
- package/workspace/public/pm/c3-demo-rep2.js +27 -6
- package/workspace/public/pm/c3-doc-sheet.js +586 -184
- package/workspace/public/pm/c3-md.js +395 -0
- package/workspace/public/pm/c3-notes.js +35 -16
- package/workspace/public/pm/console3.css +169 -0
- package/workspace/public/pm/dest-notes.js +1 -0
- package/workspace/public/pm/pm-work-view.js +1 -1
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/test-lists/c3-doc-sheet-rich.json +6 -0
|
@@ -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(
|
|
292
|
-
: el('p', { class: 'dim cal3-ov', 'data-cal3-notes': 'no-overview' },
|
|
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
|
-
|
|
295
|
-
|
|
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' },
|
|
298
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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.
|
|
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
|
}
|