jott-cli 0.7.1__tar.gz → 0.8.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 (107) hide show
  1. {jott_cli-0.7.1/jott_cli.egg-info → jott_cli-0.8.1}/PKG-INFO +1 -1
  2. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/__init__.py +1 -1
  3. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/app.py +5 -0
  4. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_metadata_mixin.py +21 -10
  5. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_delete_mixin.py +51 -2
  6. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_metadata_mixin.py +15 -0
  7. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/styles.py +43 -4
  8. {jott_cli-0.7.1 → jott_cli-0.8.1/jott_cli.egg-info}/PKG-INFO +1 -1
  9. {jott_cli-0.7.1 → jott_cli-0.8.1}/jott_cli.egg-info/SOURCES.txt +4 -0
  10. {jott_cli-0.7.1 → jott_cli-0.8.1}/pyproject.toml +1 -1
  11. jott_cli-0.8.1/tests/test_claude_org_prompt.py +90 -0
  12. jott_cli-0.8.1/tests/test_deleted_notes_dir.py +134 -0
  13. jott_cli-0.8.1/tests/test_notes_tempfile_location.py +73 -0
  14. jott_cli-0.8.1/tests/test_state_palette.py +139 -0
  15. {jott_cli-0.7.1 → jott_cli-0.8.1}/LICENSE +0 -0
  16. {jott_cli-0.7.1 → jott_cli-0.8.1}/README.md +0 -0
  17. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/_app_navigation_mixin.py +0 -0
  18. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/_dispatch_mixin.py +0 -0
  19. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/categories/__init__.py +0 -0
  20. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/categories/config.py +0 -0
  21. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/categories/manager.py +0 -0
  22. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/categories/templates.py +0 -0
  23. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/cli/__init__.py +0 -0
  24. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/cli/archive.py +0 -0
  25. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/cli/config.py +0 -0
  26. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/cli/views.py +0 -0
  27. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/__init__.py +0 -0
  28. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_ai_analysis_mixin.py +0 -0
  29. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_ai_suggest_mixin.py +0 -0
  30. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_audio_timer_mixin.py +0 -0
  31. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_bulk_mixin.py +0 -0
  32. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_claude_mixin.py +0 -0
  33. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_context_mixin.py +0 -0
  34. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_core_mixin.py +0 -0
  35. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_gcal_mixin.py +0 -0
  36. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_github_mixin.py +0 -0
  37. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_notes_mixin.py +0 -0
  38. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_transfer_mixin.py +0 -0
  39. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/_web_clipboard_mixin.py +0 -0
  40. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/commands/handler.py +0 -0
  41. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/__init__.py +0 -0
  42. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_age_backlog_mixin.py +0 -0
  43. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_compress_mixin.py +0 -0
  44. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_crud_mixin.py +0 -0
  45. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_export_mixin.py +0 -0
  46. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_id_migration_mixin.py +0 -0
  47. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_navigation_mixin.py +0 -0
  48. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_persistence_mixin.py +0 -0
  49. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/_subtask_mixin.py +0 -0
  50. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/archive_manager.py +0 -0
  51. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/constants.py +0 -0
  52. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/id_manager.py +0 -0
  53. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/core/task_manager.py +0 -0
  54. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/__init__.py +0 -0
  55. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/gcal/__init__.py +0 -0
  56. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/gcal/account_manager.py +0 -0
  57. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/gcal/auth.py +0 -0
  58. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/gcal/events.py +0 -0
  59. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/github/__init__.py +0 -0
  60. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/github/issues.py +0 -0
  61. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/keywords/__init__.py +0 -0
  62. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/keywords/_config_mixin.py +0 -0
  63. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  64. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/integrations/keywords/handler.py +0 -0
  65. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/mcp/__init__.py +0 -0
  66. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/mcp/handlers.py +0 -0
  67. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/mcp/schemas.py +0 -0
  68. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/mcp/server.py +0 -0
  69. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/projects/__init__.py +0 -0
  70. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/projects/backup.py +0 -0
  71. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/projects/registry.py +0 -0
  72. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/__init__.py +0 -0
  73. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/display.py +0 -0
  74. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/display_archive.py +0 -0
  75. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/display_footer.py +0 -0
  76. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/display_help.py +0 -0
  77. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/display_projects.py +0 -0
  78. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/display_tasks.py +0 -0
  79. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/formatting.py +0 -0
  80. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/input.py +0 -0
  81. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/picker.py +0 -0
  82. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/ui/rendering.py +0 -0
  83. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/utils/__init__.py +0 -0
  84. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/utils/date_utils.py +0 -0
  85. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/utils/text_utils.py +0 -0
  86. {jott_cli-0.7.1 → jott_cli-0.8.1}/jot/utils/validation.py +0 -0
  87. {jott_cli-0.7.1 → jott_cli-0.8.1}/jott_cli.egg-info/dependency_links.txt +0 -0
  88. {jott_cli-0.7.1 → jott_cli-0.8.1}/jott_cli.egg-info/entry_points.txt +0 -0
  89. {jott_cli-0.7.1 → jott_cli-0.8.1}/jott_cli.egg-info/requires.txt +0 -0
  90. {jott_cli-0.7.1 → jott_cli-0.8.1}/jott_cli.egg-info/top_level.txt +0 -0
  91. {jott_cli-0.7.1 → jott_cli-0.8.1}/setup.cfg +0 -0
  92. {jott_cli-0.7.1 → jott_cli-0.8.1}/setup.py +0 -0
  93. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_command_handler.py +0 -0
  94. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_dispatch.py +0 -0
  95. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_edit_edge_cases.py +0 -0
  96. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_fuzzy_search.py +0 -0
  97. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_gcal_notes.py +0 -0
  98. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_github.py +0 -0
  99. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_highlight.py +0 -0
  100. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_input.py +0 -0
  101. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_jot.py +0 -0
  102. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_picker.py +0 -0
  103. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_styles.py +0 -0
  104. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_subtask_notes.py +0 -0
  105. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_terminal_wrap.py +0 -0
  106. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_today_filter.py +0 -0
  107. {jott_cli-0.7.1 → jott_cli-0.8.1}/tests/test_transfer_subtasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.7.1
