jott-cli 0.5.4__tar.gz → 0.5.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {jott_cli-0.5.4/jott_cli.egg-info → jott_cli-0.5.6}/PKG-INFO +1 -1
  2. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/__init__.py +1 -1
  3. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/_app_navigation_mixin.py +1 -2
  4. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/_dispatch_mixin.py +12 -1
  5. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/app.py +0 -1
  6. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_transfer_mixin.py +120 -46
  7. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_subtask_mixin.py +15 -0
  8. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/display_help.py +5 -5
  9. {jott_cli-0.5.4 → jott_cli-0.5.6/jott_cli.egg-info}/PKG-INFO +1 -1
  10. {jott_cli-0.5.4 → jott_cli-0.5.6}/jott_cli.egg-info/SOURCES.txt +2 -1
  11. {jott_cli-0.5.4 → jott_cli-0.5.6}/pyproject.toml +1 -1
  12. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_jot.py +40 -30
  13. jott_cli-0.5.6/tests/test_transfer_subtasks.py +381 -0
  14. {jott_cli-0.5.4 → jott_cli-0.5.6}/LICENSE +0 -0
  15. {jott_cli-0.5.4 → jott_cli-0.5.6}/README.md +0 -0
  16. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/categories/__init__.py +0 -0
  17. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/categories/config.py +0 -0
  18. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/categories/manager.py +0 -0
  19. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/categories/templates.py +0 -0
  20. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/cli/__init__.py +0 -0
  21. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/cli/archive.py +0 -0
  22. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/cli/config.py +0 -0
  23. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/cli/views.py +0 -0
  24. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/__init__.py +0 -0
  25. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_ai_analysis_mixin.py +0 -0
  26. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_ai_suggest_mixin.py +0 -0
  27. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_audio_timer_mixin.py +0 -0
  28. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_bulk_mixin.py +0 -0
  29. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_context_mixin.py +0 -0
  30. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_core_mixin.py +0 -0
  31. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_gcal_mixin.py +0 -0
  32. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_metadata_mixin.py +0 -0
  33. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_notes_mixin.py +0 -0
  34. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/_web_clipboard_mixin.py +0 -0
  35. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/commands/handler.py +0 -0
  36. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/__init__.py +0 -0
  37. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_age_backlog_mixin.py +0 -0
  38. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_compress_mixin.py +0 -0
  39. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_crud_mixin.py +0 -0
  40. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_delete_mixin.py +0 -0
  41. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_export_mixin.py +0 -0
  42. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_id_migration_mixin.py +0 -0
  43. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_metadata_mixin.py +0 -0
  44. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_navigation_mixin.py +0 -0
  45. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/_persistence_mixin.py +0 -0
  46. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/archive_manager.py +0 -0
  47. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/constants.py +0 -0
  48. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/id_manager.py +0 -0
  49. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/core/task_manager.py +0 -0
  50. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/__init__.py +0 -0
  51. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/gcal/__init__.py +0 -0
  52. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/gcal/account_manager.py +0 -0
  53. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/gcal/auth.py +0 -0
  54. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/gcal/events.py +0 -0
  55. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/keywords/__init__.py +0 -0
  56. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/keywords/_config_mixin.py +0 -0
  57. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  58. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/integrations/keywords/handler.py +0 -0
  59. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/mcp/__init__.py +0 -0
  60. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/mcp/handlers.py +0 -0
  61. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/mcp/schemas.py +0 -0
  62. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/mcp/server.py +0 -0
  63. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/projects/__init__.py +0 -0
  64. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/projects/backup.py +0 -0
  65. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/projects/registry.py +0 -0
  66. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/__init__.py +0 -0
  67. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/display.py +0 -0
  68. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/display_archive.py +0 -0
  69. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/display_footer.py +0 -0
  70. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/display_projects.py +0 -0
  71. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/display_tasks.py +0 -0
  72. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/formatting.py +0 -0
  73. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/input.py +0 -0
  74. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/picker.py +0 -0
  75. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/rendering.py +0 -0
  76. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/ui/styles.py +0 -0
  77. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/utils/__init__.py +0 -0
  78. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/utils/date_utils.py +0 -0
  79. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/utils/text_utils.py +0 -0
  80. {jott_cli-0.5.4 → jott_cli-0.5.6}/jot/utils/validation.py +0 -0
  81. {jott_cli-0.5.4 → jott_cli-0.5.6}/jott_cli.egg-info/dependency_links.txt +0 -0
  82. {jott_cli-0.5.4 → jott_cli-0.5.6}/jott_cli.egg-info/entry_points.txt +0 -0
  83. {jott_cli-0.5.4 → jott_cli-0.5.6}/jott_cli.egg-info/requires.txt +0 -0
  84. {jott_cli-0.5.4 → jott_cli-0.5.6}/jott_cli.egg-info/top_level.txt +0 -0
  85. {jott_cli-0.5.4 → jott_cli-0.5.6}/setup.cfg +0 -0
  86. {jott_cli-0.5.4 → jott_cli-0.5.6}/setup.py +0 -0
  87. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_command_handler.py +0 -0
  88. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_dispatch.py +0 -0
  89. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_edit_edge_cases.py +0 -0
  90. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_fuzzy_search.py +0 -0
  91. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_gcal_notes.py +0 -0
  92. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_highlight.py +0 -0
  93. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_input.py +0 -0
  94. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_picker.py +0 -0
  95. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_styles.py +0 -0
  96. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_subtask_notes.py +0 -0
  97. {jott_cli-0.5.4 → jott_cli-0.5.6}/tests/test_terminal_wrap.py +0 -0
  98. {jott_cli-0.5.4 → jott_cli-0.5.6}/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.4
