@uplift-beyond/upgo 0.0.0-stage → 0.4.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,515 @@
1
+ import { isOpenStatus, POINTER_KEYS } from "./vocab.js";
2
+ import { localIssues } from "./rules.js";
3
+ import { parseGoals } from "./store.js";
4
+ import { slugify, uniqueSlug, UpgoError } from "./util.js";
5
+ const EM_DASH = new RegExp(`,?[ \\t]*${String.fromCharCode(0x2014)}[ \\t]*`, "g");
6
+ const DATE = /\d{4}-\d{2}-\d{2}/;
7
+ const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
8
+ const HR = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
9
+ const BULLET = /^[-*+]\s+/;
10
+ const LIST = /^\s*(?:[-*+]|\d+[.)])\s/;
11
+ const BOLD = /^\*\*(.+?)\*\*/;
12
+ const MARKER = /^(\[ \]|\[[xX]\]|✅|🚧|📋|❌)️?\s*/;
13
+ const PHASE_TAG = /^\*{0,2}\(\s*(now|next|later|way later(?:,\s*maybe)?|maybe)\s*\)\*{0,2}\s*/i;
14
+ const SIZE = /^([SML])\s*(?:,\s*(.*))?$/;
15
+ const POINTER = /^(\s*)[-*+]\s+↳\s*([a-z]+)\s*:\s*(.*)$/i;
16
+ const DETAIL = /\]\((?:\.\/)?(docs\/goalsDetails\/[^)\s#]+\.md)/;
17
+ const DROPPED_TAIL = /^(.+?)[,:;]?\s+(?:(?:was|were|is|are|been|then|and)\s+)?dropped\b(?=\s*(?:[,.;:(]|\d{4}-|$))(.*)$/i;
18
+ const PLACEHOLDER = /^[_*]*\(.*\)[_*]*$/s;
19
+ const NOISE = /^(?:verified\b|no known gaps\b)/i;
20
+ const STATUS = { "[ ]": "planned", "📋": "planned", "🚧": "partial", "[x]": "done", "✅": "done", "❌": "dropped" };
21
+ const trimEnd = (s) => s.replace(/[\s.,:;]+$/, "").trim();
22
+ const trimStart = (s) => s.replace(/^[\s.,:;]+/, "");
23
+ const tidy = (lines) => lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() || undefined;
24
+ const indentOf = (l) => l.length - l.trimStart().length;
25
+ const short = (s) => Array.from(s.trim()).slice(0, 60).join("");
26
+ const shown = (b) => short(b.kind === "bullet" ? `- ${b.lines[0].trim()}` : b.lines[0]);
27
+ const words = (s) => s.trim().split(/\s+/).length;
28
+ function scan(md) {
29
+ const lines = md.replace(/\r\n?/g, "\n").replace(EM_DASH, ", ").split("\n");
30
+ const blank = (i) => !lines[i].trim();
31
+ const indented = (i) => /^\s+\S/.test(lines[i]);
32
+ // Indented lines, plus blank runs that are followed by more indented lines.
33
+ const takeIndented = (i, into) => {
34
+ while (i < lines.length) {
35
+ if (indented(i))
36
+ into.push(lines[i++]);
37
+ else if (blank(i)) {
38
+ let j = i;
39
+ while (j < lines.length && blank(j))
40
+ j++;
41
+ if (j >= lines.length || !indented(j))
42
+ break;
43
+ while (i < j)
44
+ into.push(lines[i++]);
45
+ }
46
+ else
47
+ break;
48
+ }
49
+ return i;
50
+ };
51
+ const out = [];
52
+ for (let i = 0; i < lines.length;) {
53
+ const line = lines[i];
54
+ const h = HEADING.exec(line);
55
+ if (blank(i))
56
+ i++;
57
+ else if (h) {
58
+ out.push({ kind: "heading", level: h[1].length, text: h[2] });
59
+ i++;
60
+ }
61
+ else if (HR.test(line)) {
62
+ out.push({ kind: "hr" });
63
+ i++;
64
+ }
65
+ else if (line.startsWith("|")) {
66
+ const t = [];
67
+ while (i < lines.length && lines[i].startsWith("|"))
68
+ t.push(lines[i++]);
69
+ out.push({ kind: "table", lines: t });
70
+ }
71
+ else if (BULLET.test(line)) {
72
+ const b = [line.replace(BULLET, "")];
73
+ i = takeIndented(i + 1, b);
74
+ out.push({ kind: "bullet", lines: b });
75
+ }
76
+ else {
77
+ const p = [line];
78
+ i++;
79
+ while (i < lines.length && !blank(i) && !indented(i) && !HEADING.test(lines[i]) && !HR.test(lines[i]) && !BULLET.test(lines[i]) && !lines[i].startsWith("|"))
80
+ p.push(lines[i++]);
81
+ i = takeIndented(i, p);
82
+ out.push({ kind: "para", lines: p });
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+ function dedent(lines) {
88
+ const n = Math.min(...lines.filter((l) => l.trim()).map(indentOf));
89
+ return lines.map((l) => (l.trim() ? l.slice(n) : ""));
90
+ }
91
+ // The first paragraph as one line, and the dedented lines after it.
92
+ function split(b) {
93
+ let k = 1;
94
+ if (b.kind === "para")
95
+ while (k < b.lines.length && !/^\s/.test(b.lines[k]))
96
+ k++;
97
+ const after = dedent(b.lines.slice(k));
98
+ let j = 0;
99
+ if (b.kind === "bullet")
100
+ while (j < after.length && after[j].trim() && !LIST.test(after[j]))
101
+ j++;
102
+ return { first: [...b.lines.slice(0, k), ...after.slice(0, j)].map((l) => l.trim()).join(" "), after: after.slice(j) };
103
+ }
104
+ function balanced(s) {
105
+ if ((s.split("`").length - 1) % 2 || (s.split("**").length - 1) % 2)
106
+ return false;
107
+ let depth = 0;
108
+ for (const c of s.replace(/`[^`]*`/g, "")) {
109
+ if (c === "(" || c === "[")
110
+ depth++;
111
+ else if (c === ")" || c === "]")
112
+ depth = Math.max(0, depth - 1);
113
+ }
114
+ return depth === 0;
115
+ }
116
+ function sentenceEnd(s) {
117
+ for (const m of s.matchAll(/[.!?](?=\s|$)/g)) {
118
+ const head = s.slice(0, m.index);
119
+ if (balanced(head) && !/\b(?:e\.g|i\.e|vs|etc|cf|approx)$/i.test(head))
120
+ return m.index;
121
+ }
122
+ return -1;
123
+ }
124
+ function clauseCut(s) {
125
+ for (const m of s.matchAll(/[,;:](?=\s)/g)) {
126
+ if (m.index > 120)
127
+ break;
128
+ if (m.index >= 20 && balanced(s.slice(0, m.index)))
129
+ return s.slice(0, m.index);
130
+ }
131
+ }
132
+ function wordCut(s, max) {
133
+ const head = s.slice(0, max);
134
+ const t = head.lastIndexOf(" ") > 0 ? head.slice(0, head.lastIndexOf(" ")) : head;
135
+ let u = t;
136
+ while (!balanced(u)) {
137
+ const k = Math.max(u.lastIndexOf("("), u.lastIndexOf("["), u.lastIndexOf("`"), u.lastIndexOf("**"));
138
+ if (k < 10)
139
+ return t;
140
+ u = u.slice(0, k).trimEnd();
141
+ }
142
+ return u;
143
+ }
144
+ // First sentence or clause, cut before a trailing `(...date...)`. A cut title keeps the full text as `rest`; `after` is what follows the title.
145
+ function plainTitle(text) {
146
+ let end = text.length;
147
+ let next = text.length;
148
+ let date;
149
+ const dp = /\s*\(([^()]*\d{4}-\d{2}-\d{2}[^()]*)\)(?=\s*(?:[,.;:]|$))/.exec(text);
150
+ if (dp && dp.index > 0) {
151
+ end = dp.index;
152
+ next = dp.index + dp[0].length;
153
+ date = DATE.exec(dp[1])?.[0];
154
+ }
155
+ const se = sentenceEnd(text);
156
+ if (se !== -1 && se < end) {
157
+ end = se;
158
+ next = se + 1;
159
+ date = undefined;
160
+ }
161
+ const title = text.slice(0, end);
162
+ if (title.length <= 120) {
163
+ const rest = trimStart(text.slice(next));
164
+ return { title: trimEnd(title), rest, after: rest, date };
165
+ }
166
+ const cut = clauseCut(title) ?? wordCut(title, 100);
167
+ return { title: trimEnd(cut), rest: text, after: trimStart(text.slice(cut.length)), date };
168
+ }
169
+ // Leading/trailing dates and short `(closed 2026-07-19)` parens come off a done title.
170
+ function undate(title) {
171
+ let date;
172
+ const take = (d) => {
173
+ date ??= d;
174
+ return "";
175
+ };
176
+ let t = title.replace(/^(\d{4}-\d{2}-\d{2})\s*(?:[-:,]\s*)?/, (_, d) => take(d));
177
+ t = t.replace(/\s*\(([^()]*)\)/g, (m, inner) => {
178
+ const d = DATE.exec(inner);
179
+ return d && words(inner) <= 3 ? take(d[0]) : m;
180
+ });
181
+ t = trimEnd(t).replace(/[\s,;:]+(\d{4}-\d{2}-\d{2})$/, (_, d) => take(d));
182
+ date ??= DATE.exec(t)?.[0];
183
+ return { title: trimEnd(t), date };
184
+ }
185
+ function extractPointers(lines, into) {
186
+ const rest = [];
187
+ for (let i = 0; i < lines.length; i++) {
188
+ const m = POINTER.exec(lines[i]);
189
+ const key = m?.[2].toLowerCase();
190
+ if (!m || !POINTER_KEYS.includes(key)) {
191
+ rest.push(lines[i]);
192
+ continue;
193
+ }
194
+ let value = m[3].trim();
195
+ while (i + 1 < lines.length && lines[i + 1].trim() && indentOf(lines[i + 1]) > m[1].length && !LIST.test(lines[i + 1]))
196
+ value += ` ${lines[++i].trim()}`;
197
+ if (value)
198
+ (into[key] ??= []).push(value);
199
+ }
200
+ return rest;
201
+ }
202
+ function toPhase(tag) {
203
+ const t = tag.toLowerCase();
204
+ if (t.includes("maybe"))
205
+ return "maybe";
206
+ if (t.startsWith("way"))
207
+ return "wayLater";
208
+ return t;
209
+ }
210
+ function buildDraft(b, completed, key) {
211
+ const { first, after } = split(b);
212
+ let head = first.trim();
213
+ let status;
214
+ const mk = b.kind === "bullet" ? MARKER.exec(head) : null;
215
+ if (mk) {
216
+ status = STATUS[mk[1].toLowerCase()];
217
+ head = head.slice(mk[0].length);
218
+ }
219
+ else if (completed)
220
+ status = "done";
221
+ else
222
+ return null;
223
+ let phase = "later";
224
+ const pt = PHASE_TAG.exec(head);
225
+ if (pt) {
226
+ phase = toPhase(pt[1]);
227
+ head = head.slice(pt[0].length);
228
+ }
229
+ const closed = status === "done" || status === "dropped";
230
+ const sniffDrop = closed && (!mk || status === "dropped");
231
+ const d = { key, title: "", status, phase, pointers: {} };
232
+ let text = head;
233
+ let rest = "";
234
+ let plain = true;
235
+ const bold = BOLD.exec(text);
236
+ if (bold && bold[1].trim().length <= 200) {
237
+ d.title = bold[1].trim();
238
+ rest = text.slice(bold[0].length);
239
+ plain = false;
240
+ if (closed) {
241
+ const u = undate(d.title);
242
+ d.title = u.title;
243
+ d.date = u.date;
244
+ // `**Closed 2026-07-19**: the real title...`
245
+ if (!d.title || /^[a-z]+ed$/i.test(d.title)) {
246
+ if (/^dropped$/i.test(d.title))
247
+ d.status = "dropped";
248
+ text = trimStart(rest);
249
+ plain = true;
250
+ }
251
+ }
252
+ for (let k = 0; !plain && k < 2; k++) {
253
+ const p = /^\s*\(([^()]*)\)/.exec(rest);
254
+ const sz = p && SIZE.exec(p[1].trim());
255
+ if (sz && !d.size) {
256
+ d.size = sz[1];
257
+ const scope = sz[2]?.split(/,\s*/).map((s) => s.trim().toLowerCase()).filter(Boolean);
258
+ if (scope?.length)
259
+ d.scope = scope;
260
+ }
261
+ else if (p && closed && !d.date && DATE.test(p[1]) && words(p[1]) <= 3)
262
+ d.date = DATE.exec(p[1])?.[0];
263
+ else
264
+ break;
265
+ rest = rest.slice(p[0].length);
266
+ }
267
+ rest = trimStart(rest);
268
+ }
269
+ for (let tries = 0; plain && tries < 2; tries++) {
270
+ const p = plainTitle(text);
271
+ d.title = p.title;
272
+ rest = p.rest;
273
+ d.date ??= p.date;
274
+ if (!closed)
275
+ break;
276
+ const u = undate(d.title);
277
+ d.title = u.title;
278
+ d.date ??= u.date;
279
+ if (d.title && !/^[a-z]+ed$/i.test(d.title))
280
+ break;
281
+ text = p.after;
282
+ }
283
+ let noteHead = "";
284
+ if (closed && /^dropped:\s*/i.test(d.title)) {
285
+ d.status = "dropped";
286
+ d.title = d.title.replace(/^dropped:\s*/i, "");
287
+ }
288
+ else if (sniffDrop) {
289
+ const m = DROPPED_TAIL.exec(d.title);
290
+ if (m) {
291
+ d.status = "dropped";
292
+ d.title = m[1];
293
+ noteHead = m[2].replace(DATE, "").replace(/^[\s,.;:()]+|[\s,.;:()]+$/g, "");
294
+ if (noteHead && !/[.!?]$/.test(noteHead))
295
+ noteHead += ".";
296
+ }
297
+ }
298
+ d.title = trimEnd(d.title);
299
+ if (!d.title)
300
+ return null;
301
+ if (!closed)
302
+ delete d.date;
303
+ const tail = extractPointers(after, d.pointers);
304
+ d.detail = DETAIL.exec([head, ...after].join("\n"))?.[1];
305
+ if (d.status === "dropped")
306
+ d.note = tidy([[noteHead, rest].filter(Boolean).join(" "), ...tail]);
307
+ else {
308
+ const bare = closed ? undate(head).title : trimEnd(head);
309
+ d.body = tidy([plain ? (bare === d.title ? "" : head) : rest, ...tail]);
310
+ }
311
+ return d;
312
+ }
313
+ function buildTrap(b) {
314
+ const { first, after } = split(b);
315
+ const bold = BOLD.exec(first);
316
+ let title;
317
+ let rest;
318
+ if (bold && bold[1].trim().length <= 200) {
319
+ title = trimEnd(bold[1]);
320
+ rest = trimStart(first.slice(bold[0].length));
321
+ }
322
+ else
323
+ ({ title, rest } = plainTitle(first));
324
+ if (!title)
325
+ return null;
326
+ const body = tidy([rest, ...after]);
327
+ return body ? { title, body } : { title };
328
+ }
329
+ // In an archive, what follows a paragraph record belongs to it, unless it opens a record of its own:
330
+ // a bullet with a dated bold lead, or a paragraph with a dated or full-sentence one (`**Title.**`).
331
+ function continues(b) {
332
+ const lead = b.lines[0].trim();
333
+ const bold = BOLD.exec(lead);
334
+ if (!bold)
335
+ return true;
336
+ if (b.kind === "bullet")
337
+ return !DATE.test(bold[1]);
338
+ return !(DATE.test(bold[1]) || /[.!?:]$/.test(bold[1].trim()) || /^[.:]/.test(lead.slice(bold[0].length)));
339
+ }
340
+ // The block as markdown for a record's body: wrapped lines joined, sub-lists kept.
341
+ function asMarkdown(b) {
342
+ if (b.kind === "table")
343
+ return b.lines.join("\n");
344
+ const { first, after } = split(b);
345
+ if (b.kind === "para")
346
+ return [first, ...after].join("\n");
347
+ return [`- ${first}`, ...after.map((l) => (l ? ` ${l}` : l))].join("\n");
348
+ }
349
+ // Consecutive bullets stay one list; anything else gets a blank line before it.
350
+ function attach(d, b) {
351
+ const field = d.status === "dropped" ? "note" : "body";
352
+ const prev = d[field];
353
+ const inList = b.kind === "bullet" && /^- /.test(prev?.split("\n").findLast((l) => !/^\s/.test(l)) ?? "");
354
+ d[field] = prev ? `${prev}${inList ? "\n" : "\n\n"}${asMarkdown(b)}` : asMarkdown(b);
355
+ }
356
+ // Converts a hand-written goals.md (+ goals-completed.md) into a goals.json.
357
+ export function importMarkdown(goalsMd, opts) {
358
+ const skipped = [];
359
+ const traps = [];
360
+ const drafts = [];
361
+ const numbered = new Map();
362
+ const named = new Map();
363
+ let purpose;
364
+ const sectionKey = (text) => {
365
+ const m = /^(?:§\s*)?(\d{1,3})\s*[.:)]?\s+(.+)$/.exec(text);
366
+ if (m) {
367
+ const n = Number(m[1]);
368
+ if (!numbered.has(n))
369
+ numbered.set(n, m[2].trim());
370
+ return `n${n}`;
371
+ }
372
+ const k = text.trim().toLowerCase();
373
+ if (!named.has(k))
374
+ named.set(k, text.trim());
375
+ return `t${k}`;
376
+ };
377
+ const sources = [{ md: goalsMd, completed: false }, ...(opts.completedMd ? [{ md: opts.completedMd, completed: true }] : [])];
378
+ for (const { md, completed } of sources) {
379
+ let zone = "pre";
380
+ let base = "pre";
381
+ let key = "";
382
+ let last;
383
+ // Archive only: the paragraph record that following bullets and paragraphs belong to.
384
+ let parent;
385
+ for (const e of scan(md)) {
386
+ if (e.kind === "heading" || e.kind === "hr")
387
+ parent = undefined;
388
+ if (e.kind === "heading") {
389
+ if (e.level === 1)
390
+ zone = base = "pre";
391
+ else if (e.level <= 3 && /\btraps?\b/i.test(e.text))
392
+ zone = "traps";
393
+ else if (e.level === 2) {
394
+ key = sectionKey(e.text);
395
+ zone = base = "sec";
396
+ last = undefined;
397
+ }
398
+ else
399
+ zone = base;
400
+ continue;
401
+ }
402
+ if (e.kind === "hr") {
403
+ zone = base = "none";
404
+ continue;
405
+ }
406
+ // Only traps are kept here; anything else is reported, never dropped silently.
407
+ if (zone === "traps" || zone === "none") {
408
+ const t = zone === "traps" && e.kind === "bullet" ? buildTrap(e) : null;
409
+ if (t)
410
+ traps.push(t);
411
+ else
412
+ skipped.push(shown(e));
413
+ continue;
414
+ }
415
+ if (zone === "pre" && !completed && e.kind === "para" && !purpose) {
416
+ const m = /^\*{0,2}Purpose\*{0,2}:\*{0,2}\s*/i.exec(e.lines[0]);
417
+ if (m)
418
+ purpose = e.lines.map((l) => l.trim()).join(" ").slice(m[0].length).trim() || undefined;
419
+ }
420
+ if (zone !== "sec")
421
+ continue;
422
+ const lead = e.lines[0].trim();
423
+ if (e.kind === "table") {
424
+ if (parent)
425
+ attach(parent, e);
426
+ else
427
+ skipped.push(short(lead));
428
+ }
429
+ else if (e.kind === "bullet" && lead.startsWith("↳")) {
430
+ // A top-level `- ↳ files:` belongs to the item above it.
431
+ if (!last)
432
+ skipped.push(short(`- ${lead}`));
433
+ else {
434
+ const rest = extractPointers([`- ${e.lines[0]}`, ...e.lines.slice(1)], last.pointers);
435
+ if (last.status === "dropped")
436
+ last.note = tidy([last.note ?? "", ...rest]);
437
+ else
438
+ last.body = tidy([last.body ?? "", ...rest]);
439
+ }
440
+ }
441
+ else if (completed && e.kind === "para" && PLACEHOLDER.test(e.lines.join(" ").trim()))
442
+ continue;
443
+ else {
444
+ const d = buildDraft(e, completed, key);
445
+ if (completed && NOISE.test(lead))
446
+ skipped.push(short(lead));
447
+ else if (parent && d?.status !== "dropped" && continues(e))
448
+ attach(parent, e);
449
+ else if (d) {
450
+ drafts.push((last = d));
451
+ if (completed && e.kind === "para")
452
+ parent = d;
453
+ }
454
+ else
455
+ skipped.push(shown(e));
456
+ }
457
+ }
458
+ }
459
+ // Unnumbered sections that kept an item take the next free numbers, in order of appearance.
460
+ const numOf = new Map();
461
+ const titleOf = new Map(numbered);
462
+ for (const n of numbered.keys())
463
+ numOf.set(`n${n}`, n);
464
+ let next = Math.max(0, ...numbered.keys()) + 1;
465
+ for (const [k, title] of named) {
466
+ if (!drafts.some((d) => d.key === `t${k}`))
467
+ continue;
468
+ const same = [...numbered].find(([, t]) => t.toLowerCase() === k);
469
+ const n = same ? same[0] : next++;
470
+ numOf.set(`t${k}`, n);
471
+ if (!titleOf.has(n))
472
+ titleOf.set(n, title);
473
+ }
474
+ const sections = [...new Set(drafts.map((d) => numOf.get(d.key)))]
475
+ .sort((a, b) => a - b)
476
+ .map((n) => ({ n, title: titleOf.get(n).slice(0, 120).trim() }));
477
+ const taken = new Set();
478
+ const id = (title) => {
479
+ const s = uniqueSlug(slugify(title), taken);
480
+ taken.add(s);
481
+ return s;
482
+ };
483
+ const trapList = traps.map((t) => ({ id: id(t.title), ...t }));
484
+ const items = drafts.map((d) => ({
485
+ id: id(d.title),
486
+ section: numOf.get(d.key),
487
+ title: d.title,
488
+ ...(d.body ? { body: d.body } : {}),
489
+ phase: d.phase,
490
+ status: d.status,
491
+ ...(d.size ? { size: d.size } : {}),
492
+ ...(d.scope ? { scope: d.scope } : {}),
493
+ ...(Object.keys(d.pointers).length ? { pointers: d.pointers } : {}),
494
+ ...(d.detail ? { detail: d.detail } : {}),
495
+ created: d.date ?? opts.today,
496
+ updated: d.date ?? opts.today,
497
+ ...(d.status === "dropped" ? { note: d.note?.trim() || "dropped" } : {}),
498
+ }));
499
+ const file = parseGoals({ version: 1, project: opts.project, ...(purpose ? { purpose } : {}), sections, traps: trapList, items });
500
+ const issues = localIssues(file);
501
+ if (issues.length)
502
+ throw new UpgoError("import produced an invalid goals.json:", issues);
503
+ const count = (s) => items.filter((i) => i.status === s).length;
504
+ return {
505
+ file,
506
+ report: {
507
+ open: items.filter((i) => isOpenStatus(i.status)).length,
508
+ done: count("done"),
509
+ dropped: count("dropped"),
510
+ traps: trapList.length,
511
+ sections: sections.length,
512
+ skipped,
513
+ },
514
+ };
515
+ }
@@ -0,0 +1,164 @@
1
+ import { slugify, uniqueSlug, UpgoError } from "./util.js";
2
+ const clone = (f) => structuredClone(f);
3
+ function indexOf(f, id) {
4
+ const i = f.items.findIndex((it) => it.id === id);
5
+ if (i < 0)
6
+ throw new UpgoError(`no item \`${id}\` in ${f.project}`);
7
+ return i;
8
+ }
9
+ function requireSection(f, n) {
10
+ if (!f.sections.some((s) => s.n === n))
11
+ throw new UpgoError(`no section ${n} in ${f.project} (add it with \`upgo section add\`)`);
12
+ }
13
+ // Position just after the last item of section `n`, so array order keeps sections together.
14
+ // An empty section goes before the first item of a later one, so an undo puts it back where it was.
15
+ function endOfSection(f, n) {
16
+ for (let i = f.items.length - 1; i >= 0; i--)
17
+ if (f.items[i].section === n)
18
+ return i + 1;
19
+ const later = new Set(f.sections.slice(f.sections.findIndex((s) => s.n === n) + 1).map((s) => s.n));
20
+ const next = f.items.findIndex((i) => later.has(i.section));
21
+ return next < 0 ? f.items.length : next;
22
+ }
23
+ function applyPatch(target, patch) {
24
+ const out = { ...target };
25
+ for (const [k, v] of Object.entries(patch)) {
26
+ if (v === undefined)
27
+ continue;
28
+ if (v === null)
29
+ delete out[k];
30
+ else
31
+ out[k] = v;
32
+ }
33
+ return out;
34
+ }
35
+ export function addItem(file, input, day) {
36
+ const f = clone(file);
37
+ requireSection(f, input.section);
38
+ const taken = new Set(f.items.map((i) => i.id));
39
+ if (input.id && taken.has(input.id))
40
+ throw new UpgoError(`item id \`${input.id}\` already exists`);
41
+ const id = input.id ?? uniqueSlug(slugify(input.title), taken);
42
+ const item = { ...input, id, status: input.status ?? "planned", created: input.created ?? day, updated: day };
43
+ f.items.splice(endOfSection(f, item.section), 0, item);
44
+ return { file: f, id };
45
+ }
46
+ export function editItem(file, id, patch, day) {
47
+ const f = clone(file);
48
+ const i = indexOf(f, id);
49
+ if (patch.section !== undefined && patch.section !== f.items[i].section) {
50
+ requireSection(f, patch.section);
51
+ const [item] = f.items.splice(i, 1);
52
+ const moved = applyPatch(item, { ...patch, updated: day });
53
+ f.items.splice(endOfSection(f, moved.section), 0, moved);
54
+ return f;
55
+ }
56
+ f.items[i] = applyPatch(f.items[i], { ...patch, updated: day });
57
+ return f;
58
+ }
59
+ export function setStatus(file, id, status, note, day) {
60
+ if (status === "dropped" && !note?.trim())
61
+ throw new UpgoError("dropping an item needs a reason");
62
+ const patch = { status };
63
+ if (note?.trim())
64
+ patch.note = note.trim();
65
+ else if (status === "planned" || status === "partial")
66
+ patch.note = null;
67
+ return editItem(file, id, patch, day);
68
+ }
69
+ export function deleteItem(file, id) {
70
+ const f = clone(file);
71
+ f.items.splice(indexOf(f, id), 1);
72
+ return f;
73
+ }
74
+ export function moveItem(file, id, to, day) {
75
+ if (to.before && to.after)
76
+ throw new UpgoError("give either `before` or `after`, not both");
77
+ if (to.before === id || to.after === id)
78
+ throw new UpgoError("an item cannot move next to itself");
79
+ const f = clone(file);
80
+ const [item] = f.items.splice(indexOf(f, id), 1);
81
+ const anchorId = to.before ?? to.after;
82
+ const anchor = anchorId ? f.items[indexOf(f, anchorId)] : undefined;
83
+ const section = anchor?.section ?? to.section ?? item.section;
84
+ if (to.section !== undefined && anchor && anchor.section !== to.section) {
85
+ throw new UpgoError(`\`${anchorId}\` is in section ${anchor.section}, not ${to.section}`);
86
+ }
87
+ requireSection(f, section);
88
+ const moved = section === item.section ? item : { ...item, section, updated: day };
89
+ const at = anchor ? f.items.indexOf(anchor) + (to.after ? 1 : 0) : endOfSection(f, section);
90
+ f.items.splice(at, 0, moved);
91
+ return f;
92
+ }
93
+ // One step up or down among the items of its own section, optionally only counting `visible` ones.
94
+ export function nudgeItem(file, id, dir, visible) {
95
+ const item = file.items[indexOf(file, id)];
96
+ const peers = file.items.filter((i) => i.section === item.section && (i.id === id || !visible || visible.has(i.id)));
97
+ const at = peers.findIndex((i) => i.id === id);
98
+ const neighbor = peers[dir === "up" ? at - 1 : at + 1];
99
+ if (!neighbor)
100
+ return file;
101
+ return moveItem(file, id, dir === "up" ? { before: neighbor.id } : { after: neighbor.id }, item.updated);
102
+ }
103
+ export function addSection(file, title, n) {
104
+ const f = clone(file);
105
+ const num = n ?? Math.max(0, ...f.sections.map((s) => s.n)) + 1;
106
+ if (f.sections.some((s) => s.n === num))
107
+ throw new UpgoError(`section ${num} already exists`);
108
+ f.sections.push({ n: num, title });
109
+ return { file: f, n: num };
110
+ }
111
+ export function renameSection(file, n, title) {
112
+ const f = clone(file);
113
+ requireSection(f, n);
114
+ f.sections = f.sections.map((s) => (s.n === n ? { ...s, title } : s));
115
+ return f;
116
+ }
117
+ export function moveSection(file, n, before) {
118
+ const f = clone(file);
119
+ requireSection(f, n);
120
+ if (before === n)
121
+ throw new UpgoError("a section cannot move before itself");
122
+ if (before !== null)
123
+ requireSection(f, before);
124
+ const section = f.sections.find((s) => s.n === n);
125
+ f.sections = f.sections.filter((s) => s.n !== n);
126
+ f.sections.splice(before === null ? f.sections.length : f.sections.findIndex((s) => s.n === before), 0, section);
127
+ return f;
128
+ }
129
+ export function deleteSection(file, n) {
130
+ const f = clone(file);
131
+ requireSection(f, n);
132
+ const count = f.items.filter((i) => i.section === n).length;
133
+ if (count)
134
+ throw new UpgoError(`section ${n} still holds ${count} item${count === 1 ? "" : "s"} (done and dropped ones count too)`);
135
+ f.sections = f.sections.filter((s) => s.n !== n);
136
+ return f;
137
+ }
138
+ export function addTrap(file, trap) {
139
+ const f = clone(file);
140
+ const taken = new Set(f.traps.map((t) => t.id));
141
+ if (trap.id && taken.has(trap.id))
142
+ throw new UpgoError(`trap id \`${trap.id}\` already exists`);
143
+ const id = trap.id ?? uniqueSlug(slugify(trap.title), taken);
144
+ f.traps.push({ ...trap, id });
145
+ return { file: f, id };
146
+ }
147
+ export function editTrap(file, id, patch) {
148
+ const f = clone(file);
149
+ const i = f.traps.findIndex((t) => t.id === id);
150
+ if (i < 0)
151
+ throw new UpgoError(`no trap \`${id}\` in ${f.project}`);
152
+ f.traps[i] = applyPatch(f.traps[i], patch);
153
+ return f;
154
+ }
155
+ export function deleteTrap(file, id) {
156
+ const f = clone(file);
157
+ if (!f.traps.some((t) => t.id === id))
158
+ throw new UpgoError(`no trap \`${id}\` in ${f.project}`);
159
+ f.traps = f.traps.filter((t) => t.id !== id);
160
+ return f;
161
+ }
162
+ export function setPurpose(file, purpose) {
163
+ return applyPatch(clone(file), { purpose: purpose?.trim() || null });
164
+ }