3
+ Version: 0.8.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.7.1"
10
+ __version__ = "0.8.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.
@@ -84,6 +84,11 @@ class App(DispatchMixin, AppNavigationMixin):
84
84
  os.path.getmtime(task_manager.storage_file)
85
85
  if task_manager.storage_file.exists() else 0)
86
86
 
87
+ try:
88
+ task_manager._migrate_legacy_deleted_notes()
89
+ except Exception as e:
90
+ print(f"Warning: deleted-notes migration failed: {e}")
91
+
87
92
  # ------------------------------------------------------------------
88
93
  # Public entry point
89
94
  # ------------------------------------------------------------------
@@ -7,7 +7,7 @@ from jot.ui.picker import PickerItem, quick_picker, fuzzy_picker
7
7
  from jot.ui.styles import (
8
8
  RESET, BOLD, DIM, GREEN, RED,
9
9
  PRIORITY_COLORS, PRIORITY_SYMBOLS, STATUS_COLORS, STATUS_SYMBOLS,
10
- HIGHLIGHT_COLORS,
10
+ HIGHLIGHT_COLORS, STATE_ORDER, STATE_COLORS,
11
11
  )
12
12
  from jot.ui.input import restore_terminal
13
13
 
@@ -125,20 +125,29 @@ class MetadataMixin:
125
125
  return True
126
126
 
127
127
  def toggle_highlight(self):
128
- """Pick a highlight color for the current task, or clear it."""
128
+ """Pick a task state (color-coded) for the current task."""
129
129
  current_task = self.task_manager.get_current_task()
130
130
  if not current_task:
131
131
  return True
132
132
 
133
- items = [PickerItem(value="_clear", label="none (clear)", color=DIM)]
134
- for name, fmt in HIGHLIGHT_COLORS.items():
135
- items.append(PickerItem(
136
- value=name, label=name, color=fmt,
137
- ))
133
+ items = []
134
+ for state in STATE_ORDER:
135
+ color_key = STATE_COLORS[state]
136
+ if color_key is None:
137
+ items.append(PickerItem(
138
+ value="_clear", label="none", color=DIM,
139
+ annotation=state,
140
+ ))
141
+ else:
142
+ items.append(PickerItem(
143
+ value=color_key, label=color_key,
144
+ color=HIGHLIGHT_COLORS[color_key],
145
+ annotation=state,
146
+ ))
138
147
 
139
148
  result = quick_picker(
140
149
  items,
141
- prompt=f"Highlight color for: {current_task['text']}",
150
+ prompt=f"State for: {current_task['text']}",
142
151
  )
143
152
 
144
153
  if result.value is None:
@@ -146,11 +155,13 @@ class MetadataMixin:
146
155
 
147
156
  if result.value == "_clear":
148
157
  self.task_manager.set_highlight(color=None)
149
- print(f"\n{GREEN}✓{RESET} Highlight cleared")
158
+ print(f"\n{GREEN}✓{RESET} State cleared (backlog)")
150
159
  else:
151
160
  self.task_manager.set_highlight(color=result.value)