3
+ Version: 0.5.6
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.4"
10
+ __version__ = "0.5.6"
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 A/O/Y/F. Returns True if
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 ('A', 'O', 'Y'):
156
+ if key in ('O', 'Y'):
157
157
  self._handle_toggle(key)
158
158
  return
159
159
 
@@ -317,6 +317,17 @@ 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
327
+ if second == 'G':
328
+ self.command_handler.export_to_gcal()
329
+ st.input_buffer = ""
330
+ return
320
331
  # Not a chord — treat '.' as normal input
321
332
  st.input_buffer += '.'
322
333
  if second and second != '.':
@@ -65,7 +65,6 @@ class App(DispatchMixin, AppNavigationMixin):
65
65
  'P': 'set_priority',
66
66
  'H': 'set_priority_high',
67
67
  'X': 'set_status',
68
- 'G': 'export_to_gcal',
69
68
  'E': 'trigger_keyword_action',
70
69
  'N': 'edit_task_notes',
71
70
  '\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
- target_tm.add_task(
81
- task_text,
82
- priority=current_task.get('priority', 'none'),
83
- status=current_task.get('status', 'todo'),
84
- labels=[l for l in current_task.get('labels', [])
85
- if not l.startswith('parent:')],
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
- # Preserve additional metadata on the new task
90
- new_task = target_tm.tasks[-1]
91
- new_task['notes'] = current_task.get('notes', '')
92
- new_task['agent_task'] = current_task.get('agent_task', False)
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
- target_tm.add_task(
147
- task_text,
148
- priority=current_task.get('priority', 'none'),
149
- status=current_task.get('status', 'todo'),
150
- labels=[l for l in current_task.get('labels', [])
151
- if not l.startswith('parent:')],
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
- new_task = target_tm.tasks[-1]
156
- new_task['notes'] = current_task.get('notes', '')
157
- new_task['agent_task'] = current_task.get('agent_task', False)
158
- new_task['keyword'] = current_task.get('keyword', None)
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
- self.task_manager.remove_task(task_id)
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
- task_done = current_task.get('done', False)
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
- 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')
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
- self.task_manager.remove_task(task_id)
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
- dest_name = selected_category or 'default'
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
- ("Shift+A", "Toggle archive view"),
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"),
@@ -63,7 +63,7 @@ def display_categorized_shortcuts():
63
63
  ("Shift+1", "Fix duplicate task IDs"),
64
64
  ("Shift+4", "AI task suggestion"),
65
65
  ("Shift+E", "Execute keyword action"),
66
- ("Shift+G", "Google Calendar setup"),
66
+ (".G", "Export to Google Calendar"),
67
67
  (".i", "Import from Google Calendar"),
68
68
  (".c", "Copy task to clipboard"),
69
69
  ("Ctrl+U", "Open URLs in task"),
@@ -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.2{RESET}
100
+ {DIM}Version 0.5.6{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}Shift+A{RESET} Toggle archive view
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
@@ -199,7 +199,7 @@ def display_help():
199
199
  {CYAN}Shift+1{RESET} Fix duplicate task IDs
200
200
  {CYAN}Shift+4{RESET} AI task suggestion
201
201
  {CYAN}Shift+E{RESET} Execute keyword action for task
202
- {CYAN}Shift+G{RESET} Set up Google Calendar integration
202
+ {CYAN}.G{RESET} Export to Google Calendar
203
203
  {CYAN}.i{RESET} Import tasks from Google Calendar
204
204
  {CYAN}.c{RESET} Copy task text to clipboard
205
205
  {CYAN}Ctrl+U{RESET} Open URLs found in task text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.5.4
3
+ Version: 0.5.6
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>
@@ -92,4 +92,5 @@ tests/test_picker.py
92
92
  tests/test_styles.py
93
93
  tests/test_subtask_notes.py
94
94
  tests/test_terminal_wrap.py
95
- tests/test_today_filter.py
95
+ tests/test_today_filter.py
96
+ tests/test_transfer_subtasks.py
@@ -7,7 +7,7 @@ include = ["jot", "jot.*"]
7
7
 
8
8
  [project]
9
9
  name = "jott-cli"
10
- version = "0.5.4"
10
+ version = "0.5.6"
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
- # Simulate the auto-registration logic from main()
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
- # is_global=True should skip registration
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
- # Should not re-register (usage_count should stay 0)
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