pyworklog 0.4.0__tar.gz → 0.5.0__tar.gz

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 (79) hide show
  1. {pyworklog-0.4.0 → pyworklog-0.5.0}/CHANGELOG.md +28 -7
  2. {pyworklog-0.4.0 → pyworklog-0.5.0}/DESIGN.md +10 -1
  3. {pyworklog-0.4.0 → pyworklog-0.5.0}/DESIGN.zh.md +10 -1
  4. {pyworklog-0.4.0 → pyworklog-0.5.0}/PKG-INFO +1 -1
  5. {pyworklog-0.4.0 → pyworklog-0.5.0}/pyproject.toml +1 -1
  6. {pyworklog-0.4.0 → pyworklog-0.5.0}/skills/worklog-cli/SKILL.md +2 -2
  7. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/cli.py +40 -34
  8. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/commands/bulk.py +47 -61
  9. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/commands/meta.py +25 -27
  10. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/commands/metric.py +38 -47
  11. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/commands/query.py +99 -93
  12. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/commands/state.py +92 -99
  13. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/commands/views.py +197 -72
  14. pyworklog-0.5.0/src/worklog/db_table.py +174 -0
  15. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/helpers.py +17 -11
  16. pyworklog-0.5.0/src/worklog/migrations/0007_utc_timestamps.sql +46 -0
  17. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/queries.py +86 -38
  18. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/render.py +2 -2
  19. pyworklog-0.5.0/src/worklog/timeutil.py +159 -0
  20. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/conftest.py +9 -0
  21. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_add.py +4 -4
  22. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_brief.py +32 -32
  23. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_checkin.py +5 -5
  24. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_clock.py +5 -4
  25. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_dateinfo.py +3 -3
  26. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_day.py +85 -17
  27. pyworklog-0.5.0/tests/test_db_table.py +197 -0
  28. pyworklog-0.5.0/tests/test_filters.py +186 -0
  29. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_find.py +16 -16
  30. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_focus.py +3 -3
  31. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_import_apply.py +38 -38
  32. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_log.py +13 -11
  33. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_log_format.py +1 -1
  34. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_logs.py +1 -1
  35. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_meta.py +11 -11
  36. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_metric.py +18 -15
  37. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_migrations.py +44 -0
  38. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_projects.py +4 -4
  39. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_render.py +16 -16
  40. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_sched.py +27 -27
  41. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_show.py +2 -2
  42. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_state.py +38 -9
  43. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_summary.py +1 -1
  44. pyworklog-0.5.0/tests/test_timeutil.py +136 -0
  45. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_tree.py +3 -1
  46. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_ux.py +27 -27
  47. {pyworklog-0.4.0 → pyworklog-0.5.0}/uv.lock +1 -1
  48. {pyworklog-0.4.0 → pyworklog-0.5.0}/.github/workflows/release.yml +0 -0
  49. {pyworklog-0.4.0 → pyworklog-0.5.0}/.github/workflows/test.yml +0 -0
  50. {pyworklog-0.4.0 → pyworklog-0.5.0}/.gitignore +0 -0
  51. {pyworklog-0.4.0 → pyworklog-0.5.0}/AGENTS.md +0 -0
  52. {pyworklog-0.4.0 → pyworklog-0.5.0}/CONTRIBUTING.md +0 -0
  53. {pyworklog-0.4.0 → pyworklog-0.5.0}/LICENSE +0 -0
  54. {pyworklog-0.4.0 → pyworklog-0.5.0}/Makefile +0 -0
  55. {pyworklog-0.4.0 → pyworklog-0.5.0}/README.md +0 -0
  56. {pyworklog-0.4.0 → pyworklog-0.5.0}/README.zh.md +0 -0
  57. {pyworklog-0.4.0 → pyworklog-0.5.0}/pytest.ini +0 -0
  58. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/__init__.py +0 -0
  59. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/commands/__init__.py +0 -0
  60. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/completion.py +0 -0
  61. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/db.py +0 -0
  62. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/migrations/0001_initial_schema.sql +0 -0
  63. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/migrations/0002_metric_and_log_tag.sql +0 -0
  64. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/migrations/0003_backfill_checkin_metrics.sql +0 -0
  65. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/migrations/0004_meta_props_to_typed_logs.sql +0 -0
  66. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/migrations/0005_clock_table.sql +0 -0
  67. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/migrations/0006_rename_log_type_to_tag.sql +0 -0
  68. {pyworklog-0.4.0 → pyworklog-0.5.0}/src/worklog/xdg.py +0 -0
  69. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/__init__.py +0 -0
  70. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_aliases.py +0 -0
  71. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_cascade.py +0 -0
  72. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_changes.py +0 -0
  73. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_completion.py +0 -0
  74. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_config.py +0 -0
  75. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_init.py +0 -0
  76. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_link_set.py +0 -0
  77. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_ls.py +0 -0
  78. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_sample.py +0 -0
  79. {pyworklog-0.4.0 → pyworklog-0.5.0}/tests/test_xdg.py +0 -0
