things-api 0.7.0 → 0.9.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.
Files changed (154) hide show
  1. package/README.md +6 -3
  2. package/dist/cli/commands/area.d.ts +23 -0
  3. package/dist/cli/commands/area.js +164 -33
  4. package/dist/cli/commands/area.js.map +1 -1
  5. package/dist/cli/commands/project.d.ts +19 -0
  6. package/dist/cli/commands/project.js +142 -29
  7. package/dist/cli/commands/project.js.map +1 -1
  8. package/dist/cli/commands/reads.d.ts +10 -51
  9. package/dist/cli/commands/reads.js +454 -268
  10. package/dist/cli/commands/reads.js.map +1 -1
  11. package/dist/cli/commands/show.d.ts +9 -0
  12. package/dist/cli/commands/show.js +169 -0
  13. package/dist/cli/commands/show.js.map +1 -0
  14. package/dist/cli/commands/snapshot.js +1 -1
  15. package/dist/cli/commands/snapshot.js.map +1 -1
  16. package/dist/cli/commands/todo.d.ts +9 -0
  17. package/dist/cli/commands/todo.js +101 -16
  18. package/dist/cli/commands/todo.js.map +1 -1
  19. package/dist/cli/commands/writes.js +55 -1
  20. package/dist/cli/commands/writes.js.map +1 -1
  21. package/dist/cli/did-you-mean.d.ts +29 -0
  22. package/dist/cli/did-you-mean.js +53 -0
  23. package/dist/cli/did-you-mean.js.map +1 -0
  24. package/dist/cli/glyphs.d.ts +95 -0
  25. package/dist/cli/glyphs.js +282 -0
  26. package/dist/cli/glyphs.js.map +1 -0
  27. package/dist/cli/main.js +11 -1
  28. package/dist/cli/main.js.map +1 -1
  29. package/dist/cli/period.d.ts +32 -0
  30. package/dist/cli/period.js +129 -0
  31. package/dist/cli/period.js.map +1 -0
  32. package/dist/cli/read-driver.d.ts +76 -0
  33. package/dist/cli/read-driver.js +181 -0
  34. package/dist/cli/read-driver.js.map +1 -0
  35. package/dist/cli/render.d.ts +182 -0
  36. package/dist/cli/render.js +585 -0
  37. package/dist/cli/render.js.map +1 -0
  38. package/dist/cli/resolve-invocation.d.ts +78 -0
  39. package/dist/cli/resolve-invocation.js +178 -0
  40. package/dist/cli/resolve-invocation.js.map +1 -0
  41. package/dist/cli/shell-quote.d.ts +9 -0
  42. package/dist/cli/shell-quote.js +12 -0
  43. package/dist/cli/shell-quote.js.map +1 -0
  44. package/dist/cli/style.d.ts +5 -0
  45. package/dist/cli/style.js +5 -0
  46. package/dist/cli/style.js.map +1 -1
  47. package/dist/client.d.ts +45 -39
  48. package/dist/client.js +11 -74
  49. package/dist/client.js.map +1 -1
  50. package/dist/contracts.d.ts +66 -1
  51. package/dist/contracts.js +1 -1
  52. package/dist/contracts.js.map +1 -1
  53. package/dist/db/baselines/db-v26.js +7 -5
  54. package/dist/db/baselines/db-v26.js.map +1 -1
  55. package/dist/db/schema.d.ts +1 -1
  56. package/dist/db/schema.js +7 -1
  57. package/dist/db/schema.js.map +1 -1
  58. package/dist/mcp/server.js +227 -44
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/model/entities.d.ts +21 -0
  61. package/dist/model/entities.js.map +1 -1
  62. package/dist/model/mappers.js +17 -1
  63. package/dist/model/mappers.js.map +1 -1
  64. package/dist/model/occurrences.d.ts +6 -1
  65. package/dist/model/occurrences.js +9 -2
  66. package/dist/model/occurrences.js.map +1 -1
  67. package/dist/model/recurrence.d.ts +30 -2
  68. package/dist/model/recurrence.js +20 -0
  69. package/dist/model/recurrence.js.map +1 -1
  70. package/dist/read/area-view.js +21 -5
  71. package/dist/read/area-view.js.map +1 -1
  72. package/dist/read/caps.d.ts +14 -0
  73. package/dist/read/caps.js +21 -0
  74. package/dist/read/caps.js.map +1 -0
  75. package/dist/read/detail.js +12 -2
  76. package/dist/read/detail.js.map +1 -1
  77. package/dist/read/log-boundary.d.ts +29 -0
  78. package/dist/read/log-boundary.js +38 -0
  79. package/dist/read/log-boundary.js.map +1 -0
  80. package/dist/read/pagination.d.ts +104 -0
  81. package/dist/read/pagination.js +208 -0
  82. package/dist/read/pagination.js.map +1 -0
  83. package/dist/read/predicates.d.ts +31 -0
  84. package/dist/read/predicates.js +48 -0
  85. package/dist/read/predicates.js.map +1 -0
  86. package/dist/read/project-view.js +28 -3
  87. package/dist/read/project-view.js.map +1 -1
  88. package/dist/read/queries.d.ts +21 -3
  89. package/dist/read/queries.js +35 -8
  90. package/dist/read/queries.js.map +1 -1
  91. package/dist/read/search-rank.d.ts +36 -0
  92. package/dist/read/search-rank.js +31 -0
  93. package/dist/read/search-rank.js.map +1 -0
  94. package/dist/read/show-target.d.ts +25 -0
  95. package/dist/read/show-target.js +41 -0
  96. package/dist/read/show-target.js.map +1 -0
  97. package/dist/read/sidebar-order.d.ts +19 -0
  98. package/dist/read/sidebar-order.js +92 -0
  99. package/dist/read/sidebar-order.js.map +1 -0
  100. package/dist/read/snapshot.js +2 -0
  101. package/dist/read/snapshot.js.map +1 -1
  102. package/dist/read/views.d.ts +64 -8
  103. package/dist/read/views.js +290 -155
  104. package/dist/read/views.js.map +1 -1
  105. package/dist/surface-copy.d.ts +23 -0
  106. package/dist/surface-copy.js +23 -0
  107. package/dist/surface-copy.js.map +1 -1
  108. package/dist/write/automation-probe.js +14 -6
  109. package/dist/write/automation-probe.js.map +1 -1
  110. package/dist/write/capabilities.d.ts +3 -0
  111. package/dist/write/capabilities.js +3 -0
  112. package/dist/write/capabilities.js.map +1 -1
  113. package/dist/write/checklist.d.ts +50 -0
  114. package/dist/write/checklist.js +61 -0
  115. package/dist/write/checklist.js.map +1 -0
  116. package/dist/write/clear-reminder.d.ts +3 -0
  117. package/dist/write/clear-reminder.js +205 -0
  118. package/dist/write/clear-reminder.js.map +1 -0
  119. package/dist/write/commands.js +122 -8
  120. package/dist/write/commands.js.map +1 -1
  121. package/dist/write/edit-checklist.d.ts +3 -0
  122. package/dist/write/edit-checklist.js +184 -0
  123. package/dist/write/edit-checklist.js.map +1 -0
  124. package/dist/write/failure-hints.d.ts +7 -0
  125. package/dist/write/failure-hints.js +25 -2
  126. package/dist/write/failure-hints.js.map +1 -1
  127. package/dist/write/guards.d.ts +1 -1
  128. package/dist/write/guards.js +16 -0
  129. package/dist/write/guards.js.map +1 -1
  130. package/dist/write/operations.d.ts +32 -1
  131. package/dist/write/operations.js +3 -0
  132. package/dist/write/operations.js.map +1 -1
  133. package/dist/write/pipeline.d.ts +3 -0
  134. package/dist/write/pipeline.js +23 -1
  135. package/dist/write/pipeline.js.map +1 -1
  136. package/dist/write/reversibility.d.ts +46 -0
  137. package/dist/write/reversibility.js +164 -0
  138. package/dist/write/reversibility.js.map +1 -0
  139. package/dist/write/undo.d.ts +29 -2
  140. package/dist/write/undo.js +357 -9
  141. package/dist/write/undo.js.map +1 -1
  142. package/dist/write/vectors/registry.js +2 -1
  143. package/dist/write/vectors/registry.js.map +1 -1
  144. package/dist/write/vectors/shortcuts.d.ts +9 -0
  145. package/dist/write/vectors/shortcuts.js +90 -0
  146. package/dist/write/vectors/shortcuts.js.map +1 -0
  147. package/dist/write/vectors/types.d.ts +11 -3
  148. package/dist/write/vectors/url-scheme.js +16 -0
  149. package/dist/write/vectors/url-scheme.js.map +1 -1
  150. package/dist/write/verify/delta.d.ts +1 -1
  151. package/dist/write/verify/delta.js +1 -1
  152. package/dist/write/verify/delta.js.map +1 -1
  153. package/package.json +1 -1
  154. package/shortcuts/things-proxy-find-items.shortcut +0 -0
