td-task 0.1.0__py3-none-any.whl

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.
td/__init__.py ADDED
File without changes
td/__main__.py ADDED
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import os
5
+
6
+ from .tui import run_main, run_archive, run_settings
7
+
8
+
9
+ def main() -> None:
10
+ if "--dev" in sys.argv:
11
+ _run_dev()
12
+ elif len(sys.argv) > 1 and sys.argv[1] == "archive":
13
+ run_archive()
14
+ elif len(sys.argv) > 1 and sys.argv[1] == "update":
15
+ _run_update()
16
+ elif len(sys.argv) > 1 and sys.argv[1] == "add":
17
+ _run_add()
18
+ elif len(sys.argv) > 1 and sys.argv[1] == "list":
19
+ _run_list()
20
+ else:
21
+ run_main()
22
+
23
+
24
+ def _cli_ensure_unlocked() -> None:
25
+ from . import db
26
+ if db.is_encryption_enabled():
27
+ import getpass
28
+ attempts = 0
29
+ while attempts < 3:
30
+ prompt_text = "Database is encrypted. Enter password: " if attempts == 0 else f"Incorrect password. Try again: "
31
+ try:
32
+ password = getpass.getpass(prompt_text)
33
+ except (KeyboardInterrupt, EOFError):
34
+ print("\nCancelled.")
35
+ sys.exit(0)
36
+ if db.set_encryption_key_from_password(password):
37
+ return
38
+ attempts += 1
39
+ print("✗ Too many incorrect attempts. Exiting.")
40
+ sys.exit(1)
41
+
42
+
43
+ def _run_add() -> None:
44
+ if len(sys.argv) < 3 or not sys.argv[2].strip():
45
+ print("Usage: td add <task_text>")
46
+ sys.exit(1)
47
+
48
+ task_text = sys.argv[2].strip()
49
+ _cli_ensure_unlocked()
50
+
51
+ from . import db
52
+ result = db.add_task(task_text)
53
+ if result is None:
54
+ print("✗ Failed to add task (maximum active tasks reached).")
55
+ sys.exit(1)
56
+ print(f"✓ Task added successfully (ID: {result['id']})")
57
+
58
+
59
+ def _run_list() -> None:
60
+ _cli_ensure_unlocked()
61
+ from . import db
62
+ from rich.console import Console
63
+ from rich.text import Text
64
+
65
+ tasks = db.get_active_tasks()
66
+ console = Console()
67
+
68
+ open_count = sum(1 for t in tasks if t["status"] == "active")
69
+ completed_count = db.get_completed_count()
70
+ header = Text("td • ", style="bold")
71
+ header.append(Text(f"{open_count} open", style="dim"))
72
+ header.append(Text(" / ", style="dim"))
73
+ header.append(Text(f"{completed_count} completed", style="dim"))
74
+ console.print(header)
75
+
76
+ console.print(Text("─" * 40, style="dim"))
77
+
78
+ if not tasks:
79
+ console.print(Text(" No tasks found.", style="dim"))
80
+ return
81
+
82
+ for i, task in enumerate(tasks, 1):
83
+ is_done = task["status"] == "done"
84
+ marker = "✓" if is_done else "○"
85
+
86
+ text = task["text"]
87
+ if is_done:
88
+ line_text = Text(text, style="strike dim")
89
+ marker_text = Text(marker, style="green bold")
90
+ else:
91
+ line_text = Text(text)
92
+ marker_text = Text(marker, style="yellow")
93
+
94
+ line = Text(" ")
95
+ line.append(marker_text)
96
+ line.append(" ")
97
+ line.append(line_text)
98
+ console.print(line)
99
+
100
+
101
+ def _run_update() -> None:
102
+ """Update td to the latest version from PyPI."""
103
+ import subprocess
104
+ print("Updating td...")
105
+ result = subprocess.run(
106
+ ["uv", "tool", "upgrade", "td-task"],
107
+ capture_output=True, text=True, timeout=60,
108
+ )
109
+ if result.returncode == 0:
110
+ print("✓ td updated successfully")
111
+ else:
112
+ print(f"✗ update failed: {result.stderr.strip()}")
113
+ sys.exit(1)
114
+
115
+
116
+ def _run_dev() -> None:
117
+ """Watch src/td/ for changes and restart the TUI automatically."""
118
+ from watchdog.observers import Observer
119
+ from watchdog.events import FileSystemEventHandler
120
+
121
+ import subprocess
122
+ import time
123
+
124
+ src_dir = os.path.dirname(__file__)
125
+
126
+ class RestartHandler(FileSystemEventHandler):
127
+ def __init__(self):
128
+ self.changed = False
129
+
130
+ def on_modified(self, event):
131
+ if event.src_path.endswith(".py"):
132
+ self.changed = True
133
+
134
+ def on_created(self, event):
135
+ if event.src_path.endswith(".py"):
136
+ self.changed = True
137
+
138
+ observer = Observer()
139
+ handler = RestartHandler()
140
+ observer.schedule(handler, src_dir, recursive=True)
141
+ observer.start()
142
+
143
+ print("td --dev: watching for changes... (Ctrl+C to stop)")
144
+ subprocess.run(["uv", "run", "td"])
145
+ try:
146
+ while True:
147
+ time.sleep(0.5)
148
+ if handler.changed:
149
+ handler.changed = False
150
+ print("\n⟳ Change detected, restarting...\n")
151
+ subprocess.run(["uv", "run", "td"])
152
+ except KeyboardInterrupt:
153
+ pass
154
+ finally:
155
+ observer.stop()
156
+ observer.join()
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
td/db.py ADDED
@@ -0,0 +1,452 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ DB_PATH = Path.home() / ".td.db"
8
+
9
+ DEFAULT_MAX_TASKS = 15
10
+
11
+ SCHEMA = """
12
+ CREATE TABLE IF NOT EXISTS tasks (
13
+ id INTEGER PRIMARY KEY,
14
+ text TEXT NOT NULL,
15
+ status TEXT NOT NULL DEFAULT 'active',
16
+ position INTEGER NOT NULL,
17
+ created_at TEXT NOT NULL,
18
+ done_at TEXT,
19
+ archived_at TEXT
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS settings (
23
+ key TEXT PRIMARY KEY,
24
+ value TEXT NOT NULL
25
+ );
26
+ """
27
+
28
+
29
+ def _now_iso() -> str:
30
+ return datetime.now(timezone.utc).isoformat()
31
+
32
+
33
+ ENCRYPTION_KEY: bytes | None = None
34
+
35
+
36
+ def is_encryption_enabled() -> bool:
37
+ conn = _connect()
38
+ try:
39
+ row = conn.execute("SELECT value FROM settings WHERE key = 'encryption_enabled'").fetchone()
40
+ return row is not None and row["value"] == "1"
41
+ finally:
42
+ conn.close()
43
+
44
+
45
+ def get_encryption_salt() -> bytes | None:
46
+ conn = _connect()
47
+ try:
48
+ row = conn.execute("SELECT value FROM settings WHERE key = 'encryption_salt'").fetchone()
49
+ if row:
50
+ import base64
51
+ return base64.b64decode(row["value"])
52
+ return None
53
+ finally:
54
+ conn.close()
55
+
56
+
57
+ def _derive_key(password: str, salt: bytes) -> bytes:
58
+ import base64
59
+ from cryptography.hazmat.primitives import hashes
60
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
61
+ kdf = PBKDF2HMAC(
62
+ algorithm=hashes.SHA256(),
63
+ length=32,
64
+ salt=salt,
65
+ iterations=100_000,
66
+ )
67
+ return base64.urlsafe_b64encode(kdf.derive(password.encode()))
68
+
69
+
70
+ def _verify_key(key: bytes) -> bool:
71
+ conn = _connect()
72
+ try:
73
+ row = conn.execute("SELECT value FROM settings WHERE key = 'password_verifier'").fetchone()
74
+ if not row:
75
+ return False
76
+ from cryptography.fernet import Fernet
77
+ f = Fernet(key)
78
+ decrypted = f.decrypt(row["value"].encode()).decode()
79
+ return decrypted == "verification_token"
80
+ except Exception:
81
+ return False
82
+ finally:
83
+ conn.close()
84
+
85
+
86
+ def set_encryption_key_from_password(password: str) -> bool:
87
+ global ENCRYPTION_KEY
88
+ salt = get_encryption_salt()
89
+ if salt is None:
90
+ return False
91
+ key = _derive_key(password, salt)
92
+ if _verify_key(key):
93
+ ENCRYPTION_KEY = key
94
+ return True
95
+ return False
96
+
97
+
98
+ def _encrypt(text: str) -> str:
99
+ if not text:
100
+ return ""
101
+ if ENCRYPTION_KEY is None:
102
+ raise ValueError("Database is encrypted but key is not loaded")
103
+ from cryptography.fernet import Fernet
104
+ f = Fernet(ENCRYPTION_KEY)
105
+ return f.encrypt(text.encode()).decode()
106
+
107
+
108
+ def _decrypt(ciphertext: str) -> str:
109
+ if not ciphertext:
110
+ return ""
111
+ if ENCRYPTION_KEY is None:
112
+ raise ValueError("Database is encrypted but key is not loaded")
113
+ from cryptography.fernet import Fernet
114
+ f = Fernet(ENCRYPTION_KEY)
115
+ try:
116
+ return f.decrypt(ciphertext.encode()).decode()
117
+ except Exception:
118
+ return "[Decryption Failed]"
119
+
120
+
121
+ def enable_encryption(password: str) -> None:
122
+ import secrets
123
+ import base64
124
+ from cryptography.fernet import Fernet
125
+
126
+ salt = secrets.token_bytes(16)
127
+ key = _derive_key(password, salt)
128
+
129
+ conn = _connect()
130
+ try:
131
+ f = Fernet(key)
132
+ verifier = f.encrypt(b"verification_token").decode()
133
+
134
+ conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', '1')")
135
+ conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_salt', ?)", (base64.b64encode(salt).decode(),))
136
+ conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_verifier', ?)", (verifier,))
137
+
138
+ rows = conn.execute("SELECT id, text FROM tasks").fetchall()
139
+ for row in rows:
140
+ enc_text = f.encrypt(row["text"].encode()).decode()
141
+ conn.execute("UPDATE tasks SET text = ? WHERE id = ?", (enc_text, row["id"]))
142
+
143
+ conn.commit()
144
+ global ENCRYPTION_KEY
145
+ ENCRYPTION_KEY = key
146
+ finally:
147
+ conn.close()
148
+
149
+
150
+ def disable_encryption(password: str) -> bool:
151
+ global ENCRYPTION_KEY
152
+ salt = get_encryption_salt()
153
+ if salt is None:
154
+ return False
155
+ key = _derive_key(password, salt)
156
+ if not _verify_key(key):
157
+ return False
158
+
159
+ from cryptography.fernet import Fernet
160
+ f = Fernet(key)
161
+
162
+ conn = _connect()
163
+ try:
164
+ rows = conn.execute("SELECT id, text FROM tasks").fetchall()
165
+ for row in rows:
166
+ dec_text = f.decrypt(row["text"].encode()).decode()
167
+ conn.execute("UPDATE tasks SET text = ? WHERE id = ?", (dec_text, row["id"]))
168
+
169
+ conn.execute("DELETE FROM settings WHERE key IN ('encryption_enabled', 'encryption_salt', 'password_verifier')")
170
+ conn.commit()
171
+ ENCRYPTION_KEY = None
172
+ return True
173
+ finally:
174
+ conn.close()
175
+
176
+
177
+ def _connect() -> sqlite3.Connection:
178
+ conn = sqlite3.connect(str(DB_PATH))
179
+ conn.row_factory = sqlite3.Row
180
+ conn.execute("PRAGMA journal_mode=WAL")
181
+ conn.execute("PRAGMA foreign_keys=ON")
182
+ conn.executescript(SCHEMA)
183
+ return conn
184
+
185
+
186
+ def get_max_tasks() -> int:
187
+ conn = _connect()
188
+ try:
189
+ row = conn.execute("SELECT value FROM settings WHERE key = 'max_tasks'").fetchone()
190
+ if row:
191
+ return int(row["value"])
192
+ return DEFAULT_MAX_TASKS
193
+ finally:
194
+ conn.close()
195
+
196
+
197
+ def set_max_tasks(value: int) -> None:
198
+ conn = _connect()
199
+ try:
200
+ conn.execute(
201
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('max_tasks', ?)",
202
+ (str(value),),
203
+ )
204
+ conn.commit()
205
+ finally:
206
+ conn.close()
207
+
208
+
209
+ def get_active_tasks() -> list[dict]:
210
+ conn = _connect()
211
+ try:
212
+ rows = conn.execute(
213
+ "SELECT id, text, status, position FROM tasks "
214
+ "WHERE status != 'archived' ORDER BY position"
215
+ ).fetchall()
216
+ tasks = []
217
+ for r in rows:
218
+ d = dict(r)
219
+ if is_encryption_enabled():
220
+ d["text"] = _decrypt(d["text"])
221
+ tasks.append(d)
222
+ return tasks
223
+ finally:
224
+ conn.close()
225
+
226
+
227
+ def get_archived_tasks() -> list[dict]:
228
+ conn = _connect()
229
+ try:
230
+ rows = conn.execute(
231
+ "SELECT id, text, created_at, done_at, archived_at FROM tasks "
232
+ "WHERE status = 'archived' ORDER BY archived_at DESC"
233
+ ).fetchall()
234
+ tasks = []
235
+ for r in rows:
236
+ d = dict(r)
237
+ if is_encryption_enabled():
238
+ d["text"] = _decrypt(d["text"])
239
+ tasks.append(d)
240
+ return tasks
241
+ finally:
242
+ conn.close()
243
+
244
+
245
+ def add_task(text: str) -> dict | None:
246
+ conn = _connect()
247
+ try:
248
+ count = conn.execute(
249
+ "SELECT COUNT(*) FROM tasks WHERE status != 'archived'"
250
+ ).fetchone()[0]
251
+ if count >= get_max_tasks():
252
+ return None
253
+ max_pos = conn.execute(
254
+ "SELECT COALESCE(MAX(position), -1) FROM tasks WHERE status != 'archived'"
255
+ ).fetchone()[0]
256
+ now = _now_iso()
257
+ db_text = _encrypt(text) if is_encryption_enabled() else text
258
+ cursor = conn.execute(
259
+ "INSERT INTO tasks (text, position, created_at) VALUES (?, ?, ?)",
260
+ (db_text, max_pos + 1, now),
261
+ )
262
+ conn.commit()
263
+ return {"id": cursor.lastrowid, "text": text, "status": "active", "position": max_pos + 1}
264
+ finally:
265
+ conn.close()
266
+
267
+
268
+ def update_task_text(task_id: int, text: str) -> None:
269
+ conn = _connect()
270
+ try:
271
+ db_text = _encrypt(text) if is_encryption_enabled() else text
272
+ conn.execute("UPDATE tasks SET text = ? WHERE id = ?", (db_text, task_id))
273
+ conn.commit()
274
+ finally:
275
+ conn.close()
276
+
277
+
278
+ def toggle_done(task_id: int) -> None:
279
+ conn = _connect()
280
+ try:
281
+ row = conn.execute("SELECT status FROM tasks WHERE id = ?", (task_id,)).fetchone()
282
+ if row is None:
283
+ return
284
+ if row["status"] == "active":
285
+ max_pos = conn.execute(
286
+ "SELECT COALESCE(MAX(position), -1) FROM tasks WHERE status != 'archived'"
287
+ ).fetchone()[0]
288
+ conn.execute(
289
+ "UPDATE tasks SET status = 'done', done_at = ?, position = ? WHERE id = ?",
290
+ (_now_iso(), max_pos + 1, task_id),
291
+ )
292
+ _reorder_positions(conn)
293
+ elif row["status"] == "done":
294
+ conn.execute(
295
+ "UPDATE tasks SET status = 'active', done_at = NULL WHERE id = ?",
296
+ (task_id,),
297
+ )
298
+ conn.commit()
299
+ finally:
300
+ conn.close()
301
+
302
+
303
+ def delete_task(task_id: int) -> None:
304
+ conn = _connect()
305
+ try:
306
+ conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
307
+ _reorder_positions(conn)
308
+ conn.commit()
309
+ finally:
310
+ conn.close()
311
+
312
+
313
+ def archive_done() -> int:
314
+ conn = _connect()
315
+ try:
316
+ now = _now_iso()
317
+ count = conn.execute(
318
+ "SELECT COUNT(*) FROM tasks WHERE status = 'done'"
319
+ ).fetchone()[0]
320
+ conn.execute(
321
+ "UPDATE tasks SET status = 'archived', archived_at = ? WHERE status = 'done'",
322
+ (now,),
323
+ )
324
+ _reorder_positions(conn)
325
+ conn.commit()
326
+ return count
327
+ finally:
328
+ conn.close()
329
+
330
+
331
+ def move_task(task_id: int, direction: int) -> None:
332
+ """Move a task up (direction=-1) or down (direction=+1) in position."""
333
+ conn = _connect()
334
+ try:
335
+ tasks = conn.execute(
336
+ "SELECT id, position FROM tasks WHERE status != 'archived' ORDER BY position"
337
+ ).fetchall()
338
+ task_map = {t["id"]: t["position"] for t in tasks}
339
+ if task_id not in task_map:
340
+ return
341
+ current_pos = task_map[task_id]
342
+ # Find the task at the target position
343
+ target_pos = current_pos + direction
344
+ other_id = None
345
+ for tid, pos in task_map.items():
346
+ if pos == target_pos:
347
+ other_id = tid
348
+ break
349
+ if other_id is None:
350
+ return
351
+ conn.execute("UPDATE tasks SET position = ? WHERE id = ?", (target_pos, task_id))
352
+ conn.execute("UPDATE tasks SET position = ? WHERE id = ?", (current_pos, other_id))
353
+ conn.commit()
354
+ finally:
355
+ conn.close()
356
+
357
+
358
+ def duplicate_task(task_id: int, direction: int) -> dict | None:
359
+ """Duplicate a task. direction=-1 inserts above, direction=+1 inserts below."""
360
+ conn = _connect()
361
+ try:
362
+ count = conn.execute(
363
+ "SELECT COUNT(*) FROM tasks WHERE status != 'archived'"
364
+ ).fetchone()[0]
365
+ if count >= get_max_tasks():
366
+ return None
367
+ row = conn.execute(
368
+ "SELECT text, status, position FROM tasks WHERE id = ?", (task_id,)
369
+ ).fetchone()
370
+ if row is None:
371
+ return None
372
+ # Shift positions to make room
373
+ target_pos = row["position"] + direction
374
+ if direction == -1:
375
+ # Inserting above: shift everything at target_pos and above up by 1
376
+ conn.execute(
377
+ "UPDATE tasks SET position = position + 1 WHERE position >= ? AND status != 'archived'",
378
+ (target_pos,),
379
+ )
380
+ else:
381
+ # Inserting below: shift everything after current position up by 1
382
+ conn.execute(
383
+ "UPDATE tasks SET position = position + 1 WHERE position > ? AND status != 'archived'",
384
+ (row["position"],),
385
+ )
386
+ now = _now_iso()
387
+ db_text = row["text"]
388
+ plaintext = _decrypt(db_text) if is_encryption_enabled() else db_text
389
+ cursor = conn.execute(
390
+ "INSERT INTO tasks (text, status, position, created_at) VALUES (?, ?, ?, ?)",
391
+ (db_text, row["status"], target_pos, now),
392
+ )
393
+ _reorder_positions(conn)
394
+ conn.commit()
395
+ return {"id": cursor.lastrowid, "text": plaintext, "status": row["status"], "position": target_pos}
396
+ finally:
397
+ conn.close()
398
+
399
+
400
+ def restore_task(task_id: int) -> bool:
401
+ """Restore an archived task to active. Returns False if no slots available."""
402
+ conn = _connect()
403
+ try:
404
+ count = conn.execute(
405
+ "SELECT COUNT(*) FROM tasks WHERE status != 'archived'"
406
+ ).fetchone()[0]
407
+ if count >= get_max_tasks():
408
+ return False
409
+ max_pos = conn.execute(
410
+ "SELECT COALESCE(MAX(position), -1) FROM tasks WHERE status != 'archived'"
411
+ ).fetchone()[0]
412
+ conn.execute(
413
+ "UPDATE tasks SET status = 'active', position = ?, done_at = NULL, archived_at = NULL WHERE id = ?",
414
+ (max_pos + 1, task_id),
415
+ )
416
+ conn.commit()
417
+ return True
418
+ finally:
419
+ conn.close()
420
+
421
+
422
+ def clear_archived() -> int:
423
+ """Delete all archived tasks. Returns count deleted."""
424
+ conn = _connect()
425
+ try:
426
+ count = conn.execute(
427
+ "SELECT COUNT(*) FROM tasks WHERE status = 'archived'"
428
+ ).fetchone()[0]
429
+ conn.execute("DELETE FROM tasks WHERE status = 'archived'")
430
+ conn.commit()
431
+ return count
432
+ finally:
433
+ conn.close()
434
+
435
+
436
+ def get_completed_count() -> int:
437
+ conn = _connect()
438
+ try:
439
+ row = conn.execute(
440
+ "SELECT COUNT(*) FROM tasks WHERE status IN ('done', 'archived')"
441
+ ).fetchone()
442
+ return row[0]
443
+ finally:
444
+ conn.close()
445
+
446
+
447
+ def _reorder_positions(conn: sqlite3.Connection) -> None:
448
+ rows = conn.execute(
449
+ "SELECT id FROM tasks WHERE status != 'archived' ORDER BY position"
450
+ ).fetchall()
451
+ for idx, row in enumerate(rows):
452
+ conn.execute("UPDATE tasks SET position = ? WHERE id = ?", (idx, row["id"]))