things-api 0.6.0 → 0.8.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/README.md +13 -1
- package/dist/cli/commands/area.d.ts +14 -0
- package/dist/cli/commands/area.js +112 -33
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.js +8 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/project.d.ts +14 -0
- package/dist/cli/commands/project.js +123 -34
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +111 -10
- package/dist/cli/commands/reads.js +567 -73
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.d.ts +2 -0
- package/dist/cli/commands/setup.js +91 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/commands/show.d.ts +9 -0
- package/dist/cli/commands/show.js +41 -0
- package/dist/cli/commands/show.js.map +1 -0
- package/dist/cli/commands/todo.d.ts +9 -0
- package/dist/cli/commands/todo.js +89 -15
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +55 -1
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/glyphs.d.ts +72 -0
- package/dist/cli/glyphs.js +169 -0
- package/dist/cli/glyphs.js.map +1 -0
- package/dist/cli/main.js +4 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/style.d.ts +6 -0
- package/dist/cli/style.js +6 -0
- package/dist/cli/style.js.map +1 -1
- package/dist/client.d.ts +35 -37
- package/dist/client.js +10 -74
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- package/dist/db/baselines/db-v26.js +7 -5
- package/dist/db/baselines/db-v26.js.map +1 -1
- package/dist/db/schema.d.ts +1 -1
- package/dist/db/schema.js +7 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/diagnose.d.ts +19 -0
- package/dist/diagnose.js +42 -0
- package/dist/diagnose.js.map +1 -1
- package/dist/mcp/server.js +20 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +14 -0
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.js +8 -0
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/occurrences.js +4 -1
- package/dist/model/occurrences.js.map +1 -1
- package/dist/model/recurrence.d.ts +23 -2
- package/dist/model/recurrence.js +33 -1
- package/dist/model/recurrence.js.map +1 -1
- package/dist/read/area-view.js +21 -5
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.js +12 -2
- package/dist/read/detail.js.map +1 -1
- package/dist/read/log-boundary.d.ts +24 -0
- package/dist/read/log-boundary.js +38 -0
- package/dist/read/log-boundary.js.map +1 -0
- package/dist/read/project-view.js +28 -3
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +14 -1
- package/dist/read/queries.js +25 -2
- package/dist/read/queries.js.map +1 -1
- package/dist/read/show-target.d.ts +16 -0
- package/dist/read/show-target.js +32 -0
- package/dist/read/show-target.js.map +1 -0
- package/dist/read/snapshot.js +2 -0
- package/dist/read/snapshot.js.map +1 -1
- package/dist/read/views.d.ts +25 -3
- package/dist/read/views.js +176 -21
- package/dist/read/views.js.map +1 -1
- package/dist/write/automation-probe.js +14 -6
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/availability.d.ts +28 -0
- package/dist/write/availability.js +105 -0
- package/dist/write/availability.js.map +1 -0
- package/dist/write/capabilities.d.ts +3 -0
- package/dist/write/capabilities.js +2 -0
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/checklist.d.ts +50 -0
- package/dist/write/checklist.js +61 -0
- package/dist/write/checklist.js.map +1 -0
- package/dist/write/clear-reminder.d.ts +3 -0
- package/dist/write/clear-reminder.js +205 -0
- package/dist/write/clear-reminder.js.map +1 -0
- package/dist/write/commands.js +129 -9
- package/dist/write/commands.js.map +1 -1
- package/dist/write/edit-checklist.d.ts +3 -0
- package/dist/write/edit-checklist.js +184 -0
- package/dist/write/edit-checklist.js.map +1 -0
- package/dist/write/failure-hints.d.ts +14 -1
- package/dist/write/failure-hints.js +31 -6
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +16 -0
- package/dist/write/guards.js.map +1 -1
- package/dist/write/operations.d.ts +32 -1
- package/dist/write/operations.js +3 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +5 -0
- package/dist/write/pipeline.js +24 -1
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/reversibility.d.ts +46 -0
- package/dist/write/reversibility.js +164 -0
- package/dist/write/reversibility.js.map +1 -0
- package/dist/write/undo.d.ts +29 -2
- package/dist/write/undo.js +356 -9
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/registry.js +2 -1
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/shortcuts.d.ts +9 -0
- package/dist/write/vectors/shortcuts.js +90 -0
- package/dist/write/vectors/shortcuts.js.map +1 -0
- package/dist/write/vectors/types.d.ts +11 -3
- package/dist/write/vectors/url-scheme.js +16 -0
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +1 -1
- package/dist/write/verify/delta.js +1 -1
- package/dist/write/verify/delta.js.map +1 -1
- package/package.json +3 -2
- package/shortcuts/things-proxy-create-heading.shortcut +0 -0
- package/shortcuts/things-proxy-delete-items-permanently.shortcut +0 -0
- package/shortcuts/things-proxy-delete-items.shortcut +0 -0
- package/shortcuts/things-proxy-edit-title.shortcut +0 -0
- package/shortcuts/things-proxy-find-items.shortcut +0 -0
- package/shortcuts/things-proxy-set-detail.shortcut +0 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { openThings } from "../../client.js";
|
|
2
3
|
import { ThingsDbNotFoundError } from "../../db/locate.js";
|
|
3
4
|
import { ThingsDbOpenError } from "../../db/connection.js";
|
|
4
5
|
import { isTodayMember } from "../../read/views.js";
|
|
5
|
-
import {
|
|
6
|
+
import { localToday } from "../../model/dates.js";
|
|
7
|
+
import { templateStatus } from "../../model/recurrence.js";
|
|
8
|
+
import { blue, bold, dim, strike, underline } from "../style.js";
|
|
9
|
+
import { areaMark, CHECKLIST_MARK, countChip, dateChip, deadlineToken, eveningMoon, loggedDate, NOTES_MARK, projectCircle, REMINDER_MARK, shortDate, todayStar, todoBox, } from "../glyphs.js";
|
|
6
10
|
import { errorEnvelope, ExitCode, okEnvelope } from "../../contracts.js";
|
|
7
11
|
export function withClient(opts, kind, fn, render) {
|
|
8
12
|
const started = Date.now();
|
|
@@ -48,38 +52,121 @@ export function withClient(opts, kind, fn, render) {
|
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
/**
|
|
51
|
-
* One item line:
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* One item line:
|
|
56
|
+
* `<uuid-prefix> <box> [★|⏾] [logged-date] [‹chip›] <title> [‹n›] [⍾] [≡] [≔] (container) #tags [⚑ deadline]`.
|
|
57
|
+
* Repeating templates seat ↻ INSIDE the box (`[↻]`/`(↻)`) rather than as a
|
|
58
|
+
* separate mark; open project rows render their circle and title blue.
|
|
59
|
+
* The box is the glyph-language state carrier (../glyphs.ts): `[ ]`-family
|
|
60
|
+
* for to-dos, `( )`-family for projects — state survives with color
|
|
61
|
+
* stripped. Completed titles dim; canceled titles dim+strike (the `[×]`
|
|
62
|
+
* mark keeps the state when strike/ANSI is unavailable). Human output shows
|
|
63
|
+
* a SHORTENED uuid prefix (every command accepts unique prefixes >= 6
|
|
64
|
+
* chars); `uuidWidth` is the display length from uuidDisplayWidth — never
|
|
65
|
+
* below 8 so a copied prefix stays unique across the whole database, not
|
|
66
|
+
* just the rendered list. Tags follow the title (`#`-prefixed, green — GUI
|
|
67
|
+
* color), after the count chip (projects), notes marker, and container.
|
|
68
|
+
* Heading-nested to-dos label their parent PROJECT (via headingProject),
|
|
69
|
+
* never the heading — GUI behavior. Colors engage on a TTY only
|
|
70
|
+
* (../style.ts); `--json` always carries full uuids.
|
|
59
71
|
*/
|
|
60
|
-
export function formatItem(item, uuidWidth = 0) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
export function formatItem(item, uuidWidth = 0, opts = {}) {
|
|
73
|
+
const todayIso = localToday(opts.now);
|
|
74
|
+
const asTitle = opts.projectTitle === true && item.type === "project";
|
|
75
|
+
const box = item.type === "project" ? projectCircle(item) : todoBox(item);
|
|
76
|
+
const meta = [];
|
|
77
|
+
if (opts.mark != null)
|
|
78
|
+
meta.push(opts.mark);
|
|
79
|
+
// ↻ now lives INSIDE the box for templates (glyphs.ts) — no separate mark.
|
|
80
|
+
if (opts.statusWord !== undefined)
|
|
81
|
+
meta.push(dim(opts.statusWord));
|
|
82
|
+
if (item.status !== "open" && item.stopped !== null)
|
|
83
|
+
meta.push(loggedDate(item.stopped, todayIso));
|
|
84
|
+
if (opts.hideDateChip === true) {
|
|
85
|
+
// rows under a day header — the header carries the date
|
|
86
|
+
}
|
|
87
|
+
else if (item.status === "open" && item.startDate !== null && item.startDate > todayIso)
|
|
88
|
+
meta.push(dateChip(item.startDate, todayIso));
|
|
89
|
+
// Repeating templates chip their app-materialized next occurrence.
|
|
90
|
+
else if (item.repeating.isTemplate && item.repeating.nextOccurrence != null)
|
|
91
|
+
meta.push(dateChip(item.repeating.nextOccurrence, todayIso));
|
|
92
|
+
// List rows mute their tags (the GUI's gray pills); tags go green only on
|
|
93
|
+
// the opened resource (todo show / the project|area header row).
|
|
94
|
+
const tags = item.tags.length > 0 ? ` ${dim(`#${item.tags.map((t) => t.title).join(" #")}`)}` : "";
|
|
95
|
+
// Closed rows drop the deadline flag — a months-old red "n days ago" on a
|
|
96
|
+
// logged item is noise (the GUI doesn't flag logbook rows either). Raw
|
|
97
|
+
// template rows drop it via the sentinel guard: their deadline column
|
|
98
|
+
// carries app-internal 4001-01-01 sentinels (upcoming's synthesized
|
|
99
|
+
// occurrences carry REAL rule-derived deadlines and must keep the flag).
|
|
100
|
+
const rule = item.repeating.rule;
|
|
101
|
+
const deadline = item.status === "open" && item.deadline !== null && item.deadline < "4000"
|
|
102
|
+
? ` ${deadlineToken(item.deadline, todayIso)}`
|
|
103
|
+
: // The GUI's bare flag on no-date repeating templates: the rule WILL
|
|
104
|
+
// assign each occurrence a deadline (fixed rules always do; after-
|
|
105
|
+
// completion only with a start offset), date unknown until spawned.
|
|
106
|
+
item.repeating.isTemplate &&
|
|
107
|
+
rule !== undefined &&
|
|
108
|
+
(rule.type === "fixed" || rule.startOffsetDays < 0)
|
|
109
|
+
? ` ${bold(dim("⚑"))}`
|
|
110
|
+
: "";
|
|
111
|
+
const container = item.type === "to-do" ? (item.project ?? item.headingProject ?? null) : null;
|
|
112
|
+
const context = container !== null
|
|
113
|
+
? container.uuid === opts.suppressProject
|
|
114
|
+
? ""
|
|
115
|
+
: ` (${container.title})`
|
|
70
116
|
: item.area
|
|
71
|
-
?
|
|
117
|
+
? item.area.uuid === opts.suppressArea
|
|
118
|
+
? ""
|
|
119
|
+
: ` (${item.area.title})`
|
|
72
120
|
: "";
|
|
73
121
|
const shownUuid = uuidWidth > 0 && item.uuid.length > uuidWidth
|
|
74
122
|
? item.uuid.slice(0, uuidWidth)
|
|
75
123
|
: item.uuid.padEnd(uuidWidth);
|
|
124
|
+
let title = item.title;
|
|
125
|
+
if (asTitle)
|
|
126
|
+
title = bold(underline(title));
|
|
127
|
+
else if (item.status === "canceled")
|
|
128
|
+
title = dim(strike(title));
|
|
129
|
+
else if (item.status === "completed")
|
|
130
|
+
title = dim(title);
|
|
131
|
+
// Open project rows render their title blue — GUI parity (list accent) and
|
|
132
|
+
// a color cue reinforcing the round bracket. To-dos stay default-colored.
|
|
133
|
+
else if (item.type === "project")
|
|
134
|
+
title = blue(title);
|
|
135
|
+
// GUI indicator order after the title: bell, document, checklist.
|
|
136
|
+
const tail = [
|
|
137
|
+
...(item.type === "project" ? [countChip(item)] : []),
|
|
138
|
+
...(item.reminder !== null ? [dim(REMINDER_MARK)] : []),
|
|
139
|
+
...(item.notes !== "" ? [dim(NOTES_MARK)] : []),
|
|
140
|
+
...(item.type === "to-do" && item.checklistItemsCount > 0 ? [dim(CHECKLIST_MARK)] : []),
|
|
141
|
+
];
|
|
76
142
|
return [
|
|
77
143
|
`${dim(shownUuid)} `,
|
|
78
|
-
|
|
144
|
+
box,
|
|
79
145
|
...meta,
|
|
80
|
-
|
|
146
|
+
`${title}${tail.length > 0 ? ` ${tail.join(" ")}` : ""}${tags}${context === "" ? "" : dim(context)}${deadline}`,
|
|
81
147
|
].join(" ");
|
|
82
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Row prefix in today-aware views: yellow ★ for Today members, cyan ⏾ for
|
|
151
|
+
* effective This-Evening members (raw evening assignment counts only while
|
|
152
|
+
* startDate is exactly today — the UI's daily expiry), null otherwise.
|
|
153
|
+
*/
|
|
154
|
+
export function todayMark(item, now) {
|
|
155
|
+
if (!isTodayMember(item, now))
|
|
156
|
+
return null;
|
|
157
|
+
const evening = item.todaySection === "evening" && item.startDate === localToday(now);
|
|
158
|
+
return evening ? eveningMoon() : todayStar();
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Foreground the Things app on a resource via its share URI. A GUI action
|
|
162
|
+
* on this Mac — NOT headless; the shared implementation behind every
|
|
163
|
+
* `open` command. Returns the URI it launched.
|
|
164
|
+
*/
|
|
165
|
+
export function openInThings(uuid) {
|
|
166
|
+
const uri = `things:///show?id=${uuid}`;
|
|
167
|
+
execFileSync("/usr/bin/open", [uri]);
|
|
168
|
+
return uri;
|
|
169
|
+
}
|
|
83
170
|
/** Minimum displayed-prefix length: shorter prefixes collide across the DB. */
|
|
84
171
|
const UUID_DISPLAY_MIN = 8;
|
|
85
172
|
/**
|
|
@@ -107,22 +194,289 @@ function renderList(items) {
|
|
|
107
194
|
return items.length === 0 ? ["(empty)"] : items.map((i) => formatItem(i, w));
|
|
108
195
|
}
|
|
109
196
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
197
|
+
* The sidebar mirror for `things projects`: loose projects first (the GUI
|
|
198
|
+
* lists them above the areas), then a `── ⬡ Area ──` header per area with
|
|
199
|
+
* its projects beneath (the redundant `(Area)` suffix suppressed). Items
|
|
200
|
+
* arrive from projectsView already in sidebar order — this only inserts the
|
|
201
|
+
* headers. Denser than renderSections on purpose: no title styling and no
|
|
202
|
+
* blank line per project (every row here IS a project). With `hints`,
|
|
203
|
+
* default-hidden later projects are never silent: each group trails a muted
|
|
204
|
+
* `…n later projects` count (a later-only area still gets its header), and
|
|
205
|
+
* the output ends with the flag that reveals them.
|
|
206
|
+
*/
|
|
207
|
+
export function renderProjectsSidebar(items, hints) {
|
|
208
|
+
const total = hints?.groups.reduce((n, g) => n + g.hidden, 0) ?? 0;
|
|
209
|
+
if (items.length === 0 && total === 0 && (hints === undefined || hints.groups.length === 0))
|
|
210
|
+
return ["(empty)"];
|
|
211
|
+
const w = uuidDisplayWidth(items);
|
|
212
|
+
const byGroup = new Map();
|
|
213
|
+
for (const item of items) {
|
|
214
|
+
const key = item.area?.uuid ?? null;
|
|
215
|
+
byGroup.set(key, [...(byGroup.get(key) ?? []), item]);
|
|
216
|
+
}
|
|
217
|
+
// hints (when present) carry the full sidebar group order, including
|
|
218
|
+
// later-only groups the visible items can't reveal.
|
|
219
|
+
const groups = hints?.groups ??
|
|
220
|
+
[...byGroup.keys()].map((key) => ({
|
|
221
|
+
area: key === null ? null : (items.find((i) => i.area?.uuid === key)?.area ?? null),
|
|
222
|
+
hidden: 0,
|
|
223
|
+
}));
|
|
224
|
+
const lines = [];
|
|
225
|
+
for (const group of groups) {
|
|
226
|
+
const rows = byGroup.get(group.area?.uuid ?? null) ?? [];
|
|
227
|
+
// The loose block only exists when it has content; areas mirror the
|
|
228
|
+
// sidebar and render even when empty.
|
|
229
|
+
if (group.area === null && rows.length === 0 && group.hidden === 0)
|
|
230
|
+
continue;
|
|
231
|
+
if (group.area !== null) {
|
|
232
|
+
if (lines.length > 0)
|
|
233
|
+
lines.push("");
|
|
234
|
+
lines.push(`${bold("──")} ${areaMark()} ${bold(`${group.area.title} ──`)}`);
|
|
235
|
+
}
|
|
236
|
+
lines.push(...rows.map((item) => formatItem(item, w, { suppressArea: group.area?.uuid ?? null })));
|
|
237
|
+
if (group.hidden > 0)
|
|
238
|
+
lines.push(dim(`…${group.hidden} later project${group.hidden === 1 ? "" : "s"}`));
|
|
239
|
+
else if (group.area !== null && rows.length === 0)
|
|
240
|
+
lines.push(dim("(no projects)"));
|
|
241
|
+
}
|
|
242
|
+
if (total > 0)
|
|
243
|
+
lines.push("", dim(`(${total} later project${total === 1 ? "" : "s"} — visible with \`things projects --show-later\`)`));
|
|
244
|
+
return lines;
|
|
245
|
+
}
|
|
246
|
+
const FULL_MONTHS = [
|
|
247
|
+
"January",
|
|
248
|
+
"February",
|
|
249
|
+
"March",
|
|
250
|
+
"April",
|
|
251
|
+
"May",
|
|
252
|
+
"June",
|
|
253
|
+
"July",
|
|
254
|
+
"August",
|
|
255
|
+
"September",
|
|
256
|
+
"October",
|
|
257
|
+
"November",
|
|
258
|
+
"December",
|
|
259
|
+
];
|
|
260
|
+
/**
|
|
261
|
+
* Relative period: `3d`/`2w`/`1m`/`1y` (days/weeks/calendar months/years),
|
|
262
|
+
* counted from `now` — FORWARD for an until-bound, BACKWARD for a since-
|
|
263
|
+
* bound (each command's natural direction; the flag name carries it).
|
|
264
|
+
*/
|
|
265
|
+
const RELATIVE_PERIOD = /^(\d+)([dwmy])$/i;
|
|
266
|
+
function relativePeriodDate(m, now, sign) {
|
|
267
|
+
const n = sign * Number(m[1]);
|
|
268
|
+
const unit = (m[2] ?? "").toLowerCase();
|
|
269
|
+
const d = new Date(now);
|
|
270
|
+
if (unit === "d")
|
|
271
|
+
d.setDate(d.getDate() + n);
|
|
272
|
+
else if (unit === "w")
|
|
273
|
+
d.setDate(d.getDate() + 7 * n);
|
|
274
|
+
else if (unit === "m")
|
|
275
|
+
d.setMonth(d.getMonth() + n);
|
|
276
|
+
else
|
|
277
|
+
d.setFullYear(d.getFullYear() + n);
|
|
278
|
+
return d;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* `--until` accepting whole periods: `2024` means through Dec 31 2024,
|
|
282
|
+
* `2024-03` through Mar 31, `2024-03-05` through end of that day; relative
|
|
283
|
+
* periods (`2w`, `1m`, `1y`) count FORWARD from now through the end of the
|
|
284
|
+
* landing day; anything else parses as an instant.
|
|
285
|
+
*/
|
|
286
|
+
export function parsePeriodEnd(s, now = new Date()) {
|
|
287
|
+
const rel = RELATIVE_PERIOD.exec(s.trim());
|
|
288
|
+
if (rel !== null) {
|
|
289
|
+
const d = relativePeriodDate(rel, now, 1);
|
|
290
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
|
291
|
+
}
|
|
292
|
+
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(s.trim());
|
|
293
|
+
if (m === null)
|
|
294
|
+
return new Date(s);
|
|
295
|
+
const year = Number(m[1]);
|
|
296
|
+
// Day 0 of month n+1 = the last day of month n.
|
|
297
|
+
if (m[2] === undefined)
|
|
298
|
+
return new Date(year, 11, 31, 23, 59, 59, 999);
|
|
299
|
+
const month = Number(m[2]) - 1;
|
|
300
|
+
if (m[3] === undefined)
|
|
301
|
+
return new Date(year, month + 1, 0, 23, 59, 59, 999);
|
|
302
|
+
return new Date(year, month, Number(m[3]), 23, 59, 59, 999);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* `--since` accepting the same vocabulary at the period's START: `2024` =
|
|
306
|
+
* Jan 1 2024 00:00, `2024-03` = Mar 1, `2024-03-05` = that midnight;
|
|
307
|
+
* relative periods (`2w`, `1m`) count BACKWARD from now to the landing
|
|
308
|
+
* day's midnight; anything else parses as an instant.
|
|
309
|
+
*/
|
|
310
|
+
export function parsePeriodStart(s, now = new Date()) {
|
|
311
|
+
const rel = RELATIVE_PERIOD.exec(s.trim());
|
|
312
|
+
if (rel !== null) {
|
|
313
|
+
const d = relativePeriodDate(rel, now, -1);
|
|
314
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
315
|
+
}
|
|
316
|
+
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(s.trim());
|
|
317
|
+
if (m === null)
|
|
318
|
+
return new Date(s);
|
|
319
|
+
const year = Number(m[1]);
|
|
320
|
+
if (m[2] === undefined)
|
|
321
|
+
return new Date(year, 0, 1);
|
|
322
|
+
const month = Number(m[2]) - 1;
|
|
323
|
+
if (m[3] === undefined)
|
|
324
|
+
return new Date(year, month, 1);
|
|
325
|
+
return new Date(year, month, Number(m[3]));
|
|
326
|
+
}
|
|
327
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
328
|
+
const SHORT_MONTHS = [
|
|
329
|
+
"Jan",
|
|
330
|
+
"Feb",
|
|
331
|
+
"Mar",
|
|
332
|
+
"Apr",
|
|
333
|
+
"May",
|
|
334
|
+
"Jun",
|
|
335
|
+
"Jul",
|
|
336
|
+
"Aug",
|
|
337
|
+
"Sep",
|
|
338
|
+
"Oct",
|
|
339
|
+
"Nov",
|
|
340
|
+
"Dec",
|
|
341
|
+
];
|
|
342
|
+
/**
|
|
343
|
+
* GUI-style Upcoming bucket for a date, granularity decaying with distance:
|
|
344
|
+
* individual days for the next week ("Wed Jul 15"), the remainder of the
|
|
345
|
+
* current month ("Jul 19–31"), months through the end of NEXT year
|
|
346
|
+
* ("August", "January 2027"), then bare years ("2028").
|
|
347
|
+
*/
|
|
348
|
+
function upcomingBucket(iso, todayIso) {
|
|
349
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
350
|
+
const [y0, m0, d0] = todayIso.split("-").map(Number);
|
|
351
|
+
const diff = Math.round((Date.UTC(y ?? 0, (m ?? 1) - 1, d ?? 1) - Date.UTC(y0 ?? 0, (m0 ?? 1) - 1, d0 ?? 1)) /
|
|
352
|
+
86_400_000);
|
|
353
|
+
if (diff <= 7) {
|
|
354
|
+
const weekday = WEEKDAYS[new Date(Date.UTC(y ?? 0, (m ?? 1) - 1, d ?? 1)).getUTCDay()];
|
|
355
|
+
return { label: `${weekday} ${SHORT_MONTHS[(m ?? 1) - 1]} ${d}`, isDay: true };
|
|
356
|
+
}
|
|
357
|
+
if (y === y0 && m === m0) {
|
|
358
|
+
const lastDay = new Date(y ?? 0, m ?? 1, 0).getDate();
|
|
359
|
+
return { label: `${SHORT_MONTHS[(m ?? 1) - 1]} ${(d0 ?? 1) + 8}–${lastDay}`, isDay: false };
|
|
360
|
+
}
|
|
361
|
+
if ((y ?? 0) <= (y0 ?? 0) + 1) {
|
|
362
|
+
const month = FULL_MONTHS[(m ?? 1) - 1];
|
|
363
|
+
return { label: y === y0 ? `${month}` : `${month} ${y}`, isDay: false };
|
|
364
|
+
}
|
|
365
|
+
return { label: `${y}`, isDay: false };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Upcoming rows under GUI-style date headers (empty periods are simply
|
|
369
|
+
* absent), with the trailing Repeating To-Dos section: templates with no
|
|
370
|
+
* set next occurrence, carrying their waiting/paused/ended status word and
|
|
371
|
+
* the bare ⚑ when the rule will assign a deadline per occurrence.
|
|
372
|
+
*/
|
|
373
|
+
export function renderUpcoming(items, now) {
|
|
374
|
+
if (items.length === 0)
|
|
375
|
+
return ["(empty)"];
|
|
376
|
+
const todayIso = localToday(now);
|
|
377
|
+
const w = uuidDisplayWidth(items);
|
|
378
|
+
const fmtOpts = now === undefined ? {} : { now };
|
|
379
|
+
const dated = items.filter((i) => i.startDate !== null);
|
|
380
|
+
const resting = items.filter((i) => i.startDate === null && i.repeating.isTemplate);
|
|
381
|
+
const lines = [];
|
|
382
|
+
let openHeader = null;
|
|
383
|
+
for (const item of dated) {
|
|
384
|
+
const bucket = upcomingBucket(item.startDate ?? "", todayIso);
|
|
385
|
+
if (bucket.label !== openHeader) {
|
|
386
|
+
if (lines.length > 0)
|
|
387
|
+
lines.push("");
|
|
388
|
+
lines.push(bold(`── ${bucket.label} ──`));
|
|
389
|
+
openHeader = bucket.label;
|
|
390
|
+
}
|
|
391
|
+
lines.push(formatItem(item, w, { ...fmtOpts, hideDateChip: bucket.isDay }));
|
|
392
|
+
}
|
|
393
|
+
if (resting.length > 0) {
|
|
394
|
+
if (lines.length > 0)
|
|
395
|
+
lines.push("");
|
|
396
|
+
lines.push(bold("── Repeating To-Dos ──"));
|
|
397
|
+
for (const item of resting) {
|
|
398
|
+
lines.push(formatItem(item, w, { ...fmtOpts, statusWord: templateStatus(item.repeating, todayIso) }));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return lines;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Logbook rows under GUI-style date headings, month granularity throughout
|
|
405
|
+
* — `── July ──` within the current year, `── March 2025 ──` beyond (finer
|
|
406
|
+
* than the GUI's bare per-year buckets, deliberately). When the row count
|
|
407
|
+
* hits the limit, a trailing note says the range is NOT exhaustive.
|
|
113
408
|
*/
|
|
114
|
-
function
|
|
409
|
+
export function renderLogbook(items, limit, now) {
|
|
410
|
+
if (items.length === 0)
|
|
411
|
+
return ["(empty)"];
|
|
412
|
+
const w = uuidDisplayWidth(items);
|
|
413
|
+
const currentYear = localToday(now).slice(0, 4);
|
|
414
|
+
const lines = [];
|
|
415
|
+
let openHeading = null;
|
|
416
|
+
for (const item of items) {
|
|
417
|
+
const s = item.stopped;
|
|
418
|
+
const heading = s === null
|
|
419
|
+
? "no logged date"
|
|
420
|
+
: `${FULL_MONTHS[s.getMonth()]}${String(s.getFullYear()) === currentYear ? "" : ` ${s.getFullYear()}`}`;
|
|
421
|
+
if (heading !== openHeading) {
|
|
422
|
+
if (lines.length > 0)
|
|
423
|
+
lines.push("");
|
|
424
|
+
lines.push(bold(`── ${heading} ──`));
|
|
425
|
+
openHeading = heading;
|
|
426
|
+
}
|
|
427
|
+
lines.push(formatItem(item, w, now === undefined ? {} : { now }));
|
|
428
|
+
}
|
|
429
|
+
if (items.length >= limit)
|
|
430
|
+
lines.push("", dim(`(${items.length} shown — --limit reached; raise --limit or narrow --since/--until)`));
|
|
431
|
+
return lines;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Sidebar-grouped views (anytime/someday), rendered the way the GUI reads:
|
|
435
|
+
* the area-less block headerless first, then one `── <area> ──` header per
|
|
436
|
+
* area; inside a section, loose to-dos first, then each project GROUP — a
|
|
437
|
+
* blank line, the project's bold+underlined title row, then its members.
|
|
438
|
+
* Container names implied by the grouping are not repeated on member rows
|
|
439
|
+
* (an area header covers its rows; a project title row covers the to-dos
|
|
440
|
+
* beneath it — a clustered child whose project row is absent, e.g. under a
|
|
441
|
+
* tag filter, keeps its `(project)` suffix). `star` prefixes each item line
|
|
442
|
+
* with the Today-membership mark (★, or ⏾ for This-Evening members).
|
|
443
|
+
*/
|
|
444
|
+
export function renderSections(sections, star = false) {
|
|
115
445
|
const all = sections.flatMap((s) => s.items);
|
|
116
446
|
if (all.length === 0)
|
|
117
447
|
return ["(empty)"];
|
|
118
448
|
const w = uuidDisplayWidth(all);
|
|
119
449
|
const lines = [];
|
|
450
|
+
const blank = () => {
|
|
451
|
+
if (lines.length > 0 && lines.at(-1) !== "")
|
|
452
|
+
lines.push("");
|
|
453
|
+
};
|
|
120
454
|
for (const section of sections) {
|
|
121
|
-
if (section.area !== null)
|
|
122
|
-
|
|
455
|
+
if (section.area !== null) {
|
|
456
|
+
blank();
|
|
457
|
+
lines.push(`${bold("──")} ${areaMark()} ${bold(`${section.area.title} ──`)}`);
|
|
458
|
+
}
|
|
459
|
+
// The uuid of the project whose title row is directly above (its member
|
|
460
|
+
// rows drop their redundant `(project)` suffix).
|
|
461
|
+
let openProject = null;
|
|
123
462
|
for (const item of section.items) {
|
|
124
|
-
const
|
|
125
|
-
|
|
463
|
+
const mark = star ? todayMark(item) : null;
|
|
464
|
+
if (item.type === "project") {
|
|
465
|
+
openProject = item.uuid;
|
|
466
|
+
blank();
|
|
467
|
+
lines.push(formatItem(item, w, {
|
|
468
|
+
projectTitle: true,
|
|
469
|
+
suppressArea: section.area?.uuid ?? null,
|
|
470
|
+
mark,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
lines.push(formatItem(item, w, {
|
|
475
|
+
suppressProject: openProject,
|
|
476
|
+
suppressArea: section.area?.uuid ?? null,
|
|
477
|
+
mark,
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
126
480
|
}
|
|
127
481
|
}
|
|
128
482
|
return lines;
|
|
@@ -133,12 +487,21 @@ export function registerReadCommands(program) {
|
|
|
133
487
|
name: "today",
|
|
134
488
|
description: "The Today list, split into Today and This Evening (evening expires daily), with the sidebar badge split (red = deadline due/overdue)",
|
|
135
489
|
fetch: (c, tag, exactTag) => c.read.today(tag === undefined ? undefined : { tag, ...(exactTag === true && { exactTag }) }),
|
|
136
|
-
render: (data) =>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
490
|
+
render: (data) => {
|
|
491
|
+
// GUI parity: every Today row carries the star, every This-Evening
|
|
492
|
+
// row the crescent, right after the box (one shared uuid column).
|
|
493
|
+
const w = uuidDisplayWidth([...data.today, ...data.evening]);
|
|
494
|
+
return [
|
|
495
|
+
`── Today (badge: ${data.badge.dueOrOverdue} due/overdue · ${data.badge.other} other) ──`,
|
|
496
|
+
...(data.today.length === 0
|
|
497
|
+
? ["(empty)"]
|
|
498
|
+
: data.today.map((i) => formatItem(i, w, { mark: todayStar() }))),
|
|
499
|
+
"── This Evening ──",
|
|
500
|
+
...(data.evening.length === 0
|
|
501
|
+
? ["(empty)"]
|
|
502
|
+
: data.evening.map((i) => formatItem(i, w, { mark: eveningMoon() }))),
|
|
503
|
+
];
|
|
504
|
+
},
|
|
142
505
|
},
|
|
143
506
|
{
|
|
144
507
|
name: "inbox",
|
|
@@ -190,60 +553,188 @@ export function registerReadCommands(program) {
|
|
|
190
553
|
program
|
|
191
554
|
.command("upcoming")
|
|
192
555
|
.description("Future-scheduled items in date order, INCLUDING each repeating item's next " +
|
|
193
|
-
"occurrence (↻ marker; deadline derived from the repeat rule).
|
|
556
|
+
"occurrence (↻ marker; deadline derived from the repeat rule). Shows the next " +
|
|
557
|
+
"month by default — widen with --until (relative `2w`/`3m`/`1y` or absolute " +
|
|
558
|
+
"`2026-09`/`2026`) or --all for the full horizon. --horizon <n> also " +
|
|
194
559
|
"PROJECTS the following n-1 occurrences per repeating item from its decoded rule " +
|
|
195
560
|
"(fixed rules only, max 10) — projections are host math the app has not " +
|
|
196
561
|
"materialized yet.")
|
|
562
|
+
.option("--until <period>", "only items scheduled through this bound: `2w`/`3m`/`1y` from today, or `2026-09`, " +
|
|
563
|
+
"`2026-09-15`, `2026` (whole periods)", "1m")
|
|
564
|
+
.option("--all", "no date bound — every future-scheduled item (the app's full Upcoming)")
|
|
565
|
+
.option("--limit <n>", "maximum rows (applied after the date bound)")
|
|
197
566
|
.option("--tag <ref>", "filter by tag (uuid or unique name): direct, inherited, or descendant-tagged")
|
|
198
567
|
.option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
|
|
199
568
|
.option("--horizon <n>", "occurrences per repeating item (default 1 = UI parity)")
|
|
200
569
|
.option("--json", "emit versioned JSON envelope on stdout")
|
|
201
570
|
.option("--db <path>", "explicit database path")
|
|
571
|
+
.action((opts, command) => {
|
|
572
|
+
const untilGiven = command.getOptionValueSource("until") !== "default";
|
|
573
|
+
if (opts.all === true && untilGiven) {
|
|
574
|
+
process.stderr.write("error: --all and --until are mutually exclusive\n");
|
|
575
|
+
process.exitCode = ExitCode.Usage;
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const untilDate = opts.all === true ? undefined : parsePeriodEnd(opts.until);
|
|
579
|
+
if (untilDate !== undefined && Number.isNaN(untilDate.getTime())) {
|
|
580
|
+
process.stderr.write(`error: --until is not a parseable period: ${opts.until}\n`);
|
|
581
|
+
process.exitCode = ExitCode.Usage;
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const until = untilDate === undefined ? undefined : localToday(untilDate);
|
|
585
|
+
const limit = opts.limit === undefined ? undefined : Number(opts.limit);
|
|
586
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
587
|
+
process.stderr.write(`error: --limit must be a positive integer\n`);
|
|
588
|
+
process.exitCode = ExitCode.Usage;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
let clipped = false;
|
|
592
|
+
withClient(opts, "upcoming", (c) => {
|
|
593
|
+
const items = c.read.upcoming({
|
|
594
|
+
...(until !== undefined && { until }),
|
|
595
|
+
...(opts.tag !== undefined && { tag: opts.tag }),
|
|
596
|
+
...(opts.exactTag === true && { exactTag: true }),
|
|
597
|
+
...(opts.horizon !== undefined && { horizon: Number(opts.horizon) }),
|
|
598
|
+
});
|
|
599
|
+
if (limit !== undefined && items.length > limit) {
|
|
600
|
+
clipped = true;
|
|
601
|
+
return items.slice(0, limit);
|
|
602
|
+
}
|
|
603
|
+
return items;
|
|
604
|
+
}, ((items) => {
|
|
605
|
+
const lines = renderUpcoming(items);
|
|
606
|
+
if (clipped && limit !== undefined) {
|
|
607
|
+
lines.push("", dim(`(${limit} shown — --limit reached; raise --limit or widen --until)`));
|
|
608
|
+
}
|
|
609
|
+
if (until !== undefined) {
|
|
610
|
+
lines.push("", dim(`(through ${shortDate(until, localToday())} — --all for the full horizon)`));
|
|
611
|
+
}
|
|
612
|
+
return lines;
|
|
613
|
+
}));
|
|
614
|
+
});
|
|
615
|
+
program
|
|
616
|
+
.command("logbook")
|
|
617
|
+
.description("Completed and canceled items, most recent first, grouped under month headings " +
|
|
618
|
+
"(year appended beyond the current year). Scope with --area (direct items + its " +
|
|
619
|
+
"projects' children, heading-nested included) / --project (all children, " +
|
|
620
|
+
"heading-nested included) / --tag; bound the logged date with --since/--until.")
|
|
621
|
+
.option("--limit <n>", "maximum items to return", "100")
|
|
622
|
+
.option("--area <ref>", "restrict to an area: direct items plus its projects' children")
|
|
623
|
+
.option("--project <ref>", "restrict to one project's children (uuid or unique name)")
|
|
624
|
+
.option("--since <when>", "only entries logged on/after this bound: `2w`/`3m`/`1y` back from today, or " +
|
|
625
|
+
"`2024`, `2024-03`, `2024-03-05` (whole periods)")
|
|
626
|
+
.option("--until <when>", "only entries logged on/before this date (2024 or 2024-03 cover the whole period)")
|
|
627
|
+
.option("--tag <ref>", "filter by tag (uuid or unique name), direct OR inherited")
|
|
628
|
+
.option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
|
|
629
|
+
.option("--json", "emit versioned JSON envelope on stdout")
|
|
630
|
+
.option("--db <path>", "explicit database path")
|
|
202
631
|
.action((opts) => {
|
|
203
|
-
|
|
632
|
+
const since = opts.since !== undefined ? parsePeriodStart(opts.since) : undefined;
|
|
633
|
+
const until = opts.until !== undefined ? parsePeriodEnd(opts.until) : undefined;
|
|
634
|
+
for (const [flag, value] of [
|
|
635
|
+
["--since", since],
|
|
636
|
+
["--until", until],
|
|
637
|
+
]) {
|
|
638
|
+
if (value !== undefined && Number.isNaN(value.getTime())) {
|
|
639
|
+
process.stderr.write(`error: ${flag} is not a parseable date\n`);
|
|
640
|
+
process.exitCode = ExitCode.Usage;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const limit = Number(opts.limit);
|
|
645
|
+
withClient(opts, "logbook", (c) => c.read.logbook({
|
|
646
|
+
limit,
|
|
647
|
+
...(opts.area !== undefined && { area: opts.area }),
|
|
648
|
+
...(opts.project !== undefined && { project: opts.project }),
|
|
649
|
+
...(since !== undefined && { since }),
|
|
650
|
+
...(until !== undefined && { until }),
|
|
204
651
|
...(opts.tag !== undefined && { tag: opts.tag }),
|
|
205
652
|
...(opts.exactTag === true && { exactTag: true }),
|
|
206
|
-
|
|
207
|
-
|
|
653
|
+
}), ((items) => renderLogbook(items, limit)));
|
|
654
|
+
});
|
|
655
|
+
program
|
|
656
|
+
.command("trash")
|
|
657
|
+
.description("Trashed items (trashed=1 flag, any status), most recently modified first")
|
|
658
|
+
.option("--limit <n>", "maximum items to return", "200")
|
|
659
|
+
.option("--json", "emit versioned JSON envelope on stdout")
|
|
660
|
+
.option("--db <path>", "explicit database path")
|
|
661
|
+
.action((opts) => {
|
|
662
|
+
const limit = Number(opts.limit);
|
|
663
|
+
let clipped = false;
|
|
664
|
+
withClient(opts, "trash", (c) => {
|
|
665
|
+
// Fetch one past the limit so truncation is loud, never silent.
|
|
666
|
+
const items = c.read.trash({ limit: limit + 1 });
|
|
667
|
+
if (items.length > limit) {
|
|
668
|
+
clipped = true;
|
|
669
|
+
return items.slice(0, limit);
|
|
670
|
+
}
|
|
671
|
+
return items;
|
|
672
|
+
}, ((items) => {
|
|
673
|
+
const lines = renderList(items);
|
|
674
|
+
if (clipped)
|
|
675
|
+
lines.push("", dim(`(${limit} shown — --limit reached; raise --limit)`));
|
|
676
|
+
return lines;
|
|
677
|
+
}));
|
|
208
678
|
});
|
|
209
|
-
for (const cmd of [
|
|
210
|
-
{
|
|
211
|
-
name: "logbook",
|
|
212
|
-
description: "Completed and canceled items, most recent first",
|
|
213
|
-
fetch: (c, limit, tag, exactTag) => c.read.logbook({
|
|
214
|
-
limit,
|
|
215
|
-
...(tag !== undefined && { tag }),
|
|
216
|
-
...(exactTag === true && { exactTag }),
|
|
217
|
-
}),
|
|
218
|
-
defaultLimit: 100,
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
name: "trash",
|
|
222
|
-
description: "Trashed items (trashed=1 flag, any status), most recently modified first",
|
|
223
|
-
fetch: (c, limit, _tag, _exactTag) => c.read.trash({ limit }),
|
|
224
|
-
defaultLimit: 200,
|
|
225
|
-
},
|
|
226
|
-
]) {
|
|
227
|
-
program
|
|
228
|
-
.command(cmd.name)
|
|
229
|
-
.description(cmd.description)
|
|
230
|
-
.option("--limit <n>", "maximum items to return", String(cmd.defaultLimit))
|
|
231
|
-
.option("--tag <ref>", "filter by tag (uuid or unique name), direct OR inherited — logbook only")
|
|
232
|
-
.option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
|
|
233
|
-
.option("--json", "emit versioned JSON envelope on stdout")
|
|
234
|
-
.option("--db <path>", "explicit database path")
|
|
235
|
-
.action((opts) => {
|
|
236
|
-
withClient(opts, cmd.name, (c) => cmd.fetch(c, Number(opts.limit), opts.tag, opts.exactTag), renderList);
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
679
|
program
|
|
240
680
|
.command("projects")
|
|
241
|
-
.description("Active projects
|
|
681
|
+
.description("Active projects in sidebar order: loose projects first, then grouped under " +
|
|
682
|
+
"their area (optionally scoped to --area <ref>). Someday and future-scheduled " +
|
|
683
|
+
"projects are hidden — --show-later appends them after each group's active " +
|
|
684
|
+
"block (state carried by the (~) mark and ‹date› chip)")
|
|
242
685
|
.option("--area <ref>", "filter by area (uuid or unique name)")
|
|
686
|
+
.option("--show-later", "include someday/future-scheduled projects after each active block")
|
|
243
687
|
.option("--json", "emit versioned JSON envelope on stdout")
|
|
244
688
|
.option("--db <path>", "explicit database path")
|
|
245
689
|
.action((opts) => {
|
|
246
|
-
|
|
690
|
+
let hints;
|
|
691
|
+
withClient(opts, "projects", (c) => {
|
|
692
|
+
const scope = opts.area !== undefined ? { areaUuid: opts.area } : {};
|
|
693
|
+
const visible = c.read.projects({
|
|
694
|
+
...scope,
|
|
695
|
+
...(opts.showLater === true && { later: true }),
|
|
696
|
+
});
|
|
697
|
+
if (opts.area === undefined) {
|
|
698
|
+
// Sidebar scaffold: every VISIBLE area renders (project-less
|
|
699
|
+
// ones say so), in sidebar order; the loose block leads. One
|
|
700
|
+
// extra projects query buys the hidden-later counts.
|
|
701
|
+
const full = opts.showLater === true ? visible : c.read.projects({ later: true });
|
|
702
|
+
const shown = new Set(visible.map((i) => i.uuid));
|
|
703
|
+
const groups = [
|
|
704
|
+
{ area: null, hidden: 0 },
|
|
705
|
+
...c.read
|
|
706
|
+
.areas()
|
|
707
|
+
.filter((a) => a.visible)
|
|
708
|
+
.map((a) => ({ area: { uuid: a.uuid, title: a.title }, hidden: 0 })),
|
|
709
|
+
];
|
|
710
|
+
const at = new Map(groups.map((g, i) => [g.area?.uuid ?? null, i]));
|
|
711
|
+
for (const item of full) {
|
|
712
|
+
if (shown.has(item.uuid))
|
|
713
|
+
continue;
|
|
714
|
+
const g = groups[at.get(item.area?.uuid ?? null) ?? 0];
|
|
715
|
+
if (g !== undefined)
|
|
716
|
+
g.hidden += 1;
|
|
717
|
+
}
|
|
718
|
+
hints = { groups };
|
|
719
|
+
}
|
|
720
|
+
else if (opts.showLater !== true) {
|
|
721
|
+
// --area scoped: only the bottom hint needs a count.
|
|
722
|
+
const full = c.read.projects({ ...scope, later: true });
|
|
723
|
+
hints = { groups: [{ area: null, hidden: full.length - visible.length }] };
|
|
724
|
+
}
|
|
725
|
+
return visible;
|
|
726
|
+
},
|
|
727
|
+
// Scoped to one area the list is flat (the scope names the group);
|
|
728
|
+
// unscoped it mirrors the sidebar with ⬡ area headers.
|
|
729
|
+
((items) => {
|
|
730
|
+
if (opts.area === undefined)
|
|
731
|
+
return renderProjectsSidebar(items, hints);
|
|
732
|
+
const lines = renderList(items);
|
|
733
|
+
const hidden = hints?.groups.reduce((n, g) => n + g.hidden, 0) ?? 0;
|
|
734
|
+
if (hidden > 0)
|
|
735
|
+
lines.push("", dim(`(${hidden} later project${hidden === 1 ? "" : "s"} — visible with \`--show-later\`)`));
|
|
736
|
+
return lines;
|
|
737
|
+
}));
|
|
247
738
|
});
|
|
248
739
|
program
|
|
249
740
|
.command("areas")
|
|
@@ -251,7 +742,10 @@ export function registerReadCommands(program) {
|
|
|
251
742
|
.option("--json", "emit versioned JSON envelope on stdout")
|
|
252
743
|
.option("--db <path>", "explicit database path")
|
|
253
744
|
.action((opts) => {
|
|
254
|
-
withClient(opts, "areas", (c) => c.read.areas(), ((data) =>
|
|
745
|
+
withClient(opts, "areas", (c) => c.read.areas(), ((data) => {
|
|
746
|
+
const w = uuidDisplayWidth(data);
|
|
747
|
+
return data.map((a) => `${dim(a.uuid.length > w ? a.uuid.slice(0, w) : a.uuid.padEnd(w))} ${areaMark()} ${a.title}${a.tags.length ? ` ${dim(`#${a.tags.map((t) => t.title).join(" #")}`)}` : ""}`);
|
|
748
|
+
}));
|
|
255
749
|
});
|
|
256
750
|
program
|
|
257
751
|
.command("tags")
|