taskunity 2026.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskunity/__init__.py +1 -0
- taskunity/app.py +1268 -0
- taskunity/cli.py +43 -0
- taskunity/models.py +103 -0
- taskunity/render.py +371 -0
- taskunity/static/app.css +1394 -0
- taskunity/static/chart.umd.min.js +14 -0
- taskunity/static/chartjs-adapter-date-fns.bundle.min.js +7 -0
- taskunity/static/htmx.min.js +68 -0
- taskunity/task_store.py +598 -0
- taskunity/templates/base.html +397 -0
- taskunity/templates/index.html +4 -0
- taskunity/templates/partials/board.html +24 -0
- taskunity/templates/partials/calendar.html +25 -0
- taskunity/templates/partials/main.html +283 -0
- taskunity/templates/partials/milestone_banner.html +34 -0
- taskunity/templates/partials/milestone_panel.html +222 -0
- taskunity/templates/partials/milestones.html +55 -0
- taskunity/templates/partials/projects.html +41 -0
- taskunity/templates/partials/task_list.html +52 -0
- taskunity/templates/partials/task_panel.html +310 -0
- taskunity/templates/partials/timeline.html +24 -0
- taskunity-2026.1.dist-info/METADATA +238 -0
- taskunity-2026.1.dist-info/RECORD +27 -0
- taskunity-2026.1.dist-info/WHEEL +5 -0
- taskunity-2026.1.dist-info/entry_points.txt +2 -0
- taskunity-2026.1.dist-info/top_level.txt +1 -0
taskunity/task_store.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .models import Attachment, Milestone, Note, Project, Task, TaskActivityEvent
|
|
14
|
+
|
|
15
|
+
DEFAULT_WORKSPACE_APP_NAME = "Taskunity"
|
|
16
|
+
DEFAULT_WORKSPACE_DESCRIPTION = "Local file-backed workspace/task tracker"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkspaceError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Compiled once: task/milestone IDs are uppercase hex words joined by dashes.
|
|
24
|
+
_ID_RE = re.compile(r'^[A-Z0-9][A-Z0-9\-]*[A-Z0-9]$|^[A-Z0-9]$')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _safe_id(value: str, label: str = "id") -> str:
|
|
28
|
+
"""Validate *value* is a safe ID token, otherwise raise WorkspaceError.
|
|
29
|
+
|
|
30
|
+
This is a lightweight pre-check. Actual path confinement is enforced by
|
|
31
|
+
``_safe_subpath``; always prefer that function when constructing file paths.
|
|
32
|
+
"""
|
|
33
|
+
clean = (value or "").strip()
|
|
34
|
+
if not clean or not _ID_RE.match(clean) or ".." in clean or "/" in clean or "\\" in clean:
|
|
35
|
+
raise WorkspaceError(f"Invalid {label}: {value!r}")
|
|
36
|
+
return clean
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_subpath(base: Path, *parts: str) -> Path:
|
|
40
|
+
"""Build ``base / parts`` and verify the result is confined within *base*.
|
|
41
|
+
|
|
42
|
+
Uses ``os.path.normpath`` to collapse ``..`` segments so that a crafted
|
|
43
|
+
component such as ``../../etc/passwd`` resolves outside the workspace and is
|
|
44
|
+
rejected. This is the canonical path-injection mitigation pattern.
|
|
45
|
+
"""
|
|
46
|
+
joined = os.path.join(str(base), *[str(p) for p in parts])
|
|
47
|
+
normed = os.path.normpath(joined)
|
|
48
|
+
base_str = os.path.normpath(str(base))
|
|
49
|
+
if normed != base_str and not normed.startswith(base_str + os.sep):
|
|
50
|
+
raise WorkspaceError(f"Path traversal detected in: {parts!r}")
|
|
51
|
+
return Path(normed)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def workspace_paths(workspace: Path) -> dict[str, Path]:
|
|
55
|
+
return {
|
|
56
|
+
"root": workspace,
|
|
57
|
+
"projects": workspace / "projects",
|
|
58
|
+
"tasks": workspace / "tasks",
|
|
59
|
+
"milestones": workspace / "milestones",
|
|
60
|
+
"assets": workspace / "assets",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ensure_workspace(workspace: Path) -> None:
|
|
65
|
+
paths = workspace_paths(workspace)
|
|
66
|
+
paths["root"].mkdir(parents=True, exist_ok=True)
|
|
67
|
+
paths["projects"].mkdir(parents=True, exist_ok=True)
|
|
68
|
+
paths["tasks"].mkdir(parents=True, exist_ok=True)
|
|
69
|
+
paths["milestones"].mkdir(parents=True, exist_ok=True)
|
|
70
|
+
paths["assets"].mkdir(parents=True, exist_ok=True)
|
|
71
|
+
config = workspace / "config.json"
|
|
72
|
+
if not config.exists():
|
|
73
|
+
save_json(config, default_workspace_config(workspace))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def init_workspace(workspace: Path, with_sample: bool = True) -> None:
|
|
77
|
+
ensure_workspace(workspace)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
84
|
+
except FileNotFoundError as exc:
|
|
85
|
+
raise WorkspaceError(f"Missing file: {path}") from exc
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
raise WorkspaceError(f"Invalid JSON in {path}: {exc}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_json(path: Path, data: dict[str, Any]) -> None:
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _slugify_name(name: str) -> str:
|
|
96
|
+
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
|
97
|
+
return slug or "default"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _project_filename(name: str) -> str:
|
|
101
|
+
return f"{_slugify_name(name)}.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_project(workspace: Path, name: str) -> Project:
|
|
105
|
+
return Project.model_validate(load_json(workspace / "projects" / _project_filename(name)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_all_projects(workspace: Path) -> list[Project]:
|
|
109
|
+
ensure_workspace(workspace)
|
|
110
|
+
projects_dir = workspace / "projects"
|
|
111
|
+
projects = [Project.model_validate(load_json(path)) for path in sorted(projects_dir.glob("*.json"), key=lambda p: p.name.lower())]
|
|
112
|
+
return projects
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def save_project(workspace: Path, project: Project) -> None:
|
|
116
|
+
ensure_workspace(workspace)
|
|
117
|
+
save_json(workspace / "projects" / _project_filename(project.name), project.model_dump(mode="json"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
DEFAULT_PROJECT_COLOR = "#2e6fd8"
|
|
121
|
+
PROJECT_PALETTE = [
|
|
122
|
+
"#2e6fd8",
|
|
123
|
+
"#338a52",
|
|
124
|
+
"#c05746",
|
|
125
|
+
"#8a5cd1",
|
|
126
|
+
"#c08a2e",
|
|
127
|
+
"#2e9bb0",
|
|
128
|
+
"#b0457f",
|
|
129
|
+
"#5c7a8a",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def available_projects(projects: list[Project], tasks: list[Task]) -> list[Project]:
|
|
134
|
+
by_name: dict[str, Project] = {p.name: p for p in projects}
|
|
135
|
+
for task in tasks:
|
|
136
|
+
if task.project and task.project not in by_name:
|
|
137
|
+
by_name[task.project] = Project(name=task.project, color=DEFAULT_PROJECT_COLOR)
|
|
138
|
+
return sorted(by_name.values(), key=lambda p: p.name.lower())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def project_colors(projects: list[Project], tasks: list[Task]) -> dict[str, str]:
|
|
142
|
+
return {p.name: p.color for p in available_projects(projects, tasks)}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def register_project(workspace: Path, name: str) -> None:
|
|
146
|
+
name = (name or "").strip()
|
|
147
|
+
if not name:
|
|
148
|
+
return
|
|
149
|
+
projects = load_all_projects(workspace)
|
|
150
|
+
if not any(p.name == name for p in projects):
|
|
151
|
+
used = {p.color for p in projects}
|
|
152
|
+
color = next((c for c in PROJECT_PALETTE if c not in used), DEFAULT_PROJECT_COLOR)
|
|
153
|
+
save_project(workspace, Project(name=name, color=color))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def upsert_project(workspace: Path, name: str, description: str = "", color: str = "") -> None:
|
|
157
|
+
name = (name or "").strip()
|
|
158
|
+
if not name:
|
|
159
|
+
return
|
|
160
|
+
color = (color or "").strip() or DEFAULT_PROJECT_COLOR
|
|
161
|
+
project = next((item for item in load_all_projects(workspace) if item.name == name), None)
|
|
162
|
+
if project is None:
|
|
163
|
+
project = Project(name=name)
|
|
164
|
+
project.description = description
|
|
165
|
+
project.color = color
|
|
166
|
+
save_project(workspace, project)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def workspace_label(workspace: Path) -> str:
|
|
170
|
+
config_name = (load_workspace_config(workspace).get("workspace_name") or "").strip()
|
|
171
|
+
if config_name:
|
|
172
|
+
return config_name
|
|
173
|
+
return _workspace_label_from_path(workspace)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _workspace_label_from_path(workspace: Path) -> str:
|
|
177
|
+
label = (workspace.name or DEFAULT_WORKSPACE_APP_NAME).replace("_", " ").replace("-", " ").strip()
|
|
178
|
+
return label.title() if label else DEFAULT_WORKSPACE_APP_NAME
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def default_workspace_config(workspace: Path) -> dict[str, str]:
|
|
182
|
+
workspace_name = _workspace_label_from_path(workspace)
|
|
183
|
+
return {
|
|
184
|
+
"app_name": DEFAULT_WORKSPACE_APP_NAME,
|
|
185
|
+
"workspace_name": workspace_name,
|
|
186
|
+
"workspace_description": DEFAULT_WORKSPACE_DESCRIPTION,
|
|
187
|
+
"export_title": workspace_name,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_workspace_config(workspace: Path) -> dict[str, str]:
|
|
192
|
+
ensure_workspace(workspace)
|
|
193
|
+
defaults = default_workspace_config(workspace)
|
|
194
|
+
raw = load_json(workspace / "config.json")
|
|
195
|
+
config: dict[str, str] = {}
|
|
196
|
+
for key, fallback in defaults.items():
|
|
197
|
+
value = raw.get(key)
|
|
198
|
+
stripped = value.strip() if isinstance(value, str) else ""
|
|
199
|
+
config[key] = stripped or fallback
|
|
200
|
+
return config
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_task(workspace: Path, task_id: str) -> Task:
|
|
204
|
+
return Task.model_validate(load_json(_safe_subpath(workspace / "tasks", f"{task_id}.json")))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_all_tasks(workspace: Path) -> list[Task]:
|
|
208
|
+
ensure_workspace(workspace)
|
|
209
|
+
tasks: list[Task] = []
|
|
210
|
+
for path in sorted((workspace / "tasks").glob("*.json")):
|
|
211
|
+
tasks.append(Task.model_validate(load_json(path)))
|
|
212
|
+
return tasks
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def save_task(workspace: Path, task: Task) -> None:
|
|
216
|
+
ensure_workspace(workspace)
|
|
217
|
+
save_json(_safe_subpath(workspace / "tasks", f"{task.id}.json"), task.model_dump(mode="json"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _generate_task_id() -> str:
|
|
221
|
+
raw = secrets.token_hex(8) # 16 hex characters
|
|
222
|
+
return "-".join(raw[i : i + 4] for i in range(0, 16, 4)).upper()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def next_task_id(workspace: Path) -> str:
|
|
226
|
+
tasks_dir = workspace / "tasks"
|
|
227
|
+
for _ in range(1000):
|
|
228
|
+
candidate = _generate_task_id()
|
|
229
|
+
if not (tasks_dir / f"{candidate}.json").exists():
|
|
230
|
+
return candidate
|
|
231
|
+
raise WorkspaceError("Unable to generate a unique task id")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def create_task(workspace: Path, title: str = "New task") -> Task:
|
|
235
|
+
task = Task(id=next_task_id(workspace), title=title or "New task")
|
|
236
|
+
save_task(workspace, task)
|
|
237
|
+
return task
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def delete_task(workspace: Path, task_id: str) -> None:
|
|
241
|
+
path = _safe_subpath(workspace / "tasks", f"{task_id}.json")
|
|
242
|
+
if path.exists():
|
|
243
|
+
path.unlink()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def add_note(workspace: Path, task_id: str, body: str) -> Task:
|
|
247
|
+
task = load_task(workspace, task_id)
|
|
248
|
+
if body.strip():
|
|
249
|
+
task.notes.append(Note(body=body.strip()))
|
|
250
|
+
save_task(workspace, task)
|
|
251
|
+
return task
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def add_attachment(workspace: Path, task_id: str, filename: str, content: bytes, content_type: str | None = None, description: str = "") -> Task:
|
|
255
|
+
task = load_task(workspace, task_id)
|
|
256
|
+
safe_name = Path(filename).name # cross-platform: strips any leading path component
|
|
257
|
+
target_dir = _safe_subpath(workspace / "assets", task_id)
|
|
258
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
target_path = _safe_subpath(target_dir, safe_name)
|
|
260
|
+
target_path.write_bytes(content)
|
|
261
|
+
kind = "image" if (content_type or "").startswith("image/") else "file"
|
|
262
|
+
rel = os.path.relpath(str(target_path), str(workspace))
|
|
263
|
+
if rel.startswith(".."):
|
|
264
|
+
raise WorkspaceError(f"Attachment path escapes workspace: {rel!r}")
|
|
265
|
+
task.attachments.append(
|
|
266
|
+
Attachment(filename=safe_name, path=rel, kind=kind, description=description.strip())
|
|
267
|
+
)
|
|
268
|
+
save_task(workspace, task)
|
|
269
|
+
return task
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def add_task_activity_note(workspace: Path, task_id: str, body: str) -> Task:
|
|
273
|
+
task = load_task(workspace, task_id)
|
|
274
|
+
if body.strip():
|
|
275
|
+
task.activity.append(TaskActivityEvent(event_type="note", note_text=body.strip()))
|
|
276
|
+
save_task(workspace, task)
|
|
277
|
+
return task
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def add_task_activity_image(
|
|
281
|
+
workspace: Path,
|
|
282
|
+
task_id: str,
|
|
283
|
+
filename: str,
|
|
284
|
+
content: bytes,
|
|
285
|
+
content_type: str | None = None,
|
|
286
|
+
description: str = "",
|
|
287
|
+
) -> Task:
|
|
288
|
+
task = load_task(workspace, task_id)
|
|
289
|
+
safe_name = Path(filename).name # cross-platform: strips any leading path component
|
|
290
|
+
target_dir = _safe_subpath(workspace / "assets", task_id)
|
|
291
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
target_path = _safe_subpath(target_dir, safe_name)
|
|
293
|
+
target_path.write_bytes(content)
|
|
294
|
+
rel = os.path.relpath(str(target_path), str(workspace))
|
|
295
|
+
if rel.startswith(".."):
|
|
296
|
+
raise WorkspaceError(f"Image path escapes workspace: {rel!r}")
|
|
297
|
+
description_text = description.strip() or None
|
|
298
|
+
task.activity.append(
|
|
299
|
+
TaskActivityEvent(
|
|
300
|
+
event_type="image",
|
|
301
|
+
image_path=rel,
|
|
302
|
+
image_filename=safe_name,
|
|
303
|
+
note_text=description_text,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
save_task(workspace, task)
|
|
307
|
+
return task
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def log_progress_change(workspace_path: Path, task: Task, old_progress: int, new_progress: int) -> None:
|
|
311
|
+
if old_progress != new_progress:
|
|
312
|
+
task.activity.append(
|
|
313
|
+
TaskActivityEvent(
|
|
314
|
+
event_type="progress_update",
|
|
315
|
+
progress_before=old_progress,
|
|
316
|
+
progress_after=new_progress,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Milestones -------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_milestone(workspace: Path, milestone_id: str) -> Milestone:
|
|
325
|
+
return Milestone.model_validate(load_json(_safe_subpath(workspace / "milestones", f"{milestone_id}.json")))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def load_all_milestones(workspace: Path) -> list[Milestone]:
|
|
329
|
+
ensure_workspace(workspace)
|
|
330
|
+
milestones: list[Milestone] = []
|
|
331
|
+
for path in sorted((workspace / "milestones").glob("*.json")):
|
|
332
|
+
milestones.append(Milestone.model_validate(load_json(path)))
|
|
333
|
+
return milestones
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def save_milestone(workspace: Path, milestone: Milestone) -> None:
|
|
337
|
+
ensure_workspace(workspace)
|
|
338
|
+
save_json(_safe_subpath(workspace / "milestones", f"{milestone.id}.json"), milestone.model_dump(mode="json"))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def next_milestone_id(workspace: Path) -> str:
|
|
342
|
+
milestones_dir = workspace / "milestones"
|
|
343
|
+
for _ in range(1000):
|
|
344
|
+
candidate = "M-" + "-".join(
|
|
345
|
+
secrets.token_hex(4)[i : i + 4] for i in range(0, 8, 4)
|
|
346
|
+
).upper()
|
|
347
|
+
if not (milestones_dir / f"{candidate}.json").exists():
|
|
348
|
+
return candidate
|
|
349
|
+
raise WorkspaceError("Unable to generate a unique milestone id")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def create_milestone(workspace: Path, title: str = "New milestone") -> Milestone:
|
|
353
|
+
milestone = Milestone(id=next_milestone_id(workspace), title=title or "New milestone")
|
|
354
|
+
save_milestone(workspace, milestone)
|
|
355
|
+
return milestone
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def delete_milestone(workspace: Path, milestone_id: str) -> None:
|
|
359
|
+
path = _safe_subpath(workspace / "milestones", f"{milestone_id}.json")
|
|
360
|
+
if path.exists():
|
|
361
|
+
path.unlink()
|
|
362
|
+
assets = _safe_subpath(workspace / "assets", milestone_id)
|
|
363
|
+
if assets.exists():
|
|
364
|
+
shutil.rmtree(assets, ignore_errors=True)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def add_milestone_note(workspace: Path, milestone_id: str, body: str) -> Milestone:
|
|
368
|
+
milestone = load_milestone(workspace, milestone_id)
|
|
369
|
+
if body.strip():
|
|
370
|
+
milestone.notes.append(Note(body=body.strip()))
|
|
371
|
+
save_milestone(workspace, milestone)
|
|
372
|
+
return milestone
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def add_milestone_attachment(
|
|
376
|
+
workspace: Path,
|
|
377
|
+
milestone_id: str,
|
|
378
|
+
filename: str,
|
|
379
|
+
content: bytes,
|
|
380
|
+
content_type: str | None = None,
|
|
381
|
+
description: str = "",
|
|
382
|
+
) -> Milestone:
|
|
383
|
+
milestone = load_milestone(workspace, milestone_id)
|
|
384
|
+
safe_name = Path(filename).name # cross-platform: strips any leading path component
|
|
385
|
+
target_dir = _safe_subpath(workspace / "assets", milestone_id)
|
|
386
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
target_path = _safe_subpath(target_dir, safe_name)
|
|
388
|
+
target_path.write_bytes(content)
|
|
389
|
+
rel = os.path.relpath(str(target_path), str(workspace))
|
|
390
|
+
if rel.startswith(".."):
|
|
391
|
+
raise WorkspaceError(f"Attachment path escapes workspace: {rel!r}")
|
|
392
|
+
kind = "image" if (content_type or "").startswith("image/") else "file"
|
|
393
|
+
milestone.attachments.append(
|
|
394
|
+
Attachment(
|
|
395
|
+
filename=safe_name,
|
|
396
|
+
path=rel,
|
|
397
|
+
kind=kind,
|
|
398
|
+
description=description.strip(),
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
save_milestone(workspace, milestone)
|
|
402
|
+
return milestone
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def add_task_to_milestone(workspace: Path, milestone_id: str, task_id: str) -> Milestone:
|
|
406
|
+
milestone = load_milestone(workspace, milestone_id)
|
|
407
|
+
if task_id and task_id not in milestone.task_ids:
|
|
408
|
+
if _safe_subpath(workspace / "tasks", f"{task_id}.json").exists():
|
|
409
|
+
milestone.task_ids.append(task_id)
|
|
410
|
+
save_milestone(workspace, milestone)
|
|
411
|
+
return milestone
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def remove_task_from_milestone(workspace: Path, milestone_id: str, task_id: str) -> Milestone:
|
|
415
|
+
milestone = load_milestone(workspace, milestone_id)
|
|
416
|
+
if task_id in milestone.task_ids:
|
|
417
|
+
milestone.task_ids.remove(task_id)
|
|
418
|
+
save_milestone(workspace, milestone)
|
|
419
|
+
return milestone
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def move_task_in_milestone(workspace: Path, milestone_id: str, task_id: str, direction: str) -> Milestone:
|
|
423
|
+
milestone = load_milestone(workspace, milestone_id)
|
|
424
|
+
ids = milestone.task_ids
|
|
425
|
+
if task_id in ids:
|
|
426
|
+
idx = ids.index(task_id)
|
|
427
|
+
swap = idx - 1 if direction == "up" else idx + 1
|
|
428
|
+
if 0 <= swap < len(ids):
|
|
429
|
+
ids[idx], ids[swap] = ids[swap], ids[idx]
|
|
430
|
+
save_milestone(workspace, milestone)
|
|
431
|
+
return milestone
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def copy_starter_files(target: Path) -> None:
|
|
435
|
+
init_workspace(target, with_sample=False)
|
|
436
|
+
readme = target / "README.md"
|
|
437
|
+
if not readme.exists():
|
|
438
|
+
readme.write_text(
|
|
439
|
+
"# My Taskunity Workspace\n\n"
|
|
440
|
+
"Run `taskunity serve` from this folder to launch the local dashboard.\n",
|
|
441
|
+
encoding="utf-8",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _git(workspace: Path, *args: str, timeout: int = 20) -> subprocess.CompletedProcess[str]:
|
|
446
|
+
return subprocess.run(
|
|
447
|
+
["git", *args],
|
|
448
|
+
cwd=str(workspace),
|
|
449
|
+
capture_output=True,
|
|
450
|
+
text=True,
|
|
451
|
+
timeout=timeout,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _workspace_repo_message() -> str:
|
|
456
|
+
return "Git integration only works when the workspace folder is the repository root."
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def git_status(workspace: Path) -> dict[str, Any]:
|
|
460
|
+
info: dict[str, Any] = {
|
|
461
|
+
"tracked": False,
|
|
462
|
+
"branch": "",
|
|
463
|
+
"upstream": None,
|
|
464
|
+
"ahead": 0,
|
|
465
|
+
"behind": 0,
|
|
466
|
+
"dirty": 0,
|
|
467
|
+
"message": "",
|
|
468
|
+
}
|
|
469
|
+
try:
|
|
470
|
+
inside = _git(workspace, "rev-parse", "--is-inside-work-tree")
|
|
471
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
472
|
+
return info
|
|
473
|
+
top_level = _git(workspace, "rev-parse", "--show-toplevel")
|
|
474
|
+
if top_level.returncode != 0:
|
|
475
|
+
return info
|
|
476
|
+
if Path(top_level.stdout.strip()).resolve() != workspace.resolve():
|
|
477
|
+
info["message"] = _workspace_repo_message()
|
|
478
|
+
return info
|
|
479
|
+
info["tracked"] = True
|
|
480
|
+
info["branch"] = _git(workspace, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
481
|
+
upstream = _git(workspace, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
482
|
+
if upstream.returncode == 0:
|
|
483
|
+
info["upstream"] = upstream.stdout.strip()
|
|
484
|
+
counts = _git(workspace, "rev-list", "--left-right", "--count", "@{u}...HEAD")
|
|
485
|
+
if counts.returncode == 0:
|
|
486
|
+
parts = counts.stdout.split()
|
|
487
|
+
if len(parts) == 2:
|
|
488
|
+
info["behind"] = int(parts[0])
|
|
489
|
+
info["ahead"] = int(parts[1])
|
|
490
|
+
status = _git(workspace, "status", "--porcelain", "--", ".")
|
|
491
|
+
if status.returncode == 0:
|
|
492
|
+
info["dirty"] = len([line for line in status.stdout.splitlines() if line.strip()])
|
|
493
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
494
|
+
info["message"] = str(exc)
|
|
495
|
+
return info
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def git_sync(workspace: Path) -> dict[str, Any]:
|
|
499
|
+
result: dict[str, Any] = {"ok": False, "message": ""}
|
|
500
|
+
status = git_status(workspace)
|
|
501
|
+
if not status["tracked"]:
|
|
502
|
+
result["message"] = status["message"] or "This workspace is not inside a git repository."
|
|
503
|
+
return result
|
|
504
|
+
try:
|
|
505
|
+
if status["dirty"]:
|
|
506
|
+
_git(workspace, "add", "-A", "--", ".")
|
|
507
|
+
commit = _git(
|
|
508
|
+
workspace, "commit", "-m", f"taskunity: sync workspace ({datetime.now():%Y-%m-%d %H:%M})"
|
|
509
|
+
)
|
|
510
|
+
if commit.returncode != 0 and "nothing to commit" not in (commit.stdout + commit.stderr).lower():
|
|
511
|
+
result["message"] = "Commit failed: " + (commit.stderr.strip() or commit.stdout.strip())
|
|
512
|
+
return result
|
|
513
|
+
if not status["upstream"]:
|
|
514
|
+
result["ok"] = True
|
|
515
|
+
result["message"] = "Committed locally. No upstream is configured, so nothing was pushed."
|
|
516
|
+
return result
|
|
517
|
+
pull = _git(workspace, "pull", "--no-edit")
|
|
518
|
+
if pull.returncode != 0:
|
|
519
|
+
result["message"] = "Pull failed: " + (pull.stderr.strip() or pull.stdout.strip())
|
|
520
|
+
return result
|
|
521
|
+
push = _git(workspace, "push")
|
|
522
|
+
if push.returncode != 0:
|
|
523
|
+
result["message"] = "Push failed: " + (push.stderr.strip() or push.stdout.strip())
|
|
524
|
+
return result
|
|
525
|
+
result["ok"] = True
|
|
526
|
+
result["message"] = f"Synced with {status['upstream']}."
|
|
527
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
528
|
+
result["message"] = str(exc)
|
|
529
|
+
return result
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def git_lfs_available(workspace: Path) -> bool:
|
|
533
|
+
"""Return True if git-lfs is installed and accessible."""
|
|
534
|
+
try:
|
|
535
|
+
result = subprocess.run(
|
|
536
|
+
["git", "lfs", "version"],
|
|
537
|
+
capture_output=True,
|
|
538
|
+
text=True,
|
|
539
|
+
timeout=5,
|
|
540
|
+
)
|
|
541
|
+
return result.returncode == 0
|
|
542
|
+
except (OSError, subprocess.SubprocessError):
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def git_lfs_init(workspace: Path) -> dict[str, Any]:
|
|
547
|
+
"""Initialize git-lfs in the workspace: run `git lfs install` and track assets."""
|
|
548
|
+
result: dict[str, Any] = {"ok": False, "message": ""}
|
|
549
|
+
if not git_lfs_available(workspace):
|
|
550
|
+
result["message"] = "git-lfs is not installed or not on PATH."
|
|
551
|
+
return result
|
|
552
|
+
status = git_status(workspace)
|
|
553
|
+
if not status["tracked"]:
|
|
554
|
+
result["message"] = status["message"] or "Workspace is not a git repository."
|
|
555
|
+
return result
|
|
556
|
+
try:
|
|
557
|
+
install = _git(workspace, "lfs", "install", "--local")
|
|
558
|
+
if install.returncode != 0:
|
|
559
|
+
result["message"] = "git lfs install failed: " + (install.stderr.strip() or install.stdout.strip())
|
|
560
|
+
return result
|
|
561
|
+
track = _git(workspace, "lfs", "track", "assets/**")
|
|
562
|
+
if track.returncode != 0:
|
|
563
|
+
result["message"] = "git lfs track failed: " + (track.stderr.strip() or track.stdout.strip())
|
|
564
|
+
return result
|
|
565
|
+
add = _git(workspace, "add", ".gitattributes")
|
|
566
|
+
if add.returncode != 0:
|
|
567
|
+
result["message"] = "git add .gitattributes failed"
|
|
568
|
+
return result
|
|
569
|
+
commit = _git(workspace, "commit", "-m", "chore: initialize git-lfs tracking for assets")
|
|
570
|
+
if commit.returncode != 0 and "nothing to commit" not in (commit.stdout + commit.stderr).lower():
|
|
571
|
+
result["message"] = "Commit failed: " + (commit.stderr.strip() or commit.stdout.strip())
|
|
572
|
+
return result
|
|
573
|
+
result["ok"] = True
|
|
574
|
+
result["message"] = "git-lfs initialized. Assets directory is now tracked with LFS."
|
|
575
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
576
|
+
result["message"] = str(exc)
|
|
577
|
+
return result
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def git_lfs_status(workspace: Path) -> dict[str, Any]:
|
|
581
|
+
"""Return LFS status information for the workspace."""
|
|
582
|
+
info: dict[str, Any] = {"available": False, "enabled": False, "tracking_assets": False, "message": ""}
|
|
583
|
+
info["available"] = git_lfs_available(workspace)
|
|
584
|
+
if not info["available"]:
|
|
585
|
+
return info
|
|
586
|
+
status = git_status(workspace)
|
|
587
|
+
if not status["tracked"]:
|
|
588
|
+
return info
|
|
589
|
+
try:
|
|
590
|
+
lfs_hooks = workspace / ".git" / "hooks" / "pre-push"
|
|
591
|
+
info["enabled"] = lfs_hooks.exists()
|
|
592
|
+
gitattributes = workspace / ".gitattributes"
|
|
593
|
+
if gitattributes.exists():
|
|
594
|
+
content = gitattributes.read_text(encoding="utf-8", errors="replace")
|
|
595
|
+
info["tracking_assets"] = "assets/**" in content or "assets/" in content
|
|
596
|
+
except (OSError, IOError) as exc:
|
|
597
|
+
info["message"] = str(exc)
|
|
598
|
+
return info
|