@@ -1,8 +1,13 @@
1
1
  import { addDaysIso, encodePackedDate, localToday } from "../model/dates.js";
2
+ import { logBoundary, markLogged } from "./log-boundary.js";
2
3
  import { mapProject, mapTodo } from "../model/mappers.js";
3
4
  import { projectOccurrences } from "../model/occurrences.js";
4
5
  import { decodeRecurrenceRule } from "../model/recurrence.js";
5
- import { fetchTagsForTasks, fetchTaskRows, makeRefResolver, NOT_TEMPLATE, resolveAreaUuid, resolveProjectUuid, resolveTagUuid, tagScopeBinds, tagScopeSql, tagWithDescendants, } from "./queries.js";
6
+ import { fetchTagsForTasks, fetchTaskRows, makeHeadingProjectResolver, makeRefResolver, NOT_TEMPLATE, resolveAreaUuid, resolveProjectUuid, resolveTagUuid, tagScopeBinds, tagScopeSql, tagWithDescendants, } from "./queries.js";
7
+ import { ANYTIME_SELF, CONTAINER_UNTRASHED, EFF_PROJECT, LIVE, OPEN, PROJECT_ANYTIME_ACTIVE, } from "./predicates.js";
8
+ import { groupBySidebar } from "./sidebar-order.js";
9
+ import { areasView } from "./tags.js";
10
+ import { compareSearchMatches } from "./search-rank.js";
6
11
  function tagFilter(db, filter) {
7
12
  if (filter?.tag === undefined)
8
13
  return { sql: "", binds: [] };
@@ -10,14 +15,22 @@ function tagFilter(db, filter) {
10
15
  const uuids = filter.exactTag === true ? [target] : tagWithDescendants(db, target);
11
16
  return { sql: ` AND ${tagScopeSql(uuids.length)}`, binds: tagScopeBinds(uuids) };
12
17
  }
13
- const LIVE = `t.type IN (0, 1) AND t.trashed = 0 AND ${NOT_TEMPLATE}`;
14
- const OPEN = `${LIVE} AND t.status = 0`;
15
18
  function materialize(db, rows) {
16
19
  const refs = makeRefResolver(db);
20
+ const headingProject = makeHeadingProjectResolver(db);
17
21
  const tags = fetchTagsForTasks(db, rows.map((r) => r.uuid));
18
- return rows.map((row) => row.type === 1
19
- ? mapProject(row, refs, tags.get(row.uuid) ?? [])
20
- : mapTodo(row, refs, tags.get(row.uuid) ?? []));
22
+ const items = rows.map((row) => {
23
+ if (row.type === 1)
24
+ return mapProject(row, refs, tags.get(row.uuid) ?? []);
25
+ const todo = mapTodo(row, refs, tags.get(row.uuid) ?? []);
26
+ if (todo.heading !== null) {
27
+ const p = headingProject(todo.heading.uuid);
28
+ if (p !== null)
29
+ todo.headingProject = p;
30
+ }
31
+ return todo;
32
+ });
33
+ return markLogged(items, logBoundary(db));
21
34
  }
22
35
  export function todayView(db, now, filter) {
23
36
  const todayIso = localToday(now);
@@ -34,7 +47,7 @@ export function todayView(db, now, filter) {
34
47
  // (the date the item ENTERED Today: its startDate, or its deadline for
35
48
  // deadline-driven members), then todayIndex ASC, then uuid (observed
36
49
  // stable tiebreak).
37
- const rows = fetchTaskRows(db, `${OPEN} AND (
50
+ const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND (
38
51
  (t.startDate IS NOT NULL AND t.startDate <= ? AND t.start IN (1, 2))
39
52
  OR (t.deadline IS NOT NULL AND t.deadline <= ? AND t.startDate IS NULL
40
53
  AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline))
@@ -58,24 +71,6 @@ export function inboxView(db, filter) {
58
71
  const rows = fetchTaskRows(db, `${OPEN} AND t.start = 0${tf.sql} ORDER BY t."index" ASC`, tf.binds);
59
72
  return materialize(db, rows);
60
73
  }
61
- /** An item's own anytime membership: unscheduled-active, or dated <= today. */
62
- const ANYTIME_SELF = (col) => `((${col}.start = 1 AND (${col}.startDate IS NULL OR ${col}.startDate <= ?))
63
- OR (${col}.start = 2 AND ${col}.startDate IS NOT NULL AND ${col}.startDate <= ?))`;
64
- /**
65
- * The item's effective project: its own link, or its heading's project for
66
- * headed children (heading rows carry the project link).
67
- */
68
- const EFF_PROJECT = `COALESCE(t.project, (SELECT h.project FROM TMTask h WHERE h.uuid = t.heading))`;
69
- /**
70
- * Container cascade (live-verified against the UI, 2026-07-09): a to-do
71
- * inside a project that is NOT itself anytime-visible (someday or
72
- * future-scheduled, logged, or trashed) is absent from Anytime regardless of
73
- * the to-do's own start state — the project row alone represents it.
74
- * Projects and container-less to-dos pass through. Two binds (packedToday ×2).
75
- */
76
- const PROJECT_ANYTIME_ACTIVE = `(${EFF_PROJECT} IS NULL OR EXISTS (
77
- SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT}
78
- AND p.trashed = 0 AND p.status = 0 AND ${ANYTIME_SELF("p")}))`;
79
74
  export function anytimeView(db, now, filter) {
80
75
  const packedToday = encodePackedDate(localToday(now));
81
76
  const tf = tagFilter(db, filter);
@@ -86,106 +81,22 @@ export function anytimeView(db, now, filter) {
86
81
  AND ${PROJECT_ANYTIME_ACTIVE}${tf.sql} ORDER BY t."index" ASC`, [packedToday, packedToday, packedToday, packedToday, ...tf.binds]);
87
82
  return groupBySidebar(db, materialize(db, rows));
88
83
  }
89
- /**
90
- * Arranges view members into the UI's flat sidebar-mirroring order: the
91
- * area-less block first (direct to-dos, then each top-level project followed
92
- * by its members), then each area by its sidebar index (direct to-dos, then
93
- * its projects). Project and area order here mirrors the sidebar exactly —
94
- * both read the same "index" columns.
95
- */
96
- function groupBySidebar(db, items) {
97
- if (items.length === 0)
98
- return [];
99
- const inList = (n) => Array.from({ length: n }, () => "?").join(", ");
100
- // Headed children: resolve heading -> project.
101
- const headingUuids = [
102
- ...new Set(items.flatMap((i) => (i.type === "to-do" && i.heading !== null ? [i.heading.uuid] : []))),
103
- ];
104
- const headingProject = new Map();
105
- if (headingUuids.length > 0) {
106
- for (const row of db
107
- .prepare(`SELECT uuid, project FROM TMTask WHERE uuid IN (${inList(headingUuids.length)})`)
108
- .all(...headingUuids)) {
109
- headingProject.set(row.uuid, row.project);
110
- }
111
- }
112
- const effProject = (i) => i.type !== "to-do"
113
- ? null
114
- : (i.project?.uuid ??
115
- (i.heading !== null ? (headingProject.get(i.heading.uuid) ?? null) : null));
116
- // Sidebar rank + title for areas; index + area for every referenced project
117
- // (a tag-filtered list can contain a child whose project row didn't match).
118
- const areaRows = db
119
- .prepare(`SELECT uuid, title, "index" FROM TMArea ORDER BY "index" ASC, uuid ASC`)
120
- .all();
121
- const areaRank = new Map(areaRows.map((a, rank) => [a.uuid, rank]));
122
- const areaTitle = new Map(areaRows.map((a) => [a.uuid, a.title ?? ""]));
123
- const projectUuids = [
124
- ...new Set([
125
- ...items.flatMap((i) => (i.type === "project" ? [i.uuid] : [])),
126
- ...items.flatMap((i) => {
127
- const p = effProject(i);
128
- return p === null ? [] : [p];
129
- }),
130
- ]),
131
- ];
132
- const projectMeta = new Map();
133
- if (projectUuids.length > 0) {
134
- for (const row of db
135
- .prepare(`SELECT uuid, "index", area FROM TMTask WHERE uuid IN (${inList(projectUuids.length)})`)
136
- .all(...projectUuids)) {
137
- projectMeta.set(row.uuid, { index: row.index ?? 0, area: row.area });
138
- }
139
- }
140
- // items arrive in SQL "index" order, so array position IS the per-container
141
- // rank (the internal index is no longer exposed on the entity).
142
- const sortKey = (i, pos) => {
143
- const project = i.type === "project" ? i.uuid : effProject(i);
144
- const meta = project === null ? undefined : projectMeta.get(project);
145
- const area = i.area?.uuid ?? meta?.area ?? null;
146
- return {
147
- areaRank: area === null ? -1 : (areaRank.get(area) ?? areaRows.length),
148
- area: area ?? "",
149
- inProject: project === null ? 0 : 1,
150
- projectIndex: meta?.index ?? 0,
151
- project: project ?? "",
152
- headerFirst: i.type === "project" ? 0 : 1,
153
- pos,
154
- uuid: i.uuid,
155
- };
156
- };
157
- const keyed = items.map((item, pos) => ({ item, k: sortKey(item, pos) }));
158
- keyed.sort((a, b) => a.k.areaRank - b.k.areaRank ||
159
- a.k.area.localeCompare(b.k.area) ||
160
- a.k.inProject - b.k.inProject ||
161
- a.k.projectIndex - b.k.projectIndex ||
162
- a.k.project.localeCompare(b.k.project) ||
163
- a.k.headerFirst - b.k.headerFirst ||
164
- a.k.pos - b.k.pos ||
165
- a.k.uuid.localeCompare(b.k.uuid));
166
- const sections = [];
167
- for (const { item, k } of keyed) {
168
- const areaUuid = k.area === "" ? null : k.area;
169
- const last = sections.at(-1);
170
- if (last === undefined || (last.area?.uuid ?? null) !== areaUuid) {
171
- sections.push({
172
- area: areaUuid === null ? null : { uuid: areaUuid, title: areaTitle.get(areaUuid) ?? "" },
173
- items: [],
174
- });
175
- }
176
- sections.at(-1)?.items.push(item);
177
- }
178
- return sections;
179
- }
180
84
  /** The UI's star marker in Anytime: the item is also a Today member. */
181
85
  export function isTodayMember(item, now) {
182
86
  return item.startDate !== null && item.startDate <= localToday(now);
183
87
  }
184
88
  export function upcomingView(db, now, filter) {
185
89
  const packedToday = encodePackedDate(localToday(now));
90
+ const until = filter?.until;
91
+ const since = filter?.since;
92
+ const untilSql = until === undefined ? "" : " AND t.startDate <= ?";
93
+ const untilBinds = until === undefined ? [] : [encodePackedDate(until)];
94
+ const sinceSql = since === undefined ? "" : " AND t.startDate >= ?";
95
+ const sinceBinds = since === undefined ? [] : [encodePackedDate(since)];
186
96
  const tf = tagFilter(db, filter);
187
- const rows = fetchTaskRows(db, `${OPEN} AND t.start = 2 AND t.startDate IS NOT NULL AND t.startDate > ?${tf.sql}
188
- ORDER BY t.startDate ASC, t."index" ASC`, [packedToday, ...tf.binds]);
97
+ const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED}
98
+ AND t.start = 2 AND t.startDate IS NOT NULL AND t.startDate > ?${untilSql}${sinceSql}${tf.sql}
99
+ ORDER BY t.startDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
189
100
  const items = materialize(db, rows);
190
101
  if (filter?.repeats === false)
191
102
  return items;
@@ -194,19 +105,22 @@ export function upcomingView(db, now, filter) {
194
105
  // completion templates carry no next date until the prior instance
195
106
  // resolves; paused templates are excluded. The occurrence deadline follows
196
107
  // the instance-validated model: deadline = startDate − rule.startOffsetDays.
197
- const templateRows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 0 AND t.status = 0
108
+ const templateRows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 0 AND t.status = 0 AND ${CONTAINER_UNTRASHED}
198
109
  AND (t.rt1_recurrenceRule IS NOT NULL OR t.repeater IS NOT NULL)
199
110
  AND t.rt1_instanceCreationPaused = 0
200
- AND t.rt1_nextInstanceStartDate IS NOT NULL AND t.rt1_nextInstanceStartDate > ?${tf.sql}
201
- ORDER BY t.rt1_nextInstanceStartDate ASC, t."index" ASC`, [packedToday, ...tf.binds]);
111
+ AND t.rt1_nextInstanceStartDate IS NOT NULL AND t.rt1_nextInstanceStartDate > ?${until === undefined ? "" : " AND t.rt1_nextInstanceStartDate <= ?"}${since === undefined ? "" : " AND t.rt1_nextInstanceStartDate >= ?"}${tf.sql}
112
+ ORDER BY t.rt1_nextInstanceStartDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
202
113
  const horizon = Math.max(1, Math.min(10, Math.trunc(filter?.horizon ?? 1)));
203
114
  const rawByUuid = new Map(templateRows.map((r) => [r.uuid, r.rt1_recurrenceRule]));
204
115
  const occurrences = materialize(db, templateRows).flatMap((template) => {
205
116
  const startDate = template.repeating.nextOccurrence ?? null;
206
117
  if (startDate === null)
207
118
  return [];
208
- // Only the rule-derived deadline is real: the template row's own deadline
209
- // column carries app-internal sentinels (4001-01-01 observed live).
119
+ // Whether occurrences deadline is the TEMPLATE's property, not the rule's:
120
+ // a deadline-less template (repeating.deadlined false) spawns instances
121
+ // with NO deadline even for fixed ts=0 rules — its rt1_recurrenceRule is
122
+ // byte-identical to a deadlined ts=0 rule (oddities §8a, UI1 2026-07-12).
123
+ const deadlined = template.repeating.deadlined === true;
210
124
  let rule = null;
211
125
  const raw = rawByUuid.get(template.uuid);
212
126
  if (raw !== null && raw !== undefined) {
@@ -218,25 +132,60 @@ export function upcomingView(db, now, filter) {
218
132
  }
219
133
  }
220
134
  if (rule === null || horizon === 1 || rule.type !== "fixed") {
221
- const deadline = rule !== null && rule.startOffsetDays < 0
222
- ? addDaysIso(startDate, -rule.startOffsetDays)
223
- : null;
135
+ // Deadlined templates deadline the occurrence at the event date
136
+ // (start − ts, incl. ts=0); deadline-less ones carry no deadline.
137
+ const deadline = rule !== null && deadlined ? addDaysIso(startDate, -rule.startOffsetDays) : null;
224
138
  return [{ ...template, startDate, deadline }];
225
139
  }
226
140
  // horizon > 1: later occurrences PROJECTED from the decoded rule,
227
- // anchored on the app's own materialized next instance.
228
- return projectOccurrences(rule, startDate, { count: horizon }).map((o) => ({
141
+ // anchored on the app's own materialized next instance. The until bound
142
+ // clips projections the same way it clips stored rows.
143
+ return (projectOccurrences(rule, startDate, {
144
+ count: horizon,
145
+ ...(until !== undefined && { until }),
146
+ }, deadlined)
147
+ .filter((o) => until === undefined || o.startDate <= until)
148
+ .filter((o) => since === undefined || o.startDate >= since)
149
+ // oxlint-disable-next-line no-map-spread -- building fresh occurrence objects, not mutating
150
+ .map((o) => ({
229
151
  ...template,
230
152
  startDate: o.startDate,
231
153
  deadline: o.deadline,
232
- }));
154
+ })));
233
155
  });
234
- return [...items, ...occurrences]
156
+ // Within a day the UI's drag order is todayIndex ASC (live-verified
157
+ // 2026-07-11 against the GUI: plain `index` disagrees, todayIndex matches
158
+ // exactly), so the sortable Upcoming order survives the CLI.
159
+ const todayIndexOf = new Map([...rows, ...templateRows].map((r) => [r.uuid, r.todayIndex ?? 0]));
160
+ const dated = [...items, ...occurrences]
235
161
  .map((item, pos) => ({ item, pos }))
236
162
  .toSorted((a, b) => (a.item.startDate ?? "").localeCompare(b.item.startDate ?? "") ||
163
+ (todayIndexOf.get(a.item.uuid) ?? 0) - (todayIndexOf.get(b.item.uuid) ?? 0) ||
237
164
  a.pos - b.pos ||
238
165
  a.item.uuid.localeCompare(b.item.uuid))
239
166
  .map((x) => x.item);
167
+ // The UI's trailing "Repeating To-Dos" section: templates with NO set
168
+ // next occurrence (after-completion rules between instances, rules past
169
+ // their end date) plus paused ones — startDate stays null, the decoded
170
+ // rule rides along so consumers can derive waiting/paused/ended.
171
+ const restingRows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 0 AND t.status = 0 AND ${CONTAINER_UNTRASHED}
172
+ AND (t.rt1_recurrenceRule IS NOT NULL OR t.repeater IS NOT NULL)
173
+ AND (t.rt1_nextInstanceStartDate IS NULL OR t.rt1_nextInstanceStartDate <= ?
174
+ OR t.rt1_instanceCreationPaused = 1)${tf.sql}
175
+ ORDER BY t.todayIndex ASC, t."index" ASC`, [packedToday, ...tf.binds]);
176
+ const resting = materialize(db, restingRows).map((item) => {
177
+ const raw = restingRows.find((r) => r.uuid === item.uuid)?.rt1_recurrenceRule;
178
+ if (raw !== null && raw !== undefined) {
179
+ try {
180
+ item.repeating.rule = decodeRecurrenceRule(raw);
181
+ }
182
+ catch {
183
+ // undecodable rule (future Things build) — surface without it
184
+ }
185
+ }
186
+ return item;
187
+ });
188
+ return [...dated, ...resting];
240
189
  }
