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.
@@ -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}")
File without changes