jott-cli 0.5.0__tar.gz → 0.5.1__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.0/jott_cli.egg-info → jott_cli-0.5.1}/PKG-INFO +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/__init__.py +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/app.py +1 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_gcal_mixin.py +3 -1
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_metadata_mixin.py +11 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_crud_mixin.py +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_metadata_mixin.py +15 -0
- jott_cli-0.5.1/jot/core/_subtask_mixin.py +117 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/events.py +4 -2
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/_handlers_mixin.py +3 -1
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/__init__.py +4 -2
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_help.py +3 -1
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_tasks.py +11 -2
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/styles.py +7 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/SOURCES.txt +3 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/pyproject.toml +1 -1
- jott_cli-0.5.1/tests/test_gcal_notes.py +155 -0
- jott_cli-0.5.1/tests/test_highlight.py +156 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_styles.py +14 -1
- jott_cli-0.5.1/tests/test_subtask_notes.py +246 -0
- jott_cli-0.5.0/jot/core/_subtask_mixin.py +0 -68
- {jott_cli-0.5.0 → jott_cli-0.5.1}/LICENSE +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/README.md +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/_app_navigation_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/_dispatch_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/config.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/config.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/views.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_transfer_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/handler.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_delete_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/constants.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/projects/registry.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_footer.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/input.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/picker.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/setup.cfg +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/setup.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_dispatch.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_input.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_jot.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_picker.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.1}/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.1
|
|
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.1"
|
|
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.
|
|
@@ -79,6 +79,7 @@ class App(DispatchMixin, AppNavigationMixin):
|
|
|
79
79
|
'@': 'read_tasks_aloud',
|
|
80
80
|
'#': 'reauthenticate_google_calendar',
|
|
81
81
|
'\x12': 'register_current_project', # Ctrl+R
|
|
82
|
+
'*': 'toggle_highlight',
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
def __init__(self, task_manager, command_handler, registry,
|
|
@@ -109,7 +109,9 @@ class GcalMixin:
|
|
|
109
109
|
def _export_single_task(self, service, account_name, task):
|
|
110
110
|
"""Export a single task to Google Calendar."""
|
|
111
111
|
start, end, cleaned = self._build_gcal_start_time(task['text'])
|
|
112
|
-
|
|
112
|
+
notes = task.get('notes', '').strip()
|
|
113
|
+
link = create_gcal_event(service, cleaned, start_time=start,
|
|
114
|
+
description=notes or None)
|
|
113
115
|
print(f" {GREEN}✓{RESET} {BOLD}{cleaned}{RESET}")
|
|
114
116
|
print(f" {start.strftime('%-I:%M %p')} - {end.strftime('%-I:%M %p')}")
|
|
115
117
|
if link:
|
|
@@ -123,6 +123,17 @@ class MetadataMixin:
|
|
|
123
123
|
|
|
124
124
|
return True
|
|
125
125
|
|
|
126
|
+
def toggle_highlight(self):
|
|
127
|
+
"""Toggle highlight on the current task."""
|
|
128
|
+
current_task = self.task_manager.get_current_task()
|
|
129
|
+
if not current_task:
|
|
130
|
+
return True
|
|
131
|
+
was_highlighted = current_task.get('highlight', False)
|
|
132
|
+
self.task_manager.toggle_highlight()
|
|
133
|
+
label = "unhighlighted" if was_highlighted else "highlighted"
|
|
134
|
+
print(f"\n{GREEN}✓{RESET} Task {label}")
|
|
135
|
+
return True
|
|
136
|
+
|
|
126
137
|
def set_priority_high(self):
|
|
127
138
|
"""Quickly set current task priority to high (shortcut)"""
|
|
128
139
|
current_task = self.task_manager.get_current_task()
|
|
@@ -38,7 +38,7 @@ class CrudMixin:
|
|
|
38
38
|
task = {
|
|
39
39
|
'id': new_id, 'text': task_text, 'done': False,
|
|
40
40
|
'current': False, 'day': None, 'tally': 0,
|
|
41
|
-
'agent_task': False,
|
|
41
|
+
'agent_task': False, 'highlight': False,
|
|
42
42
|
'priority': priority, 'status': status,
|
|
43
43
|
'created_at': now, 'updated_at': now,
|
|
44
44
|
'labels': labels if labels is not None else [],
|
|
@@ -61,6 +61,21 @@ class MetadataMixin:
|
|
|
61
61
|
self._save_tasks()
|
|
62
62
|
return True
|
|
63
63
|
|
|
64
|
+
def toggle_highlight(self, task_id=None):
|
|
65
|
+
"""Toggle highlight on a task. Multiple tasks can be highlighted."""
|
|
66
|
+
if task_id is None:
|
|
67
|
+
current = self.get_current_task()
|
|
68
|
+
if not current:
|
|
69
|
+
return False
|
|
70
|
+
task_id = current['id']
|
|
71
|
+
|
|
72
|
+
for task in self.tasks:
|
|
73
|
+
if task['id'] == task_id:
|
|
74
|
+
task['highlight'] = not task.get('highlight', False)
|
|
75
|
+
self._save_tasks()
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
64
79
|
def set_task_day(self, task_id, day):
|
|
65
80
|
"""Assign a day of the week to a task (Monday-Sunday or None)."""
|
|
66
81
|
for task in self.tasks:
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Subtask sync mixin — parse checklist items from notes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import textwrap
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
_CHECKLIST_RE = re.compile(r'^-\s*\[([ xX])\]\s+(.+)$')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_checklist_with_notes(notes_text):
|
|
11
|
+
"""Parse checklist items and any nested text below them.
|
|
12
|
+
|
|
13
|
+
Returns list of (text, done, nested_notes) tuples.
|
|
14
|
+
Nested text is dedented and trailing whitespace stripped.
|
|
15
|
+
"""
|
|
16
|
+
items = []
|
|
17
|
+
current_nested = []
|
|
18
|
+
|
|
19
|
+
for line in notes_text.split('\n'):
|
|
20
|
+
match = _CHECKLIST_RE.match(line.strip())
|
|
21
|
+
if match:
|
|
22
|
+
# Flush previous item's nested text
|
|
23
|
+
if items:
|
|
24
|
+
items[-1] = _finalize_nested(items[-1], current_nested)
|
|
25
|
+
current_nested = []
|
|
26
|
+
done = match.group(1).lower() == 'x'
|
|
27
|
+
text = match.group(2).strip()
|
|
28
|
+
items.append((text, done, ''))
|
|
29
|
+
elif items:
|
|
30
|
+
# Non-checklist line after a checklist item
|
|
31
|
+
current_nested.append(line)
|
|
32
|
+
|
|
33
|
+
# Flush last item
|
|
34
|
+
if items:
|
|
35
|
+
items[-1] = _finalize_nested(items[-1], current_nested)
|
|
36
|
+
|
|
37
|
+
return items
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _finalize_nested(item, nested_lines):
|
|
41
|
+
"""Dedent and strip nested lines, attach to item tuple."""
|
|
42
|
+
# Drop leading/trailing blank lines
|
|
43
|
+
while nested_lines and not nested_lines[0].strip():
|
|
44
|
+
nested_lines.pop(0)
|
|
45
|
+
while nested_lines and not nested_lines[-1].strip():
|
|
46
|
+
nested_lines.pop()
|
|
47
|
+
|
|
48
|
+
if not nested_lines:
|
|
49
|
+
return item
|
|
50
|
+
|
|
51
|
+
notes = textwrap.dedent('\n'.join(nested_lines)).strip()
|
|
52
|
+
return (item[0], item[1], notes)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SubtaskMixin:
|
|
56
|
+
"""Sync subtasks from task notes checklists."""
|
|
57
|
+
|
|
58
|
+
def sync_subtasks_from_notes(self, task_id):
|
|
59
|
+
"""Parse checklist items from task notes and sync as subtasks.
|
|
60
|
+
|
|
61
|
+
Parses '- [ ] text' (todo) and '- [X] text' (done) lines.
|
|
62
|
+
Nested text below a checklist item becomes the subtask's notes.
|
|
63
|
+
Creates new tasks with 'parent:{id}' label, skips duplicates.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
dict with 'created', 'updated', 'total' counts, or None if task not found
|
|
67
|
+
"""
|
|
68
|
+
task = next((t for t in self.tasks if t['id'] == task_id), None)
|
|
69
|
+
if not task:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
notes = task.get('notes', '')
|
|
73
|
+
if not notes:
|
|
74
|
+
return {'created': 0, 'updated': 0, 'total': 0}
|
|
75
|
+
|
|
76
|
+
checklist_items = _parse_checklist_with_notes(notes)
|
|
77
|
+
if not checklist_items:
|
|
78
|
+
return {'created': 0, 'updated': 0, 'total': 0}
|
|
79
|
+
|
|
80
|
+
parent_label = f"parent:{task_id}"
|
|
81
|
+
existing = {
|
|
82
|
+
t['text']: t for t in self.tasks
|
|
83
|
+
if parent_label in t.get('labels', [])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
87
|
+
created = updated = 0
|
|
88
|
+
for text, done, nested_notes in checklist_items:
|
|
89
|
+
if text in existing:
|
|
90
|
+
sub = existing[text]
|
|
91
|
+
new_status = 'done' if done else 'todo'
|
|
92
|
+
status_changed = sub['status'] != new_status
|
|
93
|
+
notes_changed = sub.get('notes', '') != nested_notes
|
|
94
|
+
if status_changed:
|
|
95
|
+
sub['status'] = new_status
|
|
96
|
+
sub['done'] = done
|
|
97
|
+
sub['updated_at'] = now
|
|
98
|
+
updated += 1
|
|
99
|
+
if notes_changed:
|
|
100
|
+
sub['notes'] = nested_notes
|
|
101
|
+
sub['updated_at'] = now
|
|
102
|
+
if not status_changed:
|
|
103
|
+
updated += 1
|
|
104
|
+
else:
|
|
105
|
+
new_id = self.add_task(
|
|
106
|
+
text,
|
|
107
|
+
status='done' if done else 'todo',
|
|
108
|
+
labels=[parent_label],
|
|
109
|
+
)
|
|
110
|
+
if nested_notes and isinstance(new_id, int):
|
|
111
|
+
self.set_task_notes(new_id, nested_notes)
|
|
112
|
+
created += 1
|
|
113
|
+
|
|
114
|
+
if updated:
|
|
115
|
+
self._save_tasks()
|
|
116
|
+
|
|
117
|
+
return {'created': created, 'updated': updated, 'total': len(checklist_items)}
|
|
@@ -5,7 +5,8 @@ from datetime import datetime, timedelta
|
|
|
5
5
|
from jot.utils.date_utils import round_to_15_minutes
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def create_gcal_event(service, task_text, start_time=None, duration_minutes=15
|
|
8
|
+
def create_gcal_event(service, task_text, start_time=None, duration_minutes=15,
|
|
9
|
+
description=None):
|
|
9
10
|
"""Create a Google Calendar event from task text
|
|
10
11
|
|
|
11
12
|
Args:
|
|
@@ -13,6 +14,7 @@ def create_gcal_event(service, task_text, start_time=None, duration_minutes=15):
|
|
|
13
14
|
task_text: Task text to use as event summary
|
|
14
15
|
start_time: Optional timezone-aware datetime for event start (default: now rounded to 15 min)
|
|
15
16
|
duration_minutes: Event duration in minutes (default: 15)
|
|
17
|
+
description: Optional event description (default: 'Created from jot task')
|
|
16
18
|
|
|
17
19
|
Returns:
|
|
18
20
|
Event link (htmlLink) or None if failed
|
|
@@ -34,7 +36,7 @@ def create_gcal_event(service, task_text, start_time=None, duration_minutes=15):
|
|
|
34
36
|
'end': {
|
|
35
37
|
'dateTime': end_time.isoformat(),
|
|
36
38
|
},
|
|
37
|
-
'description': 'Created from jot task',
|
|
39
|
+
'description': description if description else 'Created from jot task',
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
# Insert event
|
|
@@ -62,7 +62,9 @@ class HandlersMixin:
|
|
|
62
62
|
return "No Google Calendar accounts configured (use Shift+G to set up)"
|
|
63
63
|
auth = GoogleCalendarAuth(accounts[0])
|
|
64
64
|
service = auth.get_service()
|
|
65
|
-
|
|
65
|
+
notes = task.get('notes', '').strip()
|
|
66
|
+
create_gcal_event(service, task['text'],
|
|
67
|
+
description=notes or None)
|
|
66
68
|
return f"Created Google Calendar event ({accounts[0]})"
|
|
67
69
|
except FileNotFoundError:
|
|
68
70
|
return "Google Calendar credentials not found (use Shift+G to set up)"
|
|
@@ -13,9 +13,10 @@ from jot.ui.formatting import format_category_badges, format_inline_notes
|
|
|
13
13
|
from jot.ui.picker import PickerItem, PickerResult, fuzzy_picker, quick_picker
|
|
14
14
|
from jot.ui.styles import (
|
|
15
15
|
RESET, BOLD, DIM, REVERSE,
|
|
16
|
-
RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, GRAY, ORANGE,
|
|
16
|
+
RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, GRAY, ORANGE, BLACK,
|
|
17
17
|
PRIORITY_COLORS, PRIORITY_SYMBOLS, STATUS_COLORS, STATUS_SYMBOLS,
|
|
18
18
|
ARCHIVE_SYMBOL, ARCHIVE_COLOR,
|
|
19
|
+
BG_YELLOW, HIGHLIGHT_FMT,
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
@@ -30,9 +31,10 @@ __all__ = [
|
|
|
30
31
|
'format_inline_notes',
|
|
31
32
|
# Style constants
|
|
32
33
|
'RESET', 'BOLD', 'DIM', 'REVERSE',
|
|
33
|
-
'RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE', 'GRAY', 'ORANGE',
|
|
34
|
+
'RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE', 'GRAY', 'ORANGE', 'BLACK',
|
|
34
35
|
'PRIORITY_COLORS', 'PRIORITY_SYMBOLS', 'STATUS_COLORS', 'STATUS_SYMBOLS',
|
|
35
36
|
'ARCHIVE_SYMBOL', 'ARCHIVE_COLOR',
|
|
37
|
+
'BG_YELLOW', 'HIGHLIGHT_FMT',
|
|
36
38
|
# Picker
|
|
37
39
|
'PickerItem', 'PickerResult', 'fuzzy_picker', 'quick_picker',
|
|
38
40
|
]
|
|
@@ -42,6 +42,7 @@ def display_categorized_shortcuts():
|
|
|
42
42
|
("Shift+Y", "Toggle today filter"),
|
|
43
43
|
("Shift+D", "Mark current as done"),
|
|
44
44
|
("Shift+J", "Mark as agent task"),
|
|
45
|
+
("*", "Toggle highlight (yellow)"),
|
|
45
46
|
],
|
|
46
47
|
"Multi-Select Mode": [
|
|
47
48
|
("Shift+V", "Enter multi-select mode"),
|
|
@@ -93,7 +94,7 @@ def display_help():
|
|
|
93
94
|
"""Display comprehensive help information."""
|
|
94
95
|
help_text = f"""
|
|
95
96
|
{BOLD}Jott - Simple Interactive Task List{RESET}
|
|
96
|
-
{DIM}Version 0.
|
|
97
|
+
{DIM}Version 0.5.0{RESET}
|
|
97
98
|
|
|
98
99
|
{BOLD}BASIC USAGE:{RESET}
|
|
99
100
|
jott Start interactive task manager (current project)
|
|
@@ -145,6 +146,7 @@ def display_help():
|
|
|
145
146
|
{CYAN}Shift+Y{RESET} Toggle today filter
|
|
146
147
|
{CYAN}Shift+D{RESET} Mark current task as done
|
|
147
148
|
{CYAN}Shift+J{RESET} Mark task for agent/AI assistance
|
|
149
|
+
{CYAN}*{RESET} Toggle highlight (yellow background)
|
|
148
150
|
{CYAN}Shift+E{RESET} Execute keyword action for task
|
|
149
151
|
|
|
150
152
|
{BOLD}MULTI-SELECT MODE:{RESET}
|
|
@@ -13,7 +13,7 @@ from jot.ui.formatting import format_inline_notes, format_category_badges
|
|
|
13
13
|
from jot.ui.styles import (
|
|
14
14
|
RESET, BOLD, DIM, REVERSE, CYAN, GREEN, YELLOW, MAGENTA,
|
|
15
15
|
PRIORITY_COLORS, PRIORITY_SYMBOLS, STATUS_COLORS, STATUS_SYMBOLS,
|
|
16
|
-
ARCHIVE_SYMBOL, ARCHIVE_COLOR,
|
|
16
|
+
ARCHIVE_SYMBOL, ARCHIVE_COLOR, HIGHLIGHT_FMT,
|
|
17
17
|
)
|
|
18
18
|
from jot.ui.display_footer import render_mode_footer, render_archived_section
|
|
19
19
|
|
|
@@ -258,13 +258,22 @@ def _render_task_line(
|
|
|
258
258
|
base = (color + BOLD) if (is_current or is_agent) else ""
|
|
259
259
|
task_text = _highlight_text(task_text, match_positions, base)
|
|
260
260
|
|
|
261
|
+
is_highlighted = task.get('highlight', False)
|
|
262
|
+
hl = HIGHLIGHT_FMT if is_highlighted else ""
|
|
263
|
+
|
|
261
264
|
if is_current or is_agent:
|
|
262
265
|
arrow = "\u2192" if is_current else " "
|
|
263
266
|
print(
|
|
264
|
-
f"{color}{BOLD}{arrow} [{status}] {task['id']}. "
|
|
267
|
+
f"{hl}{color}{BOLD}{arrow} [{status}] {task['id']}. "
|
|
265
268
|
f"{prefixes}{icon} {task_text}{RESET}"
|
|
266
269
|
f"{tally_suffix}{stale_warning}"
|
|
267
270
|
)
|
|
271
|
+
elif is_highlighted:
|
|
272
|
+
print(
|
|
273
|
+
f" {HIGHLIGHT_FMT}[{status}] {task['id']}. "
|
|
274
|
+
f"{prefixes}{task_text}{RESET}"
|
|
275
|
+
f"{tally_suffix}{stale_warning}"
|
|
276
|
+
)
|
|
268
277
|
else:
|
|
269
278
|
print(
|
|
270
279
|
f" [{status}] {task['id']}. "
|
|
@@ -20,6 +20,13 @@ CYAN = '\033[96m'
|
|
|
20
20
|
WHITE = '\033[97m'
|
|
21
21
|
GRAY = '\033[90m'
|
|
22
22
|
ORANGE = '\033[38;5;208m'
|
|
23
|
+
BLACK = '\033[30m'
|
|
24
|
+
|
|
25
|
+
# Background colors
|
|
26
|
+
BG_YELLOW = '\033[103m'
|
|
27
|
+
|
|
28
|
+
# Semantic mappings — highlight
|
|
29
|
+
HIGHLIGHT_FMT = BLACK + BG_YELLOW
|
|
23
30
|
|
|
24
31
|
# Semantic mappings — priority
|
|
25
32
|
PRIORITY_COLORS = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jott-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
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>
|
|
@@ -83,8 +83,11 @@ tests/test_command_handler.py
|
|
|
83
83
|
tests/test_dispatch.py
|
|
84
84
|
tests/test_edit_edge_cases.py
|
|
85
85
|
tests/test_fuzzy_search.py
|
|
86
|
+
tests/test_gcal_notes.py
|
|
87
|
+
tests/test_highlight.py
|
|
86
88
|
tests/test_input.py
|
|
87
89
|
tests/test_jot.py
|
|
88
90
|
tests/test_picker.py
|
|
89
91
|
tests/test_styles.py
|
|
92
|
+
tests/test_subtask_notes.py
|
|
90
93
|
tests/test_today_filter.py
|
|
@@ -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.1"
|
|
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"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Tests for including task notes in Google Calendar event descriptions."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import Mock
|
|
4
|
+
|
|
5
|
+
from jot.integrations.gcal.events import create_gcal_event
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestCreateGcalEventDescription:
|
|
9
|
+
"""Test that create_gcal_event uses the description parameter."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def _mock_service():
|
|
13
|
+
"""Return a mock Calendar service that captures the event body."""
|
|
14
|
+
service = Mock()
|
|
15
|
+
mock_insert = Mock()
|
|
16
|
+
mock_insert.execute.return_value = {'htmlLink': 'https://cal.google.com/event/123'}
|
|
17
|
+
service.events.return_value.insert.return_value = mock_insert
|
|
18
|
+
return service
|
|
19
|
+
|
|
20
|
+
def test_provided_description_used_in_event(self):
|
|
21
|
+
"""When a description is given, it appears in the event body."""
|
|
22
|
+
service = self._mock_service()
|
|
23
|
+
create_gcal_event(service, 'My task', description='These are my notes')
|
|
24
|
+
|
|
25
|
+
body = service.events().insert.call_args[1]['body']
|
|
26
|
+
assert body['description'] == 'These are my notes'
|
|
27
|
+
|
|
28
|
+
def test_default_description_when_none(self):
|
|
29
|
+
"""When no description is given, falls back to default text."""
|
|
30
|
+
service = self._mock_service()
|
|
31
|
+
create_gcal_event(service, 'My task')
|
|
32
|
+
|
|
33
|
+
body = service.events().insert.call_args[1]['body']
|
|
34
|
+
assert body['description'] == 'Created from jot task'
|
|
35
|
+
|
|
36
|
+
def test_default_description_when_explicit_none(self):
|
|
37
|
+
"""When description=None is passed explicitly, falls back to default."""
|
|
38
|
+
service = self._mock_service()
|
|
39
|
+
create_gcal_event(service, 'My task', description=None)
|
|
40
|
+
|
|
41
|
+
body = service.events().insert.call_args[1]['body']
|
|
42
|
+
assert body['description'] == 'Created from jot task'
|
|
43
|
+
|
|
44
|
+
def test_empty_string_description_falls_back_to_default(self):
|
|
45
|
+
"""Empty string description should fall back to default."""
|
|
46
|
+
service = self._mock_service()
|
|
47
|
+
create_gcal_event(service, 'My task', description='')
|
|
48
|
+
|
|
49
|
+
body = service.events().insert.call_args[1]['body']
|
|
50
|
+
assert body['description'] == 'Created from jot task'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestExportSingleTaskPassesNotes:
|
|
54
|
+
"""Test that _export_single_task passes task notes to create_gcal_event."""
|
|
55
|
+
|
|
56
|
+
def test_task_with_notes_passes_description(self):
|
|
57
|
+
"""Task notes are passed as the event description."""
|
|
58
|
+
from unittest.mock import patch
|
|
59
|
+
from jot.commands._gcal_mixin import GcalMixin
|
|
60
|
+
|
|
61
|
+
handler = type('Handler', (GcalMixin,), {})()
|
|
62
|
+
task = {'text': 'Team meeting', 'notes': 'Discuss Q2 roadmap'}
|
|
63
|
+
|
|
64
|
+
mock_service = Mock()
|
|
65
|
+
mock_insert = Mock()
|
|
66
|
+
mock_insert.execute.return_value = {'htmlLink': 'https://cal.google.com/event/1'}
|
|
67
|
+
mock_service.events.return_value.insert.return_value = mock_insert
|
|
68
|
+
|
|
69
|
+
with patch('jot.commands._gcal_mixin.create_gcal_event',
|
|
70
|
+
wraps=create_gcal_event) as mock_create:
|
|
71
|
+
mock_create.return_value = 'https://cal.google.com/event/1'
|
|
72
|
+
handler._export_single_task(mock_service, 'default', task)
|
|
73
|
+
mock_create.assert_called_once()
|
|
74
|
+
_, kwargs = mock_create.call_args
|
|
75
|
+
assert kwargs['description'] == 'Discuss Q2 roadmap'
|
|
76
|
+
|
|
77
|
+
def test_task_without_notes_passes_none(self):
|
|
78
|
+
"""Task with no notes passes description=None (triggers default)."""
|
|
79
|
+
from unittest.mock import patch
|
|
80
|
+
from jot.commands._gcal_mixin import GcalMixin
|
|
81
|
+
|
|
82
|
+
handler = type('Handler', (GcalMixin,), {})()
|
|
83
|
+
task = {'text': 'Quick sync'}
|
|
84
|
+
|
|
85
|
+
with patch('jot.commands._gcal_mixin.create_gcal_event') as mock_create:
|
|
86
|
+
mock_create.return_value = 'https://cal.google.com/event/1'
|
|
87
|
+
handler._export_single_task(Mock(), 'default', task)
|
|
88
|
+
_, kwargs = mock_create.call_args
|
|
89
|
+
assert kwargs['description'] is None
|
|
90
|
+
|
|
91
|
+
def test_task_with_whitespace_only_notes_passes_none(self):
|
|
92
|
+
"""Task with whitespace-only notes passes description=None."""
|
|
93
|
+
from unittest.mock import patch
|
|
94
|
+
from jot.commands._gcal_mixin import GcalMixin
|
|
95
|
+
|
|
96
|
+
handler = type('Handler', (GcalMixin,), {})()
|
|
97
|
+
task = {'text': 'Standup', 'notes': ' \n '}
|
|
98
|
+
|
|
99
|
+
with patch('jot.commands._gcal_mixin.create_gcal_event') as mock_create:
|
|
100
|
+
mock_create.return_value = 'https://cal.google.com/event/1'
|
|
101
|
+
handler._export_single_task(Mock(), 'default', task)
|
|
102
|
+
_, kwargs = mock_create.call_args
|
|
103
|
+
assert kwargs['description'] is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestHandleGcalPassesNotes:
|
|
107
|
+
"""Test that _handle_gcal keyword handler passes task notes."""
|
|
108
|
+
|
|
109
|
+
def test_keyword_handler_passes_notes(self):
|
|
110
|
+
"""gcal: keyword handler includes task notes as description."""
|
|
111
|
+
from unittest.mock import patch, Mock as M
|
|
112
|
+
from jot.integrations.keywords._handlers_mixin import HandlersMixin
|
|
113
|
+
|
|
114
|
+
handler = type('H', (HandlersMixin,), {})()
|
|
115
|
+
task = {'text': 'gcal: Sprint planning', 'notes': 'Review backlog first'}
|
|
116
|
+
|
|
117
|
+
mock_service = Mock()
|
|
118
|
+
mock_insert = Mock()
|
|
119
|
+
mock_insert.execute.return_value = {'htmlLink': 'https://cal.google.com/event/1'}
|
|
120
|
+
mock_service.events.return_value.insert.return_value = mock_insert
|
|
121
|
+
|
|
122
|
+
with patch('jot.integrations.keywords._handlers_mixin.GCAL_AVAILABLE', True), \
|
|
123
|
+
patch('jot.integrations.keywords._handlers_mixin.GoogleCalendarAccountManager') as mock_mgr, \
|
|
124
|
+
patch('jot.integrations.keywords._handlers_mixin.GoogleCalendarAuth') as mock_auth, \
|
|
125
|
+
patch('jot.integrations.keywords._handlers_mixin.create_gcal_event') as mock_create:
|
|
126
|
+
mock_mgr.return_value.discover_accounts.return_value = ['default']
|
|
127
|
+
mock_auth.return_value.get_service.return_value = mock_service
|
|
128
|
+
mock_create.return_value = 'https://cal.google.com/event/1'
|
|
129
|
+
|
|
130
|
+
handler._handle_gcal(task)
|
|
131
|
+
|
|
132
|
+
mock_create.assert_called_once()
|
|
133
|
+
_, kwargs = mock_create.call_args
|
|
134
|
+
assert kwargs['description'] == 'Review backlog first'
|
|
135
|
+
|
|
136
|
+
def test_keyword_handler_no_notes_passes_none(self):
|
|
137
|
+
"""gcal: keyword handler passes None when task has no notes."""
|
|
138
|
+
from unittest.mock import patch
|
|
139
|
+
from jot.integrations.keywords._handlers_mixin import HandlersMixin
|
|
140
|
+
|
|
141
|
+
handler = type('H', (HandlersMixin,), {})()
|
|
142
|
+
task = {'text': 'gcal: Quick check-in'}
|
|
143
|
+
|
|
144
|
+
with patch('jot.integrations.keywords._handlers_mixin.GCAL_AVAILABLE', True), \
|
|
145
|
+
patch('jot.integrations.keywords._handlers_mixin.GoogleCalendarAccountManager') as mock_mgr, \
|
|
146
|
+
patch('jot.integrations.keywords._handlers_mixin.GoogleCalendarAuth') as mock_auth, \
|
|
147
|
+
patch('jot.integrations.keywords._handlers_mixin.create_gcal_event') as mock_create:
|
|
148
|
+
mock_mgr.return_value.discover_accounts.return_value = ['default']
|
|
149
|
+
mock_auth.return_value.get_service.return_value = Mock()
|
|
150
|
+
mock_create.return_value = 'https://cal.google.com/event/1'
|
|
151
|
+
|
|
152
|
+
handler._handle_gcal(task)
|
|
153
|
+
|
|
154
|
+
_, kwargs = mock_create.call_args
|
|
155
|
+
assert kwargs['description'] is None
|