241
190
  export function somedayView(db, now, filter) {
242
191
  const tf = tagFilter(db, filter);
@@ -251,29 +200,130 @@ export function somedayView(db, now, filter) {
251
200
  : "0";
252
201
  const rows = fetchTaskRows(db, `${OPEN} AND t.start = 2 AND t.startDate IS NULL
253
202
  AND (${EFF_PROJECT} IS NULL OR ${childArm})${tf.sql} ORDER BY t."index" ASC`, [...(withActiveChildren ? [packedToday, packedToday] : []), ...tf.binds]);
254
- return groupBySidebar(db, materialize(db, rows));
203
+ const sections = groupBySidebar(db, materialize(db, rows));
204
+ // GUI order within a Someday group (live side-by-side, 2026-07-12):
205
+ // PROJECT rows first (their sidebar order preserved), then direct to-dos in
206
+ // drag order. Someday children of active projects (the activeProjectItems
207
+ // toggle) trail the group, still clustered by their project — surfaces
208
+ // present them as a separate "From active projects" section.
209
+ const isChild = (i) => i.type === "to-do" && (i.project !== null || i.headingProject !== null);
210
+ return sections.map((s) => ({
211
+ area: s.area,
212
+ items: [
213
+ ...s.items.filter((i) => i.type === "project"),
214
+ ...s.items.filter((i) => i.type === "to-do" && !isChild(i)),
215
+ ...s.items.filter(isChild),
216
+ ],
217
+ }));
255
218
  }
256
219
  export function logbookView(db, options) {
257
- const limit = options?.limit ?? 100;
220
+ const cap = options?.limit === null ? null : (options?.limit ?? 100);
258
221
  const tf = tagFilter(db, options);
259
- const rows = fetchTaskRows(db, `${LIVE} AND t.status IN (2, 3)${tf.sql} ORDER BY t.stopDate DESC LIMIT ?`, [...tf.binds, limit]);
222
+ const where = [];
223
+ const binds = [];
224
+ if (options?.project !== undefined) {
225
+ where.push(`${EFF_PROJECT} = ?`);
226
+ binds.push(resolveProjectUuid(db, options.project));
227
+ }
228
+ if (options?.area !== undefined) {
229
+ const areaUuid = resolveAreaUuid(db, options.area);
230
+ where.push(`(t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`);
231
+ binds.push(areaUuid, areaUuid);
232
+ }
233
+ if (options?.since !== undefined) {
234
+ where.push("t.stopDate >= ?");
235
+ binds.push(options.since.getTime() / 1000);
236
+ }
237
+ if (options?.until !== undefined) {
238
+ where.push("t.stopDate <= ?");
239
+ binds.push(options.until.getTime() / 1000);
240
+ }
241
+ const extra = where.length > 0 ? ` AND ${where.join(" AND ")}` : "";
242
+ // Completion ≠ logged: closed items past the log-move boundary still sit
243
+ // checked in their original lists, exactly like the GUI's Logbook.
244
+ const rows = fetchTaskRows(db, `${LIVE} AND t.status IN (2, 3) AND t.stopDate <= ?${extra}${tf.sql} ORDER BY t.stopDate DESC${cap === null ? "" : " LIMIT ?"}`, [logBoundary(db).getTime() / 1000, ...binds, ...tf.binds, ...(cap === null ? [] : [cap])]);
260
245
  return materialize(db, rows);
261
246
  }
262
247
  export function trashView(db, options) {
263
- const limit = options?.limit ?? 200;
248
+ const cap = options?.limit === null ? null : (options?.limit ?? 200);
264
249
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 1 AND ${NOT_TEMPLATE}
265
- ORDER BY t.userModificationDate DESC LIMIT ?`, [limit]);
250
+ ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, cap === null ? [] : [cap]);
266
251
  return materialize(db, rows);
267
252
  }
268
253
  export function projectsView(db, options) {
269
254
  // areaUuid accepts a uuid OR a unique (case-insensitive) title; ambiguous
270
255
  // or unknown references throw like every other ref resolver.
271
256
  const area = options?.areaUuid === undefined ? null : resolveAreaUuid(db, options.areaUuid);
257
+ const packedToday = encodePackedDate(localToday(options?.now));
258
+ // Sidebar default: LATER (someday + future-scheduled) projects are hidden —
259
+ // active means ANYTIME_SELF (a scheduled project whose date has arrived
260
+ // counts active, exactly the Anytime membership test). With later=true they
261
+ // append AFTER the active block of their group, never intermingled.
262
+ const laterSql = options?.later === true ? "" : ` AND ${ANYTIME_SELF("t")}`;
263
+ const laterBinds = options?.later === true ? [] : [packedToday, packedToday];
264
+ // "Active first" within each group needs the same test as an ORDER key
265
+ // (two more binds when later projects are included).
266
+ const activeFirst = options?.later === true ? `(CASE WHEN ${ANYTIME_SELF("t")} THEN 0 ELSE 1 END) ASC, ` : "";
267
+ const activeFirstBinds = options?.later === true ? [packedToday, packedToday] : [];
268
+ // Sidebar order, not raw global index (which interleaves areas): loose
269
+ // (area-less) projects first in their own drag order — the GUI lists them
270
+ // above the areas — then each area by ITS sidebar rank (TMArea."index"),
271
+ // projects within an area by their drag order.
272
272
  const where = area
273
- ? `${OPEN} AND t.type = 1 AND t.area = ? ORDER BY t."index" ASC`
274
- : `${OPEN} AND t.type = 1 ORDER BY t."index" ASC`;
275
- const rows = fetchTaskRows(db, where, area ? [area] : []);
276
- return materialize(db, rows);
273
+ ? `${OPEN} AND t.type = 1 AND t.area = ?${laterSql}
274
+ ORDER BY ${activeFirst}t."index" ASC`
275
+ : `${OPEN} AND t.type = 1${laterSql} ORDER BY (t.area IS NOT NULL) ASC,
276
+ (SELECT a."index" FROM TMArea a WHERE a.uuid = t.area) ASC, ${activeFirst}t."index" ASC`;
277
+ const rows = fetchTaskRows(db, where, [
278
+ ...(area ? [area] : []),
279
+ ...laterBinds,
280
+ ...activeFirstBinds,
281
+ ]);
282
+ const items = materialize(db, rows);
283
+ if (options?.later !== true)
284
+ return items;
285
+ // Each group's later sub-block reads like Upcoming: SCHEDULED projects
286
+ // first — date ascending, todayIndex within a day (the UI's drag order,
287
+ // hidden on entities but present on the raw rows) — then someday in drag
288
+ // order. The SQL already made group runs contiguous with actives leading,
289
+ // so only the later runs are re-sorted (stable within someday).
290
+ const todayIso = localToday(options?.now);
291
+ const ti = new Map(rows.map((r) => [r.uuid, r.todayIndex ?? 0]));
292
+ const isLater = (p) => p.startDate !== null ? p.startDate > todayIso : p.start === "someday";
293
+ const out = [];
294
+ let run = [];
295
+ let runArea;
296
+ const flush = () => {
297
+ out.push(...run
298
+ .map((item, pos) => ({ item, pos }))
299
+ .toSorted((a, b) => {
300
+ const ad = a.item.startDate;
301
+ const bd = b.item.startDate;
302
+ if (ad !== null && bd !== null)
303
+ return (ad.localeCompare(bd) ||
304
+ (ti.get(a.item.uuid) ?? 0) - (ti.get(b.item.uuid) ?? 0) ||
305
+ a.pos - b.pos);
306
+ if (ad !== null)
307
+ return -1;
308
+ if (bd !== null)
309
+ return 1;
310
+ return a.pos - b.pos; // someday keeps drag order
311
+ })
312
+ .map((x) => x.item));
313
+ run = [];
314
+ };
315
+ for (const item of items) {
316
+ const key = item.area?.uuid ?? null;
317
+ if (!isLater(item) || key !== runArea)
318
+ flush();
319
+ runArea = key;
320
+ if (isLater(item))
321
+ run.push(item);
322
+ else
323
+ out.push(item);
324
+ }
325
+ flush();
326
+ return out;
277
327
  }
278
328
  /**
279
329
  * Everything that changed since a moment — created or modified TMTask rows,
@@ -284,47 +334,132 @@ export function projectsView(db, options) {
284
334
  * parent task — those changes are invisible here.
285
335
  */
286
336
  export function changesView(db, options) {
287
- const limit = options.limit ?? 200;
337
+ const cap = options.limit === null ? null : (options.limit ?? 200);
288
338
  const sinceEpoch = options.since.getTime() / 1000;
289
339
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.userModificationDate > ?
290
- ORDER BY t.userModificationDate DESC LIMIT ?`, [sinceEpoch, limit]);
340
+ ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, [sinceEpoch, ...(cap === null ? [] : [cap])]);
341
+ // oxlint-disable-next-line no-map-spread -- building fresh change objects, not mutating
291
342
  return materialize(db, rows).map((item, i) => ({
292
343
  ...item,
293
344
  changeKind: (rows[i]?.creationDate ?? 0) > sinceEpoch ? "created" : "modified",
294
345
  }));
