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
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
|
rook/domain/__init__.py
ADDED
|
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
|
+
)
|