jott-cli 0.5.2__tar.gz → 0.5.4__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.
- {jott_cli-0.5.2/jott_cli.egg-info → jott_cli-0.5.4}/PKG-INFO +1 -1
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/__init__.py +1 -1
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/_app_navigation_mixin.py +5 -3
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/_dispatch_mixin.py +106 -3
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/app.py +76 -29
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_transfer_mixin.py +4 -2
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/handler.py +2 -2
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_delete_mixin.py +10 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/projects/registry.py +8 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_footer.py +3 -3
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_help.py +12 -12
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_tasks.py +19 -6
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/picker.py +59 -56
- jott_cli-0.5.4/jot/ui/rendering.py +24 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/SOURCES.txt +1 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/pyproject.toml +1 -1
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_dispatch.py +1 -2
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_jot.py +223 -108
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_subtask_notes.py +340 -1
- {jott_cli-0.5.2 → jott_cli-0.5.4}/LICENSE +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/README.md +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/config.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/config.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/views.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_metadata_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_crud_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_metadata_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_subtask_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/constants.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/input.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/styles.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/setup.cfg +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/setup.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_highlight.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_input.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_picker.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_styles.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_terminal_wrap.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_today_filter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jott-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
4
4
|
Summary: Feature-rich interactive CLI task manager with AI integration, calendar sync, and keyword automation
|
|
5
5
|
Author-email: Scott Anderson <sonander@gmail.com>
|
|
6
6
|
Maintainer-email: Scott Anderson <sonander@gmail.com>
|
|
@@ -7,7 +7,7 @@ import importlib.util
|
|
|
7
7
|
import os
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
__version__ = "0.5.
|
|
10
|
+
__version__ = "0.5.4"
|
|
11
11
|
|
|
12
12
|
# When building Sphinx docs, skip the dynamic import bridge and heavy deps.
|
|
13
13
|
# Set JOT_SPHINX_BUILD=1 in docs/sphinx/conf.py before importing jot.
|
|
@@ -17,8 +17,11 @@ class AppNavigationMixin:
|
|
|
17
17
|
def _handle_navigation(self, key, tasks_to_display):
|
|
18
18
|
"""Process UP/DOWN/SHIFT_UP/SHIFT_DOWN. Returns True if
|
|
19
19
|
the key was a navigation key."""
|
|
20
|
+
st = self.state
|
|
20
21
|
filtered = (
|
|
21
|
-
tasks_to_display
|
|
22
|
+
tasks_to_display
|
|
23
|
+
if st.show_today_only or st.collapsed_parents
|
|
24
|
+
else None)
|
|
22
25
|
|
|
23
26
|
if key == 'UP':
|
|
24
27
|
self.task_manager.set_prev_current(filtered)
|
|
@@ -35,7 +38,7 @@ class AppNavigationMixin:
|
|
|
35
38
|
return False
|
|
36
39
|
|
|
37
40
|
def _handle_toggle(self, key):
|
|
38
|
-
"""Process shared toggle keys A/O/Y/F
|
|
41
|
+
"""Process shared toggle keys A/O/Y/F. Returns True if
|
|
39
42
|
the key was a toggle key."""
|
|
40
43
|
st = self.state
|
|
41
44
|
|
|
@@ -44,7 +47,6 @@ class AppNavigationMixin:
|
|
|
44
47
|
'O': ('sort_by_day', None),
|
|
45
48
|
'Y': ('show_today_only', None),
|
|
46
49
|
'F': ('show_notes_inline', 'inline notes'),
|
|
47
|
-
'?': ('show_shortcuts', 'shortcuts'),
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
if key not in toggle_map:
|
|
@@ -6,6 +6,7 @@ from jot.core.constants import (
|
|
|
6
6
|
MODE_QUICK_ADD, MODE_COMMAND, MODE_MULTISELECT,
|
|
7
7
|
MODE_FUZZY_SEARCH, MODE_ALL_CATEGORIES,
|
|
8
8
|
)
|
|
9
|
+
from jot.ui.input import get_key
|
|
9
10
|
from jot.ui.styles import RESET, CYAN
|
|
10
11
|
|
|
11
12
|
|
|
@@ -29,7 +30,7 @@ class DispatchMixin:
|
|
|
29
30
|
st.input_buffer = st.input_buffer[:-1]
|
|
30
31
|
return
|
|
31
32
|
|
|
32
|
-
if key == '
|
|
33
|
+
if key == '\x03': # Ctrl+C
|
|
33
34
|
self._handle_switch_category()
|
|
34
35
|
return
|
|
35
36
|
|
|
@@ -79,6 +80,10 @@ class DispatchMixin:
|
|
|
79
80
|
if self._handle_navigation(key, tasks_to_display):
|
|
80
81
|
return
|
|
81
82
|
|
|
83
|
+
if key == '.':
|
|
84
|
+
self._handle_dot_chord()
|
|
85
|
+
return
|
|
86
|
+
|
|
82
87
|
if len(key) == 1 and key.isprintable():
|
|
83
88
|
st.input_buffer += key
|
|
84
89
|
|
|
@@ -184,7 +189,9 @@ class DispatchMixin:
|
|
|
184
189
|
return
|
|
185
190
|
|
|
186
191
|
filtered = (
|
|
187
|
-
tasks_to_display
|
|
192
|
+
tasks_to_display
|
|
193
|
+
if st.show_today_only or st.collapsed_parents
|
|
194
|
+
else None)
|
|
188
195
|
|
|
189
196
|
if key == 'UP':
|
|
190
197
|
self.task_manager.set_prev_current(filtered)
|
|
@@ -286,6 +293,100 @@ class DispatchMixin:
|
|
|
286
293
|
self.task_manager.set_current(
|
|
287
294
|
st.filtered_tasks[idx + 1][0]['id'])
|
|
288
295
|
|
|
296
|
+
def _handle_dot_chord(self):
|
|
297
|
+
"""Handle '.' leader key — wait for second key to form chord."""
|
|
298
|
+
st = self.state
|
|
299
|
+
second = get_key(timeout=0.5)
|
|
300
|
+
if second == 's':
|
|
301
|
+
self._toggle_collapse()
|
|
302
|
+
st.input_buffer = ""
|
|
303
|
+
return
|
|
304
|
+
if second == 'c':
|
|
305
|
+
self.command_handler.copy_to_clipboard()
|
|
306
|
+
st.input_buffer = ""
|
|
307
|
+
return
|
|
308
|
+
if second == '?':
|
|
309
|
+
st.show_shortcuts = not st.show_shortcuts
|
|
310
|
+
st.input_buffer = ""
|
|
311
|
+
return
|
|
312
|
+
if second == 'i':
|
|
313
|
+
self.command_handler.import_from_gcal()
|
|
314
|
+
st.input_buffer = ""
|
|
315
|
+
return
|
|
316
|
+
if second == 'g':
|
|
317
|
+
self.command_handler.web_search()
|
|
318
|
+
st.input_buffer = ""
|
|
319
|
+
return
|
|
320
|
+
# Not a chord — treat '.' as normal input
|
|
321
|
+
st.input_buffer += '.'
|
|
322
|
+
if second and second != '.':
|
|
323
|
+
st.input_buffer += second
|
|
324
|
+
|
|
325
|
+
def _toggle_collapse(self):
|
|
326
|
+
"""Toggle collapse state for current task's parent group."""
|
|
327
|
+
task = self.task_manager.get_current_task()
|
|
328
|
+
if not task:
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
tasks = self.task_manager.get_tasks()
|
|
332
|
+
collapsed = self.state.collapsed_parents
|
|
333
|
+
|
|
334
|
+
# Check if current task is a parent (has descendants)
|
|
335
|
+
has_children = any(
|
|
336
|
+
f'parent:{task["id"]}' in t.get('labels', [])
|
|
337
|
+
for t in tasks
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if has_children:
|
|
341
|
+
target_id = task['id']
|
|
342
|
+
else:
|
|
343
|
+
# Check if it's a child — toggle its parent
|
|
344
|
+
target_id = None
|
|
345
|
+
for label in task.get('labels', []):
|
|
346
|
+
if label.startswith('parent:'):
|
|
347
|
+
pid = label.split(':', 1)[1]
|
|
348
|
+
try:
|
|
349
|
+
target_id = int(pid)
|
|
350
|
+
except ValueError:
|
|
351
|
+
target_id = pid
|
|
352
|
+
break
|
|
353
|
+
|
|
354
|
+
if target_id is None:
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
# Count descendants for feedback
|
|
358
|
+
children_of = {}
|
|
359
|
+
for t in tasks:
|
|
360
|
+
for label in t.get('labels', []):
|
|
361
|
+
if label.startswith('parent:'):
|
|
362
|
+
pid = label.split(':', 1)[1]
|
|
363
|
+
try:
|
|
364
|
+
pid = int(pid)
|
|
365
|
+
except ValueError:
|
|
366
|
+
pass
|
|
367
|
+
children_of.setdefault(pid, []).append(t['id'])
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
count = 0
|
|
371
|
+
stack = list(children_of.get(target_id, []))
|
|
372
|
+
seen = set()
|
|
373
|
+
while stack:
|
|
374
|
+
cid = stack.pop()
|
|
375
|
+
if cid not in seen:
|
|
376
|
+
seen.add(cid)
|
|
377
|
+
count += 1
|
|
378
|
+
stack.extend(children_of.get(cid, []))
|
|
379
|
+
|
|
380
|
+
if target_id in collapsed:
|
|
381
|
+
collapsed.discard(target_id)
|
|
382
|
+
print(f"\n{CYAN}▼ Expanded {count} subtask"
|
|
383
|
+
f"{'s' if count != 1 else ''}{RESET}")
|
|
384
|
+
else:
|
|
385
|
+
collapsed.add(target_id)
|
|
386
|
+
print(f"\n{CYAN}▶ Collapsed {count} subtask"
|
|
387
|
+
f"{'s' if count != 1 else ''}{RESET}")
|
|
388
|
+
time.sleep(0.3)
|
|
389
|
+
|
|
289
390
|
def _dispatch_all_categories(self, key, tasks_to_display):
|
|
290
391
|
"""Handle a keypress in ALL_CATEGORIES mode."""
|
|
291
392
|
st = self.state
|
|
@@ -295,7 +396,9 @@ class DispatchMixin:
|
|
|
295
396
|
return
|
|
296
397
|
|
|
297
398
|
filtered = (
|
|
298
|
-
tasks_to_display
|
|
399
|
+
tasks_to_display
|
|
400
|
+
if st.show_today_only or st.collapsed_parents
|
|
401
|
+
else None)
|
|
299
402
|
if key == 'UP':
|
|
300
403
|
self.task_manager.set_prev_current(filtered)
|
|
301
404
|
elif key == 'DOWN':
|
|
@@ -20,6 +20,7 @@ from jot.utils.date_utils import (
|
|
|
20
20
|
from jot.utils.text_utils import fuzzy_match
|
|
21
21
|
from jot._dispatch_mixin import DispatchMixin
|
|
22
22
|
from jot._app_navigation_mixin import AppNavigationMixin
|
|
23
|
+
from jot.ui.rendering import buffered_output
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
# ---------------------------------------------------------------------------
|
|
@@ -44,6 +45,7 @@ class AppState:
|
|
|
44
45
|
filtered_tasks: list = field(default_factory=list)
|
|
45
46
|
archived_task_ids: set = field(default_factory=set)
|
|
46
47
|
match_positions: dict = field(default_factory=dict)
|
|
48
|
+
collapsed_parents: set = field(default_factory=set)
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
# ---------------------------------------------------------------------------
|
|
@@ -57,16 +59,13 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
57
59
|
'M': 'move_task_to_project',
|
|
58
60
|
'\x0b': 'copy_task_to_project', # Ctrl+K
|
|
59
61
|
'\x14': 'transfer_task_to_category', # Ctrl+T
|
|
60
|
-
'S': 'web_search',
|
|
61
62
|
'\x13': 'sync_subtasks', # Ctrl+S
|
|
62
|
-
'
|
|
63
|
+
'\x04': 'delete_current', # Ctrl+D
|
|
63
64
|
'W': 'assign_day',
|
|
64
65
|
'P': 'set_priority',
|
|
65
66
|
'H': 'set_priority_high',
|
|
66
67
|
'X': 'set_status',
|
|
67
68
|
'G': 'export_to_gcal',
|
|
68
|
-
'I': 'import_from_gcal',
|
|
69
|
-
'K': 'copy_to_clipboard',
|
|
70
69
|
'E': 'trigger_keyword_action',
|
|
71
70
|
'N': 'edit_task_notes',
|
|
72
71
|
'\x15': 'open_url', # Ctrl+U
|
|
@@ -93,6 +92,7 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
93
92
|
self.project_name = project_name
|
|
94
93
|
|
|
95
94
|
self.state = AppState()
|
|
95
|
+
self._needs_render = True
|
|
96
96
|
self._last_mtime = (
|
|
97
97
|
os.path.getmtime(task_manager.storage_file)
|
|
98
98
|
if task_manager.storage_file.exists() else 0)
|
|
@@ -113,8 +113,12 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
113
113
|
"""Inner event loop — separated so run() can wrap with
|
|
114
114
|
try/finally."""
|
|
115
115
|
while self.state.running:
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
if self._needs_render:
|
|
117
|
+
tasks_to_display = self._prepare_tasks()
|
|
118
|
+
self._render(tasks_to_display)
|
|
119
|
+
self._needs_render = False
|
|
120
|
+
else:
|
|
121
|
+
tasks_to_display = self._prepare_tasks()
|
|
118
122
|
|
|
119
123
|
try:
|
|
120
124
|
key = get_key()
|
|
@@ -124,6 +128,9 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
124
128
|
if self._handle_paste(key):
|
|
125
129
|
continue
|
|
126
130
|
|
|
131
|
+
# Any real key press triggers a render
|
|
132
|
+
self._needs_render = True
|
|
133
|
+
|
|
127
134
|
# Global handlers (any mode)
|
|
128
135
|
if key == '\x05': # Ctrl+E
|
|
129
136
|
self.command_handler.edit_current()
|
|
@@ -159,6 +166,12 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
159
166
|
"""Fetch, filter, and sort the task list for display."""
|
|
160
167
|
tasks = self.task_manager.get_tasks()
|
|
161
168
|
|
|
169
|
+
# Stash full list before collapse filtering so display can
|
|
170
|
+
# compute correct descendant counts for collapsed parents.
|
|
171
|
+
self._all_tasks = tasks
|
|
172
|
+
|
|
173
|
+
if self.state.collapsed_parents:
|
|
174
|
+
tasks = self._filter_collapsed(tasks)
|
|
162
175
|
if self.state.show_today_only:
|
|
163
176
|
today = get_today_day_name()
|
|
164
177
|
tasks = filter_today_tasks(tasks, today)
|
|
@@ -167,6 +180,33 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
167
180
|
|
|
168
181
|
return self._get_filtered_tasks(tasks)
|
|
169
182
|
|
|
183
|
+
def _filter_collapsed(self, tasks):
|
|
184
|
+
"""Remove descendants of collapsed parents from the task list."""
|
|
185
|
+
# Build parent→children map
|
|
186
|
+
children_of = {}
|
|
187
|
+
for task in tasks:
|
|
188
|
+
for label in task.get('labels', []):
|
|
189
|
+
if label.startswith('parent:'):
|
|
190
|
+
pid = label.split(':', 1)[1]
|
|
191
|
+
try:
|
|
192
|
+
pid = int(pid)
|
|
193
|
+
except ValueError:
|
|
194
|
+
pass
|
|
195
|
+
children_of.setdefault(pid, []).append(task['id'])
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
# Collect all descendants of collapsed parents (BFS)
|
|
199
|
+
hidden = set()
|
|
200
|
+
for root_id in self.state.collapsed_parents:
|
|
201
|
+
stack = list(children_of.get(root_id, []))
|
|
202
|
+
while stack:
|
|
203
|
+
cid = stack.pop()
|
|
204
|
+
if cid not in hidden:
|
|
205
|
+
hidden.add(cid)
|
|
206
|
+
stack.extend(children_of.get(cid, []))
|
|
207
|
+
|
|
208
|
+
return [t for t in tasks if t['id'] not in hidden]
|
|
209
|
+
|
|
170
210
|
def _get_filtered_tasks(self, tasks_to_display):
|
|
171
211
|
"""Apply fuzzy-search filtering when in FUZZY_SEARCH mode."""
|
|
172
212
|
st = self.state
|
|
@@ -214,30 +254,35 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
214
254
|
|
|
215
255
|
def _render(self, tasks_to_display):
|
|
216
256
|
"""Clear screen and draw the current view."""
|
|
217
|
-
|
|
218
|
-
|
|
257
|
+
with buffered_output():
|
|
258
|
+
sys.stdout.write('\033[?25l\033[H\033[J')
|
|
259
|
+
st = self.state
|
|
260
|
+
|
|
261
|
+
if st.mode == MODE_ALL_CATEGORIES:
|
|
262
|
+
self._render_all_categories()
|
|
263
|
+
else:
|
|
264
|
+
all_tasks = (
|
|
265
|
+
self._all_tasks
|
|
266
|
+
if st.collapsed_parents else None)
|
|
267
|
+
display_tasks(
|
|
268
|
+
tasks_to_display, st.mode, st.input_buffer,
|
|
269
|
+
self.project_name,
|
|
270
|
+
category=self.task_manager.category,
|
|
271
|
+
is_global=self.task_manager.is_global,
|
|
272
|
+
show_archived=st.show_archived,
|
|
273
|
+
archived_tasks=self.task_manager.archived,
|
|
274
|
+
selected_tasks=st.selected_tasks,
|
|
275
|
+
search_buffer=st.search_buffer,
|
|
276
|
+
archived_task_ids=st.archived_task_ids,
|
|
277
|
+
include_archived_in_search=st.include_archived_in_search,
|
|
278
|
+
show_shortcuts=st.show_shortcuts,
|
|
279
|
+
show_notes_inline=st.show_notes_inline,
|
|
280
|
+
match_positions=st.match_positions,
|
|
281
|
+
collapsed_parents=st.collapsed_parents,
|
|
282
|
+
all_tasks=all_tasks,
|
|
283
|
+
)
|
|
219
284
|
|
|
220
|
-
|
|
221
|
-
self._render_all_categories()
|
|
222
|
-
else:
|
|
223
|
-
display_tasks(
|
|
224
|
-
tasks_to_display, st.mode, st.input_buffer,
|
|
225
|
-
self.project_name,
|
|
226
|
-
category=self.task_manager.category,
|
|
227
|
-
is_global=self.task_manager.is_global,
|
|
228
|
-
show_archived=st.show_archived,
|
|
229
|
-
archived_tasks=self.task_manager.archived,
|
|
230
|
-
selected_tasks=st.selected_tasks,
|
|
231
|
-
search_buffer=st.search_buffer,
|
|
232
|
-
archived_task_ids=st.archived_task_ids,
|
|
233
|
-
include_archived_in_search=st.include_archived_in_search,
|
|
234
|
-
show_shortcuts=st.show_shortcuts,
|
|
235
|
-
show_notes_inline=st.show_notes_inline,
|
|
236
|
-
match_positions=st.match_positions,
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
sys.stdout.write('\033[?25h')
|
|
240
|
-
sys.stdout.flush()
|
|
285
|
+
sys.stdout.write('\033[?25h')
|
|
241
286
|
|
|
242
287
|
def _render_all_categories(self):
|
|
243
288
|
"""Delegate ALL_CATEGORIES rendering to the display module."""
|
|
@@ -265,6 +310,7 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
265
310
|
if current_mtime != self._last_mtime:
|
|
266
311
|
self.task_manager._load_tasks()
|
|
267
312
|
self._last_mtime = current_mtime
|
|
313
|
+
self._needs_render = True
|
|
268
314
|
return True
|
|
269
315
|
|
|
270
316
|
def _handle_paste(self, key):
|
|
@@ -276,4 +322,5 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
276
322
|
if self.state.mode == MODE_QUICK_ADD:
|
|
277
323
|
pasted_text = pasted_text.replace('\r', '').replace('\n', '')
|
|
278
324
|
self.state.input_buffer += pasted_text
|
|
325
|
+
self._needs_render = True
|
|
279
326
|
return True
|
|
@@ -81,7 +81,8 @@ class TransferMixin:
|
|
|
81
81
|
task_text,
|
|
82
82
|
priority=current_task.get('priority', 'none'),
|
|
83
83
|
status=current_task.get('status', 'todo'),
|
|
84
|
-
labels=current_task.get('labels', [])
|
|
84
|
+
labels=[l for l in current_task.get('labels', [])
|
|
85
|
+
if not l.startswith('parent:')],
|
|
85
86
|
effort=current_task.get('effort', None),
|
|
86
87
|
)
|
|
87
88
|
|
|
@@ -146,7 +147,8 @@ class TransferMixin:
|
|
|
146
147
|
task_text,
|
|
147
148
|
priority=current_task.get('priority', 'none'),
|
|
148
149
|
status=current_task.get('status', 'todo'),
|
|
149
|
-
labels=current_task.get('labels', [])
|
|
150
|
+
labels=[l for l in current_task.get('labels', [])
|
|
151
|
+
if not l.startswith('parent:')],
|
|
150
152
|
effort=current_task.get('effort', None),
|
|
151
153
|
)
|
|
152
154
|
|
|
@@ -35,7 +35,7 @@ class CommandHandler(
|
|
|
35
35
|
'a': self.add_task,
|
|
36
36
|
'c': self.set_current,
|
|
37
37
|
'd': self.delete_task,
|
|
38
|
-
'
|
|
38
|
+
'\x04': self.delete_current, # Ctrl+D
|
|
39
39
|
'e': self.edit_task,
|
|
40
40
|
'E': self.edit_current,
|
|
41
41
|
'M': self.move_task_to_project,
|
|
@@ -46,7 +46,7 @@ class CommandHandler(
|
|
|
46
46
|
|
|
47
47
|
def handle(self, command):
|
|
48
48
|
"""Handle a command"""
|
|
49
|
-
# Check for exact match first (for case-sensitive commands like '
|
|
49
|
+
# Check for exact match first (for case-sensitive commands like 'E')
|
|
50
50
|
handler = self.commands.get(command)
|
|
51
51
|
if not handler:
|
|
52
52
|
# Fall back to lowercase
|
|
@@ -7,8 +7,17 @@ from datetime import datetime
|
|
|
7
7
|
class DeleteMixin:
|
|
8
8
|
"""Task removal, archival, and notes preservation."""
|
|
9
9
|
|
|
10
|
+
def _unlink_children(self, task_id):
|
|
11
|
+
"""Remove parent:{task_id} labels from all children."""
|
|
12
|
+
parent_label = f"parent:{task_id}"
|
|
13
|
+
for task in self.tasks:
|
|
14
|
+
labels = task.get('labels', [])
|
|
15
|
+
if parent_label in labels:
|
|
16
|
+
task['labels'] = [l for l in labels if l != parent_label]
|
|
17
|
+
|
|
10
18
|
def remove_task(self, task_id):
|
|
11
19
|
"""Archive a task by ID and set previous task as current"""
|
|
20
|
+
self._unlink_children(task_id)
|
|
12
21
|
org_file = self.save_notes_to_org_file(task_id)
|
|
13
22
|
if org_file:
|
|
14
23
|
print(f"\n\u2713 Notes saved to {os.path.basename(org_file)}")
|
|
@@ -43,6 +52,7 @@ class DeleteMixin:
|
|
|
43
52
|
|
|
44
53
|
def delete_task_permanently(self, task_id):
|
|
45
54
|
"""Permanently delete a task and release its ID for reuse"""
|
|
55
|
+
self._unlink_children(task_id)
|
|
46
56
|
org_file = self.save_notes_to_org_file(task_id)
|
|
47
57
|
if org_file:
|
|
48
58
|
print(f"\n\u2713 Notes saved to {os.path.basename(org_file)}")
|
|
@@ -117,6 +117,14 @@ class ProjectRegistry:
|
|
|
117
117
|
entry["usage_count"] = entry.get("usage_count", 0) + 1
|
|
118
118
|
self._save_registry()
|
|
119
119
|
|
|
120
|
+
def is_path_registered(self, path):
|
|
121
|
+
"""Check if a directory path is already registered"""
|
|
122
|
+
resolved = str(Path(path).expanduser().resolve())
|
|
123
|
+
for entry in self.projects.values():
|
|
124
|
+
if _get_path(entry) == resolved:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
120
128
|
def refresh(self):
|
|
121
129
|
"""Re-run auto-discovery"""
|
|
122
130
|
self._auto_discover()
|
|
@@ -33,12 +33,12 @@ def _render_quick_add(input_buffer, show_shortcuts):
|
|
|
33
33
|
f"{GREEN}[QUICK ADD]{RESET} Type task, {BOLD}Enter{RESET} "
|
|
34
34
|
f"to save | {BOLD}/{RESET}: search | {BOLD}ESC{RESET}: "
|
|
35
35
|
f"commands | {BOLD}Shift+V{RESET}: multi-select | "
|
|
36
|
-
f"{BOLD}
|
|
36
|
+
f"{BOLD}.?{RESET}: shortcuts"
|
|
37
37
|
)
|
|
38
38
|
if show_shortcuts:
|
|
39
39
|
from jot.ui.display_help import display_categorized_shortcuts
|
|
40
40
|
display_categorized_shortcuts()
|
|
41
|
-
print(f"→ {input_buffer}█", end=''
|
|
41
|
+
print(f"→ {input_buffer}█", end='')
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def _render_multiselect(selected_tasks):
|
|
@@ -106,7 +106,7 @@ def _render_command():
|
|
|
106
106
|
f"{BOLD}(h){RESET}elp | {BOLD}(q){RESET}uit"
|
|
107
107
|
)
|
|
108
108
|
print("-" * get_terminal_width())
|
|
109
|
-
print("Command: ", end=''
|
|
109
|
+
print("Command: ", end='')
|
|
110
110
|
|
|
111
111
|
|
|
112
112
|
def render_archived_section(archived_tasks):
|
|
@@ -11,7 +11,7 @@ def display_categorized_shortcuts():
|
|
|
11
11
|
("Tab", "Cycle through categories"),
|
|
12
12
|
("Ctrl+F", "Fuzzy search tasks"),
|
|
13
13
|
("Shift+Z", "Switch project"),
|
|
14
|
-
("
|
|
14
|
+
("Ctrl+C", "Switch category"),
|
|
15
15
|
("Shift+L", "Toggle all categories view"),
|
|
16
16
|
],
|
|
17
17
|
"Task Management": [
|
|
@@ -40,7 +40,7 @@ def display_categorized_shortcuts():
|
|
|
40
40
|
("Shift+W", "Assign day to task"),
|
|
41
41
|
("Shift+O", "Toggle day sorting"),
|
|
42
42
|
("Shift+Y", "Toggle today filter"),
|
|
43
|
-
("
|
|
43
|
+
("Ctrl+D", "Mark current as done"),
|
|
44
44
|
("Shift+J", "Mark as agent task"),
|
|
45
45
|
("*", "Highlight color picker"),
|
|
46
46
|
("~", "Quick highlight toggle"),
|
|
@@ -64,12 +64,12 @@ def display_categorized_shortcuts():
|
|
|
64
64
|
("Shift+4", "AI task suggestion"),
|
|
65
65
|
("Shift+E", "Execute keyword action"),
|
|
66
66
|
("Shift+G", "Google Calendar setup"),
|
|
67
|
-
("
|
|
68
|
-
("
|
|
67
|
+
(".i", "Import from Google Calendar"),
|
|
68
|
+
(".c", "Copy task to clipboard"),
|
|
69
69
|
("Ctrl+U", "Open URLs in task"),
|
|
70
70
|
],
|
|
71
71
|
"System": [
|
|
72
|
-
("h
|
|
72
|
+
("h/.?", "Show this help"),
|
|
73
73
|
("r", "Refresh display"),
|
|
74
74
|
("Ctrl+R", "Register current project"),
|
|
75
75
|
("q", "Quit"),
|
|
@@ -119,7 +119,7 @@ def display_help():
|
|
|
119
119
|
{CYAN}Tab{RESET} Cycle through categories
|
|
120
120
|
{CYAN}Ctrl+F{RESET} Fuzzy search tasks
|
|
121
121
|
{CYAN}Shift+Z{RESET} Switch between projects
|
|
122
|
-
{CYAN}
|
|
122
|
+
{CYAN}Ctrl+C{RESET} Switch between categories
|
|
123
123
|
{CYAN}Shift+L{RESET} Toggle all categories view
|
|
124
124
|
|
|
125
125
|
{BOLD}TASK MANAGEMENT:{RESET}
|
|
@@ -147,7 +147,7 @@ def display_help():
|
|
|
147
147
|
{CYAN}Shift+W{RESET} Assign day to task
|
|
148
148
|
{CYAN}Shift+O{RESET} Toggle day sorting
|
|
149
149
|
{CYAN}Shift+Y{RESET} Toggle today filter
|
|
150
|
-
{CYAN}
|
|
150
|
+
{CYAN}Ctrl+D{RESET} Mark current task as done
|
|
151
151
|
{CYAN}Shift+J{RESET} Mark task for agent/AI assistance
|
|
152
152
|
{CYAN}*{RESET} Highlight color picker (6 colors)
|
|
153
153
|
{CYAN}~{RESET} Quick highlight toggle (default color)
|
|
@@ -185,7 +185,7 @@ def display_help():
|
|
|
185
185
|
|
|
186
186
|
{CYAN}jott -c backend{RESET} Start in "backend" category
|
|
187
187
|
{CYAN}Tab{RESET} Cycle through categories
|
|
188
|
-
{CYAN}
|
|
188
|
+
{CYAN}Ctrl+C{RESET} Create/switch categories interactively
|
|
189
189
|
{CYAN}m{RESET} Move task between categories
|
|
190
190
|
|
|
191
191
|
{BOLD}PROJECTS:{RESET}
|
|
@@ -200,8 +200,8 @@ def display_help():
|
|
|
200
200
|
{CYAN}Shift+4{RESET} AI task suggestion
|
|
201
201
|
{CYAN}Shift+E{RESET} Execute keyword action for task
|
|
202
202
|
{CYAN}Shift+G{RESET} Set up Google Calendar integration
|
|
203
|
-
{CYAN}
|
|
204
|
-
{CYAN}
|
|
203
|
+
{CYAN}.i{RESET} Import tasks from Google Calendar
|
|
204
|
+
{CYAN}.c{RESET} Copy task text to clipboard
|
|
205
205
|
{CYAN}Ctrl+U{RESET} Open URLs found in task text
|
|
206
206
|
|
|
207
207
|
{BOLD}FUZZY SEARCH:{RESET}
|
|
@@ -212,7 +212,7 @@ def display_help():
|
|
|
212
212
|
{CYAN}Esc{RESET} Exit search
|
|
213
213
|
|
|
214
214
|
{BOLD}SYSTEM:{RESET}
|
|
215
|
-
{CYAN}h or
|
|
215
|
+
{CYAN}h or .?{RESET} Show keyboard shortcuts
|
|
216
216
|
{CYAN}r{RESET} Refresh display
|
|
217
217
|
{CYAN}Ctrl+R{RESET} Register current project in registry
|
|
218
218
|
{CYAN}q{RESET} Quit
|
|
@@ -227,7 +227,7 @@ def display_help():
|
|
|
227
227
|
|
|
228
228
|
{BOLD}CONFIGURATION:{RESET}
|
|
229
229
|
• Edit ~/.jot-keywords.json to customize keyword actions
|
|
230
|
-
• Category colors: Edit category config (
|
|
230
|
+
• Category colors: Edit category config (Ctrl+C)
|
|
231
231
|
• Global vs local: Use -g flag or create global categories
|
|
232
232
|
|
|
233
233
|
{DIM}For more info: https://github.com/your-repo/jot{RESET}"""
|
|
@@ -36,12 +36,16 @@ def display_tasks(
|
|
|
36
36
|
show_shortcuts=False,
|
|
37
37
|
show_notes_inline=False,
|
|
38
38
|
match_positions=None,
|
|
39
|
+
collapsed_parents=None,
|
|
40
|
+
all_tasks=None,
|
|
39
41
|
):
|
|
40
42
|
"""Display current task list with mode indicator."""
|
|
41
43
|
if selected_tasks is None:
|
|
42
44
|
selected_tasks = set()
|
|
43
45
|
if archived_task_ids is None:
|
|
44
46
|
archived_task_ids = set()
|
|
47
|
+
if collapsed_parents is None:
|
|
48
|
+
collapsed_parents = set()
|
|
45
49
|
|
|
46
50
|
cat_color = _get_category_color(category, is_global)
|
|
47
51
|
_render_header(tasks, project_name, category, is_global, cat_color)
|
|
@@ -49,14 +53,18 @@ def display_tasks(
|
|
|
49
53
|
if not tasks:
|
|
50
54
|
print("\n No tasks yet. Start typing to add one!")
|
|
51
55
|
else:
|
|
52
|
-
|
|
56
|
+
# Use full task list for subtask info when available so
|
|
57
|
+
# collapsed parents still show correct descendant counts.
|
|
58
|
+
info_source = all_tasks if all_tasks is not None else tasks
|
|
59
|
+
desc_counts, depths = _build_subtask_info(info_source)
|
|
53
60
|
for task in tasks:
|
|
54
61
|
positions = (match_positions or {}).get(task['id'], [])
|
|
55
62
|
_render_task_line(
|
|
56
63
|
task, mode, selected_tasks, archived_task_ids,
|
|
57
64
|
show_notes_inline, positions,
|
|
58
65
|
child_count=desc_counts.get(task['id'], 0),
|
|
59
|
-
depth=depths.get(task['id'], 0)
|
|
66
|
+
depth=depths.get(task['id'], 0),
|
|
67
|
+
collapsed=task['id'] in collapsed_parents)
|
|
60
68
|
|
|
61
69
|
print("\n" + "-" * get_terminal_width())
|
|
62
70
|
|
|
@@ -239,7 +247,7 @@ def _build_task_prefixes(task, mode, selected_tasks, archived_task_ids,
|
|
|
239
247
|
+ day_prefix + subtask_prefix)
|
|
240
248
|
|
|
241
249
|
|
|
242
|
-
def _build_task_suffixes(task, child_count=0):
|
|
250
|
+
def _build_task_suffixes(task, child_count=0, collapsed=False):
|
|
243
251
|
"""Build tally, stale-warning, and subtask-count suffixes."""
|
|
244
252
|
tally = task.get('tally', 0)
|
|
245
253
|
tally_suffix = f" {DIM}(\u00d7{tally}){RESET}" if tally > 0 else ""
|
|
@@ -260,7 +268,12 @@ def _build_task_suffixes(task, child_count=0):
|
|
|
260
268
|
subtask_suffix = ""
|
|
261
269
|
if child_count > 0:
|
|
262
270
|
noun = "subtask" if child_count == 1 else "subtasks"
|
|
263
|
-
|
|
271
|
+
if collapsed:
|
|
272
|
+
subtask_suffix = (
|
|
273
|
+
f" {YELLOW}\u25b6 ({child_count} {noun}){RESET}")
|
|
274
|
+
else:
|
|
275
|
+
subtask_suffix = (
|
|
276
|
+
f" {DIM}\u25bc ({child_count} {noun}){RESET}")
|
|
264
277
|
|
|
265
278
|
return tally_suffix, stale_warning, subtask_suffix
|
|
266
279
|
|
|
@@ -288,7 +301,7 @@ def _highlight_text(text, positions, base_fmt=""):
|
|
|
288
301
|
def _render_task_line(
|
|
289
302
|
task, mode, selected_tasks, archived_task_ids,
|
|
290
303
|
show_notes_inline, match_positions=None,
|
|
291
|
-
child_count=0, depth=0):
|
|
304
|
+
child_count=0, depth=0, collapsed=False):
|
|
292
305
|
"""Render a single task line with all decorations and wrapping."""
|
|
293
306
|
status = "\u2713" if task.get('done', False) else " "
|
|
294
307
|
is_current = task.get('current', False)
|
|
@@ -298,7 +311,7 @@ def _render_task_line(
|
|
|
298
311
|
prefixes = _build_task_prefixes(
|
|
299
312
|
task, mode, selected_tasks, archived_task_ids, depth)
|
|
300
313
|
tally_suffix, stale_warning, subtask_suffix = _build_task_suffixes(
|
|
301
|
-
task, child_count)
|
|
314
|
+
task, child_count, collapsed)
|
|
302
315
|
|
|
303
316
|
if is_agent:
|
|
304
317
|
color, icon = MAGENTA, "\U0001f916"
|