taskai-cli 1.7.1__tar.gz → 1.8.1__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.1 → taskai_cli-1.8.1}/PKG-INFO +1 -1
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/pyproject.toml +1 -1
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/browser.py +72 -2
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/cli.py +38 -3
- taskai_cli-1.8.1/taskai/filters.py +150 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/help_menu.py +4 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/canvas.js +9 -4
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/console.js +22 -7
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/editpanel.js +2 -12
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/shortcuts.js +21 -7
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/views.py +15 -5
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/.github/workflows/publish-to-pypi.yml +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/.gitignore +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/.readthedocs.yaml +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/DEVLOG.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/DEVPLAN.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/README.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/TRIAGE.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/.gitignore +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/_static/.gitkeep +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/ai-integration.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/browser-mode.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/commands.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/conf.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/configuration.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/getting-started.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/index.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/interactive-mode.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/item-data.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/managing-the-tree.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/requirements.txt +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/scripts/bump_version.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/config.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/errors.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/json_dir_database.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/llm_models.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/models.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/ai.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/pomodoro.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/repair_database.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/user_setup.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/index.html +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/style.css +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/test/test_cli.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/test/test_execution.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/test/test_json_dir_database.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.1}/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
|
|
@@ -57,6 +81,25 @@ def get_tree():
|
|
|
57
81
|
|
|
58
82
|
class CommandRequest(BaseModel):
|
|
59
83
|
input: str
|
|
84
|
+
# raw filter args from the last `show <attr><op>value ...` the frontend
|
|
85
|
+
# ran, if any — so a mutation's refreshed tree stays scoped to that view
|
|
86
|
+
# instead of snapping back to the whole forest
|
|
87
|
+
filter: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _response_tree(filter_str):
|
|
91
|
+
"""The tree a mutation should return: pruned to `filter_str`'s matches
|
|
92
|
+
(+ their parent chains) when one is active, else the whole forest."""
|
|
93
|
+
if not filter_str:
|
|
94
|
+
return _full_tree()
|
|
95
|
+
tokens = _parse_arg_string(filter_str)
|
|
96
|
+
if not looks_like_filters(tokens):
|
|
97
|
+
return _full_tree()
|
|
98
|
+
try:
|
|
99
|
+
exprs = parse_filters(tokens)
|
|
100
|
+
except FilterError:
|
|
101
|
+
return _full_tree()
|
|
102
|
+
return _filtered_tree(with_ancestors(db, match_items(db, exprs)))
|
|
60
103
|
|
|
61
104
|
|
|
62
105
|
@app.post("/api/command")
|
|
@@ -65,7 +108,7 @@ def run_command(request: CommandRequest):
|
|
|
65
108
|
args, kwargs = _parse_remaining(arg_parts)
|
|
66
109
|
|
|
67
110
|
if not args:
|
|
68
|
-
return {"output": "", "tree":
|
|
111
|
+
return {"output": "", "tree": _response_tree(request.filter), "focus": None}
|
|
69
112
|
|
|
70
113
|
if args[0] == "show":
|
|
71
114
|
return _run_show(args)
|
|
@@ -77,12 +120,17 @@ def run_command(request: CommandRequest):
|
|
|
77
120
|
except TaskCLIError:
|
|
78
121
|
pass # Controller.throw_error() already printed the error into `output`
|
|
79
122
|
|
|
80
|
-
return {"output": output.getvalue(), "tree":
|
|
123
|
+
return {"output": output.getvalue(), "tree": _response_tree(request.filter), "focus": None}
|
|
81
124
|
|
|
82
125
|
|
|
83
126
|
def _run_show(args):
|
|
84
127
|
"""`show` is read-only from the browser's perspective: it never mutates
|
|
85
128
|
the db, it just tells the frontend which node to focus/center on."""
|
|
129
|
+
show_args = list(args[1:])
|
|
130
|
+
|
|
131
|
+
if looks_like_filters(show_args):
|
|
132
|
+
return _run_filter(show_args)
|
|
133
|
+
|
|
86
134
|
target = args[1] if len(args) > 1 else "all"
|
|
87
135
|
|
|
88
136
|
if target == "all":
|
|
@@ -100,3 +148,25 @@ def _run_show(args):
|
|
|
100
148
|
}
|
|
101
149
|
|
|
102
150
|
return {"output": "", "tree": _full_tree(), "focus": str(item.id)}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _run_filter(show_args):
|
|
154
|
+
"""`show attr<op>value ...` — return a pruned tree (matches + their parent
|
|
155
|
+
chains) plus a `filtered` flag so the frontend fits the view to it."""
|
|
156
|
+
try:
|
|
157
|
+
exprs = parse_filters(show_args)
|
|
158
|
+
except FilterError as e:
|
|
159
|
+
return {"output": str(e), "tree": _full_tree(), "focus": None}
|
|
160
|
+
|
|
161
|
+
matched = match_items(db, exprs)
|
|
162
|
+
if not matched:
|
|
163
|
+
return {"output": "No items match those filters", "tree": _full_tree(), "focus": None}
|
|
164
|
+
|
|
165
|
+
keep = with_ancestors(db, matched)
|
|
166
|
+
plural = "s" if len(matched) != 1 else ""
|
|
167
|
+
return {
|
|
168
|
+
"output": f"{len(matched)} item{plural} matched",
|
|
169
|
+
"tree": _filtered_tree(keep),
|
|
170
|
+
"focus": None,
|
|
171
|
+
"filtered": True,
|
|
172
|
+
}
|
|
@@ -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
|
|
@@ -403,7 +403,9 @@ function fitAll(duration = STYLE.zoom.focusDurationMs) {
|
|
|
403
403
|
|
|
404
404
|
const pad = 60; // screen px of breathing room around the content
|
|
405
405
|
const scale = Math.min(
|
|
406
|
-
|
|
406
|
+
// don't auto-zoom in past the focus threshold — a small subset
|
|
407
|
+
// shouldn't fill the screen; the user can still wheel in past this
|
|
408
|
+
STYLE.zoom.focusScale,
|
|
407
409
|
Math.max(
|
|
408
410
|
STYLE.zoom.min,
|
|
409
411
|
Math.min(
|
|
@@ -741,10 +743,13 @@ canvas.addEventListener("dblclick", (e) => {
|
|
|
741
743
|
const rect = canvas.getBoundingClientRect();
|
|
742
744
|
const { x, y } = screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
743
745
|
const clicked = nodes.find(node => hitTest(node, x, y));
|
|
746
|
+
if (!clicked) return;
|
|
744
747
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
+
// single click already selected it; double click also opens the editor
|
|
749
|
+
selectedNode = clicked;
|
|
750
|
+
if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(clicked));
|
|
751
|
+
if (typeof openEditPanel === "function") openEditPanel();
|
|
752
|
+
focusOnNode(clicked); // after openEditPanel so the ease tracks the narrowed canvas
|
|
748
753
|
})
|
|
749
754
|
|
|
750
755
|
canvas.addEventListener("mousedown", (e) => {
|
|
@@ -29,6 +29,12 @@ function appendLine(text) {
|
|
|
29
29
|
consoleScrollback.scrollTop = consoleScrollback.scrollHeight;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// raw filter args from the last `show <attr><op>value ...` query (e.g.
|
|
33
|
+
// `priority>3 status="IN PROGRESS"`), or null. Sent with every /api/command
|
|
34
|
+
// POST so a mutation's refreshed tree stays scoped to the same filtered view.
|
|
35
|
+
// Cleared by `show all` and by navigating to a specific node.
|
|
36
|
+
let activeFilter = null;
|
|
37
|
+
|
|
32
38
|
const PAN_AMOUNT = 250; // screen pixels per pan command
|
|
33
39
|
const ZOOM_FACTOR = 1.5;
|
|
34
40
|
|
|
@@ -62,10 +68,12 @@ function resolveDotRefs(command) {
|
|
|
62
68
|
// POST a raw command string to the shared endpoint; returns the parsed
|
|
63
69
|
// { output, tree, focus } (or throws). Callers decide what to echo.
|
|
64
70
|
async function postCommand(input) {
|
|
71
|
+
const body = { input };
|
|
72
|
+
if (activeFilter) body.filter = activeFilter;
|
|
65
73
|
const res = await fetch("/api/command", {
|
|
66
74
|
method: "POST",
|
|
67
75
|
headers: { "Content-Type": "application/json" },
|
|
68
|
-
body: JSON.stringify(
|
|
76
|
+
body: JSON.stringify(body),
|
|
69
77
|
});
|
|
70
78
|
return res.json();
|
|
71
79
|
}
|
|
@@ -82,6 +90,7 @@ async function showAll() {
|
|
|
82
90
|
return;
|
|
83
91
|
}
|
|
84
92
|
applyTree(tree);
|
|
93
|
+
activeFilter = null; // `show all` drops any filter
|
|
85
94
|
selectedNode = rootNode;
|
|
86
95
|
if (typeof onNodeSelected === "function") onNodeSelected(null);
|
|
87
96
|
fitAll();
|
|
@@ -151,7 +160,18 @@ consoleInput.addEventListener("keydown", async (e) => {
|
|
|
151
160
|
|
|
152
161
|
applyTree(data.tree);
|
|
153
162
|
|
|
163
|
+
// `show <attr><op><value> ...` — server returns a pruned tree + a flag
|
|
164
|
+
if (data.filtered) {
|
|
165
|
+
activeFilter = target; // remember it so mutations keep this view
|
|
166
|
+
selectedNode = rootNode;
|
|
167
|
+
if (typeof onNodeSelected === "function") onNodeSelected(null);
|
|
168
|
+
fitAll();
|
|
169
|
+
if (data.output) appendLine(data.output);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
154
173
|
if (data.focus) {
|
|
174
|
+
activeFilter = null; // navigated to a specific node — no longer filtered
|
|
155
175
|
const node = nodes.find(n => n.id === String(data.focus));
|
|
156
176
|
if (node) {
|
|
157
177
|
selectedNode = node;
|
|
@@ -199,12 +219,7 @@ consoleInput.addEventListener("keydown", async (e) => {
|
|
|
199
219
|
|
|
200
220
|
let data;
|
|
201
221
|
try {
|
|
202
|
-
|
|
203
|
-
method: "POST",
|
|
204
|
-
headers: { "Content-Type": "application/json" },
|
|
205
|
-
body: JSON.stringify({ input: command }),
|
|
206
|
-
});
|
|
207
|
-
data = await res.json();
|
|
222
|
+
data = await postCommand(command);
|
|
208
223
|
} catch (err) {
|
|
209
224
|
appendLine("Error: " + err.message);
|
|
210
225
|
return;
|
|
@@ -64,12 +64,7 @@ async function sendFieldUpdate(item, def, value) {
|
|
|
64
64
|
|
|
65
65
|
let data;
|
|
66
66
|
try {
|
|
67
|
-
|
|
68
|
-
method: "POST",
|
|
69
|
-
headers: { "Content-Type": "application/json" },
|
|
70
|
-
body: JSON.stringify({ input: command }),
|
|
71
|
-
});
|
|
72
|
-
data = await res.json();
|
|
67
|
+
data = await postCommand(command); // carries the active filter, if any
|
|
73
68
|
} catch (err) {
|
|
74
69
|
appendLine(`Error updating item ${item.id}: ${err.message}`);
|
|
75
70
|
return;
|
|
@@ -87,12 +82,7 @@ async function sendComment(itemId, text) {
|
|
|
87
82
|
|
|
88
83
|
let data;
|
|
89
84
|
try {
|
|
90
|
-
|
|
91
|
-
method: "POST",
|
|
92
|
-
headers: { "Content-Type": "application/json" },
|
|
93
|
-
body: JSON.stringify({ input: command }),
|
|
94
|
-
});
|
|
95
|
-
data = await res.json();
|
|
85
|
+
data = await postCommand(command); // carries the active filter, if any
|
|
96
86
|
} catch (err) {
|
|
97
87
|
appendLine(`Error adding comment to item ${itemId}: ${err.message}`);
|
|
98
88
|
return;
|
|
@@ -31,10 +31,19 @@ function toggleShortcutPanel(force) {
|
|
|
31
31
|
shortcutToggle.addEventListener("click", () => toggleShortcutPanel());
|
|
32
32
|
|
|
33
33
|
function toggleEditPanel() {
|
|
34
|
-
if (editPanel.classList.contains("expanded"))
|
|
34
|
+
if (editPanel.classList.contains("expanded")) closeEditPanelAndRefocus();
|
|
35
35
|
else openEditPanel();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// close the edit panel, then ease the selected node back to center as the
|
|
39
|
+
// canvas widens out — without this the panel's width animation re-anchors on
|
|
40
|
+
// the old center and leaves the node cut off at the edge
|
|
41
|
+
function closeEditPanelAndRefocus() {
|
|
42
|
+
closeEditPanel();
|
|
43
|
+
const node = selectedRealNode();
|
|
44
|
+
if (node) focusOnNode(node);
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
// --- action helpers --------------------------------------------------------
|
|
39
48
|
|
|
40
49
|
// the selected node, or null when the selection is the synthetic root
|
|
@@ -69,9 +78,6 @@ function toggleDoneSelected() {
|
|
|
69
78
|
function deleteSelected() {
|
|
70
79
|
const node = selectedRealNode();
|
|
71
80
|
if (!node) return;
|
|
72
|
-
const item = itemForNode(node);
|
|
73
|
-
const label = item ? item.name : node.id;
|
|
74
|
-
if (!window.confirm(`Delete "${label}" and everything under it?`)) return;
|
|
75
81
|
runMutation(`delete ${node.id}`);
|
|
76
82
|
}
|
|
77
83
|
|
|
@@ -115,6 +121,10 @@ function handleEscape() {
|
|
|
115
121
|
el.blur();
|
|
116
122
|
return;
|
|
117
123
|
}
|
|
124
|
+
if (editPanel.classList.contains("expanded")) {
|
|
125
|
+
closeEditPanelAndRefocus();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
118
128
|
// deselect, but leave the camera where it is — Esc shouldn't move/zoom
|
|
119
129
|
selectedNode = rootNode;
|
|
120
130
|
if (typeof onNodeSelected === "function") onNodeSelected(null);
|
|
@@ -130,9 +140,13 @@ const PAN_VEC = { ArrowUp: [0, 1], ArrowDown: [0, -1], ArrowLeft: [1, 0], ArrowR
|
|
|
130
140
|
const SHORTCUTS = [
|
|
131
141
|
{ section: "Panels" },
|
|
132
142
|
{
|
|
133
|
-
combos: [{ key: "`" }], glyphs: ["`"], desc: "
|
|
143
|
+
combos: [{ key: "`" }, { key: "~" }], glyphs: ["`"], desc: "Terminal (focus / close)",
|
|
134
144
|
whileTyping: (el) => el === consoleInput, // also closes it from inside
|
|
135
|
-
run: () =>
|
|
145
|
+
run: () => {
|
|
146
|
+
if (document.activeElement === consoleInput) toggleConsole(false); // close from inside
|
|
147
|
+
else if (consolePanel.classList.contains("expanded")) consoleInput.focus(); // open but unfocused -> jump in
|
|
148
|
+
else toggleConsole(true); // closed -> open (focuses input)
|
|
149
|
+
},
|
|
136
150
|
},
|
|
137
151
|
{
|
|
138
152
|
combos: [{ key: "?" }], glyphs: ["?"], desc: "Toggle this panel",
|
|
@@ -185,7 +199,7 @@ const SHORTCUTS = [
|
|
|
185
199
|
run: () => toggleDoneSelected(),
|
|
186
200
|
},
|
|
187
201
|
{
|
|
188
|
-
combos: [{ key: "Delete" }], glyphs: ["Del"], desc: "Delete
|
|
202
|
+
combos: [{ key: "Delete" }], glyphs: ["Del"], desc: "Delete",
|
|
189
203
|
run: () => deleteSelected(),
|
|
190
204
|
},
|
|
191
205
|
];
|
|
@@ -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
|