taskai-cli 1.7.1__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.
Files changed (47) hide show
  1. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/PKG-INFO +1 -1
  2. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/pyproject.toml +1 -1
  3. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/browser.py +51 -0
  4. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/cli.py +38 -3
  5. taskai_cli-1.8.0/taskai/filters.py +150 -0
  6. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/help_menu.py +4 -0
  7. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/console.js +9 -0
  8. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/views.py +15 -5
  9. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/.github/workflows/publish-to-pypi.yml +0 -0
  10. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/.gitignore +0 -0
  11. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/.readthedocs.yaml +0 -0
  12. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/DEVLOG.md +0 -0
  13. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/DEVPLAN.md +0 -0
  14. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/README.md +0 -0
  15. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/TRIAGE.md +0 -0
  16. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/.gitignore +0 -0
  17. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/_static/.gitkeep +0 -0
  18. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/ai-integration.md +0 -0
  19. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/browser-mode.md +0 -0
  20. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/commands.md +0 -0
  21. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/conf.py +0 -0
  22. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/configuration.md +0 -0
  23. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/getting-started.md +0 -0
  24. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/index.md +0 -0
  25. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/interactive-mode.md +0 -0
  26. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/item-data.md +0 -0
  27. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/managing-the-tree.md +0 -0
  28. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/requirements.txt +0 -0
  29. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/scripts/bump_version.py +0 -0
  30. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/config.py +0 -0
  31. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/errors.py +0 -0
  32. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/json_dir_database.py +0 -0
  33. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/llm_models.py +0 -0
  34. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/models.py +0 -0
  35. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/ai.py +0 -0
  36. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/pomodoro.py +0 -0
  37. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/repair_database.py +0 -0
  38. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/user_setup.py +0 -0
  39. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/canvas.js +0 -0
  40. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/editpanel.js +0 -0
  41. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/index.html +0 -0
  42. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/shortcuts.js +0 -0
  43. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/style.css +0 -0
  44. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_cli.py +0 -0
  45. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_execution.py +0 -0
  46. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_json_dir_database.py +0 -0
  47. {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskai-cli
3
- Version: 1.7.1
3
+ Version: 1.8.0
4
4
  Author-email: Alex Paskal <alexcpaskal@gmail.com>
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: fastapi>=0.141.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskai-cli"
3
- version = "1.7.1"
3
+ version = "1.8.0"
4
4
  description = ""
5
5
  authors = [{name = "Alex Paskal", email = "alexcpaskal@gmail.com"}]
6
6
  readme = "README.md"
@@ -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
- match args[1]:
415
- case "all": Controller.show_all(*args[2:], **kwargs)
416
- case _: Controller.show_item(args[1], **kwargs)
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
@@ -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) {
@@ -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
- display_str: str = "id name status",
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