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.
- {jott_cli-0.5.3/jott_cli.egg-info → jott_cli-0.5.4}/PKG-INFO +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/__init__.py +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/_app_navigation_mixin.py +1 -2
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/_dispatch_mixin.py +19 -3
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/app.py +1 -4
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/handler.py +2 -2
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/projects/registry.py +8 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_footer.py +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_help.py +12 -12
- {jott_cli-0.5.3 → jott_cli-0.5.4/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.4}/pyproject.toml +1 -1
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_dispatch.py +1 -2
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_jot.py +223 -108
- {jott_cli-0.5.3 → jott_cli-0.5.4}/LICENSE +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/README.md +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/config.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/config.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/cli/views.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_metadata_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_transfer_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_crud_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_delete_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_metadata_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/_subtask_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/constants.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/display_tasks.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/input.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/picker.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/rendering.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/ui/styles.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/SOURCES.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/setup.cfg +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/setup.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_highlight.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_input.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_picker.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_styles.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_subtask_notes.py +0 -0
- {jott_cli-0.5.3 → jott_cli-0.5.4}/tests/test_terminal_wrap.py +0 -0
- {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
|
+
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.
|
|
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
|
|
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 == '
|
|
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.
|
|
300
|
-
if second == '
|
|
299
|
+
second = get_key(timeout=0.5)
|
|
300
|
+
if second == 's':
|
|
301
301
|
self._toggle_collapse()
|
|
302
302
|
st.input_buffer = ""
|
|
303
303
|
return
|
|
304
|
+
if second == 'c':
|
|
305
|
+
self.command_handler.copy_to_clipboard()
|
|
306
|
+
st.input_buffer = ""
|
|
307
|
+
return
|
|
308
|
+
if second == '?':
|
|
309
|
+
st.show_shortcuts = not st.show_shortcuts
|
|
310
|
+
st.input_buffer = ""
|
|
311
|
+
return
|
|
312
|
+
if second == 'i':
|
|
313
|
+
self.command_handler.import_from_gcal()
|
|
314
|
+
st.input_buffer = ""
|
|
315
|
+
return
|
|
316
|
+
if second == 'g':
|
|
317
|
+
self.command_handler.web_search()
|
|
318
|
+
st.input_buffer = ""
|
|
319
|
+
return
|
|
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
|
-
'
|
|
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
|
-
'
|
|
38
|
+
'\x04': self.delete_current, # Ctrl+D
|
|
39
39
|
'e': self.edit_task,
|
|
40
40
|
'E': self.edit_current,
|
|
41
41
|
'M': self.move_task_to_project,
|
|
@@ -46,7 +46,7 @@ class CommandHandler(
|
|
|
46
46
|
|
|
47
47
|
def handle(self, command):
|
|
48
48
|
"""Handle a command"""
|
|
49
|
-
# Check for exact match first (for case-sensitive commands like '
|
|
49
|
+
# Check for exact match first (for case-sensitive commands like 'E')
|
|
50
50
|
handler = self.commands.get(command)
|
|
51
51
|
if not handler:
|
|
52
52
|
# Fall back to lowercase
|
|
@@ -117,6 +117,14 @@ class ProjectRegistry:
|
|
|
117
117
|
entry["usage_count"] = entry.get("usage_count", 0) + 1
|
|
118
118
|
self._save_registry()
|
|
119
119
|
|
|
120
|
+
def is_path_registered(self, path):
|
|
121
|
+
"""Check if a directory path is already registered"""
|
|
122
|
+
resolved = str(Path(path).expanduser().resolve())
|
|
123
|
+
for entry in self.projects.values():
|
|
124
|
+
if _get_path(entry) == resolved:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
120
128
|
def refresh(self):
|
|
121
129
|
"""Re-run auto-discovery"""
|
|
122
130
|
self._auto_discover()
|
|
@@ -33,7 +33,7 @@ def _render_quick_add(input_buffer, show_shortcuts):
|
|
|
33
33
|
f"{GREEN}[QUICK ADD]{RESET} Type task, {BOLD}Enter{RESET} "
|
|
34
34
|
f"to save | {BOLD}/{RESET}: search | {BOLD}ESC{RESET}: "
|
|
35
35
|
f"commands | {BOLD}Shift+V{RESET}: multi-select | "
|
|
36
|
-
f"{BOLD}
|
|
36
|
+
f"{BOLD}.?{RESET}: shortcuts"
|
|
37
37
|
)
|
|
38
38
|
if show_shortcuts:
|
|
39
39
|
from jot.ui.display_help import display_categorized_shortcuts
|
|
@@ -11,7 +11,7 @@ def display_categorized_shortcuts():
|
|
|
11
11
|
("Tab", "Cycle through categories"),
|
|
12
12
|
("Ctrl+F", "Fuzzy search tasks"),
|
|
13
13
|
("Shift+Z", "Switch project"),
|
|
14
|
-
("
|
|
14
|
+
("Ctrl+C", "Switch category"),
|
|
15
15
|
("Shift+L", "Toggle all categories view"),
|
|
16
16
|
],
|
|
17
17
|
"Task Management": [
|
|
@@ -40,7 +40,7 @@ def display_categorized_shortcuts():
|
|
|
40
40
|
("Shift+W", "Assign day to task"),
|
|
41
41
|
("Shift+O", "Toggle day sorting"),
|
|
42
42
|
("Shift+Y", "Toggle today filter"),
|
|
43
|
-
("
|
|
43
|
+
("Ctrl+D", "Mark current as done"),
|
|
44
44
|
("Shift+J", "Mark as agent task"),
|
|
45
45
|
("*", "Highlight color picker"),
|
|
46
46
|
("~", "Quick highlight toggle"),
|
|
@@ -64,12 +64,12 @@ def display_categorized_shortcuts():
|
|
|
64
64
|
("Shift+4", "AI task suggestion"),
|
|
65
65
|
("Shift+E", "Execute keyword action"),
|
|
66
66
|
("Shift+G", "Google Calendar setup"),
|
|
67
|
-
("
|
|
68
|
-
("
|
|
67
|
+
(".i", "Import from Google Calendar"),
|
|
68
|
+
(".c", "Copy task to clipboard"),
|
|
69
69
|
("Ctrl+U", "Open URLs in task"),
|
|
70
70
|
],
|
|
71
71
|
"System": [
|
|
72
|
-
("h
|
|
72
|
+
("h/.?", "Show this help"),
|
|
73
73
|
("r", "Refresh display"),
|
|
74
74
|
("Ctrl+R", "Register current project"),
|
|
75
75
|
("q", "Quit"),
|
|
@@ -119,7 +119,7 @@ def display_help():
|
|
|
119
119
|
{CYAN}Tab{RESET} Cycle through categories
|
|
120
120
|
{CYAN}Ctrl+F{RESET} Fuzzy search tasks
|
|
121
121
|
{CYAN}Shift+Z{RESET} Switch between projects
|
|
122
|
-
{CYAN}
|
|
122
|
+
{CYAN}Ctrl+C{RESET} Switch between categories
|
|
123
123
|
{CYAN}Shift+L{RESET} Toggle all categories view
|
|
124
124
|
|
|
125
125
|
{BOLD}TASK MANAGEMENT:{RESET}
|
|
@@ -147,7 +147,7 @@ def display_help():
|
|
|
147
147
|
{CYAN}Shift+W{RESET} Assign day to task
|
|
148
148
|
{CYAN}Shift+O{RESET} Toggle day sorting
|
|
149
149
|
{CYAN}Shift+Y{RESET} Toggle today filter
|
|
150
|
-
{CYAN}
|
|
150
|
+
{CYAN}Ctrl+D{RESET} Mark current task as done
|
|
151
151
|
{CYAN}Shift+J{RESET} Mark task for agent/AI assistance
|
|
152
152
|
{CYAN}*{RESET} Highlight color picker (6 colors)
|
|
153
153
|
{CYAN}~{RESET} Quick highlight toggle (default color)
|
|
@@ -185,7 +185,7 @@ def display_help():
|
|
|
185
185
|
|
|
186
186
|
{CYAN}jott -c backend{RESET} Start in "backend" category
|
|
187
187
|
{CYAN}Tab{RESET} Cycle through categories
|
|
188
|
-
{CYAN}
|
|
188
|
+
{CYAN}Ctrl+C{RESET} Create/switch categories interactively
|
|
189
189
|
{CYAN}m{RESET} Move task between categories
|
|
190
190
|
|
|
191
191
|
{BOLD}PROJECTS:{RESET}
|
|
@@ -200,8 +200,8 @@ def display_help():
|
|
|
200
200
|
{CYAN}Shift+4{RESET} AI task suggestion
|
|
201
201
|
{CYAN}Shift+E{RESET} Execute keyword action for task
|
|
202
202
|
{CYAN}Shift+G{RESET} Set up Google Calendar integration
|
|
203
|
-
{CYAN}
|
|
204
|
-
{CYAN}
|
|
203
|
+
{CYAN}.i{RESET} Import tasks from Google Calendar
|
|
204
|
+
{CYAN}.c{RESET} Copy task text to clipboard
|
|
205
205
|
{CYAN}Ctrl+U{RESET} Open URLs found in task text
|
|
206
206
|
|
|
207
207
|
{BOLD}FUZZY SEARCH:{RESET}
|
|
@@ -212,7 +212,7 @@ def display_help():
|
|
|
212
212
|
{CYAN}Esc{RESET} Exit search
|
|
213
213
|
|
|
214
214
|
{BOLD}SYSTEM:{RESET}
|
|
215
|
-
{CYAN}h or
|
|
215
|
+
{CYAN}h or .?{RESET} Show keyboard shortcuts
|
|
216
216
|
{CYAN}r{RESET} Refresh display
|
|
217
217
|
{CYAN}Ctrl+R{RESET} Register current project in registry
|
|
218
218
|
{CYAN}q{RESET} Quit
|
|
@@ -227,7 +227,7 @@ def display_help():
|
|
|
227
227
|
|
|
228
228
|
{BOLD}CONFIGURATION:{RESET}
|
|
229
229
|
• Edit ~/.jot-keywords.json to customize keyword actions
|
|
230
|
-
• Category colors: Edit category config (
|
|
230
|
+
• Category colors: Edit category config (Ctrl+C)
|
|
231
231
|
• Global vs local: Use -g flag or create global categories
|
|
232
232
|
|
|
233
233
|
{DIM}For more info: https://github.com/your-repo/jot{RESET}"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jott-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.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.
|
|
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
|
-
'
|
|
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(
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
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(
|
|
3152
|
-
|
|
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(
|
|
3156
|
-
|
|
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(
|
|
3184
|
-
|
|
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(
|
|
3188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3323
|
-
(
|
|
3324
|
-
|
|
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
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
3492
|
-
|
|
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
|
-
|
|
3497
|
-
'
|
|
3498
|
-
|
|
3499
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|