152
161
  fmt = HIGHLIGHT_COLORS[result.value]
153
- print(f"\n{GREEN}✓{RESET} Highlight set to {fmt} {result.value} {RESET}")
162
+ from jot.ui.styles import COLOR_TO_STATE
163
+ state_name = COLOR_TO_STATE.get(result.value, result.value)
164
+ print(f"\n{GREEN}✓{RESET} State: {fmt} {state_name} {RESET}")
154
165
 
155
166
  print("\nPress Enter to continue...", end='', flush=True)
156
167
  restore_terminal()
@@ -1,12 +1,55 @@
1
- """Delete mixin archive, permanent delete, notes export."""
1
+ """Delete mixin, archive, permanent delete, notes export."""
2
2
 
3
3
  import os
4
+ import re
4
5
  from datetime import datetime
5
6
 
6
7
 
8
+ DELETED_NOTES_DIR = ".jot-deleted-notes"
9
+ _DELETED_NOTES_FILENAME_RE = re.compile(
10
+ r"^TASK-\d+-notes-\d{8}_\d{6}\.org$"
11
+ )
12
+
13
+
7
14
  class DeleteMixin:
8
15
  """Task removal, archival, and notes preservation."""
9
16
 
17
+ def _migrate_legacy_deleted_notes(self):
18
+ """Move stray TASK-<id>-notes-<ts>.org files from project_dir
19
+ into the DELETED_NOTES_DIR subdirectory. Idempotent: a no-op
20
+ when no matching files exist at the project root.
21
+ """
22
+ try:
23
+ entries = list(self.project_dir.iterdir())
24
+ except (OSError, AttributeError):
25
+ return 0
26
+
27
+ matches = [
28
+ p for p in entries
29
+ if p.is_file() and _DELETED_NOTES_FILENAME_RE.match(p.name)
30
+ ]
31
+ if not matches:
32
+ return 0
33
+
34
+ dest_dir = self.project_dir / DELETED_NOTES_DIR
35
+ try:
36
+ dest_dir.mkdir(exist_ok=True)
37
+ except OSError as e:
38
+ print(f"Warning: could not create {dest_dir}: {e}")
39
+ return 0
40
+
41
+ moved = 0
42
+ for src in matches:
43
+ dest = dest_dir / src.name
44
+ if dest.exists():
45
+ continue
46
+ try:
47
+ src.rename(dest)
48
+ moved += 1
49
+ except OSError as e:
50
+ print(f"Warning: could not move {src.name}: {e}")
51
+ return moved
52
+
10
53
  def _unlink_children(self, task_id):
11
54
  """Remove parent:{task_id} labels from all children."""
12
55
  parent_label = f"parent:{task_id}"
@@ -131,7 +174,13 @@ class DeleteMixin:
131
174
  )
132
175
 
133
176
  filename = f"TASK-{task_id}-notes-{now.strftime('%Y%m%d_%H%M%S')}.org"
134
- org_file_path = self.project_dir / filename
177
+ dest_dir = self.project_dir / DELETED_NOTES_DIR
178
+ try:
179
+ dest_dir.mkdir(exist_ok=True)
180
+ except OSError as e:
181
+ print(f"Warning: Could not create {dest_dir}: {e}")
182
+ return None
183
+ org_file_path = dest_dir / filename
135
184
 
136
185
  try:
137
186
  with open(org_file_path, 'w') as f:
@@ -3,6 +3,18 @@
3
3
  from datetime import datetime, timezone
4
4
 
5
5
 
6
+ # One-way mapping from legacy status values to highlight color keys.
7
+ # Setting status auto-paints the highlight; the reverse is NOT applied.
8
+ # Status is being deprecated in favor of the richer state palette;
9
+ # this exists so the legacy `* status` picker still does something useful.
10
+ _STATUS_TO_HIGHLIGHT = {
11
+ 'todo': 'red',
12
+ 'in-progress': 'orange',
13
+ 'blocked': 'burnt',
14
+ 'done': 'yellow',
15
+ }
16
+
17
+
6
18
  class MetadataMixin:
7
19
  """Set task metadata: current flag, agent, day, priority, status, tally."""
8
20
 
@@ -118,6 +130,9 @@ class MetadataMixin:
118
130
  task['done'] = True
119
131
  elif task.get('done', False):
120
132
  task['done'] = False
133
+ color = _STATUS_TO_HIGHLIGHT.get(status)
134
+ if color:
135
+ task['highlight'] = color
121
136
  self._save_tasks()
122
137
  return True
123
138
  return False
@@ -49,26 +49,65 @@ BLACK = '\033[30m'
49
49
  # Background colors