295
346
  }
347
+ /**
348
+ * The did-you-mean fallback search: a case-insensitive SUBSTRING match on
349
+ * TITLES ONLY (never notes, headings, or checklist items), across areas,
350
+ * projects, and to-dos — open + untrashed only. Ordered per the house
351
+ * doctrine: containers (areas, then projects) first, then to-dos; within a
352
+ * group active rows precede someday rows, then most-recently-modified. `type`
353
+ * scopes to a single class (the typed-namespace paths). Results are capped
354
+ * (default 10); `total` reports the pre-cap match count so the caller can
355
+ * append a "… n more" tail.
356
+ */
357
+ const somedayRank = (i) => (i.start === "someday" ? 1 : 0);
358
+ /** Lite-search order within a task group: active before someday, then most-recently-modified. */
359
+ function byStatusThenRecent(a, b) {
360
+ return somedayRank(a) - somedayRank(b) || b.modified.getTime() - a.modified.getTime();
361
+ }
362
+ export function liteTitleSearch(db, query, options) {
363
+ const cap = options?.limit ?? 10;
364
+ const type = options?.type;
365
+ const needle = query.toLowerCase();
366
+ const areas = type === undefined || type === "area"
367
+ ? areasView(db)
368
+ .filter((a) => a.title.toLowerCase().includes(needle))
369
+ .toSorted((a, b) => a.title.localeCompare(b.title))
370
+ : [];
371
+ let tasks = [];
372
+ if (type === undefined || type === "to-do" || type === "project") {
373
+ const typeSql = type === "to-do" ? "t.type = 0" : type === "project" ? "t.type = 1" : "t.type IN (0, 1)";
374
+ const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND ${typeSql} AND t.title LIKE ?`, [`%${query}%`]);
375
+ tasks = materialize(db, rows);
376
+ }
377
+ const projects = tasks.filter((t) => t.type === "project").toSorted(byStatusThenRecent);
378
+ const todos = tasks.filter((t) => t.type === "to-do").toSorted(byStatusThenRecent);
379
+ const ordered = [
380
+ ...areas.map((area) => ({ kind: "area", area })),
381
+ ...projects.map((task) => ({ kind: "task", task })),
382
+ ...todos.map((task) => ({ kind: "task", task })),
383
+ ];
384
+ return { candidates: ordered.slice(0, cap), total: ordered.length };
385
+ }
296
386
  export function searchView(db, query, options) {
297
- const limit = options?.limit ?? 50;
387
+ const cap = options?.limit === null ? null : (options?.limit ?? 50);
298
388
  const needle = `%${query}%`;
299
- const where = [`${NOT_TEMPLATE} AND (t.title LIKE ? OR t.notes LIKE ?)`];
300
- const binds = [needle, needle];
301
- where.push(options?.type === "to-do"
389
+ // The scope predicates (everything except the match needle), reused verbatim
390
+ // for the needle query AND the heading-credited-project query so both honor
391
+ // the same type/status/scope constraints.
392
+ const scope = [NOT_TEMPLATE];
393
+ const scopeBinds = [];
394
+ scope.push(options?.type === "to-do"
302
395
  ? "t.type = 0"
303
396
  : options?.type === "project"
304
397
  ? "t.type = 1"
305
398
  : "t.type IN (0, 1)");
306
399
  // Scope: open + untrashed by default; --logged/--trashed widen; --all is
307
- // the legacy include-everything behavior.
400
+ // the legacy include-everything behavior. Untrashed means the whole
401
+ // container chain (derived trash, A24B), not just the row's own flag.
308
402
  if (options?.all !== true) {
309
403
  const statuses = options?.logged === true ? "(0, 2, 3)" : "(0)";
310
- where.push(`t.status IN ${statuses}`);
404
+ scope.push(`t.status IN ${statuses}`);
311
405
  if (options?.trashed !== true)
312
- where.push("t.trashed = 0");
406
+ scope.push(`t.trashed = 0 AND ${CONTAINER_UNTRASHED}`);
313
407
  }
314
408
  if (options?.project !== undefined) {
315
409
  const uuid = resolveProjectUuid(db, options.project);
316
410
  // Children incl. headed ones (heading rows carry the project link).
317
- where.push("(t.project = ? OR t.heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?))");
318
- binds.push(uuid, uuid);
411
+ scope.push("(t.project = ? OR t.heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?))");
412
+ scopeBinds.push(uuid, uuid);
319
413
  }
320
414
  if (options?.area !== undefined) {
321
415
  const uuid = resolveAreaUuid(db, options.area);
322
- where.push("t.area = ?");
323
- binds.push(uuid);
416
+ scope.push("t.area = ?");
417
+ scopeBinds.push(uuid);
324
418
  }
325
419
  const tf = tagFilter(db, options);
326
- const rows = fetchTaskRows(db, `${where.join(" AND ")}${tf.sql}
327
- ORDER BY t.userModificationDate DESC LIMIT ?`, [...binds, ...tf.binds, limit]);
328
- return materialize(db, rows);
420
+ // Needle matches: title OR notes. No SQL LIMIT — ranking runs before the cap.
421
+ const rows = fetchTaskRows(db, `${scope.join(" AND ")} AND (t.title LIKE ? OR t.notes LIKE ?)${tf.sql}`, [...scopeBinds, needle, needle, ...tf.binds]);
422
+ const needleLower = query.toLowerCase();
423
+ const matches = new Map();
424
+ for (const item of materialize(db, rows)) {
425
+ // Field credit: title beats notes when both carry the substring.
426
+ const field = item.title.toLowerCase().includes(needleLower) ? "title" : "notes";
427
+ matches.set(item.uuid, { item, field });
428
+ }
429
+ // Heading titles are treated as if they lived in the parent PROJECT's notes:
430
+ // a heading-title match surfaces the parent project (never a bare heading
431
+ // row), credited at the heading-via-project field rank. A to-do-only search
432
+ // has no project rows, so headings do not apply there.
433
+ if (options?.type !== "to-do") {
434
+ const headingRows = db
435
+ .prepare(`SELECT project AS projectUuid, title FROM TMTask
436
+ WHERE type = 2 AND project IS NOT NULL AND trashed = 0 AND title LIKE ?`)
437
+ .all(needle);
438
+ const headingTitleFor = new Map();
439
+ for (const h of headingRows) {
440
+ if (!headingTitleFor.has(h.projectUuid))
441
+ headingTitleFor.set(h.projectUuid, h.title ?? "");
442
+ }
443
+ // Surface only the projects NOT already matched by their own title/notes
444
+ // (a higher-ranked field already represents them), and only those passing
445
+ // the view's scope.
446
+ const wanted = [...headingTitleFor.keys()].filter((uuid) => !matches.has(uuid));
447
+ if (wanted.length > 0) {
448
+ const placeholders = wanted.map(() => "?").join(", ");
449
+ const projectRows = fetchTaskRows(db, `${scope.join(" AND ")} AND t.type = 1 AND t.uuid IN (${placeholders})${tf.sql}`, [...scopeBinds, ...wanted, ...tf.binds]);
450
+ for (const item of materialize(db, projectRows)) {
451
+ const matchedVia = {
452
+ kind: "heading",
453
+ title: headingTitleFor.get(item.uuid) ?? "",
454
+ };
455
+ // Annotate the freshly materialized entity in place (owned here).
456
+ item.matchedVia = matchedVia;
457
+ matches.set(item.uuid, { item, field: "heading", matchedVia });
458
+ }
459
+ }
460
+ }
461
+ const ranked = [...matches.values()].toSorted(compareSearchMatches);
462
+ const sliced = cap === null ? ranked : ranked.slice(0, cap);
463
+ return sliced.map((m) => m.item);
329
464
  }
330
465
  //# sourceMappingURL=views.js.map