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 +0 -0
- td/__main__.py +160 -0
- td/db.py +452 -0
- td/terminal.py +148 -0
- td/tui.py +666 -0
- td_task-0.1.0.dist-info/METADATA +8 -0
- td_task-0.1.0.dist-info/RECORD +9 -0
- td_task-0.1.0.dist-info/WHEEL +4 -0
- td_task-0.1.0.dist-info/entry_points.txt +2 -0
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"]))
|