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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
rook/__main__.py ADDED
@@ -0,0 +1,48 @@
1
+ import sys
2
+
3
+ from rook.app import RookApp
4
+ from rook.paths import default_database_path
5
+ from rook.persistence.database import connect
6
+ from rook.persistence.metadata import MetadataRepository
7
+ from rook.persistence.migrations import UnsupportedSchemaVersionError, migrate
8
+ from rook.persistence.tasks import TaskRepository
9
+ from rook.services.rollover import RolloverService
10
+ from rook.services.tasks import TaskService
11
+
12
+
13
+ def main() -> int:
14
+ if len(sys.argv) > 1 and sys.argv[1] == "--data-path":
15
+ print(default_database_path())
16
+ return 0
17
+
18
+ db_path = default_database_path()
19
+ connection = connect(db_path)
20
+
21
+ try:
22
+ migrate(connection)
23
+ except UnsupportedSchemaVersionError as error:
24
+ connection.close()
25
+ print(error)
26
+ print(f"Database file: {db_path}")
27
+ return 1
28
+
29
+ task_service = TaskService(TaskRepository(connection))
30
+ rollover_service = RolloverService(connection, MetadataRepository(connection))
31
+
32
+ # Section 16.8: rollover runs once at startup, before Today ever renders.
33
+ rollover_service.roll_forward_if_needed()
34
+
35
+ try:
36
+ RookApp(
37
+ task_service=task_service,
38
+ rollover_service=rollover_service,
39
+ connection=connection,
40
+ ).run()
41
+ finally:
42
+ connection.close()
43
+
44
+ return 0
45
+
46
+
47
+ if __name__ == "__main__":
48
+ raise SystemExit(main())
rook/app.py ADDED
@@ -0,0 +1,222 @@
1
+ import sqlite3
2
+ from collections.abc import Callable
3
+ from datetime import date
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.widgets import Static
8
+
9
+ from rook import branding
10
+ from rook.formatting import format_header_date
11
+ from rook.persistence.metadata import MetadataRepository
12
+ from rook.services.rollover import RolloverService
13
+ from rook.services.tasks import TaskService
14
+ from rook.services.undo import UndoManager
15
+ from rook.widgets.archive_screen import ArchiveScreen
16
+ from rook.widgets.shortcut_footer import ShortcutFooter
17
+ from rook.widgets.task_list import TaskListView
18
+
19
+ TodayProvider = Callable[[], date]
20
+
21
+ # Section 21.13 explicitly excludes anything more frequent than needed; a
22
+ # once-a-minute check is enough to notice a live midnight crossing without
23
+ # being a tight polling loop.
24
+ ROLLOVER_CHECK_INTERVAL_SECONDS = 60
25
+
26
+
27
+ class RookApp(App[None]):
28
+ """The Rook terminal shell.
29
+
30
+ Phase 5 replaces the in-memory Task list with SQLite as the source of
31
+ truth, via an injected TaskService. Task state changes, Archive, and
32
+ Routines are still not implemented.
33
+ """
34
+
35
+ CSS = """
36
+ Screen {
37
+ layout: vertical;
38
+ }
39
+
40
+ #header, #mascot-quote, #spacer, #status {
41
+ height: 1;
42
+ }
43
+
44
+ #header {
45
+ text-style: bold;
46
+ }
47
+
48
+ #task-list {
49
+ height: 1fr;
50
+ /* The "ansi-dark" theme doesn't define scrollbar variables (only
51
+ * "ansi-light" happens to), so these fall back to Textual's fixed
52
+ * truecolor defaults - the mismatched, chunky bar reported after
53
+ * scrolling a long list. Set them directly to ANSI colors instead,
54
+ * and use a slimmer single-column bar (Section 11.1 "compact"). */
55
+ scrollbar-size-vertical: 1;
56
+ scrollbar-color: ansi_default;
57
+ scrollbar-color-hover: ansi_default;
58
+ scrollbar-color-active: ansi_default;
59
+ scrollbar-background: ansi_default;
60
+ scrollbar-background-hover: ansi_default;
61
+ scrollbar-background-active: ansi_default;
62
+ scrollbar-corner-color: ansi_default;
63
+ }
64
+
65
+ /* "ansi-dark" hardcodes the text-entry cursor to a solid reverse-video
66
+ * block in ansi_black on ansi_bright_white, which many customized
67
+ * terminal palettes remap to something unrelated to the terminal's
68
+ * actual foreground/background (reported as an unrelated light gray).
69
+ * Reversing the terminal's own default foreground instead keeps the
70
+ * filled-block look but in the user's own theme color. This is Rook's
71
+ * own simulated text cursor, not the terminal's hardware cursor - a
72
+ * full-screen app owning the whole display can't repurpose the
73
+ * terminal's native caret for a styled, multi-character-wide input. */
74
+ TaskLineInput > .input--cursor {
75
+ color: ansi_default;
76
+ text-style: reverse;
77
+ }
78
+
79
+ #footer {
80
+ height: 1;
81
+ dock: bottom;
82
+ }
83
+ """
84
+
85
+ BINDINGS = [
86
+ Binding("q", "quit", "Quit"),
87
+ Binding("up", "cursor_up", "Up", show=False),
88
+ Binding("down", "cursor_down", "Down", show=False),
89
+ Binding("n", "new_task", "New", show=False),
90
+ Binding("e", "edit_task", "Edit", show=False),
91
+ Binding("enter", "edit_task", "Edit", show=False),
92
+ Binding("x", "toggle_completed", "Complete", show=False),
93
+ Binding(">", "toggle_migrated", "Migrate", show=False),
94
+ Binding("d", "delete_or_remove", "Delete", show=False),
95
+ Binding("u", "undo", "Undo", show=False),
96
+ Binding("a", "archive", "Archive", show=False),
97
+ ]
98
+
99
+ def __init__(
100
+ self,
101
+ today_provider: TodayProvider = date.today,
102
+ *,
103
+ task_service: TaskService,
104
+ rollover_service: RolloverService,
105
+ connection: sqlite3.Connection | None = None,
106
+ safe_symbols: bool = False,
107
+ ) -> None:
108
+ super().__init__()
109
+ self._today_provider = today_provider
110
+ self._task_service = task_service
111
+ self._rollover_service = rollover_service
112
+ self._connection = connection
113
+ self._rollover_pending = False
114
+ # Session-scoped: owned here (not per-widget) so a later Routine
115
+ # phase can share the same single undo slot (Section 6.10).
116
+ self._undo_manager = UndoManager()
117
+ self._safe_symbols = safe_symbols
118
+ # Map foreground/background to the terminal's own ANSI defaults
119
+ # instead of Textual's fixed truecolor theme, so the app respects
120
+ # the user's existing terminal background (Section 11.9-11.10).
121
+ self.theme = "ansi-dark"
122
+
123
+ def _mascot_quote_text(self) -> str:
124
+ mascot, quote = branding.pick_for_date(self._today_provider())
125
+ return f'{mascot} "{quote}"'
126
+
127
+ def compose(self) -> ComposeResult:
128
+ mascot_quote_text = self._mascot_quote_text()
129
+ tasks = self._task_service.list_active_tasks()
130
+
131
+ yield Static(self._header_text(), id="header", markup=False)
132
+ yield Static(mascot_quote_text, id="mascot-quote", markup=False)
133
+ yield Static("", id="spacer")
134
+ yield TaskListView(
135
+ tasks,
136
+ task_service=self._task_service,
137
+ undo_manager=self._undo_manager,
138
+ safe_symbols=self._safe_symbols,
139
+ id="task-list",
140
+ )
141
+ yield Static("", id="status", markup=False)
142
+ yield ShortcutFooter(has_tasks=bool(tasks), id="footer")
143
+
144
+ def _header_text(self) -> str:
145
+ today = self._today_provider()
146
+ return f"{branding.DISPLAY_NAME.lower()} {branding.ICON} Today — {format_header_date(today)}"
147
+
148
+ def on_mount(self) -> None:
149
+ self.set_interval(ROLLOVER_CHECK_INTERVAL_SECONDS, self._check_for_new_day)
150
+
151
+ async def _check_for_new_day(self) -> None:
152
+ if self.query_one(TaskListView).is_editing:
153
+ # Section 18.12: defer while the user is actively typing;
154
+ # on_task_list_view_editing_changed runs it once they finish.
155
+ self._rollover_pending = True
156
+ return
157
+ await self._apply_rollover_if_needed()
158
+
159
+ async def _apply_rollover_if_needed(self) -> None:
160
+ try:
161
+ result = self._rollover_service.roll_forward_if_needed()
162
+ except Exception:
163
+ # A transient failure here must not crash a running session -
164
+ # Section 16.24's fatal-error handling is for startup, not a
165
+ # live background reconciliation. The next check retries.
166
+ return
167
+
168
+ if not result.changed:
169
+ return
170
+
171
+ self.query_one("#header", Static).update(self._header_text())
172
+ self.query_one("#mascot-quote", Static).update(self._mascot_quote_text())
173
+ tasks = self._task_service.list_active_tasks()
174
+ await self.query_one(TaskListView).reload(tasks)
175
+ self.query_one(ShortcutFooter).set_has_tasks(bool(tasks))
176
+ # Section 6.10: Day Rollover clears session undo after completing.
177
+ self._undo_manager.clear()
178
+
179
+ def action_cursor_up(self) -> None:
180
+ self.query_one(TaskListView).select_previous()
181
+
182
+ def action_cursor_down(self) -> None:
183
+ self.query_one(TaskListView).select_next()
184
+
185
+ async def action_new_task(self) -> None:
186
+ await self.query_one(TaskListView).begin_create()
187
+
188
+ async def action_edit_task(self) -> None:
189
+ await self.query_one(TaskListView).begin_edit()
190
+
191
+ async def action_toggle_completed(self) -> None:
192
+ await self.query_one(TaskListView).toggle_completed()
193
+
194
+ async def action_toggle_migrated(self) -> None:
195
+ await self.query_one(TaskListView).toggle_migrated()
196
+
197
+ async def action_delete_or_remove(self) -> None:
198
+ await self.query_one(TaskListView).delete_or_remove()
199
+
200
+ async def action_undo(self) -> None:
201
+ await self.query_one(TaskListView).undo()
202
+
203
+ def on_task_list_view_status_message(self, message: TaskListView.StatusMessage) -> None:
204
+ self.query_one("#status", Static).update(message.text)
205
+
206
+ async def on_task_list_view_editing_changed(self, message: TaskListView.EditingChanged) -> None:
207
+ self.query_one(ShortcutFooter).set_editing(message.editing)
208
+ self.query_one("#status", Static).update("")
209
+ if not message.editing and self._rollover_pending:
210
+ self._rollover_pending = False
211
+ await self._apply_rollover_if_needed()
212
+
213
+ def on_task_list_view_tasks_empty_changed(
214
+ self, message: TaskListView.TasksEmptyChanged
215
+ ) -> None:
216
+ self.query_one(ShortcutFooter).set_has_tasks(message.has_tasks)
217
+
218
+ def action_archive(self) -> None:
219
+ if self._connection is None:
220
+ return
221
+ first_weekday = MetadataRepository(self._connection).get_week_start_day()
222
+ self.push_screen(ArchiveScreen(connection=self._connection, first_weekday=first_weekday))
rook/branding.py ADDED
@@ -0,0 +1,69 @@
1
+ """Centralized presentation identity for Rook.
2
+
3
+ Domain and persistence code must not import this module (see
4
+ DEVELOPMENT_GUIDE.md Section 16.22) so branding can change without
5
+ touching task-state logic, schema, or tests unrelated to display copy.
6
+ """
7
+
8
+ import random
9
+ from datetime import date
10
+
11
+ DISPLAY_NAME = "Rook"
12
+ ICON = "♖"
13
+ FALLBACK_ICON = "R"
14
+ CONSOLE_COMMAND = "rook"
15
+
16
+ # Fallbacks used when the libraries below are empty (Section 18.33).
17
+ MASCOT = "‧₊˚ 🖇️✩ ₊˚⊹"
18
+ QUOTE = "Let's begin."
19
+
20
+ _MASCOTS: list[str] = [
21
+ # Face-forward, decorative-framed
22
+ "。・::・゚★ (。•̀ᴗ-) ✧.。",
23
+ "˚₊‧꒰ (˶ˆ ᗜ ˆ˵) ꒱‧₊˚",
24
+ "⋆。°✩ ( ˙▿˙ ) ✩°。⋆",
25
+ "‧₊˚ ( ◡̈ ) ₊˚‧ ☾",
26
+ "✧・゜: *.(๑˃ᴗ˂)゚・✧",
27
+ "⊹ ˚。 (づ ̄ ³ ̄)づ 。˚ ⊹",
28
+ "˖° ┈┈ ( ノ^ω^)ノ ┈┈ °˖",
29
+ "⋆⁺₊ (。'▽'。)♡ ₊⁺⋆",
30
+ "。:゜(´∀`)゜:。",
31
+ "⌒°。(ˊᵕˋ) 。°⌒",
32
+ # Faceless, purely decorative
33
+ "‧₊˚ 🖇️✩ ₊˚⊹",
34
+ "˚₊‧꒰ ☕✨ ꒱‧₊˚",
35
+ "⋆。°✩ 📖 ⁺˚*•̩̩͙✩.",
36
+ "˚₊‧꒰ა ☕️ ໒꒱‧₊˚",
37
+ "⋆。°✩ ⁺˚•̩̩͙✩•̩̩͙˚",
38
+ "‧₊˚ 🕯️ ⋆。˚ ☾",
39
+ "✧˖°⋆ 🧵📌⋆⁺₊",
40
+ "˚ · . 🩶 . · ˚ ⊹",
41
+ "₊˚⊹♡ 🪞 ⊹˚₊",
42
+ "⋆⁺˚。⋆ 🎀 ⋆。˚⁺⋆",
43
+ "・゜・.。 🧸 。.・゜・",
44
+ "˖⁺‧₊˚ 📖✨ ˚₊‧⁺˖",
45
+ "⊹˚₊ ‧ 🪄 ‧ ₊˚⊹.",
46
+ ]
47
+
48
+ _QUOTES: list[str] = [
49
+ "You are art. You will never be again.",
50
+ "Do the thing, and you shall have the power.",
51
+ "You already survived every day before this one.",
52
+ "Discipline is a mountain of evidence.",
53
+ "You are the proof of your own effort.",
54
+ "Your genius is how everything meets in you.",
55
+ "Showing up is the whole trick.",
56
+ ]
57
+
58
+
59
+ def pick_for_date(today: date) -> tuple[str, str]:
60
+ """Return (mascot, quote) for the given date, stable within the day.
61
+
62
+ Uses the date ordinal as a seed so the pair changes each calendar day
63
+ but never flickers during a session. Mascot and quote are drawn
64
+ independently so every combination is reachable over time.
65
+ """
66
+ rng = random.Random(today.toordinal())
67
+ mascot = rng.choice(_MASCOTS) if _MASCOTS else MASCOT
68
+ quote = rng.choice(_QUOTES) if _QUOTES else QUOTE
69
+ return mascot, quote
File without changes
rook/domain/tasks.py ADDED
@@ -0,0 +1,55 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+
6
+ class TaskState(str, Enum):
7
+ OPEN = "open"
8
+ MIGRATED = "migrated"
9
+ COMPLETED = "completed"
10
+ DELETED = "deleted"
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Task:
15
+ """A one-line Task and its current persistent state.
16
+
17
+ This is intentionally minimal for Phase 2 (id, text, state only).
18
+ Ordering and timestamps are added when a phase first needs them.
19
+ """
20
+
21
+ id: int
22
+ text: str
23
+ state: TaskState
24
+
25
+
26
+ # Section 6.2: initial selection prefers the first Open Task, then the
27
+ # first Migrated, then the first Completed, then the first Soft-Deleted.
28
+ _INITIAL_SELECTION_PRIORITY = (
29
+ TaskState.OPEN,
30
+ TaskState.MIGRATED,
31
+ TaskState.COMPLETED,
32
+ TaskState.DELETED,
33
+ )
34
+
35
+
36
+ def initial_selection(tasks: Sequence[Task]) -> int | None:
37
+ """The id of the Task that should be selected when Today first opens.
38
+
39
+ Returns None when there are no Tasks to select.
40
+ """
41
+ for state in _INITIAL_SELECTION_PRIORITY:
42
+ for task in tasks:
43
+ if task.state is state:
44
+ return task.id
45
+ return None
46
+
47
+
48
+ def toggle_completed(state: TaskState) -> TaskState:
49
+ """Section 8.5: `x` returns a Completed Task to Open, otherwise completes it."""
50
+ return TaskState.OPEN if state is TaskState.COMPLETED else TaskState.COMPLETED
51
+
52
+
53
+ def toggle_migrated(state: TaskState) -> TaskState:
54
+ """Section 8.5: `>` returns a Migrated Task to Open, otherwise migrates it."""
55
+ return TaskState.OPEN if state is TaskState.MIGRATED else TaskState.MIGRATED
rook/formatting.py ADDED
@@ -0,0 +1,10 @@
1
+ from datetime import date
2
+
3
+
4
+ def format_header_date(today: date) -> str:
5
+ """Render a date as "Weekday, Month D, Year" without a leading zero.
6
+
7
+ ``strftime("%-d")`` is a glibc extension not available on Windows'
8
+ CRT, so the day number is formatted separately instead.
9
+ """
10
+ return f"{today:%A, %B} {today.day}, {today:%Y}"
rook/paths.py ADDED
@@ -0,0 +1,31 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from rook import branding
6
+
7
+ DATABASE_FILENAME = "data.sqlite3"
8
+
9
+
10
+ def default_data_directory() -> Path:
11
+ """The per-user directory Rook's database lives in (Section 15.3, 20.10).
12
+
13
+ Never the source repository, the current working directory, or the
14
+ installed package directory.
15
+ """
16
+ if sys.platform == "win32":
17
+ base = os.environ.get("LOCALAPPDATA")
18
+ if not base:
19
+ base = str(Path.home() / "AppData" / "Local")
20
+ return Path(base) / branding.DISPLAY_NAME
21
+
22
+ if sys.platform == "darwin":
23
+ return Path.home() / "Library" / "Application Support" / branding.DISPLAY_NAME
24
+
25
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
26
+ base_dir = Path(xdg_data_home) if xdg_data_home else Path.home() / ".local" / "share"
27
+ return base_dir / branding.CONSOLE_COMMAND
28
+
29
+
30
+ def default_database_path() -> Path:
31
+ return default_data_directory() / DATABASE_FILENAME
File without changes
@@ -0,0 +1,72 @@
1
+ import sqlite3
2
+ from dataclasses import dataclass
3
+ from datetime import date, timedelta
4
+
5
+ from rook.domain.tasks import TaskState
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ArchivedTask:
10
+ text: str
11
+ state: TaskState # completed or deleted
12
+
13
+
14
+ class ArchiveRepository:
15
+ """Read-only SQL access for the Archive (Section 21.14)."""
16
+
17
+ def __init__(self, connection: sqlite3.Connection) -> None:
18
+ self._connection = connection
19
+
20
+ def list_archive_dates(self) -> list[date]:
21
+ """All distinct dates with archived tasks, newest first.
22
+
23
+ Routine activity never sets archived_date on the tasks table, so
24
+ the query naturally excludes it (FR-068).
25
+ """
26
+ rows = self._connection.execute(
27
+ """
28
+ SELECT DISTINCT archived_date
29
+ FROM tasks
30
+ WHERE archived_date IS NOT NULL
31
+ ORDER BY archived_date DESC
32
+ """
33
+ ).fetchall()
34
+ return [date.fromisoformat(row["archived_date"]) for row in rows]
35
+
36
+ def list_week_items(
37
+ self, week_start: date, week_end_exclusive: date
38
+ ) -> list[tuple[date, list[ArchivedTask]]]:
39
+ """Archived tasks in [week_start, week_end_exclusive), grouped by day.
40
+
41
+ Days are returned oldest-first. Tasks within each day follow their
42
+ original archive_order. Only completed and deleted tasks appear
43
+ (the schema constraint ensures no open/migrated tasks are archived).
44
+ """
45
+ rows = self._connection.execute(
46
+ """
47
+ SELECT text, state, archived_date
48
+ FROM tasks
49
+ WHERE archived_date IS NOT NULL
50
+ AND archived_date >= ?
51
+ AND archived_date < ?
52
+ ORDER BY archived_date ASC, archive_order ASC
53
+ """,
54
+ (week_start.isoformat(), week_end_exclusive.isoformat()),
55
+ ).fetchall()
56
+
57
+ grouped: dict[date, list[ArchivedTask]] = {}
58
+ for row in rows:
59
+ d = date.fromisoformat(row["archived_date"])
60
+ grouped.setdefault(d, []).append(
61
+ ArchivedTask(text=row["text"], state=TaskState(row["state"]))
62
+ )
63
+ return [(d, tasks) for d, tasks in grouped.items()]
64
+
65
+
66
+ def week_start_for(d: date, *, first_weekday: int) -> date:
67
+ """Return the start of the calendar week containing d.
68
+
69
+ first_weekday uses Python's weekday() convention: 0=Monday, 6=Sunday.
70
+ """
71
+ days_back = (d.weekday() - first_weekday) % 7
72
+ return d - timedelta(days=days_back)
@@ -0,0 +1,18 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+
4
+
5
+ def connect(path: Path) -> sqlite3.Connection:
6
+ """Open a Rook database connection with the required pragmas (Section 15.4).
7
+
8
+ Creates the parent directory if needed - the database must never be
9
+ expected to already exist at a fresh install.
10
+ """
11
+ path.parent.mkdir(parents=True, exist_ok=True)
12
+ connection = sqlite3.connect(path)
13
+ connection.row_factory = sqlite3.Row
14
+ connection.execute("PRAGMA foreign_keys = ON;")
15
+ connection.execute("PRAGMA busy_timeout = 3000;")
16
+ connection.execute("PRAGMA journal_mode = DELETE;")
17
+ connection.execute("PRAGMA synchronous = FULL;")
18
+ return connection
@@ -0,0 +1,56 @@
1
+ import sqlite3
2
+ from datetime import date
3
+
4
+ _LAST_PROCESSED_DATE_KEY = "last_processed_date"
5
+ _WEEK_START_DAY_KEY = "week_start_day"
6
+
7
+ # Python weekday() values: 0 = Monday, 6 = Sunday.
8
+ _WEEK_START_VALUES = {"sunday": 6, "monday": 0}
9
+ _WEEK_START_DEFAULT = 6 # Sunday
10
+
11
+
12
+ class MetadataRepository:
13
+ """SQL access for the small `app_meta` key/value table (Section 15.7)."""
14
+
15
+ def __init__(self, connection: sqlite3.Connection) -> None:
16
+ self._connection = connection
17
+
18
+ def get_last_processed_date(self) -> date | None:
19
+ row = self._connection.execute(
20
+ "SELECT value FROM app_meta WHERE key = ?", (_LAST_PROCESSED_DATE_KEY,)
21
+ ).fetchone()
22
+ return date.fromisoformat(row["value"]) if row is not None else None
23
+
24
+ def set_last_processed_date(self, value: date) -> None:
25
+ with self._connection:
26
+ self._connection.execute(
27
+ """
28
+ INSERT INTO app_meta (key, value) VALUES (?, ?)
29
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
30
+ """,
31
+ (_LAST_PROCESSED_DATE_KEY, value.isoformat()),
32
+ )
33
+
34
+ def get_week_start_day(self) -> int:
35
+ """Return the first weekday as a Python weekday() int (0=Mon, 6=Sun).
36
+
37
+ Defaults to 6 (Sunday) when the key is absent or unrecognised.
38
+ """
39
+ row = self._connection.execute(
40
+ "SELECT value FROM app_meta WHERE key = ?", (_WEEK_START_DAY_KEY,)
41
+ ).fetchone()
42
+ if row is None:
43
+ return _WEEK_START_DEFAULT
44
+ return _WEEK_START_VALUES.get(row["value"], _WEEK_START_DEFAULT)
45
+
46
+ def set_week_start_day(self, first_weekday: int) -> None:
47
+ """Persist week_start_day. first_weekday must be 0 (Mon) or 6 (Sun)."""
48
+ label = next(k for k, v in _WEEK_START_VALUES.items() if v == first_weekday)
49
+ with self._connection:
50
+ self._connection.execute(
51
+ """
52
+ INSERT INTO app_meta (key, value) VALUES (?, ?)
53
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
54
+ """,
55
+ (_WEEK_START_DAY_KEY, label),
56
+ )