rook-cli 0.2.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.
- rook/__init__.py +1 -0
- rook/__main__.py +48 -0
- rook/app.py +222 -0
- rook/branding.py +69 -0
- rook/domain/__init__.py +0 -0
- rook/domain/tasks.py +55 -0
- rook/formatting.py +10 -0
- rook/paths.py +31 -0
- rook/persistence/__init__.py +0 -0
- rook/persistence/archive.py +72 -0
- rook/persistence/database.py +18 -0
- rook/persistence/metadata.py +56 -0
- rook/persistence/migrations.py +111 -0
- rook/persistence/tasks.py +185 -0
- rook/services/__init__.py +0 -0
- rook/services/rollover.py +94 -0
- rook/services/tasks.py +85 -0
- rook/services/undo.py +72 -0
- rook/symbols.py +40 -0
- rook/widgets/__init__.py +0 -0
- rook/widgets/archive_screen.py +162 -0
- rook/widgets/shortcut_footer.py +79 -0
- rook/widgets/task_line_input.py +44 -0
- rook/widgets/task_list.py +415 -0
- rook/widgets/task_row.py +155 -0
- rook_cli-0.2.0.dist-info/METADATA +120 -0
- rook_cli-0.2.0.dist-info/RECORD +30 -0
- rook_cli-0.2.0.dist-info/WHEEL +4 -0
- rook_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rook_cli-0.2.0.dist-info/licenses/LICENSE +10 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
|
|
3
|
+
# Section 15.28: all four version-1 tables are created together in one
|
|
4
|
+
# migration, since Routines/Routine Items are part of the same accepted
|
|
5
|
+
# schema even though they remain unused until Phase 11.
|
|
6
|
+
CURRENT_SCHEMA_VERSION = 1
|
|
7
|
+
|
|
8
|
+
_SCHEMA_V1 = """
|
|
9
|
+
CREATE TABLE app_meta (
|
|
10
|
+
key TEXT PRIMARY KEY,
|
|
11
|
+
value TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE tasks (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
text TEXT NOT NULL CHECK (length(trim(text)) > 0),
|
|
17
|
+
state TEXT NOT NULL
|
|
18
|
+
CHECK (state IN ('open', 'migrated', 'completed', 'deleted')),
|
|
19
|
+
sort_order INTEGER NOT NULL,
|
|
20
|
+
created_at TEXT NOT NULL,
|
|
21
|
+
updated_at TEXT NOT NULL,
|
|
22
|
+
state_changed_at TEXT NOT NULL,
|
|
23
|
+
state_date TEXT NOT NULL,
|
|
24
|
+
archived_date TEXT,
|
|
25
|
+
archived_at TEXT,
|
|
26
|
+
archive_order INTEGER,
|
|
27
|
+
|
|
28
|
+
CHECK (
|
|
29
|
+
(archived_date IS NULL AND archived_at IS NULL AND archive_order IS NULL)
|
|
30
|
+
OR
|
|
31
|
+
(archived_date IS NOT NULL AND archived_at IS NOT NULL AND archive_order IS NOT NULL)
|
|
32
|
+
),
|
|
33
|
+
|
|
34
|
+
CHECK (
|
|
35
|
+
archived_date IS NULL
|
|
36
|
+
OR state IN ('completed', 'deleted')
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE routines (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
name TEXT NOT NULL CHECK (length(trim(name)) > 0),
|
|
43
|
+
sort_order INTEGER NOT NULL,
|
|
44
|
+
is_deleted INTEGER NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1)),
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
updated_at TEXT NOT NULL
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE routine_items (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
routine_id INTEGER NOT NULL,
|
|
52
|
+
text TEXT NOT NULL CHECK (length(trim(text)) > 0),
|
|
53
|
+
sort_order INTEGER NOT NULL,
|
|
54
|
+
is_deleted INTEGER NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1)),
|
|
55
|
+
created_at TEXT NOT NULL,
|
|
56
|
+
updated_at TEXT NOT NULL,
|
|
57
|
+
|
|
58
|
+
FOREIGN KEY (routine_id)
|
|
59
|
+
REFERENCES routines(id)
|
|
60
|
+
ON DELETE CASCADE
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX ix_tasks_active_order
|
|
64
|
+
ON tasks(archived_date, sort_order, id);
|
|
65
|
+
|
|
66
|
+
CREATE INDEX ix_tasks_archive_date_order
|
|
67
|
+
ON tasks(archived_date, archive_order, id);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX ix_tasks_rollover
|
|
70
|
+
ON tasks(archived_date, state, state_date);
|
|
71
|
+
|
|
72
|
+
CREATE INDEX ix_routines_order
|
|
73
|
+
ON routines(sort_order, id);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX ix_routine_items_order
|
|
76
|
+
ON routine_items(routine_id, is_deleted, sort_order, id);
|
|
77
|
+
|
|
78
|
+
CREATE UNIQUE INDEX ux_routines_active_name
|
|
79
|
+
ON routines(lower(trim(name)))
|
|
80
|
+
WHERE is_deleted = 0;
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UnsupportedSchemaVersionError(RuntimeError):
|
|
85
|
+
"""Raised when the database's schema is newer than this build understands."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, found_version: int) -> None:
|
|
88
|
+
super().__init__(
|
|
89
|
+
f"This database uses schema version {found_version}, but this version "
|
|
90
|
+
f"of Rook only understands up to version {CURRENT_SCHEMA_VERSION}. "
|
|
91
|
+
"The database was left untouched."
|
|
92
|
+
)
|
|
93
|
+
self.found_version = found_version
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def migrate(connection: sqlite3.Connection) -> None:
|
|
97
|
+
"""Bring the database up to CURRENT_SCHEMA_VERSION (Section 15.5).
|
|
98
|
+
|
|
99
|
+
Safe to call every startup: a database already at the current version
|
|
100
|
+
is left untouched. A database from a newer, not-yet-understood version
|
|
101
|
+
is rejected rather than silently used or altered.
|
|
102
|
+
"""
|
|
103
|
+
current_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
104
|
+
|
|
105
|
+
if current_version > CURRENT_SCHEMA_VERSION:
|
|
106
|
+
raise UnsupportedSchemaVersionError(current_version)
|
|
107
|
+
|
|
108
|
+
if current_version == 0:
|
|
109
|
+
connection.executescript(_SCHEMA_V1)
|
|
110
|
+
connection.execute(f"PRAGMA user_version = {CURRENT_SCHEMA_VERSION};")
|
|
111
|
+
connection.commit()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
|
|
5
|
+
from rook.domain.tasks import Task, TaskState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class TaskSnapshot:
|
|
10
|
+
"""A permanently-removed Task's row exactly as it existed just before
|
|
11
|
+
removal (Section 8.9), kept only long enough for a same-session undo
|
|
12
|
+
to restore it precisely - including its position, since a restored
|
|
13
|
+
Task must reappear in its original relative order.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
id: int
|
|
17
|
+
text: str
|
|
18
|
+
state: TaskState
|
|
19
|
+
sort_order: int
|
|
20
|
+
created_at: str
|
|
21
|
+
updated_at: str
|
|
22
|
+
state_changed_at: str
|
|
23
|
+
state_date: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskRepository:
|
|
27
|
+
"""SQL access for active Tasks (Section 15.22). No Textual imports."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
30
|
+
self._connection = connection
|
|
31
|
+
|
|
32
|
+
def list_active_tasks(self) -> list[Task]:
|
|
33
|
+
rows = self._connection.execute(
|
|
34
|
+
"""
|
|
35
|
+
SELECT id, text, state
|
|
36
|
+
FROM tasks
|
|
37
|
+
WHERE archived_date IS NULL
|
|
38
|
+
ORDER BY sort_order ASC, id ASC
|
|
39
|
+
"""
|
|
40
|
+
).fetchall()
|
|
41
|
+
return [_row_to_task(row) for row in rows]
|
|
42
|
+
|
|
43
|
+
def create_task(self, text: str, *, now: datetime, local_date: date) -> Task:
|
|
44
|
+
timestamp = now.isoformat()
|
|
45
|
+
with self._connection:
|
|
46
|
+
next_order = self._connection.execute(
|
|
47
|
+
"SELECT COALESCE(MAX(sort_order), 0) + 1 FROM tasks WHERE archived_date IS NULL"
|
|
48
|
+
).fetchone()[0]
|
|
49
|
+
cursor = self._connection.execute(
|
|
50
|
+
"""
|
|
51
|
+
INSERT INTO tasks (
|
|
52
|
+
text, state, sort_order, created_at, updated_at,
|
|
53
|
+
state_changed_at, state_date
|
|
54
|
+
) VALUES (?, 'open', ?, ?, ?, ?, ?)
|
|
55
|
+
""",
|
|
56
|
+
(text, next_order, timestamp, timestamp, timestamp, local_date.isoformat()),
|
|
57
|
+
)
|
|
58
|
+
task_id = cursor.lastrowid
|
|
59
|
+
assert task_id is not None, "INSERT must assign a row id"
|
|
60
|
+
return Task(id=task_id, text=text, state=TaskState.OPEN)
|
|
61
|
+
|
|
62
|
+
def update_task_text(self, task_id: int, text: str, *, now: datetime) -> Task:
|
|
63
|
+
timestamp = now.isoformat()
|
|
64
|
+
with self._connection:
|
|
65
|
+
cursor = self._connection.execute(
|
|
66
|
+
"""
|
|
67
|
+
UPDATE tasks
|
|
68
|
+
SET text = ?, updated_at = ?
|
|
69
|
+
WHERE id = ? AND archived_date IS NULL
|
|
70
|
+
""",
|
|
71
|
+
(text, timestamp, task_id),
|
|
72
|
+
)
|
|
73
|
+
if cursor.rowcount != 1:
|
|
74
|
+
raise LookupError(f"No active task with id {task_id}")
|
|
75
|
+
row = self._connection.execute(
|
|
76
|
+
"SELECT id, text, state FROM tasks WHERE id = ?", (task_id,)
|
|
77
|
+
).fetchone()
|
|
78
|
+
return _row_to_task(row)
|
|
79
|
+
|
|
80
|
+
def set_task_state(
|
|
81
|
+
self, task_id: int, state: TaskState, *, now: datetime, local_date: date
|
|
82
|
+
) -> Task:
|
|
83
|
+
"""Section 15.9: state_date is stamped at the moment of the
|
|
84
|
+
transition, not derived later, so historical Archive dates stay
|
|
85
|
+
stable even if the user's time zone changes afterward."""
|
|
86
|
+
timestamp = now.isoformat()
|
|
87
|
+
with self._connection:
|
|
88
|
+
cursor = self._connection.execute(
|
|
89
|
+
"""
|
|
90
|
+
UPDATE tasks
|
|
91
|
+
SET state = ?, updated_at = ?, state_changed_at = ?, state_date = ?
|
|
92
|
+
WHERE id = ? AND archived_date IS NULL
|
|
93
|
+
""",
|
|
94
|
+
(state.value, timestamp, timestamp, local_date.isoformat(), task_id),
|
|
95
|
+
)
|
|
96
|
+
if cursor.rowcount != 1:
|
|
97
|
+
raise LookupError(f"No active task with id {task_id}")
|
|
98
|
+
row = self._connection.execute(
|
|
99
|
+
"SELECT id, text, state FROM tasks WHERE id = ?", (task_id,)
|
|
100
|
+
).fetchone()
|
|
101
|
+
return _row_to_task(row)
|
|
102
|
+
|
|
103
|
+
def delete_active_task(self, task_id: int) -> TaskSnapshot:
|
|
104
|
+
"""Second-stage delete (Section 15.14): permanently remove a
|
|
105
|
+
Soft-Deleted Task. Returns the row exactly as it existed
|
|
106
|
+
immediately before removal, so a same-session undo (Phase 7) can
|
|
107
|
+
restore it precisely.
|
|
108
|
+
"""
|
|
109
|
+
with self._connection:
|
|
110
|
+
row = self._connection.execute(
|
|
111
|
+
"""
|
|
112
|
+
SELECT id, text, state, sort_order, created_at, updated_at,
|
|
113
|
+
state_changed_at, state_date
|
|
114
|
+
FROM tasks
|
|
115
|
+
WHERE id = ? AND archived_date IS NULL
|
|
116
|
+
""",
|
|
117
|
+
(task_id,),
|
|
118
|
+
).fetchone()
|
|
119
|
+
if row is None:
|
|
120
|
+
raise LookupError(f"No active task with id {task_id}")
|
|
121
|
+
removed = _row_to_snapshot(row)
|
|
122
|
+
|
|
123
|
+
cursor = self._connection.execute(
|
|
124
|
+
"DELETE FROM tasks WHERE id = ? AND archived_date IS NULL AND state = 'deleted'",
|
|
125
|
+
(task_id,),
|
|
126
|
+
)
|
|
127
|
+
if cursor.rowcount != 1:
|
|
128
|
+
raise LookupError(f"Task {task_id} is not eligible for permanent removal")
|
|
129
|
+
return removed
|
|
130
|
+
|
|
131
|
+
def restore_task(self, snapshot: TaskSnapshot) -> Task:
|
|
132
|
+
"""Undo of a permanent removal: reinsert the row exactly as
|
|
133
|
+
captured. The restored Task is still Deleted (Section 8.9) -
|
|
134
|
+
undoing a removal does not reopen the Task."""
|
|
135
|
+
with self._connection:
|
|
136
|
+
self._connection.execute(
|
|
137
|
+
"""
|
|
138
|
+
INSERT INTO tasks (
|
|
139
|
+
id, text, state, sort_order, created_at, updated_at,
|
|
140
|
+
state_changed_at, state_date
|
|
141
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
142
|
+
""",
|
|
143
|
+
(
|
|
144
|
+
snapshot.id,
|
|
145
|
+
snapshot.text,
|
|
146
|
+
snapshot.state.value,
|
|
147
|
+
snapshot.sort_order,
|
|
148
|
+
snapshot.created_at,
|
|
149
|
+
snapshot.updated_at,
|
|
150
|
+
snapshot.state_changed_at,
|
|
151
|
+
snapshot.state_date,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
return Task(id=snapshot.id, text=snapshot.text, state=snapshot.state)
|
|
155
|
+
|
|
156
|
+
def delete_task_by_id(self, task_id: int) -> None:
|
|
157
|
+
"""Unconditional removal used only to undo a same-session Task
|
|
158
|
+
creation (Section 15.19 DeleteCreatedTask). Safe without a state
|
|
159
|
+
guard because single-level undo guarantees no other mutation has
|
|
160
|
+
touched this Task since it was created - otherwise that later
|
|
161
|
+
mutation would have replaced this undo record first.
|
|
162
|
+
"""
|
|
163
|
+
with self._connection:
|
|
164
|
+
cursor = self._connection.execute(
|
|
165
|
+
"DELETE FROM tasks WHERE id = ? AND archived_date IS NULL", (task_id,)
|
|
166
|
+
)
|
|
167
|
+
if cursor.rowcount != 1:
|
|
168
|
+
raise LookupError(f"No active task with id {task_id}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _row_to_task(row: sqlite3.Row) -> Task:
|
|
172
|
+
return Task(id=row["id"], text=row["text"], state=TaskState(row["state"]))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _row_to_snapshot(row: sqlite3.Row) -> TaskSnapshot:
|
|
176
|
+
return TaskSnapshot(
|
|
177
|
+
id=row["id"],
|
|
178
|
+
text=row["text"],
|
|
179
|
+
state=TaskState(row["state"]),
|
|
180
|
+
sort_order=row["sort_order"],
|
|
181
|
+
created_at=row["created_at"],
|
|
182
|
+
updated_at=row["updated_at"],
|
|
183
|
+
state_changed_at=row["state_changed_at"],
|
|
184
|
+
state_date=row["state_date"],
|
|
185
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
|
|
6
|
+
from rook.persistence.metadata import MetadataRepository
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class RolloverResult:
|
|
11
|
+
"""Summary sufficient for the screen to decide whether to refresh
|
|
12
|
+
(Section 16.17)."""
|
|
13
|
+
|
|
14
|
+
changed: bool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RolloverService:
|
|
18
|
+
"""Owns Day Rollover (Section 5.2, 15.12, 16.17).
|
|
19
|
+
|
|
20
|
+
Unlike ordinary Task mutations, archiving, renumbering, and updating
|
|
21
|
+
`last_processed_date` must commit as a single atomic transaction, so
|
|
22
|
+
this service holds the raw connection directly rather than composing
|
|
23
|
+
calls to TaskRepository/MetadataRepository, each of which would commit
|
|
24
|
+
independently and break that guarantee.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
connection: sqlite3.Connection,
|
|
30
|
+
metadata_repository: MetadataRepository,
|
|
31
|
+
*,
|
|
32
|
+
today_provider: Callable[[], date] = date.today,
|
|
33
|
+
now_provider: Callable[[], datetime] = datetime.now,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._connection = connection
|
|
36
|
+
self._metadata = metadata_repository
|
|
37
|
+
self._today_provider = today_provider
|
|
38
|
+
self._now_provider = now_provider
|
|
39
|
+
|
|
40
|
+
def roll_forward_if_needed(self) -> RolloverResult:
|
|
41
|
+
today = self._today_provider()
|
|
42
|
+
last_processed = self._metadata.get_last_processed_date()
|
|
43
|
+
|
|
44
|
+
if last_processed is None:
|
|
45
|
+
# First launch ever: nothing exists to archive yet.
|
|
46
|
+
self._metadata.set_last_processed_date(today)
|
|
47
|
+
return RolloverResult(changed=False)
|
|
48
|
+
|
|
49
|
+
# Section 18.14: only roll forward, never backward. This single
|
|
50
|
+
# comparison also handles multiple missed days (Section 18.13)
|
|
51
|
+
# without any per-day loop - archive eligibility below is judged
|
|
52
|
+
# against each Task's own state_date, not against "yesterday".
|
|
53
|
+
if today <= last_processed:
|
|
54
|
+
return RolloverResult(changed=False)
|
|
55
|
+
|
|
56
|
+
timestamp = self._now_provider().isoformat()
|
|
57
|
+
with self._connection:
|
|
58
|
+
self._connection.execute(
|
|
59
|
+
"""
|
|
60
|
+
UPDATE tasks
|
|
61
|
+
SET archived_date = state_date, archived_at = ?, archive_order = sort_order
|
|
62
|
+
WHERE archived_date IS NULL
|
|
63
|
+
AND state IN ('completed', 'deleted')
|
|
64
|
+
AND state_date < ?
|
|
65
|
+
""",
|
|
66
|
+
(timestamp, today.isoformat()),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Section 15.12 step 3: renumber remaining active Tasks so
|
|
70
|
+
# sort_order stays contiguous, preserving relative order.
|
|
71
|
+
remaining_ids = [
|
|
72
|
+
row["id"]
|
|
73
|
+
for row in self._connection.execute(
|
|
74
|
+
"""
|
|
75
|
+
SELECT id FROM tasks
|
|
76
|
+
WHERE archived_date IS NULL
|
|
77
|
+
ORDER BY sort_order ASC, id ASC
|
|
78
|
+
"""
|
|
79
|
+
).fetchall()
|
|
80
|
+
]
|
|
81
|
+
for new_order, task_id in enumerate(remaining_ids, start=1):
|
|
82
|
+
self._connection.execute(
|
|
83
|
+
"UPDATE tasks SET sort_order = ? WHERE id = ?", (new_order, task_id)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self._connection.execute(
|
|
87
|
+
"""
|
|
88
|
+
INSERT INTO app_meta (key, value) VALUES ('last_processed_date', ?)
|
|
89
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
90
|
+
""",
|
|
91
|
+
(today.isoformat(),),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return RolloverResult(changed=True)
|
rook/services/tasks.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
|
|
5
|
+
from rook.domain.tasks import Task, TaskState
|
|
6
|
+
from rook.persistence.tasks import TaskRepository, TaskSnapshot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PersistenceError(Exception):
|
|
10
|
+
"""Raised when a Task mutation could not be durably saved.
|
|
11
|
+
|
|
12
|
+
The presentation layer catches this and shows a status message
|
|
13
|
+
(Section 10.16) rather than pretending the mutation succeeded
|
|
14
|
+
(FR-104).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskService:
|
|
19
|
+
"""Coordinates Task mutations for the presentation layer.
|
|
20
|
+
|
|
21
|
+
Translates low-level sqlite3 failures into PersistenceError so
|
|
22
|
+
widgets never need to know about sqlite3 directly.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
repository: TaskRepository,
|
|
28
|
+
*,
|
|
29
|
+
now_provider: Callable[[], datetime] = datetime.now,
|
|
30
|
+
today_provider: Callable[[], date] = date.today,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._repository = repository
|
|
33
|
+
self._now_provider = now_provider
|
|
34
|
+
self._today_provider = today_provider
|
|
35
|
+
|
|
36
|
+
def list_active_tasks(self) -> list[Task]:
|
|
37
|
+
try:
|
|
38
|
+
return self._repository.list_active_tasks()
|
|
39
|
+
except sqlite3.Error as error:
|
|
40
|
+
raise PersistenceError(str(error)) from error
|
|
41
|
+
|
|
42
|
+
def create_task(self, text: str) -> Task:
|
|
43
|
+
try:
|
|
44
|
+
return self._repository.create_task(
|
|
45
|
+
text, now=self._now_provider(), local_date=self._today_provider()
|
|
46
|
+
)
|
|
47
|
+
except sqlite3.Error as error:
|
|
48
|
+
raise PersistenceError(str(error)) from error
|
|
49
|
+
|
|
50
|
+
def update_task_text(self, task_id: int, text: str) -> Task:
|
|
51
|
+
try:
|
|
52
|
+
return self._repository.update_task_text(task_id, text, now=self._now_provider())
|
|
53
|
+
except sqlite3.Error as error:
|
|
54
|
+
raise PersistenceError(str(error)) from error
|
|
55
|
+
|
|
56
|
+
def set_task_state(self, task_id: int, state: TaskState) -> Task:
|
|
57
|
+
try:
|
|
58
|
+
return self._repository.set_task_state(
|
|
59
|
+
task_id, state, now=self._now_provider(), local_date=self._today_provider()
|
|
60
|
+
)
|
|
61
|
+
except sqlite3.Error as error:
|
|
62
|
+
raise PersistenceError(str(error)) from error
|
|
63
|
+
|
|
64
|
+
def delete_task(self, task_id: int) -> TaskSnapshot:
|
|
65
|
+
"""Permanently remove a Soft-Deleted Task, returning its last
|
|
66
|
+
known row so a same-session undo can restore it precisely."""
|
|
67
|
+
try:
|
|
68
|
+
return self._repository.delete_active_task(task_id)
|
|
69
|
+
except sqlite3.Error as error:
|
|
70
|
+
raise PersistenceError(str(error)) from error
|
|
71
|
+
|
|
72
|
+
def restore_task(self, snapshot: TaskSnapshot) -> Task:
|
|
73
|
+
"""Undo a permanent removal (Phase 7)."""
|
|
74
|
+
try:
|
|
75
|
+
return self._repository.restore_task(snapshot)
|
|
76
|
+
except sqlite3.Error as error:
|
|
77
|
+
raise PersistenceError(str(error)) from error
|
|
78
|
+
|
|
79
|
+
def discard_created_task(self, task_id: int) -> None:
|
|
80
|
+
"""Undo a Task creation (Phase 7). Distinct from delete_task
|
|
81
|
+
because a just-created Task is Open, not Deleted."""
|
|
82
|
+
try:
|
|
83
|
+
self._repository.delete_task_by_id(task_id)
|
|
84
|
+
except sqlite3.Error as error:
|
|
85
|
+
raise PersistenceError(str(error)) from error
|
rook/services/undo.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from rook.domain.tasks import TaskState
|
|
4
|
+
from rook.persistence.tasks import TaskSnapshot
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class DeleteCreatedTask:
|
|
9
|
+
"""Undo a Task creation: the Task is Open and untouched since single-
|
|
10
|
+
level undo guarantees nothing else has mutated it since (Section 15.19).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
task_id: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class RestoreTaskText:
|
|
18
|
+
"""Undo a text edit."""
|
|
19
|
+
|
|
20
|
+
task_id: int
|
|
21
|
+
text: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class RestoreTaskState:
|
|
26
|
+
"""Undo a state change (`x`, `>`, or a first `d`)."""
|
|
27
|
+
|
|
28
|
+
task_id: int
|
|
29
|
+
state: TaskState
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class RestoreTaskSnapshot:
|
|
34
|
+
"""Undo a permanent removal (second `d`), reinserting at its original
|
|
35
|
+
list position."""
|
|
36
|
+
|
|
37
|
+
index: int
|
|
38
|
+
snapshot: TaskSnapshot
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
UndoCommand = DeleteCreatedTask | RestoreTaskText | RestoreTaskState | RestoreTaskSnapshot
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UndoManager:
|
|
45
|
+
"""Holds at most one inverse command for the current session (Section
|
|
46
|
+
16.20). Pure in-memory bookkeeping - no database connection, no
|
|
47
|
+
widgets, no asyncio. The presentation layer decides how to apply each
|
|
48
|
+
command type and through which service calls.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._command: UndoCommand | None = None
|
|
53
|
+
|
|
54
|
+
def record(self, command: UndoCommand) -> None:
|
|
55
|
+
self._command = command
|
|
56
|
+
|
|
57
|
+
def clear(self) -> None:
|
|
58
|
+
self._command = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def has_undo(self) -> bool:
|
|
62
|
+
return self._command is not None
|
|
63
|
+
|
|
64
|
+
def take(self) -> UndoCommand | None:
|
|
65
|
+
"""Remove and return the pending command, if any.
|
|
66
|
+
|
|
67
|
+
Version 1 has no redo, so taking a command always clears the slot
|
|
68
|
+
- if applying it fails, the caller may choose to record() it again.
|
|
69
|
+
"""
|
|
70
|
+
command = self._command
|
|
71
|
+
self._command = None
|
|
72
|
+
return command
|
rook/symbols.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from rook.domain.tasks import TaskState
|
|
4
|
+
|
|
5
|
+
# Column layout is fixed (Section 11.3): selection indicator, space, state
|
|
6
|
+
# symbol, space, then task text. The reserved selection column plus its
|
|
7
|
+
# following space is 2 characters; the state symbol plus its following
|
|
8
|
+
# space is another 2 characters.
|
|
9
|
+
PREFIX_WIDTH = 4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class SymbolSet:
|
|
14
|
+
open: str
|
|
15
|
+
completed: str
|
|
16
|
+
migrated: str
|
|
17
|
+
selected: str
|
|
18
|
+
deleted_fallback: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PREFERRED = SymbolSet(open="•", completed="×", migrated=">", selected="❯", deleted_fallback="~")
|
|
22
|
+
SAFE = SymbolSet(open="*", completed="x", migrated=">", selected=">", deleted_fallback="~")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def state_symbol(state: TaskState, symbols: SymbolSet, *, safe_mode: bool) -> str:
|
|
26
|
+
"""The glyph shown in the state-symbol column for a given Task state.
|
|
27
|
+
|
|
28
|
+
A Deleted Task's preferred display retains its normal open bullet and
|
|
29
|
+
relies on strikethrough styling to signal deletion (Section 11.5); the
|
|
30
|
+
fallback instead replaces the symbol outright with ``deleted_fallback``.
|
|
31
|
+
"""
|
|
32
|
+
if state is TaskState.DELETED:
|
|
33
|
+
return symbols.deleted_fallback if safe_mode else symbols.open
|
|
34
|
+
if state is TaskState.OPEN:
|
|
35
|
+
return symbols.open
|
|
36
|
+
if state is TaskState.MIGRATED:
|
|
37
|
+
return symbols.migrated
|
|
38
|
+
if state is TaskState.COMPLETED:
|
|
39
|
+
return symbols.completed
|
|
40
|
+
raise AssertionError(f"Unhandled task state: {state!r}")
|
rook/widgets/__init__.py
ADDED
|
File without changes
|