50
50
  BG_YELLOW = '\033[103m'
51
51
  BG_ORANGE = '\033[48;5;208m'
52
+ BG_BURNT_ORANGE = '\033[48;5;166m'
52
53
  BG_MAGENTA = '\033[105m'
53
54
  BG_GRAY = '\033[100m'
54
55
  BG_DARK_GRAY = '\033[48;5;236m'
55
56
  BG_CYAN = '\033[106m'
57
+ BG_BLUE = '\033[48;5;33m'
56
58
  BG_RED = '\033[101m'
57
59
  BG_GREEN = '\033[102m'
58
60
 
59
- # Semantic mappings highlight color palette
61
+ # Semantic mappings, highlight color palette.
62
+ # Each color maps to a task state in STATE_HIGHLIGHTS below; the legacy
63
+ # `status` field is being deprecated in favor of these state-bearing
64
+ # highlights.
60
65
  HIGHLIGHT_COLORS = {
61
66
  'gray': WHITE + BG_DARK_GRAY,
62
- 'yellow': BLACK + BG_YELLOW,
63
- 'orange': BLACK + BG_ORANGE,
64
67
  'magenta': WHITE + BG_MAGENTA,
65
68
  'cyan': BLACK + BG_CYAN,
66
69
  'red': WHITE + BG_RED,
70
+ 'orange': BLACK + BG_ORANGE,
71
+ 'burnt': WHITE + BG_BURNT_ORANGE,
72
+ 'yellow': BLACK + BG_YELLOW,
73
+ 'blue': WHITE + BG_BLUE,
67
74
  'green': BLACK + BG_GREEN,
68
75
  }
69
- HIGHLIGHT_DEFAULT = 'yellow'
76
+ HIGHLIGHT_DEFAULT = 'gray'
70
77
  HIGHLIGHT_FMT = HIGHLIGHT_COLORS[HIGHLIGHT_DEFAULT]
71
78
 
79
+ # Semantic mappings, task state pipeline.
80
+ # Ordered from earliest to latest; STATE_COLORS[state] is the
81
+ # highlight-color key (looks up into HIGHLIGHT_COLORS), or None for
82
+ # the unhighlighted "backlog" state.
83
+ STATE_ORDER = [
84
+ 'backlog',
85
+ 'needs-review',
86
+ 'in-review',
87
+ 'ready',
88
+ 'todo',
89
+ 'doing',
90
+ 'blocked',
91
+ 'done',
92
+ 'shipping',
93
+ 'complete',
94
+ ]
95
+ STATE_COLORS = {
96
+ 'backlog': None,
97
+ 'needs-review': 'gray',
98
+ 'in-review': 'magenta',
99
+ 'ready': 'cyan',
100
+ 'todo': 'red',
101
+ 'doing': 'orange',
102
+ 'blocked': 'burnt',
103
+ 'done': 'yellow',
104
+ 'shipping': 'blue',
105
+ 'complete': 'green',
106
+ }
107
+ # Reverse lookup: highlight-color key → state name (for one-way
108
+ # inference from the legacy color field if ever needed).
109
+ COLOR_TO_STATE = {v: k for k, v in STATE_COLORS.items() if v is not None}
110
+
72
111
  # Semantic mappings — priority
