jott-cli 0.5.0__tar.gz → 0.5.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.5.0/jott_cli.egg-info → jott_cli-0.5.2}/PKG-INFO +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/__init__.py +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/app.py +3 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_gcal_mixin.py +3 -1
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_metadata_mixin.py +122 -1
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_transfer_mixin.py +48 -72
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_crud_mixin.py +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_metadata_mixin.py +20 -0
- jott_cli-0.5.2/jot/core/_subtask_mixin.py +117 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/gcal/events.py +4 -2
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/keywords/_handlers_mixin.py +3 -1
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/__init__.py +7 -2
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/display_archive.py +5 -4
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/display_footer.py +5 -4
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/display_help.py +11 -4
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/display_projects.py +10 -6
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/display_tasks.py +125 -47
- jott_cli-0.5.2/jot/ui/styles.py +102 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jott_cli.egg-info/SOURCES.txt +4 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/pyproject.toml +1 -1
- jott_cli-0.5.2/tests/test_gcal_notes.py +155 -0
- jott_cli-0.5.2/tests/test_highlight.py +176 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_jot.py +4 -4
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_styles.py +24 -1
- jott_cli-0.5.2/tests/test_subtask_notes.py +545 -0
- jott_cli-0.5.2/tests/test_terminal_wrap.py +212 -0
- jott_cli-0.5.0/jot/core/_subtask_mixin.py +0 -68
- jott_cli-0.5.0/jot/ui/styles.py +0 -54
- {jott_cli-0.5.0 → jott_cli-0.5.2}/LICENSE +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/README.md +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/_app_navigation_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/_dispatch_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/categories/config.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/cli/config.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/cli/views.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/commands/handler.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_delete_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/constants.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/projects/registry.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/display.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/input.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/ui/picker.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/setup.cfg +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/setup.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_dispatch.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_input.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_picker.py +0 -0
- {jott_cli-0.5.0 → jott_cli-0.5.2}/tests/test_today_filter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jott-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.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>
|
|
@@ -7,7 +7,7 @@ import importlib.util
|
|
|
7
7
|
import os
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
__version__ = "0.5.
|
|
10
|
+
__version__ = "0.5.2"
|
|
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,9 @@ 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',
|
|
83
|
+
'~': 'quick_highlight',
|
|
84
|
+
'\x0c': 'assign_parent', # Ctrl+L
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
def __init__(self, task_manager, command_handler, registry,
|
|
@@ -109,7 +109,9 @@ class GcalMixin:
|
|
|
109
109
|
def _export_single_task(self, service, account_name, task):
|
|
110
110
|
"""Export a single task to Google Calendar."""
|
|
111
111
|
start, end, cleaned = self._build_gcal_start_time(task['text'])
|
|
112
|
-
|
|
112
|
+
notes = task.get('notes', '').strip()
|
|
113
|
+
link = create_gcal_event(service, cleaned, start_time=start,
|
|
114
|
+
description=notes or None)
|
|
113
115
|
print(f" {GREEN}✓{RESET} {BOLD}{cleaned}{RESET}")
|
|
114
116
|
print(f" {start.strftime('%-I:%M %p')} - {end.strftime('%-I:%M %p')}")
|
|
115
117
|
if link:
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
|
|
5
5
|
from jot.core.constants import DAY_COLORS, DAY_ABBREVIATIONS
|
|
6
|
-
from jot.ui.picker import PickerItem, quick_picker
|
|
6
|
+
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
11
|
)
|
|
11
12
|
from jot.ui.input import restore_terminal
|
|
12
13
|
|
|
@@ -123,6 +124,55 @@ class MetadataMixin:
|
|
|
123
124
|
|
|
124
125
|
return True
|
|
125
126
|
|
|
127
|
+
def toggle_highlight(self):
|
|
128
|
+
"""Pick a highlight color for the current task, or clear it."""
|
|
129
|
+
current_task = self.task_manager.get_current_task()
|
|
130
|
+
if not current_task:
|
|
131
|
+
return True
|
|
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
|
+
))
|
|
138
|
+
|
|
139
|
+
result = quick_picker(
|
|
140
|
+
items,
|
|
141
|
+
prompt=f"Highlight color for: {current_task['text']}",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if result.value is None:
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
if result.value == "_clear":
|
|
148
|
+
self.task_manager.set_highlight(color=None)
|
|
149
|
+
print(f"\n{GREEN}✓{RESET} Highlight cleared")
|
|
150
|
+
else:
|
|
151
|
+
self.task_manager.set_highlight(color=result.value)
|
|
152
|
+
fmt = HIGHLIGHT_COLORS[result.value]
|
|
153
|
+
print(f"\n{GREEN}✓{RESET} Highlight set to {fmt} {result.value} {RESET}")
|
|
154
|
+
|
|
155
|
+
print("\nPress Enter to continue...", end='', flush=True)
|
|
156
|
+
restore_terminal()
|
|
157
|
+
input()
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def quick_highlight(self):
|
|
161
|
+
"""Toggle default highlight color on the current task."""
|
|
162
|
+
current_task = self.task_manager.get_current_task()
|
|
163
|
+
if not current_task:
|
|
164
|
+
return True
|
|
165
|
+
from jot.ui.styles import HIGHLIGHT_DEFAULT
|
|
166
|
+
was_highlighted = current_task.get('highlight', False)
|
|
167
|
+
if was_highlighted:
|
|
168
|
+
self.task_manager.set_highlight(color=None)
|
|
169
|
+
print(f"\n{GREEN}✓{RESET} Highlight cleared")
|
|
170
|
+
else:
|
|
171
|
+
self.task_manager.set_highlight(color=HIGHLIGHT_DEFAULT)
|
|
172
|
+
fmt = HIGHLIGHT_COLORS[HIGHLIGHT_DEFAULT]
|
|
173
|
+
print(f"\n{GREEN}✓{RESET} Highlighted {fmt} {HIGHLIGHT_DEFAULT} {RESET}")
|
|
174
|
+
return True
|
|
175
|
+
|
|
126
176
|
def set_priority_high(self):
|
|
127
177
|
"""Quickly set current task priority to high (shortcut)"""
|
|
128
178
|
current_task = self.task_manager.get_current_task()
|
|
@@ -165,3 +215,74 @@ class MetadataMixin:
|
|
|
165
215
|
input()
|
|
166
216
|
|
|
167
217
|
return True
|
|
218
|
+
|
|
219
|
+
def assign_parent(self):
|
|
220
|
+
"""Assign current task as a subtask of another task (Ctrl+L)."""
|
|
221
|
+
current_task = self.task_manager.get_current_task()
|
|
222
|
+
if not current_task:
|
|
223
|
+
print("\n✗ No current task selected")
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
task_id = current_task['id']
|
|
227
|
+
labels = current_task.get('labels', [])
|
|
228
|
+
|
|
229
|
+
# Find existing parent label
|
|
230
|
+
current_parent = None
|
|
231
|
+
for lbl in labels:
|
|
232
|
+
if lbl.startswith('parent:'):
|
|
233
|
+
current_parent = lbl.split(':', 1)[1]
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
# Build picker items
|
|
237
|
+
items = []
|
|
238
|
+
if current_parent is not None:
|
|
239
|
+
items.append(PickerItem(
|
|
240
|
+
value="_clear", label="Remove parent link", color=DIM,
|
|
241
|
+
))
|
|
242
|
+
|
|
243
|
+
for task in self.task_manager.get_tasks():
|
|
244
|
+
if task['id'] == task_id:
|
|
245
|
+
continue
|
|
246
|
+
if task.get('status') == 'done':
|
|
247
|
+
continue
|
|
248
|
+
annotation = "(current parent)" if str(task['id']) == str(current_parent) else ""
|
|
249
|
+
items.append(PickerItem(
|
|
250
|
+
value=str(task['id']),
|
|
251
|
+
label=f"#{task['id']} {task['text']}",
|
|
252
|
+
annotation=annotation,
|
|
253
|
+
))
|
|
254
|
+
|
|
255
|
+
if not items:
|
|
256
|
+
print("\n✗ No candidate tasks found")
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
result = fuzzy_picker(
|
|
260
|
+
items,
|
|
261
|
+
prompt=f"Assign parent for: {current_task['text']}",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if result.value is None:
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
# Strip existing parent: label
|
|
268
|
+
new_labels = [l for l in labels if not l.startswith('parent:')]
|
|
269
|
+
|
|
270
|
+
if result.value != "_clear":
|
|
271
|
+
new_labels.append(f"parent:{result.value}")
|
|
272
|
+
|
|
273
|
+
# Save updated labels
|
|
274
|
+
for task in self.task_manager.tasks:
|
|
275
|
+
if task['id'] == task_id:
|
|
276
|
+
task['labels'] = new_labels
|
|
277
|
+
self.task_manager._save_tasks()
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
if result.value == "_clear":
|
|
281
|
+
print(f"\n{GREEN}✓{RESET} Parent link removed")
|
|
282
|
+
else:
|
|
283
|
+
print(f"\n{GREEN}✓{RESET} Assigned as subtask of #{result.value}")
|
|
284
|
+
|
|
285
|
+
print("\nPress Enter to continue...", end='', flush=True)
|
|
286
|
+
restore_terminal()
|
|
287
|
+
input()
|
|
288
|
+
return True
|
|
@@ -5,9 +5,8 @@ from pathlib import Path
|
|
|
5
5
|
from jot.categories.config import CategoryConfig
|
|
6
6
|
from jot.categories.manager import CategoryManager
|
|
7
7
|
from jot.core.task_manager import TaskManager
|
|
8
|
-
from jot.ui.picker import PickerItem, fuzzy_picker
|
|
8
|
+
from jot.ui.picker import PickerItem, fuzzy_picker, quick_picker
|
|
9
9
|
from jot.ui.styles import RESET, RED
|
|
10
|
-
from jot.ui.input import restore_terminal
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
class TransferMixin:
|
|
@@ -211,78 +210,55 @@ class TransferMixin:
|
|
|
211
210
|
|
|
212
211
|
cat_config = CategoryConfig(project_dir=project_dir)
|
|
213
212
|
|
|
214
|
-
|
|
215
|
-
for
|
|
213
|
+
items = []
|
|
214
|
+
for display_name, cat_name, is_global in categories:
|
|
216
215
|
if display_name == "default":
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
items.append(PickerItem(
|
|
217
|
+
value=(None, False),
|
|
218
|
+
label="default",
|
|
219
|
+
annotation="(no category)",
|
|
220
|
+
color=cat_config.get_color('default'),
|
|
221
|
+
))
|
|
219
222
|
else:
|
|
220
|
-
cat_color = cat_config.get_color(cat_name, for_global=is_global)
|
|
221
223
|
scope = "[global]" if is_global else "[local]"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return True
|
|
232
|
-
|
|
233
|
-
selected_category = None
|
|
234
|
-
selected_is_global = False
|
|
235
|
-
|
|
236
|
-
try:
|
|
237
|
-
choice_index = int(user_input) - 1
|
|
238
|
-
if 0 <= choice_index < len(categories):
|
|
239
|
-
_, cat_name, is_global = categories[choice_index]
|
|
240
|
-
selected_category = cat_name
|
|
241
|
-
selected_is_global = is_global
|
|
242
|
-
else:
|
|
243
|
-
print("✗ Invalid number")
|
|
244
|
-
return True
|
|
245
|
-
except ValueError:
|
|
246
|
-
found = False
|
|
247
|
-
for display_name, cat_name, is_global in categories:
|
|
248
|
-
if cat_name == user_input or display_name == user_input:
|
|
249
|
-
selected_category = cat_name
|
|
250
|
-
selected_is_global = is_global
|
|
251
|
-
found = True
|
|
252
|
-
break
|
|
253
|
-
if not found:
|
|
254
|
-
print(f"✗ Category '{user_input}' not found")
|
|
255
|
-
return True
|
|
256
|
-
|
|
257
|
-
if selected_is_global:
|
|
258
|
-
dest_tm = TaskManager(
|
|
259
|
-
category=selected_category, is_global=True, project_registry=self.registry
|
|
260
|
-
)
|
|
261
|
-
else:
|
|
262
|
-
dest_tm = TaskManager(
|
|
263
|
-
directory=project_dir,
|
|
264
|
-
category=selected_category,
|
|
265
|
-
is_global=False,
|
|
266
|
-
project_registry=self.registry,
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
dest_tm.add_task(task_text)
|
|
270
|
-
if task_done:
|
|
271
|
-
new_task_id = dest_tm.tasks[-1]['id']
|
|
272
|
-
dest_tm.set_task_status(new_task_id, 'done')
|
|
273
|
-
|
|
274
|
-
self.task_manager.remove_task(task_id)
|
|
275
|
-
|
|
276
|
-
dest_name = selected_category or 'default'
|
|
277
|
-
if selected_is_global:
|
|
278
|
-
print(f"✓ Transferred task to global:{dest_name}")
|
|
279
|
-
else:
|
|
280
|
-
print(f"✓ Transferred task to {dest_name}")
|
|
281
|
-
|
|
282
|
-
print("\nPress Enter to continue...", end='', flush=True)
|
|
283
|
-
input()
|
|
224
|
+
items.append(PickerItem(
|
|
225
|
+
value=(cat_name, is_global),
|
|
226
|
+
label=cat_name,
|
|
227
|
+
annotation=scope,
|
|
228
|
+
color=cat_config.get_color(cat_name, for_global=is_global),
|
|
229
|
+
))
|
|
230
|
+
|
|
231
|
+
result = quick_picker(items, prompt=f"Transfer '{task_text}' to")
|
|
232
|
+
if result.value is None:
|
|
284
233
|
return True
|
|
285
234
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
235
|
+
selected_category, selected_is_global = result.value
|
|
236
|
+
|
|
237
|
+
if selected_is_global:
|
|
238
|
+
dest_tm = TaskManager(
|
|
239
|
+
category=selected_category, is_global=True, project_registry=self.registry
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
dest_tm = TaskManager(
|
|
243
|
+
directory=project_dir,
|
|
244
|
+
category=selected_category,
|
|
245
|
+
is_global=False,
|
|
246
|
+
project_registry=self.registry,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
dest_tm.add_task(task_text)
|
|
250
|
+
if task_done:
|
|
251
|
+
new_task_id = dest_tm.tasks[-1]['id']
|
|
252
|
+
dest_tm.set_task_status(new_task_id, 'done')
|
|
253
|
+
|
|
254
|
+
self.task_manager.remove_task(task_id)
|
|
255
|
+
|
|
256
|
+
dest_name = selected_category or 'default'
|
|
257
|
+
if selected_is_global:
|
|
258
|
+
print(f"✓ Transferred task to global:{dest_name}")
|
|
259
|
+
else:
|
|
260
|
+
print(f"✓ Transferred task to {dest_name}")
|
|
261
|
+
|
|
262
|
+
print("\nPress Enter to continue...", end='', flush=True)
|
|
263
|
+
input()
|
|
264
|
+
return True
|
|
@@ -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,26 @@ class MetadataMixin:
|
|
|
61
61
|
self._save_tasks()
|
|
62
62
|
return True
|
|
63
63
|
|
|
64
|
+
def set_highlight(self, task_id=None, color=None):
|
|
65
|
+
"""Set highlight color on a task, or clear it.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
task_id: Task to highlight (defaults to current task).
|
|
69
|
+
color: Color name from HIGHLIGHT_COLORS, or None to clear.
|
|
70
|
+
"""
|
|
71
|
+
if task_id is None:
|
|
72
|
+
current = self.get_current_task()
|
|
73
|
+
if not current:
|
|
74
|
+
return False
|
|
75
|
+
task_id = current['id']
|
|
76
|
+
|
|
77
|
+
for task in self.tasks:
|
|
78
|
+
if task['id'] == task_id:
|
|
79
|
+
task['highlight'] = color if color else False
|
|
80
|
+
self._save_tasks()
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
64
84
|
def set_task_day(self, task_id, day):
|
|
65
85
|
"""Assign a day of the week to a task (Monday-Sunday or None)."""
|
|
66
86
|
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
|
+
elif not done:
|
|
105
|
+
new_id = self.add_task(
|
|
106
|
+
text,
|
|
107
|
+
status='done' if done else 'todo',
|
|
108
|
+
labels=[parent_label],
|
|
109
|
+
)
|
|
110
|
+
if nested_notes and isinstance(new_id, int):
|
|
111
|
+
self.set_task_notes(new_id, nested_notes)
|
|
112
|
+
created += 1
|
|
113
|
+
|
|
114
|
+
if updated:
|
|
115
|
+
self._save_tasks()
|
|
116
|
+
|
|
117
|
+
return {'created': created, 'updated': updated, 'total': len(checklist_items)}
|
|
@@ -5,7 +5,8 @@ from datetime import datetime, timedelta
|
|
|
5
5
|
from jot.utils.date_utils import round_to_15_minutes
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def create_gcal_event(service, task_text, start_time=None, duration_minutes=15
|
|
8
|
+
def create_gcal_event(service, task_text, start_time=None, duration_minutes=15,
|
|
9
|
+
description=None):
|
|
9
10
|
"""Create a Google Calendar event from task text
|
|
10
11
|
|
|
11
12
|
Args:
|
|
@@ -13,6 +14,7 @@ def create_gcal_event(service, task_text, start_time=None, duration_minutes=15):
|
|
|
13
14
|
task_text: Task text to use as event summary
|
|
14
15
|
start_time: Optional timezone-aware datetime for event start (default: now rounded to 15 min)
|
|
15
16
|
duration_minutes: Event duration in minutes (default: 15)
|
|
17
|
+
description: Optional event description (default: 'Created from jot task')
|
|
16
18
|
|
|
17
19
|
Returns:
|
|
18
20
|
Event link (htmlLink) or None if failed
|
|
@@ -34,7 +36,7 @@ def create_gcal_event(service, task_text, start_time=None, duration_minutes=15):
|
|
|
34
36
|
'end': {
|
|
35
37
|
'dateTime': end_time.isoformat(),
|
|
36
38
|
},
|
|
37
|
-
'description': 'Created from jot task',
|
|
39
|
+
'description': description if description else 'Created from jot task',
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
# Insert event
|
|
@@ -62,7 +62,9 @@ class HandlersMixin:
|
|
|
62
62
|
return "No Google Calendar accounts configured (use Shift+G to set up)"
|
|
63
63
|
auth = GoogleCalendarAuth(accounts[0])
|
|
64
64
|
service = auth.get_service()
|
|
65
|
-
|
|
65
|
+
notes = task.get('notes', '').strip()
|
|
66
|
+
create_gcal_event(service, task['text'],
|
|
67
|
+
description=notes or None)
|
|
66
68
|
return f"Created Google Calendar event ({accounts[0]})"
|
|
67
69
|
except FileNotFoundError:
|
|
68
70
|
return "Google Calendar credentials not found (use Shift+G to set up)"
|
|
@@ -13,9 +13,12 @@ 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, BG_ORANGE, BG_MAGENTA, BG_GRAY,
|
|
20
|
+
HIGHLIGHT_COLORS, HIGHLIGHT_DEFAULT, HIGHLIGHT_FMT,
|
|
21
|
+
get_terminal_width, strip_ansi, visible_len,
|
|
19
22
|
)
|
|
20
23
|
|
|
21
24
|
__all__ = [
|
|
@@ -30,9 +33,11 @@ __all__ = [
|
|
|
30
33
|
'format_inline_notes',
|
|
31
34
|
# Style constants
|
|
32
35
|
'RESET', 'BOLD', 'DIM', 'REVERSE',
|
|
33
|
-
'RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE', 'GRAY', 'ORANGE',
|
|
36
|
+
'RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE', 'GRAY', 'ORANGE', 'BLACK',
|
|
34
37
|
'PRIORITY_COLORS', 'PRIORITY_SYMBOLS', 'STATUS_COLORS', 'STATUS_SYMBOLS',
|
|
35
38
|
'ARCHIVE_SYMBOL', 'ARCHIVE_COLOR',
|
|
39
|
+
'BG_YELLOW', 'HIGHLIGHT_COLORS', 'HIGHLIGHT_DEFAULT', 'HIGHLIGHT_FMT',
|
|
40
|
+
'get_terminal_width', 'strip_ansi', 'visible_len',
|
|
36
41
|
# Picker
|
|
37
42
|
'PickerItem', 'PickerResult', 'fuzzy_picker', 'quick_picker',
|
|
38
43
|
]
|
|
@@ -5,6 +5,7 @@ from jot.categories.manager import CategoryManager
|
|
|
5
5
|
from jot.categories.config import CategoryConfig
|
|
6
6
|
from jot.ui.styles import (
|
|
7
7
|
RESET, BOLD, DIM, CYAN, YELLOW,
|
|
8
|
+
get_terminal_width,
|
|
8
9
|
)
|
|
9
10
|
|
|
10
11
|
|
|
@@ -15,7 +16,7 @@ def display_archive(project_dir, filter_category=None):
|
|
|
15
16
|
project_dir, cat_manager, filter_category)
|
|
16
17
|
|
|
17
18
|
print(f"\n{BOLD}Archived Tasks{RESET}")
|
|
18
|
-
print("=" *
|
|
19
|
+
print("=" * get_terminal_width())
|
|
19
20
|
|
|
20
21
|
total_archived = 0
|
|
21
22
|
for cat_name, tm in managers_to_check:
|
|
@@ -32,7 +33,7 @@ def display_archive(project_dir, filter_category=None):
|
|
|
32
33
|
|
|
33
34
|
if total_archived == 0:
|
|
34
35
|
print(f"\n{DIM}No archived tasks{RESET}")
|
|
35
|
-
print("=" *
|
|
36
|
+
print("=" * get_terminal_width() + "\n")
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def _build_archive_managers(project_dir, cat_manager, filter_category):
|
|
@@ -74,7 +75,7 @@ def display_all_categories_view(
|
|
|
74
75
|
show_archived: Whether to include archived tasks in the view.
|
|
75
76
|
"""
|
|
76
77
|
print(f"\n{CYAN}{BOLD}ALL CATEGORIES VIEW{RESET}")
|
|
77
|
-
print("-" *
|
|
78
|
+
print("-" * get_terminal_width())
|
|
78
79
|
|
|
79
80
|
cat_config = CategoryConfig(project_dir=project_dir)
|
|
80
81
|
task_num = 1
|
|
@@ -110,7 +111,7 @@ def display_all_categories_view(
|
|
|
110
111
|
f"\n{DIM}Total: {total_tasks} tasks across "
|
|
111
112
|
f"{len(all_cat_data)} categories{RESET}"
|
|
112
113
|
)
|
|
113
|
-
print("-" *
|
|
114
|
+
print("-" * get_terminal_width())
|
|
114
115
|
print(
|
|
115
116
|
f"{CYAN}Press Shift+L or ESC to return to "
|
|
116
117
|
f"single category view{RESET}"
|
|
@@ -6,6 +6,7 @@ from jot.core.constants import (
|
|
|
6
6
|
)
|
|
7
7
|
from jot.ui.styles import (
|
|
8
8
|
RESET, BOLD, DIM, CYAN, GREEN, YELLOW,
|
|
9
|
+
get_terminal_width,
|
|
9
10
|
)
|
|
10
11
|
|
|
11
12
|
|
|
@@ -53,7 +54,7 @@ def _render_multiselect(selected_tasks):
|
|
|
53
54
|
f"{BOLD}Shift+↑/↓{RESET}: select while moving | "
|
|
54
55
|
f"{BOLD}A{RESET}: select all | {BOLD}N{RESET}: select none"
|
|
55
56
|
)
|
|
56
|
-
print("-" *
|
|
57
|
+
print("-" * get_terminal_width())
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
def _render_fuzzy_search(
|
|
@@ -88,7 +89,7 @@ def _render_fuzzy_search(
|
|
|
88
89
|
f"{BOLD}Ctrl+A{RESET}: toggle archives | "
|
|
89
90
|
f"{BOLD}ESC{RESET}: cancel"
|
|
90
91
|
)
|
|
91
|
-
print("-" *
|
|
92
|
+
print("-" * get_terminal_width())
|
|
92
93
|
|
|
93
94
|
|
|
94
95
|
def _render_command():
|
|
@@ -104,7 +105,7 @@ def _render_command():
|
|
|
104
105
|
f"{BOLD}(M){RESET}ove | {BOLD}(r){RESET}efresh | "
|
|
105
106
|
f"{BOLD}(h){RESET}elp | {BOLD}(q){RESET}uit"
|
|
106
107
|
)
|
|
107
|
-
print("-" *
|
|
108
|
+
print("-" * get_terminal_width())
|
|
108
109
|
print("Command: ", end='', flush=True)
|
|
109
110
|
|
|
110
111
|
|
|
@@ -130,4 +131,4 @@ def render_archived_section(archived_tasks):
|
|
|
130
131
|
f"{DIM} [{status}] {task['id']}. "
|
|
131
132
|
f"{day_prefix}{task['text']}{tally_suffix}{RESET}"
|
|
132
133
|
)
|
|
133
|
-
print(f"{DIM}-" *
|
|
134
|
+
print(f"{DIM}" + "-" * get_terminal_width() + RESET)
|