things-api 0.10.0 → 0.12.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 +50 -13
- package/dist/audit/schema.d.ts +7 -0
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/clock.d.ts +8 -0
- package/dist/cli/clock.js +24 -0
- package/dist/cli/clock.js.map +1 -0
- package/dist/cli/commands/area.d.ts +18 -11
- package/dist/cli/commands/area.js +61 -74
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -2
- package/dist/cli/commands/doctor.js +11 -1
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +60 -0
- package/dist/cli/commands/install-skill.js +256 -0
- package/dist/cli/commands/install-skill.js.map +1 -0
- package/dist/cli/commands/mcp.js +9 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +1 -0
- package/dist/cli/commands/project.js +27 -34
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +15 -2
- package/dist/cli/commands/reads.js +144 -44
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.js +7 -2
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +38 -14
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.js +15 -9
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +797 -174
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +14 -3
- package/dist/cli/did-you-mean.js +26 -3
- package/dist/cli/did-you-mean.js.map +1 -1
- package/dist/cli/glyphs.d.ts +32 -2
- package/dist/cli/glyphs.js +83 -12
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.js +121 -16
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +33 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +46 -0
- package/dist/cli/move-hint.js +196 -0
- package/dist/cli/move-hint.js.map +1 -0
- package/dist/cli/period.d.ts +2 -8
- package/dist/cli/period.js +44 -30
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +43 -5
- package/dist/cli/read-driver.js +91 -15
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +19 -8
- package/dist/cli/render.js +70 -27
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +2 -2
- package/dist/cli/resolve-invocation.js +3 -3
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +27 -0
- package/dist/cli/skill-check.js +99 -0
- package/dist/cli/skill-check.js.map +1 -0
- package/dist/cli/skill.d.ts +70 -0
- package/dist/cli/skill.js +152 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/cli/tag-filters.d.ts +6 -2
- package/dist/cli/tag-filters.js +8 -6
- package/dist/cli/tag-filters.js.map +1 -1
- package/dist/cli/verb-hint.js +28 -3
- package/dist/cli/verb-hint.js.map +1 -1
- package/dist/cli/version.d.ts +4 -0
- package/dist/cli/version.js +18 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/client.d.ts +171 -27
- package/dist/client.js +279 -40
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +66 -2
- package/dist/config.js +120 -12
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +186 -28
- package/dist/contracts.js +22 -1
- package/dist/contracts.js.map +1 -1
- package/dist/diagnose.d.ts +34 -2
- package/dist/diagnose.js +36 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +25 -9
- package/dist/index.js +22 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +1157 -684
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/clock.d.ts +61 -0
- package/dist/model/clock.js +71 -0
- package/dist/model/clock.js.map +1 -0
- package/dist/model/dates.d.ts +26 -3
- package/dist/model/dates.js +84 -3
- package/dist/model/dates.js.map +1 -1
- package/dist/model/entities.d.ts +60 -3
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +4 -2
- package/dist/model/mappers.js +66 -9
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/recurrence.d.ts +8 -1
- package/dist/model/recurrence.js.map +1 -1
- package/dist/read/area-filter.d.ts +76 -0
- package/dist/read/area-filter.js +59 -0
- package/dist/read/area-filter.js.map +1 -0
- package/dist/read/area-view.d.ts +12 -12
- package/dist/read/area-view.js +58 -34
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.d.ts +1 -1
- package/dist/read/detail.js +26 -12
- package/dist/read/detail.js.map +1 -1
- package/dist/read/log-boundary.d.ts +1 -1
- package/dist/read/log-boundary.js +13 -3
- package/dist/read/log-boundary.js.map +1 -1
- package/dist/read/predicates.d.ts +18 -0
- package/dist/read/predicates.js +19 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +32 -16
- package/dist/read/project-view.js +80 -45
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/pseudo-area.d.ts +29 -0
- package/dist/read/pseudo-area.js +27 -0
- package/dist/read/pseudo-area.js.map +1 -0
- package/dist/read/queries.d.ts +108 -19
- package/dist/read/queries.js +212 -35
- package/dist/read/queries.js.map +1 -1
- package/dist/read/scope.d.ts +126 -0
- package/dist/read/scope.js +162 -0
- package/dist/read/scope.js.map +1 -0
- package/dist/read/search-rank.d.ts +2 -5
- package/dist/read/shape.d.ts +162 -0
- package/dist/read/shape.js +686 -0
- package/dist/read/shape.js.map +1 -0
- package/dist/read/show-target.d.ts +9 -1
- package/dist/read/show-target.js +45 -4
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/snapshot.d.ts +1 -1
- package/dist/read/snapshot.js +9 -2
- package/dist/read/snapshot.js.map +1 -1
- package/dist/read/stage.d.ts +199 -0
- package/dist/read/stage.js +125 -0
- package/dist/read/stage.js.map +1 -0
- package/dist/read/truncation.d.ts +8 -6
- package/dist/read/truncation.js +35 -10
- package/dist/read/truncation.js.map +1 -1
- package/dist/read/views.d.ts +61 -20
- package/dist/read/views.js +240 -82
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +9 -0
- package/dist/surface-copy.js +9 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/write/accessibility-probe.d.ts +1 -1
- package/dist/write/accessibility-probe.js +8 -0
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.d.ts +1 -1
- package/dist/write/automation-probe.js +8 -0
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.d.ts +49 -10
- package/dist/write/batch.js +423 -70
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +1 -1
- package/dist/write/commands.js +316 -66
- package/dist/write/commands.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +94 -10
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.d.ts +10 -1
- package/dist/write/heading.js +35 -5
- package/dist/write/heading.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +2 -2
- package/dist/write/make-repeating-project.js +9 -9
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/move.d.ts +130 -0
- package/dist/write/move.js +1533 -0
- package/dist/write/move.js.map +1 -0
- package/dist/write/operations.d.ts +105 -19
- package/dist/write/operations.js +56 -7
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +31 -0
- package/dist/write/opid.js +30 -0
- package/dist/write/opid.js.map +1 -0
- package/dist/write/pipeline.d.ts +122 -3
- package/dist/write/pipeline.js +309 -52
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/planner.js +8 -1
- package/dist/write/planner.js.map +1 -1
- package/dist/write/pre-state.d.ts +197 -6
- package/dist/write/pre-state.js +427 -20
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/recurrence-rule-blob.d.ts +65 -0
- package/dist/write/recurrence-rule-blob.js +150 -0
- package/dist/write/recurrence-rule-blob.js.map +1 -0
- package/dist/write/reorder.d.ts +23 -3
- package/dist/write/reorder.js +1871 -85
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/reversibility.js +19 -7
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/scope-guard.d.ts +31 -0
- package/dist/write/scope-guard.js +162 -0
- package/dist/write/scope-guard.js.map +1 -0
- package/dist/write/undo.js +120 -13
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/applescript.js +16 -7
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +5 -7
- package/dist/write/vectors/registry.js +49 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +1 -1
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +44 -0
- package/dist/write/vectors/simulator.js +1192 -0
- package/dist/write/vectors/simulator.js.map +1 -0
- package/dist/write/vectors/types.d.ts +46 -0
- package/dist/write/vectors/ui-certification.d.ts +10 -4
- package/dist/write/vectors/ui-certification.js +43 -10
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-drag.d.ts +21 -1
- package/dist/write/vectors/ui-drag.js +138 -27
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +34 -1
- package/dist/write/vectors/ui-recipes.js +217 -6
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +40 -1
- package/dist/write/vectors/ui.js +196 -39
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +127 -2
- package/dist/write/verify/delta.js +255 -16
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +7 -1
- package/dist/write/verify/poller.js +20 -1
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +24 -3
- package/schema/envelope.schema.json +383 -0
- package/skills/things-cli/SKILL.md +70 -0
- package/skills/things-cli/references/banner.md +35 -0
- package/skills/things-cli/references/contracts.md +62 -0
- package/skills/things-cli/references/errors.md +49 -0
- package/skills/things-cli/references/gui.md +11 -0
- package/skills/things-cli/references/model.md +60 -0
- package/skills/things-cli/references/ordering.md +71 -0
package/dist/read/views.js
CHANGED
|
@@ -4,7 +4,9 @@ import { mapProject, mapTodo } from "../model/mappers.js";
|
|
|
4
4
|
import { projectOccurrences } from "../model/occurrences.js";
|
|
5
5
|
import { decodeRecurrenceRule } from "../model/recurrence.js";
|
|
6
6
|
import { directTagScopeSql, directUntaggedScopeSql, fetchTagsForTasks, fetchTaskRows, makeHeadingProjectResolver, makeRefResolver, NOT_TEMPLATE, resolveAreaUuid, resolveProjectUuid, resolveTagUuid, tagScopeBinds, tagScopeSql, tagWithDescendants, untaggedScopeSql, } from "./queries.js";
|
|
7
|
-
import { ANYTIME_SELF, CONTAINER_UNTRASHED, EFF_PROJECT, LIVE, OPEN, OPEN_OR_UNSWEPT, OVERDUE, PROJECT_ANYTIME_ACTIVE, } from "./predicates.js";
|
|
7
|
+
import { ANYTIME_SELF, CONTAINER_UNTRASHED, DEADLINE_PULLED, EFF_PROJECT, LIVE, OPEN, OPEN_OR_UNSWEPT, OVERDUE, PROJECT_ANYTIME_ACTIVE, } from "./predicates.js";
|
|
8
|
+
import { isLooseRef } from "./pseudo-area.js";
|
|
9
|
+
import { CANDIDATE_CAP } from "./shape.js";
|
|
8
10
|
import { groupBySidebar } from "./sidebar-order.js";
|
|
9
11
|
import { areasView } from "./tags.js";
|
|
10
12
|
import { compareSearchMatches } from "./search-rank.js";
|
|
@@ -80,14 +82,17 @@ function overdueFilter(filter, packedToday) {
|
|
|
80
82
|
// view's injected `now` via logBoundary(db, now) so the `logged` flag it stamps
|
|
81
83
|
// honors the same clock as the SQL membership filter (a pinned test clock, a
|
|
82
84
|
// lab run) — nothing here silently reaches for real time.
|
|
83
|
-
|
|
85
|
+
// `packedToday` (the view's injected clock, encodePackedDate(localToday(now)))
|
|
86
|
+
// is threaded so the mapper can gate `todaySection` to Today members only —
|
|
87
|
+
// the same clock the SQL membership filter uses.
|
|
88
|
+
function materialize(db, rows, boundary, packedToday) {
|
|
84
89
|
const refs = makeRefResolver(db);
|
|
85
90
|
const headingProject = makeHeadingProjectResolver(db);
|
|
86
91
|
const tags = fetchTagsForTasks(db, rows.map((r) => r.uuid));
|
|
87
92
|
const items = rows.map((row) => {
|
|
88
93
|
if (row.type === 1)
|
|
89
|
-
return mapProject(row, refs, tags.get(row.uuid) ?? []);
|
|
90
|
-
const todo = mapTodo(row, refs, tags.get(row.uuid) ?? []);
|
|
94
|
+
return mapProject(row, refs, tags.get(row.uuid) ?? [], packedToday);
|
|
95
|
+
const todo = mapTodo(row, refs, tags.get(row.uuid) ?? [], packedToday);
|
|
91
96
|
if (todo.heading !== null) {
|
|
92
97
|
const p = headingProject(todo.heading.uuid);
|
|
93
98
|
if (p !== null)
|
|
@@ -102,8 +107,8 @@ function materialize(db, rows, boundary) {
|
|
|
102
107
|
}
|
|
103
108
|
/** Badge = OPEN members only (the GUI badge counts remaining work). */
|
|
104
109
|
const isOpen = (i) => i.status === "open";
|
|
105
|
-
export function todayView(db, now, filter) {
|
|
106
|
-
const todayIso = localToday(now);
|
|
110
|
+
export function todayView(db, now, filter, zone) {
|
|
111
|
+
const todayIso = localToday(now, zone);
|
|
107
112
|
const packedToday = encodePackedDate(todayIso);
|
|
108
113
|
const tf = tagFilter(db, filter);
|
|
109
114
|
const of = overdueFilter(filter, packedToday);
|
|
@@ -122,16 +127,15 @@ export function todayView(db, now, filter) {
|
|
|
122
127
|
// has not yet passed keeps its comparator slot in place (GUI-parity ruling
|
|
123
128
|
// 2026-07-14). The boundary is threaded into both the SQL filter and
|
|
124
129
|
// materialize so the row's presence and its `logged` flag agree.
|
|
125
|
-
const boundary = logBoundary(db, now);
|
|
130
|
+
const boundary = logBoundary(db, now, zone);
|
|
126
131
|
const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND ${CONTAINER_UNTRASHED} AND (
|
|
127
132
|
(t.startDate IS NOT NULL AND t.startDate <= ? AND t.start IN (1, 2))
|
|
128
|
-
OR
|
|
129
|
-
AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline))
|
|
133
|
+
OR ${DEADLINE_PULLED}
|
|
130
134
|
)${tf.sql}${of.sql}
|
|
131
135
|
ORDER BY t.startBucket ASC,
|
|
132
136
|
COALESCE(t.todayIndexReferenceDate, t.startDate, t.deadline) DESC,
|
|
133
137
|
t.todayIndex ASC, t.uuid ASC`, [boundary.getTime() / 1000, packedToday, packedToday, ...tf.binds, ...of.binds]);
|
|
134
|
-
const items = materialize(db, rows, boundary);
|
|
138
|
+
const items = materialize(db, rows, boundary, packedToday);
|
|
135
139
|
// Evening membership expires daily: raw startBucket=1 counts only while
|
|
136
140
|
// startDate is exactly today; stale evening items belong to Today proper.
|
|
137
141
|
const isEvening = (i) => i.todaySection === "evening" && i.startDate === todayIso;
|
|
@@ -159,11 +163,13 @@ export function todayView(db, now, filter) {
|
|
|
159
163
|
badge: { dueOrOverdue, other: openItems.length - dueOrOverdue },
|
|
160
164
|
};
|
|
161
165
|
}
|
|
162
|
-
export function inboxView(db, now, filter) {
|
|
166
|
+
export function inboxView(db, now, filter, zone) {
|
|
163
167
|
const tf = tagFilter(db, filter);
|
|
164
|
-
// `now` is threaded
|
|
165
|
-
// the since/until window key on epoch
|
|
166
|
-
|
|
168
|
+
// `now` is threaded for the `--overdue` boundary AND for the deadline-pull
|
|
169
|
+
// exclusion below (the inbox order and the since/until window key on epoch
|
|
170
|
+
// creationDate, not on today).
|
|
171
|
+
const packedToday = encodePackedDate(localToday(now, zone));
|
|
172
|
+
const of = overdueFilter(filter, packedToday);
|
|
167
173
|
const where = [OPEN, "t.start = 0"];
|
|
168
174
|
const binds = [];
|
|
169
175
|
// creationDate is an epoch REAL (Unix seconds) — mirror the changes/logbook
|
|
@@ -176,11 +182,19 @@ export function inboxView(db, now, filter) {
|
|
|
176
182
|
where.push("t.creationDate <= ?");
|
|
177
183
|
binds.push(filter.until.getTime() / 1000);
|
|
178
184
|
}
|
|
185
|
+
// R13 (BANNER1 Q1b, law L-A): a due/overdue un-dismissed deadline pulls an
|
|
186
|
+
// undated Inbox row into Today AND removes it from the GUI Inbox list — even
|
|
187
|
+
// while its raw `start` still reads 0. EXCLUDE the pulled rows so the flat
|
|
188
|
+
// inbox view stays GUI-faithful; reuse the Today deadline arm verbatim so the
|
|
189
|
+
// exclusion can never drift from the pull todayView claims. A SUPPRESSED
|
|
190
|
+
// deadline (guard inside DEADLINE_PULLED) is not a pull, so it stays in Inbox.
|
|
191
|
+
where.push(`NOT ${DEADLINE_PULLED}`);
|
|
192
|
+
binds.push(packedToday);
|
|
179
193
|
const rows = fetchTaskRows(db, `${where.join(" AND ")}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [...binds, ...tf.binds, ...of.binds]);
|
|
180
|
-
return materialize(db, rows, logBoundary(db, now));
|
|
194
|
+
return materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
|
|
181
195
|
}
|
|
182
|
-
export function anytimeView(db, now, filter) {
|
|
183
|
-
const packedToday = encodePackedDate(localToday(now));
|
|
196
|
+
export function anytimeView(db, now, filter, zone) {
|
|
197
|
+
const packedToday = encodePackedDate(localToday(now, zone));
|
|
184
198
|
const tf = tagFilter(db, filter);
|
|
185
199
|
const of = overdueFilter(filter, packedToday);
|
|
186
200
|
// Mirrors UI membership: every active item, including Today members
|
|
@@ -191,18 +205,27 @@ export function anytimeView(db, now, filter) {
|
|
|
191
205
|
// the parent project be open, so a closed-but-unswept project's children stay
|
|
192
206
|
// cascade-excluded — the checked project row represents them. The boundary is
|
|
193
207
|
// threaded into the SQL filter and materialize so presence and `logged` agree.
|
|
194
|
-
|
|
195
|
-
|
|
208
|
+
//
|
|
209
|
+
// R13 (BANNER1b, law L-A): Anytime is a SUPERSET of Today's to-dos — a
|
|
210
|
+
// due-deadline pull re-files an undated Inbox/Someday row into Anytime (out of
|
|
211
|
+
// its origin bucket) even while its raw `start` still reads 0/2. So the
|
|
212
|
+
// membership arm is ANYTIME_SELF *OR* the Today deadline-pull arm (reused
|
|
213
|
+
// verbatim from todayView, never re-derived). The container cascade still
|
|
214
|
+
// applies (a pulled child of a non-active project is represented by that
|
|
215
|
+
// project row).
|
|
216
|
+
const boundary = logBoundary(db, now, zone);
|
|
217
|
+
const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND (${ANYTIME_SELF("t")} OR ${DEADLINE_PULLED})
|
|
196
218
|
AND ${PROJECT_ANYTIME_ACTIVE}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [
|
|
197
219
|
boundary.getTime() / 1000,
|
|
198
|
-
packedToday,
|
|
199
|
-
packedToday,
|
|
200
|
-
packedToday,
|
|
201
|
-
packedToday,
|
|
220
|
+
packedToday, // ANYTIME_SELF arm 1
|
|
221
|
+
packedToday, // ANYTIME_SELF arm 2
|
|
222
|
+
packedToday, // DEADLINE_PULLED
|
|
223
|
+
packedToday, // PROJECT_ANYTIME_ACTIVE arm 1
|
|
224
|
+
packedToday, // PROJECT_ANYTIME_ACTIVE arm 2
|
|
202
225
|
...tf.binds,
|
|
203
226
|
...of.binds,
|
|
204
227
|
]);
|
|
205
|
-
return groupBySidebar(db, materialize(db, rows, boundary));
|
|
228
|
+
return groupBySidebar(db, materialize(db, rows, boundary, packedToday));
|
|
206
229
|
}
|
|
207
230
|
/**
|
|
208
231
|
* The UI's star marker in Anytime: the item is also a Today member. Mirrors
|
|
@@ -217,8 +240,8 @@ export function anytimeView(db, now, filter) {
|
|
|
217
240
|
* deadline (deadlineSuppressionDate >= deadline); that column is not surfaced
|
|
218
241
|
* on the entity, so a suppressed unscheduled overdue row is over-starred here.
|
|
219
242
|
*/
|
|
220
|
-
export function isTodayMember(item, now) {
|
|
221
|
-
const today = localToday(now);
|
|
243
|
+
export function isTodayMember(item, now, zone) {
|
|
244
|
+
const today = localToday(now, zone);
|
|
222
245
|
if (item.startDate !== null)
|
|
223
246
|
return item.startDate <= today;
|
|
224
247
|
return item.deadline !== null && item.deadline <= today;
|
|
@@ -228,9 +251,9 @@ export function isTodayMember(item, now) {
|
|
|
228
251
|
* scheduled row groups under its when-date, a forecast row under its deadline.
|
|
229
252
|
*/
|
|
230
253
|
const groupKey = (i) => i.startDate ?? i.deadline ?? "";
|
|
231
|
-
export function upcomingView(db, now, filter) {
|
|
232
|
-
const packedToday = encodePackedDate(localToday(now));
|
|
233
|
-
const boundary = logBoundary(db, now);
|
|
254
|
+
export function upcomingView(db, now, filter, zone) {
|
|
255
|
+
const packedToday = encodePackedDate(localToday(now, zone));
|
|
256
|
+
const boundary = logBoundary(db, now, zone);
|
|
234
257
|
const until = filter?.until;
|
|
235
258
|
const since = filter?.since;
|
|
236
259
|
const untilBinds = until === undefined ? [] : [encodePackedDate(until)];
|
|
@@ -258,8 +281,8 @@ export function upcomingView(db, now, filter) {
|
|
|
258
281
|
AND t.deadline IS NOT NULL AND t.deadline > ?
|
|
259
282
|
AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline)${fcUntilSql}${fcSinceSql}${tf.sql}
|
|
260
283
|
ORDER BY t.deadline ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
|
|
261
|
-
const scheduled = materialize(db, rows, boundary);
|
|
262
|
-
const forecast = materialize(db, forecastRows, boundary);
|
|
284
|
+
const scheduled = materialize(db, rows, boundary, packedToday);
|
|
285
|
+
const forecast = materialize(db, forecastRows, boundary, packedToday);
|
|
263
286
|
// The merged date-ordered stream keys on COALESCE(startDate, deadline) — a
|
|
264
287
|
// scheduled row groups under its when-date, a forecast row under its deadline
|
|
265
288
|
// — then the UI's within-day drag order (todayIndex ASC; live-verified
|
|
@@ -289,7 +312,7 @@ export function upcomingView(db, now, filter) {
|
|
|
289
312
|
ORDER BY t.rt1_nextInstanceStartDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
|
|
290
313
|
const horizon = Math.max(1, Math.min(10, Math.trunc(filter?.horizon ?? 1)));
|
|
291
314
|
const rawByUuid = new Map(templateRows.map((r) => [r.uuid, r.rt1_recurrenceRule]));
|
|
292
|
-
const occurrences = materialize(db, templateRows, boundary).flatMap((template) => {
|
|
315
|
+
const occurrences = materialize(db, templateRows, boundary, packedToday).flatMap((template) => {
|
|
293
316
|
const startDate = template.repeating.nextOccurrence ?? null;
|
|
294
317
|
if (startDate === null)
|
|
295
318
|
return [];
|
|
@@ -342,7 +365,7 @@ export function upcomingView(db, now, filter) {
|
|
|
342
365
|
AND (t.rt1_nextInstanceStartDate IS NULL OR t.rt1_nextInstanceStartDate <= ?
|
|
343
366
|
OR t.rt1_instanceCreationPaused = 1)${tf.sql}
|
|
344
367
|
ORDER BY t.todayIndex ASC, t."index" ASC`, [packedToday, ...tf.binds]);
|
|
345
|
-
const resting = materialize(db, restingRows, boundary).map((item) => {
|
|
368
|
+
const resting = materialize(db, restingRows, boundary, packedToday).map((item) => {
|
|
346
369
|
const raw = restingRows.find((r) => r.uuid === item.uuid)?.rt1_recurrenceRule;
|
|
347
370
|
if (raw !== null && raw !== undefined) {
|
|
348
371
|
try {
|
|
@@ -358,9 +381,9 @@ export function upcomingView(db, now, filter) {
|
|
|
358
381
|
}
|
|
359
382
|
/** A someday to-do nested under a project (directly or via a heading). */
|
|
360
383
|
const isChild = (i) => i.type === "to-do" && (i.project !== null || i.headingProject !== null);
|
|
361
|
-
export function somedayView(db, now, filter) {
|
|
384
|
+
export function somedayView(db, now, filter, zone) {
|
|
362
385
|
const tf = tagFilter(db, filter);
|
|
363
|
-
const packedToday = encodePackedDate(localToday(now));
|
|
386
|
+
const packedToday = encodePackedDate(localToday(now, zone));
|
|
364
387
|
const of = overdueFilter(filter, packedToday);
|
|
365
388
|
// Default membership excludes ALL project children (the UI shows only
|
|
366
389
|
// container-less someday to-dos, area members, and someday project rows);
|
|
@@ -370,9 +393,20 @@ export function somedayView(db, now, filter) {
|
|
|
370
393
|
? `EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT}
|
|
371
394
|
AND p.trashed = 0 AND p.status = 0 AND ${ANYTIME_SELF("p")})`
|
|
372
395
|
: "0";
|
|
396
|
+
// R13 (BANNER1 Q1b, law L-A): a due/overdue un-dismissed deadline pulls an
|
|
397
|
+
// undated Someday row into Today AND removes it from the GUI Someday list —
|
|
398
|
+
// even while its raw `start` still reads 2. EXCLUDE the pulled rows so the flat
|
|
399
|
+
// someday view stays GUI-faithful; reuse the Today deadline arm verbatim so the
|
|
400
|
+
// exclusion can never drift from the pull todayView claims. A SUPPRESSED
|
|
401
|
+
// deadline (guard inside DEADLINE_PULLED) is not a pull, so it stays in Someday.
|
|
373
402
|
const rows = fetchTaskRows(db, `${OPEN} AND t.start = 2 AND t.startDate IS NULL
|
|
374
|
-
AND (${EFF_PROJECT} IS NULL OR ${childArm})${tf.sql}${of.sql} ORDER BY t."index" ASC`, [
|
|
375
|
-
|
|
403
|
+
AND (${EFF_PROJECT} IS NULL OR ${childArm}) AND NOT ${DEADLINE_PULLED}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [
|
|
404
|
+
...(withActiveChildren ? [packedToday, packedToday] : []),
|
|
405
|
+
packedToday, // DEADLINE_PULLED exclusion
|
|
406
|
+
...tf.binds,
|
|
407
|
+
...of.binds,
|
|
408
|
+
]);
|
|
409
|
+
const sections = groupBySidebar(db, materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone))));
|
|
376
410
|
// GUI order within a Someday group (live side-by-side, 2026-07-12):
|
|
377
411
|
// PROJECT rows first (their sidebar order preserved), then direct to-dos in
|
|
378
412
|
// drag order. Someday children of active projects (the activeProjectItems
|
|
@@ -387,7 +421,7 @@ export function somedayView(db, now, filter) {
|
|
|
387
421
|
],
|
|
388
422
|
}));
|
|
389
423
|
}
|
|
390
|
-
export function logbookView(db, now, options) {
|
|
424
|
+
export function logbookView(db, now, options, zone) {
|
|
391
425
|
const cap = options?.limit === null ? null : (options?.limit ?? 100);
|
|
392
426
|
const tf = tagFilter(db, options);
|
|
393
427
|
const where = [];
|
|
@@ -397,9 +431,17 @@ export function logbookView(db, now, options) {
|
|
|
397
431
|
binds.push(resolveProjectUuid(db, options.project));
|
|
398
432
|
}
|
|
399
433
|
if (options?.area !== undefined) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
434
|
+
if (isLooseRef(options.area)) {
|
|
435
|
+
// `--area loose`: the area-less logged rows — effective area is null (own
|
|
436
|
+
// area null AND no area-carrying containing project). The inversion of the
|
|
437
|
+
// real-area subtree keep-rule below.
|
|
438
|
+
where.push(`t.area IS NULL AND (${EFF_PROJECT} IS NULL OR NOT EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area IS NOT NULL))`);
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
const areaUuid = resolveAreaUuid(db, options.area);
|
|
442
|
+
where.push(`(t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`);
|
|
443
|
+
binds.push(areaUuid, areaUuid);
|
|
444
|
+
}
|
|
403
445
|
}
|
|
404
446
|
if (options?.since !== undefined) {
|
|
405
447
|
where.push("t.stopDate >= ?");
|
|
@@ -414,21 +456,40 @@ export function logbookView(db, now, options) {
|
|
|
414
456
|
// checked in their original lists, exactly like the GUI's Logbook. The
|
|
415
457
|
// membership boundary rides the injected clock (threaded into both the SQL
|
|
416
458
|
// filter and materialize so presence and the `logged` flag agree).
|
|
417
|
-
const boundary = logBoundary(db, now);
|
|
459
|
+
const boundary = logBoundary(db, now, zone);
|
|
418
460
|
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 ?"}`, [boundary.getTime() / 1000, ...binds, ...tf.binds, ...(cap === null ? [] : [cap])]);
|
|
419
|
-
return materialize(db, rows, boundary);
|
|
461
|
+
return materialize(db, rows, boundary, encodePackedDate(localToday(now, zone)));
|
|
420
462
|
}
|
|
421
|
-
|
|
463
|
+
/**
|
|
464
|
+
* A cheap COUNT of one area's logged rows — the SAME subtree-inclusive scope
|
|
465
|
+
* (own area OR containing project's area) and log-move boundary logbookView
|
|
466
|
+
* applies, without materializing a single row. Feeds the `area show` TTY card's
|
|
467
|
+
* live logbook-footer count; real areas only (the loose pseudo-area accumulates
|
|
468
|
+
* no `logbook --area` archive).
|
|
469
|
+
*/
|
|
470
|
+
export function areaLoggedCount(db, areaUuid, now, zone) {
|
|
471
|
+
const boundary = logBoundary(db, now, zone);
|
|
472
|
+
const row = db
|
|
473
|
+
.prepare(`SELECT COUNT(*) AS n FROM TMTask t
|
|
474
|
+
WHERE ${LIVE} AND t.status IN (2, 3) AND t.stopDate <= ?
|
|
475
|
+
AND (t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`)
|
|
476
|
+
.get(boundary.getTime() / 1000, areaUuid, areaUuid);
|
|
477
|
+
return row.n;
|
|
478
|
+
}
|
|
479
|
+
export function trashView(db, now, options, zone) {
|
|
422
480
|
const cap = options?.limit === null ? null : (options?.limit ?? 200);
|
|
423
481
|
const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 1 AND ${NOT_TEMPLATE}
|
|
424
482
|
ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, cap === null ? [] : [cap]);
|
|
425
|
-
return materialize(db, rows, logBoundary(db, now));
|
|
483
|
+
return materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
|
|
426
484
|
}
|
|
427
485
|
export function projectsView(db, options) {
|
|
428
486
|
// areaUuid accepts a uuid OR a unique (case-insensitive) title; ambiguous
|
|
429
|
-
// or unknown references throw like every other ref resolver.
|
|
430
|
-
|
|
431
|
-
|
|
487
|
+
// or unknown references throw like every other ref resolver. The reserved
|
|
488
|
+
// `loose` ref scopes to the NULL area (area-less projects) — the sidebar's
|
|
489
|
+
// leading loose block — with no uuid to resolve.
|
|
490
|
+
const loose = options?.areaUuid !== undefined && isLooseRef(options.areaUuid);
|
|
491
|
+
const area = options?.areaUuid === undefined || loose ? null : resolveAreaUuid(db, options.areaUuid);
|
|
492
|
+
const packedToday = encodePackedDate(localToday(options?.now, options?.zone));
|
|
432
493
|
// Tag scope (§9a): the projects LIST is a FLAT view (like `anytime` restricted
|
|
433
494
|
// to project rows), NOT a single-container view — projects sit in different
|
|
434
495
|
// areas with heterogeneous inheritance, there is no universally-inherited tag
|
|
@@ -457,8 +518,11 @@ export function projectsView(db, options) {
|
|
|
457
518
|
// (area-less) projects first in their own drag order — the GUI lists them
|
|
458
519
|
// above the areas — then each area by ITS sidebar rank (TMArea."index"),
|
|
459
520
|
// projects within an area by their drag order.
|
|
460
|
-
|
|
461
|
-
|
|
521
|
+
// A single-group flat listing (loose OR a real area) shares one ORDER shape;
|
|
522
|
+
// only the unscoped sidebar catalogue interleaves areas by their rank.
|
|
523
|
+
const areaClause = loose ? " AND t.area IS NULL" : area ? " AND t.area = ?" : "";
|
|
524
|
+
const where = loose || area
|
|
525
|
+
? `${OPEN} AND t.type = 1${areaClause}${laterSql}${overdueSql}${tf.sql}
|
|
462
526
|
ORDER BY ${activeFirst}t."index" ASC`
|
|
463
527
|
: `${OPEN} AND t.type = 1${laterSql}${overdueSql}${tf.sql} ORDER BY (t.area IS NOT NULL) ASC,
|
|
464
528
|
(SELECT a."index" FROM TMArea a WHERE a.uuid = t.area) ASC, ${activeFirst}t."index" ASC`;
|
|
@@ -469,7 +533,7 @@ export function projectsView(db, options) {
|
|
|
469
533
|
...tf.binds,
|
|
470
534
|
...activeFirstBinds,
|
|
471
535
|
]);
|
|
472
|
-
const items = materialize(db, rows, logBoundary(db, options?.now));
|
|
536
|
+
const items = materialize(db, rows, logBoundary(db, options?.now, options?.zone), packedToday);
|
|
473
537
|
if (options?.later !== true)
|
|
474
538
|
return items;
|
|
475
539
|
// Each group's later sub-block reads like Upcoming: SCHEDULED projects
|
|
@@ -477,7 +541,7 @@ export function projectsView(db, options) {
|
|
|
477
541
|
// hidden on entities but present on the raw rows) — then someday in drag
|
|
478
542
|
// order. The SQL already made group runs contiguous with actives leading,
|
|
479
543
|
// so only the later runs are re-sorted (stable within someday).
|
|
480
|
-
const todayIso = localToday(options?.now);
|
|
544
|
+
const todayIso = localToday(options?.now, options?.zone);
|
|
481
545
|
const ti = new Map(rows.map((r) => [r.uuid, r.todayIndex ?? 0]));
|
|
482
546
|
const isLater = (p) => p.startDate !== null ? p.startDate > todayIso : p.start === "someday";
|
|
483
547
|
const out = [];
|
|
@@ -523,13 +587,14 @@ export function projectsView(db, options) {
|
|
|
523
587
|
* carry no modification dates, and checklist-item edits do not bump the
|
|
524
588
|
* parent task — those changes are invisible here.
|
|
525
589
|
*/
|
|
526
|
-
export function changesView(db, now, options) {
|
|
590
|
+
export function changesView(db, now, options, zone) {
|
|
527
591
|
const cap = options.limit === null ? null : (options.limit ?? 200);
|
|
528
592
|
const sinceEpoch = options.since.getTime() / 1000;
|
|
529
593
|
const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.userModificationDate > ?
|
|
530
594
|
ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, [sinceEpoch, ...(cap === null ? [] : [cap])]);
|
|
595
|
+
const changed = materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
|
|
531
596
|
// oxlint-disable-next-line no-map-spread -- building fresh change objects, not mutating
|
|
532
|
-
return
|
|
597
|
+
return changed.map((item, i) => ({
|
|
533
598
|
...item,
|
|
534
599
|
changeKind: (rows[i]?.creationDate ?? 0) > sinceEpoch ? "created" : "modified",
|
|
535
600
|
}));
|
|
@@ -541,16 +606,16 @@ export function changesView(db, now, options) {
|
|
|
541
606
|
* doctrine: containers (areas, then projects) first, then to-dos; within a
|
|
542
607
|
* group active rows precede someday rows, then most-recently-modified. `type`
|
|
543
608
|
* scopes to a single class (the typed-namespace paths). Results are capped
|
|
544
|
-
* (default
|
|
545
|
-
* append a "… n more" tail.
|
|
609
|
+
* (default {@link CANDIDATE_CAP}); `total` reports the pre-cap match count so the
|
|
610
|
+
* caller can append a "… n more" tail.
|
|
546
611
|
*/
|
|
547
612
|
const somedayRank = (i) => (i.start === "someday" ? 1 : 0);
|
|
548
613
|
/** Lite-search order within a task group: active before someday, then most-recently-modified. */
|
|
549
614
|
function byStatusThenRecent(a, b) {
|
|
550
615
|
return somedayRank(a) - somedayRank(b) || b.modified.getTime() - a.modified.getTime();
|
|
551
616
|
}
|
|
552
|
-
export function liteTitleSearch(db, query, options, now) {
|
|
553
|
-
const cap = options?.limit ??
|
|
617
|
+
export function liteTitleSearch(db, query, options, now, zone) {
|
|
618
|
+
const cap = options?.limit ?? CANDIDATE_CAP;
|
|
554
619
|
const type = options?.type;
|
|
555
620
|
const needle = query.toLowerCase();
|
|
556
621
|
const areas = type === undefined || type === "area"
|
|
@@ -562,7 +627,7 @@ export function liteTitleSearch(db, query, options, now) {
|
|
|
562
627
|
if (type === undefined || type === "to-do" || type === "project") {
|
|
563
628
|
const typeSql = type === "to-do" ? "t.type = 0" : type === "project" ? "t.type = 1" : "t.type IN (0, 1)";
|
|
564
629
|
const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND ${typeSql} AND t.title LIKE ?${LIKE_ESCAPE}`, [`%${escapeLike(query)}%`]);
|
|
565
|
-
tasks = materialize(db, rows, logBoundary(db, now));
|
|
630
|
+
tasks = materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
|
|
566
631
|
}
|
|
567
632
|
const projects = tasks.filter((t) => t.type === "project").toSorted(byStatusThenRecent);
|
|
568
633
|
const todos = tasks.filter((t) => t.type === "to-do").toSorted(byStatusThenRecent);
|
|
@@ -571,18 +636,56 @@ export function liteTitleSearch(db, query, options, now) {
|
|
|
571
636
|
...projects.map((task) => ({ kind: "task", task })),
|
|
572
637
|
...todos.map((task) => ({ kind: "task", task })),
|
|
573
638
|
];
|
|
574
|
-
|
|
639
|
+
// Zero LIVE matches: probe whether the name matches DEAD rows (trashed, or
|
|
640
|
+
// swept to the logbook) so the caller gets an honest hint instead of a bare
|
|
641
|
+
// miss — the dead rows themselves never become candidates (areas can be
|
|
642
|
+
// neither trashed nor logged, so only tasks are counted).
|
|
643
|
+
let deadMatches;
|
|
644
|
+
if (ordered.length === 0 && type !== "area") {
|
|
645
|
+
const typeSql = type === "to-do" ? "t.type = 0" : type === "project" ? "t.type = 1" : "t.type IN (0, 1)";
|
|
646
|
+
const boundary = logBoundary(db, now, zone).getTime() / 1000;
|
|
647
|
+
const row = db
|
|
648
|
+
.prepare(`SELECT
|
|
649
|
+
SUM(CASE WHEN t.trashed = 1 THEN 1 ELSE 0 END) AS trashed,
|
|
650
|
+
SUM(CASE WHEN t.trashed = 0 AND t.status IN (2, 3) AND t.stopDate IS NOT NULL AND t.stopDate <= ? THEN 1 ELSE 0 END) AS logbook
|
|
651
|
+
FROM TMTask t
|
|
652
|
+
WHERE ${NOT_TEMPLATE} AND ${typeSql} AND t.title LIKE ?${LIKE_ESCAPE}`)
|
|
653
|
+
.get(boundary, `%${escapeLike(query)}%`);
|
|
654
|
+
const trashed = row.trashed ?? 0;
|
|
655
|
+
const logbook = row.logbook ?? 0;
|
|
656
|
+
if (trashed > 0 || logbook > 0)
|
|
657
|
+
deadMatches = { trashed, logbook };
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
candidates: ordered.slice(0, cap),
|
|
661
|
+
total: ordered.length,
|
|
662
|
+
...(deadMatches !== undefined && { deadMatches }),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/** A one-line notes snippet (~80 chars) centered on the first occurrence of the needle. */
|
|
666
|
+
const SNIPPET_MAX = 80;
|
|
667
|
+
function notesSnippet(notes, needleLower) {
|
|
668
|
+
const flat = notes.replace(/\s+/g, " ").trim();
|
|
669
|
+
if (flat.length <= SNIPPET_MAX)
|
|
670
|
+
return flat;
|
|
671
|
+
const hit = Math.max(0, flat.toLowerCase().indexOf(needleLower));
|
|
672
|
+
const half = Math.max(0, Math.floor((SNIPPET_MAX - needleLower.length) / 2));
|
|
673
|
+
let end = Math.min(flat.length, Math.max(hit - half, 0) + SNIPPET_MAX);
|
|
674
|
+
const start = Math.max(0, end - SNIPPET_MAX);
|
|
675
|
+
const core = flat.slice(start, end);
|
|
676
|
+
return `${start > 0 ? "…" : ""}${core}${end < flat.length ? "…" : ""}`;
|
|
575
677
|
}
|
|
576
|
-
export function searchView(db, query, options, now) {
|
|
678
|
+
export function searchView(db, query, options, now, zone) {
|
|
577
679
|
const cap = options?.limit === null ? null : (options?.limit ?? 50);
|
|
578
680
|
const needle = `%${escapeLike(query)}%`;
|
|
579
681
|
// `--overdue` narrows to OPEN, past-deadline matches. Its open-only predicate
|
|
580
682
|
// is contradictory with the status-widening flags (logged/trashed/all); the
|
|
581
683
|
// CLI/MCP surfaces reject that combination, so here it simply intersects.
|
|
582
|
-
const
|
|
684
|
+
const packedToday = encodePackedDate(localToday(now, zone));
|
|
685
|
+
const of = overdueFilter(options, packedToday);
|
|
583
686
|
// Threaded into every materialize so the `logged` flag (--logged widens to
|
|
584
687
|
// closed rows) honors the injected clock, not wall time.
|
|
585
|
-
const boundary = logBoundary(db, now);
|
|
688
|
+
const boundary = logBoundary(db, now, zone);
|
|
586
689
|
// The scope predicates (everything except the match needle), reused verbatim
|
|
587
690
|
// for the needle query AND the heading-credited-project query so both honor
|
|
588
691
|
// the same type/status/scope constraints.
|
|
@@ -609,24 +712,49 @@ export function searchView(db, query, options, now) {
|
|
|
609
712
|
scopeBinds.push(uuid, uuid);
|
|
610
713
|
}
|
|
611
714
|
if (options?.area !== undefined) {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
715
|
+
if (isLooseRef(options.area)) {
|
|
716
|
+
// `--area loose`: the area-less hits — effective area is null (own area
|
|
717
|
+
// null AND no area-carrying containing project). The inversion of the
|
|
718
|
+
// real-area subtree keep-rule below; inbox captures (area-less) are
|
|
719
|
+
// legitimately included.
|
|
720
|
+
scope.push(`t.area IS NULL AND (${EFF_PROJECT} IS NULL OR NOT EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area IS NOT NULL))`);
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
// Real-area filter is SUBTREE-INCLUSIVE (own area OR containing project's
|
|
724
|
+
// area) — the same effective-area shape logbookView uses, so every --area
|
|
725
|
+
// view scopes to the whole subtree, not just an area's direct members.
|
|
726
|
+
const uuid = resolveAreaUuid(db, options.area);
|
|
727
|
+
scope.push(`(t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`);
|
|
728
|
+
scopeBinds.push(uuid, uuid);
|
|
729
|
+
}
|
|
615
730
|
}
|
|
616
731
|
const tf = tagFilter(db, options);
|
|
617
732
|
// Needle matches: title OR notes. No SQL LIMIT — ranking runs before the cap.
|
|
618
733
|
const rows = fetchTaskRows(db, `${scope.join(" AND ")} AND (t.title LIKE ?${LIKE_ESCAPE} OR t.notes LIKE ?${LIKE_ESCAPE})${tf.sql}${of.sql}`, [...scopeBinds, needle, needle, ...tf.binds, ...of.binds]);
|
|
619
734
|
const needleLower = query.toLowerCase();
|
|
620
735
|
const matches = new Map();
|
|
621
|
-
|
|
622
|
-
|
|
736
|
+
/** Attach a match annotation to a materialized hit (title matches carry none). */
|
|
737
|
+
const annotate = (item, ann) => {
|
|
738
|
+
if (ann !== undefined)
|
|
739
|
+
item.match = ann;
|
|
740
|
+
};
|
|
741
|
+
for (const item of materialize(db, rows, boundary, packedToday)) {
|
|
742
|
+
// Field credit: title beats notes when both carry the substring. A notes-only
|
|
743
|
+
// hit is annotated with a bounded snippet centered on the match; a title hit
|
|
744
|
+
// carries no annotation (presence-keyed — matched where you'd expect).
|
|
623
745
|
const field = item.title.toLowerCase().includes(needleLower) ? "title" : "notes";
|
|
746
|
+
if (field === "notes") {
|
|
747
|
+
annotate(item, { field: "notes", text: notesSnippet(item.notes ?? "", needleLower) });
|
|
748
|
+
}
|
|
624
749
|
matches.set(item.uuid, { item, field });
|
|
625
750
|
}
|
|
626
751
|
// Heading titles are treated as if they lived in the parent PROJECT's notes:
|
|
627
752
|
// a heading-title match surfaces the parent project (never a bare heading
|
|
628
|
-
// row), credited at the heading
|
|
629
|
-
//
|
|
753
|
+
// row), credited at the heading field rank. A to-do-only search has no project
|
|
754
|
+
// rows, so headings do not apply there. Heading OUTRANKS notes for the
|
|
755
|
+
// annotation (precedence title > heading > notes): a project matched by its
|
|
756
|
+
// own notes AND by a child heading shows the heading annotation, though it
|
|
757
|
+
// keeps its notes rank; a title-matched project keeps NO annotation.
|
|
630
758
|
if (options?.type !== "to-do") {
|
|
631
759
|
const headingRows = db
|
|
632
760
|
.prepare(`SELECT project AS projectUuid, title FROM TMTask
|
|
@@ -637,21 +765,51 @@ export function searchView(db, query, options, now) {
|
|
|
637
765
|
if (!headingTitleFor.has(h.projectUuid))
|
|
638
766
|
headingTitleFor.set(h.projectUuid, h.title ?? "");
|
|
639
767
|
}
|
|
640
|
-
//
|
|
641
|
-
// (
|
|
642
|
-
// the view's scope.
|
|
643
|
-
const wanted = [
|
|
768
|
+
// Projects ALREADY matched: re-credit a notes annotation to the heading
|
|
769
|
+
// (title stays annotation-free). Projects NOT yet matched: materialize and
|
|
770
|
+
// add them at the heading rank. Both only where the view's scope allows.
|
|
771
|
+
const wanted = [];
|
|
772
|
+
for (const [uuid, headingTitle] of headingTitleFor) {
|
|
773
|
+
const existing = matches.get(uuid);
|
|
774
|
+
if (existing === undefined) {
|
|
775
|
+
wanted.push(uuid);
|
|
776
|
+
}
|
|
777
|
+
else if (existing.item.type === "project" && existing.field === "notes") {
|
|
778
|
+
annotate(existing.item, { field: "heading", text: headingTitle });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
644
781
|
if (wanted.length > 0) {
|
|
645
782
|
const placeholders = wanted.map(() => "?").join(", ");
|
|
646
783
|
const projectRows = fetchTaskRows(db, `${scope.join(" AND ")} AND t.type = 1 AND t.uuid IN (${placeholders})${tf.sql}${of.sql}`, [...scopeBinds, ...wanted, ...tf.binds, ...of.binds]);
|
|
647
|
-
for (const item of materialize(db, projectRows, boundary)) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
784
|
+
for (const item of materialize(db, projectRows, boundary, packedToday)) {
|
|
785
|
+
annotate(item, { field: "heading", text: headingTitleFor.get(item.uuid) ?? "" });
|
|
786
|
+
matches.set(item.uuid, { item, field: "heading" });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// Checklist-item titles are treated as properties of the parent TO-DO (like
|
|
791
|
+
// notes): a checklist-body match surfaces the parent to-do (deduped — one to-do
|
|
792
|
+
// no matter how many of its rows match), annotated with the FIRST matching
|
|
793
|
+
// row's title. Checklist is the lowest-precedence field, so it only adds to-dos
|
|
794
|
+
// not already matched by their own title/notes (those keep the higher credit).
|
|
795
|
+
// Checklist-item uuids appear on NO surface — the join reads titles only.
|
|
796
|
+
if (options?.type !== "project") {
|
|
797
|
+
const checklistRows = db
|
|
798
|
+
.prepare(`SELECT task AS todoUuid, title FROM TMChecklistItem
|
|
799
|
+
WHERE task IS NOT NULL AND title LIKE ?${LIKE_ESCAPE} ORDER BY "index" ASC`)
|
|
800
|
+
.all(needle);
|
|
801
|
+
const checklistTitleFor = new Map();
|
|
802
|
+
for (const c of checklistRows) {
|
|
803
|
+
if (!checklistTitleFor.has(c.todoUuid))
|
|
804
|
+
checklistTitleFor.set(c.todoUuid, c.title ?? "");
|
|
805
|
+
}
|
|
806
|
+
const wanted = [...checklistTitleFor.keys()].filter((uuid) => !matches.has(uuid));
|
|
807
|
+
if (wanted.length > 0) {
|
|
808
|
+
const placeholders = wanted.map(() => "?").join(", ");
|
|
809
|
+
const todoRows = fetchTaskRows(db, `${scope.join(" AND ")} AND t.type = 0 AND t.uuid IN (${placeholders})${tf.sql}${of.sql}`, [...scopeBinds, ...wanted, ...tf.binds, ...of.binds]);
|
|
810
|
+
for (const item of materialize(db, todoRows, boundary, packedToday)) {
|
|
811
|
+
annotate(item, { field: "checklist", text: checklistTitleFor.get(item.uuid) ?? "" });
|
|
812
|
+
matches.set(item.uuid, { item, field: "checklist" });
|
|
655
813
|
}
|
|
656
814
|
}
|
|
657
815
|
}
|