taskflow-agent 0.3.0__tar.gz → 0.5.0__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.
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/PKG-INFO +1 -1
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/pyproject.toml +1 -1
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/db.py +382 -14
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/server.py +66 -15
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/web.py +500 -147
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/static/index.html +338 -27
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/.gitignore +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/LICENSE +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/Makefile +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/README.md +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/__init__.py +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/importer.py +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/models.py +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/repos.py +0 -0
- {taskflow_agent-0.3.0 → taskflow_agent-0.5.0}/src/workflows.py +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import re
|
|
6
7
|
import sqlite3
|
|
7
8
|
from pathlib import Path
|
|
@@ -138,6 +139,17 @@ CREATE TABLE IF NOT EXISTS daily_focus (
|
|
|
138
139
|
);
|
|
139
140
|
CREATE INDEX IF NOT EXISTS idx_daily_focus_date ON daily_focus(focus_date);
|
|
140
141
|
CREATE INDEX IF NOT EXISTS idx_daily_focus_task ON daily_focus(task_id);
|
|
142
|
+
|
|
143
|
+
CREATE TABLE IF NOT EXISTS deleted_items (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'section', 'goal')),
|
|
146
|
+
entity_id INTEGER NOT NULL,
|
|
147
|
+
entity_name TEXT NOT NULL DEFAULT '',
|
|
148
|
+
snapshot TEXT NOT NULL,
|
|
149
|
+
deleted_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
150
|
+
);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS idx_deleted_items_deleted_at
|
|
152
|
+
ON deleted_items(deleted_at DESC);
|
|
141
153
|
"""
|
|
142
154
|
|
|
143
155
|
|
|
@@ -576,12 +588,21 @@ def move_section(conn: sqlite3.Connection, section_id: int, new_position: int) -
|
|
|
576
588
|
|
|
577
589
|
def delete_section(conn: sqlite3.Connection, section_id: int) -> bool:
|
|
578
590
|
"""Delete a section. Reassigns its tasks to section_id=NULL (Ungrouped)."""
|
|
579
|
-
conn.execute(
|
|
580
|
-
|
|
581
|
-
(section_id
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
591
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
592
|
+
try:
|
|
593
|
+
snap = _snapshot_section(conn, section_id)
|
|
594
|
+
if snap:
|
|
595
|
+
_save_deleted_snapshot(conn, "section", section_id, snap["section"]["name"], snap)
|
|
596
|
+
conn.execute(
|
|
597
|
+
"UPDATE tasks SET section_id = NULL, last_modified = datetime('now') WHERE section_id = ?",
|
|
598
|
+
(section_id,),
|
|
599
|
+
)
|
|
600
|
+
cur = conn.execute("DELETE FROM sections WHERE id = ?", (section_id,))
|
|
601
|
+
conn.commit()
|
|
602
|
+
except Exception:
|
|
603
|
+
conn.rollback()
|
|
604
|
+
raise
|
|
605
|
+
_purge_deleted_items(conn)
|
|
585
606
|
return cur.rowcount > 0
|
|
586
607
|
|
|
587
608
|
|
|
@@ -760,12 +781,20 @@ def move_task(conn: sqlite3.Connection, task_id: int, project_id: int | None = N
|
|
|
760
781
|
|
|
761
782
|
|
|
762
783
|
def delete_task(conn: sqlite3.Connection, task_id: int) -> bool:
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
784
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
785
|
+
try:
|
|
786
|
+
snap = _snapshot_task(conn, task_id)
|
|
787
|
+
if snap:
|
|
788
|
+
_save_deleted_snapshot(conn, "task", task_id, snap["task"]["name"], snap)
|
|
789
|
+
conn.execute("DELETE FROM task_tags WHERE task_id IN (SELECT id FROM tasks WHERE parent_task_id = ?)", (task_id,))
|
|
790
|
+
conn.execute("DELETE FROM tasks WHERE parent_task_id = ?", (task_id,))
|
|
791
|
+
conn.execute("DELETE FROM task_tags WHERE task_id = ?", (task_id,))
|
|
792
|
+
cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
|
793
|
+
conn.commit()
|
|
794
|
+
except Exception:
|
|
795
|
+
conn.rollback()
|
|
796
|
+
raise
|
|
797
|
+
_purge_deleted_items(conn)
|
|
769
798
|
return cur.rowcount > 0
|
|
770
799
|
|
|
771
800
|
|
|
@@ -950,11 +979,350 @@ def reopen_goal(conn: sqlite3.Connection, goal_id: int) -> bool:
|
|
|
950
979
|
|
|
951
980
|
|
|
952
981
|
def delete_goal(conn: sqlite3.Connection, goal_id: int) -> bool:
|
|
953
|
-
|
|
954
|
-
|
|
982
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
983
|
+
try:
|
|
984
|
+
snap = _snapshot_goal(conn, goal_id)
|
|
985
|
+
if snap:
|
|
986
|
+
_save_deleted_snapshot(conn, "goal", goal_id, snap["goal"]["text"], snap)
|
|
987
|
+
cur = conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,))
|
|
988
|
+
conn.commit()
|
|
989
|
+
except Exception:
|
|
990
|
+
conn.rollback()
|
|
991
|
+
raise
|
|
992
|
+
_purge_deleted_items(conn)
|
|
955
993
|
return cur.rowcount > 0
|
|
956
994
|
|
|
957
995
|
|
|
996
|
+
# ---------------------------------------------------------------------------
|
|
997
|
+
# Deleted Items (Undo Support)
|
|
998
|
+
# ---------------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
def _snapshot_task(conn: sqlite3.Connection, task_id: int) -> dict[str, Any] | None:
|
|
1001
|
+
"""Build a restorable snapshot for a task + subtasks + tags + focus entries."""
|
|
1002
|
+
row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
|
1003
|
+
if not row:
|
|
1004
|
+
return None
|
|
1005
|
+
task = dict(row)
|
|
1006
|
+
tags = [
|
|
1007
|
+
r["name"]
|
|
1008
|
+
for r in conn.execute(
|
|
1009
|
+
"""
|
|
1010
|
+
SELECT tg.name
|
|
1011
|
+
FROM tags tg
|
|
1012
|
+
JOIN task_tags tt ON tt.tag_id = tg.id
|
|
1013
|
+
WHERE tt.task_id = ?
|
|
1014
|
+
""",
|
|
1015
|
+
(task_id,),
|
|
1016
|
+
).fetchall()
|
|
1017
|
+
]
|
|
1018
|
+
focus_entries = [
|
|
1019
|
+
{"focus_date": r["focus_date"], "position": r["position"], "added_at": r["added_at"]}
|
|
1020
|
+
for r in conn.execute(
|
|
1021
|
+
"SELECT focus_date, position, added_at FROM daily_focus WHERE task_id = ?",
|
|
1022
|
+
(task_id,),
|
|
1023
|
+
).fetchall()
|
|
1024
|
+
]
|
|
1025
|
+
subtasks = []
|
|
1026
|
+
for sub in conn.execute("SELECT * FROM tasks WHERE parent_task_id = ?", (task_id,)).fetchall():
|
|
1027
|
+
sub_tags = [
|
|
1028
|
+
r["name"]
|
|
1029
|
+
for r in conn.execute(
|
|
1030
|
+
"""
|
|
1031
|
+
SELECT tg.name
|
|
1032
|
+
FROM tags tg
|
|
1033
|
+
JOIN task_tags tt ON tt.tag_id = tg.id
|
|
1034
|
+
WHERE tt.task_id = ?
|
|
1035
|
+
""",
|
|
1036
|
+
(sub["id"],),
|
|
1037
|
+
).fetchall()
|
|
1038
|
+
]
|
|
1039
|
+
sub_focus = [
|
|
1040
|
+
{"focus_date": r["focus_date"], "position": r["position"], "added_at": r["added_at"]}
|
|
1041
|
+
for r in conn.execute(
|
|
1042
|
+
"SELECT focus_date, position, added_at FROM daily_focus WHERE task_id = ?",
|
|
1043
|
+
(sub["id"],),
|
|
1044
|
+
).fetchall()
|
|
1045
|
+
]
|
|
1046
|
+
subtasks.append({"task": dict(sub), "tags": sub_tags, "focus_entries": sub_focus})
|
|
1047
|
+
return {"task": task, "tags": tags, "focus_entries": focus_entries, "subtasks": subtasks}
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _snapshot_section(conn: sqlite3.Connection, section_id: int) -> dict[str, Any] | None:
|
|
1051
|
+
"""Build a restorable snapshot for a section + affected tasks with their last_modified."""
|
|
1052
|
+
row = conn.execute("SELECT * FROM sections WHERE id = ?", (section_id,)).fetchone()
|
|
1053
|
+
if not row:
|
|
1054
|
+
return None
|
|
1055
|
+
tasks = [
|
|
1056
|
+
{"id": r["id"], "last_modified": r["last_modified"]}
|
|
1057
|
+
for r in conn.execute(
|
|
1058
|
+
"SELECT id, last_modified FROM tasks WHERE section_id = ?",
|
|
1059
|
+
(section_id,),
|
|
1060
|
+
).fetchall()
|
|
1061
|
+
]
|
|
1062
|
+
return {"section": dict(row), "tasks": tasks}
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _snapshot_goal(conn: sqlite3.Connection, goal_id: int) -> dict[str, Any] | None:
|
|
1066
|
+
"""Build a restorable snapshot for a goal."""
|
|
1067
|
+
row = conn.execute("SELECT * FROM goals WHERE id = ?", (goal_id,)).fetchone()
|
|
1068
|
+
if not row:
|
|
1069
|
+
return None
|
|
1070
|
+
return {"goal": dict(row)}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _save_deleted_snapshot(
|
|
1074
|
+
conn: sqlite3.Connection,
|
|
1075
|
+
entity_type: str,
|
|
1076
|
+
entity_id: int,
|
|
1077
|
+
entity_name: str,
|
|
1078
|
+
snapshot: dict[str, Any],
|
|
1079
|
+
) -> None:
|
|
1080
|
+
"""Save a snapshot before deletion. Caller commits as part of the delete transaction."""
|
|
1081
|
+
conn.execute(
|
|
1082
|
+
"INSERT INTO deleted_items (entity_type, entity_id, entity_name, snapshot) VALUES (?, ?, ?, ?)",
|
|
1083
|
+
(entity_type, entity_id, entity_name, json.dumps(snapshot)),
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def list_deleted_items(
|
|
1088
|
+
conn: sqlite3.Connection,
|
|
1089
|
+
entity_type: str | None = None,
|
|
1090
|
+
limit: int = 20,
|
|
1091
|
+
) -> list[dict[str, Any]]:
|
|
1092
|
+
"""List recent deleted items, optionally filtered by type."""
|
|
1093
|
+
if entity_type:
|
|
1094
|
+
rows = conn.execute(
|
|
1095
|
+
"""
|
|
1096
|
+
SELECT id, entity_type, entity_id, entity_name, deleted_at
|
|
1097
|
+
FROM deleted_items
|
|
1098
|
+
WHERE entity_type = ?
|
|
1099
|
+
ORDER BY deleted_at DESC
|
|
1100
|
+
LIMIT ?
|
|
1101
|
+
""",
|
|
1102
|
+
(entity_type, limit),
|
|
1103
|
+
).fetchall()
|
|
1104
|
+
else:
|
|
1105
|
+
rows = conn.execute(
|
|
1106
|
+
"""
|
|
1107
|
+
SELECT id, entity_type, entity_id, entity_name, deleted_at
|
|
1108
|
+
FROM deleted_items
|
|
1109
|
+
ORDER BY deleted_at DESC
|
|
1110
|
+
LIMIT ?
|
|
1111
|
+
""",
|
|
1112
|
+
(limit,),
|
|
1113
|
+
).fetchall()
|
|
1114
|
+
return [dict(r) for r in rows]
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def restore_deleted_item(conn: sqlite3.Connection, deleted_item_id: int) -> tuple[str, int] | None:
|
|
1118
|
+
"""Restore a deleted item from its snapshot."""
|
|
1119
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1120
|
+
try:
|
|
1121
|
+
row = conn.execute(
|
|
1122
|
+
"SELECT * FROM deleted_items WHERE id = ?",
|
|
1123
|
+
(deleted_item_id,),
|
|
1124
|
+
).fetchone()
|
|
1125
|
+
if not row:
|
|
1126
|
+
conn.rollback()
|
|
1127
|
+
return None
|
|
1128
|
+
snap = json.loads(row["snapshot"])
|
|
1129
|
+
entity_type = row["entity_type"]
|
|
1130
|
+
|
|
1131
|
+
if entity_type == "task":
|
|
1132
|
+
_restore_task(conn, snap)
|
|
1133
|
+
elif entity_type == "section":
|
|
1134
|
+
_restore_section(conn, snap)
|
|
1135
|
+
elif entity_type == "goal":
|
|
1136
|
+
_restore_goal(conn, snap)
|
|
1137
|
+
|
|
1138
|
+
conn.execute("DELETE FROM deleted_items WHERE id = ?", (deleted_item_id,))
|
|
1139
|
+
conn.commit()
|
|
1140
|
+
except sqlite3.IntegrityError as exc:
|
|
1141
|
+
conn.rollback()
|
|
1142
|
+
raise ValueError(f"Cannot restore: {exc}") from exc
|
|
1143
|
+
except Exception:
|
|
1144
|
+
conn.rollback()
|
|
1145
|
+
raise
|
|
1146
|
+
|
|
1147
|
+
return entity_type, row["entity_id"]
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _restore_task(conn: sqlite3.Connection, snap: dict[str, Any]) -> None:
|
|
1151
|
+
"""Re-insert a task + subtasks + tags + focus entries from snapshot."""
|
|
1152
|
+
t = snap["task"]
|
|
1153
|
+
proj = conn.execute("SELECT id FROM projects WHERE id = ?", (t["project_id"],)).fetchone()
|
|
1154
|
+
if not proj:
|
|
1155
|
+
raise sqlite3.IntegrityError(f"Parent project {t['project_id']} no longer exists")
|
|
1156
|
+
|
|
1157
|
+
section_id = t["section_id"]
|
|
1158
|
+
if section_id is not None:
|
|
1159
|
+
sec_row = conn.execute(
|
|
1160
|
+
"SELECT id, project_id FROM sections WHERE id = ?",
|
|
1161
|
+
(section_id,),
|
|
1162
|
+
).fetchone()
|
|
1163
|
+
if not sec_row or sec_row["project_id"] != t["project_id"]:
|
|
1164
|
+
section_id = None
|
|
1165
|
+
|
|
1166
|
+
parent_task_id = t["parent_task_id"]
|
|
1167
|
+
if parent_task_id is not None:
|
|
1168
|
+
parent_row = conn.execute(
|
|
1169
|
+
"SELECT id, project_id FROM tasks WHERE id = ?",
|
|
1170
|
+
(parent_task_id,),
|
|
1171
|
+
).fetchone()
|
|
1172
|
+
if not parent_row:
|
|
1173
|
+
raise sqlite3.IntegrityError(
|
|
1174
|
+
f"Parent task {parent_task_id} no longer exists - restore the parent task first"
|
|
1175
|
+
)
|
|
1176
|
+
if parent_row["project_id"] != t["project_id"]:
|
|
1177
|
+
raise sqlite3.IntegrityError(
|
|
1178
|
+
f"Parent task {parent_task_id} is now in project {parent_row['project_id']}, "
|
|
1179
|
+
f"but subtask belongs to project {t['project_id']} - move parent back first"
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
conn.execute(
|
|
1183
|
+
"""
|
|
1184
|
+
INSERT INTO tasks (
|
|
1185
|
+
id,
|
|
1186
|
+
project_id,
|
|
1187
|
+
section_id,
|
|
1188
|
+
parent_task_id,
|
|
1189
|
+
name,
|
|
1190
|
+
notes,
|
|
1191
|
+
assignee,
|
|
1192
|
+
status,
|
|
1193
|
+
start_date,
|
|
1194
|
+
due_date,
|
|
1195
|
+
created_at,
|
|
1196
|
+
completed_at,
|
|
1197
|
+
last_modified,
|
|
1198
|
+
position
|
|
1199
|
+
)
|
|
1200
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1201
|
+
""",
|
|
1202
|
+
(
|
|
1203
|
+
t["id"],
|
|
1204
|
+
t["project_id"],
|
|
1205
|
+
section_id,
|
|
1206
|
+
parent_task_id,
|
|
1207
|
+
t["name"],
|
|
1208
|
+
t["notes"],
|
|
1209
|
+
t["assignee"],
|
|
1210
|
+
t["status"],
|
|
1211
|
+
t["start_date"],
|
|
1212
|
+
t["due_date"],
|
|
1213
|
+
t["created_at"],
|
|
1214
|
+
t["completed_at"],
|
|
1215
|
+
t["last_modified"],
|
|
1216
|
+
t["position"],
|
|
1217
|
+
),
|
|
1218
|
+
)
|
|
1219
|
+
if snap.get("tags"):
|
|
1220
|
+
_set_tags(conn, t["id"], snap["tags"])
|
|
1221
|
+
for fe in snap.get("focus_entries", []):
|
|
1222
|
+
conn.execute(
|
|
1223
|
+
"INSERT OR IGNORE INTO daily_focus (task_id, focus_date, position, added_at) VALUES (?, ?, ?, ?)",
|
|
1224
|
+
(t["id"], fe["focus_date"], fe["position"], fe["added_at"]),
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
for sub_snap in snap.get("subtasks", []):
|
|
1228
|
+
st = sub_snap["task"]
|
|
1229
|
+
sub_project_id = t["project_id"]
|
|
1230
|
+
sub_section_id = st["section_id"]
|
|
1231
|
+
if sub_section_id is not None:
|
|
1232
|
+
sec_row = conn.execute(
|
|
1233
|
+
"SELECT id, project_id FROM sections WHERE id = ?",
|
|
1234
|
+
(sub_section_id,),
|
|
1235
|
+
).fetchone()
|
|
1236
|
+
if not sec_row or sec_row["project_id"] != sub_project_id:
|
|
1237
|
+
sub_section_id = None
|
|
1238
|
+
conn.execute(
|
|
1239
|
+
"""
|
|
1240
|
+
INSERT INTO tasks (
|
|
1241
|
+
id,
|
|
1242
|
+
project_id,
|
|
1243
|
+
section_id,
|
|
1244
|
+
parent_task_id,
|
|
1245
|
+
name,
|
|
1246
|
+
notes,
|
|
1247
|
+
assignee,
|
|
1248
|
+
status,
|
|
1249
|
+
start_date,
|
|
1250
|
+
due_date,
|
|
1251
|
+
created_at,
|
|
1252
|
+
completed_at,
|
|
1253
|
+
last_modified,
|
|
1254
|
+
position
|
|
1255
|
+
)
|
|
1256
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1257
|
+
""",
|
|
1258
|
+
(
|
|
1259
|
+
st["id"],
|
|
1260
|
+
sub_project_id,
|
|
1261
|
+
sub_section_id,
|
|
1262
|
+
st["parent_task_id"],
|
|
1263
|
+
st["name"],
|
|
1264
|
+
st["notes"],
|
|
1265
|
+
st["assignee"],
|
|
1266
|
+
st["status"],
|
|
1267
|
+
st["start_date"],
|
|
1268
|
+
st["due_date"],
|
|
1269
|
+
st["created_at"],
|
|
1270
|
+
st["completed_at"],
|
|
1271
|
+
st["last_modified"],
|
|
1272
|
+
st["position"],
|
|
1273
|
+
),
|
|
1274
|
+
)
|
|
1275
|
+
if sub_snap.get("tags"):
|
|
1276
|
+
_set_tags(conn, st["id"], sub_snap["tags"])
|
|
1277
|
+
for fe in sub_snap.get("focus_entries", []):
|
|
1278
|
+
conn.execute(
|
|
1279
|
+
"INSERT OR IGNORE INTO daily_focus (task_id, focus_date, position, added_at) VALUES (?, ?, ?, ?)",
|
|
1280
|
+
(st["id"], fe["focus_date"], fe["position"], fe["added_at"]),
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _restore_section(conn: sqlite3.Connection, snap: dict[str, Any]) -> None:
|
|
1285
|
+
"""Re-insert a section and reassign tasks still left ungrouped in the same project."""
|
|
1286
|
+
s = snap["section"]
|
|
1287
|
+
proj = conn.execute("SELECT id FROM projects WHERE id = ?", (s["project_id"],)).fetchone()
|
|
1288
|
+
if not proj:
|
|
1289
|
+
raise sqlite3.IntegrityError(f"Parent project {s['project_id']} no longer exists")
|
|
1290
|
+
conn.execute(
|
|
1291
|
+
"INSERT INTO sections (id, project_id, name, position, plan) VALUES (?, ?, ?, ?, ?)",
|
|
1292
|
+
(s["id"], s["project_id"], s["name"], s["position"], s.get("plan", "")),
|
|
1293
|
+
)
|
|
1294
|
+
for task_info in snap.get("tasks", []):
|
|
1295
|
+
conn.execute(
|
|
1296
|
+
"""
|
|
1297
|
+
UPDATE tasks
|
|
1298
|
+
SET section_id = ?, last_modified = datetime('now')
|
|
1299
|
+
WHERE id = ? AND section_id IS NULL AND project_id = ?
|
|
1300
|
+
""",
|
|
1301
|
+
(s["id"], task_info["id"], s["project_id"]),
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _restore_goal(conn: sqlite3.Connection, snap: dict[str, Any]) -> None:
|
|
1306
|
+
"""Re-insert a goal from snapshot."""
|
|
1307
|
+
g = snap["goal"]
|
|
1308
|
+
conn.execute(
|
|
1309
|
+
"INSERT INTO goals (id, text, timeframe, active, created_at, completed_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
1310
|
+
(g["id"], g["text"], g["timeframe"], g["active"], g["created_at"], g["completed_at"]),
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _purge_deleted_items(conn: sqlite3.Connection, older_than_hours: int = 24) -> None:
|
|
1315
|
+
"""Best-effort cleanup for expired deleted-item snapshots."""
|
|
1316
|
+
try:
|
|
1317
|
+
conn.execute(
|
|
1318
|
+
"DELETE FROM deleted_items WHERE deleted_at < datetime('now', ?)",
|
|
1319
|
+
(f"-{older_than_hours} hours",),
|
|
1320
|
+
)
|
|
1321
|
+
conn.commit()
|
|
1322
|
+
except Exception:
|
|
1323
|
+
pass
|
|
1324
|
+
|
|
1325
|
+
|
|
958
1326
|
# ---------------------------------------------------------------------------
|
|
959
1327
|
# Daily Focus
|
|
960
1328
|
# ---------------------------------------------------------------------------
|
|
@@ -43,6 +43,31 @@ def _json(data) -> str:
|
|
|
43
43
|
return json.dumps(data, indent=2, default=str)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
_TASK_LIST_FIELDS = {"id", "name", "status", "due_date", "project_id", "section_name", "project_name"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _slim_task(task: dict) -> dict:
|
|
50
|
+
return {k: v for k, v in task.items() if k in _TASK_LIST_FIELDS}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _slim_search_result(task: dict) -> dict:
|
|
54
|
+
result = _slim_task(task)
|
|
55
|
+
notes = task.get("notes") or ""
|
|
56
|
+
if notes:
|
|
57
|
+
result["notes"] = notes[:200] + ("..." if len(notes) > 200 else "")
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_PROJECT_LIST_FIELDS = {"id", "name", "icon", "phase", "open_count", "task_count", "last_activity"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _slim_project(project: dict) -> dict:
|
|
65
|
+
base = {k: v for k, v in project.items() if k in _PROJECT_LIST_FIELDS}
|
|
66
|
+
if "tasks" in project:
|
|
67
|
+
base["tasks"] = [_slim_task(task) for task in project["tasks"]]
|
|
68
|
+
return base
|
|
69
|
+
|
|
70
|
+
|
|
46
71
|
def _error(msg: str) -> str:
|
|
47
72
|
return json.dumps({"status": "error", "error": msg})
|
|
48
73
|
|
|
@@ -192,16 +217,16 @@ def _get_serve_status() -> dict:
|
|
|
192
217
|
|
|
193
218
|
@mcp.tool()
|
|
194
219
|
def tf_list_projects(phase: Optional[str] = None) -> str:
|
|
195
|
-
"""List
|
|
220
|
+
"""List projects (summary: id, name, phase, counts). Use tf_get_project for full plan."""
|
|
196
221
|
conn = _conn()
|
|
197
222
|
projects = db.list_projects(conn, phase=phase)
|
|
198
223
|
conn.close()
|
|
199
|
-
return _json({"projects": projects, "count": len(projects)})
|
|
224
|
+
return _json({"projects": [_slim_project(project) for project in projects], "count": len(projects)})
|
|
200
225
|
|
|
201
226
|
|
|
202
227
|
@mcp.tool()
|
|
203
228
|
def tf_get_project(project_id: int) -> str:
|
|
204
|
-
"""Get project details with sections and top-level tasks."""
|
|
229
|
+
"""Get full project details with sections and slim top-level tasks. Use tf_get_task for task detail."""
|
|
205
230
|
conn = _conn()
|
|
206
231
|
project = db.get_project(conn, project_id)
|
|
207
232
|
if not project:
|
|
@@ -210,7 +235,7 @@ def tf_get_project(project_id: int) -> str:
|
|
|
210
235
|
sections = db.list_sections(conn, project_id)
|
|
211
236
|
tasks = db.list_tasks(conn, project_id=project_id)
|
|
212
237
|
conn.close()
|
|
213
|
-
return _json({"project": project, "sections": sections, "tasks": tasks})
|
|
238
|
+
return _json({"project": project, "sections": sections, "tasks": [_slim_task(task) for task in tasks]})
|
|
214
239
|
|
|
215
240
|
|
|
216
241
|
@mcp.tool()
|
|
@@ -335,11 +360,11 @@ def tf_list_tasks(
|
|
|
335
360
|
status: Optional[str] = None,
|
|
336
361
|
assignee: Optional[str] = None,
|
|
337
362
|
) -> str:
|
|
338
|
-
"""List tasks
|
|
363
|
+
"""List top-level tasks (summary: id, name, status, due_date, project context). Use tf_get_task for notes and subtasks."""
|
|
339
364
|
conn = _conn()
|
|
340
365
|
tasks = db.list_tasks(conn, project_id=project_id, section_id=section_id, status=status, assignee=assignee)
|
|
341
366
|
conn.close()
|
|
342
|
-
return _json({"tasks": tasks, "count": len(tasks)})
|
|
367
|
+
return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
|
|
343
368
|
|
|
344
369
|
|
|
345
370
|
@mcp.tool()
|
|
@@ -452,47 +477,48 @@ def tf_delete_task(task_id: int) -> str:
|
|
|
452
477
|
|
|
453
478
|
@mcp.tool()
|
|
454
479
|
def tf_search(query: str, limit: int = 50) -> str:
|
|
455
|
-
"""
|
|
480
|
+
"""Search tasks by name/notes (summary + notes excerpt). Use tf_get_task for full detail."""
|
|
456
481
|
conn = _conn()
|
|
457
482
|
results = db.search_tasks(conn, query, limit)
|
|
458
483
|
conn.close()
|
|
459
|
-
return _json({"results": results, "count": len(results)})
|
|
484
|
+
return _json({"results": [_slim_search_result(result) for result in results], "count": len(results)})
|
|
460
485
|
|
|
461
486
|
|
|
462
487
|
@mcp.tool()
|
|
463
488
|
def tf_backlog() -> str:
|
|
464
|
-
"""List
|
|
489
|
+
"""List backlog tasks (summary: id, name, status, due_date, project context). Use tf_get_task for full detail."""
|
|
465
490
|
conn = _conn()
|
|
466
491
|
tasks = db.backlog(conn)
|
|
467
492
|
conn.close()
|
|
468
|
-
return _json({"tasks": tasks, "count": len(tasks)})
|
|
493
|
+
return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
|
|
469
494
|
|
|
470
495
|
|
|
471
496
|
@mcp.tool()
|
|
472
497
|
def tf_active() -> str:
|
|
473
|
-
"""List active projects with
|
|
498
|
+
"""List active projects (summary fields) with slim next tasks and backlog count. Use tf_get_project/tf_get_task for full detail."""
|
|
474
499
|
conn = _conn()
|
|
475
500
|
data = db.active_view(conn)
|
|
476
501
|
conn.close()
|
|
502
|
+
data["projects"] = [_slim_project(project) for project in data["projects"]]
|
|
477
503
|
return _json(data)
|
|
478
504
|
|
|
479
505
|
|
|
480
506
|
@mcp.tool()
|
|
481
507
|
def tf_due_soon(days: int = 7) -> str:
|
|
482
|
-
"""List
|
|
508
|
+
"""List tasks due within N days (summary: id, name, status, due_date, project context). Use tf_get_task for full detail."""
|
|
483
509
|
conn = _conn()
|
|
484
510
|
tasks = db.due_soon(conn, days)
|
|
485
511
|
conn.close()
|
|
486
|
-
return _json({"tasks": tasks, "count": len(tasks)})
|
|
512
|
+
return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
|
|
487
513
|
|
|
488
514
|
|
|
489
515
|
@mcp.tool()
|
|
490
516
|
def tf_overdue() -> str:
|
|
491
|
-
"""List
|
|
517
|
+
"""List overdue tasks (summary: id, name, status, due_date, project context). Use tf_get_task for full detail."""
|
|
492
518
|
conn = _conn()
|
|
493
519
|
tasks = db.overdue(conn)
|
|
494
520
|
conn.close()
|
|
495
|
-
return _json({"tasks": tasks, "count": len(tasks)})
|
|
521
|
+
return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
|
|
496
522
|
|
|
497
523
|
|
|
498
524
|
# ---------------------------------------------------------------------------
|
|
@@ -626,6 +652,31 @@ def tf_goal_remove(goal_id: int) -> str:
|
|
|
626
652
|
return _json({"status": "ok" if ok else "not_found"})
|
|
627
653
|
|
|
628
654
|
|
|
655
|
+
@mcp.tool()
|
|
656
|
+
def tf_list_deleted(entity_type: str = "") -> str:
|
|
657
|
+
"""List recently deleted items available for undo. Optional filter: 'task', 'section', or 'goal'."""
|
|
658
|
+
conn = _conn()
|
|
659
|
+
items = db.list_deleted_items(conn, entity_type or None)
|
|
660
|
+
conn.close()
|
|
661
|
+
return _json({"deleted_items": items, "count": len(items)})
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@mcp.tool()
|
|
665
|
+
def tf_undo_delete(deleted_item_id: int) -> str:
|
|
666
|
+
"""Restore a previously deleted item by its deleted_items ID."""
|
|
667
|
+
conn = _conn()
|
|
668
|
+
try:
|
|
669
|
+
result = db.restore_deleted_item(conn, deleted_item_id)
|
|
670
|
+
except ValueError as exc:
|
|
671
|
+
conn.close()
|
|
672
|
+
return _json({"status": "error", "error": str(exc)})
|
|
673
|
+
conn.close()
|
|
674
|
+
if result:
|
|
675
|
+
entity_type, entity_id = result
|
|
676
|
+
return _json({"status": "ok", "restored": entity_type, "entity_id": entity_id})
|
|
677
|
+
return _json({"status": "not_found"})
|
|
678
|
+
|
|
679
|
+
|
|
629
680
|
# ---------------------------------------------------------------------------
|
|
630
681
|
# Import
|
|
631
682
|
# ---------------------------------------------------------------------------
|