@@ -6,6 +6,19 @@ The autogenerated `Commits since vX.Y.Z` block on each GitHub Release captures e
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] — 2026-06-05
10
+
11
+ ### Added
12
+ - **Shared `--tag` / `--kind` / `--status` filter across `wl ls` / `tree` / `day` / `logs` / `agenda`** — one filter with the same meaning everywhere (wired through a parent parser, like the existing time-window flags), instead of each command rolling its own. `-t` / `--tag` is comma-separated **AND** (the node must carry every listed tag); `--kind` / `--status` are equality. So `wl day -t work` shows only the work bucket (empty buckets/groups aren't rendered, and the summary line counts the filtered set), `wl tree -t work` prunes the tree to matching nodes plus the ancestor paths that lead to them, and `ls` / `logs` / `agenda` filter identically. An explicit `--status DONE/CANCELED` overrides the default terminal-status hide. `-t` is now the short form on all five (matching `wl add -t`).
13
+ - **`wl day` header states the day's nature** — every day now reads at a glance: `workday` (Mon–Fri) or `weekend` (Sat/Sun) by default, refined to `holiday` / `leave` / `workday` when a `wl dateinfo` label says so, with the label appended for context — e.g. `2026-06-01 Mon · Children's Day holiday`, `2026-06-13 Sat · makeup workday`, `2026-05-21 Thu · workday (Grain Buds solar term)`. Previously the header showed a label only when one existed, so a bare day told you nothing about its working status.
14
+
15
+ ### Changed
16
+ - **Timestamps follow an `at`/`date` convention — precise instants stored UTC, literal dates kept local.** A column named `*_at` (or bare `at`: `created_at` / `closed_at` / `logged_at` / `metric.at` / `clock.start_at` / `end_at` / `sched.created_at`) is a precise instant, now stored as UTC and rendered in local time; a column holding a calendar *day* is named `*date` and kept verbatim as a local date. As part of this, `node.scheduled_at` → **`scheduled_date`** and `node.deadline_at` → **`deadline_date`** (they hold a planned/deadline day, plus fuzzy `@2026-06` pins — not instants). "Local" is the machine zone by default; `$WORKLOG_TZ` pins a fixed offset (e.g. `+08:00`) for reproducibility / overriding. Day-grouping (`wl day` / `summary` / `logs --date` / habit month-to-date) converts UTC→local before bucketing, so a log near local midnight lands on the right day. **Migration 0007** converts pre-existing rows from local (+08:00) to UTC (bare `YYYY-MM-DD` values left untouched) and renames the two date columns; new installs are unaffected.
17
+ - **Habit month-to-date label is English** — `(本月 N/M)` → `(this month N/M)` in `wl day` / `wl tree`, matching the rest of the output.
18
+
19
+ ### Internal
20
+ - **A zero-dependency single-table CRUD layer (`db_table`).** All hand-written single-table SQL — inserts, updates, deletes, selects, dynamically-assembled `WHERE` / `SET`, and `INSERT OR IGNORE/REPLACE` — now goes through a small helper (`insert` / `update` / `delete` / `query` / `query_one` / `get` / `exists` / `count` / `clause`) with identifier validation and parameter binding throughout; SQL `datetime('now')` is gone (all timestamps flow through `timeutil`). No behavior change — a maintainability/safety refactor, codified as design goal **G3** (few deps: stdlib + `rich`; simple explicit SQL over query-builder DSLs; small helpers over an ORM). Genuinely-complex queries (display JOINs, `GROUP BY`, correlated subqueries) stay raw by design. The test fixtures were also de-Chinese'd to English (the relative-date input aliases like `明天` / `下周` remain — they're a feature).
21
+
9
22
  ## [0.4.0] — 2026-06-05
10
23
 
11
24
  ### Fixed
@@ -81,16 +94,24 @@ The autogenerated `Commits since vX.Y.Z` block on each GitHub Release captures e
81
94
  Initial open-source release of `worklog-cli` (`wl`).
82
95
 
83
96
  ### Added
84
- - 37 subcommands covering create / log / done / state transitions / tree / projects / day / summary / changes / find / focus / show.
85
- - Bulk loaders: `wl import` (JSON) and `wl apply` (wl-diff format, symmetric with the rendered tree output).
86
- - Compound flags on `wl add` / `done` / `cancel` create + log + close + timestamp + vault-link + schedule in one shot.
87
- - Brief mode `-q` on every command (skip log body / timeline / detail; ~50–90% character savings for AI consumers).
88
- - `rich`-based highlighting with `dark` / `light` / `mono` themes, auto-detected via `$COLORFGBG` / OSC 11 terminal background probe.
89
- - Shell completion (fish / bash / zsh) generated at runtime by `wl print-completion`; loaded via init-load (the starship / direnv / zoxide pattern).
97
+ - **Single-table execution-system model** one `node` table holds the whole hierarchy across 13 kinds (`lifetime` / `decade` / `year` / `quarter` / `month` / `week` / `day` / `area` / `project` / `task` / `habit` / `signal` / `meetlog`), sharing one id space and tree-linked via a `parent_id` self-reference. Six sibling tables hang off it: `log` (timestamped entries), `tag` (labels), `sched` (scheduling), `prop` (key-value attributes), `link` (vault-doc references), `date_meta`.
98
+ - **Seven-state task lifecycle** — `TODO` / `DOING` / `LATER` / `WAIT` / `DONE` / `DEFERRED` / `CANCELED`, a superset of the markdown `[ ]` / `[/]` / `[>]` / `[x]` four-state set that adds `LATER` (deferred to the future) vs. `WAIT` (blocked on someone). Plus `A` / `B` / `C` priority (rendered `[#A]` = P0) and auto-stamped `closed_at`.
99
+ - **37 subcommands** create/edit (`add` / `set` / `link` / `init`), logging (`log` / `unlog` / `relog`), state transitions (`done` / `defer` / `start` / `stop` / `spent` / `active` / `wait` / `reopen` / `cancel` / `tick` / `checkin`), views (`show` / `ls` / `tree` / `focus` / `ancestors` / `descendants` / `projects` / `changes` / `summary` / `day`), meta fields (`goal` / `recap`), scheduling (`sched` / `dateinfo`), bulk I/O (`import` / `apply`), search (`find` / `logs`), and display (`themes` / `print-completion`). Most carry short aliases.
100
+ - **Scheduling with recurrence** — the `sched` table records both one-off dates (`on_date`) and recurrence rules (`rrule`: `daily`, `weekly:Mon,Wed,Fri`); a task scheduled for a given day counts as planned, derived from `sched` rather than a manual tag.
101
+ - **Time tracking** `wl start` / `stop` / `spent` / `active` / `wait` record work intervals and report durations per node.
102
+ - **Project association via shared tags** `wl projects` rolls a project's members up by structural children plus shared semantic tags, with a `GENERIC_TAGS` set (work / personal / planned / unplanned / P0–P2 / habit / meeting / …) excluded so generic dimension tags don't cross-link unrelated work.
103
+ - **Bulk loaders** — `wl import` (JSON) and `wl apply` (wl-diff format, symmetric with the rendered tree output) for scripted/AI-driven batch entry.
104
+ - **Compound flags on `wl add` / `done` / `cancel`** — create + log + close + timestamp + vault-link + schedule in one shot.
105
+ - **Date aliases** — `today` / `yesterday` / `tomorrow` / `day-before-yesterday` / … and Chinese equivalents resolve to concrete dates wherever a date is accepted.
106
+ - **Brief mode `-q`** on every command (skip log body / timeline / detail; ~50–90% character savings for AI consumers).
107
+ - **`rich`-based highlighting** with `dark` / `light` / `mono` themes, auto-detected via `$COLORFGBG` / OSC 11 terminal-background probe; manual override via `--theme` or `$WL_COLOR` / `$WL_THEME` / `$NO_COLOR`.
108
+ - **Shell completion** (fish / bash / zsh) generated at runtime by `wl print-completion`; loaded via init-load (the starship / direnv / zoxide pattern).
109
+ - **Vault integration** — `wl link` associates a node with one or more Obsidian vault docs (`[[doc]]`), kept in the `link` table for a two-way knowledge graph.
90
110
  - Bilingual project documentation (English + Chinese for README / DESIGN / SKILL).
91
111
  - MIT License.
92
112
 
93
- [Unreleased]: https://github.com/xyb/worklog/compare/v0.4.0...HEAD
113
+ [Unreleased]: https://github.com/xyb/worklog/compare/v0.5.0...HEAD
114
+ [0.5.0]: https://github.com/xyb/worklog/compare/v0.4.0...v0.5.0
94
115
  [0.4.0]: https://github.com/xyb/worklog/compare/v0.3.0...v0.4.0
95
116
  [0.3.0]: https://github.com/xyb/worklog/compare/v0.2.0...v0.3.0
96
117
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  ## 0. Design goals (north star, override every specific convention)
9
9
 
10
- These two trade-offs are foundational. Pass them before adding or changing any feature; when they conflict with a specific convention below, they win.
10
+ These goals are foundational. Pass them before adding or changing any feature; when they conflict with a specific convention below, they win.
11
11
 
12
12
  ### G1 Structured-first (no string matching)
13
13
 
@@ -24,6 +24,15 @@ The tool must be simple enough to **use without reading the manual**, for both h
24
24
  - Self-check any new design with three questions: (1) how many new concepts does it add? (2) does using it force AI/humans to make a choice ("A or B?")? (3) can you guess it right without docs? If any answer is poor, simplify further.
25
25
  - When G1 and G2 conflict, find the "structured AND fewest-concepts" answer — don't pile on new tables / fields / commands just to be structured.
26
26
 
27
+ ### G3 Few dependencies, simple logic, easy to maintain
28
+
29
+ worklog deliberately stays **dependency-light and low-abstraction**: runtime is **stdlib + `rich` only**, and new features prefer **zero new dependencies**. Logic stays **explicit and direct** — plain SQL over query-builder DSLs, small purpose-built helpers over frameworks/ORMs that hide what is happening. The bar: a maintainer (human or AI) can read any code path top-to-bottom without first learning a hidden layer.
30
+
31
+ - **Borrow the technique, not the library.** Prefer writing a tens-of-lines, zero-dependency helper over pulling in a package (e.g. a dict→`INSERT` helper in the spirit of sqlite-utils, *not* an ORM; a `field__op`→`WHERE` helper, *not* a query builder).
32
+ - **No ORM / no query builder.** Wrap only the uniform ~80 % (single-table CRUD + existence/count) in thin helpers that map transparently to one SQL statement; keep the complex ~20 % (JOIN / CTE / CASE / time-window) as explicit SQL. Don't reinvent SQL as a kwargs DSL.
33
+ - **Each table / module maintainable on its own.** Minimize forced coupling (hard FK cascades, cross-cutting magic, triggers that hide intent) so one part can change — or be migrated / synced — without rippling everywhere. Lean toward avoiding irreversible operations (prefer soft-delete / status over `DELETE`) where they create cross-table consistency burdens.
34
+ - When a "clever" abstraction conflicts with G3, choose the boringly-simple version. Fewer moving parts beats elegance.
35
+
27
36
  ## 1. Command style (todo.sh school)
28
37
 
29
38
  - Verb first: `wl <verb> [args] [--flags]`
@@ -7,7 +7,7 @@
7
7
 
8
8
  ## 0. 设计目标(北极星,凌驾于一切具体约定之上)
9
9
 
10
- 下面两条取舍是地基。加任何功能、改任何设计前先过这两关;跟下文具体约定冲突时,以这两条为准。
10
+ 下面几条是地基。加任何功能、改任何设计前先过这几关;跟下文具体约定冲突时,以这几条为准。
11
11
 
12
12
  ### G1 结构化优先(不靠字符串匹配)
13
13
 
@@ -24,6 +24,15 @@ worklog 的整个职责就是把工作记录**结构化**,让信息能被精
24
24
  - 任何新设计自检三问:(1) 它新增了几个概念?(2) 用它要不要逼人 / AI 做选择("用 A 还是 B?")?(3) 不看文档能不能猜对用法?任一条答得不好,就再简化。
25
25
  - G1 和 G2 冲突时,找"既结构化又最少概念"的答案——别为了结构化就堆新表 / 新字段 / 新命令。
26
26
 
27
+ ### G3 依赖少、逻辑简单、易维护
28
+
29
+ worklog 刻意保持**少依赖、低抽象**:运行时只有 **stdlib + `rich`**,新功能优先**零新依赖**。逻辑保持**显式直白**——宁可裸 SQL 也不上 query-builder DSL,宁可写小而专的 helper 也不引入藏掉细节的框架 / ORM。底线:维护者(人或 AI)能把任一条代码路径从头读到尾,不必先学一层隐藏机制。
30
+
31
+ - **借技法,不借库**:宁可写几十行零依赖 helper,也别拉一个包(例如照 sqlite-utils 思路写 dict→`INSERT` helper,而**不是** ORM;写 `field__op`→`WHERE` helper,而**不是** query builder)。
32
+ - **不上 ORM / 不上 query builder**:只把均匀的 ~80%(单表 CRUD + 存在/计数)包成**透明映射到一条 SQL**的薄 helper;复杂的 ~20%(JOIN / CTE / CASE / 时间窗)留显式 SQL。别把 SQL 重新发明成一套 kwargs DSL。
33
+ - **每张表 / 每个模块能各自维护**:尽量减少强制耦合(硬 FK 级联、跨切面魔法、藏意图的 trigger),让一处能改动 / 迁移 / 同步而不牵连全局。在会造成跨表一致性负担的地方,倾向**避免不可逆操作**(用软删除 / 状态标记替代 `DELETE`)。
34
+ - "聪明"的抽象跟 G3 冲突时,选无聊但简单的版本。零件少胜过优雅。
35
+
27
36
  ## 1. 命令风格(todo.sh 派)
28
37
 
29
38
  - 动词在前:`wl <verb> [args] [--flags]`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyworklog
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: SQLite-backed worklog tool with a todo.sh-style CLI
5
5
  Project-URL: Homepage, https://github.com/xyb/worklog
6
6
  Project-URL: Repository, https://github.com/xyb/worklog
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyworklog"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "SQLite-backed worklog tool with a todo.sh-style CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -32,7 +32,7 @@ Likewise, before adding a dev todo under a project, check that project's existin
32
32
  | Schedule task to a date / repeat (drives "planned") | `wl sched <id> 2026-06-15` (also accepts `tomorrow` / `day-after-tomorrow`) / `--clear`. `--recur` supports period start / end: `daily` / `weekly:Mon,Fri` (also 1-7 / -1..-7) / `monthly:1` (month start) · `monthly:-1` (month end) / `quarterly:1-1` (quarter start) · `quarterly:-1` (quarter end) / `yearly:01-01` (year start) · `yearly:-1` (year end); `-1` always means period end. A task scheduled to a day shows up in `wl day` as "planned · not yet logged" even with no log |
33
33
  | Meta info (end-of-day summary / Top5 / today's goal) | History-preserving typed logs (`log.tag`), not props: `wl goal "..."` (day) / `wl recap "..."` (day summary) / `wl set <month> top5 "..."` / `wl set <week> overview "..."`. Each write appends; the latest is current. `wl day` shows them at the top as a blockquote. Prefer `wl recap`/`wl goal` over `wl set` for day fields (read-back + stale-warning) |
34
34
  | Date context (holidays / vacation / makeup days) | `wl dateinfo 2026-05-01 Labor-Day-holiday` / `wl dateinfo --import holidays.json` (`{"YYYY-MM-DD":"label"}`) / `wl dateinfo <date> --clear`. Weekday auto-computed; `wl day` header shows "date weekday · label" |
35
- | Reproduce a day's progress (like markdown worklog) | `wl day [YYYY-MM-DD]` (log-date based: work/personal split → secondary group → task → indented logs + stats). **Default `--by plan`** (planned / unplanned / unplanned-unmarked anything without `planned`/`unplanned` tag is treated as unplanned); switch dimensions with `--by project` / `--by priority` (P0/P1/P2) |
35
+ | Reproduce a day's progress (like markdown worklog) | `wl day [YYYY-MM-DD]` (log-date based: work/personal split → secondary group → task → indented logs + stats). **Default `--by plan`** (planned / unplanned). **Planned = has a `sched`/recur entry firing that day (source of truth, set via `wl sched`); everything else = unplanned.** The legacy `:planned:` tag is honored only as a transitional fallback — do NOT add it to new tasks, use `wl sched`. Switch dimensions with `--by project` / `--by priority` (P0/P1/P2) |
36
36
  | List all active projects | `wl projects` |
37
37
  | Tree view | `wl tree` (**default = overview: timeline expanded to today [year→quarter→month→week→today + today's tasks] + areas only listed as names, ~30 lines to avoid flooding**) / `wl tree --root <area>` (projects + tasks under that area) / `wl tree --root <week/month>` (per-day activity) / `wl tree --depth N` (fully expanded from lifetime) / `wl tree --by project/tag/direction` (switch dimension). Time nodes sorted by date; **day node expansion = tasks that have logs that day + only their logs from that day** |
38
38
  | Time-range progress (weekly report input) | `wl changes --week 2026-W22` / `wl summary --week ... --by project/day` |
@@ -224,7 +224,7 @@ echo '{
224
224
 
225
225
  ```
226
226
  #6 [day] 2026-05-29 Friday ← anchor: locate existing node as parent, don't modify
227
- + [x] [#A] morning-check :planned:P0: ← add (indent = child), [x]=DONE
227
+ + [x] [#A] morning-check ← add (indent = child), [x]=DONE; for planned use `wl sched`, not a :planned: tag
228
228
  + @log check key points
229
229
  ~ [x] #14 ← change #14 status (single-line shorthand)
230
230
  ~ #20 ← complex update: lock + field operations
@@ -183,6 +183,14 @@ def build_parser():
183
183
  window.add_argument("--week", help="YYYY-Www (ISO week, overrides since/until)")
184
184
  window.add_argument("--month", help="YYYY-MM (overrides since/until)")
185
185
 
186
+ # node-filter parent parser (reused by ls/tree/day/logs/agenda so every list/view
187
+ # command takes the SAME --tag/--kind/--status, with the same meaning — see
188
+ # make_node_filter). --tag is comma-separated AND.
189
+ filters = argparse.ArgumentParser(add_help=False)
190
+ filters.add_argument("-t", "--tag", help="comma-separated tags, AND filter (e.g. -t work)")
191
+ filters.add_argument("--kind", help="filter by kind (task/habit/meetlog/project/area/...)")
192
+ filters.add_argument("--status", help="filter by status (TODO/DOING/DONE/WAIT/LATER/CANCELED)")
193
+
186
194
  _real_sub = p.add_subparsers(dest="cmd", required=False)
187
195
 
188
196
  # wrap add_parser to inject user aliases (cross-shell uniform: wl d == wl day)
@@ -279,7 +287,7 @@ Differences from related commands:
279
287
  a.add_argument("--proj", help="project (stored as prop)")
280
288
  a.add_argument("--parent", type=int, help="parent node id")
281
289
  a.add_argument("--status")
282
- a.add_argument("--scheduled", help="(rough hint, writes node.scheduled_at) scheduled time: YYYY-MM-DD / YYYY-MM / YYYY-Www / YYYY-Qn / YYYY / someday / tomorrow / next-week / next-month / next-quarter")
290
+ a.add_argument("--scheduled", help="(rough hint, writes node.scheduled_date) scheduled time: YYYY-MM-DD / YYYY-MM / YYYY-Www / YYYY-Qn / YYYY / someday / tomorrow / next-week / next-month / next-quarter")
283
291
  a.add_argument("--sched", help="(precise, writes the sched table = visible as planned in `wl day` for that date) date: YYYY-MM-DD / today / yesterday / tomorrow / day-after-tomorrow")
284
292
  a.add_argument("--deadline", help="deadline date YYYY-MM-DD")
285
293
  a.add_argument("--body", help="optional body text")
@@ -337,7 +345,7 @@ Inverse of wl reopen (undo DONE back to TODO).""")
337
345
  d.add_argument("--at", help="closed_at + log use this timestamp (HH:MM / YYYY-MM-DD [HH:MM[:SS]])")
338
346
 
339
347
  df = sub.add_parser("defer",
340
- help="defer a task to a future point (LATER + scheduled_at; fuzzy times supported)",
348
+ help="defer a task to a future point (LATER + scheduled_date; fuzzy times supported)",
341
349
  formatter_class=argparse.RawDescriptionHelpFormatter,
342
350
  epilog="""\
343
351
  Common examples:
@@ -347,7 +355,7 @@ Common examples:
347
355
  wl defer 42 someday # no scheduled time
348
356
 
349
357
  Differences from wl sched:
350
- - wl defer -> status=LATER + scheduled_at field (rough hint, does NOT appear as "planned" in wl day on that day)
358
+ - wl defer -> status=LATER + scheduled_date field (rough hint, does NOT appear as "planned" in wl day on that day)
351
359
  - wl sched -> writes to sched table (precise, appears as "planned" in wl day on that day)
352
360
  To schedule it as planned for a specific day, use wl sched. defer is for "set aside, vaguely revisit later".""")
353
361
  df.add_argument("id", type=int)
@@ -358,12 +366,12 @@ To schedule it as planned for a specific day, use wl sched. defer is for "set as
358
366
  formatter_class=argparse.RawDescriptionHelpFormatter,
359
367
  epilog="""\
360
368
  Common examples:
361
- wl start 42 # start timing now (inserts CLOCK_IN log)
369
+ wl start 42 # start timing now (opens a clock interval)
362
370
  wl start 42 43 # multiple tasks at once (parallel timers)
363
371
  wl start 42 --at 09:00 # backfill 9am start (forgot to clock in)
364
372
  wl start 42 --at 2026-05-30 14:30 # full ts
365
373
 
366
- Related: close with wl stop <id>; see what's running via wl active; wl spent records a CLOCK pair from a duration.""")
374
+ Related: close with wl stop <id>; see what's running via wl active; wl spent records a clock interval from a duration.""")
367
375
  s.add_argument("ids", type=int, nargs="+", help="node id(s)")
368
376
  s.add_argument("--at", help="backfill start time: HH:MM (today) / YYYY-MM-DD / YYYY-MM-DD HH:MM[:SS]")
369
377
 
@@ -372,17 +380,17 @@ Related: close with wl stop <id>; see what's running via wl active; wl spent rec
372
380
  formatter_class=argparse.RawDescriptionHelpFormatter,
373
381
  epilog="""\
374
382
  Common examples:
375
- wl stop 42 # stop now, write CLOCK_OUT elapsed=Nmin
383
+ wl stop 42 # stop now, close the interval (elapsed=Nmin)
376
384
  wl stop 42 43 # batch stop
377
- wl stop 42 --at 11:30 # backfill 11:30 end (must be later than CLOCK_IN)
385
+ wl stop 42 --at 11:30 # backfill 11:30 end (must be later than the clock start)
378
386
  wl stop 42 --at 2026-05-30 16:00 # full ts
379
387
 
380
- Difference from wl spent: stop pairs with a prior CLOCK_IN; spent creates a pair directly from a duration.""")
388
+ Difference from wl spent: stop closes a prior open clock; spent creates a closed interval directly from a duration.""")
381
389
  st.add_argument("ids", type=int, nargs="+", help="node id(s)")
382
- st.add_argument("--at", help="backfill end time (must be later than CLOCK_IN)")
390
+ st.add_argument("--at", help="backfill end time (must be later than the clock start)")
383
391
 
384
392
  sp = sub.add_parser("spent",
385
- help="record a past time spent (build CLOCK pair from duration, good for retrospective entries)",
393
+ help="record a past time spent (build a clock interval from a duration, good for retrospective entries)",
386
394
  formatter_class=argparse.RawDescriptionHelpFormatter,
387
395
  epilog="""\
388
396
  Common examples:
@@ -392,14 +400,14 @@ Common examples:
392
400
  wl spent 42 2h # 2 hours
393
401
  wl spent 42 30m --at 14:30 # end at 14:30, backfill start at 14:00
394
402
 
395
- Difference from wl start/stop: spent builds CLOCK_IN+OUT pair from a duration in one step; no need to start first. Good for "forgot to clock, recording it after the fact".""")
403
+ Difference from wl start/stop: spent builds a closed clock interval from a duration in one step; no need to start first. Good for "forgot to clock, recording it after the fact".""")
396
404
  sp.add_argument("id", type=int, help="node id")
397
405
  sp.add_argument("duration", help="duration: 90 / 90m / 1h30m / 2h")
398
406
  sp.add_argument("--at", help="end timestamp (default NOW); start = at - duration")
399
407
 
400
408
  ac = sub.add_parser("active",
401
- help="tasks running right now (open CLOCK_IN) + today's elapsed + latest log",
402
- description="List tasks that are timing right now (open CLOCK_IN). Shows current session elapsed, today's total, and the most recent log. Good for live focus check and finding tasks you forgot to stop.",
409
+ help="tasks running right now (open clock) + today's elapsed + latest log",
410
+ description="List tasks that are timing right now (an open clock interval). Shows current session elapsed, today's total, and the most recent log. Good for live focus check and finding tasks you forgot to stop.",
403
411
  formatter_class=argparse.RawDescriptionHelpFormatter,
404
412
  epilog="""\
405
413
  Use cases:
@@ -409,14 +417,14 @@ Use cases:
409
417
 
410
418
  Difference from wl day:
411
419
  - wl day = full progress for the day (includes done / not-yet-started planned items), for end-of-day review
412
- - wl active = what's timing right now (open CLOCK_IN), for live focus check
420
+ - wl active = what's timing right now (open clock), for live focus check
413
421
 
414
422
  Output includes: current session elapsed + today's total (to decide stop or continue) + latest log (context).
415
423
  Brief mode -q: id + elapsed only. Full log body: --log-format full.""")
416
424
  # ac has no other flags but we keep the variable for future args (e.g. --since to look at past activity)
417
425
 
418
426
  wa = sub.add_parser("wait",
419
- help="mark WAIT (blocked on others / external input); auto-closes CLOCK; multiple ids",
427
+ help="mark WAIT (blocked on others / external input); auto-closes the clock; multiple ids",
420
428
  formatter_class=argparse.RawDescriptionHelpFormatter,
421
429
  epilog="""\
422
430
  Common examples:
@@ -424,7 +432,7 @@ Common examples:
424
432
  wl wait 42 --note "waiting on review" # add a log explaining what we're waiting on
425
433
  wl wait 42 43 --note "waiting on approval" # batch
426
434
 
427
- Note: marking WAIT auto-closes any open CLOCK_IN (WAIT = suspended, no longer timing). Use wl reopen to revert to TODO.""")
435
+ Note: marking WAIT auto-closes any open clock (WAIT = suspended, no longer timing). Use wl reopen to revert to TODO.""")
428
436
  wa.add_argument("ids", type=int, nargs="+", help="node id(s)")
429
437
  wa.add_argument("--note", help="add a log explaining what you're waiting on")
430
438
 
@@ -537,7 +545,8 @@ Differences from related commands:
537
545
  sh.add_argument("--all-timelines", action="store_true",
538
546
  help="full timeline, no elision")
539
547
 
540
- ls = sub.add_parser("ls", help="list nodes (default limit 20; see shell ls -t / -S / -r-style dimensions)",
548
+ ls = sub.add_parser("ls", parents=[filters],
549
+ help="list nodes (default limit 20; see shell ls -t / -S / -r-style dimensions)",
541
550
  formatter_class=argparse.RawDescriptionHelpFormatter,
542
551
  epilog="""\
543
552
  Common examples (precise queries, shell-ls multi-dimensional):
@@ -553,9 +562,6 @@ Common examples (precise queries, shell-ls multi-dimensional):
553
562
  wl ls --all remove the 20-row limit + include DONE/CANCELED
554
563
 
555
564
  See also: wl find <q> / wl day / wl active / wl projects (each has a dedicated entry point sharper than ls)""")
556
- ls.add_argument("--kind", help="filter by kind (task/habit/meetlog/project/area/...)")
557
- ls.add_argument("--status", help="filter by status (TODO/DOING/DONE/WAIT/LATER/CANCELED)")
558
- ls.add_argument("--tag", help="comma-separated tags, AND filter")
559
565
  ls.add_argument("--parent", type=int, help="only direct children of this node")
560
566
  ls.add_argument("--all", action="store_true", help="include DONE/CANCELED + remove the limit cap")
561
567
  ls.add_argument("--limit", type=int, metavar="N",
@@ -574,7 +580,7 @@ See also: wl find <q> / wl day / wl active / wl projects (each has a dedicated e
574
580
  ls.add_argument("--ids", type=int, nargs="+", metavar="id",
575
581
  help="list specific ids directly, skipping filters (like shell `ls file1 file2`)")
576
582
 
577
- tr = sub.add_parser("tree",
583
+ tr = sub.add_parser("tree", parents=[filters],
578
584
  help="tree view of nodes (default: timeline up to today + areas one level, ~30 rows)",
579
585
  description="Tree view of nodes. Default: timeline expanded up to today (year -> quarter -> month -> week -> today + today's tasks) + areas one level, ~30 rows to avoid scrolling. Use --root <id> to drill into a node.",
580
586
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -593,7 +599,6 @@ Differences from related commands:
593
599
  - wl day log-date-driven view of a single day (not tied to tree)
594
600
  - wl projects list projects as cards (subtask counts, no tree expansion)
595
601
  - wl ls --parent <N> flat list of direct children, no recursion""")
596
- tr.add_argument("--kind")
597
602
  tr.add_argument("--proj")
598
603
  tr.add_argument("--root", type=int, help="start tree from this node id")
599
604
  tr.add_argument("--by", choices=["project", "tag", "direction"], help="regroup by dimension (flat 2-level)")
@@ -632,7 +637,7 @@ Related: wl show is self + timeline only; wl ancestors/descendants only go one d
632
637
  de.add_argument("id", type=int)
633
638
  de.add_argument("--depth", type=int, help="max depth")
634
639
 
635
- ag = sub.add_parser("agenda",
640
+ ag = sub.add_parser("agenda", parents=[filters],
636
641
  help="cross-time-range scheduling overview: everything scheduled in [start, end]",
637
642
  formatter_class=argparse.RawDescriptionHelpFormatter,
638
643
  epilog="""\
@@ -714,15 +719,16 @@ Dedup: by default a task appearing in multiple projects (via parent + shared tag
714
719
  sm.add_argument("--no-dedup", action="store_true",
715
720
  help="no dedup: a task across multiple projects is repeated in each bucket (old behavior)")
716
721
 
717
- dy = sub.add_parser("day",
722
+ dy = sub.add_parser("day", parents=[filters],
718
723
  help="full view of a day (default today): bucket -> project/plan -> task -> log",
719
- description="Full view of one day: work/personal/other -> (planned/unplanned/project/priority) -> task -> indented logs. Top shows end-of-day summary + today's goal + Top5 (if set). Defaults to log-date-driven (works for past days too).",
724
+ description="Full view of one day: work/personal/other -> (planned/unplanned/project/priority) -> task -> indented logs. The header states the day's nature (workday / weekend, refined to holiday / leave / makeup by a `wl dateinfo` label). Top shows end-of-day summary + today's goal + Top5 (if set). Defaults to log-date-driven (works for past days too).",
720
725
  formatter_class=argparse.RawDescriptionHelpFormatter,
721
726
  epilog="""\
722
727
  Common examples:
723
728
  wl day # today
724
729
  wl day 2026-05-30 # historical day
725
730
  wl day yesterday # short form (yesterday / day-before-yesterday / tomorrow / day-after-tomorrow)
731
+ wl day -t work # only work items (filter; -t/--tag, AND); -t personal for personal
726
732
  wl day --by project # change grouping (default plan: planned/unplanned)
727
733
  wl day --by priority # group by P0/P1/P2
728
734
  wl day --log-tail 1 # logs default to last 3, narrow to 1
@@ -770,7 +776,7 @@ Common examples:
770
776
  wl recap --date yesterday # read yesterday's recap
771
777
 
772
778
  wl day shows "Recap: ... (written at MM-DD HH:MM)" at the top;
773
- if there are new non-CLOCK logs after recap, wl day shows "⚠ N changes after recap, consider rewriting".
779
+ if there are new logs after recap, wl day shows "⚠ N changes after recap, consider rewriting".
774
780
  Using wl set <day_id> summary "..." directly does not stamp the timestamp; not recommended.""")
775
781
  rc.add_argument("text", nargs="?", help="no arg = read; with text = write")
776
782
  rc.add_argument("--date", help="target day (YYYY-MM-DD / today / yesterday / 昨天 ...); default today")
@@ -804,20 +810,20 @@ Common examples:
804
810
  wl unlog #L282 # exact delete by log id
805
811
  wl unlog L282 # same (# optional)
806
812
  wl unlog 282 # same (plain number)
807
- wl unlog --node 39 # delete the latest non-CLOCK log for #39 today
813
+ wl unlog --node 39 # delete the latest log for #39 today
808
814
  wl unlog --node 39 --date yesterday # latest log that day
809
- wl unlog --node 39 --all # delete all non-CLOCK logs for #39 that day
815
+ wl unlog --node 39 --all # delete all logs for #39 that day
810
816
 
811
817
  Find a log id: wl show <node_id> or wl logs --id <node_id> displays #L<id> in the timeline.
812
- CLOCK_IN/OUT logs cannot be deleted (would break timing pairs). Edit a mistyped log with wl relog #L<id> instead.""")
818
+ Edit a mistyped log with wl relog #L<id> instead. (Timing lives in the clock table, not logs — fix a clock with wl stop --at.)""")
813
819
  ul.add_argument("log_id", type=_log_id_arg, nargs="?",
814
820
  help="log id (e.g. #L282 / L282 / 282; from wl show / wl logs timeline)")
815
- ul.add_argument("--node", type=int, help="delete by node id (default: latest non-CLOCK log today)")
821
+ ul.add_argument("--node", type=int, help="delete by node id (default: latest log today)")
816
822
  ul.add_argument("--date", help="with --node: delete logs from that day (default today)")
817
- ul.add_argument("--all", action="store_true", help="with --node: delete all non-CLOCK logs for that node that day")
823
+ ul.add_argument("--all", action="store_true", help="with --node: delete all logs for that node that day")
818
824
 
819
825
  rl = sub.add_parser("relog",
820
- help="rewrite a log: new body / new time / editor (CLOCK not accepted)",
826
+ help="rewrite a log: new body / new time / editor",
821
827
  formatter_class=argparse.RawDescriptionHelpFormatter,
822
828
  epilog="""\
823
829
  Common examples:
@@ -827,7 +833,7 @@ Common examples:
827
833
  wl relog #L282 --at 2026-05-30 # only change date (keep time)
828
834
  wl relog #L282 # no body/--at -> open $EDITOR
829
835
 
830
- CLOCK_IN/OUT logs cannot be edited (would break timing pairs); use wl stop --at to fix CLOCK times.
836
+ Timing lives in the clock table, not logs to fix a clock interval use wl stop --at.
831
837
  Cannot move a log across nodes (that's unlog + log).""")
832
838
  rl.add_argument("log_id", type=_log_id_arg,
833
839
  help="log id (#L282 / L282 / 282; from wl show / wl logs)")
@@ -1018,7 +1024,7 @@ Before writing a new task / log, run wl find to check if there's an existing nod
1018
1024
  fd.add_argument("--limit", type=int, metavar="N", help="show only the first N (default 20; use 0 or --all for no cap)")
1019
1025
  fd.add_argument("--all", action="store_true", help="no row limit")
1020
1026
 
1021
- lg = sub.add_parser("logs", parents=[window],
1027
+ lg = sub.add_parser("logs", parents=[window, filters],
1022
1028
  help="list log entries (default last 7 days; preset today/yesterday/week/recent)",
1023
1029
  formatter_class=argparse.RawDescriptionHelpFormatter,
1024
1030
  epilog="""\
@@ -11,6 +11,8 @@ from datetime import datetime, timedelta
11
11
  from pathlib import Path
12
12
 
13
13
  from .. import render
14
+ from .. import db_table as _db
15
+ from .. import timeutil as _tu
14
16
  from ..helpers import (
15
17
  _apply_top_limit,
16
18
  _fmt_dur,
@@ -181,27 +183,23 @@ def cmd_apply(args, con):
181
183
  # recursive subtree delete: node self-ref is ON DELETE SET NULL, only deleting the parent would orphan children, so we must explicitly collect descendants
182
184
  ids = [f["id"]] + _collect_descendants(con, f["id"])
183
185
  for did in ids:
184
- con.execute("DELETE FROM node WHERE id = ?", (did,))
186
+ _db.delete(con, "node", id=did)
185
187
  counts["delete"] += len(ids)
186
188
  continue
187
189
  # pfx == "+": add new node
188
190
  parent_id = stack.get(depth - 1) if depth > 0 else None
189
191
  status = _MARKER_STATUS.get(f.get("marker", " "), "TODO")
190
192
  kind = f.get("kind", "task")
193
+ now = _tu.utc_now()
194
+ node_row = {
195
+ "parent_id": parent_id, "title": f["title"], "kind": kind,
196
+ "status": status, "priority": f.get("priority"), "created_at": now,
197
+ }
191
198
  if status == "DONE":
192
- cur = con.execute(
193
- "INSERT INTO node (parent_id,title,kind,status,priority,closed_at) "
194
- "VALUES (?,?,?,?,?, datetime('now','localtime'))",
195
- (parent_id, f["title"], kind, status, f.get("priority")),
196
- )
197
- else:
198
- cur = con.execute(
199
- "INSERT INTO node (parent_id,title,kind,status,priority) VALUES (?,?,?,?,?)",
200
- (parent_id, f["title"], kind, status, f.get("priority")),
201
- )
202
- nid = cur.lastrowid
199
+ node_row["closed_at"] = now
200
+ nid = _db.insert(con, "node", node_row)
203
201
  for t in f.get("tags", []):
204
- con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, t))
202
+ _db.insert(con, "tag", {"node_id": nid, "tag": t}, or_="ignore")
205
203
  for kind_, val in o["subs"]:
206
204
  _apply_sub(con, nid, kind_, val)
207
205
  counts["add"] += 1
@@ -229,48 +227,40 @@ def _import_node(con, spec, parent_id, ref_map, dry, counters):
229
227
  if spec["parent_ref"] not in ref_map:
230
228
  raise ValueError(f"parent_ref '{spec['parent_ref']}' undefined (must appear before reference)")
231
229
  pid = ref_map[spec["parent_ref"]]
232
- closed_at = None
233
- if status == "DONE":
234
- closed_at = "datetime_now" # placeholder; the SQL below uses datetime('now','localtime')
235
-
236
230
  if dry:
237
231
  nid = f"<ref:{spec.get('ref', '?')}>"
238
232
  counters["add"] += 1
239
233
  else:
240
- if closed_at:
241
- cur = con.execute(
242
- "INSERT INTO node (parent_id,title,kind,status,priority,scheduled_at,deadline_at,body,closed_at) "
243
- "VALUES (?,?,?,?,?,?,?,?, datetime('now','localtime'))",
244
- (pid, title, kind, status, spec.get("priority"), sched,
245
- spec.get("deadline"), spec.get("body")),
246
- )
247
- else:
248
- cur = con.execute(
249
- "INSERT INTO node (parent_id,title,kind,status,priority,scheduled_at,deadline_at,body) "
250
- "VALUES (?,?,?,?,?,?,?,?)",
251
- (pid, title, kind, status, spec.get("priority"), sched,
252
- spec.get("deadline"), spec.get("body")),
253
- )
254
- nid = cur.lastrowid
234
+ now = _tu.utc_now()
235
+ node_row = {
236
+ "parent_id": pid, "title": title, "kind": kind, "status": status,
237
+ "priority": spec.get("priority"), "scheduled_date": sched,
238
+ "deadline_date": spec.get("deadline"), "body": spec.get("body"),
239
+ "created_at": now,
240
+ }
241
+ if status == "DONE":
242
+ node_row["closed_at"] = now
243
+ nid = _db.insert(con, "node", node_row)
255
244
  counters["add"] += 1
256
245
  for t in spec.get("tags", []):
257
- con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, t))
246
+ _db.insert(con, "tag", {"node_id": nid, "tag": t}, or_="ignore")
258
247
  for k, v in (spec.get("props") or {}).items():
259
248
  _upsert_prop(con, nid, k, str(v))
260
249
  for d in spec.get("links", []):
261
- con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, d))
250
+ _db.insert(con, "link", {"node_id": nid, "vault_doc": d}, or_="ignore")
262
251
  for entry in spec.get("logs", []):
263
252
  _insert_log(con, nid, entry)
264
253
  # a log entry may carry structured datapoints: {"body":..., "metrics":[{tag,value,unit}]}
265
254
  if isinstance(entry, dict) and entry.get("metrics"):
266
255
  log_id = con.execute("SELECT last_insert_rowid()").fetchone()[0]
267
- log_at = con.execute("SELECT logged_at FROM log WHERE id = ?", (log_id,)).fetchone()["logged_at"]
256
+ log_at = _db.get(con, "log", log_id)["logged_at"]
268
257
  for mspec in entry["metrics"]:
269
258
  import_metric(con, log_id, nid, mspec, default_at=log_at)
270
259
  # node-level metrics → a dedicated carrier log (1 carrier → N datapoints, e.g. a CGM import)
271
260
  if spec.get("metrics"):
272
- con.execute("INSERT INTO log (node_id, body, tag) VALUES (?, '', ?)", (nid, _CARRIER_TYPE))
273
- log_id = con.execute("SELECT last_insert_rowid()").fetchone()[0]
261
+ log_id = _db.insert(con, "log", {
262
+ "node_id": nid, "logged_at": _tu.utc_now(), "body": "", "tag": _CARRIER_TYPE,
263
+ })
274
264
  for mspec in spec["metrics"]:
275
265
  import_metric(con, log_id, nid, mspec)
276
266
  counters["metric"] = counters.get("metric", 0) + len(spec["metrics"])
@@ -299,24 +289,20 @@ def _import_update(con, spec, dry, counters):
299
289
  return
300
290
  if "parent" in spec and spec["parent"] is not None and not _node_exists(con, spec["parent"]):
301
291
  raise ValueError(f"update #{nid}: parent #{spec['parent']} does not exist")
302
- fields, vals = [], []
303
- for col in ("status", "priority", "title", "scheduled_at", "deadline_at", "body"):
304
- if col in spec:
305
- fields.append(f"{col} = ?")
306
- vals.append(spec[col])
307
- if "parent" in spec: # move; parent_id column name differs from spec key, handled separately
308
- fields.append("parent_id = ?")
309
- vals.append(spec["parent"])
292
+ changes = {col: spec[col] for col in
293
+ ("status", "priority", "title", "scheduled_date", "deadline_date", "body") if col in spec}
294
+ if "parent" in spec: # move; spec key 'parent' maps to the parent_id column
295
+ changes["parent_id"] = spec["parent"]
310
296
  if spec.get("status") == "DONE" and "closed_at" not in spec:
311
- fields.append("closed_at = datetime('now','localtime')")
312
- if fields:
313
- con.execute(f"UPDATE node SET {', '.join(fields)} WHERE id = ?", (*vals, nid))
297
+ changes["closed_at"] = _tu.utc_now()
298
+ if changes:
299
+ _db.update(con, "node", nid, changes)
314
300
  for t in spec.get("add_tags", []):
315
- con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, t))
301
+ _db.insert(con, "tag", {"node_id": nid, "tag": t}, or_="ignore")
316
302
  for t in spec.get("remove_tags", []):
317
- con.execute("DELETE FROM tag WHERE node_id = ? AND tag = ?", (nid, t))
303
+ _db.delete(con, "tag", node_id=nid, tag=t)
318
304
  for d in spec.get("add_links", []):
319
- con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, d))
305
+ _db.insert(con, "link", {"node_id": nid, "vault_doc": d}, or_="ignore")
320
306
  for entry in spec.get("add_logs", []):
321
307
  _insert_log(con, nid, entry)
322
308
  counters["update"] += 1
@@ -460,7 +446,7 @@ def _parse_wld(text):
460
446
 
461
447
  _STATUSES = {"TODO", "DOING", "LATER", "WAIT", "DONE", "DEFERRED", "CANCELED"}
462
448
  _SET_COL = {"status": "status", "priority": "priority", "title": "title",
463
- "parent": "parent_id", "scheduled": "scheduled_at", "deadline": "deadline_at"}
449
+ "parent": "parent_id", "scheduled": "scheduled_date", "deadline": "deadline_date"}
464
450
 
465
451
  def _validate_fieldop(con, lineno, action, field, value, errs):
466
452
  if field == "status" and action == "set" and value not in _STATUSES:
@@ -485,7 +471,7 @@ def _exec_update(con, o):
485
471
  if field in _SET_COL:
486
472
  col = _SET_COL[field]
487
473
  if action == "clear":
488
- con.execute(f"UPDATE node SET {col} = NULL WHERE id = ?", (nid,))
474
+ _db.update(con, "node", nid, {col: None})
489
475
  else:
490
476
  if field == "parent":
491
477
  v = int(value)
@@ -493,27 +479,27 @@ def _exec_update(con, o):
493
479
  v = _norm_sched(value)
494
480
  else:
495
481
  v = value
496
- con.execute(f"UPDATE node SET {col} = ? WHERE id = ?", (v, nid))
482
+ _db.update(con, "node", nid, {col: v})
497
483
  if field == "status" and value == "DONE":
498
- con.execute("UPDATE node SET closed_at = datetime('now','localtime') WHERE id = ? AND closed_at IS NULL", (nid,))
484
+ con.execute("UPDATE node SET closed_at = ? WHERE id = ? AND closed_at IS NULL", (_tu.utc_now(), nid))
499
485
  elif field == "tag":
500
486
  if action == "add":
501
- con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, value))
487
+ _db.insert(con, "tag", {"node_id": nid, "tag": value}, or_="ignore")
502
488
  else:
503
- con.execute("DELETE FROM tag WHERE node_id = ? AND tag = ?", (nid, value))
489
+ _db.delete(con, "tag", node_id=nid, tag=value)
504
490
  elif field == "log":
505
491
  _insert_log(con, nid, value)
506
492
  elif field == "link":
507
493
  if action == "add":
508
- con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, value))
494
+ _db.insert(con, "link", {"node_id": nid, "vault_doc": value}, or_="ignore")
509
495
  else:
510
- con.execute("DELETE FROM link WHERE node_id = ? AND vault_doc = ?", (nid, value))
496
+ _db.delete(con, "link", node_id=nid, vault_doc=value)
511
497
  elif field == "prop":
512
498
  if action == "set":
513
499
  k, v = value
514
500
  _upsert_prop(con, nid, k, v)
515
501
  else:
516
- con.execute("DELETE FROM prop WHERE node_id = ? AND key = ?", (nid, value))
502
+ _db.delete(con, "prop", node_id=nid, key=value)
517
503
 
518
504
  def _fieldop_desc(action, field, value):
519
505
  if action == "clear":
@@ -530,7 +516,7 @@ def _apply_sub(con, nid, kind, val):
530
516
  if kind == "log":
531
517
  _insert_log(con, nid, val)
532
518
  elif kind == "link":
533
- con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, val))
519
+ _db.insert(con, "link", {"node_id": nid, "vault_doc": val}, or_="ignore")
534
520
  elif kind == "prop":
535
521
  if "=" in val:
536
522
  k, v = val.split("=", 1)