taskai-cli 1.7.0__tar.gz → 1.8.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.
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/PKG-INFO +1 -1
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/pyproject.toml +1 -1
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/browser.py +51 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/cli.py +38 -3
- taskai_cli-1.8.0/taskai/filters.py +150 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/help_menu.py +4 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/canvas.js +40 -19
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/console.js +9 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/editpanel.js +22 -1
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/shortcuts.js +8 -3
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/views.py +15 -5
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/.gitignore +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/.readthedocs.yaml +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/DEVLOG.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/DEVPLAN.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/README.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/TRIAGE.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/.gitignore +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/_static/.gitkeep +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/ai-integration.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/browser-mode.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/commands.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/conf.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/configuration.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/getting-started.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/index.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/interactive-mode.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/item-data.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/managing-the-tree.md +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/requirements.txt +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/scripts/bump_version.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/config.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/errors.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/json_dir_database.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/llm_models.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/models.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/ai.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/pomodoro.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/repair_database.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/user_setup.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/index.html +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/style.css +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_cli.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_execution.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_json_dir_database.py +0 -0
- {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_view.py +0 -0
|
@@ -9,6 +9,13 @@ from pydantic import BaseModel
|
|
|
9
9
|
|
|
10
10
|
from taskai.cli import Controller, _parse_arg_string, _parse_remaining, db, execute_commands
|
|
11
11
|
from taskai.errors import TaskCLIError
|
|
12
|
+
from taskai.filters import (
|
|
13
|
+
FilterError,
|
|
14
|
+
looks_like_filters,
|
|
15
|
+
match_items,
|
|
16
|
+
parse_filters,
|
|
17
|
+
with_ancestors,
|
|
18
|
+
)
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
app = FastAPI()
|
|
@@ -49,6 +56,23 @@ def _full_tree():
|
|
|
49
56
|
return tree
|
|
50
57
|
|
|
51
58
|
|
|
59
|
+
def _filtered_tree(keep):
|
|
60
|
+
"""Same shape as _full_tree(), but only the ids in `keep`, with
|
|
61
|
+
child_ids / linked_ids narrowed to `keep` too so the frontend never
|
|
62
|
+
walks into a pruned-out node."""
|
|
63
|
+
tree = {}
|
|
64
|
+
for item_id in db.get_item_ids():
|
|
65
|
+
if item_id not in keep:
|
|
66
|
+
continue
|
|
67
|
+
item = db.get_item(item_id)
|
|
68
|
+
dump = item.model_dump(mode="json")
|
|
69
|
+
dump["child_ids"] = [c for c in dump["child_ids"] if c in keep]
|
|
70
|
+
dump["linked_ids"] = [l for l in dump["linked_ids"] if l in keep]
|
|
71
|
+
dump["comments"] = _resolve_comments(item)
|
|
72
|
+
tree[item_id] = dump
|
|
73
|
+
return tree
|
|
74
|
+
|
|
75
|
+
|
|
52
76
|
@app.get("/api/tree")
|
|
53
77
|
def get_tree():
|
|
54
78
|
db.flush() # reload from disk in case another process (e.g. the CLI) wrote since we last connected
|
|
@@ -83,6 +107,11 @@ def run_command(request: CommandRequest):
|
|
|
83
107
|
def _run_show(args):
|
|
84
108
|
"""`show` is read-only from the browser's perspective: it never mutates
|
|
85
109
|
the db, it just tells the frontend which node to focus/center on."""
|
|
110
|
+
show_args = list(args[1:])
|
|
111
|
+
|
|
112
|
+
if looks_like_filters(show_args):
|
|
113
|
+
return _run_filter(show_args)
|
|
114
|
+
|
|
86
115
|
target = args[1] if len(args) > 1 else "all"
|
|
87
116
|
|
|
88
117
|
if target == "all":
|
|
@@ -100,3 +129,25 @@ def _run_show(args):
|
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
return {"output": "", "tree": _full_tree(), "focus": str(item.id)}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_filter(show_args):
|
|
135
|
+
"""`show attr<op>value ...` — return a pruned tree (matches + their parent
|
|
136
|
+
chains) plus a `filtered` flag so the frontend fits the view to it."""
|
|
137
|
+
try:
|
|
138
|
+
exprs = parse_filters(show_args)
|
|
139
|
+
except FilterError as e:
|
|
140
|
+
return {"output": str(e), "tree": _full_tree(), "focus": None}
|
|
141
|
+
|
|
142
|
+
matched = match_items(db, exprs)
|
|
143
|
+
if not matched:
|
|
144
|
+
return {"output": "No items match those filters", "tree": _full_tree(), "focus": None}
|
|
145
|
+
|
|
146
|
+
keep = with_ancestors(db, matched)
|
|
147
|
+
plural = "s" if len(matched) != 1 else ""
|
|
148
|
+
return {
|
|
149
|
+
"output": f"{len(matched)} item{plural} matched",
|
|
150
|
+
"tree": _filtered_tree(keep),
|
|
151
|
+
"focus": None,
|
|
152
|
+
"filtered": True,
|
|
153
|
+
}
|
|
@@ -11,6 +11,13 @@ import getpass
|
|
|
11
11
|
# local
|
|
12
12
|
from taskai.json_dir_database import JsonDirectoryDatabase
|
|
13
13
|
from taskai.views import view_lists, view_item
|
|
14
|
+
from taskai.filters import (
|
|
15
|
+
FilterError,
|
|
16
|
+
looks_like_filters,
|
|
17
|
+
match_items,
|
|
18
|
+
parse_filters,
|
|
19
|
+
with_ancestors,
|
|
20
|
+
)
|
|
14
21
|
from taskai.models import TodoItem, CLIConfig
|
|
15
22
|
from taskai.services.ai import ai_headstart_service, ai_natural_language_service
|
|
16
23
|
from taskai.services.user_setup import user_setup_service
|
|
@@ -135,6 +142,29 @@ class Controller:
|
|
|
135
142
|
return
|
|
136
143
|
view_item(db, item.id, **kwargs)
|
|
137
144
|
|
|
145
|
+
def show_filtered(tokens: list[str], **kwargs):
|
|
146
|
+
"""`task show attr<op>value ...` - render only the items matching all
|
|
147
|
+
the given filters, each with its parent chain for context."""
|
|
148
|
+
try:
|
|
149
|
+
exprs = parse_filters(tokens)
|
|
150
|
+
except FilterError as e:
|
|
151
|
+
Controller.throw_error(str(e))
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
matched = match_items(db, exprs)
|
|
155
|
+
if not matched:
|
|
156
|
+
print("No items match those filters")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
display = with_ancestors(db, matched)
|
|
160
|
+
roots = [r for r in Controller._get_root_ids() if r in display]
|
|
161
|
+
view_lists(
|
|
162
|
+
db, roots,
|
|
163
|
+
show_done=kwargs.get("show_done", True),
|
|
164
|
+
only_ids=display,
|
|
165
|
+
)
|
|
166
|
+
print(f"\n{len(matched)} item{'s' if len(matched) != 1 else ''} matched")
|
|
167
|
+
|
|
138
168
|
def show_examples():
|
|
139
169
|
print(help_menu["examples"])
|
|
140
170
|
|
|
@@ -411,9 +441,14 @@ def execute_commands(*args, **kwargs) -> int:
|
|
|
411
441
|
Controller.run_setup_service()
|
|
412
442
|
|
|
413
443
|
case "show":
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
444
|
+
show_args = list(args[1:])
|
|
445
|
+
if looks_like_filters(show_args):
|
|
446
|
+
Controller.show_filtered(show_args, **kwargs)
|
|
447
|
+
else:
|
|
448
|
+
target = args[1] if len(args) > 1 else "all"
|
|
449
|
+
match target:
|
|
450
|
+
case "all": Controller.show_all(*args[2:], **kwargs)
|
|
451
|
+
case _: Controller.show_item(target, **kwargs)
|
|
417
452
|
|
|
418
453
|
case "create":
|
|
419
454
|
Controller.create_item(args[1], **kwargs)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Attribute filters for `task show`.
|
|
3
|
+
|
|
4
|
+
task show status="IN PROGRESS" priority>=2 due_by<12-31-2026
|
|
5
|
+
|
|
6
|
+
Each argument is one filter, `attr<op>value`, with no spaces around the
|
|
7
|
+
operator (a quoted value with spaces still arrives as a single token). Every
|
|
8
|
+
filter must pass for an item to match - the list is AND-ed. Text matches (`=`
|
|
9
|
+
on a string attribute) use fnmatch, so `name=Write*` works; the ordering
|
|
10
|
+
operators compare numbers, dates, or strings as appropriate.
|
|
11
|
+
|
|
12
|
+
`match_items` returns the ids that match; `with_ancestors` pads that set out
|
|
13
|
+
with every matched item's parent chain, so the result renders as a coherent
|
|
14
|
+
tree rather than a scatter of disconnected nodes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
from taskai.json_dir_database import DatabaseError
|
|
23
|
+
|
|
24
|
+
# attr -> how both sides of the comparison should be read
|
|
25
|
+
_INT_ATTRS = {"id", "parent_id", "priority"}
|
|
26
|
+
_DATE_ATTRS = {"due_by", "created_on"}
|
|
27
|
+
_BOOL_ATTRS = {"completed"}
|
|
28
|
+
_STR_ATTRS = {"name", "description", "status"}
|
|
29
|
+
|
|
30
|
+
FILTERABLE_ATTRS = _INT_ATTRS | _DATE_ATTRS | _BOOL_ATTRS | _STR_ATTRS
|
|
31
|
+
|
|
32
|
+
_DATE_FORMAT = "%m-%d-%Y" # matches the CLI's --due_by format
|
|
33
|
+
|
|
34
|
+
# two-char operators first so `>=` isn't read as `>` then `=`
|
|
35
|
+
_FILTER_RE = re.compile(
|
|
36
|
+
r"^(?P<attr>[A-Za-z_][A-Za-z0-9_]*)(?P<op>>=|<=|=|>|<)(?P<value>.*)$",
|
|
37
|
+
re.DOTALL,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FilterError(ValueError):
|
|
42
|
+
"""A filter argument that doesn't parse, or whose value is unusable."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _split_token(token: str):
|
|
46
|
+
"""(attr, op, raw_value) if `token` is structurally `attr<op>...` with a
|
|
47
|
+
known attribute, else None. Purely structural - no value coercion."""
|
|
48
|
+
match = _FILTER_RE.match(token)
|
|
49
|
+
if match is None or match.group("attr") not in FILTERABLE_ATTRS:
|
|
50
|
+
return None
|
|
51
|
+
return match.group("attr"), match.group("op"), match.group("value")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _coerce(attr: str, raw: str):
|
|
55
|
+
if attr in _INT_ATTRS:
|
|
56
|
+
return int(raw)
|
|
57
|
+
if attr in _BOOL_ATTRS:
|
|
58
|
+
return str(raw).strip().lower() in ("true", "1", "yes")
|
|
59
|
+
if attr in _DATE_ATTRS:
|
|
60
|
+
return datetime.strptime(raw.strip(), _DATE_FORMAT).date()
|
|
61
|
+
return raw # string: compared with fnmatch (=) or lexically (< >)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class FilterExpr:
|
|
66
|
+
attr: str
|
|
67
|
+
op: str
|
|
68
|
+
value: object
|
|
69
|
+
|
|
70
|
+
def matches(self, item) -> bool:
|
|
71
|
+
return _compare(self.op, _item_value(item, self.attr), self.value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _item_value(item, attr):
|
|
75
|
+
value = getattr(item, attr, None)
|
|
76
|
+
if attr in _DATE_ATTRS and value is not None:
|
|
77
|
+
return value.date() # drop the time component for date comparisons
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _compare(op: str, actual, expected) -> bool:
|
|
82
|
+
if op == "=":
|
|
83
|
+
if isinstance(expected, str):
|
|
84
|
+
return fnmatch.fnmatch("" if actual is None else str(actual), expected)
|
|
85
|
+
return actual == expected
|
|
86
|
+
if actual is None:
|
|
87
|
+
return False # None has no ordering
|
|
88
|
+
try:
|
|
89
|
+
if op == ">":
|
|
90
|
+
return actual > expected
|
|
91
|
+
if op == "<":
|
|
92
|
+
return actual < expected
|
|
93
|
+
if op == ">=":
|
|
94
|
+
return actual >= expected
|
|
95
|
+
if op == "<=":
|
|
96
|
+
return actual <= expected
|
|
97
|
+
except TypeError:
|
|
98
|
+
return False
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def looks_like_filters(tokens) -> bool:
|
|
103
|
+
"""True when `show`'s arguments should be treated as a filter query rather
|
|
104
|
+
than a single id / name target."""
|
|
105
|
+
return bool(tokens) and any(_split_token(t) is not None for t in tokens)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_filters(tokens) -> list[FilterExpr]:
|
|
109
|
+
"""All tokens -> FilterExprs. Raises FilterError on the first token that
|
|
110
|
+
isn't a valid `attr<op>value`."""
|
|
111
|
+
exprs = []
|
|
112
|
+
for token in tokens:
|
|
113
|
+
parts = _split_token(token)
|
|
114
|
+
if parts is None:
|
|
115
|
+
raise FilterError(
|
|
116
|
+
f"'{token}' is not a valid filter - expected attr=value, "
|
|
117
|
+
f"attr>value, etc. (attributes: {', '.join(sorted(FILTERABLE_ATTRS))})"
|
|
118
|
+
)
|
|
119
|
+
attr, op, raw = parts
|
|
120
|
+
try:
|
|
121
|
+
value = _coerce(attr, raw)
|
|
122
|
+
except (ValueError, TypeError):
|
|
123
|
+
raise FilterError(f"'{raw}' is not a valid value for '{attr}'")
|
|
124
|
+
exprs.append(FilterExpr(attr, op, value))
|
|
125
|
+
return exprs
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def match_items(db, exprs) -> set:
|
|
129
|
+
"""Ids of the items that satisfy every filter."""
|
|
130
|
+
matched = set()
|
|
131
|
+
for item_id in db.get_item_ids():
|
|
132
|
+
item = db.get_item(item_id)
|
|
133
|
+
if all(expr.matches(item) for expr in exprs):
|
|
134
|
+
matched.add(item_id)
|
|
135
|
+
return matched
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def with_ancestors(db, ids) -> set:
|
|
139
|
+
"""`ids` plus every ancestor of every id, so the selection is a set of
|
|
140
|
+
complete root-to-node paths."""
|
|
141
|
+
keep = set()
|
|
142
|
+
for id_ in ids:
|
|
143
|
+
cursor = id_
|
|
144
|
+
while cursor is not None and cursor not in keep:
|
|
145
|
+
keep.add(cursor)
|
|
146
|
+
try:
|
|
147
|
+
cursor = db.get_item_attr(cursor, "parent_id")
|
|
148
|
+
except DatabaseError:
|
|
149
|
+
break
|
|
150
|
+
return keep
|
|
@@ -12,6 +12,10 @@ Viewing:
|
|
|
12
12
|
'task show all' --> show every item, with ids prepended, as a tree
|
|
13
13
|
'task show {id}' --> show full details for the item with that id
|
|
14
14
|
'task show {name|fnmatch pattern}' --> match an item by name (or glob pattern) and show it
|
|
15
|
+
'task show {attr}{op}{value} ...' --> show only items matching every filter, each with its parent chain for context
|
|
16
|
+
ops: = > < >= <= ; text uses = with fnmatch (name=Write*), the rest compare numbers/dates
|
|
17
|
+
no spaces around the operator; quote each expression so your shell doesn't treat > < as redirects
|
|
18
|
+
e.g. task show 'status=IN PROGRESS' 'priority>=2' 'due_by<12-31-2026'
|
|
15
19
|
|
|
16
20
|
Creating:
|
|
17
21
|
'task create {name} {--field value ...}' --> create a new top-level (root) item - use this when there's no existing parent to attach to
|
|
@@ -327,9 +327,6 @@ function focusOnNode(node, scale = STYLE.zoom.focusScale, duration = STYLE.zoom.
|
|
|
327
327
|
const startOffsetY = view.offsetY;
|
|
328
328
|
const startScale = view.scale;
|
|
329
329
|
|
|
330
|
-
const targetOffsetX = canvas.width / 2 - node.x * scale;
|
|
331
|
-
const targetOffsetY = canvas.height * STYLE.zoom.focusYRatio - node.y * scale;
|
|
332
|
-
|
|
333
330
|
const startTime = performance.now();
|
|
334
331
|
|
|
335
332
|
function step(now) {
|
|
@@ -337,6 +334,15 @@ function focusOnNode(node, scale = STYLE.zoom.focusScale, duration = STYLE.zoom.
|
|
|
337
334
|
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
|
|
338
335
|
|
|
339
336
|
view.scale = startScale + (scale - startScale) * eased;
|
|
337
|
+
|
|
338
|
+
// recompute the target each frame from the current canvas size: the
|
|
339
|
+
// canvas can be resizing under us when a side panel opens in step with
|
|
340
|
+
// this animation (e.g. the `a` add-node shortcut), and a target
|
|
341
|
+
// captured once up front would leave the node off-centre by half the
|
|
342
|
+
// width change — often hidden behind the panel that just opened.
|
|
343
|
+
const targetOffsetX = canvas.width / 2 - node.x * scale;
|
|
344
|
+
const targetOffsetY = canvas.height * STYLE.zoom.focusYRatio - node.y * scale;
|
|
345
|
+
|
|
340
346
|
view.offsetX = startOffsetX + (targetOffsetX - startOffsetX) * eased;
|
|
341
347
|
view.offsetY = startOffsetY + (targetOffsetY - startOffsetY) * eased;
|
|
342
348
|
|
|
@@ -412,35 +418,50 @@ function fitAll(duration = STYLE.zoom.focusDurationMs) {
|
|
|
412
418
|
easeView(canvas.width / 2 - cx * scale, canvas.height / 2 - cy * scale, scale, duration);
|
|
413
419
|
}
|
|
414
420
|
|
|
421
|
+
// per-parent memory of the last child navigated to, keyed by parent id (node
|
|
422
|
+
// objects are rebuilt on every applyTree, so ids not references). Lets `down`
|
|
423
|
+
// return to where you last were under a parent; stale ids are harmless — the
|
|
424
|
+
// lookup just misses and falls back to the first child.
|
|
425
|
+
const lastChildByParent = {};
|
|
426
|
+
|
|
415
427
|
// moves the selection relative to the current node along the node tree.
|
|
416
428
|
// The move is equivalent to a `show <target>`: the node becomes selected and
|
|
417
|
-
// the view eases + zooms to it (focusOnNode).
|
|
418
|
-
// past
|
|
419
|
-
//
|
|
429
|
+
// the view eases + zooms to it (focusOnNode). up/down don't wrap — off the
|
|
430
|
+
// top or past a leaf does nothing; only left/right wrap, looping to the
|
|
431
|
+
// first (or last) node of the current depth level.
|
|
420
432
|
function navigate(direction) {
|
|
421
433
|
const cur = selectedNode || rootNode;
|
|
422
434
|
let target = null;
|
|
423
435
|
|
|
424
436
|
if (direction === "down") {
|
|
425
|
-
|
|
437
|
+
if (!cur.children.length) return; // at a leaf -> nothing below
|
|
438
|
+
// return to the last child visited under `cur`, else its first child
|
|
439
|
+
const remembered = lastChildByParent[cur.id];
|
|
440
|
+
target = cur.children.find(c => c.id === remembered) || cur.children[0];
|
|
426
441
|
} else if (direction === "up") {
|
|
427
|
-
if (cur.parent)
|
|
428
|
-
|
|
429
|
-
} else {
|
|
430
|
-
// at rootNode (the top) -> wrap to the bottom: follow the
|
|
431
|
-
// first-child chain down to the deepest leaf
|
|
432
|
-
target = cur;
|
|
433
|
-
while (target.children[0]) target = target.children[0];
|
|
434
|
-
}
|
|
442
|
+
if (!cur.parent) return; // at the top -> nothing above
|
|
443
|
+
target = cur.parent;
|
|
435
444
|
} else if (direction === "left" || direction === "right") {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
445
|
+
// step to the node immediately left/right at the same depth, across
|
|
446
|
+
// the whole level — so you cross into a cousin subtree rather than
|
|
447
|
+
// wrapping inside the current parent. The layout gives every node at a
|
|
448
|
+
// given depth the same y, so the level is just "nodes sharing cur.y"
|
|
449
|
+
// sorted by x; no explicit depth/level bookkeeping. Wrap to the first
|
|
450
|
+
// (or last) node of the level only when you run off its end.
|
|
451
|
+
const row = nodes
|
|
452
|
+
.filter(n => Math.abs(n.y - cur.y) < 1)
|
|
453
|
+
.sort((a, b) => a.x - b.x);
|
|
454
|
+
const i = row.indexOf(cur);
|
|
455
|
+
if (i === -1 || row.length < 2) return;
|
|
456
|
+
const step = direction === "right" ? 1 : -1;
|
|
457
|
+
target = row[(i + step + row.length) % row.length];
|
|
440
458
|
}
|
|
441
459
|
|
|
442
460
|
if (!target || target === cur) return;
|
|
443
461
|
|
|
462
|
+
// remember this child so a later `down` into its parent returns here
|
|
463
|
+
if (target.parent) lastChildByParent[target.parent.id] = target.id;
|
|
464
|
+
|
|
444
465
|
selectedNode = target;
|
|
445
466
|
if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(target));
|
|
446
467
|
if (target === rootNode) fitAll();
|
|
@@ -151,6 +151,15 @@ consoleInput.addEventListener("keydown", async (e) => {
|
|
|
151
151
|
|
|
152
152
|
applyTree(data.tree);
|
|
153
153
|
|
|
154
|
+
// `show <attr><op><value> ...` — server returns a pruned tree + a flag
|
|
155
|
+
if (data.filtered) {
|
|
156
|
+
selectedNode = rootNode;
|
|
157
|
+
if (typeof onNodeSelected === "function") onNodeSelected(null);
|
|
158
|
+
fitAll();
|
|
159
|
+
if (data.output) appendLine(data.output);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
if (data.focus) {
|
|
155
164
|
const node = nodes.find(n => n.id === String(data.focus));
|
|
156
165
|
if (node) {
|
|
@@ -102,7 +102,7 @@ async function sendComment(itemId, text) {
|
|
|
102
102
|
applyTree(data.tree);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function handleFieldChange(item, def, value) {
|
|
105
|
+
function handleFieldChange(item, def, value, immediate = false) {
|
|
106
106
|
if (def.type === "checkbox") {
|
|
107
107
|
sendFieldUpdate(item, def, value ? "true" : "false");
|
|
108
108
|
return;
|
|
@@ -115,6 +115,15 @@ function handleFieldChange(item, def, value) {
|
|
|
115
115
|
|
|
116
116
|
const timerKey = `${item.id}:${def.key}`;
|
|
117
117
|
clearTimeout(pendingFieldUpdates[timerKey]);
|
|
118
|
+
|
|
119
|
+
// Enter (see buildField) commits right away rather than waiting out the
|
|
120
|
+
// debounce — the edit is done, no reason to sit on it
|
|
121
|
+
if (immediate) {
|
|
122
|
+
delete pendingFieldUpdates[timerKey];
|
|
123
|
+
sendFieldUpdate(item, def, value);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
pendingFieldUpdates[timerKey] = setTimeout(() => {
|
|
119
128
|
delete pendingFieldUpdates[timerKey];
|
|
120
129
|
sendFieldUpdate(item, def, value);
|
|
@@ -173,6 +182,18 @@ function buildField(item, def) {
|
|
|
173
182
|
handleFieldChange(item, def, value);
|
|
174
183
|
});
|
|
175
184
|
|
|
185
|
+
// Enter on a single-line field ends the edit: flush the pending update
|
|
186
|
+
// now and blur. Skipped for the textarea (Enter is a newline there) and
|
|
187
|
+
// the checkbox (toggled with space, no debounce to flush).
|
|
188
|
+
if (def.type !== "textarea" && def.type !== "checkbox") {
|
|
189
|
+
input.addEventListener("keydown", (e) => {
|
|
190
|
+
if (e.key !== "Enter") return;
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
handleFieldChange(item, def, input.value, true);
|
|
193
|
+
input.blur();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
176
197
|
return wrapper;
|
|
177
198
|
}
|
|
178
199
|
|
|
@@ -91,8 +91,10 @@ async function addNodeAndEdit() {
|
|
|
91
91
|
|
|
92
92
|
selectedNode = node;
|
|
93
93
|
if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(node));
|
|
94
|
-
focusOnNode
|
|
94
|
+
// open the panel first so focusOnNode eases into the already-narrowing
|
|
95
|
+
// canvas and its per-frame target tracks the new width (see focusOnNode)
|
|
95
96
|
if (typeof openEditPanel === "function") openEditPanel();
|
|
97
|
+
focusOnNode(node);
|
|
96
98
|
|
|
97
99
|
requestAnimationFrame(() => {
|
|
98
100
|
const nameInput = document.querySelector('#edit-panel [data-field-key="name"]');
|
|
@@ -113,7 +115,10 @@ function handleEscape() {
|
|
|
113
115
|
el.blur();
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
|
-
|
|
118
|
+
// deselect, but leave the camera where it is — Esc shouldn't move/zoom
|
|
119
|
+
selectedNode = rootNode;
|
|
120
|
+
if (typeof onNodeSelected === "function") onNodeSelected(null);
|
|
121
|
+
draw();
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
// --- registry ------------------------------------------------------------
|
|
@@ -138,7 +143,7 @@ const SHORTCUTS = [
|
|
|
138
143
|
run: () => toggleEditPanel(),
|
|
139
144
|
},
|
|
140
145
|
{
|
|
141
|
-
combos: [{ key: "Escape" }], glyphs: ["Esc"], desc: "Leave field · deselect
|
|
146
|
+
combos: [{ key: "Escape" }], glyphs: ["Esc"], desc: "Leave field · deselect",
|
|
142
147
|
whileTyping: true, keepDefault: true,
|
|
143
148
|
run: handleEscape,
|
|
144
149
|
},
|
|
@@ -35,14 +35,20 @@ VALID_ATTRS = {
|
|
|
35
35
|
@config("DISPLAY_STRING", "display_str")
|
|
36
36
|
@config("DISPLAY_COLORS", "display_colors")
|
|
37
37
|
def view_lists(
|
|
38
|
-
db: JsonDirectoryDatabase,
|
|
39
|
-
roots: list[int],
|
|
38
|
+
db: JsonDirectoryDatabase,
|
|
39
|
+
roots: list[int],
|
|
40
40
|
show_done=True,
|
|
41
41
|
max_level=1000,
|
|
42
|
-
|
|
42
|
+
only_ids: set[int] | None = None,
|
|
43
|
+
display_str: str = "id name status",
|
|
43
44
|
display_colors: str = None
|
|
44
45
|
):
|
|
45
|
-
"""Shows all the lists
|
|
46
|
+
"""Shows all the lists.
|
|
47
|
+
|
|
48
|
+
`only_ids`, when given, prunes the walk to just those ids - used by
|
|
49
|
+
`task show <filters>` to render matched items with their parent chain
|
|
50
|
+
and nothing else.
|
|
51
|
+
"""
|
|
46
52
|
|
|
47
53
|
attrs = display_str.lower().split(" ")
|
|
48
54
|
|
|
@@ -77,16 +83,20 @@ def view_lists(
|
|
|
77
83
|
def _recursive_print(item_id: int, level: int):
|
|
78
84
|
if level >= max_level:
|
|
79
85
|
return
|
|
86
|
+
if only_ids is not None and item_id not in only_ids:
|
|
87
|
+
return
|
|
80
88
|
|
|
81
89
|
try:
|
|
82
90
|
item = db.get_item(item_id)
|
|
83
91
|
except DatabaseError:
|
|
84
92
|
return
|
|
85
|
-
|
|
93
|
+
|
|
86
94
|
_print_item(item, level)
|
|
87
95
|
|
|
88
96
|
|
|
89
97
|
for linked_id in item.linked_ids:
|
|
98
|
+
if only_ids is not None and linked_id not in only_ids:
|
|
99
|
+
continue
|
|
90
100
|
try:
|
|
91
101
|
linked_item = db.get_item(linked_id)
|
|
92
102
|
except DatabaseError:
|
|
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
|
|
File without changes
|
|
File without changes
|