jott-cli 0.5.3__tar.gz → 0.5.5__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.3/jott_cli.egg-info → jott_cli-0.5.5}/PKG-INFO +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/__init__.py +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/_app_navigation_mixin.py +1 -3
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/_dispatch_mixin.py +27 -4
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/app.py +1 -4
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_transfer_mixin.py +120 -46
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/handler.py +2 -2
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_subtask_mixin.py +15 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/projects/registry.py +8 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_footer.py +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_help.py +15 -15
- {jott_cli-0.5.3 → jott_cli-0.5.5/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/SOURCES.txt +2 -1
- {jott_cli-0.5.3 → jott_cli-0.5.5}/pyproject.toml +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_dispatch.py +1 -2
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_jot.py +233 -108
- jott_cli-0.5.5/tests/test_transfer_subtasks.py +381 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/LICENSE +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/README.md +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/config.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/config.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/views.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_metadata_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_crud_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_delete_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_metadata_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/constants.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_tasks.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/input.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/picker.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/rendering.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/styles.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/setup.cfg +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/setup.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_highlight.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_input.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_picker.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_styles.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_subtask_notes.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_terminal_wrap.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.5}/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.5
|
|
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.5"
|
|
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.
|
|
@@ -38,16 +38,14 @@ class AppNavigationMixin:
|
|
|
38
38
|
return False
|
|
39
39
|
|
|
40
40
|
def _handle_toggle(self, key):
|
|
41
|
-
"""Process shared toggle keys
|
|
41
|
+
"""Process shared toggle keys O/Y/F. Returns True if
|
|
42
42
|
the key was a toggle key."""
|
|
43
43
|
st = self.state
|
|
44
44
|
|
|
45
45
|
toggle_map = {
|
|
46
|
-
'A': ('show_archived', 'archived tasks'),
|
|
47
46
|
'O': ('sort_by_day', None),
|
|
48
47
|
'Y': ('show_today_only', None),
|
|
49
48
|
'F': ('show_notes_inline', 'inline notes'),
|
|
50
|
-
'?': ('show_shortcuts', 'shortcuts'),
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
if key not in toggle_map:
|
|
@@ -30,7 +30,7 @@ class DispatchMixin:
|
|
|
30
30
|
st.input_buffer = st.input_buffer[:-1]
|
|
31
31
|
return
|
|
32
32
|
|
|
33
|
-
if key == '
|
|
33
|
+
if key == '\x03': # Ctrl+C
|
|
34
34
|
self._handle_switch_category()
|
|
35
35
|
return
|
|
36
36
|
|
|
@@ -153,7 +153,7 @@ class DispatchMixin:
|
|
|
153
153
|
time.sleep(0.3)
|
|
154
154
|
return
|
|
155
155
|
|
|
156
|
-
if key in ('
|
|
156
|
+
if key in ('O', 'Y'):
|
|
157
157
|
self._handle_toggle(key)
|
|
158
158
|
return
|
|
159
159
|
|
|
@@ -296,11 +296,34 @@ class DispatchMixin:
|
|
|
296
296
|
def _handle_dot_chord(self):
|
|
297
297
|
"""Handle '.' leader key — wait for second key to form chord."""
|
|
298
298
|
st = self.state
|
|
299
|
-
second = get_key(timeout=0.
|
|
300
|
-
if second == '
|
|
299
|
+
second = get_key(timeout=0.5)
|
|
300
|
+
if second == 's':
|
|
301
301
|
self._toggle_collapse()
|
|
302
302
|
st.input_buffer = ""
|
|
303
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
|
+
if second == 'a':
|
|
321
|
+
st.show_archived = not st.show_archived
|
|
322
|
+
status = "Showing" if st.show_archived else "Hiding"
|
|
323
|
+
print(f"\n{CYAN}\u2713 {status} archived tasks{RESET}")
|
|
324
|
+
time.sleep(0.3)
|
|
325
|
+
st.input_buffer = ""
|
|
326
|
+
return
|
|
304
327
|
# Not a chord — treat '.' as normal input
|
|
305
328
|
st.input_buffer += '.'
|
|
306
329
|
if second and second != '.':
|
|
@@ -59,16 +59,13 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
59
59
|
'M': 'move_task_to_project',
|
|
60
60
|
'\x0b': 'copy_task_to_project', # Ctrl+K
|
|
61
61
|
'\x14': 'transfer_task_to_category', # Ctrl+T
|
|
62
|
-
'S': 'web_search',
|
|
63
62
|
'\x13': 'sync_subtasks', # Ctrl+S
|
|
64
|
-
'
|
|
63
|
+
'\x04': 'delete_current', # Ctrl+D
|
|
65
64
|
'W': 'assign_day',
|
|
66
65
|
'P': 'set_priority',
|
|
67
66
|
'H': 'set_priority_high',
|
|
68
67
|
'X': 'set_status',
|
|
69
68
|
'G': 'export_to_gcal',
|
|
70
|
-
'I': 'import_from_gcal',
|
|
71
|
-
'K': 'copy_to_clipboard',
|
|
72
69
|
'E': 'trigger_keyword_action',
|
|
73
70
|
'N': 'edit_task_notes',
|
|
74
71
|
'\x15': 'open_url', # Ctrl+U
|
|
@@ -43,6 +43,58 @@ class TransferMixin:
|
|
|
43
43
|
result = fuzzy_picker(items, prompt=prompt_text)
|
|
44
44
|
return result.value
|
|
45
45
|
|
|
46
|
+
def _transfer_task_to_target(self, task, target_tm, labels=None):
|
|
47
|
+
"""Add task to target_tm preserving metadata. Returns new task ID."""
|
|
48
|
+
new_id = target_tm.add_task(
|
|
49
|
+
task['text'],
|
|
50
|
+
priority=task.get('priority', 'none'),
|
|
51
|
+
status=task.get('status', 'todo'),
|
|
52
|
+
labels=labels if labels is not None else [],
|
|
53
|
+
effort=task.get('effort', None),
|
|
54
|
+
)
|
|
55
|
+
new_task = target_tm.tasks[-1]
|
|
56
|
+
for key in ('notes', 'agent_task', 'keyword', 'day', 'created_at'):
|
|
57
|
+
if task.get(key) is not None:
|
|
58
|
+
new_task[key] = task[key]
|
|
59
|
+
return new_id
|
|
60
|
+
|
|
61
|
+
def _transfer_descendants(self, parent_task, target_tm, id_map):
|
|
62
|
+
"""Transfer all descendants of parent_task to target_tm.
|
|
63
|
+
|
|
64
|
+
Walks descendants in BFS order, remapping parent:{old} labels to
|
|
65
|
+
parent:{new} using the id_map. Updates id_map in place.
|
|
66
|
+
"""
|
|
67
|
+
descendants = self.task_manager._get_descendants(parent_task['id'])
|
|
68
|
+
for child in descendants:
|
|
69
|
+
old_labels = child.get('labels', [])
|
|
70
|
+
new_labels = []
|
|
71
|
+
for label in old_labels:
|
|
72
|
+
if label.startswith('parent:'):
|
|
73
|
+
old_pid = int(label.split(':')[1])
|
|
74
|
+
new_pid = id_map.get(old_pid, old_pid)
|
|
75
|
+
new_labels.append(f"parent:{new_pid}")
|
|
76
|
+
else:
|
|
77
|
+
new_labels.append(label)
|
|
78
|
+
new_id = self._transfer_task_to_target(child, target_tm, labels=new_labels)
|
|
79
|
+
id_map[child['id']] = new_id
|
|
80
|
+
return descendants
|
|
81
|
+
|
|
82
|
+
def _remove_tasks_from_source(self, parent_id, descendants):
|
|
83
|
+
"""Remove descendants first, then parent, from source task manager.
|
|
84
|
+
|
|
85
|
+
Removes children before parent so _unlink_children on the parent
|
|
86
|
+
doesn't redundantly strip labels from already-present tasks.
|
|
87
|
+
"""
|
|
88
|
+
for child in reversed(descendants):
|
|
89
|
+
self.task_manager.remove_task(child['id'])
|
|
90
|
+
self.task_manager.remove_task(parent_id)
|
|
91
|
+
|
|
92
|
+
def _confirm_subtask_action(self, action, count, target_name):
|
|
93
|
+
"""Prompt user to confirm moving/copying subtasks. Returns True if confirmed."""
|
|
94
|
+
prompt = f"{action} task + {count} subtask{'s' if count != 1 else ''} to {target_name}? (y/N): "
|
|
95
|
+
response = input(prompt).strip().lower()
|
|
96
|
+
return response == 'y'
|
|
97
|
+
|
|
46
98
|
def copy_task_to_project(self):
|
|
47
99
|
"""Copy current task to a different project (keeps original)"""
|
|
48
100
|
if not self.registry:
|
|
@@ -55,15 +107,20 @@ class TransferMixin:
|
|
|
55
107
|
return True
|
|
56
108
|
|
|
57
109
|
task_text = current_task['text']
|
|
110
|
+
descendants = self.task_manager._get_descendants(current_task['id'])
|
|
58
111
|
|
|
59
112
|
target_project = self._pick_project_fuzzy(f"Copy '{task_text}' to:")
|
|
60
113
|
if not target_project:
|
|
61
114
|
print("\n✗ Cancelled")
|
|
62
115
|
return True
|
|
63
116
|
|
|
117
|
+
if descendants:
|
|
118
|
+
if not self._confirm_subtask_action("Copy", len(descendants), target_project):
|
|
119
|
+
print("\n✗ Cancelled")
|
|
120
|
+
return True
|
|
121
|
+
|
|
64
122
|
target_path = self.registry.get_project_path(target_project)
|
|
65
123
|
|
|
66
|
-
# Check if destination has the same category
|
|
67
124
|
current_category = self.task_manager.category
|
|
68
125
|
target_category = None
|
|
69
126
|
category_preserved = False
|
|
@@ -77,36 +134,32 @@ class TransferMixin:
|
|
|
77
134
|
target_tm = TaskManager(
|
|
78
135
|
directory=target_path, category=target_category, project_registry=self.registry
|
|
79
136
|
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
effort=current_task.get('effort', None),
|
|
137
|
+
|
|
138
|
+
# Copy parent task (strip parent: labels since parent is top-level in dest)
|
|
139
|
+
parent_labels = [l for l in current_task.get('labels', [])
|
|
140
|
+
if not l.startswith('parent:')]
|
|
141
|
+
new_parent_id = self._transfer_task_to_target(
|
|
142
|
+
current_task, target_tm, labels=parent_labels
|
|
87
143
|
)
|
|
88
144
|
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
new_task['keyword'] = current_task.get('keyword', None)
|
|
94
|
-
new_task['day'] = current_task.get('day', None)
|
|
95
|
-
if current_task.get('created_at'):
|
|
96
|
-
new_task['created_at'] = current_task['created_at']
|
|
145
|
+
# Copy descendants with remapped parent labels
|
|
146
|
+
if descendants:
|
|
147
|
+
id_map = {current_task['id']: new_parent_id}
|
|
148
|
+
self._transfer_descendants(current_task, target_tm, id_map)
|
|
97
149
|
|
|
98
150
|
target_tm._save_tasks()
|
|
99
151
|
self.registry.record_usage(target_project)
|
|
100
152
|
|
|
153
|
+
count_msg = f" + {len(descendants)} subtask{'s' if len(descendants) != 1 else ''}" if descendants else ""
|
|
101
154
|
if category_preserved:
|
|
102
|
-
print(f"✓ Copied task to {target_project} (kept in '{current_category}' category)")
|
|
155
|
+
print(f"✓ Copied task{count_msg} to {target_project} (kept in '{current_category}' category)")
|
|
103
156
|
elif current_category:
|
|
104
157
|
print(
|
|
105
|
-
f"✓ Copied task to {target_project} "
|
|
158
|
+
f"✓ Copied task{count_msg} to {target_project} "
|
|
106
159
|
f"(category '{current_category}' not available, added to default)"
|
|
107
160
|
)
|
|
108
161
|
else:
|
|
109
|
-
print(f"✓ Copied task to {target_project}")
|
|
162
|
+
print(f"✓ Copied task{count_msg} to {target_project}")
|
|
110
163
|
return True
|
|
111
164
|
|
|
112
165
|
def move_task_to_project(self):
|
|
@@ -122,12 +175,18 @@ class TransferMixin:
|
|
|
122
175
|
|
|
123
176
|
task_id = current_task['id']
|
|
124
177
|
task_text = current_task['text']
|
|
178
|
+
descendants = self.task_manager._get_descendants(task_id)
|
|
125
179
|
|
|
126
180
|
target_project = self._pick_project_fuzzy(f"Move '{task_text}' to:")
|
|
127
181
|
if not target_project:
|
|
128
182
|
print("\n✗ Cancelled")
|
|
129
183
|
return True
|
|
130
184
|
|
|
185
|
+
if descendants:
|
|
186
|
+
if not self._confirm_subtask_action("Move", len(descendants), target_project):
|
|
187
|
+
print("\n✗ Cancelled")
|
|
188
|
+
return True
|
|
189
|
+
|
|
131
190
|
target_path = self.registry.get_project_path(target_project)
|
|
132
191
|
|
|
133
192
|
current_category = self.task_manager.category
|
|
@@ -143,36 +202,35 @@ class TransferMixin:
|
|
|
143
202
|
target_tm = TaskManager(
|
|
144
203
|
directory=target_path, category=target_category, project_registry=self.registry
|
|
145
204
|
)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
effort=current_task.get('effort', None),
|
|
205
|
+
|
|
206
|
+
# Move parent task
|
|
207
|
+
parent_labels = [l for l in current_task.get('labels', [])
|
|
208
|
+
if not l.startswith('parent:')]
|
|
209
|
+
new_parent_id = self._transfer_task_to_target(
|
|
210
|
+
current_task, target_tm, labels=parent_labels
|
|
153
211
|
)
|
|
154
212
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
new_task['day'] = current_task.get('day', None)
|
|
160
|
-
if current_task.get('created_at'):
|
|
161
|
-
new_task['created_at'] = current_task['created_at']
|
|
213
|
+
# Move descendants with remapped parent labels
|
|
214
|
+
if descendants:
|
|
215
|
+
id_map = {task_id: new_parent_id}
|
|
216
|
+
self._transfer_descendants(current_task, target_tm, id_map)
|
|
162
217
|
|
|
163
218
|
target_tm._save_tasks()
|
|
164
|
-
|
|
219
|
+
|
|
220
|
+
# Remove from source: descendants first, then parent
|
|
221
|
+
self._remove_tasks_from_source(task_id, descendants)
|
|
165
222
|
self.registry.record_usage(target_project)
|
|
166
223
|
|
|
224
|
+
count_msg = f" + {len(descendants)} subtask{'s' if len(descendants) != 1 else ''}" if descendants else ""
|
|
167
225
|
if category_preserved:
|
|
168
|
-
print(f"✓ Moved task to {target_project} (kept in '{current_category}' category)")
|
|
226
|
+
print(f"✓ Moved task{count_msg} to {target_project} (kept in '{current_category}' category)")
|
|
169
227
|
elif current_category:
|
|
170
228
|
print(
|
|
171
|
-
f"✓ Moved task to {target_project} "
|
|
229
|
+
f"✓ Moved task{count_msg} to {target_project} "
|
|
172
230
|
f"(category '{current_category}' not available, added to default)"
|
|
173
231
|
)
|
|
174
232
|
else:
|
|
175
|
-
print(f"✓ Moved task to {target_project}")
|
|
233
|
+
print(f"✓ Moved task{count_msg} to {target_project}")
|
|
176
234
|
return True
|
|
177
235
|
|
|
178
236
|
def transfer_task_to_category(self):
|
|
@@ -184,7 +242,7 @@ class TransferMixin:
|
|
|
184
242
|
|
|
185
243
|
task_text = current_task['text']
|
|
186
244
|
task_id = current_task['id']
|
|
187
|
-
|
|
245
|
+
descendants = self.task_manager._get_descendants(task_id)
|
|
188
246
|
|
|
189
247
|
project_dir = (
|
|
190
248
|
self.task_manager.project_dir if not self.task_manager.is_global else Path.cwd()
|
|
@@ -235,6 +293,12 @@ class TransferMixin:
|
|
|
235
293
|
return True
|
|
236
294
|
|
|
237
295
|
selected_category, selected_is_global = result.value
|
|
296
|
+
dest_name = selected_category or 'default'
|
|
297
|
+
|
|
298
|
+
if descendants:
|
|
299
|
+
if not self._confirm_subtask_action("Transfer", len(descendants), dest_name):
|
|
300
|
+
print("\n✗ Cancelled")
|
|
301
|
+
return True
|
|
238
302
|
|
|
239
303
|
if selected_is_global:
|
|
240
304
|
dest_tm = TaskManager(
|
|
@@ -248,18 +312,28 @@ class TransferMixin:
|
|
|
248
312
|
project_registry=self.registry,
|
|
249
313
|
)
|
|
250
314
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
315
|
+
# Transfer parent task with full metadata
|
|
316
|
+
parent_labels = [l for l in current_task.get('labels', [])
|
|
317
|
+
if not l.startswith('parent:')]
|
|
318
|
+
new_parent_id = self._transfer_task_to_target(
|
|
319
|
+
current_task, dest_tm, labels=parent_labels
|
|
320
|
+
)
|
|
255
321
|
|
|
256
|
-
|
|
322
|
+
# Transfer descendants with remapped parent labels
|
|
323
|
+
if descendants:
|
|
324
|
+
id_map = {task_id: new_parent_id}
|
|
325
|
+
self._transfer_descendants(current_task, dest_tm, id_map)
|
|
257
326
|
|
|
258
|
-
|
|
327
|
+
dest_tm._save_tasks()
|
|
328
|
+
|
|
329
|
+
# Remove from source: descendants first, then parent
|
|
330
|
+
self._remove_tasks_from_source(task_id, descendants)
|
|
331
|
+
|
|
332
|
+
count_msg = f" + {len(descendants)} subtask{'s' if len(descendants) != 1 else ''}" if descendants else ""
|
|
259
333
|
if selected_is_global:
|
|
260
|
-
print(f"✓ Transferred task to global:{dest_name}")
|
|
334
|
+
print(f"✓ Transferred task{count_msg} to global:{dest_name}")
|
|
261
335
|
else:
|
|
262
|
-
print(f"✓ Transferred task to {dest_name}")
|
|
336
|
+
print(f"✓ Transferred task{count_msg} to {dest_name}")
|
|
263
337
|
|
|
264
338
|
print("\nPress Enter to continue...", end='', flush=True)
|
|
265
339
|
input()
|
|
@@ -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
|
|
@@ -55,6 +55,21 @@ def _finalize_nested(item, nested_lines):
|
|
|
55
55
|
class SubtaskMixin:
|
|
56
56
|
"""Sync subtasks from task notes checklists."""
|
|
57
57
|
|
|
58
|
+
def _get_descendants(self, task_id):
|
|
59
|
+
"""Return all descendant tasks (BFS) for a parent task."""
|
|
60
|
+
descendants = []
|
|
61
|
+
queue = [task_id]
|
|
62
|
+
visited = {task_id}
|
|
63
|
+
while queue:
|
|
64
|
+
pid = queue.pop(0)
|
|
65
|
+
parent_label = f"parent:{pid}"
|
|
66
|
+
for task in self.tasks:
|
|
67
|
+
if parent_label in task.get('labels', []) and task['id'] not in visited:
|
|
68
|
+
visited.add(task['id'])
|
|
69
|
+
descendants.append(task)
|
|
70
|
+
queue.append(task['id'])
|
|
71
|
+
return descendants
|
|
72
|
+
|
|
58
73
|
def sync_subtasks_from_notes(self, task_id):
|
|
59
74
|
"""Parse checklist items from task notes and sync as subtasks.
|
|
60
75
|
|
|
@@ -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,7 +33,7 @@ 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
|
|
@@ -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": [
|
|
@@ -21,7 +21,7 @@ def display_categorized_shortcuts():
|
|
|
21
21
|
("d", "Delete task"),
|
|
22
22
|
("t", "Toggle task done"),
|
|
23
23
|
("a", "Archive task"),
|
|
24
|
-
("
|
|
24
|
+
(".a", "Toggle archive view"),
|
|
25
25
|
("m", "Move to different category"),
|
|
26
26
|
("Shift+M", "Move task to another project"),
|
|
27
27
|
("Ctrl+K", "Copy task to another project"),
|
|
@@ -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"),
|
|
@@ -97,7 +97,7 @@ def display_help():
|
|
|
97
97
|
"""Display comprehensive help information."""
|
|
98
98
|
help_text = f"""
|
|
99
99
|
{BOLD}Jott - Simple Interactive Task List{RESET}
|
|
100
|
-
{DIM}Version 0.5.
|
|
100
|
+
{DIM}Version 0.5.5{RESET}
|
|
101
101
|
|
|
102
102
|
{BOLD}BASIC USAGE:{RESET}
|
|
103
103
|
jott Start interactive task manager (current project)
|
|
@@ -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}
|
|
@@ -129,7 +129,7 @@ def display_help():
|
|
|
129
129
|
{CYAN}d{RESET} Delete selected task
|
|
130
130
|
{CYAN}t{RESET} Toggle task done/undone
|
|
131
131
|
{CYAN}a{RESET} Archive task (mark as done/canceled)
|
|
132
|
-
{CYAN}
|
|
132
|
+
{CYAN}.a{RESET} Toggle archive view
|
|
133
133
|
{CYAN}m{RESET} Move task to different category
|
|
134
134
|
{CYAN}Shift+M{RESET} Move task to another project
|
|
135
135
|
{CYAN}Ctrl+K{RESET} Copy task to another project
|
|
@@ -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}"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jott-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.5
|
|
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 @@ include = ["jot", "jot.*"]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "jott-cli"
|
|
10
|
-
version = "0.5.
|
|
10
|
+
version = "0.5.5"
|
|
11
11
|
description = "Feature-rich interactive CLI task manager with AI integration, calendar sync, and keyword automation"
|
|
12
12
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
13
13
|
requires-python = ">=3.6"
|
|
@@ -82,13 +82,12 @@ class TestQuickAddDispatchTable:
|
|
|
82
82
|
def test_critical_keys_present(self):
|
|
83
83
|
"""Verify that the most important shortcuts are wired up."""
|
|
84
84
|
critical = {
|
|
85
|
-
'
|
|
85
|
+
'\x04': 'delete_current', # Ctrl+D
|
|
86
86
|
'W': 'assign_day',
|
|
87
87
|
'P': 'set_priority',
|
|
88
88
|
'X': 'set_status',
|
|
89
89
|
'M': 'move_task_to_project',
|
|
90
90
|
'N': 'edit_task_notes',
|
|
91
|
-
'S': 'web_search',
|
|
92
91
|
'\x14': 'transfer_task_to_category', # Ctrl+T
|
|
93
92
|
}
|
|
94
93
|
for key, expected_method in critical.items():
|