tasko 0.0.1__tar.gz → 0.0.3__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 → tasko-0.0.3}/PKG-INFO +7 -17
- tasko-0.0.3/README.md +24 -0
- tasko-0.0.3/pyproject.toml +121 -0
- tasko-0.0.1/pyproject.toml → tasko-0.0.3/pyproject.toml.orig +11 -22
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/core/core.py +13 -40
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/core/migrations.py +2 -2
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/core/models.py +2 -4
- tasko-0.0.3/src/tasko/main.py +33 -0
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/app.py +7 -9
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/screens/dialogs.py +10 -32
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/screens/projects.py +6 -10
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/screens/task_detail.py +9 -13
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/screens/task_list.py +54 -20
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/widgets.py +2 -6
- tasko-0.0.1/README.md +0 -34
- tasko-0.0.1/src/tasko/cli/__init__.py +0 -1
- tasko-0.0.1/src/tasko/cli/main.py +0 -89
- {tasko-0.0.1 → tasko-0.0.3}/LICENSE +0 -0
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/__init__.py +0 -0
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/core/__init__.py +0 -0
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/core/errors.py +0 -0
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/__init__.py +0 -0
- {tasko-0.0.1 → tasko-0.0.3}/src/tasko/tui/screens/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasko
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Personal task manager
|
|
5
5
|
Author: pybass
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,30 +17,20 @@ Description-Content-Type: text/markdown
|
|
|
17
17
|
|
|
18
18
|
# tasko
|
|
19
19
|
|
|
20
|
-
A personal task manager: a fast Textual TUI over local SQLite
|
|
21
|
-
CLI for quick capture.
|
|
20
|
+
A personal task manager: a fast Textual TUI over local SQLite.
|
|
22
21
|
|
|
23
22
|
## Usage
|
|
24
23
|
|
|
25
|
-
`tasko`
|
|
26
|
-
|
|
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
|
-
```
|
|
24
|
+
`tasko` launches the TUI — the only interface, where all task and project
|
|
25
|
+
management lives.
|
|
35
26
|
|
|
36
27
|
Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
|
|
37
|
-
`~/.local/share/tasko`); override the directory with `--data-dir
|
|
38
|
-
`TASKO_DATA_DIR` env var.
|
|
28
|
+
`~/.local/share/tasko`); override the directory with `--data-dir`.
|
|
39
29
|
|
|
40
30
|
## Design
|
|
41
31
|
|
|
42
|
-
- [docs/architecture.md](docs/architecture.md) — layering (`core/` + `
|
|
43
|
-
|
|
32
|
+
- [docs/architecture.md](docs/architecture.md) — layering (`core/` + `tui/`),
|
|
33
|
+
the TUI-only UI strategy, tool choices.
|
|
44
34
|
- [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
|
|
45
35
|
and the reasoning behind it.
|
|
46
36
|
- [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
|
tasko-0.0.3/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# tasko
|
|
2
|
+
|
|
3
|
+
A personal task manager: a fast Textual TUI over local SQLite.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
`tasko` launches the TUI — the only interface, where all task and project
|
|
8
|
+
management lives.
|
|
9
|
+
|
|
10
|
+
Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
|
|
11
|
+
`~/.local/share/tasko`); override the directory with `--data-dir`.
|
|
12
|
+
|
|
13
|
+
## Design
|
|
14
|
+
|
|
15
|
+
- [docs/architecture.md](docs/architecture.md) — layering (`core/` + `tui/`),
|
|
16
|
+
the TUI-only UI strategy, tool choices.
|
|
17
|
+
- [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
|
|
18
|
+
and the reasoning behind it.
|
|
19
|
+
- [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
|
|
20
|
+
and why.
|
|
21
|
+
|
|
22
|
+
## License
|
|
23
|
+
|
|
24
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tasko"
|
|
3
|
+
version = "0.0.3"
|
|
4
|
+
description = "Personal task manager"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Environment :: Console",
|
|
11
|
+
"Programming Language :: Python :: 3.14",
|
|
12
|
+
"Topic :: Office/Business",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"rich~=15.0",
|
|
16
|
+
"textual~=8.2",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[project.authors]]
|
|
20
|
+
name = "pybass"
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://github.com/pybass/tasko"
|
|
24
|
+
Issues = "https://github.com/pybass/tasko/issues"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
tasko = "tasko.main:main"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.11,<0.13"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"ruff~=0.16.2",
|
|
36
|
+
"mypy~=2.3.0",
|
|
37
|
+
"pytest~=9.1.1",
|
|
38
|
+
"pytest-asyncio~=1.4.0",
|
|
39
|
+
"pytest-cov~=7.1.0",
|
|
40
|
+
"pytest-xdist~=3.8.0",
|
|
41
|
+
"pip-audit~=2.10.1",
|
|
42
|
+
"deptry~=0.25.1",
|
|
43
|
+
"pre-commit~=4.6.2",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 130
|
|
48
|
+
target-version = "py314"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["ALL"]
|
|
52
|
+
ignore = [
|
|
53
|
+
"COM812",
|
|
54
|
+
"D203",
|
|
55
|
+
"D213",
|
|
56
|
+
"A005",
|
|
57
|
+
"PLC0414",
|
|
58
|
+
"RET503",
|
|
59
|
+
"G004",
|
|
60
|
+
"PT001",
|
|
61
|
+
"PT006",
|
|
62
|
+
"PT007",
|
|
63
|
+
"PT023",
|
|
64
|
+
"FIX",
|
|
65
|
+
"FBT",
|
|
66
|
+
"EM",
|
|
67
|
+
"CPY",
|
|
68
|
+
"TC",
|
|
69
|
+
"TRY003",
|
|
70
|
+
"C901",
|
|
71
|
+
"PLR0911",
|
|
72
|
+
"PLR0912",
|
|
73
|
+
"PLR0913",
|
|
74
|
+
"PLR2004",
|
|
75
|
+
"ASYNC109",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.pep8-naming]
|
|
79
|
+
classmethod-decorators = ["pydantic.field_validator"]
|
|
80
|
+
|
|
81
|
+
[tool.ruff.lint.per-file-ignores]
|
|
82
|
+
"tests/**/*.py" = [
|
|
83
|
+
"ANN",
|
|
84
|
+
"S",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[tool.ruff.format]
|
|
88
|
+
quote-style = "double"
|
|
89
|
+
indent-style = "space"
|
|
90
|
+
docstring-code-format = true
|
|
91
|
+
|
|
92
|
+
[tool.mypy]
|
|
93
|
+
python_version = "3.14"
|
|
94
|
+
strict = true
|
|
95
|
+
|
|
96
|
+
[[tool.mypy.overrides]]
|
|
97
|
+
module = "tests.*"
|
|
98
|
+
disallow_untyped_defs = false
|
|
99
|
+
disallow_incomplete_defs = false
|
|
100
|
+
disallow_untyped_decorators = false
|
|
101
|
+
disallow_untyped_calls = false
|
|
102
|
+
warn_unused_ignores = false
|
|
103
|
+
|
|
104
|
+
[tool.codespell]
|
|
105
|
+
skip = "uv.lock,.git,.venv,.mypy_cache,.ruff_cache,.pytest_cache,htmlcov"
|
|
106
|
+
|
|
107
|
+
[tool.pytest.ini_options]
|
|
108
|
+
testpaths = ["tests"]
|
|
109
|
+
addopts = "-ra --strict-markers --strict-config --import-mode=importlib"
|
|
110
|
+
xfail_strict = true
|
|
111
|
+
asyncio_mode = "auto"
|
|
112
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
113
|
+
filterwarnings = ["error"]
|
|
114
|
+
|
|
115
|
+
[tool.coverage.run]
|
|
116
|
+
branch = true
|
|
117
|
+
source = ["src"]
|
|
118
|
+
|
|
119
|
+
[tool.coverage.report]
|
|
120
|
+
show_missing = true
|
|
121
|
+
skip_covered = false
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# ============================================================================
|
|
4
4
|
[project]
|
|
5
5
|
name = "tasko"
|
|
6
|
-
version = "0.0.
|
|
6
|
+
version = "0.0.3"
|
|
7
7
|
description = "Personal task manager"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
authors = [{ name = "pybass" }]
|
|
@@ -25,7 +25,7 @@ Repository = "https://github.com/pybass/tasko"
|
|
|
25
25
|
Issues = "https://github.com/pybass/tasko/issues"
|
|
26
26
|
|
|
27
27
|
[project.scripts]
|
|
28
|
-
tasko = "tasko.
|
|
28
|
+
tasko = "tasko.main:main"
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
# ============================================================================
|
|
@@ -35,7 +35,7 @@ tasko = "tasko.cli.main:main"
|
|
|
35
35
|
# uv.lock does not cover, so an open-ended requirement would let the same tag build differently
|
|
36
36
|
# over time.
|
|
37
37
|
[build-system]
|
|
38
|
-
requires = ["uv_build>=0.11,<0.
|
|
38
|
+
requires = ["uv_build>=0.11,<0.13"]
|
|
39
39
|
build-backend = "uv_build"
|
|
40
40
|
|
|
41
41
|
|
|
@@ -45,10 +45,9 @@ build-backend = "uv_build"
|
|
|
45
45
|
[dependency-groups]
|
|
46
46
|
dev = [
|
|
47
47
|
# Linting & formatting
|
|
48
|
-
"ruff~=0.
|
|
48
|
+
"ruff~=0.16.2",
|
|
49
49
|
# Type checking
|
|
50
50
|
"mypy~=2.3.0",
|
|
51
|
-
"ty~=0.0.60",
|
|
52
51
|
# Testing & coverage
|
|
53
52
|
"pytest~=9.1.1",
|
|
54
53
|
"pytest-asyncio~=1.4.0",
|
|
@@ -59,7 +58,7 @@ dev = [
|
|
|
59
58
|
# Dependency hygiene — imports vs declared deps
|
|
60
59
|
"deptry~=0.25.1",
|
|
61
60
|
# Git pre-commit hooks
|
|
62
|
-
"pre-commit~=4.6.
|
|
61
|
+
"pre-commit~=4.6.2",
|
|
63
62
|
]
|
|
64
63
|
|
|
65
64
|
|
|
@@ -75,7 +74,6 @@ select = ["ALL"]
|
|
|
75
74
|
ignore = [
|
|
76
75
|
# --- Ecosystem / tool conflicts ---
|
|
77
76
|
"COM812", # trailing-comma — handled by ruff formatter, must be off here
|
|
78
|
-
"ISC001", # single-line-implicit-string-concat — conflicts with ruff formatter
|
|
79
77
|
"D203", # one-blank-line-before-class — conflicts with D211
|
|
80
78
|
"D213", # multi-line-summary-second-line — conflicts with D212
|
|
81
79
|
|
|
@@ -95,6 +93,8 @@ ignore = [
|
|
|
95
93
|
"FIX", # flake8-fixme — TODOs are fine; tracked elsewhere
|
|
96
94
|
"FBT", # boolean-trap — too restrictive
|
|
97
95
|
"EM", # error-message strings — inline messages accepted
|
|
96
|
+
"CPY", # flake8-copyright — no copyright headers: internal-only code, ships no license by design (README)
|
|
97
|
+
"TC", # flake8-type-checking — TYPE_CHECKING blocks save import time we don't care about; cost: import noise + broken runtime annotations (pydantic)
|
|
98
98
|
"TRY003", # raise-vanilla-args — too noisy
|
|
99
99
|
"C901", # complex-structure — judgment call
|
|
100
100
|
|
|
@@ -104,14 +104,13 @@ ignore = [
|
|
|
104
104
|
"PLR0913", # too-many-arguments
|
|
105
105
|
"PLR2004", # magic-value-comparison
|
|
106
106
|
|
|
107
|
-
# --- TODO metadata (optional) ---
|
|
108
|
-
"TD002", # missing-todo-author
|
|
109
|
-
"TD003", # missing-todo-link
|
|
110
|
-
|
|
111
107
|
# --- Async edge case ---
|
|
112
108
|
"ASYNC109", # async-function-with-timeout — debatable, off
|
|
113
109
|
]
|
|
114
110
|
|
|
111
|
+
[tool.ruff.lint.pep8-naming]
|
|
112
|
+
classmethod-decorators = ["pydantic.field_validator"] # N805: @field_validator implies classmethod — cls is correct without a stacked @classmethod
|
|
113
|
+
|
|
115
114
|
[tool.ruff.lint.per-file-ignores]
|
|
116
115
|
"tests/**/*.py" = ["ANN", "S"] # tests skip type-annotation (ANN) and assert/security (S) rules
|
|
117
116
|
|
|
@@ -122,7 +121,7 @@ docstring-code-format = true # keep code examples in docstrings formatted like
|
|
|
122
121
|
|
|
123
122
|
|
|
124
123
|
# ============================================================================
|
|
125
|
-
# Type checking — mypy (strict)
|
|
124
|
+
# Type checking — mypy (strict)
|
|
126
125
|
# ============================================================================
|
|
127
126
|
[tool.mypy]
|
|
128
127
|
python_version = "3.14"
|
|
@@ -137,16 +136,6 @@ disallow_untyped_decorators = false
|
|
|
137
136
|
disallow_untyped_calls = false
|
|
138
137
|
warn_unused_ignores = false
|
|
139
138
|
|
|
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
139
|
|
|
151
140
|
# ============================================================================
|
|
152
141
|
# Spell checking — codespell
|
|
@@ -2,15 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import sqlite3
|
|
4
4
|
import time
|
|
5
|
-
from
|
|
5
|
+
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from tasko.core.errors import AppError
|
|
8
8
|
from tasko.core.migrations import MIGRATIONS
|
|
9
9
|
from tasko.core.models import AppState, Priority, Project, Status, Task
|
|
10
10
|
|
|
11
|
-
if TYPE_CHECKING:
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
11
|
|
|
15
12
|
def _now() -> int:
|
|
16
13
|
"""Return the current unix time in whole seconds."""
|
|
@@ -20,13 +17,15 @@ def _now() -> int:
|
|
|
20
17
|
class Core:
|
|
21
18
|
"""The application without UI: owns the SQLite connection and every operation on it.
|
|
22
19
|
|
|
23
|
-
Adapters (
|
|
20
|
+
Adapters (the TUI) call these methods and show AppError messages to the user.
|
|
24
21
|
"""
|
|
25
22
|
|
|
26
23
|
def __init__(self, db_path: Path) -> None:
|
|
27
24
|
"""Open the database (creating the file and its directory if needed) and migrate it."""
|
|
28
25
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
-
|
|
26
|
+
# autocommit: every write is a single self-committing statement; anything that ever
|
|
27
|
+
# needs multi-statement atomicity must use explicit BEGIN/COMMIT (as _migrate does).
|
|
28
|
+
self._conn = sqlite3.connect(db_path, autocommit=True) # single shared connection
|
|
30
29
|
self._conn.row_factory = sqlite3.Row
|
|
31
30
|
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
32
31
|
self._conn.execute("PRAGMA busy_timeout = 5000")
|
|
@@ -68,7 +67,6 @@ class Core:
|
|
|
68
67
|
).fetchone()
|
|
69
68
|
except sqlite3.IntegrityError as e: # foreign key: no such project
|
|
70
69
|
raise AppError(f"Project #{project_id} not found.") from e
|
|
71
|
-
self._conn.commit()
|
|
72
70
|
return self.get_task(int(row["id"]))
|
|
73
71
|
|
|
74
72
|
def get_task(self, task_id: int) -> Task:
|
|
@@ -81,28 +79,24 @@ class Core:
|
|
|
81
79
|
raise AppError(f"Task #{task_id} not found.")
|
|
82
80
|
return Task.from_row(row)
|
|
83
81
|
|
|
84
|
-
def list_tasks(
|
|
85
|
-
|
|
86
|
-
) -> list[Task]:
|
|
87
|
-
"""Return tasks in working order, optionally filtered by project id and/or status; a None filter means "any".
|
|
82
|
+
def list_tasks(self, *, project_id: int | None = None, include_done: bool = False) -> list[Task]:
|
|
83
|
+
"""Return tasks in working order, optionally filtered to one project; done tasks appear only with include_done.
|
|
88
84
|
|
|
89
85
|
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).
|
|
91
|
-
are excluded by default; asking for status DONE explicitly implies them.
|
|
86
|
+
sink to the bottom (recently finished first, since done_at == updated_at).
|
|
92
87
|
"""
|
|
93
88
|
rows = self._conn.execute(
|
|
94
89
|
"""
|
|
95
90
|
SELECT t.*, p.name AS project_name
|
|
96
91
|
FROM tasks t JOIN projects p ON p.id = t.project_id
|
|
97
92
|
WHERE (:project_id IS NULL OR t.project_id = :project_id)
|
|
98
|
-
AND (:status IS NULL OR t.status = :status)
|
|
99
93
|
AND (:include_done OR t.status != 'done')
|
|
100
94
|
ORDER BY t.status = 'done',
|
|
101
95
|
t.status = 'doing' DESC,
|
|
102
96
|
CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
|
|
103
97
|
t.updated_at DESC
|
|
104
98
|
""",
|
|
105
|
-
{"project_id": project_id, "
|
|
99
|
+
{"project_id": project_id, "include_done": include_done},
|
|
106
100
|
).fetchall()
|
|
107
101
|
return [Task.from_row(row) for row in rows]
|
|
108
102
|
|
|
@@ -121,7 +115,6 @@ class Core:
|
|
|
121
115
|
"UPDATE tasks SET status = :status, done_at = :done_at, updated_at = :now WHERE id = :id",
|
|
122
116
|
{"status": status, "done_at": done_at, "now": now, "id": task_id},
|
|
123
117
|
)
|
|
124
|
-
self._conn.commit()
|
|
125
118
|
if cur.rowcount == 0:
|
|
126
119
|
raise AppError(f"Task #{task_id} not found.")
|
|
127
120
|
|
|
@@ -134,7 +127,6 @@ class Core:
|
|
|
134
127
|
"UPDATE tasks SET title = :title, updated_at = :now WHERE id = :id",
|
|
135
128
|
{"title": title, "now": _now(), "id": task_id},
|
|
136
129
|
)
|
|
137
|
-
self._conn.commit()
|
|
138
130
|
if cur.rowcount == 0:
|
|
139
131
|
raise AppError(f"Task #{task_id} not found.")
|
|
140
132
|
|
|
@@ -146,7 +138,6 @@ class Core:
|
|
|
146
138
|
"UPDATE tasks SET body = :body, updated_at = :now WHERE id = :id",
|
|
147
139
|
{"body": body, "now": _now(), "id": task_id},
|
|
148
140
|
)
|
|
149
|
-
self._conn.commit()
|
|
150
141
|
if cur.rowcount == 0:
|
|
151
142
|
raise AppError(f"Task #{task_id} not found.")
|
|
152
143
|
|
|
@@ -156,7 +147,6 @@ class Core:
|
|
|
156
147
|
"UPDATE tasks SET priority = :priority, updated_at = :now WHERE id = :id",
|
|
157
148
|
{"priority": priority, "now": _now(), "id": task_id},
|
|
158
149
|
)
|
|
159
|
-
self._conn.commit()
|
|
160
150
|
if cur.rowcount == 0:
|
|
161
151
|
raise AppError(f"Task #{task_id} not found.")
|
|
162
152
|
|
|
@@ -169,14 +159,12 @@ class Core:
|
|
|
169
159
|
)
|
|
170
160
|
except sqlite3.IntegrityError as e: # foreign key: no such project
|
|
171
161
|
raise AppError(f"Project #{project_id} not found.") from e
|
|
172
|
-
self._conn.commit()
|
|
173
162
|
if cur.rowcount == 0:
|
|
174
163
|
raise AppError(f"Task #{task_id} not found.")
|
|
175
164
|
|
|
176
165
|
def delete_task(self, task_id: int) -> None:
|
|
177
166
|
"""Delete a task permanently."""
|
|
178
167
|
cur = self._conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
|
179
|
-
self._conn.commit()
|
|
180
168
|
if cur.rowcount == 0:
|
|
181
169
|
raise AppError(f"Task #{task_id} not found.")
|
|
182
170
|
|
|
@@ -194,26 +182,16 @@ class Core:
|
|
|
194
182
|
raise AppError(f"Project #{project_id} not found.")
|
|
195
183
|
return Project(**dict(row))
|
|
196
184
|
|
|
197
|
-
def
|
|
198
|
-
"""
|
|
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."""
|
|
185
|
+
def create_project(self, name: str) -> Project:
|
|
186
|
+
"""Create a project with a unique non-empty name and return it."""
|
|
209
187
|
name = name.strip()
|
|
210
188
|
if not name:
|
|
211
189
|
raise AppError("Project name cannot be empty.")
|
|
212
190
|
try:
|
|
213
|
-
self._conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
|
|
191
|
+
row = self._conn.execute("INSERT INTO projects (name) VALUES (?) RETURNING id", (name,)).fetchone()
|
|
214
192
|
except sqlite3.IntegrityError as e:
|
|
215
193
|
raise AppError(f"Project '{name}' already exists.") from e
|
|
216
|
-
|
|
194
|
+
return Project(id=row["id"], name=name)
|
|
217
195
|
|
|
218
196
|
def rename_project(self, project_id: int, new_name: str) -> None:
|
|
219
197
|
"""Rename a project; the new name must be unique and non-empty."""
|
|
@@ -224,7 +202,6 @@ class Core:
|
|
|
224
202
|
cur = self._conn.execute("UPDATE projects SET name = ? WHERE id = ?", (new_name, project_id))
|
|
225
203
|
except sqlite3.IntegrityError as e:
|
|
226
204
|
raise AppError(f"Project '{new_name}' already exists.") from e
|
|
227
|
-
self._conn.commit()
|
|
228
205
|
if cur.rowcount == 0:
|
|
229
206
|
raise AppError(f"Project #{project_id} not found.")
|
|
230
207
|
|
|
@@ -233,7 +210,6 @@ class Core:
|
|
|
233
210
|
if project_id == self.app_state().default_project_id:
|
|
234
211
|
raise AppError("Cannot delete the default project; make another project the default first.")
|
|
235
212
|
cur = self._conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
|
236
|
-
self._conn.commit()
|
|
237
213
|
if cur.rowcount == 0:
|
|
238
214
|
raise AppError(f"Project #{project_id} not found.")
|
|
239
215
|
|
|
@@ -251,12 +227,10 @@ class Core:
|
|
|
251
227
|
def set_selected_project(self, project_id: int | None) -> None:
|
|
252
228
|
"""Set the persisted project filter; None means all projects."""
|
|
253
229
|
self._conn.execute("UPDATE app_state SET selected_project_id = ? WHERE id = 1", (project_id,))
|
|
254
|
-
self._conn.commit()
|
|
255
230
|
|
|
256
231
|
def set_theme(self, theme: str) -> None:
|
|
257
232
|
"""Persist the UI theme choice."""
|
|
258
233
|
self._conn.execute("UPDATE app_state SET theme = ? WHERE id = 1", (theme,))
|
|
259
|
-
self._conn.commit()
|
|
260
234
|
|
|
261
235
|
def set_default_project(self, project_id: int) -> None:
|
|
262
236
|
"""Make the given project the default target for quick capture."""
|
|
@@ -264,4 +238,3 @@ class Core:
|
|
|
264
238
|
self._conn.execute("UPDATE app_state SET default_project_id = ? WHERE id = 1", (project_id,))
|
|
265
239
|
except sqlite3.IntegrityError as e: # foreign key: no such project
|
|
266
240
|
raise AppError(f"Project #{project_id} not found.") from e
|
|
267
|
-
self._conn.commit()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Database schema migrations, applied in order by Core."""
|
|
2
2
|
|
|
3
|
-
# Schema v1 (see docs/data-model.md). IF NOT EXISTS / OR IGNORE
|
|
4
|
-
# in Core._migrate
|
|
3
|
+
# Schema v1 (see docs/data-model.md). IF NOT EXISTS / OR IGNORE make a rerun a no-op;
|
|
4
|
+
# with the atomic runner in Core._migrate, new migrations don't need to be rerun-safe.
|
|
5
5
|
_MIGRATION_V1 = """
|
|
6
6
|
CREATE TABLE IF NOT EXISTS projects (
|
|
7
7
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
"""Domain models: projects, tasks, and their enums."""
|
|
2
2
|
|
|
3
|
+
import sqlite3
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from enum import StrEnum
|
|
5
|
-
from typing import
|
|
6
|
-
|
|
7
|
-
if TYPE_CHECKING:
|
|
8
|
-
import sqlite3
|
|
6
|
+
from typing import Any, Self
|
|
9
7
|
|
|
10
8
|
|
|
11
9
|
class Status(StrEnum):
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Entry point: resolve the data directory, open the database, run the TUI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tasko.core.core import Core
|
|
10
|
+
from tasko.core.errors import AppError
|
|
11
|
+
from tasko.tui.app import TaskoApp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""Launch the TUI against the resolved database."""
|
|
16
|
+
parser = argparse.ArgumentParser(prog="tasko", description="Personal task manager.")
|
|
17
|
+
parser.add_argument("--version", action="version", version=version("tasko"))
|
|
18
|
+
parser.add_argument("--data-dir", type=Path, help="data directory (default: XDG data dir)")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
core = Core(_resolve_db_path(args.data_dir))
|
|
21
|
+
try:
|
|
22
|
+
TaskoApp(core).run()
|
|
23
|
+
except AppError as e:
|
|
24
|
+
sys.exit(f"error: {e}")
|
|
25
|
+
finally:
|
|
26
|
+
core.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_db_path(data_dir: Path | None) -> Path:
|
|
30
|
+
"""Database file path; without an explicit directory the XDG data dir is used."""
|
|
31
|
+
if data_dir is None:
|
|
32
|
+
data_dir = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "tasko"
|
|
33
|
+
return data_dir / "tasko.db"
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
"""Textual application: a thin shell that mounts the main screen."""
|
|
2
2
|
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import ClassVar
|
|
4
4
|
|
|
5
5
|
from textual.app import App
|
|
6
|
+
from textual.binding import BindingType
|
|
6
7
|
|
|
8
|
+
from tasko.core.core import Core
|
|
7
9
|
from tasko.tui.screens.dialogs import HelpDialog
|
|
8
10
|
from tasko.tui.screens.projects import ProjectsScreen
|
|
9
11
|
from tasko.tui.screens.task_detail import TaskDetailScreen
|
|
10
12
|
from tasko.tui.screens.task_list import TaskListScreen
|
|
11
13
|
|
|
12
|
-
if TYPE_CHECKING:
|
|
13
|
-
from textual.binding import BindingType
|
|
14
|
-
|
|
15
|
-
from tasko.core.core import Core
|
|
16
|
-
|
|
17
14
|
|
|
18
15
|
class TaskoApp(App[None]):
|
|
19
16
|
"""Interactive TUI for managing tasks."""
|
|
@@ -42,7 +39,7 @@ class TaskoApp(App[None]):
|
|
|
42
39
|
def __init__(self, core: Core) -> None:
|
|
43
40
|
"""Wire the app to the shared core."""
|
|
44
41
|
super().__init__()
|
|
45
|
-
self._core = core # application core,
|
|
42
|
+
self._core = core # application core, built by main() and owned by it
|
|
46
43
|
|
|
47
44
|
def action_help(self) -> None:
|
|
48
45
|
"""Show the current screen's keys; screens opt in by defining a HELP_KEYS attribute."""
|
|
@@ -64,8 +61,9 @@ class TaskoApp(App[None]):
|
|
|
64
61
|
def on_app_focus(self) -> None:
|
|
65
62
|
"""Reload the visible screen when the terminal regains focus.
|
|
66
63
|
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
The database can change under a running instance — a second tasko in another
|
|
65
|
+
terminal — so alt-tabbing back shows a fresh list rather than a stale one.
|
|
66
|
+
Skipped while a dialog is on top — reloading under a modal could
|
|
69
67
|
fight the interaction in progress (r covers that case), and TaskDetailScreen.reload
|
|
70
68
|
pops the top screen when its task is gone, which must never hit a dialog.
|
|
71
69
|
"""
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
"""Reusable modal dialogs: one-line text input, yes/no confirmation, option picking, and key help."""
|
|
1
|
+
"""Reusable modal dialogs: one-line and multi-line text input, yes/no confirmation, option picking, and key help."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Sequence
|
|
3
4
|
from importlib.metadata import version
|
|
4
|
-
from typing import
|
|
5
|
+
from typing import ClassVar, Final
|
|
5
6
|
|
|
6
7
|
from rich.style import Style
|
|
7
8
|
from rich.table import Table
|
|
8
9
|
from rich.text import Text
|
|
10
|
+
from textual.app import ComposeResult
|
|
9
11
|
from textual.binding import Binding, BindingType
|
|
10
12
|
from textual.containers import Vertical
|
|
11
13
|
from textual.fuzzy import FuzzySearch
|
|
@@ -13,19 +15,11 @@ from textual.screen import ModalScreen
|
|
|
13
15
|
from textual.widgets import Input, Label, OptionList, Static, TextArea
|
|
14
16
|
from textual.widgets.option_list import Option
|
|
15
17
|
|
|
16
|
-
if TYPE_CHECKING:
|
|
17
|
-
from collections.abc import Sequence
|
|
18
|
-
|
|
19
|
-
from textual.app import ComposeResult
|
|
20
|
-
|
|
21
18
|
_MATCH_STYLE: Final = Style(bold=True, underline=True) # characters of a label the fuzzy query matched
|
|
22
19
|
|
|
23
20
|
|
|
24
21
|
class Dialog[ResultT](ModalScreen[ResultT]):
|
|
25
|
-
"""Base modal dialog: a centered bordered panel over the dimmed screen; Escape cancels.
|
|
26
|
-
|
|
27
|
-
Subclasses define action_cancel with their own "cancelled" result.
|
|
28
|
-
"""
|
|
22
|
+
"""Base modal dialog: a centered bordered panel over the dimmed screen; Escape cancels with None."""
|
|
29
23
|
|
|
30
24
|
DEFAULT_CSS = """
|
|
31
25
|
Dialog {
|
|
@@ -46,6 +40,10 @@ class Dialog[ResultT](ModalScreen[ResultT]):
|
|
|
46
40
|
|
|
47
41
|
BINDINGS: ClassVar[list[BindingType]] = [("escape", "cancel", "Cancel")]
|
|
48
42
|
|
|
43
|
+
def action_cancel(self) -> None:
|
|
44
|
+
"""Cancel the dialog without a result."""
|
|
45
|
+
self.dismiss(None)
|
|
46
|
+
|
|
49
47
|
|
|
50
48
|
class InputDialog(Dialog[str | None]):
|
|
51
49
|
"""Prompt for one line of text; dismisses with the value, or None on cancel."""
|
|
@@ -58,7 +56,7 @@ class InputDialog(Dialog[str | None]):
|
|
|
58
56
|
"""
|
|
59
57
|
|
|
60
58
|
def __init__(self, title: str, value: str = "") -> None:
|
|
61
|
-
"""Set the prompt title and the optional pre-filled value
|
|
59
|
+
"""Set the prompt title and the optional pre-filled value."""
|
|
62
60
|
super().__init__()
|
|
63
61
|
self._title = title # prompt shown above the input
|
|
64
62
|
self._value = value # initial input content
|
|
@@ -77,10 +75,6 @@ class InputDialog(Dialog[str | None]):
|
|
|
77
75
|
"""Enter accepts the value."""
|
|
78
76
|
self.dismiss(event.value)
|
|
79
77
|
|
|
80
|
-
def action_cancel(self) -> None:
|
|
81
|
-
"""Escape cancels without a value."""
|
|
82
|
-
self.dismiss(None)
|
|
83
|
-
|
|
84
78
|
|
|
85
79
|
class TextDialog(Dialog[str | None]):
|
|
86
80
|
"""Edit multi-line text; dismisses with the text on Ctrl+S, or None on cancel.
|
|
@@ -135,10 +129,6 @@ class TextDialog(Dialog[str | None]):
|
|
|
135
129
|
"""Ctrl+A selects the whole text, e.g. to replace or delete a long body at once."""
|
|
136
130
|
self.query_one(TextArea).select_all()
|
|
137
131
|
|
|
138
|
-
def action_cancel(self) -> None:
|
|
139
|
-
"""Escape cancels without a value."""
|
|
140
|
-
self.dismiss(None)
|
|
141
|
-
|
|
142
132
|
|
|
143
133
|
class ConfirmDialog(Dialog[bool]):
|
|
144
134
|
"""Ask a yes/no question; dismisses with True only on explicit confirmation."""
|
|
@@ -170,10 +160,6 @@ class ConfirmDialog(Dialog[bool]):
|
|
|
170
160
|
"""Confirm the action."""
|
|
171
161
|
self.dismiss(True)
|
|
172
162
|
|
|
173
|
-
def action_cancel(self) -> None:
|
|
174
|
-
"""Cancel the action."""
|
|
175
|
-
self.dismiss(False)
|
|
176
|
-
|
|
177
163
|
|
|
178
164
|
class HelpDialog(Dialog[None]):
|
|
179
165
|
"""Show the current screen's keys as a two-column list; Escape, ? or i closes it."""
|
|
@@ -207,10 +193,6 @@ class HelpDialog(Dialog[None]):
|
|
|
207
193
|
yield Static(grid, id="keys")
|
|
208
194
|
yield Label(f"tasko v{version('tasko')}", id="about")
|
|
209
195
|
|
|
210
|
-
def action_cancel(self) -> None:
|
|
211
|
-
"""Close the help."""
|
|
212
|
-
self.dismiss(None)
|
|
213
|
-
|
|
214
196
|
|
|
215
197
|
class SelectDialog(Dialog[str | None]):
|
|
216
198
|
"""Pick one option with arrows and Enter; dismisses with the option id, or None on cancel.
|
|
@@ -336,7 +318,3 @@ class SelectDialog(Dialog[str | None]):
|
|
|
336
318
|
def action_last(self) -> None:
|
|
337
319
|
"""Jump the list highlight to the last option without leaving the input."""
|
|
338
320
|
self.query_one(OptionList).action_last()
|
|
339
|
-
|
|
340
|
-
def action_cancel(self) -> None:
|
|
341
|
-
"""Escape cancels without a choice."""
|
|
342
|
-
self.dismiss(None)
|
|
@@ -1,28 +1,24 @@
|
|
|
1
1
|
"""Projects screen: a table of projects with management actions."""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import ClassVar, Final
|
|
4
5
|
|
|
5
6
|
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import BindingType
|
|
6
9
|
from textual.screen import Screen
|
|
7
10
|
from textual.widgets import DataTable
|
|
8
11
|
|
|
12
|
+
from tasko.core.core import Core
|
|
9
13
|
from tasko.core.errors import AppError
|
|
10
14
|
from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog
|
|
11
15
|
from tasko.tui.widgets import StatusBar
|
|
12
16
|
|
|
13
|
-
if TYPE_CHECKING:
|
|
14
|
-
from collections.abc import Callable
|
|
15
|
-
|
|
16
|
-
from textual.app import ComposeResult
|
|
17
|
-
from textual.binding import BindingType
|
|
18
|
-
|
|
19
|
-
from tasko.core.core import Core
|
|
20
|
-
|
|
21
17
|
_DEFAULT_CELL: Final = Text("default", style="green") # marks the default project; space is not scarce here
|
|
22
18
|
|
|
23
19
|
|
|
24
20
|
class ProjectsScreen(Screen[None]):
|
|
25
|
-
"""Project management — rarely visited, reached from the task
|
|
21
|
+
"""Project management — rarely visited, reached from the task list with P."""
|
|
26
22
|
|
|
27
23
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
28
24
|
("j", "cursor_down", "Down"),
|
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
"""Task detail screen: full details of one task with per-field editing."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from datetime import UTC, datetime
|
|
4
|
-
from typing import
|
|
5
|
+
from typing import ClassVar, Final
|
|
5
6
|
|
|
6
7
|
from rich.table import Table
|
|
7
8
|
from rich.text import Text
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import BindingType
|
|
8
11
|
from textual.containers import VerticalScroll
|
|
9
12
|
from textual.screen import Screen
|
|
10
13
|
from textual.widgets import Static
|
|
11
14
|
|
|
15
|
+
from tasko.core.core import Core
|
|
12
16
|
from tasko.core.errors import AppError
|
|
13
17
|
from tasko.core.models import Priority, Status, Task
|
|
14
18
|
from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog, SelectDialog, TextDialog
|
|
15
19
|
from tasko.tui.widgets import StatusBar
|
|
16
20
|
|
|
17
|
-
if TYPE_CHECKING:
|
|
18
|
-
from collections.abc import Callable
|
|
19
|
-
|
|
20
|
-
from textual.app import ComposeResult
|
|
21
|
-
from textual.binding import BindingType
|
|
22
|
-
|
|
23
|
-
from tasko.core.core import Core
|
|
24
|
-
|
|
25
21
|
# Same glyphs and colors as the table on the main screen, spelled out with the word.
|
|
26
22
|
_STATUS_TEXT: Final = {
|
|
27
|
-
Status.TODO: Text("
|
|
28
|
-
Status.DOING: Text("
|
|
29
|
-
Status.DONE: Text("
|
|
23
|
+
Status.TODO: Text("· todo", style="dim"),
|
|
24
|
+
Status.DOING: Text("» doing", style="yellow"),
|
|
25
|
+
Status.DONE: Text("✓ done", style="green"),
|
|
30
26
|
}
|
|
31
27
|
_PRIORITY_TEXT: Final = {
|
|
32
28
|
Priority.HIGH: Text("high", style="red"),
|
|
@@ -123,7 +119,7 @@ class TaskDetailScreen(Screen[None]):
|
|
|
123
119
|
self.query_one(StatusBar).set_context(Text.assemble(f"task #{task.id}", (f" · {task.project_name}", "dim")))
|
|
124
120
|
|
|
125
121
|
self.query_one("#title", Static).update(Text(task.title))
|
|
126
|
-
# Two column groups: live fields on the left (main-table order), dates on the right
|
|
122
|
+
# Two column groups: live fields on the left (main-table order), dates on the right; all dim but the field values.
|
|
127
123
|
meta = Table.grid(padding=(0, 2))
|
|
128
124
|
meta.add_column(style="dim")
|
|
129
125
|
meta.add_column()
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Main screen: the task table."""
|
|
2
2
|
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import ClassVar, Final
|
|
4
4
|
|
|
5
5
|
from rich.cells import cell_len
|
|
6
6
|
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import BindingType
|
|
7
9
|
from textual.screen import Screen
|
|
8
10
|
from textual.widgets import DataTable
|
|
9
11
|
|
|
12
|
+
from tasko.core.core import Core
|
|
10
13
|
from tasko.core.errors import AppError
|
|
11
14
|
from tasko.core.models import Priority, Status
|
|
12
15
|
from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog, SelectDialog
|
|
@@ -14,17 +17,11 @@ from tasko.tui.screens.projects import ProjectsScreen
|
|
|
14
17
|
from tasko.tui.screens.task_detail import TaskDetailScreen
|
|
15
18
|
from tasko.tui.widgets import StatusBar
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
from textual.app import ComposeResult
|
|
19
|
-
from textual.binding import BindingType
|
|
20
|
-
|
|
21
|
-
from tasko.core.core import Core
|
|
22
|
-
|
|
23
|
-
# One-character, color-coded cells: the S/P/B columns must not waste width.
|
|
20
|
+
# One-character, color-coded cells: the status, priority and body columns must not waste width.
|
|
24
21
|
_STATUS_CELLS: Final = {
|
|
25
|
-
Status.TODO: Text("
|
|
26
|
-
Status.DOING: Text("
|
|
27
|
-
Status.DONE: Text("
|
|
22
|
+
Status.TODO: Text("·", style="dim"),
|
|
23
|
+
Status.DOING: Text("»", style="yellow"),
|
|
24
|
+
Status.DONE: Text("✓", style="green"),
|
|
28
25
|
}
|
|
29
26
|
_PRIORITY_CELLS: Final = {
|
|
30
27
|
Priority.HIGH: Text("↑", style="red"),
|
|
@@ -46,6 +43,7 @@ class TaskListScreen(Screen[None]):
|
|
|
46
43
|
("j", "cursor_down", "Down"),
|
|
47
44
|
("k", "cursor_up", "Up"),
|
|
48
45
|
("a", "add", "Add"),
|
|
46
|
+
("A", "add_with_project", "Add to project"),
|
|
49
47
|
("d", "toggle_done", "Done"),
|
|
50
48
|
("s", "toggle_doing", "Doing"),
|
|
51
49
|
("plus,equals_sign", "priority_up", "Priority up"), # = raises too: + is shifted on most layouts
|
|
@@ -64,6 +62,7 @@ class TaskListScreen(Screen[None]):
|
|
|
64
62
|
("↑ ↓ j k", "move the cursor"),
|
|
65
63
|
("enter", "open the task under the cursor"),
|
|
66
64
|
("a", "add a task into the cursor row's project"),
|
|
65
|
+
("A", "add a task, choosing the project first"),
|
|
67
66
|
("d", "mark done / back to todo"),
|
|
68
67
|
("s", "mark doing / back to todo"),
|
|
69
68
|
("+ -", "raise / lower the priority"),
|
|
@@ -162,14 +161,8 @@ class TaskListScreen(Screen[None]):
|
|
|
162
161
|
falls back to the selected, then the default project. Wrong target? Escape, move
|
|
163
162
|
the cursor to a row of the right project, or change the project on the task later.
|
|
164
163
|
"""
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
task = self._core.get_task(task_id)
|
|
168
|
-
project_id, name = task.project_id, task.project_name
|
|
169
|
-
else:
|
|
170
|
-
state = self._core.app_state()
|
|
171
|
-
project_id = state.selected_project_id or state.default_project_id
|
|
172
|
-
name = self._core.get_project(project_id).name
|
|
164
|
+
project_id = self._add_target_project_id()
|
|
165
|
+
name = self._core.get_project(project_id).name
|
|
173
166
|
|
|
174
167
|
def on_result(title: str | None) -> None:
|
|
175
168
|
if not title:
|
|
@@ -183,6 +176,47 @@ class TaskListScreen(Screen[None]):
|
|
|
183
176
|
|
|
184
177
|
self.app.push_screen(InputDialog(f'New task in "{name}":'), on_result)
|
|
185
178
|
|
|
179
|
+
def action_add_with_project(self) -> None:
|
|
180
|
+
"""Pick the target project from the full list, then prompt for a title.
|
|
181
|
+
|
|
182
|
+
The escape hatch for 'a': used when no visible row carries the wanted project.
|
|
183
|
+
"""
|
|
184
|
+
options: list[tuple[str, str | Text]] = [(str(p.id), p.name) for p in self._core.list_projects()]
|
|
185
|
+
# Preselect the project plain 'a' would target, so Enter-Enter behaves like 'a'.
|
|
186
|
+
current = str(self._add_target_project_id())
|
|
187
|
+
|
|
188
|
+
def on_project(choice: str | None) -> None:
|
|
189
|
+
if choice is None:
|
|
190
|
+
return
|
|
191
|
+
project_id = int(choice)
|
|
192
|
+
name = self._core.get_project(project_id).name
|
|
193
|
+
|
|
194
|
+
def on_title(title: str | None) -> None:
|
|
195
|
+
if not title:
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
self._core.add_task(title, project_id=project_id)
|
|
199
|
+
except AppError as e:
|
|
200
|
+
self.notify(str(e), severity="error")
|
|
201
|
+
return
|
|
202
|
+
self.reload()
|
|
203
|
+
# The task is invisible when a different project filter is active — confirm it landed.
|
|
204
|
+
filter_id = self._core.app_state().selected_project_id
|
|
205
|
+
if filter_id is not None and filter_id != project_id:
|
|
206
|
+
self.notify(f'Added to "{name}"')
|
|
207
|
+
|
|
208
|
+
self.app.push_screen(InputDialog(f'New task in "{name}":'), on_title)
|
|
209
|
+
|
|
210
|
+
self.app.push_screen(SelectDialog("Add task to project:", options, current), on_project)
|
|
211
|
+
|
|
212
|
+
def _add_target_project_id(self) -> int:
|
|
213
|
+
"""Project a plain add targets: the cursor row's project, else the selected, else the default one."""
|
|
214
|
+
task_id = self._cursor_task_id()
|
|
215
|
+
if task_id is not None:
|
|
216
|
+
return self._core.get_task(task_id).project_id
|
|
217
|
+
state = self._core.app_state()
|
|
218
|
+
return state.selected_project_id or state.default_project_id
|
|
219
|
+
|
|
186
220
|
def _cursor_task_id(self) -> int | None:
|
|
187
221
|
"""Return the id of the task under the cursor, or None on an empty table.
|
|
188
222
|
|
|
@@ -297,7 +331,7 @@ class TaskListScreen(Screen[None]):
|
|
|
297
331
|
self.reload()
|
|
298
332
|
|
|
299
333
|
def action_refresh(self) -> None:
|
|
300
|
-
"""Reload the list: apply pending re-sorts/hides from d/s and pick up
|
|
334
|
+
"""Reload the list: apply pending re-sorts/hides from d/s and pick up changes from another instance."""
|
|
301
335
|
self.reload()
|
|
302
336
|
|
|
303
337
|
def action_focus_project(self) -> None:
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
"""Shared TUI widgets."""
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
4
|
-
|
|
5
3
|
from rich.text import Text
|
|
4
|
+
from textual.app import ComposeResult
|
|
6
5
|
from textual.containers import Horizontal
|
|
6
|
+
from textual.visual import VisualType
|
|
7
7
|
from textual.widgets import Label
|
|
8
8
|
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from textual.app import ComposeResult
|
|
11
|
-
from textual.visual import VisualType
|
|
12
|
-
|
|
13
9
|
|
|
14
10
|
class StatusBar(Horizontal):
|
|
15
11
|
"""One-line bottom bar: the screen's context on the left, the help key hint on the right.
|
tasko-0.0.1/README.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""CLI adapter."""
|
|
@@ -1,89 +0,0 @@
|
|
|
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())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|