jott-cli 0.5.4__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.4/jott_cli.egg-info → jott_cli-0.5.5}/PKG-INFO +1 -1
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/__init__.py +1 -1
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/_app_navigation_mixin.py +1 -2
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/_dispatch_mixin.py +8 -1
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_transfer_mixin.py +120 -46
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_subtask_mixin.py +15 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/display_help.py +3 -3
- {jott_cli-0.5.4 → jott_cli-0.5.5/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jott_cli.egg-info/SOURCES.txt +2 -1
- {jott_cli-0.5.4 → jott_cli-0.5.5}/pyproject.toml +1 -1
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_jot.py +40 -30
- jott_cli-0.5.5/tests/test_transfer_subtasks.py +381 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/LICENSE +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/README.md +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/app.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/categories/config.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/cli/config.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/cli/views.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_metadata_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/commands/handler.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_crud_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_delete_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_metadata_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/constants.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/projects/registry.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/display.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/display_footer.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/display_tasks.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/input.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/picker.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/rendering.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/ui/styles.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/setup.cfg +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/setup.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_dispatch.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_highlight.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_input.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_picker.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_styles.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_subtask_notes.py +0 -0
- {jott_cli-0.5.4 → jott_cli-0.5.5}/tests/test_terminal_wrap.py +0 -0
- {jott_cli-0.5.4 → 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,12 +38,11 @@ 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'),
|
|
@@ -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
|
|
|
@@ -317,6 +317,13 @@ class DispatchMixin:
|
|
|
317
317
|
self.command_handler.web_search()
|
|
318
318
|
st.input_buffer = ""
|
|
319
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
|
|
320
327
|
# Not a chord — treat '.' as normal input
|
|
321
328
|
st.input_buffer += '.'
|
|
322
329
|
if second and second != '.':
|
|
@@ -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()
|
|
@@ -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
|
|
|
@@ -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"),
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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"
|
|
@@ -730,6 +730,20 @@ class TestProjectRegistry:
|
|
|
730
730
|
assert pr.is_path_registered("/other/path") is False
|
|
731
731
|
|
|
732
732
|
|
|
733
|
+
def _simulate_auto_register(proj_dir, registry, is_global=False):
|
|
734
|
+
"""Simulate the auto-registration logic from main().
|
|
735
|
+
|
|
736
|
+
Uses proj_dir (task_manager.project_dir) which is always set,
|
|
737
|
+
regardless of whether target_dir was passed on the command line.
|
|
738
|
+
"""
|
|
739
|
+
if (
|
|
740
|
+
not is_global
|
|
741
|
+
and (proj_dir / '.jot.json').exists()
|
|
742
|
+
and not registry.is_path_registered(proj_dir)
|
|
743
|
+
):
|
|
744
|
+
registry.register_project(proj_dir.name, str(proj_dir))
|
|
745
|
+
|
|
746
|
+
|
|
733
747
|
def test_auto_register_on_open():
|
|
734
748
|
"""Test that main() auto-registers a project with .jot.json not in registry"""
|
|
735
749
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
@@ -744,40 +758,45 @@ def test_auto_register_on_open():
|
|
|
744
758
|
pr = ProjectRegistry(registry_file=str(registry_file))
|
|
745
759
|
assert pr.is_path_registered(str(project_dir)) is False
|
|
746
760
|
|
|
747
|
-
|
|
748
|
-
target_dir = project_dir
|
|
749
|
-
is_global = False
|
|
750
|
-
if (
|
|
751
|
-
target_dir
|
|
752
|
-
and not is_global
|
|
753
|
-
and (target_dir / '.jot.json').exists()
|
|
754
|
-
and not pr.is_path_registered(target_dir)
|
|
755
|
-
):
|
|
756
|
-
pr.register_project(target_dir.name, str(target_dir))
|
|
761
|
+
_simulate_auto_register(project_dir, pr)
|
|
757
762
|
|
|
758
763
|
assert pr.is_path_registered(str(project_dir)) is True
|
|
759
764
|
assert pr.get_project_path("newproject") is not None
|
|
760
765
|
|
|
761
766
|
|
|
767
|
+
def test_auto_register_when_no_target_dir():
|
|
768
|
+
"""Test auto-register works when user runs jott from inside a project (no target_dir)."""
|
|
769
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
770
|
+
tmpdir = Path(tmpdir)
|
|
771
|
+
project_dir = tmpdir / 'myproject'
|
|
772
|
+
project_dir.mkdir()
|
|
773
|
+
(project_dir / '.jot.json').write_text('{"tasks": [], "archived": []}')
|
|
774
|
+
|
|
775
|
+
registry_file = tmpdir / 'test-registry.json'
|
|
776
|
+
registry_file.write_text('{}')
|
|
777
|
+
|
|
778
|
+
pr = ProjectRegistry(registry_file=str(registry_file))
|
|
779
|
+
assert pr.is_path_registered(str(project_dir)) is False
|
|
780
|
+
|
|
781
|
+
# Simulate: user cd'd into project_dir and ran `jott` with no args.
|
|
782
|
+
# target_dir would be None, but task_manager.project_dir = cwd = project_dir
|
|
783
|
+
_simulate_auto_register(project_dir, pr)
|
|
784
|
+
|
|
785
|
+
assert pr.is_path_registered(str(project_dir)) is True
|
|
786
|
+
assert pr.get_project_path("myproject") is not None
|
|
787
|
+
|
|
788
|
+
|
|
762
789
|
def test_auto_register_skips_global():
|
|
763
790
|
"""Test that auto-registration is skipped for global categories"""
|
|
764
791
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
765
792
|
tmpdir = Path(tmpdir)
|
|
793
|
+
(tmpdir / '.jot.json').write_text('{"tasks": [], "archived": []}')
|
|
766
794
|
registry_file = tmpdir / 'test-registry.json'
|
|
767
795
|
registry_file.write_text('{}')
|
|
768
796
|
|
|
769
797
|
pr = ProjectRegistry(registry_file=str(registry_file))
|
|
770
798
|
|
|
771
|
-
|
|
772
|
-
target_dir = tmpdir
|
|
773
|
-
is_global = True
|
|
774
|
-
if (
|
|
775
|
-
target_dir
|
|
776
|
-
and not is_global
|
|
777
|
-
and (target_dir / '.jot.json').exists()
|
|
778
|
-
and not pr.is_path_registered(target_dir)
|
|
779
|
-
):
|
|
780
|
-
pr.register_project(target_dir.name, str(target_dir))
|
|
799
|
+
_simulate_auto_register(tmpdir, pr, is_global=True)
|
|
781
800
|
|
|
782
801
|
assert pr.is_path_registered(str(tmpdir)) is False
|
|
783
802
|
|
|
@@ -796,16 +815,7 @@ def test_auto_register_skips_already_registered():
|
|
|
796
815
|
pr = ProjectRegistry(registry_file=str(registry_file))
|
|
797
816
|
pr.register_project("existing", str(project_dir))
|
|
798
817
|
|
|
799
|
-
|
|
800
|
-
target_dir = project_dir
|
|
801
|
-
is_global = False
|
|
802
|
-
if (
|
|
803
|
-
target_dir
|
|
804
|
-
and not is_global
|
|
805
|
-
and (target_dir / '.jot.json').exists()
|
|
806
|
-
and not pr.is_path_registered(target_dir)
|
|
807
|
-
):
|
|
808
|
-
pr.register_project(target_dir.name, str(target_dir))
|
|
818
|
+
_simulate_auto_register(project_dir, pr)
|
|
809
819
|
|
|
810
820
|
items = pr.list_projects_by_usage()
|
|
811
821
|
assert len(items) == 1
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Tests for subtask transfer — move/copy/transfer with descendants (#634)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch, MagicMock
|
|
7
|
+
|
|
8
|
+
from jot.core.task_manager import TaskManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_tm(tmpdir):
|
|
12
|
+
"""Create a TaskManager in a temporary directory."""
|
|
13
|
+
jot_file = tmpdir / '.jot.json'
|
|
14
|
+
jot_file.write_text(json.dumps({'tasks': [], 'archived': []}))
|
|
15
|
+
ids_file = tmpdir / '.jot.ids.json'
|
|
16
|
+
ids_file.write_text(json.dumps({'next_id': 1, 'allocated': []}))
|
|
17
|
+
return TaskManager(directory=tmpdir)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestGetDescendants:
|
|
21
|
+
"""Tests for SubtaskMixin._get_descendants()."""
|
|
22
|
+
|
|
23
|
+
def test_no_children(self):
|
|
24
|
+
"""Leaf task returns empty list."""
|
|
25
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
26
|
+
tm = _make_tm(Path(tmpdir))
|
|
27
|
+
tm.add_task('Parent')
|
|
28
|
+
tm.add_task('Unrelated')
|
|
29
|
+
pid = tm.tasks[0]['id']
|
|
30
|
+
assert tm._get_descendants(pid) == []
|
|
31
|
+
|
|
32
|
+
def test_direct_children(self):
|
|
33
|
+
"""Returns direct children of parent."""
|
|
34
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
35
|
+
tm = _make_tm(Path(tmpdir))
|
|
36
|
+
tm.add_task('Parent')
|
|
37
|
+
pid = tm.tasks[0]['id']
|
|
38
|
+
tm.add_task('Child 1', labels=[f'parent:{pid}'])
|
|
39
|
+
tm.add_task('Child 2', labels=[f'parent:{pid}'])
|
|
40
|
+
|
|
41
|
+
descendants = tm._get_descendants(pid)
|
|
42
|
+
texts = [d['text'] for d in descendants]
|
|
43
|
+
assert texts == ['Child 1', 'Child 2']
|
|
44
|
+
|
|
45
|
+
def test_grandchildren(self):
|
|
46
|
+
"""Returns children and grandchildren in BFS order."""
|
|
47
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
48
|
+
tm = _make_tm(Path(tmpdir))
|
|
49
|
+
tm.add_task('Root')
|
|
50
|
+
root_id = tm.tasks[0]['id']
|
|
51
|
+
tm.add_task('Child', labels=[f'parent:{root_id}'])
|
|
52
|
+
child_id = tm.tasks[1]['id']
|
|
53
|
+
tm.add_task('Grandchild', labels=[f'parent:{child_id}'])
|
|
54
|
+
|
|
55
|
+
descendants = tm._get_descendants(root_id)
|
|
56
|
+
texts = [d['text'] for d in descendants]
|
|
57
|
+
assert texts == ['Child', 'Grandchild']
|
|
58
|
+
|
|
59
|
+
def test_no_cycles(self):
|
|
60
|
+
"""Visited set prevents infinite loops on cycle."""
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
62
|
+
tm = _make_tm(Path(tmpdir))
|
|
63
|
+
tm.add_task('A')
|
|
64
|
+
tm.add_task('B')
|
|
65
|
+
a_id = tm.tasks[0]['id']
|
|
66
|
+
b_id = tm.tasks[1]['id']
|
|
67
|
+
# Create cycle: A -> B -> A
|
|
68
|
+
tm.tasks[1]['labels'] = [f'parent:{a_id}']
|
|
69
|
+
tm.tasks[0]['labels'] = [f'parent:{b_id}']
|
|
70
|
+
tm._save_tasks()
|
|
71
|
+
|
|
72
|
+
descendants = tm._get_descendants(a_id)
|
|
73
|
+
# Should find B but not loop back to A
|
|
74
|
+
assert len(descendants) == 1
|
|
75
|
+
assert descendants[0]['id'] == b_id
|
|
76
|
+
|
|
77
|
+
def test_nonexistent_parent(self):
|
|
78
|
+
"""Returns empty for nonexistent parent ID."""
|
|
79
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
80
|
+
tm = _make_tm(Path(tmpdir))
|
|
81
|
+
tm.add_task('Unrelated')
|
|
82
|
+
assert tm._get_descendants(999) == []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestTransferTaskToTarget:
|
|
86
|
+
"""Tests for TransferMixin._transfer_task_to_target()."""
|
|
87
|
+
|
|
88
|
+
def test_preserves_metadata(self):
|
|
89
|
+
"""Transferred task preserves priority, status, effort, notes, etc."""
|
|
90
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
91
|
+
tmpdir = Path(tmpdir)
|
|
92
|
+
source = tmpdir / 'source'
|
|
93
|
+
target = tmpdir / 'target'
|
|
94
|
+
source.mkdir()
|
|
95
|
+
target.mkdir()
|
|
96
|
+
|
|
97
|
+
tm_src = _make_tm(source)
|
|
98
|
+
tm_src.add_task('Test task', priority='high', status='in_progress',
|
|
99
|
+
effort='large')
|
|
100
|
+
task = tm_src.tasks[0]
|
|
101
|
+
task['notes'] = 'Important notes'
|
|
102
|
+
task['agent_task'] = True
|
|
103
|
+
task['keyword'] = 'bug'
|
|
104
|
+
task['day'] = '2026-03-31'
|
|
105
|
+
tm_src._save_tasks()
|
|
106
|
+
|
|
107
|
+
tm_tgt = _make_tm(target)
|
|
108
|
+
|
|
109
|
+
mixin = _make_transfer_mixin(tm_src)
|
|
110
|
+
new_id = mixin._transfer_task_to_target(task, tm_tgt, labels=['feature'])
|
|
111
|
+
|
|
112
|
+
new_task = tm_tgt.tasks[-1]
|
|
113
|
+
assert new_task['id'] == new_id
|
|
114
|
+
assert new_task['text'] == 'Test task'
|
|
115
|
+
assert new_task['priority'] == 'high'
|
|
116
|
+
assert new_task['status'] == 'in_progress'
|
|
117
|
+
assert new_task['effort'] == 'large'
|
|
118
|
+
assert new_task['notes'] == 'Important notes'
|
|
119
|
+
assert new_task['agent_task'] is True
|
|
120
|
+
assert new_task['keyword'] == 'bug'
|
|
121
|
+
assert new_task['day'] == '2026-03-31'
|
|
122
|
+
assert new_task['labels'] == ['feature']
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestMoveTaskWithSubtasks:
|
|
126
|
+
"""Tests for moving parent + descendants to another project."""
|
|
127
|
+
|
|
128
|
+
def test_move_parent_with_children(self):
|
|
129
|
+
"""Move parent + children, verify target has remapped parent labels."""
|
|
130
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
131
|
+
tmpdir = Path(tmpdir)
|
|
132
|
+
source = tmpdir / 'source'
|
|
133
|
+
target = tmpdir / 'target'
|
|
134
|
+
source.mkdir()
|
|
135
|
+
target.mkdir()
|
|
136
|
+
|
|
137
|
+
tm_src = _make_tm(source)
|
|
138
|
+
tm_src.add_task('Parent task')
|
|
139
|
+
pid = tm_src.tasks[0]['id']
|
|
140
|
+
tm_src.add_task('Child 1', labels=[f'parent:{pid}'])
|
|
141
|
+
tm_src.add_task('Child 2', labels=[f'parent:{pid}', 'feature'])
|
|
142
|
+
tm_src.tasks[0]['current'] = True
|
|
143
|
+
tm_src._save_tasks()
|
|
144
|
+
|
|
145
|
+
tm_tgt = _make_tm(target)
|
|
146
|
+
registry = _make_mock_registry(target)
|
|
147
|
+
|
|
148
|
+
mixin = _make_transfer_mixin(tm_src, registry=registry)
|
|
149
|
+
|
|
150
|
+
with patch.object(mixin, '_pick_project_fuzzy', return_value='target-proj'), \
|
|
151
|
+
patch('jot.commands._transfer_mixin.TaskManager', return_value=tm_tgt), \
|
|
152
|
+
patch('jot.commands._transfer_mixin.CategoryManager'), \
|
|
153
|
+
patch('builtins.input', return_value='y'):
|
|
154
|
+
mixin.move_task_to_project()
|
|
155
|
+
|
|
156
|
+
# Source should have no tasks (all moved)
|
|
157
|
+
assert len(tm_src.tasks) == 0
|
|
158
|
+
|
|
159
|
+
# Target should have 3 tasks with correct parent links
|
|
160
|
+
assert len(tm_tgt.tasks) == 3
|
|
161
|
+
new_pid = tm_tgt.tasks[0]['id']
|
|
162
|
+
assert tm_tgt.tasks[0]['text'] == 'Parent task'
|
|
163
|
+
|
|
164
|
+
child1 = tm_tgt.tasks[1]
|
|
165
|
+
assert child1['text'] == 'Child 1'
|
|
166
|
+
assert f'parent:{new_pid}' in child1['labels']
|
|
167
|
+
|
|
168
|
+
child2 = tm_tgt.tasks[2]
|
|
169
|
+
assert child2['text'] == 'Child 2'
|
|
170
|
+
assert f'parent:{new_pid}' in child2['labels']
|
|
171
|
+
assert 'feature' in child2['labels']
|
|
172
|
+
|
|
173
|
+
def test_move_leaf_task_no_subtasks(self):
|
|
174
|
+
"""Moving a leaf task (no children) works as before."""
|
|
175
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
176
|
+
tmpdir = Path(tmpdir)
|
|
177
|
+
source = tmpdir / 'source'
|
|
178
|
+
target = tmpdir / 'target'
|
|
179
|
+
source.mkdir()
|
|
180
|
+
target.mkdir()
|
|
181
|
+
|
|
182
|
+
tm_src = _make_tm(source)
|
|
183
|
+
tm_src.add_task('Leaf task')
|
|
184
|
+
tm_src.tasks[0]['current'] = True
|
|
185
|
+
tm_src._save_tasks()
|
|
186
|
+
|
|
187
|
+
tm_tgt = _make_tm(target)
|
|
188
|
+
registry = _make_mock_registry(target)
|
|
189
|
+
|
|
190
|
+
mixin = _make_transfer_mixin(tm_src, registry=registry)
|
|
191
|
+
|
|
192
|
+
with patch.object(mixin, '_pick_project_fuzzy', return_value='target-proj'), \
|
|
193
|
+
patch('jot.commands._transfer_mixin.TaskManager', return_value=tm_tgt), \
|
|
194
|
+
patch('jot.commands._transfer_mixin.CategoryManager'):
|
|
195
|
+
mixin.move_task_to_project()
|
|
196
|
+
|
|
197
|
+
assert len(tm_src.tasks) == 0
|
|
198
|
+
assert len(tm_tgt.tasks) == 1
|
|
199
|
+
assert tm_tgt.tasks[0]['text'] == 'Leaf task'
|
|
200
|
+
|
|
201
|
+
def test_move_with_grandchildren(self):
|
|
202
|
+
"""Move parent with multi-level hierarchy, all IDs remapped correctly."""
|
|
203
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
204
|
+
tmpdir = Path(tmpdir)
|
|
205
|
+
source = tmpdir / 'source'
|
|
206
|
+
target = tmpdir / 'target'
|
|
207
|
+
source.mkdir()
|
|
208
|
+
target.mkdir()
|
|
209
|
+
|
|
210
|
+
tm_src = _make_tm(source)
|
|
211
|
+
tm_src.add_task('Root')
|
|
212
|
+
root_id = tm_src.tasks[0]['id']
|
|
213
|
+
tm_src.add_task('Child', labels=[f'parent:{root_id}'])
|
|
214
|
+
child_id = tm_src.tasks[1]['id']
|
|
215
|
+
tm_src.add_task('Grandchild', labels=[f'parent:{child_id}'])
|
|
216
|
+
tm_src.tasks[0]['current'] = True
|
|
217
|
+
tm_src._save_tasks()
|
|
218
|
+
|
|
219
|
+
tm_tgt = _make_tm(target)
|
|
220
|
+
registry = _make_mock_registry(target)
|
|
221
|
+
|
|
222
|
+
mixin = _make_transfer_mixin(tm_src, registry=registry)
|
|
223
|
+
|
|
224
|
+
with patch.object(mixin, '_pick_project_fuzzy', return_value='target-proj'), \
|
|
225
|
+
patch('jot.commands._transfer_mixin.TaskManager', return_value=tm_tgt), \
|
|
226
|
+
patch('jot.commands._transfer_mixin.CategoryManager'), \
|
|
227
|
+
patch('builtins.input', return_value='y'):
|
|
228
|
+
mixin.move_task_to_project()
|
|
229
|
+
|
|
230
|
+
assert len(tm_src.tasks) == 0
|
|
231
|
+
assert len(tm_tgt.tasks) == 3
|
|
232
|
+
|
|
233
|
+
new_root_id = tm_tgt.tasks[0]['id']
|
|
234
|
+
new_child_id = tm_tgt.tasks[1]['id']
|
|
235
|
+
new_grandchild = tm_tgt.tasks[2]
|
|
236
|
+
|
|
237
|
+
assert f'parent:{new_root_id}' in tm_tgt.tasks[1]['labels']
|
|
238
|
+
assert f'parent:{new_child_id}' in new_grandchild['labels']
|
|
239
|
+
|
|
240
|
+
def test_move_cancelled_on_subtask_confirm(self):
|
|
241
|
+
"""User declining subtask confirmation aborts move."""
|
|
242
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
243
|
+
tmpdir = Path(tmpdir)
|
|
244
|
+
source = tmpdir / 'source'
|
|
245
|
+
target = tmpdir / 'target'
|
|
246
|
+
source.mkdir()
|
|
247
|
+
target.mkdir()
|
|
248
|
+
|
|
249
|
+
tm_src = _make_tm(source)
|
|
250
|
+
tm_src.add_task('Parent')
|
|
251
|
+
pid = tm_src.tasks[0]['id']
|
|
252
|
+
tm_src.add_task('Child', labels=[f'parent:{pid}'])
|
|
253
|
+
tm_src.tasks[0]['current'] = True
|
|
254
|
+
tm_src._save_tasks()
|
|
255
|
+
|
|
256
|
+
registry = _make_mock_registry(target)
|
|
257
|
+
mixin = _make_transfer_mixin(tm_src, registry=registry)
|
|
258
|
+
|
|
259
|
+
with patch.object(mixin, '_pick_project_fuzzy', return_value='target-proj'), \
|
|
260
|
+
patch('builtins.input', return_value='n'):
|
|
261
|
+
mixin.move_task_to_project()
|
|
262
|
+
|
|
263
|
+
# Nothing should have been removed
|
|
264
|
+
assert len(tm_src.tasks) == 2
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class TestCopyTaskWithSubtasks:
|
|
268
|
+
"""Tests for copying parent + descendants to another project."""
|
|
269
|
+
|
|
270
|
+
def test_copy_parent_with_children(self):
|
|
271
|
+
"""Copy parent + children, originals preserved."""
|
|
272
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
273
|
+
tmpdir = Path(tmpdir)
|
|
274
|
+
source = tmpdir / 'source'
|
|
275
|
+
target = tmpdir / 'target'
|
|
276
|
+
source.mkdir()
|
|
277
|
+
target.mkdir()
|
|
278
|
+
|
|
279
|
+
tm_src = _make_tm(source)
|
|
280
|
+
tm_src.add_task('Parent')
|
|
281
|
+
pid = tm_src.tasks[0]['id']
|
|
282
|
+
tm_src.add_task('Child', labels=[f'parent:{pid}'])
|
|
283
|
+
tm_src.tasks[0]['current'] = True
|
|
284
|
+
tm_src._save_tasks()
|
|
285
|
+
|
|
286
|
+
tm_tgt = _make_tm(target)
|
|
287
|
+
registry = _make_mock_registry(target)
|
|
288
|
+
|
|
289
|
+
mixin = _make_transfer_mixin(tm_src, registry=registry)
|
|
290
|
+
|
|
291
|
+
with patch.object(mixin, '_pick_project_fuzzy', return_value='target-proj'), \
|
|
292
|
+
patch('jot.commands._transfer_mixin.TaskManager', return_value=tm_tgt), \
|
|
293
|
+
patch('jot.commands._transfer_mixin.CategoryManager'), \
|
|
294
|
+
patch('builtins.input', return_value='y'):
|
|
295
|
+
mixin.copy_task_to_project()
|
|
296
|
+
|
|
297
|
+
# Source unchanged
|
|
298
|
+
assert len(tm_src.tasks) == 2
|
|
299
|
+
|
|
300
|
+
# Target has copies with remapped IDs
|
|
301
|
+
assert len(tm_tgt.tasks) == 2
|
|
302
|
+
new_pid = tm_tgt.tasks[0]['id']
|
|
303
|
+
assert tm_tgt.tasks[0]['text'] == 'Parent'
|
|
304
|
+
assert f'parent:{new_pid}' in tm_tgt.tasks[1]['labels']
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class TestTransferCategoryWithSubtasks:
|
|
308
|
+
"""Tests for transferring parent + descendants to another category."""
|
|
309
|
+
|
|
310
|
+
def test_transfer_parent_with_children(self):
|
|
311
|
+
"""Transfer parent + children to different category."""
|
|
312
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
313
|
+
tmpdir = Path(tmpdir)
|
|
314
|
+
|
|
315
|
+
# Source category
|
|
316
|
+
cat_dir = tmpdir / '.jot-categories'
|
|
317
|
+
cat_dir.mkdir()
|
|
318
|
+
src_dir = cat_dir / 'work'
|
|
319
|
+
src_dir.mkdir()
|
|
320
|
+
dest_dir = cat_dir / 'personal'
|
|
321
|
+
dest_dir.mkdir()
|
|
322
|
+
|
|
323
|
+
tm_src = _make_tm(src_dir)
|
|
324
|
+
tm_src.add_task('Parent')
|
|
325
|
+
pid = tm_src.tasks[0]['id']
|
|
326
|
+
tm_src.add_task('Child', labels=[f'parent:{pid}'])
|
|
327
|
+
tm_src.tasks[0]['current'] = True
|
|
328
|
+
tm_src._save_tasks()
|
|
329
|
+
|
|
330
|
+
tm_dest = _make_tm(dest_dir)
|
|
331
|
+
|
|
332
|
+
registry = MagicMock()
|
|
333
|
+
mixin = _make_transfer_mixin(tm_src, registry=registry)
|
|
334
|
+
mixin.task_manager.category = 'work'
|
|
335
|
+
mixin.task_manager.is_global = False
|
|
336
|
+
mixin.task_manager.project_dir = tmpdir
|
|
337
|
+
|
|
338
|
+
mock_picker_result = MagicMock()
|
|
339
|
+
mock_picker_result.value = ('personal', False)
|
|
340
|
+
|
|
341
|
+
with patch('jot.commands._transfer_mixin.CategoryManager') as MockCM, \
|
|
342
|
+
patch('jot.commands._transfer_mixin.CategoryConfig'), \
|
|
343
|
+
patch('jot.commands._transfer_mixin.quick_picker', return_value=mock_picker_result), \
|
|
344
|
+
patch('jot.commands._transfer_mixin.TaskManager', return_value=tm_dest), \
|
|
345
|
+
patch('builtins.input', side_effect=['y', '']):
|
|
346
|
+
mock_cm = MockCM.return_value
|
|
347
|
+
mock_cm.discover_categories.return_value = ['work', 'personal']
|
|
348
|
+
mock_cm.discover_global_categories.return_value = []
|
|
349
|
+
mixin.transfer_task_to_category()
|
|
350
|
+
|
|
351
|
+
# Source cleared
|
|
352
|
+
assert len(tm_src.tasks) == 0
|
|
353
|
+
|
|
354
|
+
# Dest has both with remapped parent
|
|
355
|
+
assert len(tm_dest.tasks) == 2
|
|
356
|
+
new_pid = tm_dest.tasks[0]['id']
|
|
357
|
+
assert tm_dest.tasks[0]['text'] == 'Parent'
|
|
358
|
+
assert f'parent:{new_pid}' in tm_dest.tasks[1]['labels']
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Helpers
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def _make_mock_registry(target_path):
|
|
366
|
+
"""Create a mock registry that returns target_path."""
|
|
367
|
+
registry = MagicMock()
|
|
368
|
+
registry.get_project_path.return_value = str(target_path)
|
|
369
|
+
return registry
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _make_transfer_mixin(task_manager, registry=None):
|
|
373
|
+
"""Create a minimal TransferMixin instance for testing."""
|
|
374
|
+
from jot.commands._transfer_mixin import TransferMixin
|
|
375
|
+
|
|
376
|
+
class TestHandler(TransferMixin):
|
|
377
|
+
def __init__(self, tm, reg):
|
|
378
|
+
self.task_manager = tm
|
|
379
|
+
self.registry = reg
|
|
380
|
+
|
|
381
|
+
return TestHandler(task_manager, registry)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|