taskai-cli 1.6.3__tar.gz → 1.6.5__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.
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/DEVLOG.md +40 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/DEVPLAN.md +84 -24
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/PKG-INFO +1 -1
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/getting-started.md +4 -2
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/item-data.md +6 -3
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/pyproject.toml +1 -1
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/cli.py +20 -2
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/help_menu.py +5 -2
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/static/canvas.js +96 -5
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/static/console.js +73 -2
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/static/editpanel.js +10 -1
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/.github/workflows/publish-to-pypi.yml +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/.gitignore +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/.readthedocs.yaml +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/README.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/TRIAGE.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/.gitignore +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/_static/.gitkeep +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/ai-integration.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/browser-mode.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/commands.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/conf.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/configuration.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/index.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/interactive-mode.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/managing-the-tree.md +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/docs/requirements.txt +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/scripts/bump_version.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/browser.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/config.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/errors.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/json_dir_database.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/llm_models.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/models.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/services/ai.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/services/pomodoro.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/services/repair_database.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/services/user_setup.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/static/index.html +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/static/style.css +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/taskai/views.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/test/test_cli.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/test/test_execution.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/test/test_json_dir_database.py +0 -0
- {taskai_cli-1.6.3 → taskai_cli-1.6.5}/test/test_view.py +0 -0
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
# 9-3
|
|
2
|
+
|
|
3
|
+
Browser session — console-driven navigation and selection, mostly beyond the
|
|
4
|
+
original Phase 1 scope (DEVPLAN 1.7 has the durable version):
|
|
5
|
+
|
|
6
|
+
- Synthetic invisible root node (`canvas.js`, `rootNode` / `"__root__"`) —
|
|
7
|
+
never drawn or hit-tested, sits above the real roots as the nav anchor and
|
|
8
|
+
the "whole tree / nothing specific" selection. `selectedNode` is now never
|
|
9
|
+
null: defaults to `rootNode`, and an empty-canvas click selects it.
|
|
10
|
+
- `show all` / bare `show` — client-side console command: refetch `/api/tree`,
|
|
11
|
+
select the root, `fitAll()` (eases the view out until the whole forest fits
|
|
12
|
+
with padding). `show <id|name>` selects + focuses the resolved node without
|
|
13
|
+
opening the edit panel (that's `edit <id|name>`).
|
|
14
|
+
- Tree navigation — `navigate(direction)` + `up`/`down`/`left`/`right` console
|
|
15
|
+
commands. Nodes got a `parent` back-ref; walks up/down/across siblings,
|
|
16
|
+
wraps on both axes. Each move acts like `show <target>`.
|
|
17
|
+
- `.` as a standalone console token → selected node's id (`resolveDotRefs`),
|
|
18
|
+
so `update . --priority 3`, `done .`, `edit .` work. Leaves `a.md` / `3.5`
|
|
19
|
+
alone; errors if nothing real is selected.
|
|
20
|
+
- `hide edit` console command → `closeEditPanel()`, complements `edit <id>`.
|
|
21
|
+
- Up-arrow console history (was the last open Phase 1.6 item) — done earlier
|
|
22
|
+
(`commandHistory` in `console.js`), just now checked off in DEVPLAN.
|
|
23
|
+
|
|
24
|
+
CLI (not browser): added `task undone <id>`, and made recursion on
|
|
25
|
+
`complete`/`done` an opt-in `-r` / `-recursive` flag instead of always-on.
|
|
26
|
+
Resynced the docs for it — `help_menu.py` (`help_general` + `help_examples`,
|
|
27
|
+
which is also the AI's command reference) and the hand-written mentions in
|
|
28
|
+
`docs/getting-started.md` / `docs/item-data.md`. `docs/commands.md` is a
|
|
29
|
+
`literalinclude` of generated help text, so it tracks automatically.
|
|
30
|
+
|
|
31
|
+
Fixed the long-standing `update_item` quirk: it now calls
|
|
32
|
+
`_parse_item_kwargs` before `db.update_item` (like `create_item` does), so
|
|
33
|
+
`update <id> --due_by MM-DD-YYYY` no longer throws. Extended
|
|
34
|
+
`_parse_item_kwargs` in the same pass — maps `depends_on` (`"1,2,3"`) to
|
|
35
|
+
`dependency_ids`, and guarded the `due_by` branch with `isinstance(v, str)`
|
|
36
|
+
so it's idempotent for the recursive re-parse. Edit panel's Due by /
|
|
37
|
+
Depends on fields round-trip now.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
1
41
|
# 8-29
|
|
2
42
|
|
|
3
43
|
Worked the TRIAGE backlog, then built out the docs.
|
|
@@ -45,9 +45,11 @@ testing — don't `nuke` it.
|
|
|
45
45
|
(methods intentionally take no `self` — called as `Controller.foo(...)`, not
|
|
46
46
|
instantiated) holds all the CRUD/business logic. `execute_commands(*args,
|
|
47
47
|
**kwargs)` is one big `match` statement mapping subcommands (`show`,
|
|
48
|
-
`create`, `add`, `update`, `move`, `rename`, `delete`,
|
|
49
|
-
`
|
|
50
|
-
`pomo`, `repair`, `nuke`, `browser`, ...) to
|
|
48
|
+
`create`, `add`, `update`, `move`, `rename`, `delete`,
|
|
49
|
+
`complete`/`done`/`undone`, `comment`, `link`, `reorder`, `clear`,
|
|
50
|
+
`status`, `config`, `ai`, `pomo`, `repair`, `nuke`, `browser`, ...) to
|
|
51
|
+
`Controller` calls. `complete`/`done`/`undone` take an opt-in `-r` /
|
|
52
|
+
`-recursive` flag to apply down the subtree. There's a
|
|
51
53
|
hand-rolled arg/kwarg parser (`_parse_arg_string`, `_parse_remaining`)
|
|
52
54
|
instead of `argparse`. `interactive_program()` is the REPL entered when
|
|
53
55
|
`task` runs with no arguments. The module connects to the database and loads
|
|
@@ -96,17 +98,20 @@ unless you're told to fix them):**
|
|
|
96
98
|
Removed rather than wired up — `Controller.show_items` and `view_items`
|
|
97
99
|
are gone (CLI audit, see DEVLOG); `task show` only ever takes one target
|
|
98
100
|
now.
|
|
99
|
-
-
|
|
100
|
-
(`create_item` does) before handing kwargs to `db.update_item
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`
|
|
101
|
+
- ~~`Controller.update_item` never calls `Controller._parse_item_kwargs`
|
|
102
|
+
(`create_item` does) before handing kwargs to `db.update_item`, so
|
|
103
|
+
`update <id> --due_by MM-DD-YYYY` threw a pydantic validation error and
|
|
104
|
+
`update <id> --depends_on 1,2` silently no-op'd.~~ **Fixed.**
|
|
105
|
+
`update_item` now runs `kwargs = Controller._parse_item_kwargs(kwargs)`
|
|
106
|
+
before `db.update_item`, same as `create_item`. `_parse_item_kwargs` was
|
|
107
|
+
extended in the same pass: it now also maps the CLI-facing `depends_on`
|
|
108
|
+
(a comma-separated id string, `"1,2,3"`) to the model's real
|
|
109
|
+
`dependency_ids: list[int]` — so `create --depends_on ...` works now too,
|
|
110
|
+
not just `update` — and its `due_by` branch is guarded with
|
|
111
|
+
`isinstance(v, str)` so the helper is idempotent (safe for the
|
|
112
|
+
already-parsed kwargs `update_item`'s `recursive=True` branch passes back
|
|
113
|
+
down). Edit panel's **Due by** and **Depends on** fields now round-trip
|
|
114
|
+
end to end.
|
|
110
115
|
- **Sync is one-directional: CLI → browser only, not the reverse.**
|
|
111
116
|
`GET /api/tree` (`browser.py`) reloads from disk (`db.flush()`) on every
|
|
112
117
|
call, and `canvas.js` refetches on window focus, so browser-side views
|
|
@@ -286,6 +291,8 @@ stdlib `http.server` — no new dependency, consistent with "less code."
|
|
|
286
291
|
break after any mutation). Drawn with the same accent-blue
|
|
287
292
|
border/thicker stroke as hover. This is exactly the state 1.5's edit
|
|
288
293
|
menu piggybacks off — not edge highlighting yet, just the node.
|
|
294
|
+
(Later, in 1.7: `selectedNode` became **never-null** — an empty
|
|
295
|
+
selection is now the synthetic `rootNode`, not `null`.)
|
|
289
296
|
- [x] Color/style nodes by `completed` — soft green fill + border (see
|
|
290
297
|
`STYLE.colors.nodeFill/BorderDone`), not strike-through/dim like the
|
|
291
298
|
CLI. `status` ended up as a small orange text label in the node's
|
|
@@ -334,9 +341,9 @@ add new visual knobs there, not inline in `draw()`.
|
|
|
334
341
|
|
|
335
342
|
While wiring this up, confirmed against a throwaway test item (created and
|
|
336
343
|
deleted, not real data): `name`/`description`/`priority`/`completed` all
|
|
337
|
-
update correctly end to end. `due_by` and `depends_on` hit the
|
|
338
|
-
`update_item`/`_parse_item_kwargs` bug documented above —
|
|
339
|
-
|
|
344
|
+
update correctly end to end. `due_by` and `depends_on` originally hit the
|
|
345
|
+
`update_item`/`_parse_item_kwargs` bug documented above — **now fixed** (see
|
|
346
|
+
that entry), so all seven fields round-trip.
|
|
340
347
|
|
|
341
348
|
### 1.6 — Console
|
|
342
349
|
|
|
@@ -351,13 +358,58 @@ purpose, current call.
|
|
|
351
358
|
response already carries the fresh tree. Also handles the `focus`
|
|
352
359
|
field `show` commands return: calls `focusOnNode` on that id instead
|
|
353
360
|
of expecting text output.
|
|
354
|
-
- [
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
+
- [x] Up-arrow history — done (`console.js`). `commandHistory` array, every
|
|
362
|
+
submitted line pushed on; `ArrowUp`/`ArrowDown` walk it, an index at
|
|
363
|
+
`length` meaning "not currently browsing history", and `ArrowDown`
|
|
364
|
+
past the end restores an empty input.
|
|
365
|
+
|
|
366
|
+
At the end of Phase 1: the whole 1.1–1.6 checklist is done
|
|
367
|
+
(dependency/link edges (1.4) deferred to 2.0, see above). The DAG, edit
|
|
368
|
+
panel, and console all funnel through one `/api/command` endpoint and one
|
|
369
|
+
`/api/tree` read, as planned — that's the "less code" payoff of routing
|
|
370
|
+
everything through the existing `Controller`. Console-driven canvas
|
|
371
|
+
navigation then grew past the original Phase 1 scope — see 1.7.
|
|
372
|
+
|
|
373
|
+
### 1.7 — Console-driven navigation & selection (post-plan, beyond original scope)
|
|
374
|
+
|
|
375
|
+
Grew out of Phase 1's console + selection machinery; none of this was in
|
|
376
|
+
the original checklist.
|
|
377
|
+
|
|
378
|
+
- [x] **Synthetic invisible root node** (`canvas.js`, `rootNode` /
|
|
379
|
+
`"__root__"`). Never added to `nodes`, so never drawn, hit-tested, or
|
|
380
|
+
bordered. It's the nav anchor above the real root items and the
|
|
381
|
+
"nothing specific / the whole tree" selection. `selectedNode` is now
|
|
382
|
+
**never null** — it defaults to `rootNode`, and a click on empty
|
|
383
|
+
canvas selects `rootNode` rather than clearing the selection.
|
|
384
|
+
`applyTree` re-parents it over the freshly-built roots and parks it
|
|
385
|
+
one `ySpacing` above them on every refresh.
|
|
386
|
+
- [x] **`show all` / bare `show`** (console, client-side). Refetches
|
|
387
|
+
`/api/tree`, selects the synthetic root, and calls `fitAll()` — eases
|
|
388
|
+
the view out until every real node fits on screen with padding,
|
|
389
|
+
clamped to `STYLE.zoom` bounds.
|
|
390
|
+
- [x] **`show <id|name>`** (console, client-side). POSTs the command,
|
|
391
|
+
applies the returned tree, then selects + `focusOnNode`s the resolved
|
|
392
|
+
node. Deliberately does *not* open the edit panel — that's
|
|
393
|
+
`edit <id|name>`, mirroring the canvas's single-click vs. double-click
|
|
394
|
+
split.
|
|
395
|
+
- [x] **Tree navigation** — `navigate(direction)` in `canvas.js`, wired to
|
|
396
|
+
`up` / `down` / `left` / `right` console commands. Nodes carry a
|
|
397
|
+
`parent` back-reference (set in `buildTree` / `applyTree`) so the walk
|
|
398
|
+
goes up as well as down: `down` → first child, `up` → parent,
|
|
399
|
+
`left`/`right` → previous/next sibling. Wraps on both axes — past the
|
|
400
|
+
last sibling loops to the first; `down` past the deepest leaf and `up`
|
|
401
|
+
from `rootNode` loop between the top and the deepest first-child leaf.
|
|
402
|
+
Each move behaves like a `show <target>` (select + ease/zoom; `fitAll`
|
|
403
|
+
when the target is `rootNode`).
|
|
404
|
+
- [x] **`.` = the selected node** (`resolveDotRefs` in `console.js`). A bare
|
|
405
|
+
`.` token in any console command is swapped for `selectedNode.id`
|
|
406
|
+
before dispatch, so `update . --priority 3`, `done .`, `edit .` act on
|
|
407
|
+
the current selection. Only matches a standalone `.` (whitespace or
|
|
408
|
+
string end on both sides), so `a.md` / `3.5` are untouched; errors if
|
|
409
|
+
`.` is used with nothing real selected.
|
|
410
|
+
- [x] **`hide edit`** (console) → `closeEditPanel()` in `editpanel.js`,
|
|
411
|
+
collapsing the right panel and restoring canvas width. Complements the
|
|
412
|
+
existing `edit <id|name>` that opens it.
|
|
361
413
|
|
|
362
414
|
---
|
|
363
415
|
|
|
@@ -544,6 +596,14 @@ Re-scope this section before starting 2.4.
|
|
|
544
596
|
`html_baseurl` + README); explanation/architecture pages; polish
|
|
545
597
|
(screenshots for browser-mode, custom 404, OpenGraph cards, CI
|
|
546
598
|
`linkcheck`, tested examples, changelog).
|
|
599
|
+
- [x] **`help_menu.py` drift from `execute_commands`.** The CLI gained
|
|
600
|
+
`task undone <id>` and an opt-in `-r` / `-recursive` flag on
|
|
601
|
+
`complete` / `done` (recursion used to be unconditional). Resynced:
|
|
602
|
+
`help_general` and `help_examples` in `help_menu.py` (also the AI's
|
|
603
|
+
verbatim command reference), plus the two hand-written mentions in
|
|
604
|
+
`docs/getting-started.md` and `docs/item-data.md`. `docs/commands.md`
|
|
605
|
+
needs no edit — it `literalinclude`s the `builder-inited`-generated
|
|
606
|
+
dump of `help_general`.
|
|
547
607
|
- [ ] **UX pass.** Empty/loading state for the DAG when a user has no tasks
|
|
548
608
|
yet (currently the app would just render nothing); a keyboard shortcut
|
|
549
609
|
to toggle the console (e.g. backtick); consistent spacing/typography
|
|
@@ -88,10 +88,12 @@ task show 2
|
|
|
88
88
|
task status 2 "in progress"
|
|
89
89
|
task comment 2 "outline done, drafting the intro"
|
|
90
90
|
task done 2
|
|
91
|
+
task done 2 -r # also complete everything under item 2
|
|
91
92
|
```
|
|
92
93
|
|
|
93
|
-
`task done` (alias: `task complete`) marks
|
|
94
|
-
complete
|
|
94
|
+
`task done` (alias: `task complete`) marks just that item complete; add `-r`
|
|
95
|
+
(or `-recursive`) to complete all its descendants too. `task undone` reverses
|
|
96
|
+
it and takes the same `-r` flag. Clean finished work up with `task clear`:
|
|
95
97
|
|
|
96
98
|
```bash
|
|
97
99
|
task clear "Launch blog" # delete completed items under that parent
|
|
@@ -25,13 +25,16 @@ Some changes have their own command instead of an option:
|
|
|
25
25
|
task rename 4 "Ship v2.1"
|
|
26
26
|
task status 4 "in review" # same as --status, as its own verb
|
|
27
27
|
task comment 4 "waiting on QA sign-off"
|
|
28
|
-
task complete 4 # mark
|
|
28
|
+
task complete 4 # mark item 4 done
|
|
29
29
|
task done 4 # alias for complete
|
|
30
|
+
task done 4 -r # also complete every descendant
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
`task complete` / `task done` mark
|
|
33
|
-
|
|
33
|
+
`task complete` / `task done` mark just that item complete; add `-r` (or
|
|
34
|
+
`-recursive`) to include all its descendants. To reopen something, use
|
|
35
|
+
`task undone` (which takes the same `-r` flag) or set the field explicitly:
|
|
34
36
|
|
|
35
37
|
```bash
|
|
38
|
+
task undone 4
|
|
36
39
|
task update 4 --completed false
|
|
37
40
|
```
|
|
@@ -86,12 +86,23 @@ class Controller:
|
|
|
86
86
|
|
|
87
87
|
|
|
88
88
|
def _parse_item_kwargs(kwargs):
|
|
89
|
+
# idempotent: also called on the already-parsed kwargs that
|
|
90
|
+
# update_item()'s recursive branch passes back down
|
|
89
91
|
for k, v in kwargs.copy().items():
|
|
90
92
|
if v is None:
|
|
91
93
|
continue
|
|
92
94
|
match k:
|
|
93
95
|
case "completed": kwargs["completed"] = bool(v)
|
|
94
|
-
case "due_by":
|
|
96
|
+
case "due_by":
|
|
97
|
+
if isinstance(v, str):
|
|
98
|
+
kwargs["due_by"] = datetime.strptime(v, "%m-%d-%Y")
|
|
99
|
+
case "depends_on":
|
|
100
|
+
# CLI-facing alias for the model's dependency_ids field;
|
|
101
|
+
# accepts a comma-separated id list ("1,2,3") or one id
|
|
102
|
+
kwargs["dependency_ids"] = [
|
|
103
|
+
int(x) for x in str(v).split(",") if str(x).strip()
|
|
104
|
+
]
|
|
105
|
+
del kwargs["depends_on"]
|
|
95
106
|
return kwargs
|
|
96
107
|
|
|
97
108
|
def _get_root_ids():
|
|
@@ -137,6 +148,7 @@ class Controller:
|
|
|
137
148
|
|
|
138
149
|
def update_item(item_id: int|str, recursive=False, **kwargs):
|
|
139
150
|
item_id = Controller._resolve_item(item_id).id
|
|
151
|
+
kwargs = Controller._parse_item_kwargs(kwargs)
|
|
140
152
|
db.update_item(item_id, **kwargs)
|
|
141
153
|
print(f"Updated item {item_id}")
|
|
142
154
|
|
|
@@ -443,7 +455,13 @@ def execute_commands(*args, **kwargs) -> int:
|
|
|
443
455
|
Controller.remove_link(parent_identifier, item_identifier)
|
|
444
456
|
|
|
445
457
|
case "complete" | "done":
|
|
446
|
-
|
|
458
|
+
recursive = "-r" in args or "-recursive" in args
|
|
459
|
+
Controller.update_item(args[1], completed=True, recursive=recursive)
|
|
460
|
+
|
|
461
|
+
case "undone":
|
|
462
|
+
recursive = "-r" in args or "-recursive" in args
|
|
463
|
+
Controller.update_item(args[1], completed=False, recursive=recursive)
|
|
464
|
+
|
|
447
465
|
|
|
448
466
|
case "examples":
|
|
449
467
|
Controller.show_examples()
|
|
@@ -22,7 +22,8 @@ Modifying items (an id or a name works everywhere):
|
|
|
22
22
|
'task rename {id|name} {new name}' --> rename an item
|
|
23
23
|
'task status {id|name} {text}' --> set an item's status string
|
|
24
24
|
'task comment {id|name} {text}' --> add a comment to an item
|
|
25
|
-
'task complete {id|name}' / 'task done {id|name}' --> mark an item
|
|
25
|
+
'task complete {id|name}' / 'task done {id|name}' --> mark an item complete; add -r (or -recursive) to also complete all its descendants
|
|
26
|
+
'task undone {id|name}' --> mark an item not complete; add -r (or -recursive) to also un-complete all its descendants
|
|
26
27
|
'task reorder {id1|name} before|after {id2|name}' --> reorder one item relative to a sibling
|
|
27
28
|
'task move {id|name} {new parent id|name}' --> reparent an item; pass an empty string for the new parent to move it to the top level
|
|
28
29
|
'task link {parent id|name} {item id|name}' --> soft-link item under parent, without reparenting it
|
|
@@ -79,7 +80,9 @@ Build a small project tree:
|
|
|
79
80
|
Work an item and finish it:
|
|
80
81
|
task status 4 "in progress"
|
|
81
82
|
task comment 4 "stuck on the intro paragraph"
|
|
82
|
-
task done 4 # completes item 4
|
|
83
|
+
task done 4 # completes just item 4
|
|
84
|
+
task done 4 -r # completes item 4 and everything under it
|
|
85
|
+
task undone 4 # reopen it
|
|
83
86
|
|
|
84
87
|
Reorganize (reorder/rename/status take an id; move takes an id or a name):
|
|
85
88
|
task move "Set up hosting" "Write first post"
|
|
@@ -79,6 +79,13 @@ let roots = [];
|
|
|
79
79
|
let nodes = [];
|
|
80
80
|
let latestItemsById = {}; // full raw item data keyed by id, from the last /api/tree or /api/command response
|
|
81
81
|
|
|
82
|
+
// synthetic, never-drawn node sitting above the real root items. It's the
|
|
83
|
+
// nav anchor for top-level movement and the "selection" that means "the whole
|
|
84
|
+
// tree / nothing specific" — e.g. after `show all`, or clicking empty canvas.
|
|
85
|
+
// Never added to `nodes`, so it's never hit-tested, drawn, or bordered.
|
|
86
|
+
const ROOT_NODE_ID = "__root__";
|
|
87
|
+
const rootNode = { id: ROOT_NODE_ID, isRoot: true, label: "", size: 0, children: [], x: 0, y: 0 };
|
|
88
|
+
|
|
82
89
|
// the real item behind a node — for a shadow (soft-link) node that's the
|
|
83
90
|
// linked-to item (node.realId), not the synthetic shadow id
|
|
84
91
|
function itemForNode(node) {
|
|
@@ -119,7 +126,7 @@ function buildTree(itemsById, id) {
|
|
|
119
126
|
if (linked) children.push(buildShadowNode(linked, item.id));
|
|
120
127
|
});
|
|
121
128
|
|
|
122
|
-
|
|
129
|
+
const node = {
|
|
123
130
|
id: String(item.id),
|
|
124
131
|
label: item.name,
|
|
125
132
|
size: STYLE.node.size,
|
|
@@ -127,6 +134,11 @@ function buildTree(itemsById, id) {
|
|
|
127
134
|
status: item.status,
|
|
128
135
|
children,
|
|
129
136
|
};
|
|
137
|
+
|
|
138
|
+
// back-reference so navigation (navigate()) can walk up as well as down
|
|
139
|
+
children.forEach(child => { child.parent = node; });
|
|
140
|
+
|
|
141
|
+
return node;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
// depth-first layout: y from depth, x from a running leaf counter shared across the whole forest,
|
|
@@ -163,10 +175,24 @@ function applyTree(itemsById) {
|
|
|
163
175
|
|
|
164
176
|
nodes = roots.flatMap(root => flatten(root));
|
|
165
177
|
|
|
178
|
+
// keep the synthetic root pointing at the freshly-built real roots, and
|
|
179
|
+
// park it just above them so it works as a nav origin
|
|
180
|
+
rootNode.children = roots;
|
|
181
|
+
roots.forEach(r => { r.parent = rootNode; });
|
|
182
|
+
if (roots.length) {
|
|
183
|
+
rootNode.x = roots.reduce((sum, r) => sum + r.x, 0) / roots.length;
|
|
184
|
+
rootNode.y = Math.min(...roots.map(r => r.y)) - STYLE.layout.ySpacing;
|
|
185
|
+
} else {
|
|
186
|
+
rootNode.x = 0;
|
|
187
|
+
rootNode.y = 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
// the previously-selected node was rebuilt as a new object (or may no
|
|
167
191
|
// longer exist) — re-resolve by id so selection survives a tree refresh
|
|
168
192
|
if (selectedNode) {
|
|
169
|
-
selectedNode =
|
|
193
|
+
selectedNode = selectedNode.id === ROOT_NODE_ID
|
|
194
|
+
? rootNode
|
|
195
|
+
: nodes.find(n => n.id === selectedNode.id) || rootNode;
|
|
170
196
|
if (typeof onNodeSelected === "function") {
|
|
171
197
|
onNodeSelected(itemForNode(selectedNode));
|
|
172
198
|
}
|
|
@@ -345,6 +371,70 @@ function canvasPan(dx, dy) {
|
|
|
345
371
|
easeView(view.offsetX + dx, view.offsetY + dy, view.scale);
|
|
346
372
|
}
|
|
347
373
|
|
|
374
|
+
// eases the view out until every real node fits on screen, with padding —
|
|
375
|
+
// used by `show all` / bare `show`
|
|
376
|
+
function fitAll(duration = STYLE.zoom.focusDurationMs) {
|
|
377
|
+
if (!nodes.length) return;
|
|
378
|
+
|
|
379
|
+
const half = STYLE.node.size / 2;
|
|
380
|
+
const minX = Math.min(...nodes.map(n => n.x)) - half;
|
|
381
|
+
const maxX = Math.max(...nodes.map(n => n.x)) + half;
|
|
382
|
+
const minY = Math.min(...nodes.map(n => n.y)) - half;
|
|
383
|
+
const maxY = Math.max(...nodes.map(n => n.y)) + half;
|
|
384
|
+
|
|
385
|
+
const pad = 60; // screen px of breathing room around the content
|
|
386
|
+
const scale = Math.min(
|
|
387
|
+
STYLE.zoom.max,
|
|
388
|
+
Math.max(
|
|
389
|
+
STYLE.zoom.min,
|
|
390
|
+
Math.min(
|
|
391
|
+
(canvas.width - pad * 2) / (maxX - minX),
|
|
392
|
+
(canvas.height - pad * 2) / (maxY - minY),
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const cx = (minX + maxX) / 2;
|
|
398
|
+
const cy = (minY + maxY) / 2;
|
|
399
|
+
easeView(canvas.width / 2 - cx * scale, canvas.height / 2 - cy * scale, scale, duration);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// moves the selection relative to the current node along the node tree.
|
|
403
|
+
// The move is equivalent to a `show <target>`: the node becomes selected and
|
|
404
|
+
// the view eases + zooms to it (focusOnNode). Wraps around on both axes —
|
|
405
|
+
// past the last sibling loops to the first, past the bottom leaf loops back
|
|
406
|
+
// to rootNode (the top), and `up` from rootNode drops to the deepest node.
|
|
407
|
+
function navigate(direction) {
|
|
408
|
+
const cur = selectedNode || rootNode;
|
|
409
|
+
let target = null;
|
|
410
|
+
|
|
411
|
+
if (direction === "down") {
|
|
412
|
+
target = cur.children[0] || rootNode; // past the bottom -> wrap to the top
|
|
413
|
+
} else if (direction === "up") {
|
|
414
|
+
if (cur.parent) {
|
|
415
|
+
target = cur.parent;
|
|
416
|
+
} else {
|
|
417
|
+
// at rootNode (the top) -> wrap to the bottom: follow the
|
|
418
|
+
// first-child chain down to the deepest leaf
|
|
419
|
+
target = cur;
|
|
420
|
+
while (target.children[0]) target = target.children[0];
|
|
421
|
+
}
|
|
422
|
+
} else if (direction === "left" || direction === "right") {
|
|
423
|
+
const sibs = cur.parent && cur.parent.children;
|
|
424
|
+
if (!sibs || !sibs.length) return;
|
|
425
|
+
const n = sibs.length;
|
|
426
|
+
target = sibs[(sibs.indexOf(cur) + (direction === "right" ? 1 : -1) + n) % n];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!target || target === cur) return;
|
|
430
|
+
|
|
431
|
+
selectedNode = target;
|
|
432
|
+
if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(target));
|
|
433
|
+
if (target === rootNode) fitAll();
|
|
434
|
+
else focusOnNode(target);
|
|
435
|
+
draw();
|
|
436
|
+
}
|
|
437
|
+
|
|
348
438
|
// trims text, always appending an ellipsis, until "text…" fits maxWidth
|
|
349
439
|
function truncateWithEllipsis(ctx, text, maxWidth) {
|
|
350
440
|
let truncated = text;
|
|
@@ -586,8 +676,9 @@ function draw() {
|
|
|
586
676
|
let hoveredNode = null;
|
|
587
677
|
|
|
588
678
|
// selection — persists across hover and drives the edit panel (see
|
|
589
|
-
// onNodeSelected, defined in editpanel.js)
|
|
590
|
-
|
|
679
|
+
// onNodeSelected, defined in editpanel.js). Never null: an empty/whole-tree
|
|
680
|
+
// selection is the synthetic rootNode.
|
|
681
|
+
let selectedNode = rootNode;
|
|
591
682
|
|
|
592
683
|
// panning state
|
|
593
684
|
let isPanning = false;
|
|
@@ -605,7 +696,7 @@ canvas.addEventListener("click", (e) => {
|
|
|
605
696
|
|
|
606
697
|
const clicked = nodes.find(node => hitTest(node, x, y));
|
|
607
698
|
|
|
608
|
-
selectedNode = clicked ||
|
|
699
|
+
selectedNode = clicked || rootNode; // empty canvas -> whole-tree selection
|
|
609
700
|
if (typeof onNodeSelected === "function") {
|
|
610
701
|
onNodeSelected(itemForNode(selectedNode));
|
|
611
702
|
}
|
|
@@ -30,8 +30,26 @@ const CLIENT_COMMANDS = {
|
|
|
30
30
|
"pan right": () => canvasPan(-PAN_AMOUNT, 0),
|
|
31
31
|
"pan up": () => canvasPan(0, PAN_AMOUNT),
|
|
32
32
|
"pan down": () => canvasPan(0, -PAN_AMOUNT),
|
|
33
|
+
"up": () => navigate("up"),
|
|
34
|
+
"down": () => navigate("down"),
|
|
35
|
+
"left": () => navigate("left"),
|
|
36
|
+
"right": () => navigate("right"),
|
|
37
|
+
"hide edit": () => closeEditPanel(),
|
|
33
38
|
};
|
|
34
39
|
|
|
40
|
+
// `.` as a standalone token refers to the currently selected node — swap it
|
|
41
|
+
// for that node's id before the command is matched or sent on. Only matches a
|
|
42
|
+
// bare `.` (spaces or string ends on both sides), so `a.md` / `3.5` are left
|
|
43
|
+
// alone. Returns null (and reports) if `.` is used with no real selection.
|
|
44
|
+
function resolveDotRefs(command) {
|
|
45
|
+
if (!/(^|\s)\.(\s|$)/.test(command)) return command;
|
|
46
|
+
if (!selectedNode || selectedNode === rootNode) {
|
|
47
|
+
appendLine("No node selected — `.` refers to the selected node");
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return command.replace(/(^|\s)\.(?=\s|$)/g, `$1${selectedNode.id}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
const commandHistory = [];
|
|
36
54
|
let historyIndex = commandHistory.length; // length = not currently browsing history
|
|
37
55
|
|
|
@@ -52,7 +70,7 @@ consoleInput.addEventListener("keydown", async (e) => {
|
|
|
52
70
|
}
|
|
53
71
|
if (e.key !== "Enter") return;
|
|
54
72
|
|
|
55
|
-
|
|
73
|
+
let command = consoleInput.value.trim();
|
|
56
74
|
if (!command) return;
|
|
57
75
|
|
|
58
76
|
commandHistory.push(command);
|
|
@@ -61,12 +79,65 @@ consoleInput.addEventListener("keydown", async (e) => {
|
|
|
61
79
|
appendLine("> " + command);
|
|
62
80
|
consoleInput.value = "";
|
|
63
81
|
|
|
82
|
+
command = resolveDotRefs(command); // `.` -> selected node's id
|
|
83
|
+
if (command === null) return;
|
|
84
|
+
|
|
64
85
|
const clientHandler = CLIENT_COMMANDS[command.toLowerCase()];
|
|
65
86
|
if (clientHandler) {
|
|
66
87
|
clientHandler();
|
|
67
88
|
return;
|
|
68
89
|
}
|
|
69
90
|
|
|
91
|
+
// `show all` / bare `show` — select the synthetic root and fit the forest
|
|
92
|
+
if (/^show(\s+all)?$/i.test(command)) {
|
|
93
|
+
let tree;
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetch("/api/tree");
|
|
96
|
+
tree = await res.json();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
appendLine("Error: " + err.message);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
applyTree(tree);
|
|
102
|
+
selectedNode = rootNode;
|
|
103
|
+
if (typeof onNodeSelected === "function") onNodeSelected(null);
|
|
104
|
+
fitAll();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// `show <id|name>` — select + focus the resolved node. Does NOT open the
|
|
109
|
+
// edit panel (that's `edit <id|name>`), mirroring click vs. double-click.
|
|
110
|
+
const showMatch = command.match(/^show\s+(.+)$/i);
|
|
111
|
+
if (showMatch) {
|
|
112
|
+
const target = showMatch[1].trim();
|
|
113
|
+
let data;
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch("/api/command", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ input: command }),
|
|
119
|
+
});
|
|
120
|
+
data = await res.json();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
appendLine("Error: " + err.message);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
applyTree(data.tree);
|
|
127
|
+
|
|
128
|
+
if (data.focus) {
|
|
129
|
+
const node = nodes.find(n => n.id === String(data.focus));
|
|
130
|
+
if (node) {
|
|
131
|
+
selectedNode = node;
|
|
132
|
+
if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(node));
|
|
133
|
+
focusOnNode(node);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
appendLine(data.output || `No item found matching '${target}'`);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
70
141
|
// `edit <id|name>` — resolve via show, then select + focus + open panel
|
|
71
142
|
const editMatch = command.match(/^edit\s+(.+)$/i);
|
|
72
143
|
if (editMatch) {
|
|
@@ -90,7 +161,7 @@ consoleInput.addEventListener("keydown", async (e) => {
|
|
|
90
161
|
const node = nodes.find(n => n.id === String(data.focus));
|
|
91
162
|
if (node) {
|
|
92
163
|
selectedNode = node;
|
|
93
|
-
if (typeof onNodeSelected === "function") onNodeSelected(
|
|
164
|
+
if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(node));
|
|
94
165
|
focusOnNode(node);
|
|
95
166
|
if (typeof openEditPanel === "function") openEditPanel();
|
|
96
167
|
}
|
|
@@ -162,7 +162,7 @@ function onNodeSelected(item) {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// expands the edit panel if it isn't already — called by the console's
|
|
165
|
-
// `edit`
|
|
165
|
+
// `edit <node>` / `edit .` commands so the panel opens without a click
|
|
166
166
|
function openEditPanel() {
|
|
167
167
|
if (!editPanel.classList.contains("expanded")) {
|
|
168
168
|
editPanel.classList.add("expanded");
|
|
@@ -171,4 +171,13 @@ function openEditPanel() {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// collapses the edit panel if it's open — the `hide edit` console command
|
|
175
|
+
function closeEditPanel() {
|
|
176
|
+
if (editPanel.classList.contains("expanded")) {
|
|
177
|
+
editPanel.classList.remove("expanded");
|
|
178
|
+
editToggle.setAttribute("aria-expanded", "false");
|
|
179
|
+
setRightPanelWidth(EDIT_PANEL_COLLAPSED_WIDTH);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
174
183
|
renderEditForm(null);
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|