jott-cli 0.7.1__tar.gz → 0.8.2__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.7.1/jott_cli.egg-info → jott_cli-0.8.2}/PKG-INFO +1 -1
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/__init__.py +3 -13
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/app.py +5 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_metadata_mixin.py +21 -10
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_delete_mixin.py +51 -2
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_metadata_mixin.py +15 -0
- jott_cli-0.8.2/jot/main.py +227 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/display_help.py +2 -1
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/styles.py +43 -4
- {jott_cli-0.7.1 → jott_cli-0.8.2/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jott_cli.egg-info/SOURCES.txt +6 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/pyproject.toml +1 -1
- jott_cli-0.8.2/tests/test_claude_org_prompt.py +90 -0
- jott_cli-0.8.2/tests/test_deleted_notes_dir.py +134 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_jot.py +1 -8
- jott_cli-0.8.2/tests/test_main_entry_point.py +84 -0
- jott_cli-0.8.2/tests/test_notes_tempfile_location.py +73 -0
- jott_cli-0.8.2/tests/test_state_palette.py +139 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/LICENSE +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/README.md +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/_app_navigation_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/_dispatch_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/categories/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/categories/config.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/categories/manager.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/categories/templates.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/cli/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/cli/archive.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/cli/config.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/cli/views.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_claude_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_github_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_transfer_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/commands/handler.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_crud_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/_subtask_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/constants.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/id_manager.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/core/task_manager.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/github/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/github/issues.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/mcp/server.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/projects/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/projects/backup.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/projects/registry.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/display.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/display_footer.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/display_tasks.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/formatting.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/input.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/picker.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/ui/rendering.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/utils/__init__.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jot/utils/validation.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/setup.cfg +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/setup.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_command_handler.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_dispatch.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_github.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_highlight.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_input.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_picker.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_styles.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_subtask_notes.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_terminal_wrap.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/tests/test_today_filter.py +0 -0
- {jott_cli-0.7.1 → jott_cli-0.8.2}/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.
|
|
3
|
+
Version: 0.8.2
|
|
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>
|
|
@@ -3,18 +3,15 @@
|
|
|
3
3
|
This package provides modular components for the jott task management system.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
import importlib.util
|
|
7
6
|
import os
|
|
8
|
-
from pathlib import Path
|
|
9
7
|
|
|
10
|
-
__version__ = "0.
|
|
8
|
+
__version__ = "0.8.2"
|
|
11
9
|
|
|
12
|
-
# When building Sphinx docs, skip the
|
|
10
|
+
# When building Sphinx docs, skip the heavy runtime imports.
|
|
13
11
|
# Set JOT_SPHINX_BUILD=1 in docs/sphinx/conf.py before importing jot.
|
|
14
12
|
if os.environ.get('JOT_SPHINX_BUILD'):
|
|
15
13
|
main = None
|
|
16
14
|
else:
|
|
17
|
-
# Import extracted classes from their new modules
|
|
18
15
|
from jot.core.id_manager import IDManager
|
|
19
16
|
from jot.core.task_manager import TaskManager
|
|
20
17
|
from jot.projects.registry import ProjectRegistry
|
|
@@ -27,14 +24,7 @@ else:
|
|
|
27
24
|
from jot.integrations.gcal.events import create_gcal_event, fetch_gcal_events
|
|
28
25
|
from jot.integrations.keywords.handler import KeywordHandler
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
_jot_script_path = Path(__file__).parent.parent / 'jot.py'
|
|
32
|
-
_spec = importlib.util.spec_from_file_location("_jot_script", _jot_script_path)
|
|
33
|
-
_jot_script = importlib.util.module_from_spec(_spec)
|
|
34
|
-
_spec.loader.exec_module(_jot_script)
|
|
35
|
-
|
|
36
|
-
# Export main function from jot.py
|
|
37
|
-
main = _jot_script.main
|
|
27
|
+
from jot.main import main
|
|
38
28
|
|
|
39
29
|
__all__ = [
|
|
40
30
|
'main',
|
|
@@ -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
|
|
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 = [
|
|
134
|
-
for
|
|
135
|
-
|
|
136
|
-
|
|
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"
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""jott entry point.
|
|
2
|
+
|
|
3
|
+
Hosts main() and the CLI argument plumbing previously in the
|
|
4
|
+
repo-root jot.py script. The console-script entry point in
|
|
5
|
+
pyproject.toml resolves through jot.__init__, which re-exports
|
|
6
|
+
main from this module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from jot.core.task_manager import TaskManager
|
|
14
|
+
from jot.projects.registry import ProjectRegistry
|
|
15
|
+
from jot.categories.manager import CategoryManager
|
|
16
|
+
from jot.commands import CommandHandler
|
|
17
|
+
from jot.cli import handle_cli_args
|
|
18
|
+
from jot.ui import display_help
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_auto_backlog_config():
|
|
22
|
+
"""Load auto-backlog configuration from ~/.jot-config.json"""
|
|
23
|
+
config_file = Path.home() / '.jot-config.json'
|
|
24
|
+
default_config = {
|
|
25
|
+
'enabled': True,
|
|
26
|
+
'age_threshold_days': 30,
|
|
27
|
+
'backlog_category': 'backlog',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if not config_file.exists():
|
|
31
|
+
return default_config
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(config_file, 'r') as f:
|
|
35
|
+
config = json.load(f)
|
|
36
|
+
result = default_config.copy()
|
|
37
|
+
result.update(config.get('auto_backlog', {}))
|
|
38
|
+
return result
|
|
39
|
+
except (json.JSONDecodeError, IOError):
|
|
40
|
+
return default_config
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_flags(args):
|
|
44
|
+
"""Extract --all, --global, --category/-c flags from args.
|
|
45
|
+
|
|
46
|
+
Returns (args, broadcast_all, explicit_global, category) with
|
|
47
|
+
consumed flags removed from args.
|
|
48
|
+
"""
|
|
49
|
+
broadcast_all = '--all' in args
|
|
50
|
+
if broadcast_all:
|
|
51
|
+
args.remove('--all')
|
|
52
|
+
|
|
53
|
+
explicit_global = '--global' in args
|
|
54
|
+
if explicit_global:
|
|
55
|
+
args.remove('--global')
|
|
56
|
+
|
|
57
|
+
category = None
|
|
58
|
+
for flag in ('--category', '-c'):
|
|
59
|
+
if flag in args:
|
|
60
|
+
idx = args.index(flag)
|
|
61
|
+
if idx + 1 < len(args):
|
|
62
|
+
category = args[idx + 1]
|
|
63
|
+
args = args[:idx] + args[idx + 2 :]
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
return args, broadcast_all, explicit_global, category
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_global(category, explicit_global, target_dir):
|
|
70
|
+
"""Determine whether a category should be global or local."""
|
|
71
|
+
if not category:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
if explicit_global:
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
cat_manager = CategoryManager(project_dir=target_dir or Path.cwd())
|
|
78
|
+
location = cat_manager.resolve_category_location(category)
|
|
79
|
+
|
|
80
|
+
if location == 'global':
|
|
81
|
+
return True
|
|
82
|
+
if location == 'local':
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# New category — create locally by default
|
|
86
|
+
can_create, error_msg = cat_manager.can_create_category(category)
|
|
87
|
+
if not can_create:
|
|
88
|
+
print(f"✗ {error_msg}")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
warning_msg = cat_manager.get_warning_message(category)
|
|
92
|
+
if warning_msg:
|
|
93
|
+
print(warning_msg)
|
|
94
|
+
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_project_and_task(args, registry):
|
|
99
|
+
"""Parse remaining args into (target_dir, project_name, initial_task)."""
|
|
100
|
+
target_dir = None
|
|
101
|
+
project_name = None
|
|
102
|
+
initial_task = None
|
|
103
|
+
|
|
104
|
+
if args:
|
|
105
|
+
project_path = registry.get_project_path(args[0])
|
|
106
|
+
if project_path:
|
|
107
|
+
target_dir = project_path
|
|
108
|
+
project_name = args[0]
|
|
109
|
+
if len(args) > 1:
|
|
110
|
+
initial_task = ' '.join(args[1:])
|
|
111
|
+
else:
|
|
112
|
+
initial_task = ' '.join(args)
|
|
113
|
+
|
|
114
|
+
return target_dir, project_name, initial_task
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _handle_initial_task(
|
|
118
|
+
task_manager, initial_task, broadcast_all, registry, project_name, is_global, category
|
|
119
|
+
):
|
|
120
|
+
"""Add a task supplied on the command line and exit."""
|
|
121
|
+
if initial_task and broadcast_all:
|
|
122
|
+
projects = registry.list_projects()
|
|
123
|
+
if not projects:
|
|
124
|
+
print("✗ No registered projects found. Use --register to add projects.")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
n = len(projects)
|
|
128
|
+
confirm = input(f"⚠ Broadcast \"{initial_task}\" to {n} projects? (y/N): ").strip().lower()
|
|
129
|
+
if confirm != 'y':
|
|
130
|
+
print("Cancelled.")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
added = 0
|
|
134
|
+
seen_paths = set()
|
|
135
|
+
for _, proj_path in projects.items():
|
|
136
|
+
resolved = str(Path(proj_path).resolve())
|
|
137
|
+
if resolved in seen_paths:
|
|
138
|
+
continue
|
|
139
|
+
seen_paths.add(resolved)
|
|
140
|
+
tm = TaskManager(directory=proj_path, project_registry=registry)
|
|
141
|
+
all_texts = [t['text'] for t in tm.tasks + tm.archived]
|
|
142
|
+
if initial_task in all_texts:
|
|
143
|
+
continue
|
|
144
|
+
tm.add_task(initial_task)
|
|
145
|
+
added += 1
|
|
146
|
+
print(f"✓ Added task to {added}/{len(projects)} projects: {initial_task}")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
if initial_task:
|
|
150
|
+
task_manager.add_task(initial_task)
|
|
151
|
+
location = project_name or Path.cwd().name
|
|
152
|
+
|
|
153
|
+
if is_global and category:
|
|
154
|
+
print(f"✓ Added task to global ({category}): {initial_task}")
|
|
155
|
+
elif category:
|
|
156
|
+
print(f"✓ Added task to {location} ({category}): {initial_task}")
|
|
157
|
+
else:
|
|
158
|
+
print(f"✓ Added task to {location}: {initial_task}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main():
|
|
162
|
+
"""Entry point: parse flags, dispatch CLI commands, or start TUI."""
|
|
163
|
+
registry = ProjectRegistry()
|
|
164
|
+
args = sys.argv[1:]
|
|
165
|
+
|
|
166
|
+
# --help is always checked first
|
|
167
|
+
if '--help' in args or '-h' in args:
|
|
168
|
+
display_help()
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
args, broadcast_all, explicit_global, category = _parse_flags(args)
|
|
172
|
+
|
|
173
|
+
# One-shot CLI commands (--list-projects, --archive, --today, etc.)
|
|
174
|
+
if handle_cli_args(args, registry, category, explicit_global):
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
# Resolve project routing and initial task text
|
|
178
|
+
target_dir, project_name, initial_task = _resolve_project_and_task(args, registry)
|
|
179
|
+
|
|
180
|
+
is_global = _resolve_global(category, explicit_global, target_dir)
|
|
181
|
+
|
|
182
|
+
# Set up TaskManager and CommandHandler
|
|
183
|
+
task_manager = TaskManager(
|
|
184
|
+
directory=target_dir,
|
|
185
|
+
category=category,
|
|
186
|
+
is_global=is_global,
|
|
187
|
+
project_registry=registry,
|
|
188
|
+
)
|
|
189
|
+
command_handler = CommandHandler(task_manager, registry)
|
|
190
|
+
|
|
191
|
+
# Auto-register project if opened with .jot.json but not in registry
|
|
192
|
+
proj_dir = task_manager.project_dir
|
|
193
|
+
if (
|
|
194
|
+
not is_global
|
|
195
|
+
and (proj_dir / '.jot.json').exists()
|
|
196
|
+
and not registry.is_path_registered(proj_dir)
|
|
197
|
+
):
|
|
198
|
+
registry.register_project(proj_dir.name, str(proj_dir))
|
|
199
|
+
|
|
200
|
+
# Auto-transfer stale tasks to backlog
|
|
201
|
+
auto_cfg = load_auto_backlog_config()
|
|
202
|
+
if auto_cfg.get('enabled', True) and category != auto_cfg.get('backlog_category', 'backlog'):
|
|
203
|
+
task_manager.transfer_old_tasks_to_backlog(
|
|
204
|
+
age_threshold_days=auto_cfg.get('age_threshold_days', 30),
|
|
205
|
+
backlog_category=auto_cfg.get('backlog_category', 'backlog'),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
task_manager.ensure_default_task()
|
|
209
|
+
|
|
210
|
+
# If a task was supplied on the command line, add it and exit
|
|
211
|
+
if initial_task or broadcast_all:
|
|
212
|
+
_handle_initial_task(
|
|
213
|
+
task_manager,
|
|
214
|
+
initial_task,
|
|
215
|
+
broadcast_all,
|
|
216
|
+
registry,
|
|
217
|
+
project_name,
|
|
218
|
+
is_global,
|
|
219
|
+
category,
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Launch the interactive TUI
|
|
224
|
+
from jot.app import App
|
|
225
|
+
|
|
226
|
+
app = App(task_manager, command_handler, registry, target_dir, project_name)
|
|
227
|
+
app.run()
|
|
@@ -257,9 +257,10 @@ def display_categorized_shortcuts():
|
|
|
257
257
|
|
|
258
258
|
def display_help():
|
|
259
259
|
"""Display comprehensive help information."""
|
|
260
|
+
from jot import __version__
|
|
260
261
|
help_text = f"""
|
|
261
262
|
{BOLD}Jott - Simple Interactive Task List{RESET}
|
|
262
|
-
{DIM}Version
|
|
263
|
+
{DIM}Version {__version__}{RESET}
|
|
263
264
|
|
|
264
265
|
{BOLD}BASIC USAGE:{RESET}
|
|
265
266
|
jott Start interactive task manager (current project)
|
|
@@ -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
|
|
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 = '
|
|
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.
|
|
3
|
+
Version: 0.8.2
|
|
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>
|
|
@@ -6,6 +6,7 @@ jot/__init__.py
|
|
|
6
6
|
jot/_app_navigation_mixin.py
|
|
7
7
|
jot/_dispatch_mixin.py
|
|
8
8
|
jot/app.py
|
|
9
|
+
jot/main.py
|
|
9
10
|
jot/categories/__init__.py
|
|
10
11
|
jot/categories/config.py
|
|
11
12
|
jot/categories/manager.py
|
|
@@ -84,7 +85,9 @@ jott_cli.egg-info/dependency_links.txt
|
|
|
84
85
|
jott_cli.egg-info/entry_points.txt
|
|
85
86
|
jott_cli.egg-info/requires.txt
|
|
86
87
|
jott_cli.egg-info/top_level.txt
|
|
88
|
+
tests/test_claude_org_prompt.py
|
|
87
89
|
tests/test_command_handler.py
|
|
90
|
+
tests/test_deleted_notes_dir.py
|
|
88
91
|
tests/test_dispatch.py
|
|
89
92
|
tests/test_edit_edge_cases.py
|
|
90
93
|
tests/test_fuzzy_search.py
|
|
@@ -93,7 +96,10 @@ tests/test_github.py
|
|
|
93
96
|
tests/test_highlight.py
|
|
94
97
|
tests/test_input.py
|
|
95
98
|
tests/test_jot.py
|
|
99
|
+
tests/test_main_entry_point.py
|
|
100
|
+
tests/test_notes_tempfile_location.py
|
|
96
101
|
tests/test_picker.py
|
|
102
|
+
tests/test_state_palette.py
|
|
97
103
|
tests/test_styles.py
|
|
98
104
|
tests/test_subtask_notes.py
|
|
99
105
|
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.
|
|
10
|
+
version = "0.8.2"
|
|
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)
|