73
112
  PRIORITY_COLORS = {
74
113
  'none': DIM,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.7.1
3
+ Version: 0.8.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>
@@ -84,7 +84,9 @@ jott_cli.egg-info/dependency_links.txt
84
84
  jott_cli.egg-info/entry_points.txt
85
85
  jott_cli.egg-info/requires.txt
86
86
  jott_cli.egg-info/top_level.txt
87
+ tests/test_claude_org_prompt.py
87
88
  tests/test_command_handler.py
89
+ tests/test_deleted_notes_dir.py
88
90
  tests/test_dispatch.py
89
91
  tests/test_edit_edge_cases.py
90
92
  tests/test_fuzzy_search.py
@@ -93,7 +95,9 @@ tests/test_github.py
93
95
  tests/test_highlight.py
94
96
  tests/test_input.py
95
97
  tests/test_jot.py
98
+ tests/test_notes_tempfile_location.py
96
99
  tests/test_picker.py
100
+ tests/test_state_palette.py
97
101
  tests/test_styles.py
98
102
  tests/test_subtask_notes.py
99
103
  tests/test_terminal_wrap.py
@@ -7,7 +7,7 @@ include = ["jot", "jot.*"]
7
7
 
8
8
  [project]
9
9
  name = "jott-cli"
10
- version = "0.7.1"
10
+ version = "0.8.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,90 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for org-mode formatting instruction appended to Claude prompts."""
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ from unittest.mock import patch
8
+
9
+ from jot import TaskManager
10
+ from jot.commands.handler import CommandHandler
11
+ from jot.commands._claude_mixin import _ORG_OUTPUT_INSTRUCTION
12
+
13
+
14
+ def _make_handler(tmpdir: Path):
15
+ (tmpdir / ".jot-registry.json").write_text("{}")
16
+ tm = TaskManager(storage_file='.jot.json', directory=str(tmpdir))
17
+ return tm, CommandHandler(tm)
18
+
19
+
20
+ def _fake_subprocess_result(stdout="* Result\nbody\n"):
21
+ return SimpleNamespace(stdout=stdout, stderr="", returncode=0)
22
+
23
+
24
+ class TestOrgOutputInstruction:
25
+ def test_instruction_constant_mentions_org_markers(self):
26
+ # Sanity: ensure the constant actually directs to org-mode output.
27
+ assert '#+begin_src' in _ORG_OUTPUT_INSTRUCTION
28
+ assert 'Do NOT use markdown' in _ORG_OUTPUT_INSTRUCTION
29
+ assert '~symbol~' in _ORG_OUTPUT_INSTRUCTION or '=literal=' in _ORG_OUTPUT_INSTRUCTION
30
+
31
+ def test_instruction_appended_when_no_existing_notes(self):
32
+ with tempfile.TemporaryDirectory() as raw:
33
+ tmpdir = Path(raw)
34
+ tm, handler = _make_handler(tmpdir)
35
+ tm.add_task("write a fizzbuzz")
36
+ task = tm.tasks[0]
37
+ with patch(
38
+ 'jot.commands._claude_mixin.subprocess.run',
39
+ return_value=_fake_subprocess_result(),
40
+ ) as mocked:
41
+ handler._run_claude_background(
42
+ task['id'], task['text'], existing_notes='',
43
+ allow_edits=False,
44
+ )
45
+
46
+ assert mocked.called
47
+ prompt = mocked.call_args.kwargs['input']
48
+ assert prompt.startswith("write a fizzbuzz")
49
+ assert prompt.endswith(_ORG_OUTPUT_INSTRUCTION)
50
+
51
+ def test_instruction_appended_when_existing_notes_present(self):
52
+ with tempfile.TemporaryDirectory() as raw:
53
+ tmpdir = Path(raw)
54
+ tm, handler = _make_handler(tmpdir)
55
+ tm.add_task("rewrite the parser")
56
+ task = tm.tasks[0]
57
+ with patch(
58
+ 'jot.commands._claude_mixin.subprocess.run',
59
+ return_value=_fake_subprocess_result(),
60
+ ) as mocked:
61
+ handler._run_claude_background(
62
+ task['id'], task['text'],
63
+ existing_notes='prior context here',
64
+ allow_edits=False,
65
+ )
66
+
67
+ prompt = mocked.call_args.kwargs['input']
68
+ assert 'rewrite the parser' in prompt
69
+ assert 'prior context here' in prompt
70
+ assert prompt.endswith(_ORG_OUTPUT_INSTRUCTION)
71
+
72
+ def test_instruction_appended_in_edit_mode(self):
73
+ # allow_edits=True path uses a different cmd but same prompt assembly.
74
+ with tempfile.TemporaryDirectory() as raw:
75
+ tmpdir = Path(raw)
76
+ tm, handler = _make_handler(tmpdir)
77
+ tm.add_task("apply diff")
78
+ task = tm.tasks[0]
79
+ with patch(
80
+ 'jot.commands._claude_mixin.subprocess.run',
81
+ return_value=_fake_subprocess_result(),
82
+ ) as mocked:
83
+ handler._run_claude_background(
84
+ task['id'], task['text'],
85
+ existing_notes='notes',
86
+ allow_edits=True,
87
+ )
88
+
89
+ prompt = mocked.call_args.kwargs['input']
90
+ assert prompt.endswith(_ORG_OUTPUT_INSTRUCTION)
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for deleted-task notes living in .jot-deleted-notes/ subdir
3
+ and the one-shot migration of legacy stray files at the project root.
4
+ """
5
+
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from jot import TaskManager
10
+ from jot.core._delete_mixin import DELETED_NOTES_DIR
11
+
12
+
13
+ def _make_tm(tmpdir: Path) -> TaskManager:
14
+ # Pre-seed registry to avoid auto-discovery side effects.
15
+ (tmpdir / ".jot-registry.json").write_text("{}")
16
+ return TaskManager(storage_file='.jot.json', directory=str(tmpdir))
17
+
18
+
19
+ class TestDeletedNotesDirectory:
20
+ def test_save_notes_writes_into_subdir(self):
21
+ with tempfile.TemporaryDirectory() as raw:
22
+ tmpdir = Path(raw)
23
+ tm = _make_tm(tmpdir)
24
+ tm.add_task("task with notes")
25
+ tid = tm.tasks[0]['id']
26
+ tm.tasks[0]['notes'] = "important context\nline two"
27
+
28
+ path = tm.save_notes_to_org_file(tid)
29
+
30
+ assert path is not None
31
+ p = Path(path)
32
+ assert p.parent.name == DELETED_NOTES_DIR
33
+ assert p.parent.parent == tmpdir
34
+ assert p.exists()
35
+ assert "important context" in p.read_text()
36
+
37
+ def test_remove_task_routes_notes_into_subdir(self):
38
+ with tempfile.TemporaryDirectory() as raw:
39
+ tmpdir = Path(raw)
40
+ tm = _make_tm(tmpdir)
41
+ tm.add_task("doomed task")
42
+ tid = tm.tasks[0]['id']
43
+ tm.tasks[0]['notes'] = "save me"
44
+
45
+ tm.remove_task(tid)
46
+
47
+ root_strays = list(tmpdir.glob("TASK-*-notes-*.org"))
48
+ subdir_files = list(
49
+ (tmpdir / DELETED_NOTES_DIR).glob("TASK-*-notes-*.org")
50
+ )
51
+ assert root_strays == []
52
+ assert len(subdir_files) == 1
53
+
54
+
55
+ class TestLegacyNotesMigration:
56
+ def test_migrate_moves_matching_files(self):
57
+ with tempfile.TemporaryDirectory() as raw:
58
+ tmpdir = Path(raw)
59
+ tm = _make_tm(tmpdir)
60
+
61
+ (tmpdir / "TASK-1-notes-20260101_120000.org").write_text("a")
62
+ (tmpdir / "TASK-42-notes-20260518_235959.org").write_text("b")
63
+
64
+ moved = tm._migrate_legacy_deleted_notes()
65
+
66
+ assert moved == 2
67
+ assert list(tmpdir.glob("TASK-*-notes-*.org")) == []
68
+ dest = tmpdir / DELETED_NOTES_DIR
69
+ assert (dest / "TASK-1-notes-20260101_120000.org").exists()
70
+ assert (dest / "TASK-42-notes-20260518_235959.org").exists()
71
+
72
+ def test_migrate_is_noop_when_no_matches(self):
73
+ with tempfile.TemporaryDirectory() as raw:
74
+ tmpdir = Path(raw)
75
+ tm = _make_tm(tmpdir)
76
+
77
+ moved = tm._migrate_legacy_deleted_notes()
78
+
79
+ assert moved == 0
80
+ assert not (tmpdir / DELETED_NOTES_DIR).exists()
81
+
82
+ def test_migrate_ignores_non_matching_filenames(self):
83
+ with tempfile.TemporaryDirectory() as raw:
84
+ tmpdir = Path(raw)
85
+ tm = _make_tm(tmpdir)
86
+
87
+ keep = [
88
+ "TASK-rfc-notes-draft.org",
89
+ "TASK-1-notes-bad.org",
90
+ "task-1-notes-20260101_120000.org",
91
+ "TASK-1-notes-20260101_120000.txt",
92
+ "NOTES.org",
93
+ ]
94
+ for name in keep:
95
+ (tmpdir / name).write_text("x")
96
+
97
+ moved = tm._migrate_legacy_deleted_notes()
98
+
99
+ assert moved == 0
100
+ for name in keep:
101
+ assert (tmpdir / name).exists()
102
+
103
+ def test_migrate_does_not_clobber_existing_dest(self):
104
+ with tempfile.TemporaryDirectory() as raw:
105
+ tmpdir = Path(raw)
106
+ tm = _make_tm(tmpdir)
107
+
108
+ stray = tmpdir / "TASK-7-notes-20260101_120000.org"
109
+ stray.write_text("new")
110
+ dest_dir = tmpdir / DELETED_NOTES_DIR
111
+ dest_dir.mkdir()
112
+ existing = dest_dir / "TASK-7-notes-20260101_120000.org"
113
+ existing.write_text("original")
114
+
115
+ tm._migrate_legacy_deleted_notes()
116
+
117
+ assert stray.exists()
118
+ assert existing.read_text() == "original"
119
+
120
+ def test_migrate_idempotent(self):
121
+ with tempfile.TemporaryDirectory() as raw:
122
+ tmpdir = Path(raw)
123
+ tm = _make_tm(tmpdir)
124
+ (tmpdir / "TASK-9-notes-20260202_020202.org").write_text("z")
125
+
126
+ first = tm._migrate_legacy_deleted_notes()
127
+ second = tm._migrate_legacy_deleted_notes()
128
+
129
+ assert first == 1
130
+ assert second == 0
131
+ assert (
132
+ tmpdir / DELETED_NOTES_DIR
133
+ / "TASK-9-notes-20260202_020202.org"
134
+ ).exists()
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Tests that notes editor temp files are created inside the project dir
3
+ (not /tmp), so editor/LSP can resolve project-local config and tools.
4
+ """
5
+
6
+ import os
7
+ import tempfile
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from unittest.mock import patch
11
+
12
+ from jot import TaskManager
13
+ from jot.commands.handler import CommandHandler
14
+
15
+
16
+ def _make_handler(tmpdir: Path):
17
+ (tmpdir / ".jot-registry.json").write_text("{}")
18
+ tm = TaskManager(storage_file='.jot.json', directory=str(tmpdir))
19
+ return tm, CommandHandler(tm)
20
+
21
+
22
+ @contextmanager
23
+ def _captured_edit(handler, editor):
24
+ """Run edit_task_notes() under stubs and yield the captured file_path."""
25
+ captured = {}
26
+
27
+ def fake_editor(file_path):
28
+ captured['path'] = file_path
29
+ return True
30
+
31
+ with patch.object(handler, 'safe_launch_editor', side_effect=fake_editor), \
32
+ patch('builtins.input', return_value=''), \
33
+ patch.dict(os.environ, {'EDITOR': editor}, clear=False):
34
+ handler.edit_task_notes()
35
+
36
+ yield captured['path']
37
+
38
+
39
+ class TestNotesTempfileLocation:
40
+ def test_tempfile_created_inside_project_dir(self):
41
+ with tempfile.TemporaryDirectory() as raw:
42
+ tmpdir = Path(raw).resolve()
43
+ tm, handler = _make_handler(tmpdir)
44
+ tm.add_task("a task")
45
+ tm.set_current(tm.tasks[0]['id'])
46
+
47
+ with _captured_edit(handler, 'nano') as path:
48
+ edited_path = Path(path).resolve()
49
+
50
+ # The temp file should live inside tmpdir, not in /tmp.
51
+ assert tmpdir in edited_path.parents, (
52
+ f"temp file {edited_path} not under project {tmpdir}"
53
+ )
54
+
55
+ def test_tempfile_uses_org_suffix_for_emacs(self):
56
+ with tempfile.TemporaryDirectory() as raw:
57
+ tmpdir = Path(raw).resolve()
58
+ tm, handler = _make_handler(tmpdir)
59
+ tm.add_task("emacs task")
60
+ tm.set_current(tm.tasks[0]['id'])
61
+
62
+ with _captured_edit(handler, 'emacs -nw') as path:
63
+ assert path.endswith('.org')
64
+
65
+ def test_tempfile_uses_txt_suffix_for_other_editors(self):
66
+ with tempfile.TemporaryDirectory() as raw:
67
+ tmpdir = Path(raw).resolve()
68
+ tm, handler = _make_handler(tmpdir)
69
+ tm.add_task("vim task")
70
+ tm.set_current(tm.tasks[0]['id'])
71
+
72
+ with _captured_edit(handler, 'vim') as path:
73
+ assert path.endswith('.txt')
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for the state-color palette and one-way status->highlight."""
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from jot import TaskManager
8
+ from jot.ui import styles
9
+ from jot.ui.styles import (
10
+ HIGHLIGHT_COLORS,
11
+ HIGHLIGHT_DEFAULT,
12
+ STATE_ORDER,
13
+ STATE_COLORS,
14
+ COLOR_TO_STATE,
15
+ )
16
+
17
+
18
+ def _make_tm(tmpdir: Path) -> TaskManager:
19
+ (tmpdir / ".jot-registry.json").write_text("{}")
20
+ return TaskManager(storage_file='.jot.json', directory=str(tmpdir))
21
+
22
+
23
+ class TestStatePalette:
24
+ def test_state_order_has_ten_entries(self):
25
+ assert len(STATE_ORDER) == 10
26
+
27
+ def test_state_order_is_pipeline_sequence(self):
28
+ assert STATE_ORDER == [
29
+ 'backlog', 'needs-review', 'in-review', 'ready',
30
+ 'todo', 'doing', 'blocked', 'done', 'shipping', 'complete',
31
+ ]
32
+
33
+ def test_every_state_has_a_color_entry(self):
34
+ for state in STATE_ORDER:
35
+ assert state in STATE_COLORS
36
+
37
+ def test_backlog_maps_to_no_color(self):
38
+ assert STATE_COLORS['backlog'] is None
39
+
40
+ def test_non_backlog_states_map_to_real_palette_keys(self):
41
+ for state in STATE_ORDER:
42
+ color = STATE_COLORS[state]
43
+ if color is None:
44
+ continue
45
+ assert color in HIGHLIGHT_COLORS, (
46
+ f"state {state} maps to unknown color {color}"
47
+ )
48
+
49
+ def test_state_colors_are_unique(self):
50
+ used = [c for c in STATE_COLORS.values() if c is not None]
51
+ assert len(used) == len(set(used)), "two states share a color"
52
+
53
+ def test_color_to_state_is_reverse_of_state_colors(self):
54
+ for color, state in COLOR_TO_STATE.items():
55
+ assert STATE_COLORS[state] == color
56
+
57
+ def test_burnt_and_blue_added_to_palette(self):
58
+ assert 'burnt' in HIGHLIGHT_COLORS
59
+ assert 'blue' in HIGHLIGHT_COLORS
60
+
61
+ def test_default_highlight_is_gray(self):
62
+ assert HIGHLIGHT_DEFAULT == 'gray'
63
+
64
+ def test_burnt_uses_256_color_166(self):
65
+ assert '48;5;166' in HIGHLIGHT_COLORS['burnt']
66
+
67
+ def test_blue_uses_256_color_33(self):
68
+ assert '48;5;33' in HIGHLIGHT_COLORS['blue']
69
+
70
+
71
+ class TestStatusAutoHighlights:
72
+ def test_setting_todo_paints_red(self):
73
+ with tempfile.TemporaryDirectory() as raw:
74
+ tm = _make_tm(Path(raw))
75
+ tm.add_task("x")
76
+ tid = tm.tasks[0]['id']
77
+
78
+ tm.set_task_status(tid, 'todo')
79
+
80
+ assert tm.tasks[0]['highlight'] == 'red'
81
+
82
+ def test_setting_in_progress_paints_orange(self):
83
+ with tempfile.TemporaryDirectory() as raw:
84
+ tm = _make_tm(Path(raw))
85
+ tm.add_task("x")
86
+ tid = tm.tasks[0]['id']
87
+
88
+ tm.set_task_status(tid, 'in-progress')
89
+
90
+ assert tm.tasks[0]['highlight'] == 'orange'
91
+
92
+ def test_setting_blocked_paints_burnt(self):
93
+ with tempfile.TemporaryDirectory() as raw:
94
+ tm = _make_tm(Path(raw))
95
+ tm.add_task("x")
96
+ tid = tm.tasks[0]['id']
97
+
98
+ tm.set_task_status(tid, 'blocked')
99
+
100
+ assert tm.tasks[0]['highlight'] == 'burnt'
101
+
102
+ def test_setting_done_paints_yellow(self):
103
+ with tempfile.TemporaryDirectory() as raw:
104
+ tm = _make_tm(Path(raw))
105
+ tm.add_task("x")
106
+ tid = tm.tasks[0]['id']
107
+
108
+ tm.set_task_status(tid, 'done')
109
+
110
+ assert tm.tasks[0]['highlight'] == 'yellow'
111
+
112
+ def test_status_overrides_prior_highlight(self):
113
+ # Status change *always* repaints, since status is the driver.
114
+ with tempfile.TemporaryDirectory() as raw:
115
+ tm = _make_tm(Path(raw))
116
+ tm.add_task("x")
117
+ tid = tm.tasks[0]['id']
118
+ tm.set_current(tid)
119
+ tm.set_highlight(color='magenta')
120
+ assert tm.tasks[0]['highlight'] == 'magenta'
121
+
122
+ tm.set_task_status(tid, 'todo')
123
+
124
+ assert tm.tasks[0]['highlight'] == 'red'
125
+
126
+ def test_highlight_change_does_not_alter_status(self):
127
+ # One-way: highlight changes must not feed back into status.
128
+ with tempfile.TemporaryDirectory() as raw:
129
+ tm = _make_tm(Path(raw))
130
+ tm.add_task("x")
131
+ tid = tm.tasks[0]['id']
132
+ tm.set_current(tid)
133
+ tm.set_task_status(tid, 'todo')
134
+ assert tm.tasks[0]['status'] == 'todo'
135
+
136
+ tm.set_highlight(color='green')
137
+
138
+ assert tm.tasks[0]['status'] == 'todo'
139
+ assert tm.tasks[0]['highlight'] == 'green'
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