tasko 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tasko-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pybass
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tasko-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: tasko
3
+ Version: 0.0.1
4
+ Summary: Personal task manager
5
+ Author: pybass
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3.14
10
+ Classifier: Topic :: Office/Business
11
+ Requires-Dist: rich~=15.0
12
+ Requires-Dist: textual~=8.2
13
+ Requires-Python: >=3.14
14
+ Project-URL: Repository, https://github.com/pybass/tasko
15
+ Project-URL: Issues, https://github.com/pybass/tasko/issues
16
+ Description-Content-Type: text/markdown
17
+
18
+ # tasko
19
+
20
+ A personal task manager: a fast Textual TUI over local SQLite, with a minimal
21
+ CLI for quick capture.
22
+
23
+ ## Usage
24
+
25
+ `tasko` with no arguments launches the TUI — the primary interface, where all
26
+ task and project management lives.
27
+
28
+ The CLI is deliberately minimal (see
29
+ [docs/architecture.md](docs/architecture.md) for why):
30
+
31
+ ```sh
32
+ tasko add "fix nginx" -p infra # quick capture from the shell
33
+ tasko list # a quick look without launching the TUI
34
+ ```
35
+
36
+ Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
37
+ `~/.local/share/tasko`); override the directory with `--data-dir` or the
38
+ `TASKO_DATA_DIR` env var.
39
+
40
+ ## Design
41
+
42
+ - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `cli/` +
43
+ `tui/`), the TUI-first UI strategy, tool choices.
44
+ - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
45
+ and the reasoning behind it.
46
+ - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
47
+ and why.
48
+
49
+ ## License
50
+
51
+ [MIT](LICENSE)
tasko-0.0.1/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # tasko
2
+
3
+ A personal task manager: a fast Textual TUI over local SQLite, with a minimal
4
+ CLI for quick capture.
5
+
6
+ ## Usage
7
+
8
+ `tasko` with no arguments launches the TUI — the primary interface, where all
9
+ task and project management lives.
10
+
11
+ The CLI is deliberately minimal (see
12
+ [docs/architecture.md](docs/architecture.md) for why):
13
+
14
+ ```sh
15
+ tasko add "fix nginx" -p infra # quick capture from the shell
16
+ tasko list # a quick look without launching the TUI
17
+ ```
18
+
19
+ Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
20
+ `~/.local/share/tasko`); override the directory with `--data-dir` or the
21
+ `TASKO_DATA_DIR` env var.
22
+
23
+ ## Design
24
+
25
+ - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `cli/` +
26
+ `tui/`), the TUI-first UI strategy, tool choices.
27
+ - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
28
+ and the reasoning behind it.
29
+ - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
30
+ and why.
31
+
32
+ ## License
33
+
34
+ [MIT](LICENSE)
@@ -0,0 +1,180 @@
1
+ # ============================================================================
2
+ # Project metadata
3
+ # ============================================================================
4
+ [project]
5
+ name = "tasko"
6
+ version = "0.0.1"
7
+ description = "Personal task manager"
8
+ readme = "README.md"
9
+ authors = [{ name = "pybass" }]
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.14"
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Topic :: Office/Business",
17
+ ]
18
+ dependencies = [
19
+ "rich~=15.0",
20
+ "textual~=8.2",
21
+ ]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/pybass/tasko"
25
+ Issues = "https://github.com/pybass/tasko/issues"
26
+
27
+ [project.scripts]
28
+ tasko = "tasko.cli.main:main"
29
+
30
+
31
+ # ============================================================================
32
+ # Build system
33
+ # ============================================================================
34
+ # uv_build is pinned to a compatible range: the backend is an isolated build dependency that
35
+ # uv.lock does not cover, so an open-ended requirement would let the same tag build differently
36
+ # over time.
37
+ [build-system]
38
+ requires = ["uv_build>=0.11,<0.12"]
39
+ build-backend = "uv_build"
40
+
41
+
42
+ # ============================================================================
43
+ # Development dependencies — grouped by role (mirrors the tool sections below)
44
+ # ============================================================================
45
+ [dependency-groups]
46
+ dev = [
47
+ # Linting & formatting
48
+ "ruff~=0.15.22",
49
+ # Type checking
50
+ "mypy~=2.3.0",
51
+ "ty~=0.0.60",
52
+ # Testing & coverage
53
+ "pytest~=9.1.1",
54
+ "pytest-asyncio~=1.4.0",
55
+ "pytest-cov~=7.1.0",
56
+ "pytest-xdist~=3.8.0",
57
+ # Security & vulnerability audit
58
+ "pip-audit~=2.10.1",
59
+ # Dependency hygiene — imports vs declared deps
60
+ "deptry~=0.25.1",
61
+ # Git pre-commit hooks
62
+ "pre-commit~=4.6.0",
63
+ ]
64
+
65
+
66
+ # ============================================================================
67
+ # Ruff — linting & formatting
68
+ # ============================================================================
69
+ [tool.ruff]
70
+ line-length = 130
71
+ target-version = "py314"
72
+
73
+ [tool.ruff.lint]
74
+ select = ["ALL"]
75
+ ignore = [
76
+ # --- Ecosystem / tool conflicts ---
77
+ "COM812", # trailing-comma — handled by ruff formatter, must be off here
78
+ "ISC001", # single-line-implicit-string-concat — conflicts with ruff formatter
79
+ "D203", # one-blank-line-before-class — conflicts with D211
80
+ "D213", # multi-line-summary-second-line — conflicts with D212
81
+
82
+ # --- Stylistic preferences ---
83
+ "A005", # stdlib-module-shadowing — allowed when intentional
84
+ "PLC0414", # useless-import-alias — `import x as x` is our explicit re-export marker
85
+ "RET503", # implicit-return — explicit None returns are noisy
86
+ "G004", # f-strings in logging — accepted
87
+
88
+ # --- flake8-pytest-style: opinionated rules only (bug-catchers stay on) ---
89
+ "PT001", # fixture-incorrect-parentheses-style — cosmetic
90
+ "PT006", # parametrize-names-wrong-type — cosmetic
91
+ "PT007", # parametrize-values-wrong-type — cosmetic
92
+ "PT023", # mark-incorrect-parentheses-style — cosmetic
93
+
94
+ # --- Categories that fight our coding style ---
95
+ "FIX", # flake8-fixme — TODOs are fine; tracked elsewhere
96
+ "FBT", # boolean-trap — too restrictive
97
+ "EM", # error-message strings — inline messages accepted
98
+ "TRY003", # raise-vanilla-args — too noisy
99
+ "C901", # complex-structure — judgment call
100
+
101
+ # --- Soft pylint thresholds ---
102
+ "PLR0911", # too-many-return-statements
103
+ "PLR0912", # too-many-branches
104
+ "PLR0913", # too-many-arguments
105
+ "PLR2004", # magic-value-comparison
106
+
107
+ # --- TODO metadata (optional) ---
108
+ "TD002", # missing-todo-author
109
+ "TD003", # missing-todo-link
110
+
111
+ # --- Async edge case ---
112
+ "ASYNC109", # async-function-with-timeout — debatable, off
113
+ ]
114
+
115
+ [tool.ruff.lint.per-file-ignores]
116
+ "tests/**/*.py" = ["ANN", "S"] # tests skip type-annotation (ANN) and assert/security (S) rules
117
+
118
+ [tool.ruff.format]
119
+ quote-style = "double"
120
+ indent-style = "space"
121
+ docstring-code-format = true # keep code examples in docstrings formatted like real code
122
+
123
+
124
+ # ============================================================================
125
+ # Type checking — mypy (strict) + ty
126
+ # ============================================================================
127
+ [tool.mypy]
128
+ python_version = "3.14"
129
+ strict = true
130
+
131
+ # Relax strictness on tests so pytest fixtures and untyped helpers don't fight you.
132
+ [[tool.mypy.overrides]]
133
+ module = "tests.*"
134
+ disallow_untyped_defs = false
135
+ disallow_incomplete_defs = false
136
+ disallow_untyped_decorators = false
137
+ disallow_untyped_calls = false
138
+ warn_unused_ignores = false
139
+
140
+ [tool.ty.environment]
141
+ python-version = "3.14"
142
+
143
+ # Same intent as the mypy test override: silence test-only false positives.
144
+ [[tool.ty.overrides]]
145
+ include = ["tests/**"]
146
+ [tool.ty.overrides.rules]
147
+ possibly-missing-attribute = "ignore"
148
+ invalid-assignment = "ignore"
149
+
150
+
151
+ # ============================================================================
152
+ # Spell checking — codespell
153
+ # ============================================================================
154
+ [tool.codespell]
155
+ skip = "uv.lock,.git,.venv,.mypy_cache,.ruff_cache,.pytest_cache,htmlcov"
156
+
157
+
158
+ # ============================================================================
159
+ # Testing & coverage — pytest + coverage
160
+ # ============================================================================
161
+ [tool.pytest.ini_options]
162
+ testpaths = ["tests"]
163
+ addopts = "-ra --strict-markers --strict-config --import-mode=importlib"
164
+ xfail_strict = true
165
+ # `auto` lets async TUI tests (Textual Pilot) be plain `async def` functions.
166
+ asyncio_mode = "auto"
167
+ asyncio_default_fixture_loop_scope = "function"
168
+ # Any warning raised in tests is a failure — deprecations surface at the commit that introduces
169
+ # them, not at the upgrade that removes the API. Add targeted ignores below "error" when an
170
+ # unfixable third-party warning appears, e.g.:
171
+ # "ignore:<message prefix>:DeprecationWarning:somepackage.*",
172
+ filterwarnings = ["error"]
173
+
174
+ [tool.coverage.run]
175
+ branch = true
176
+ source = ["src"]
177
+
178
+ [tool.coverage.report]
179
+ show_missing = true
180
+ skip_covered = false
@@ -0,0 +1 @@
1
+ """Personal task manager."""
@@ -0,0 +1 @@
1
+ """CLI adapter."""
@@ -0,0 +1,89 @@
1
+ """CLI adapter: quick capture and a quick look at the list. The TUI is the primary interface."""
2
+
3
+ # ruff: noqa: T201 -- printing to stdout is this module's purpose: every command's user-facing
4
+ # output goes through print(); the rule targets stray debug prints, which cannot exist here.
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+
12
+ from tasko.core.core import Core
13
+ from tasko.core.errors import AppError
14
+ from tasko.core.models import Priority, Status
15
+
16
+ DATA_DIR_ENV_VAR = "TASKO_DATA_DIR" # overrides the default data directory
17
+
18
+
19
+ def main() -> None:
20
+ """Entry point: no subcommand launches the TUI; `add` and `list` serve capture and a quick look."""
21
+ args = _build_parser().parse_args()
22
+ core = Core(_resolve_db_path(args.data_dir))
23
+ try:
24
+ if args.command is None:
25
+ # Lazy import: quick capture (`tasko add`) must not pay the TUI framework's import cost.
26
+ from tasko.tui.app import TaskoApp # noqa: PLC0415
27
+
28
+ TaskoApp(core).run()
29
+ elif args.command == "add":
30
+ _cmd_add(core, args)
31
+ else:
32
+ _cmd_list(core, args)
33
+ except AppError as e:
34
+ sys.exit(f"error: {e}")
35
+ finally:
36
+ core.close()
37
+
38
+
39
+ def _resolve_db_path(data_dir: Path | None = None) -> Path:
40
+ """Path to the SQLite database file, resolving the data directory as CLI arg → env var → XDG default."""
41
+ if data_dir is None:
42
+ env = os.environ.get(DATA_DIR_ENV_VAR)
43
+ if env:
44
+ data_dir = Path(env)
45
+ else:
46
+ xdg_data_home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
47
+ data_dir = xdg_data_home / "tasko"
48
+ return data_dir / "tasko.db"
49
+
50
+
51
+ def _build_parser() -> argparse.ArgumentParser:
52
+ """Build the argument parser with the `add` and `list` subcommands."""
53
+ parser = argparse.ArgumentParser(prog="tasko", description="Personal task manager.")
54
+ parser.add_argument("--version", action="version", version=version("tasko"))
55
+ parser.add_argument("--data-dir", type=Path, help=f"data directory (env: {DATA_DIR_ENV_VAR}; default: XDG data dir)")
56
+ subparsers = parser.add_subparsers(dest="command")
57
+
58
+ add = subparsers.add_parser("add", help="add a task")
59
+ add.add_argument("title", help="task title")
60
+ add.add_argument("-p", "--project", help="project name (default: the default project)")
61
+ add.add_argument("-b", "--body", help="longer description")
62
+ add.add_argument("--priority", type=Priority, choices=list(Priority), default=Priority.MEDIUM, help="task priority")
63
+
64
+ list_ = subparsers.add_parser("list", help="list tasks (open ones by default)")
65
+ list_.add_argument("-p", "--project", help="filter by project name")
66
+ list_.add_argument("-s", "--status", type=Status, choices=list(Status), help="filter by status")
67
+ list_.add_argument("--all", action="store_true", help="include done tasks")
68
+ return parser
69
+
70
+
71
+ def _cmd_add(core: Core, args: argparse.Namespace) -> None:
72
+ """Create a task and confirm where it landed."""
73
+ project_id = core.get_project_by_name(args.project).id if args.project is not None else None
74
+ task = core.add_task(args.title, project_id=project_id, body=args.body, priority=args.priority)
75
+ print(f"Task #{task.id} created in project '{task.project_name}'.")
76
+
77
+
78
+ def _cmd_list(core: Core, args: argparse.Namespace) -> None:
79
+ """Print tasks as an aligned table."""
80
+ project_id = core.get_project_by_name(args.project).id if args.project is not None else None
81
+ tasks = core.list_tasks(project_id=project_id, status=args.status, include_done=args.all)
82
+ if not tasks:
83
+ print("No tasks.")
84
+ return
85
+ header = ("ID", "Project", "Status", "Priority", "Title")
86
+ rows = [(str(t.id), t.project_name, t.status, t.priority, t.title) for t in tasks]
87
+ widths = [max(len(row[i]) for row in (header, *rows)) for i in range(len(header))]
88
+ for row in (header, *rows):
89
+ print(" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip())
@@ -0,0 +1 @@
1
+ """Core business logic — the application without UI."""
@@ -0,0 +1,267 @@
1
+ """Application core: SQLite storage and business operations in one class."""
2
+
3
+ import sqlite3
4
+ import time
5
+ from typing import TYPE_CHECKING
6
+
7
+ from tasko.core.errors import AppError
8
+ from tasko.core.migrations import MIGRATIONS
9
+ from tasko.core.models import AppState, Priority, Project, Status, Task
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ def _now() -> int:
16
+ """Return the current unix time in whole seconds."""
17
+ return int(time.time())
18
+
19
+
20
+ class Core:
21
+ """The application without UI: owns the SQLite connection and every operation on it.
22
+
23
+ Adapters (CLI, TUI) call these methods and show AppError messages to the user.
24
+ """
25
+
26
+ def __init__(self, db_path: Path) -> None:
27
+ """Open the database (creating the file and its directory if needed) and migrate it."""
28
+ db_path.parent.mkdir(parents=True, exist_ok=True)
29
+ self._conn = sqlite3.connect(db_path) # single shared connection
30
+ self._conn.row_factory = sqlite3.Row
31
+ self._conn.execute("PRAGMA journal_mode = WAL")
32
+ self._conn.execute("PRAGMA busy_timeout = 5000")
33
+ self._conn.execute("PRAGMA foreign_keys = ON")
34
+ self._migrate()
35
+
36
+ def close(self) -> None:
37
+ """Close the connection."""
38
+ self._conn.close()
39
+
40
+ def _migrate(self) -> None:
41
+ """Apply pending migrations, tracked via PRAGMA user_version."""
42
+ version = int(self._conn.execute("PRAGMA user_version").fetchone()[0])
43
+ for number, script in enumerate(MIGRATIONS[version:], start=version + 1):
44
+ # The script and the version bump commit together: a crash mid-migration rolls back
45
+ # cleanly, so a migration is either fully applied and recorded, or not at all.
46
+ self._conn.executescript(f"BEGIN;\n{script}\nPRAGMA user_version = {number};\nCOMMIT;")
47
+
48
+ # --- Tasks ---
49
+
50
+ def add_task(
51
+ self, title: str, *, project_id: int | None = None, body: str | None = None, priority: Priority = Priority.MEDIUM
52
+ ) -> Task:
53
+ """Create a task and return it. Without a project id, it goes to the selected or default project."""
54
+ title = title.strip()
55
+ if not title:
56
+ raise AppError("Task title cannot be empty.")
57
+ if project_id is None:
58
+ state = self.app_state()
59
+ project_id = state.selected_project_id or state.default_project_id
60
+ try:
61
+ row = self._conn.execute(
62
+ """
63
+ INSERT INTO tasks (project_id, title, body, priority, created_at, updated_at)
64
+ VALUES (:project_id, :title, :body, :priority, :now, :now)
65
+ RETURNING id
66
+ """,
67
+ {"project_id": project_id, "title": title, "body": body, "priority": priority, "now": _now()},
68
+ ).fetchone()
69
+ except sqlite3.IntegrityError as e: # foreign key: no such project
70
+ raise AppError(f"Project #{project_id} not found.") from e
71
+ self._conn.commit()
72
+ return self.get_task(int(row["id"]))
73
+
74
+ def get_task(self, task_id: int) -> Task:
75
+ """Return a task by id; raises AppError when it does not exist."""
76
+ row = self._conn.execute(
77
+ "SELECT t.*, p.name AS project_name FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.id = ?",
78
+ (task_id,),
79
+ ).fetchone()
80
+ if row is None:
81
+ raise AppError(f"Task #{task_id} not found.")
82
+ return Task.from_row(row)
83
+
84
+ def list_tasks(
85
+ self, *, project_id: int | None = None, status: Status | None = None, include_done: bool = False
86
+ ) -> list[Task]:
87
+ """Return tasks in working order, optionally filtered by project id and/or status; a None filter means "any".
88
+
89
+ Working order: doing first, then by priority, then recently updated; done tasks
90
+ sink to the bottom (recently finished first, since done_at == updated_at). They
91
+ are excluded by default; asking for status DONE explicitly implies them.
92
+ """
93
+ rows = self._conn.execute(
94
+ """
95
+ SELECT t.*, p.name AS project_name
96
+ FROM tasks t JOIN projects p ON p.id = t.project_id
97
+ WHERE (:project_id IS NULL OR t.project_id = :project_id)
98
+ AND (:status IS NULL OR t.status = :status)
99
+ AND (:include_done OR t.status != 'done')
100
+ ORDER BY t.status = 'done',
101
+ t.status = 'doing' DESC,
102
+ CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
103
+ t.updated_at DESC
104
+ """,
105
+ {"project_id": project_id, "status": status, "include_done": include_done or status is Status.DONE},
106
+ ).fetchall()
107
+ return [Task.from_row(row) for row in rows]
108
+
109
+ def project_task_counts(self) -> dict[int, tuple[int, int]]:
110
+ """Return {project_id: (open, total)} task counts; projects without tasks are absent."""
111
+ rows = self._conn.execute(
112
+ "SELECT project_id, sum(status != 'done') AS open, count(*) AS total FROM tasks GROUP BY project_id"
113
+ ).fetchall()
114
+ return {int(row["project_id"]): (int(row["open"]), int(row["total"])) for row in rows}
115
+
116
+ def set_status(self, task_id: int, status: Status) -> None:
117
+ """Move a task to the given status, maintaining done_at."""
118
+ now = _now() # one timestamp for both columns: on completion done_at == updated_at
119
+ done_at = now if status is Status.DONE else None
120
+ cur = self._conn.execute(
121
+ "UPDATE tasks SET status = :status, done_at = :done_at, updated_at = :now WHERE id = :id",
122
+ {"status": status, "done_at": done_at, "now": now, "id": task_id},
123
+ )
124
+ self._conn.commit()
125
+ if cur.rowcount == 0:
126
+ raise AppError(f"Task #{task_id} not found.")
127
+
128
+ def set_title(self, task_id: int, title: str) -> None:
129
+ """Rename a task; the new title must be non-empty."""
130
+ title = title.strip()
131
+ if not title:
132
+ raise AppError("Task title cannot be empty.")
133
+ cur = self._conn.execute(
134
+ "UPDATE tasks SET title = :title, updated_at = :now WHERE id = :id",
135
+ {"title": title, "now": _now(), "id": task_id},
136
+ )
137
+ self._conn.commit()
138
+ if cur.rowcount == 0:
139
+ raise AppError(f"Task #{task_id} not found.")
140
+
141
+ def set_body(self, task_id: int, body: str | None) -> None:
142
+ """Replace a task's body; empty or whitespace-only text clears it."""
143
+ if body is not None:
144
+ body = body.strip() or None
145
+ cur = self._conn.execute(
146
+ "UPDATE tasks SET body = :body, updated_at = :now WHERE id = :id",
147
+ {"body": body, "now": _now(), "id": task_id},
148
+ )
149
+ self._conn.commit()
150
+ if cur.rowcount == 0:
151
+ raise AppError(f"Task #{task_id} not found.")
152
+
153
+ def set_priority(self, task_id: int, priority: Priority) -> None:
154
+ """Change a task's priority."""
155
+ cur = self._conn.execute(
156
+ "UPDATE tasks SET priority = :priority, updated_at = :now WHERE id = :id",
157
+ {"priority": priority, "now": _now(), "id": task_id},
158
+ )
159
+ self._conn.commit()
160
+ if cur.rowcount == 0:
161
+ raise AppError(f"Task #{task_id} not found.")
162
+
163
+ def set_project(self, task_id: int, project_id: int) -> None:
164
+ """Move a task to another project."""
165
+ try:
166
+ cur = self._conn.execute(
167
+ "UPDATE tasks SET project_id = :project_id, updated_at = :now WHERE id = :id",
168
+ {"project_id": project_id, "now": _now(), "id": task_id},
169
+ )
170
+ except sqlite3.IntegrityError as e: # foreign key: no such project
171
+ raise AppError(f"Project #{project_id} not found.") from e
172
+ self._conn.commit()
173
+ if cur.rowcount == 0:
174
+ raise AppError(f"Task #{task_id} not found.")
175
+
176
+ def delete_task(self, task_id: int) -> None:
177
+ """Delete a task permanently."""
178
+ cur = self._conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
179
+ self._conn.commit()
180
+ if cur.rowcount == 0:
181
+ raise AppError(f"Task #{task_id} not found.")
182
+
183
+ # --- Projects ---
184
+
185
+ def list_projects(self) -> list[Project]:
186
+ """Return all projects ordered by name."""
187
+ rows = self._conn.execute("SELECT id, name FROM projects ORDER BY name").fetchall()
188
+ return [Project(**dict(row)) for row in rows]
189
+
190
+ def get_project(self, project_id: int) -> Project:
191
+ """Return a project by id; raises AppError when it does not exist."""
192
+ row = self._conn.execute("SELECT id, name FROM projects WHERE id = ?", (project_id,)).fetchone()
193
+ if row is None:
194
+ raise AppError(f"Project #{project_id} not found.")
195
+ return Project(**dict(row))
196
+
197
+ def get_project_by_name(self, name: str) -> Project:
198
+ """Return a project by its unique name; raises AppError when it does not exist.
199
+
200
+ For adapters whose input addresses projects by name (the CLI); everything else uses ids.
201
+ """
202
+ row = self._conn.execute("SELECT id, name FROM projects WHERE name = ?", (name,)).fetchone()
203
+ if row is None:
204
+ raise AppError(f"Project '{name}' not found.")
205
+ return Project(**dict(row))
206
+
207
+ def create_project(self, name: str) -> None:
208
+ """Create a project with a unique non-empty name."""
209
+ name = name.strip()
210
+ if not name:
211
+ raise AppError("Project name cannot be empty.")
212
+ try:
213
+ self._conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
214
+ except sqlite3.IntegrityError as e:
215
+ raise AppError(f"Project '{name}' already exists.") from e
216
+ self._conn.commit()
217
+
218
+ def rename_project(self, project_id: int, new_name: str) -> None:
219
+ """Rename a project; the new name must be unique and non-empty."""
220
+ new_name = new_name.strip()
221
+ if not new_name:
222
+ raise AppError("Project name cannot be empty.")
223
+ try:
224
+ cur = self._conn.execute("UPDATE projects SET name = ? WHERE id = ?", (new_name, project_id))
225
+ except sqlite3.IntegrityError as e:
226
+ raise AppError(f"Project '{new_name}' already exists.") from e
227
+ self._conn.commit()
228
+ if cur.rowcount == 0:
229
+ raise AppError(f"Project #{project_id} not found.")
230
+
231
+ def delete_project(self, project_id: int) -> None:
232
+ """Delete a project together with its tasks (ON DELETE CASCADE); the default project cannot be deleted."""
233
+ if project_id == self.app_state().default_project_id:
234
+ raise AppError("Cannot delete the default project; make another project the default first.")
235
+ cur = self._conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
236
+ self._conn.commit()
237
+ if cur.rowcount == 0:
238
+ raise AppError(f"Project #{project_id} not found.")
239
+
240
+ # --- App state ---
241
+
242
+ def app_state(self) -> AppState:
243
+ """Return the singleton application state."""
244
+ # Explicit columns, not *: the row also carries the singleton id, which AppState
245
+ # does not model and the dataclass constructor would reject.
246
+ row = self._conn.execute("SELECT default_project_id, selected_project_id, theme FROM app_state").fetchone()
247
+ if row is None: # invariant broken — the database was edited outside the app; fail loudly
248
+ raise RuntimeError("database invariant violated: app_state row missing")
249
+ return AppState(**dict(row))
250
+
251
+ def set_selected_project(self, project_id: int | None) -> None:
252
+ """Set the persisted project filter; None means all projects."""
253
+ self._conn.execute("UPDATE app_state SET selected_project_id = ? WHERE id = 1", (project_id,))
254
+ self._conn.commit()
255
+
256
+ def set_theme(self, theme: str) -> None:
257
+ """Persist the UI theme choice."""
258
+ self._conn.execute("UPDATE app_state SET theme = ? WHERE id = 1", (theme,))
259
+ self._conn.commit()
260
+
261
+ def set_default_project(self, project_id: int) -> None:
262
+ """Make the given project the default target for quick capture."""
263
+ try:
264
+ self._conn.execute("UPDATE app_state SET default_project_id = ? WHERE id = 1", (project_id,))
265
+ except sqlite3.IntegrityError as e: # foreign key: no such project
266
+ raise AppError(f"Project #{project_id} not found.") from e
267
+ self._conn.commit()
@@ -0,0 +1,5 @@
1
+ """Application errors shared by all adapters."""
2
+
3
+
4
+ class AppError(Exception):
5
+ """Business-rule violation or bad input; adapters show the message to the user."""