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.
Files changed (96) hide show
  1. {jott_cli-0.5.0/jott_cli.egg-info → jott_cli-0.5.1}/PKG-INFO +1 -1
  2. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/__init__.py +1 -1
  3. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/app.py +1 -0
  4. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_gcal_mixin.py +3 -1
  5. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_metadata_mixin.py +11 -0
  6. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_crud_mixin.py +1 -1
  7. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_metadata_mixin.py +15 -0
  8. jott_cli-0.5.1/jot/core/_subtask_mixin.py +117 -0
  9. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/events.py +4 -2
  10. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/_handlers_mixin.py +3 -1
  11. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/__init__.py +4 -2
  12. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_help.py +3 -1
  13. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_tasks.py +11 -2
  14. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/styles.py +7 -0
  15. {jott_cli-0.5.0 → jott_cli-0.5.1/jott_cli.egg-info}/PKG-INFO +1 -1
  16. {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/SOURCES.txt +3 -0
  17. {jott_cli-0.5.0 → jott_cli-0.5.1}/pyproject.toml +1 -1
  18. jott_cli-0.5.1/tests/test_gcal_notes.py +155 -0
  19. jott_cli-0.5.1/tests/test_highlight.py +156 -0
  20. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_styles.py +14 -1
  21. jott_cli-0.5.1/tests/test_subtask_notes.py +246 -0
  22. jott_cli-0.5.0/jot/core/_subtask_mixin.py +0 -68
  23. {jott_cli-0.5.0 → jott_cli-0.5.1}/LICENSE +0 -0
  24. {jott_cli-0.5.0 → jott_cli-0.5.1}/README.md +0 -0
  25. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/_app_navigation_mixin.py +0 -0
  26. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/_dispatch_mixin.py +0 -0
  27. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/__init__.py +0 -0
  28. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/config.py +0 -0
  29. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/manager.py +0 -0
  30. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/categories/templates.py +0 -0
  31. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/__init__.py +0 -0
  32. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/archive.py +0 -0
  33. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/config.py +0 -0
  34. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/cli/views.py +0 -0
  35. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/__init__.py +0 -0
  36. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_ai_analysis_mixin.py +0 -0
  37. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_ai_suggest_mixin.py +0 -0
  38. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_audio_timer_mixin.py +0 -0
  39. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_bulk_mixin.py +0 -0
  40. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_context_mixin.py +0 -0
  41. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_core_mixin.py +0 -0
  42. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_notes_mixin.py +0 -0
  43. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_transfer_mixin.py +0 -0
  44. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/_web_clipboard_mixin.py +0 -0
  45. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/commands/handler.py +0 -0
  46. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/__init__.py +0 -0
  47. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_age_backlog_mixin.py +0 -0
  48. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_compress_mixin.py +0 -0
  49. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_delete_mixin.py +0 -0
  50. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_export_mixin.py +0 -0
  51. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_id_migration_mixin.py +0 -0
  52. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_navigation_mixin.py +0 -0
  53. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/_persistence_mixin.py +0 -0
  54. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/archive_manager.py +0 -0
  55. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/constants.py +0 -0
  56. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/id_manager.py +0 -0
  57. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/core/task_manager.py +0 -0
  58. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/__init__.py +0 -0
  59. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/__init__.py +0 -0
  60. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/account_manager.py +0 -0
  61. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/gcal/auth.py +0 -0
  62. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/__init__.py +0 -0
  63. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/_config_mixin.py +0 -0
  64. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/integrations/keywords/handler.py +0 -0
  65. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/__init__.py +0 -0
  66. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/handlers.py +0 -0
  67. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/schemas.py +0 -0
  68. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/mcp/server.py +0 -0
  69. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/projects/__init__.py +0 -0
  70. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/projects/backup.py +0 -0
  71. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/projects/registry.py +0 -0
  72. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display.py +0 -0
  73. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_archive.py +0 -0
  74. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_footer.py +0 -0
  75. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/display_projects.py +0 -0
  76. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/formatting.py +0 -0
  77. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/input.py +0 -0
  78. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/ui/picker.py +0 -0
  79. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/__init__.py +0 -0
  80. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/date_utils.py +0 -0
  81. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/text_utils.py +0 -0
  82. {jott_cli-0.5.0 → jott_cli-0.5.1}/jot/utils/validation.py +0 -0
  83. {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/dependency_links.txt +0 -0
  84. {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/entry_points.txt +0 -0
  85. {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/requires.txt +0 -0
  86. {jott_cli-0.5.0 → jott_cli-0.5.1}/jott_cli.egg-info/top_level.txt +0 -0
  87. {jott_cli-0.5.0 → jott_cli-0.5.1}/setup.cfg +0 -0
  88. {jott_cli-0.5.0 → jott_cli-0.5.1}/setup.py +0 -0
  89. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_command_handler.py +0 -0
  90. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_dispatch.py +0 -0
  91. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_edit_edge_cases.py +0 -0
  92. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_fuzzy_search.py +0 -0
  93. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_input.py +0 -0
  94. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_jot.py +0 -0
  95. {jott_cli-0.5.0 → jott_cli-0.5.1}/tests/test_picker.py +0 -0
  96. {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.0
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.0"
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
- link = create_gcal_event(service, cleaned, start_time=start)
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
- create_gcal_event(service, task['text'])
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.2.2{RESET}
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.0
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.0"
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