-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathon-exit_annn.py.orig
More file actions
executable file
·337 lines (266 loc) · 9.96 KB
/
on-exit_annn.py.orig
File metadata and controls
executable file
·337 lines (266 loc) · 9.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
#!/usr/bin/env python3
"""
on-exit_annn.py - Auto-annotation hook for Taskwarrior 2.6.2
Version: 0.5.2
When a task tagged with +ann (configurable) is completed or deleted,
opens $EDITOR for the user to write an annotation. The annotation is
saved to the task after the editor closes.
Configuration (in annn.rc):
annn.tag=ann Tag that triggers the hook (default: ann)
annn.on_complete=yes Prompt on task completion (default: yes)
annn.on_delete=yes Prompt on task deletion (default: yes)
annn.editor=vim Editor override (default: $EDITOR or vim)
Install:
cp on-exit_annn.py ~/.task/hooks/on-exit_annn.py
chmod +x ~/.task/hooks/on-exit_annn.py
echo 'include ~/.task/config/annn.rc' >> ~/.taskrc
"""
# ============================================================================
# DEBUG ENHANCED VERSION - Auto-generated by make-awesome.py
# ============================================================================
import os
import sys
from pathlib import Path
from datetime import datetime
import json
import re
import subprocess
import tempfile
# ============================================================================
# Enhanced Debug Infrastructure
# ============================================================================
# Check if running under tw --debug
tw_debug_level = os.environ.get('TW_DEBUG', '0')
try:
tw_debug_level = int(tw_debug_level)
except ValueError:
tw_debug_level = 0
# Determine log directory based on context
def get_log_dir():
"""Auto-detect dev vs production mode"""
cwd = Path.cwd()
# Dev mode: running from project directory (has .git)
if (cwd / '.git').exists():
log_dir = cwd / 'logs' / 'debug'
else:
# Production mode
log_dir = Path.home() / '.task' / 'logs' / 'debug'
log_dir.mkdir(parents=True, exist_ok=True)
return log_dir
# ============================================================================
# Timing support - set TW_TIMING=1 to enable; zero overhead otherwise
# ============================================================================
if os.environ.get('TW_TIMING'):
import time as _time_module
import atexit as _atexit
_t0 = _time_module.perf_counter()
def _report_timing():
elapsed = (_time_module.perf_counter() - _t0) * 1000
print(f"[timing] {os.path.basename(__file__)}: {elapsed:.1f}ms", file=sys.stderr)
_atexit.register(_report_timing)
# ============================================================================
# Original Code with Debug Enhancements
# ============================================================================
VERSION = "0.1.0"
ANNN_RC = os.path.expanduser("~/.task/config/annn.rc")
# Debug logging - set DEBUG_ANNN=1 to enable
DEBUG = os.environ.get("DEBUG_ANNN", "0") == "1"
LOG_FILE = os.path.expanduser("~/.task/logs/debug/annn_debug.log")
# Defaults (overridden by annn.rc)
DEFAULTS = {
"annn.tag": "ann",
"annn.on_complete": "yes",
"annn.on_delete": "yes",
"annn.editor": "",
}
# Config cache
_config = None
def debug_log(msg):
if not DEBUG:
return
try:
log_dir = os.path.dirname(LOG_FILE)
os.makedirs(log_dir, exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write("{} [annn-exit] {}\n".format(
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg))
except Exception:
pass
def get_config():
"""Load configuration from annn.rc with lazy caching."""
global _config
if _config is not None:
return _config
_config = dict(DEFAULTS)
if not os.path.exists(ANNN_RC):
debug_log("No annn.rc found, using defaults")
return _config
try:
with open(ANNN_RC, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
key = key.strip()
val = val.strip()
if key in _config:
_config[key] = val
except Exception as e:
debug_log("Error reading annn.rc: {}".format(e))
debug_log("Config loaded: {}".format(_config))
return _config
def get_editor():
"""Determine which editor to use."""
config = get_config()
editor = config.get("annn.editor", "")
if editor:
return editor
return os.environ.get("EDITOR", "vim")
def sanitize_for_filename(text):
"""Sanitize task description for use in temp filename."""
slug = text.lower()
slug = re.sub(r'[^a-z0-9]', '-', slug)
slug = re.sub(r'-+', '-', slug)
slug = slug.strip('-')
return slug[:40]
def prompt_annotation(task, event):
"""Open editor for annotation, return text or None."""
uuid = task.get("uuid", "unknown")
desc = task.get("description", "task")
task_id = task.get("id", 0)
slug = sanitize_for_filename(desc)
editor = get_editor()
# Create descriptive temp file
prefix = "annn_{}_{}_{}_".format(task_id, event, slug)
try:
fd, tmppath = tempfile.mkstemp(prefix=prefix, suffix=".md", dir="/tmp")
os.close(fd)
except Exception as e:
debug_log("Failed to create temp file: {}".format(e))
return None
try:
# Show context to the user
print("")
print("[annn] Task {}: {}".format(task_id, desc))
print("[annn] Event: {}".format(event))
print("[annn] Opening editor for annotation...")
print("")
# Open editor
result = subprocess.run([editor, tmppath])
if result.returncode != 0:
debug_log("Editor exited with code {}".format(result.returncode))
return None
# Read content
with open(tmppath, "r") as f:
text = f.read().strip()
if not text:
print("[annn] Empty annotation, skipping.")
return None
return text
except Exception as e:
debug_log("Error during editor prompt: {}".format(e))
return None
finally:
# Clean up temp file
try:
os.unlink(tmppath)
except Exception:
pass
def save_annotation(task, text):
"""Save annotation to task via task command."""
uuid = task.get("uuid")
if not uuid:
debug_log("No UUID for task, cannot annotate")
return False
try:
result = subprocess.run(
["task", "rc.hooks=off", "rc.confirmation=off", uuid, "annotate", text],
capture_output=True, text=True
)
if result.returncode == 0:
debug_log("Annotation saved to {}".format(uuid))
print("[annn] Annotation saved.")
return True
else:
debug_log("Annotate failed: {}".format(result.stderr))
print("[annn] Error saving annotation: {}".format(result.stderr.strip()))
return False
except Exception as e:
debug_log("Exception saving annotation: {}".format(e))
print("[annn] Error: {}".format(e))
return False
def should_trigger(task, config):
"""Check if this task should trigger the annotation prompt."""
tag = config.get("annn.tag", "ann")
tags = task.get("tags", [])
if tag not in tags:
return False, None
status = task.get("status", "")
if status == "completed" and config.get("annn.on_complete", "yes") == "yes":
return True, "completed"
if status == "deleted" and config.get("annn.on_delete", "yes") == "yes":
return True, "deleted"
return False, None
def main():
# on-exit: consume stdin, do NOT echo back
lines = sys.stdin.readlines()
config = get_config()
debug_log("Hook triggered, {} task(s) on stdin".format(len(lines)))
# Parse tasks from stdin
tasks = []
for line in lines:
line = line.strip()
if not line:
continue
try:
task = json.loads(line)
tasks.append(task)
except json.JSONDecodeError:
debug_log("Skipping non-JSON line: {}".format(line[:80]))
continue
# Check each task for trigger conditions
for task in tasks:
trigger, event = should_trigger(task, config)
if not trigger:
debug_log("Task {} ({}): no trigger".format(
task.get("id"), task.get("description", "")[:30]))
continue
debug_log("Task {} ({}): triggered by {}".format(
task.get("id"), task.get("description", "")[:30], event))
text = prompt_annotation(task, event)
if text:
save_annotation(task, text)
sys.exit(0)
if __name__ == "__main__":
main()
# ============================================================================
# Enhanced Debug Logging Wrapper
# ============================================================================
_original_debug_log = debug_log
if None or tw_debug_level >= 2:
DEBUG_LOG_DIR = get_log_dir()
DEBUG_SESSION_ID = datetime.now().strftime("%Y%m%d_%H%M%S")
try:
script_name = Path(__file__).stem
except:
script_name = Path(sys.argv[0]).stem if sys.argv else "script"
DEBUG_LOG_FILE = DEBUG_LOG_DIR / f"{script_name}_debug_{DEBUG_SESSION_ID}.log"
def debug_log(msg):
"""Enhanced debug with file logging"""
if None or tw_debug_level >= 2:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log_line = f"{timestamp} [DEBUG] {msg}\n"
with open(DEBUG_LOG_FILE, "a") as f:
f.write(log_line)
_original_debug_log(msg)
with open(DEBUG_LOG_FILE, "w") as f:
f.write("=" * 70 + "\n")
f.write(f"Debug Session - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"Script: {script_name}\n")
f.write(f"None: {None}\n")
f.write(f"TW_DEBUG Level: {tw_debug_level}\n")
f.write(f"Session ID: {DEBUG_SESSION_ID}\n")
f.write("=" * 70 + "\n\n")
debug_log("Debug logging initialized")