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 +21 -0
- tasko-0.0.1/PKG-INFO +51 -0
- tasko-0.0.1/README.md +34 -0
- tasko-0.0.1/pyproject.toml +180 -0
- tasko-0.0.1/src/tasko/__init__.py +1 -0
- tasko-0.0.1/src/tasko/cli/__init__.py +1 -0
- tasko-0.0.1/src/tasko/cli/main.py +89 -0
- tasko-0.0.1/src/tasko/core/__init__.py +1 -0
- tasko-0.0.1/src/tasko/core/core.py +267 -0
- tasko-0.0.1/src/tasko/core/errors.py +5 -0
- tasko-0.0.1/src/tasko/core/migrations.py +49 -0
- tasko-0.0.1/src/tasko/core/models.py +65 -0
- tasko-0.0.1/src/tasko/tui/__init__.py +1 -0
- tasko-0.0.1/src/tasko/tui/app.py +74 -0
- tasko-0.0.1/src/tasko/tui/screens/__init__.py +1 -0
- tasko-0.0.1/src/tasko/tui/screens/dialogs.py +342 -0
- tasko-0.0.1/src/tasko/tui/screens/projects.py +160 -0
- tasko-0.0.1/src/tasko/tui/screens/task_detail.py +223 -0
- tasko-0.0.1/src/tasko/tui/screens/task_list.py +335 -0
- tasko-0.0.1/src/tasko/tui/widgets.py +40 -0
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()
|