jott-cli 0.5.1__tar.gz → 0.5.3__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.1/jott_cli.egg-info → jott_cli-0.5.3}/PKG-INFO +1 -1
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/__init__.py +1 -1
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/_app_navigation_mixin.py +4 -1
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/_dispatch_mixin.py +89 -2
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/app.py +77 -25
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_metadata_mixin.py +115 -5
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_transfer_mixin.py +52 -74
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_delete_mixin.py +10 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_metadata_mixin.py +8 -3
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_subtask_mixin.py +1 -1
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/__init__.py +5 -2
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_archive.py +5 -4
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_footer.py +7 -6
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_help.py +11 -6
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_projects.py +10 -6
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_tasks.py +138 -56
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/picker.py +59 -56
- jott_cli-0.5.3/jot/ui/rendering.py +24 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/styles.py +43 -2
- {jott_cli-0.5.1 → jott_cli-0.5.3/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/SOURCES.txt +2 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/pyproject.toml +1 -1
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_highlight.py +64 -44
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_jot.py +4 -4
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_styles.py +14 -4
- jott_cli-0.5.3/tests/test_subtask_notes.py +884 -0
- jott_cli-0.5.3/tests/test_terminal_wrap.py +212 -0
- jott_cli-0.5.1/tests/test_subtask_notes.py +0 -246
- {jott_cli-0.5.1 → jott_cli-0.5.3}/LICENSE +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/README.md +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/config.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/config.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/views.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/handler.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_crud_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/constants.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/projects/registry.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/input.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/setup.cfg +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/setup.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_dispatch.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_input.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_picker.py +0 -0
- {jott_cli-0.5.1 → jott_cli-0.5.3}/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.3
|
|
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.2"
|
|
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)
|
|
@@ -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
|
|
|
@@ -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,84 @@ 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.3)
|
|
300
|
+
if second == 'c':
|
|
301
|
+
self._toggle_collapse()
|
|
302
|
+
st.input_buffer = ""
|
|
303
|
+
return
|
|
304
|
+
# Not a chord — treat '.' as normal input
|
|
305
|
+
st.input_buffer += '.'
|
|
306
|
+
if second and second != '.':
|
|
307
|
+
st.input_buffer += second
|
|
308
|
+
|
|
309
|
+
def _toggle_collapse(self):
|
|
310
|
+
"""Toggle collapse state for current task's parent group."""
|
|
311
|
+
task = self.task_manager.get_current_task()
|
|
312
|
+
if not task:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
tasks = self.task_manager.get_tasks()
|
|
316
|
+
collapsed = self.state.collapsed_parents
|
|
317
|
+
|
|
318
|
+
# Check if current task is a parent (has descendants)
|
|
319
|
+
has_children = any(
|
|
320
|
+
f'parent:{task["id"]}' in t.get('labels', [])
|
|
321
|
+
for t in tasks
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if has_children:
|
|
325
|
+
target_id = task['id']
|
|
326
|
+
else:
|
|
327
|
+
# Check if it's a child — toggle its parent
|
|
328
|
+
target_id = None
|
|
329
|
+
for label in task.get('labels', []):
|
|
330
|
+
if label.startswith('parent:'):
|
|
331
|
+
pid = label.split(':', 1)[1]
|
|
332
|
+
try:
|
|
333
|
+
target_id = int(pid)
|
|
334
|
+
except ValueError:
|
|
335
|
+
target_id = pid
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
if target_id is None:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
# Count descendants for feedback
|
|
342
|
+
children_of = {}
|
|
343
|
+
for t in tasks:
|
|
344
|
+
for label in t.get('labels', []):
|
|
345
|
+
if label.startswith('parent:'):
|
|
346
|
+
pid = label.split(':', 1)[1]
|
|
347
|
+
try:
|
|
348
|
+
pid = int(pid)
|
|
349
|
+
except ValueError:
|
|
350
|
+
pass
|
|
351
|
+
children_of.setdefault(pid, []).append(t['id'])
|
|
352
|
+
break
|
|
353
|
+
|
|
354
|
+
count = 0
|
|
355
|
+
stack = list(children_of.get(target_id, []))
|
|
356
|
+
seen = set()
|
|
357
|
+
while stack:
|
|
358
|
+
cid = stack.pop()
|
|
359
|
+
if cid not in seen:
|
|
360
|
+
seen.add(cid)
|
|
361
|
+
count += 1
|
|
362
|
+
stack.extend(children_of.get(cid, []))
|
|
363
|
+
|
|
364
|
+
if target_id in collapsed:
|
|
365
|
+
collapsed.discard(target_id)
|
|
366
|
+
print(f"\n{CYAN}▼ Expanded {count} subtask"
|
|
367
|
+
f"{'s' if count != 1 else ''}{RESET}")
|
|
368
|
+
else:
|
|
369
|
+
collapsed.add(target_id)
|
|
370
|
+
print(f"\n{CYAN}▶ Collapsed {count} subtask"
|
|
371
|
+
f"{'s' if count != 1 else ''}{RESET}")
|
|
372
|
+
time.sleep(0.3)
|
|
373
|
+
|
|
289
374
|
def _dispatch_all_categories(self, key, tasks_to_display):
|
|
290
375
|
"""Handle a keypress in ALL_CATEGORIES mode."""
|
|
291
376
|
st = self.state
|
|
@@ -295,7 +380,9 @@ class DispatchMixin:
|
|
|
295
380
|
return
|
|
296
381
|
|
|
297
382
|
filtered = (
|
|
298
|
-
tasks_to_display
|
|
383
|
+
tasks_to_display
|
|
384
|
+
if st.show_today_only or st.collapsed_parents
|
|
385
|
+
else None)
|
|
299
386
|
if key == 'UP':
|
|
300
387
|
self.task_manager.set_prev_current(filtered)
|
|
301
388
|
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
|
# ---------------------------------------------------------------------------
|
|
@@ -80,6 +82,8 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
80
82
|
'#': 'reauthenticate_google_calendar',
|
|
81
83
|
'\x12': 'register_current_project', # Ctrl+R
|
|
82
84
|
'*': 'toggle_highlight',
|
|
85
|
+
'~': 'quick_highlight',
|
|
86
|
+
'\x0c': 'assign_parent', # Ctrl+L
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
def __init__(self, task_manager, command_handler, registry,
|
|
@@ -91,6 +95,7 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
91
95
|
self.project_name = project_name
|
|
92
96
|
|
|
93
97
|
self.state = AppState()
|
|
98
|
+
self._needs_render = True
|
|
94
99
|
self._last_mtime = (
|
|
95
100
|
os.path.getmtime(task_manager.storage_file)
|
|
96
101
|
if task_manager.storage_file.exists() else 0)
|
|
@@ -111,8 +116,12 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
111
116
|
"""Inner event loop — separated so run() can wrap with
|
|
112
117
|
try/finally."""
|
|
113
118
|
while self.state.running:
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
if self._needs_render:
|
|
120
|
+
tasks_to_display = self._prepare_tasks()
|
|
121
|
+
self._render(tasks_to_display)
|
|
122
|
+
self._needs_render = False
|
|
123
|
+
else:
|
|
124
|
+
tasks_to_display = self._prepare_tasks()
|
|
116
125
|
|
|
117
126
|
try:
|
|
118
127
|
key = get_key()
|
|
@@ -122,6 +131,9 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
122
131
|
if self._handle_paste(key):
|
|
123
132
|
continue
|
|
124
133
|
|
|
134
|
+
# Any real key press triggers a render
|
|
135
|
+
self._needs_render = True
|
|
136
|
+
|
|
125
137
|
# Global handlers (any mode)
|
|
126
138
|
if key == '\x05': # Ctrl+E
|
|
127
139
|
self.command_handler.edit_current()
|
|
@@ -157,6 +169,12 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
157
169
|
"""Fetch, filter, and sort the task list for display."""
|
|
158
170
|
tasks = self.task_manager.get_tasks()
|
|
159
171
|
|
|
172
|
+
# Stash full list before collapse filtering so display can
|
|
173
|
+
# compute correct descendant counts for collapsed parents.
|
|
174
|
+
self._all_tasks = tasks
|
|
175
|
+
|
|
176
|
+
if self.state.collapsed_parents:
|
|
177
|
+
tasks = self._filter_collapsed(tasks)
|
|
160
178
|
if self.state.show_today_only:
|
|
161
179
|
today = get_today_day_name()
|
|
162
180
|
tasks = filter_today_tasks(tasks, today)
|
|
@@ -165,6 +183,33 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
165
183
|
|
|
166
184
|
return self._get_filtered_tasks(tasks)
|
|
167
185
|
|
|
186
|
+
def _filter_collapsed(self, tasks):
|
|
187
|
+
"""Remove descendants of collapsed parents from the task list."""
|
|
188
|
+
# Build parent→children map
|
|
189
|
+
children_of = {}
|
|
190
|
+
for task in tasks:
|
|
191
|
+
for label in task.get('labels', []):
|
|
192
|
+
if label.startswith('parent:'):
|
|
193
|
+
pid = label.split(':', 1)[1]
|
|
194
|
+
try:
|
|
195
|
+
pid = int(pid)
|
|
196
|
+
except ValueError:
|
|
197
|
+
pass
|
|
198
|
+
children_of.setdefault(pid, []).append(task['id'])
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
# Collect all descendants of collapsed parents (BFS)
|
|
202
|
+
hidden = set()
|
|
203
|
+
for root_id in self.state.collapsed_parents:
|
|
204
|
+
stack = list(children_of.get(root_id, []))
|
|
205
|
+
while stack:
|
|
206
|
+
cid = stack.pop()
|
|
207
|
+
if cid not in hidden:
|
|
208
|
+
hidden.add(cid)
|
|
209
|
+
stack.extend(children_of.get(cid, []))
|
|
210
|
+
|
|
211
|
+
return [t for t in tasks if t['id'] not in hidden]
|
|
212
|
+
|
|
168
213
|
def _get_filtered_tasks(self, tasks_to_display):
|
|
169
214
|
"""Apply fuzzy-search filtering when in FUZZY_SEARCH mode."""
|
|
170
215
|
st = self.state
|
|
@@ -212,30 +257,35 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
212
257
|
|
|
213
258
|
def _render(self, tasks_to_display):
|
|
214
259
|
"""Clear screen and draw the current view."""
|
|
215
|
-
|
|
216
|
-
|
|
260
|
+
with buffered_output():
|
|
261
|
+
sys.stdout.write('\033[?25l\033[H\033[J')
|
|
262
|
+
st = self.state
|
|
263
|
+
|
|
264
|
+
if st.mode == MODE_ALL_CATEGORIES:
|
|
265
|
+
self._render_all_categories()
|
|
266
|
+
else:
|
|
267
|
+
all_tasks = (
|
|
268
|
+
self._all_tasks
|
|
269
|
+
if st.collapsed_parents else None)
|
|
270
|
+
display_tasks(
|
|
271
|
+
tasks_to_display, st.mode, st.input_buffer,
|
|
272
|
+
self.project_name,
|
|
273
|
+
category=self.task_manager.category,
|
|
274
|
+
is_global=self.task_manager.is_global,
|
|
275
|
+
show_archived=st.show_archived,
|
|
276
|
+
archived_tasks=self.task_manager.archived,
|
|
277
|
+
selected_tasks=st.selected_tasks,
|
|
278
|
+
search_buffer=st.search_buffer,
|
|
279
|
+
archived_task_ids=st.archived_task_ids,
|
|
280
|
+
include_archived_in_search=st.include_archived_in_search,
|
|
281
|
+
show_shortcuts=st.show_shortcuts,
|
|
282
|
+
show_notes_inline=st.show_notes_inline,
|
|
283
|
+
match_positions=st.match_positions,
|
|
284
|
+
collapsed_parents=st.collapsed_parents,
|
|
285
|
+
all_tasks=all_tasks,
|
|
286
|
+
)
|
|
217
287
|
|
|
218
|
-
|
|
219
|
-
self._render_all_categories()
|
|
220
|
-
else:
|
|
221
|
-
display_tasks(
|
|
222
|
-
tasks_to_display, st.mode, st.input_buffer,
|
|
223
|
-
self.project_name,
|
|
224
|
-
category=self.task_manager.category,
|
|
225
|
-
is_global=self.task_manager.is_global,
|
|
226
|
-
show_archived=st.show_archived,
|
|
227
|
-
archived_tasks=self.task_manager.archived,
|
|
228
|
-
selected_tasks=st.selected_tasks,
|
|
229
|
-
search_buffer=st.search_buffer,
|
|
230
|
-
archived_task_ids=st.archived_task_ids,
|
|
231
|
-
include_archived_in_search=st.include_archived_in_search,
|
|
232
|
-
show_shortcuts=st.show_shortcuts,
|
|
233
|
-
show_notes_inline=st.show_notes_inline,
|
|
234
|
-
match_positions=st.match_positions,
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
sys.stdout.write('\033[?25h')
|
|
238
|
-
sys.stdout.flush()
|
|
288
|
+
sys.stdout.write('\033[?25h')
|
|
239
289
|
|
|
240
290
|
def _render_all_categories(self):
|
|
241
291
|
"""Delegate ALL_CATEGORIES rendering to the display module."""
|
|
@@ -263,6 +313,7 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
263
313
|
if current_mtime != self._last_mtime:
|
|
264
314
|
self.task_manager._load_tasks()
|
|
265
315
|
self._last_mtime = current_mtime
|
|
316
|
+
self._needs_render = True
|
|
266
317
|
return True
|
|
267
318
|
|
|
268
319
|
def _handle_paste(self, key):
|
|
@@ -274,4 +325,5 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
274
325
|
if self.state.mode == MODE_QUICK_ADD:
|
|
275
326
|
pasted_text = pasted_text.replace('\r', '').replace('\n', '')
|
|
276
327
|
self.state.input_buffer += pasted_text
|
|
328
|
+
self._needs_render = True
|
|
277
329
|
return True
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
|
|
5
5
|
from jot.core.constants import DAY_COLORS, DAY_ABBREVIATIONS
|
|
6
|
-
from jot.ui.picker import PickerItem, quick_picker
|
|
6
|
+
from jot.ui.picker import PickerItem, quick_picker, fuzzy_picker
|
|
7
7
|
from jot.ui.styles import (
|
|
8
8
|
RESET, BOLD, DIM, GREEN, RED,
|
|
9
9
|
PRIORITY_COLORS, PRIORITY_SYMBOLS, STATUS_COLORS, STATUS_SYMBOLS,
|
|
10
|
+
HIGHLIGHT_COLORS,
|
|
10
11
|
)
|
|
11
12
|
from jot.ui.input import restore_terminal
|
|
12
13
|
|
|
@@ -124,14 +125,52 @@ class MetadataMixin:
|
|
|
124
125
|
return True
|
|
125
126
|
|
|
126
127
|
def toggle_highlight(self):
|
|
127
|
-
"""
|
|
128
|
+
"""Pick a highlight color for the current task, or clear it."""
|
|
128
129
|
current_task = self.task_manager.get_current_task()
|
|
129
130
|
if not current_task:
|
|
130
131
|
return True
|
|
132
|
+
|
|
133
|
+
items = [PickerItem(value="_clear", label="none (clear)", color=DIM)]
|
|
134
|
+
for name, fmt in HIGHLIGHT_COLORS.items():
|
|
135
|
+
items.append(PickerItem(
|
|
136
|
+
value=name, label=name, color=fmt,
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
result = quick_picker(
|
|
140
|
+
items,
|
|
141
|
+
prompt=f"Highlight color for: {current_task['text']}",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if result.value is None:
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
if result.value == "_clear":
|
|
148
|
+
self.task_manager.set_highlight(color=None)
|
|
149
|
+
print(f"\n{GREEN}✓{RESET} Highlight cleared")
|
|
150
|
+
else:
|
|
151
|
+
self.task_manager.set_highlight(color=result.value)
|
|
152
|
+
fmt = HIGHLIGHT_COLORS[result.value]
|
|
153
|
+
print(f"\n{GREEN}✓{RESET} Highlight set to {fmt} {result.value} {RESET}")
|
|
154
|
+
|
|
155
|
+
print("\nPress Enter to continue...", end='', flush=True)
|
|
156
|
+
restore_terminal()
|
|
157
|
+
input()
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def quick_highlight(self):
|
|
161
|
+
"""Toggle default highlight color on the current task."""
|
|
162
|
+
current_task = self.task_manager.get_current_task()
|
|
163
|
+
if not current_task:
|
|
164
|
+
return True
|
|
165
|
+
from jot.ui.styles import HIGHLIGHT_DEFAULT
|
|
131
166
|
was_highlighted = current_task.get('highlight', False)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
167
|
+
if was_highlighted:
|
|
168
|
+
self.task_manager.set_highlight(color=None)
|
|
169
|
+
print(f"\n{GREEN}✓{RESET} Highlight cleared")
|
|
170
|
+
else:
|
|
171
|
+
self.task_manager.set_highlight(color=HIGHLIGHT_DEFAULT)
|
|
172
|
+
fmt = HIGHLIGHT_COLORS[HIGHLIGHT_DEFAULT]
|
|
173
|
+
print(f"\n{GREEN}✓{RESET} Highlighted {fmt} {HIGHLIGHT_DEFAULT} {RESET}")
|
|
135
174
|
return True
|
|
136
175
|
|
|
137
176
|
def set_priority_high(self):
|
|
@@ -176,3 +215,74 @@ class MetadataMixin:
|
|
|
176
215
|
input()
|
|
177
216
|
|
|
178
217
|
return True
|
|
218
|
+
|
|
219
|
+
def assign_parent(self):
|
|
220
|
+
"""Assign current task as a subtask of another task (Ctrl+L)."""
|
|
221
|
+
current_task = self.task_manager.get_current_task()
|
|
222
|
+
if not current_task:
|
|
223
|
+
print("\n✗ No current task selected")
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
task_id = current_task['id']
|
|
227
|
+
labels = current_task.get('labels', [])
|
|
228
|
+
|
|
229
|
+
# Find existing parent label
|
|
230
|
+
current_parent = None
|
|
231
|
+
for lbl in labels:
|
|
232
|
+
if lbl.startswith('parent:'):
|
|
233
|
+
current_parent = lbl.split(':', 1)[1]
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
# Build picker items
|
|
237
|
+
items = []
|
|
238
|
+
if current_parent is not None:
|
|
239
|
+
items.append(PickerItem(
|
|
240
|
+
value="_clear", label="Remove parent link", color=DIM,
|
|
241
|
+
))
|
|
242
|
+
|
|
243
|
+
for task in self.task_manager.get_tasks():
|
|
244
|
+
if task['id'] == task_id:
|
|
245
|
+
continue
|
|
246
|
+
if task.get('status') == 'done':
|
|
247
|
+
continue
|
|
248
|
+
annotation = "(current parent)" if str(task['id']) == str(current_parent) else ""
|
|
249
|
+
items.append(PickerItem(
|
|
250
|
+
value=str(task['id']),
|
|
251
|
+
label=f"#{task['id']} {task['text']}",
|
|
252
|
+
annotation=annotation,
|
|
253
|
+
))
|
|
254
|
+
|
|
255
|
+
if not items:
|
|
256
|
+
print("\n✗ No candidate tasks found")
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
result = fuzzy_picker(
|
|
260
|
+
items,
|
|
261
|
+
prompt=f"Assign parent for: {current_task['text']}",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if result.value is None:
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
# Strip existing parent: label
|
|
268
|
+
new_labels = [l for l in labels if not l.startswith('parent:')]
|
|
269
|
+
|
|
270
|
+
if result.value != "_clear":
|
|
271
|
+
new_labels.append(f"parent:{result.value}")
|
|
272
|
+
|
|
273
|
+
# Save updated labels
|
|
274
|
+
for task in self.task_manager.tasks:
|
|
275
|
+
if task['id'] == task_id:
|
|
276
|
+
task['labels'] = new_labels
|
|
277
|
+
self.task_manager._save_tasks()
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
if result.value == "_clear":
|
|
281
|
+
print(f"\n{GREEN}✓{RESET} Parent link removed")
|
|
282
|
+
else:
|
|
283
|
+
print(f"\n{GREEN}✓{RESET} Assigned as subtask of #{result.value}")
|
|
284
|
+
|
|
285
|
+
print("\nPress Enter to continue...", end='', flush=True)
|
|
286
|
+
restore_terminal()
|
|
287
|
+
input()
|
|
288
|
+
return True
|
|
@@ -5,9 +5,8 @@ from pathlib import Path
|
|
|
5
5
|
from jot.categories.config import CategoryConfig
|
|
6
6
|
from jot.categories.manager import CategoryManager
|
|
7
7
|
from jot.core.task_manager import TaskManager
|
|
8
|
-
from jot.ui.picker import PickerItem, fuzzy_picker
|
|
8
|
+
from jot.ui.picker import PickerItem, fuzzy_picker, quick_picker
|
|
9
9
|
from jot.ui.styles import RESET, RED
|
|
10
|
-
from jot.ui.input import restore_terminal
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
class TransferMixin:
|
|
@@ -82,7 +81,8 @@ class TransferMixin:
|
|
|
82
81
|
task_text,
|
|
83
82
|
priority=current_task.get('priority', 'none'),
|
|
84
83
|
status=current_task.get('status', 'todo'),
|
|
85
|
-
labels=current_task.get('labels', [])
|
|
84
|
+
labels=[l for l in current_task.get('labels', [])
|
|
85
|
+
if not l.startswith('parent:')],
|
|
86
86
|
effort=current_task.get('effort', None),
|
|
87
87
|
)
|
|
88
88
|
|
|
@@ -147,7 +147,8 @@ class TransferMixin:
|
|
|
147
147
|
task_text,
|
|
148
148
|
priority=current_task.get('priority', 'none'),
|
|
149
149
|
status=current_task.get('status', 'todo'),
|
|
150
|
-
labels=current_task.get('labels', [])
|
|
150
|
+
labels=[l for l in current_task.get('labels', [])
|
|
151
|
+
if not l.startswith('parent:')],
|
|
151
152
|
effort=current_task.get('effort', None),
|
|
152
153
|
)
|
|
153
154
|
|
|
@@ -211,78 +212,55 @@ class TransferMixin:
|
|
|
211
212
|
|
|
212
213
|
cat_config = CategoryConfig(project_dir=project_dir)
|
|
213
214
|
|
|
214
|
-
|
|
215
|
-
for
|
|
215
|
+
items = []
|
|
216
|
+
for display_name, cat_name, is_global in categories:
|
|
216
217
|
if display_name == "default":
|
|
217
|
-
|
|
218
|
-
|
|
218
|
+
items.append(PickerItem(
|
|
219
|
+
value=(None, False),
|
|
220
|
+
label="default",
|
|
221
|
+
annotation="(no category)",
|
|
222
|
+
color=cat_config.get_color('default'),
|
|
223
|
+
))
|
|
219
224
|
else:
|
|
220
|
-
cat_color = cat_config.get_color(cat_name, for_global=is_global)
|
|
221
225
|
scope = "[global]" if is_global else "[local]"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return True
|
|
232
|
-
|
|
233
|
-
selected_category = None
|
|
234
|
-
selected_is_global = False
|
|
235
|
-
|
|
236
|
-
try:
|
|
237
|
-
choice_index = int(user_input) - 1
|
|
238
|
-
if 0 <= choice_index < len(categories):
|
|
239
|
-
_, cat_name, is_global = categories[choice_index]
|
|
240
|
-
selected_category = cat_name
|
|
241
|
-
selected_is_global = is_global
|
|
242
|
-
else:
|
|
243
|
-
print("✗ Invalid number")
|
|
244
|
-
return True
|
|
245
|
-
except ValueError:
|
|
246
|
-
found = False
|
|
247
|
-
for display_name, cat_name, is_global in categories:
|
|
248
|
-
if cat_name == user_input or display_name == user_input:
|
|
249
|
-
selected_category = cat_name
|
|
250
|
-
selected_is_global = is_global
|
|
251
|
-
found = True
|
|
252
|
-
break
|
|
253
|
-
if not found:
|
|
254
|
-
print(f"✗ Category '{user_input}' not found")
|
|
255
|
-
return True
|
|
256
|
-
|
|
257
|
-
if selected_is_global:
|
|
258
|
-
dest_tm = TaskManager(
|
|
259
|
-
category=selected_category, is_global=True, project_registry=self.registry
|
|
260
|
-
)
|
|
261
|
-
else:
|
|
262
|
-
dest_tm = TaskManager(
|
|
263
|
-
directory=project_dir,
|
|
264
|
-
category=selected_category,
|
|
265
|
-
is_global=False,
|
|
266
|
-
project_registry=self.registry,
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
dest_tm.add_task(task_text)
|
|
270
|
-
if task_done:
|
|
271
|
-
new_task_id = dest_tm.tasks[-1]['id']
|
|
272
|
-
dest_tm.set_task_status(new_task_id, 'done')
|
|
273
|
-
|
|
274
|
-
self.task_manager.remove_task(task_id)
|
|
275
|
-
|
|
276
|
-
dest_name = selected_category or 'default'
|
|
277
|
-
if selected_is_global:
|
|
278
|
-
print(f"✓ Transferred task to global:{dest_name}")
|
|
279
|
-
else:
|
|
280
|
-
print(f"✓ Transferred task to {dest_name}")
|
|
281
|
-
|
|
282
|
-
print("\nPress Enter to continue...", end='', flush=True)
|
|
283
|
-
input()
|
|
226
|
+
items.append(PickerItem(
|
|
227
|
+
value=(cat_name, is_global),
|
|
228
|
+
label=cat_name,
|
|
229
|
+
annotation=scope,
|
|
230
|
+
color=cat_config.get_color(cat_name, for_global=is_global),
|
|
231
|
+
))
|
|
232
|
+
|
|
233
|
+
result = quick_picker(items, prompt=f"Transfer '{task_text}' to")
|
|
234
|
+
if result.value is None:
|
|
284
235
|
return True
|
|
285
236
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
237
|
+
selected_category, selected_is_global = result.value
|
|
238
|
+
|
|
239
|
+
if selected_is_global:
|
|
240
|
+
dest_tm = TaskManager(
|
|
241
|
+
category=selected_category, is_global=True, project_registry=self.registry
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
dest_tm = TaskManager(
|
|
245
|
+
directory=project_dir,
|
|
246
|
+
category=selected_category,
|
|
247
|
+
is_global=False,
|
|
248
|
+
project_registry=self.registry,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
dest_tm.add_task(task_text)
|
|
252
|
+
if task_done:
|
|
253
|
+
new_task_id = dest_tm.tasks[-1]['id']
|
|
254
|
+
dest_tm.set_task_status(new_task_id, 'done')
|
|
255
|
+
|
|
256
|
+
self.task_manager.remove_task(task_id)
|
|
257
|
+
|
|
258
|
+
dest_name = selected_category or 'default'
|
|
259
|
+
if selected_is_global:
|
|
260
|
+
print(f"✓ Transferred task to global:{dest_name}")
|
|
261
|
+
else:
|
|
262
|
+
print(f"✓ Transferred task to {dest_name}")
|
|
263
|
+
|
|
264
|
+
print("\nPress Enter to continue...", end='', flush=True)
|
|
265
|
+
input()
|
|
266
|
+
return True
|
|
@@ -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)}")
|
|
@@ -61,8 +61,13 @@ class MetadataMixin:
|
|
|
61
61
|
self._save_tasks()
|
|
62
62
|
return True
|
|
63
63
|
|
|
64
|
-
def
|
|
65
|
-
"""
|
|
64
|
+
def set_highlight(self, task_id=None, color=None):
|
|
65
|
+
"""Set highlight color on a task, or clear it.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
task_id: Task to highlight (defaults to current task).
|
|
69
|
+
color: Color name from HIGHLIGHT_COLORS, or None to clear.
|
|
70
|
+
"""
|
|
66
71
|
if task_id is None:
|
|
67
72
|
current = self.get_current_task()
|
|
68
73
|
if not current:
|
|
@@ -71,7 +76,7 @@ class MetadataMixin:
|
|
|
71
76
|
|
|
72
77
|
for task in self.tasks:
|
|
73
78
|
if task['id'] == task_id:
|
|
74
|
-
task['highlight'] =
|
|
79
|
+
task['highlight'] = color if color else False
|
|
75
80
|
self._save_tasks()
|
|
76
81
|
return True
|
|
77
82
|
return False
|