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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {jott_cli-0.5.3/jott_cli.egg-info → jott_cli-0.5.4}/PKG-INFO +1 -1
  2. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/__init__.py +1 -1
  3. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/_app_navigation_mixin.py +1 -2
  4. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/_dispatch_mixin.py +19 -3
  5. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/app.py +1 -4
  6. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/handler.py +2 -2
  7. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/projects/registry.py +8 -0
  8. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_footer.py +1 -1
  9. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_help.py +12 -12
  10. {jott_cli-0.5.3 → jott_cli-0.5.4/jott_cli.egg-info}/PKG-INFO +1 -1
  11. {jott_cli-0.5.3 → jott_cli-0.5.4}/pyproject.toml +1 -1
  12. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_dispatch.py +1 -2
  13. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_jot.py +223 -108
  14. {jott_cli-0.5.3 → jott_cli-0.5.4}/LICENSE +0 -0
  15. {jott_cli-0.5.3 → jott_cli-0.5.4}/README.md +0 -0
  16. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/__init__.py +0 -0
  17. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/config.py +0 -0
  18. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/manager.py +0 -0
  19. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/templates.py +0 -0
  20. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/__init__.py +0 -0
  21. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/archive.py +0 -0
  22. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/config.py +0 -0
  23. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/views.py +0 -0
  24. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/__init__.py +0 -0
  25. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_ai_analysis_mixin.py +0 -0
  26. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_ai_suggest_mixin.py +0 -0
  27. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_audio_timer_mixin.py +0 -0
  28. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_bulk_mixin.py +0 -0
  29. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_context_mixin.py +0 -0
  30. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_core_mixin.py +0 -0
  31. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_gcal_mixin.py +0 -0
  32. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_metadata_mixin.py +0 -0
  33. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_notes_mixin.py +0 -0
  34. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_transfer_mixin.py +0 -0
  35. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_web_clipboard_mixin.py +0 -0
  36. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/__init__.py +0 -0
  37. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_age_backlog_mixin.py +0 -0
  38. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_compress_mixin.py +0 -0
  39. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_crud_mixin.py +0 -0
  40. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_delete_mixin.py +0 -0
  41. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_export_mixin.py +0 -0
  42. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_id_migration_mixin.py +0 -0
  43. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_metadata_mixin.py +0 -0
  44. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_navigation_mixin.py +0 -0
  45. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_persistence_mixin.py +0 -0
  46. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_subtask_mixin.py +0 -0
  47. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/archive_manager.py +0 -0
  48. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/constants.py +0 -0
  49. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/id_manager.py +0 -0
  50. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/task_manager.py +0 -0
  51. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/__init__.py +0 -0
  52. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/__init__.py +0 -0
  53. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/account_manager.py +0 -0
  54. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/auth.py +0 -0
  55. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/events.py +0 -0
  56. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/__init__.py +0 -0
  57. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/_config_mixin.py +0 -0
  58. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  59. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/handler.py +0 -0
  60. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/__init__.py +0 -0
  61. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/handlers.py +0 -0
  62. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/schemas.py +0 -0
  63. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/server.py +0 -0
  64. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/projects/__init__.py +0 -0
  65. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/projects/backup.py +0 -0
  66. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/__init__.py +0 -0
  67. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display.py +0 -0
  68. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_archive.py +0 -0
  69. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_projects.py +0 -0
  70. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_tasks.py +0 -0
  71. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/formatting.py +0 -0
  72. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/input.py +0 -0
  73. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/picker.py +0 -0
  74. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/rendering.py +0 -0
  75. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/styles.py +0 -0
  76. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/__init__.py +0 -0
  77. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/date_utils.py +0 -0
  78. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/text_utils.py +0 -0
  79. {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/validation.py +0 -0
  80. {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/SOURCES.txt +0 -0
  81. {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/dependency_links.txt +0 -0
  82. {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/entry_points.txt +0 -0
  83. {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/requires.txt +0 -0
  84. {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/top_level.txt +0 -0
  85. {jott_cli-0.5.3 → jott_cli-0.5.4}/setup.cfg +0 -0
  86. {jott_cli-0.5.3 → jott_cli-0.5.4}/setup.py +0 -0
  87. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_command_handler.py +0 -0
  88. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_edit_edge_cases.py +0 -0
  89. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_fuzzy_search.py +0 -0
  90. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_gcal_notes.py +0 -0
  91. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_highlight.py +0 -0
  92. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_input.py +0 -0
  93. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_picker.py +0 -0
  94. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_styles.py +0 -0
  95. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_subtask_notes.py +0 -0
  96. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_terminal_wrap.py +0 -0
  97. {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_today_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Feature-rich interactive CLI task manager with AI integration, calendar sync, and keyword automation
5
5
  Author-email: Scott Anderson <sonander@gmail.com>
6
6
  Maintainer-email: Scott Anderson <sonander@gmail.com>
@@ -7,7 +7,7 @@ import importlib.util
7
7
  import os
8
8
  from pathlib import Path
9
9
 
10
- __version__ = "0.5.2"
10
+ __version__ = "0.5.4"
11
11
 
12
12
  # When building Sphinx docs, skip the dynamic import bridge and heavy deps.
13
13
  # Set JOT_SPHINX_BUILD=1 in docs/sphinx/conf.py before importing jot.
@@ -38,7 +38,7 @@ 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 A/O/Y/F. Returns True if
42
42
  the key was a toggle key."""
43
43
  st = self.state
44
44
 
@@ -47,7 +47,6 @@ class AppNavigationMixin:
47
47
  'O': ('sort_by_day', None),
48
48
  'Y': ('show_today_only', None),
49
49
  'F': ('show_notes_inline', 'inline notes'),
50
- '?': ('show_shortcuts', 'shortcuts'),
51
50
  }
52
51
 
53
52
  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 == 'C':
33
+ if key == '\x03': # Ctrl+C
34
34
  self._handle_switch_category()
35
35
  return
36
36
 
@@ -296,11 +296,27 @@ 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.3)
300
- if second == 'c':
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
304
320
  # Not a chord — treat '.' as normal input
305
321
  st.input_buffer += '.'
306
322
  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
- 'D': 'delete_current',
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
@@ -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
- 'D': self.delete_current,
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 'D')
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
@@ -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}?{RESET}: shortcuts"
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
- ("Shift+C", "Switch category"),
14
+ ("Ctrl+C", "Switch category"),
15
15
  ("Shift+L", "Toggle all categories view"),
16
16
  ],
17
17
  "Task Management": [
@@ -40,7 +40,7 @@ def display_categorized_shortcuts():
40
40
  ("Shift+W", "Assign day to task"),
41
41
  ("Shift+O", "Toggle day sorting"),
42
42
  ("Shift+Y", "Toggle today filter"),
43
- ("Shift+D", "Mark current as done"),
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
- ("Shift+I", "Import from Google Calendar"),
68
- ("Shift+K", "Copy task to clipboard"),
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/?", "Show this help"),
72
+ ("h/.?", "Show this help"),
73
73
  ("r", "Refresh display"),
74
74
  ("Ctrl+R", "Register current project"),
75
75
  ("q", "Quit"),
@@ -119,7 +119,7 @@ def display_help():
119
119
  {CYAN}Tab{RESET} Cycle through categories
120
120
  {CYAN}Ctrl+F{RESET} Fuzzy search tasks
121
121
  {CYAN}Shift+Z{RESET} Switch between projects
122
- {CYAN}Shift+C{RESET} Switch between categories
122
+ {CYAN}Ctrl+C{RESET} Switch between categories
123
123
  {CYAN}Shift+L{RESET} Toggle all categories view
124
124
 
125
125
  {BOLD}TASK MANAGEMENT:{RESET}
@@ -147,7 +147,7 @@ def display_help():
147
147
  {CYAN}Shift+W{RESET} Assign day to task
148
148
  {CYAN}Shift+O{RESET} Toggle day sorting
149
149
  {CYAN}Shift+Y{RESET} Toggle today filter
150
- {CYAN}Shift+D{RESET} Mark current task as done
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}Shift+C{RESET} Create/switch categories interactively
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}Shift+I{RESET} Import tasks from Google Calendar
204
- {CYAN}Shift+K{RESET} Copy task text to clipboard
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 ?{RESET} Show keyboard shortcuts
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 (Shift+C)
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
3
+ Version: 0.5.4
4
4
  Summary: Feature-rich interactive CLI task manager with AI integration, calendar sync, and keyword automation
5
5
  Author-email: Scott Anderson <sonander@gmail.com>
6
6
  Maintainer-email: Scott Anderson <sonander@gmail.com>
@@ -7,7 +7,7 @@ include = ["jot", "jot.*"]
7
7
 
8
8
  [project]
9
9
  name = "jott-cli"
10
- version = "0.5.3"
10
+ version = "0.5.4"
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
- 'D': 'delete_current',
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():
@@ -666,9 +666,7 @@ class TestProjectRegistry:
666
666
  with tempfile.TemporaryDirectory() as tmpdir:
667
667
  registry_file = Path(tmpdir) / 'test-registry.json'
668
668
  # Write old format directly
669
- registry_file.write_text(json.dumps({
670
- "old-project": "/path/old"
671
- }))
669
+ registry_file.write_text(json.dumps({"old-project": "/path/old"}))
672
670
 
673
671
  pr = ProjectRegistry(registry_file=str(registry_file))
674
672
 
@@ -706,6 +704,112 @@ class TestProjectRegistry:
706
704
  items = pr.list_projects_by_usage()
707
705
  assert items[0][2] == 2 # Usage count preserved
708
706
 
707
+ def test_is_path_registered_new_format(self):
708
+ """Test is_path_registered with new dict format"""
709
+ with tempfile.TemporaryDirectory() as tmpdir:
710
+ registry_file = Path(tmpdir) / 'test-registry.json'
711
+ registry_file.write_text('{}')
712
+
713
+ pr = ProjectRegistry(registry_file=str(registry_file))
714
+ project_path = Path(tmpdir) / 'myproject'
715
+ project_path.mkdir()
716
+ pr.register_project("myproject", str(project_path))
717
+
718
+ assert pr.is_path_registered(str(project_path)) is True
719
+ assert pr.is_path_registered("/nonexistent/path") is False
720
+
721
+ def test_is_path_registered_old_format(self):
722
+ """Test is_path_registered with old string format"""
723
+ with tempfile.TemporaryDirectory() as tmpdir:
724
+ registry_file = Path(tmpdir) / 'test-registry.json'
725
+ resolved = str(Path(tmpdir).resolve() / 'oldproj')
726
+ registry_file.write_text(json.dumps({"oldproj": resolved}))
727
+
728
+ pr = ProjectRegistry(registry_file=str(registry_file))
729
+ assert pr.is_path_registered(resolved) is True
730
+ assert pr.is_path_registered("/other/path") is False
731
+
732
+
733
+ def test_auto_register_on_open():
734
+ """Test that main() auto-registers a project with .jot.json not in registry"""
735
+ with tempfile.TemporaryDirectory() as tmpdir:
736
+ tmpdir = Path(tmpdir)
737
+ project_dir = tmpdir / 'newproject'
738
+ project_dir.mkdir()
739
+ (project_dir / '.jot.json').write_text('{"tasks": [], "archived": []}')
740
+
741
+ registry_file = tmpdir / 'test-registry.json'
742
+ registry_file.write_text('{}')
743
+
744
+ pr = ProjectRegistry(registry_file=str(registry_file))
745
+ assert pr.is_path_registered(str(project_dir)) is False
746
+
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))
757
+
758
+ assert pr.is_path_registered(str(project_dir)) is True
759
+ assert pr.get_project_path("newproject") is not None
760
+
761
+
762
+ def test_auto_register_skips_global():
763
+ """Test that auto-registration is skipped for global categories"""
764
+ with tempfile.TemporaryDirectory() as tmpdir:
765
+ tmpdir = Path(tmpdir)
766
+ registry_file = tmpdir / 'test-registry.json'
767
+ registry_file.write_text('{}')
768
+
769
+ pr = ProjectRegistry(registry_file=str(registry_file))
770
+
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))
781
+
782
+ assert pr.is_path_registered(str(tmpdir)) is False
783
+
784
+
785
+ def test_auto_register_skips_already_registered():
786
+ """Test that auto-registration is skipped if already registered"""
787
+ with tempfile.TemporaryDirectory() as tmpdir:
788
+ tmpdir = Path(tmpdir)
789
+ project_dir = tmpdir / 'existing'
790
+ project_dir.mkdir()
791
+ (project_dir / '.jot.json').write_text('{"tasks": [], "archived": []}')
792
+
793
+ registry_file = tmpdir / 'test-registry.json'
794
+ registry_file.write_text('{}')
795
+
796
+ pr = ProjectRegistry(registry_file=str(registry_file))
797
+ pr.register_project("existing", str(project_dir))
798
+
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))
809
+
810
+ items = pr.list_projects_by_usage()
811
+ assert len(items) == 1
812
+
709
813
 
710
814
  def test_id_uniqueness_stress_test():
711
815
  """Stress test: Add, delete, add many times"""
@@ -3053,6 +3157,7 @@ class TestImportIntegrity:
3053
3157
  def test_task_manager_imports(self):
3054
3158
  """TaskManager module has all required imports (sys, re, uuid, etc.)"""
3055
3159
  import jot.core.task_manager as mod
3160
+
3056
3161
  assert hasattr(mod, 'sys')
3057
3162
  assert hasattr(mod, 're')
3058
3163
  assert hasattr(mod, 'json')
@@ -3064,6 +3169,7 @@ class TestImportIntegrity:
3064
3169
  import jot.commands._web_clipboard_mixin as web_mod
3065
3170
  import jot.commands._ai_analysis_mixin as ai_mod
3066
3171
  import jot.commands._core_mixin as core_mod
3172
+
3067
3173
  assert hasattr(web_mod, 'webbrowser')
3068
3174
  assert hasattr(web_mod, 'urllib')
3069
3175
  assert hasattr(ai_mod, 'uuid')
@@ -3072,12 +3178,14 @@ class TestImportIntegrity:
3072
3178
  def test_keyword_handler_imports(self):
3073
3179
  """KeywordHandler mixin modules have all required imports"""
3074
3180
  import jot.integrations.keywords._handlers_mixin as mod
3181
+
3075
3182
  assert hasattr(mod, 'json')
3076
3183
  assert hasattr(mod, 'subprocess')
3077
3184
 
3078
3185
  def test_category_templates_importable(self):
3079
3186
  """CategoryTemplates class exists and is importable"""
3080
3187
  from jot.categories.templates import CategoryTemplates
3188
+
3081
3189
  ct = CategoryTemplates()
3082
3190
  templates = ct.list_templates()
3083
3191
  assert len(templates) > 0
@@ -3086,6 +3194,7 @@ class TestImportIntegrity:
3086
3194
  def test_category_templates_apply(self):
3087
3195
  """CategoryTemplates can apply a template"""
3088
3196
  from jot.categories.templates import CategoryTemplates
3197
+
3089
3198
  with tempfile.TemporaryDirectory() as tmpdir:
3090
3199
  ct = CategoryTemplates(project_dir=tmpdir)
3091
3200
  success, msg = ct.apply_template('software-dev')
@@ -3095,6 +3204,7 @@ class TestImportIntegrity:
3095
3204
  def test_category_templates_invalid(self):
3096
3205
  """CategoryTemplates rejects invalid template name"""
3097
3206
  from jot.categories.templates import CategoryTemplates
3207
+
3098
3208
  ct = CategoryTemplates()
3099
3209
  success, msg = ct.apply_template('nonexistent-template')
3100
3210
  assert success is False
@@ -3102,14 +3212,18 @@ class TestImportIntegrity:
3102
3212
  def test_webbrowser_open_url_no_crash(self):
3103
3213
  """open_url method can be called without NameError"""
3104
3214
  from jot.commands.handler import CommandHandler
3215
+
3105
3216
  with tempfile.TemporaryDirectory() as tmpdir:
3106
3217
  tmpdir = Path(tmpdir)
3107
3218
  jot_file = tmpdir / '.jot.json'
3108
- jot_file.write_text(json.dumps({
3109
- 'tasks': [{'id': 1, 'text': 'no url here', 'done': False,
3110
- 'current': True}],
3111
- 'archived': [],
3112
- }))
3219
+ jot_file.write_text(
3220
+ json.dumps(
3221
+ {
3222
+ 'tasks': [{'id': 1, 'text': 'no url here', 'done': False, 'current': True}],
3223
+ 'archived': [],
3224
+ }
3225
+ )
3226
+ )
3113
3227
  tm = TaskManager(directory=tmpdir)
3114
3228
  ch = CommandHandler(tm)
3115
3229
  # Should print "No URLs found" but NOT raise NameError
@@ -3119,6 +3233,7 @@ class TestImportIntegrity:
3119
3233
  def test_web_search_no_crash(self):
3120
3234
  """web_search method doesn't crash on urllib.parse import"""
3121
3235
  import jot.commands._web_clipboard_mixin as mod
3236
+
3122
3237
  # Verify urllib.parse is available in the module
3123
3238
  assert hasattr(mod.urllib, 'parse')
3124
3239
  assert callable(mod.urllib.parse.quote_plus)
@@ -3126,17 +3241,20 @@ class TestImportIntegrity:
3126
3241
  def test_uuid_available_in_handler(self):
3127
3242
  """uuid module available for session tracking in analysis mixin"""
3128
3243
  import jot.commands._ai_analysis_mixin as mod
3244
+
3129
3245
  assert callable(mod.uuid.uuid4)
3130
3246
  assert callable(mod.uuid.uuid5)
3131
3247
 
3132
3248
  def test_sys_stderr_in_task_manager(self):
3133
3249
  """sys.stderr available for warning output in TaskManager"""
3134
3250
  import jot.core.task_manager as mod
3251
+
3135
3252
  assert hasattr(mod.sys, 'stderr')
3136
3253
 
3137
3254
  def test_keyword_all_broadcast(self):
3138
3255
  """'all:' keyword returns __all__ sentinel for broadcast"""
3139
3256
  from jot.integrations.keywords.handler import KeywordHandler
3257
+
3140
3258
  kh = KeywordHandler()
3141
3259
  keyword, text, project = kh.extract_keyword('all: Test task')
3142
3260
  assert keyword is None
@@ -3148,20 +3266,29 @@ class TestImportIntegrity:
3148
3266
  with tempfile.TemporaryDirectory() as tmpdir:
3149
3267
  tmpdir = Path(tmpdir)
3150
3268
  jot_file = tmpdir / '.jot.json'
3151
- jot_file.write_text(json.dumps({
3152
- 'tasks': [], 'archived': [],
3153
- }))
3269
+ jot_file.write_text(
3270
+ json.dumps(
3271
+ {
3272
+ 'tasks': [],
3273
+ 'archived': [],
3274
+ }
3275
+ )
3276
+ )
3154
3277
  ids_file = tmpdir / '.jot.ids.json'
3155
- ids_file.write_text(json.dumps({
3156
- 'next_id': 1, 'allocated': [],
3157
- }))
3278
+ ids_file.write_text(
3279
+ json.dumps(
3280
+ {
3281
+ 'next_id': 1,
3282
+ 'allocated': [],
3283
+ }
3284
+ )
3285
+ )
3158
3286
 
3159
3287
  tm = TaskManager(directory=tmpdir)
3160
3288
  tm.add_task('Parent task')
3161
3289
  parent_id = tm.tasks[0]['id']
3162
3290
 
3163
- tm.set_task_notes(parent_id,
3164
- '- [ ] sub one\n- [ ] sub two\n- [X] sub done')
3291
+ tm.set_task_notes(parent_id, '- [ ] sub one\n- [ ] sub two\n- [X] sub done')
3165
3292
 
3166
3293
  # First sync — checked item skipped (#578)
3167
3294
  r1 = tm.sync_subtasks_from_notes(parent_id)
@@ -3180,13 +3307,23 @@ class TestImportIntegrity:
3180
3307
  with tempfile.TemporaryDirectory() as tmpdir:
3181
3308
  tmpdir = Path(tmpdir)
3182
3309
  jot_file = tmpdir / '.jot.json'
3183
- jot_file.write_text(json.dumps({
3184
- 'tasks': [], 'archived': [],
3185
- }))
3310
+ jot_file.write_text(
3311
+ json.dumps(
3312
+ {
3313
+ 'tasks': [],
3314
+ 'archived': [],
3315
+ }
3316
+ )
3317
+ )
3186
3318
  ids_file = tmpdir / '.jot.ids.json'
3187
- ids_file.write_text(json.dumps({
3188
- 'next_id': 1, 'allocated': [],
3189
- }))
3319
+ ids_file.write_text(
3320
+ json.dumps(
3321
+ {
3322
+ 'next_id': 1,
3323
+ 'allocated': [],
3324
+ }
3325
+ )
3326
+ )
3190
3327
 
3191
3328
  tm = TaskManager(directory=tmpdir)
3192
3329
  tm.add_task('Parent')
@@ -3218,17 +3355,14 @@ class TestImportIntegrity:
3218
3355
  proj_b.mkdir()
3219
3356
 
3220
3357
  for proj in [proj_a, proj_b]:
3221
- (proj / '.jot.json').write_text(
3222
- json.dumps({'tasks': [], 'archived': []}))
3223
- (proj / '.jot.ids.json').write_text(
3224
- json.dumps({'next_id': 1, 'allocated': []}))
3358
+ (proj / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3359
+ (proj / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3225
3360
 
3226
3361
  # Pre-create empty registry to prevent auto-discover
3227
3362
  # from loading real ~/projects into the test registry
3228
3363
  reg_file = tmpdir / '.jot-projects.json'
3229
3364
  reg_file.write_text('{}')
3230
- registry = ProjectRegistry(
3231
- registry_file=str(reg_file))
3365
+ registry = ProjectRegistry(registry_file=str(reg_file))
3232
3366
  registry.register_project('proj-a', str(proj_a))
3233
3367
  registry.register_project('proj-b', str(proj_b))
3234
3368
 
@@ -3247,8 +3381,10 @@ class TestImportIntegrity:
3247
3381
  """CLI --all broadcast requires y confirmation, N cancels"""
3248
3382
  from unittest.mock import patch
3249
3383
  import importlib.util
3384
+
3250
3385
  spec = importlib.util.spec_from_file_location(
3251
- "jot_main", str(Path(__file__).parent.parent / "jot.py"))
3386
+ "jot_main", str(Path(__file__).parent.parent / "jot.py")
3387
+ )
3252
3388
  jot_main = importlib.util.module_from_spec(spec)
3253
3389
  spec.loader.exec_module(jot_main)
3254
3390
  _handle_initial_task = jot_main._handle_initial_task
@@ -3257,10 +3393,8 @@ class TestImportIntegrity:
3257
3393
  tmpdir = Path(tmpdir)
3258
3394
  proj = tmpdir / 'proj'
3259
3395
  proj.mkdir()
3260
- (proj / '.jot.json').write_text(
3261
- json.dumps({'tasks': [], 'archived': []}))
3262
- (proj / '.jot.ids.json').write_text(
3263
- json.dumps({'next_id': 1, 'allocated': []}))
3396
+ (proj / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3397
+ (proj / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3264
3398
 
3265
3399
  reg_file = tmpdir / '.jot-projects.json'
3266
3400
  reg_file.write_text('{}')
@@ -3271,9 +3405,7 @@ class TestImportIntegrity:
3271
3405
 
3272
3406
  # Declining should NOT add any tasks
3273
3407
  with patch('builtins.input', return_value='n'):
3274
- _handle_initial_task(
3275
- tm, 'test task', True,
3276
- registry, None, False, None)
3408
+ _handle_initial_task(tm, 'test task', True, registry, None, False, None)
3277
3409
 
3278
3410
  data = json.loads((proj / '.jot.json').read_text())
3279
3411
  assert len(data['tasks']) == 0
@@ -3282,10 +3414,8 @@ class TestImportIntegrity:
3282
3414
  """Normal add_task returns an integer task ID"""
3283
3415
  with tempfile.TemporaryDirectory() as tmpdir:
3284
3416
  tmpdir = Path(tmpdir)
3285
- (tmpdir / '.jot.json').write_text(
3286
- json.dumps({'tasks': [], 'archived': []}))
3287
- (tmpdir / '.jot.ids.json').write_text(
3288
- json.dumps({'next_id': 1, 'allocated': []}))
3417
+ (tmpdir / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3418
+ (tmpdir / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3289
3419
  tm = TaskManager(directory=tmpdir)
3290
3420
  result = tm.add_task('normal task')
3291
3421
  assert isinstance(result, int)
@@ -3294,14 +3424,11 @@ class TestImportIntegrity:
3294
3424
  """add_task with 'all:' prefix returns broadcast metadata"""
3295
3425
  with tempfile.TemporaryDirectory() as tmpdir:
3296
3426
  tmpdir = Path(tmpdir)
3297
- (tmpdir / '.jot.json').write_text(
3298
- json.dumps({'tasks': [], 'archived': []}))
3299
- (tmpdir / '.jot.ids.json').write_text(
3300
- json.dumps({'next_id': 1, 'allocated': []}))
3427
+ (tmpdir / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3428
+ (tmpdir / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3301
3429
  reg_file = tmpdir / '.jot-projects.json'
3302
3430
  reg_file.write_text('{}')
3303
- registry = ProjectRegistry(
3304
- registry_file=str(reg_file))
3431
+ registry = ProjectRegistry(registry_file=str(reg_file))
3305
3432
  registry.register_project('proj-a', str(tmpdir))
3306
3433
  tm = TaskManager(directory=tmpdir, project_registry=registry)
3307
3434
  result = tm.add_task('all: broadcast task')
@@ -3318,18 +3445,13 @@ class TestImportIntegrity:
3318
3445
  tmpdir = Path(tmpdir)
3319
3446
  proj_a = tmpdir / 'proj-a'
3320
3447
  proj_a.mkdir()
3321
- (proj_a / '.jot.json').write_text(
3322
- json.dumps({'tasks': [], 'archived': []}))
3323
- (proj_a / '.jot.ids.json').write_text(
3324
- json.dumps({'next_id': 1, 'allocated': []}))
3325
- (tmpdir / '.jot.json').write_text(
3326
- json.dumps({'tasks': [], 'archived': []}))
3327
- (tmpdir / '.jot.ids.json').write_text(
3328
- json.dumps({'next_id': 1, 'allocated': []}))
3448
+ (proj_a / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3449
+ (proj_a / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3450
+ (tmpdir / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3451
+ (tmpdir / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3329
3452
  reg_file = tmpdir / '.jot-projects.json'
3330
3453
  reg_file.write_text('{}')
3331
- registry = ProjectRegistry(
3332
- registry_file=str(reg_file))
3454
+ registry = ProjectRegistry(registry_file=str(reg_file))
3333
3455
  registry.register_project('proj-a', str(proj_a))
3334
3456
  tm = TaskManager(directory=tmpdir, project_registry=registry)
3335
3457
  result = tm.add_task('proj-a: routed task')
@@ -3347,14 +3469,10 @@ class TestImportIntegrity:
3347
3469
  proj_a.mkdir()
3348
3470
  proj_b.mkdir()
3349
3471
  for proj in [proj_a, proj_b]:
3350
- (proj / '.jot.json').write_text(
3351
- json.dumps({'tasks': [], 'archived': []}))
3352
- (proj / '.jot.ids.json').write_text(
3353
- json.dumps({'next_id': 1, 'allocated': []}))
3354
- (tmpdir / '.jot.json').write_text(
3355
- json.dumps({'tasks': [], 'archived': []}))
3356
- (tmpdir / '.jot.ids.json').write_text(
3357
- json.dumps({'next_id': 1, 'allocated': []}))
3472
+ (proj / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3473
+ (proj / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3474
+ (tmpdir / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3475
+ (tmpdir / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3358
3476
  # Pre-create registry file to prevent auto-discover
3359
3477
  reg_file = tmpdir / '.jot-projects.json'
3360
3478
  reg_file.write_text('{}')
@@ -3362,21 +3480,17 @@ class TestImportIntegrity:
3362
3480
  registry.register_project('proj-a', str(proj_a))
3363
3481
  registry.register_project('proj-b', str(proj_b))
3364
3482
  tm = TaskManager(directory=tmpdir, project_registry=registry)
3365
- count = tm._route_to_project(
3366
- 'test task', '__all__', 'none', 'todo', None, None)
3483
+ count = tm._route_to_project('test task', '__all__', 'none', 'todo', None, None)
3367
3484
  assert count == 2
3368
3485
 
3369
-
3370
3486
  def test_broadcast_deduplicates_symlinked_projects(self):
3371
3487
  """Symlinked projects that resolve to the same path get one write, not two"""
3372
3488
  with tempfile.TemporaryDirectory() as tmpdir:
3373
3489
  tmpdir = Path(tmpdir)
3374
3490
  real_proj = tmpdir / 'real-proj'
3375
3491
  real_proj.mkdir()
3376
- (real_proj / '.jot.json').write_text(
3377
- json.dumps({'tasks': [], 'archived': []}))
3378
- (real_proj / '.jot.ids.json').write_text(
3379
- json.dumps({'next_id': 1, 'allocated': []}))
3492
+ (real_proj / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3493
+ (real_proj / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3380
3494
 
3381
3495
  # Create a symlink to the same directory
3382
3496
  symlink_proj = tmpdir / 'symlink-proj'
@@ -3389,14 +3503,11 @@ class TestImportIntegrity:
3389
3503
  registry.projects['symlink-proj'] = str(symlink_proj)
3390
3504
  registry._save_registry()
3391
3505
 
3392
- (tmpdir / '.jot.json').write_text(
3393
- json.dumps({'tasks': [], 'archived': []}))
3394
- (tmpdir / '.jot.ids.json').write_text(
3395
- json.dumps({'next_id': 1, 'allocated': []}))
3506
+ (tmpdir / '.jot.json').write_text(json.dumps({'tasks': [], 'archived': []}))
3507
+ (tmpdir / '.jot.ids.json').write_text(json.dumps({'next_id': 1, 'allocated': []}))
3396
3508
  tm = TaskManager(directory=tmpdir, project_registry=registry)
3397
3509
 
3398
- count = tm._route_to_project(
3399
- 'dedup test', '__all__', 'none', 'todo', None, None)
3510
+ count = tm._route_to_project('dedup test', '__all__', 'none', 'todo', None, None)
3400
3511
  assert count == 1 # Only one write despite two registry entries
3401
3512
 
3402
3513
  data = json.loads((real_proj / '.jot.json').read_text())
@@ -3425,6 +3536,7 @@ class TestImportIntegrity:
3425
3536
 
3426
3537
  # Manually call _auto_discover with patched home
3427
3538
  from unittest.mock import patch
3539
+
3428
3540
  with patch.object(Path, 'home', return_value=tmpdir):
3429
3541
  registry.projects = {}
3430
3542
  registry._auto_discover()
@@ -3438,6 +3550,7 @@ class TestExtractTimeTag:
3438
3550
 
3439
3551
  def test_time_tag_at_end(self):
3440
3552
  from jot.utils.date_utils import extract_time_tag
3553
+
3441
3554
  hour, minute, cleaned = extract_time_tag("standup 0930")
3442
3555
  assert hour == 9
3443
3556
  assert minute == 30
@@ -3445,6 +3558,7 @@ class TestExtractTimeTag:
3445
3558
 
3446
3559
  def test_time_tag_at_start(self):
3447
3560
  from jot.utils.date_utils import extract_time_tag
3561
+
3448
3562
  hour, minute, cleaned = extract_time_tag("0800 morning routine")
3449
3563
  assert hour == 8
3450
3564
  assert minute == 0
@@ -3452,6 +3566,7 @@ class TestExtractTimeTag:
3452
3566
 
3453
3567
  def test_time_tag_in_middle(self):
3454
3568
  from jot.utils.date_utils import extract_time_tag
3569
+
3455
3570
  hour, minute, cleaned = extract_time_tag("do 1430 thing")
3456
3571
  assert hour == 14
3457
3572
  assert minute == 30
@@ -3459,6 +3574,7 @@ class TestExtractTimeTag:
3459
3574
 
3460
3575
  def test_no_time_tag(self):
3461
3576
  from jot.utils.date_utils import extract_time_tag
3577
+
3462
3578
  hour, minute, cleaned = extract_time_tag("no time here")
3463
3579
  assert hour is None
3464
3580
  assert minute is None
@@ -3466,16 +3582,19 @@ class TestExtractTimeTag:
3466
3582
 
3467
3583
  def test_invalid_hour(self):
3468
3584
  from jot.utils.date_utils import extract_time_tag
3585
+
3469
3586
  hour, minute, cleaned = extract_time_tag("meeting 2500")
3470
3587
  assert hour is None # 25 > 23
3471
3588
 
3472
3589
  def test_invalid_minute(self):
3473
3590
  from jot.utils.date_utils import extract_time_tag
3591
+
3474
3592
  hour, minute, cleaned = extract_time_tag("meeting 0961")
3475
3593
  assert hour is None # 61 > 59
3476
3594
 
3477
3595
  def test_not_standalone(self):
3478
3596
  from jot.utils.date_utils import extract_time_tag
3597
+
3479
3598
  hour, minute, cleaned = extract_time_tag("task1430foo")
3480
3599
  assert hour is None # Not space-delimited
3481
3600
 
@@ -3486,17 +3605,24 @@ class TestGcalImportTaskText:
3486
3605
  @staticmethod
3487
3606
  def _make_event(summary, all_day=False, start_hour=None, end_hour=None):
3488
3607
  from datetime import datetime, timezone
3608
+
3489
3609
  date = datetime(2026, 2, 27).date()
3490
3610
  if all_day:
3491
- return {'summary': summary, 'start': None, 'end': None,
3492
- 'all_day': True, 'date': date, 'description': ''}
3611
+ return {
3612
+ 'summary': summary,
3613
+ 'start': None,
3614
+ 'end': None,
3615
+ 'all_day': True,
3616
+ 'date': date,
3617
+ 'description': '',
3618
+ }
3493
3619
  return {
3494
3620
  'summary': summary,
3495
- 'start': datetime(2026, 2, 27, start_hour or 9, 0,
3496
- tzinfo=timezone.utc),
3497
- 'end': datetime(2026, 2, 27, end_hour or 10, 0,
3498
- tzinfo=timezone.utc),
3499
- 'all_day': False, 'date': date, 'description': '',
3621
+ 'start': datetime(2026, 2, 27, start_hour or 9, 0, tzinfo=timezone.utc),
3622
+ 'end': datetime(2026, 2, 27, end_hour or 10, 0, tzinfo=timezone.utc),
3623
+ 'all_day': False,
3624
+ 'date': date,
3625
+ 'description': '',
3500
3626
  }
3501
3627
 
3502
3628
  def test_timed_event_uses_calendar_icon_not_time(self, tmp_path):
@@ -3510,10 +3636,8 @@ class TestGcalImportTaskText:
3510
3636
  handler = type('Handler', (GcalMixin,), {})()
3511
3637
  handler.task_manager = tm
3512
3638
 
3513
- with patch('jot.commands._gcal_mixin.multiselect_picker',
3514
- return_value=[ev]):
3515
- handler._display_and_import_events(
3516
- [ev], 'today', 'default', 'today')
3639
+ with patch('jot.commands._gcal_mixin.multiselect_picker', return_value=[ev]):
3640
+ handler._display_and_import_events([ev], 'today', 'default', 'today')
3517
3641
 
3518
3642
  assert len(tm.tasks) == 1
3519
3643
  assert tm.tasks[0]['text'] == '📆 Morning Standup'
@@ -3530,10 +3654,8 @@ class TestGcalImportTaskText:
3530
3654
  handler = type('Handler', (GcalMixin,), {})()
3531
3655
  handler.task_manager = tm
3532
3656
 
3533
- with patch('jot.commands._gcal_mixin.multiselect_picker',
3534
- return_value=[ev]):
3535
- handler._display_and_import_events(
3536
- [ev], 'today', 'default', 'today')
3657
+ with patch('jot.commands._gcal_mixin.multiselect_picker', return_value=[ev]):
3658
+ handler._display_and_import_events([ev], 'today', 'default', 'today')
3537
3659
 
3538
3660
  assert len(tm.tasks) == 1
3539
3661
  assert tm.tasks[0]['text'] == '📅 Team Offsite'
@@ -3549,10 +3671,8 @@ class TestGcalImportTaskText:
3549
3671
  handler = type('Handler', (GcalMixin,), {})()
3550
3672
  handler.task_manager = tm
3551
3673
 
3552
- with patch('jot.commands._gcal_mixin.multiselect_picker',
3553
- return_value=[]):
3554
- handler._display_and_import_events(
3555
- [ev], 'today', 'default', 'today')
3674
+ with patch('jot.commands._gcal_mixin.multiselect_picker', return_value=[]):
3675
+ handler._display_and_import_events([ev], 'today', 'default', 'today')
3556
3676
 
3557
3677
  assert len(tm.tasks) == 0
3558
3678
 
@@ -3569,10 +3689,8 @@ class TestGcalImportTaskText:
3569
3689
  handler = type('Handler', (GcalMixin,), {})()
3570
3690
  handler.task_manager = tm
3571
3691
 
3572
- with patch('jot.commands._gcal_mixin.multiselect_picker',
3573
- return_value=[ev1, ev3]):
3574
- handler._display_and_import_events(
3575
- [ev1, ev2, ev3], 'today', 'default', 'today')
3692
+ with patch('jot.commands._gcal_mixin.multiselect_picker', return_value=[ev1, ev3]):
3693
+ handler._display_and_import_events([ev1, ev2, ev3], 'today', 'default', 'today')
3576
3694
 
3577
3695
  assert len(tm.tasks) == 2
3578
3696
  texts = [t['text'] for t in tm.tasks]
@@ -3596,10 +3714,8 @@ class TestGcalImportTaskText:
3596
3714
 
3597
3715
  today = datetime.now().date()
3598
3716
  assert GcalMixin._event_day_annotation(today, today) == "Today"
3599
- assert GcalMixin._event_day_annotation(
3600
- today + timedelta(days=1), today) == "Tomorrow"
3601
- assert GcalMixin._event_day_annotation(
3602
- today - timedelta(days=1), today) == "Yesterday"
3717
+ assert GcalMixin._event_day_annotation(today + timedelta(days=1), today) == "Tomorrow"
3718
+ assert GcalMixin._event_day_annotation(today - timedelta(days=1), today) == "Yesterday"
3603
3719
 
3604
3720
 
3605
3721
  class TestMultiselectPicker:
@@ -3610,8 +3726,7 @@ class TestMultiselectPicker:
3610
3726
  from unittest.mock import patch
3611
3727
  from jot.ui.picker import PickerItem, multiselect_picker
3612
3728
 
3613
- items = [PickerItem(value='a', label='Alpha'),
3614
- PickerItem(value='b', label='Beta')]
3729
+ items = [PickerItem(value='a', label='Alpha'), PickerItem(value='b', label='Beta')]
3615
3730
 
3616
3731
  with patch('jot.ui.picker.get_key', return_value='\r'):
3617
3732
  result = multiselect_picker(items)
@@ -3635,8 +3750,7 @@ class TestMultiselectPicker:
3635
3750
  from unittest.mock import patch
3636
3751
  from jot.ui.picker import PickerItem, multiselect_picker
3637
3752
 
3638
- items = [PickerItem(value='a', label='Alpha'),
3639
- PickerItem(value='b', label='Beta')]
3753
+ items = [PickerItem(value='a', label='Alpha'), PickerItem(value='b', label='Beta')]
3640
3754
 
3641
3755
  keys = iter([' ', '\r']) # Space deselects item 0, Enter confirms
3642
3756
  with patch('jot.ui.picker.get_key', side_effect=keys):
@@ -3647,6 +3761,7 @@ class TestMultiselectPicker:
3647
3761
  def test_empty_items_returns_empty(self):
3648
3762
  """Empty item list returns empty immediately."""
3649
3763
  from jot.ui.picker import multiselect_picker
3764
+
3650
3765
  assert multiselect_picker([]) == []
3651
3766
 
3652
3767
 
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