tasko 0.0.1__tar.gz → 0.0.2__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.2}/PKG-INFO +7 -17
- tasko-0.0.2/README.md +24 -0
- {tasko-0.0.1 → tasko-0.0.2}/pyproject.toml +3 -14
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/core/core.py +8 -28
- tasko-0.0.2/src/tasko/main.py +33 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/app.py +4 -3
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/screens/task_list.py +41 -1
- 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.2}/LICENSE +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/__init__.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/core/__init__.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/core/errors.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/core/migrations.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/core/models.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/__init__.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/screens/__init__.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/screens/dialogs.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/screens/projects.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/screens/task_detail.py +0 -0
- {tasko-0.0.1 → tasko-0.0.2}/src/tasko/tui/widgets.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.2
|
|
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.2/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)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# ============================================================================
|
|
4
4
|
[project]
|
|
5
5
|
name = "tasko"
|
|
6
|
-
version = "0.0.
|
|
6
|
+
version = "0.0.2"
|
|
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
|
# ============================================================================
|
|
@@ -48,7 +48,6 @@ dev = [
|
|
|
48
48
|
"ruff~=0.15.22",
|
|
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",
|
|
@@ -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
|
|
@@ -20,13 +20,15 @@ def _now() -> int:
|
|
|
20
20
|
class Core:
|
|
21
21
|
"""The application without UI: owns the SQLite connection and every operation on it.
|
|
22
22
|
|
|
23
|
-
Adapters (
|
|
23
|
+
Adapters (the TUI) call these methods and show AppError messages to the user.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
def __init__(self, db_path: Path) -> None:
|
|
27
27
|
"""Open the database (creating the file and its directory if needed) and migrate it."""
|
|
28
28
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
-
|
|
29
|
+
# autocommit: every write is a single self-committing statement; anything that ever
|
|
30
|
+
# needs multi-statement atomicity must use explicit BEGIN/COMMIT (as _migrate does).
|
|
31
|
+
self._conn = sqlite3.connect(db_path, autocommit=True) # single shared connection
|
|
30
32
|
self._conn.row_factory = sqlite3.Row
|
|
31
33
|
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
32
34
|
self._conn.execute("PRAGMA busy_timeout = 5000")
|
|
@@ -68,7 +70,6 @@ class Core:
|
|
|
68
70
|
).fetchone()
|
|
69
71
|
except sqlite3.IntegrityError as e: # foreign key: no such project
|
|
70
72
|
raise AppError(f"Project #{project_id} not found.") from e
|
|
71
|
-
self._conn.commit()
|
|
72
73
|
return self.get_task(int(row["id"]))
|
|
73
74
|
|
|
74
75
|
def get_task(self, task_id: int) -> Task:
|
|
@@ -121,7 +122,6 @@ class Core:
|
|
|
121
122
|
"UPDATE tasks SET status = :status, done_at = :done_at, updated_at = :now WHERE id = :id",
|
|
122
123
|
{"status": status, "done_at": done_at, "now": now, "id": task_id},
|
|
123
124
|
)
|
|
124
|
-
self._conn.commit()
|
|
125
125
|
if cur.rowcount == 0:
|
|
126
126
|
raise AppError(f"Task #{task_id} not found.")
|
|
127
127
|
|
|
@@ -134,7 +134,6 @@ class Core:
|
|
|
134
134
|
"UPDATE tasks SET title = :title, updated_at = :now WHERE id = :id",
|
|
135
135
|
{"title": title, "now": _now(), "id": task_id},
|
|
136
136
|
)
|
|
137
|
-
self._conn.commit()
|
|
138
137
|
if cur.rowcount == 0:
|
|
139
138
|
raise AppError(f"Task #{task_id} not found.")
|
|
140
139
|
|
|
@@ -146,7 +145,6 @@ class Core:
|
|
|
146
145
|
"UPDATE tasks SET body = :body, updated_at = :now WHERE id = :id",
|
|
147
146
|
{"body": body, "now": _now(), "id": task_id},
|
|
148
147
|
)
|
|
149
|
-
self._conn.commit()
|
|
150
148
|
if cur.rowcount == 0:
|
|
151
149
|
raise AppError(f"Task #{task_id} not found.")
|
|
152
150
|
|
|
@@ -156,7 +154,6 @@ class Core:
|
|
|
156
154
|
"UPDATE tasks SET priority = :priority, updated_at = :now WHERE id = :id",
|
|
157
155
|
{"priority": priority, "now": _now(), "id": task_id},
|
|
158
156
|
)
|
|
159
|
-
self._conn.commit()
|
|
160
157
|
if cur.rowcount == 0:
|
|
161
158
|
raise AppError(f"Task #{task_id} not found.")
|
|
162
159
|
|
|
@@ -169,14 +166,12 @@ class Core:
|
|
|
169
166
|
)
|
|
170
167
|
except sqlite3.IntegrityError as e: # foreign key: no such project
|
|
171
168
|
raise AppError(f"Project #{project_id} not found.") from e
|
|
172
|
-
self._conn.commit()
|
|
173
169
|
if cur.rowcount == 0:
|
|
174
170
|
raise AppError(f"Task #{task_id} not found.")
|
|
175
171
|
|
|
176
172
|
def delete_task(self, task_id: int) -> None:
|
|
177
173
|
"""Delete a task permanently."""
|
|
178
174
|
cur = self._conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
|
179
|
-
self._conn.commit()
|
|
180
175
|
if cur.rowcount == 0:
|
|
181
176
|
raise AppError(f"Task #{task_id} not found.")
|
|
182
177
|
|
|
@@ -194,26 +189,16 @@ class Core:
|
|
|
194
189
|
raise AppError(f"Project #{project_id} not found.")
|
|
195
190
|
return Project(**dict(row))
|
|
196
191
|
|
|
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."""
|
|
192
|
+
def create_project(self, name: str) -> Project:
|
|
193
|
+
"""Create a project with a unique non-empty name and return it."""
|
|
209
194
|
name = name.strip()
|
|
210
195
|
if not name:
|
|
211
196
|
raise AppError("Project name cannot be empty.")
|
|
212
197
|
try:
|
|
213
|
-
self._conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
|
|
198
|
+
row = self._conn.execute("INSERT INTO projects (name) VALUES (?) RETURNING id", (name,)).fetchone()
|
|
214
199
|
except sqlite3.IntegrityError as e:
|
|
215
200
|
raise AppError(f"Project '{name}' already exists.") from e
|
|
216
|
-
|
|
201
|
+
return Project(id=row["id"], name=name)
|
|
217
202
|
|
|
218
203
|
def rename_project(self, project_id: int, new_name: str) -> None:
|
|
219
204
|
"""Rename a project; the new name must be unique and non-empty."""
|
|
@@ -224,7 +209,6 @@ class Core:
|
|
|
224
209
|
cur = self._conn.execute("UPDATE projects SET name = ? WHERE id = ?", (new_name, project_id))
|
|
225
210
|
except sqlite3.IntegrityError as e:
|
|
226
211
|
raise AppError(f"Project '{new_name}' already exists.") from e
|
|
227
|
-
self._conn.commit()
|
|
228
212
|
if cur.rowcount == 0:
|
|
229
213
|
raise AppError(f"Project #{project_id} not found.")
|
|
230
214
|
|
|
@@ -233,7 +217,6 @@ class Core:
|
|
|
233
217
|
if project_id == self.app_state().default_project_id:
|
|
234
218
|
raise AppError("Cannot delete the default project; make another project the default first.")
|
|
235
219
|
cur = self._conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
|
236
|
-
self._conn.commit()
|
|
237
220
|
if cur.rowcount == 0:
|
|
238
221
|
raise AppError(f"Project #{project_id} not found.")
|
|
239
222
|
|
|
@@ -251,12 +234,10 @@ class Core:
|
|
|
251
234
|
def set_selected_project(self, project_id: int | None) -> None:
|
|
252
235
|
"""Set the persisted project filter; None means all projects."""
|
|
253
236
|
self._conn.execute("UPDATE app_state SET selected_project_id = ? WHERE id = 1", (project_id,))
|
|
254
|
-
self._conn.commit()
|
|
255
237
|
|
|
256
238
|
def set_theme(self, theme: str) -> None:
|
|
257
239
|
"""Persist the UI theme choice."""
|
|
258
240
|
self._conn.execute("UPDATE app_state SET theme = ? WHERE id = 1", (theme,))
|
|
259
|
-
self._conn.commit()
|
|
260
241
|
|
|
261
242
|
def set_default_project(self, project_id: int) -> None:
|
|
262
243
|
"""Make the given project the default target for quick capture."""
|
|
@@ -264,4 +245,3 @@ class Core:
|
|
|
264
245
|
self._conn.execute("UPDATE app_state SET default_project_id = ? WHERE id = 1", (project_id,))
|
|
265
246
|
except sqlite3.IntegrityError as e: # foreign key: no such project
|
|
266
247
|
raise AppError(f"Project #{project_id} not found.") from e
|
|
267
|
-
self._conn.commit()
|
|
@@ -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"
|
|
@@ -42,7 +42,7 @@ class TaskoApp(App[None]):
|
|
|
42
42
|
def __init__(self, core: Core) -> None:
|
|
43
43
|
"""Wire the app to the shared core."""
|
|
44
44
|
super().__init__()
|
|
45
|
-
self._core = core # application core,
|
|
45
|
+
self._core = core # application core, built by main() and owned by it
|
|
46
46
|
|
|
47
47
|
def action_help(self) -> None:
|
|
48
48
|
"""Show the current screen's keys; screens opt in by defining a HELP_KEYS attribute."""
|
|
@@ -64,8 +64,9 @@ class TaskoApp(App[None]):
|
|
|
64
64
|
def on_app_focus(self) -> None:
|
|
65
65
|
"""Reload the visible screen when the terminal regains focus.
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
The database can change under a running instance — a second tasko in another
|
|
68
|
+
terminal — so alt-tabbing back shows a fresh list rather than a stale one.
|
|
69
|
+
Skipped while a dialog is on top — reloading under a modal could
|
|
69
70
|
fight the interaction in progress (r covers that case), and TaskDetailScreen.reload
|
|
70
71
|
pops the top screen when its task is gone, which must never hit a dialog.
|
|
71
72
|
"""
|
|
@@ -46,6 +46,7 @@ class TaskListScreen(Screen[None]):
|
|
|
46
46
|
("j", "cursor_down", "Down"),
|
|
47
47
|
("k", "cursor_up", "Up"),
|
|
48
48
|
("a", "add", "Add"),
|
|
49
|
+
("A", "add_with_project", "Add to project"),
|
|
49
50
|
("d", "toggle_done", "Done"),
|
|
50
51
|
("s", "toggle_doing", "Doing"),
|
|
51
52
|
("plus,equals_sign", "priority_up", "Priority up"), # = raises too: + is shifted on most layouts
|
|
@@ -64,6 +65,7 @@ class TaskListScreen(Screen[None]):
|
|
|
64
65
|
("↑ ↓ j k", "move the cursor"),
|
|
65
66
|
("enter", "open the task under the cursor"),
|
|
66
67
|
("a", "add a task into the cursor row's project"),
|
|
68
|
+
("A", "add a task, choosing the project first"),
|
|
67
69
|
("d", "mark done / back to todo"),
|
|
68
70
|
("s", "mark doing / back to todo"),
|
|
69
71
|
("+ -", "raise / lower the priority"),
|
|
@@ -183,6 +185,44 @@ class TaskListScreen(Screen[None]):
|
|
|
183
185
|
|
|
184
186
|
self.app.push_screen(InputDialog(f'New task in "{name}":'), on_result)
|
|
185
187
|
|
|
188
|
+
def action_add_with_project(self) -> None:
|
|
189
|
+
"""Pick the target project from the full list, then prompt for a title.
|
|
190
|
+
|
|
191
|
+
The escape hatch for 'a': used when no visible row carries the wanted project.
|
|
192
|
+
"""
|
|
193
|
+
options: list[tuple[str, str | Text]] = [(str(p.id), p.name) for p in self._core.list_projects()]
|
|
194
|
+
# Preselect the project plain 'a' would target, so Enter-Enter behaves like 'a'.
|
|
195
|
+
task_id = self._cursor_task_id()
|
|
196
|
+
if task_id is not None:
|
|
197
|
+
current = str(self._core.get_task(task_id).project_id)
|
|
198
|
+
else:
|
|
199
|
+
state = self._core.app_state()
|
|
200
|
+
current = str(state.selected_project_id or state.default_project_id)
|
|
201
|
+
|
|
202
|
+
def on_project(choice: str | None) -> None:
|
|
203
|
+
if choice is None:
|
|
204
|
+
return
|
|
205
|
+
project_id = int(choice)
|
|
206
|
+
name = self._core.get_project(project_id).name
|
|
207
|
+
|
|
208
|
+
def on_title(title: str | None) -> None:
|
|
209
|
+
if not title:
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
self._core.add_task(title, project_id=project_id)
|
|
213
|
+
except AppError as e:
|
|
214
|
+
self.notify(str(e), severity="error")
|
|
215
|
+
return
|
|
216
|
+
self.reload()
|
|
217
|
+
# The task is invisible when a different project filter is active — confirm it landed.
|
|
218
|
+
filter_id = self._core.app_state().selected_project_id
|
|
219
|
+
if filter_id is not None and filter_id != project_id:
|
|
220
|
+
self.notify(f'Added to "{name}"')
|
|
221
|
+
|
|
222
|
+
self.app.push_screen(InputDialog(f'New task in "{name}":'), on_title)
|
|
223
|
+
|
|
224
|
+
self.app.push_screen(SelectDialog("Add task to project:", options, current), on_project)
|
|
225
|
+
|
|
186
226
|
def _cursor_task_id(self) -> int | None:
|
|
187
227
|
"""Return the id of the task under the cursor, or None on an empty table.
|
|
188
228
|
|
|
@@ -297,7 +337,7 @@ class TaskListScreen(Screen[None]):
|
|
|
297
337
|
self.reload()
|
|
298
338
|
|
|
299
339
|
def action_refresh(self) -> None:
|
|
300
|
-
"""Reload the list: apply pending re-sorts/hides from d/s and pick up
|
|
340
|
+
"""Reload the list: apply pending re-sorts/hides from d/s and pick up changes from another instance."""
|
|
301
341
|
self.reload()
|
|
302
342
|
|
|
303
343
|
def action_focus_project(self) -> None:
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|