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/app.py ADDED
@@ -0,0 +1,1268 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ import urllib.parse
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ import markdown as markdown_lib
11
+ from fastapi import FastAPI, File, Form, Query, Request, UploadFile
12
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.templating import Jinja2Templates
15
+
16
+ from .models import ChecklistItem, Task
17
+ from .render import (
18
+ SORTS,
19
+ STATUSES,
20
+ build_calendar,
21
+ dashboard_model,
22
+ filter_tasks,
23
+ hide_stale_closed_tasks,
24
+ milestone_rollup,
25
+ sort_tasks,
26
+ tasks_to_jsonantt,
27
+ )
28
+ from .task_store import (
29
+ add_milestone_attachment,
30
+ add_milestone_note,
31
+ add_task_activity_image,
32
+ add_task_activity_note,
33
+ add_task_to_milestone,
34
+ available_projects,
35
+ create_milestone,
36
+ create_task,
37
+ delete_milestone,
38
+ delete_task,
39
+ ensure_workspace,
40
+ git_lfs_init,
41
+ git_lfs_status,
42
+ git_status,
43
+ git_sync,
44
+ log_progress_change,
45
+ load_all_milestones,
46
+ load_all_tasks,
47
+ load_all_projects,
48
+ load_milestone,
49
+ load_workspace_config,
50
+ load_task,
51
+ project_colors,
52
+ register_project,
53
+ remove_task_from_milestone,
54
+ save_milestone,
55
+ save_task,
56
+ upsert_project,
57
+ )
58
+
59
+ PACKAGE_DIR = Path(__file__).parent
60
+
61
+
62
+ def markdown_filter(text: str) -> str:
63
+ return markdown_lib.markdown(text or "", extensions=["extra", "sane_lists"])
64
+
65
+
66
+ def build_task_activity_entries(task: Task | None) -> list[dict[str, object]]:
67
+ if task is None:
68
+ return []
69
+
70
+ entries: list[dict[str, object]] = []
71
+ for note in task.notes:
72
+ entries.append(
73
+ {
74
+ "kind": "note",
75
+ "created_at": note.created_at,
76
+ "body": note.body,
77
+ "filename": None,
78
+ "path": None,
79
+ "is_image": False,
80
+ "progress_before": None,
81
+ "progress_after": None,
82
+ }
83
+ )
84
+ for attachment in task.attachments:
85
+ entries.append(
86
+ {
87
+ "kind": "image" if attachment.kind == "image" else "file",
88
+ "created_at": attachment.uploaded_at,
89
+ "body": attachment.description,
90
+ "filename": attachment.filename,
91
+ "path": attachment.path,
92
+ "is_image": attachment.kind == "image",
93
+ "progress_before": None,
94
+ "progress_after": None,
95
+ }
96
+ )
97
+ for event in task.activity:
98
+ if event.event_type == "progress_update":
99
+ entries.append(
100
+ {
101
+ "kind": "progress_update",
102
+ "created_at": event.created_at,
103
+ "body": None,
104
+ "filename": None,
105
+ "path": None,
106
+ "is_image": False,
107
+ "progress_before": event.progress_before,
108
+ "progress_after": event.progress_after,
109
+ }
110
+ )
111
+ elif event.event_type == "image":
112
+ image_name = event.image_filename or event.image_path or ""
113
+ is_image = Path(image_name).suffix.lower() in {
114
+ ".png",
115
+ ".jpg",
116
+ ".jpeg",
117
+ ".gif",
118
+ ".webp",
119
+ ".bmp",
120
+ ".svg",
121
+ }
122
+ entries.append(
123
+ {
124
+ "kind": "image" if is_image else "file",
125
+ "created_at": event.created_at,
126
+ "body": event.note_text,
127
+ "filename": event.image_filename,
128
+ "path": event.image_path,
129
+ "is_image": is_image,
130
+ "progress_before": None,
131
+ "progress_after": None,
132
+ }
133
+ )
134
+ else:
135
+ entries.append(
136
+ {
137
+ "kind": "note",
138
+ "created_at": event.created_at,
139
+ "body": event.note_text,
140
+ "filename": None,
141
+ "path": None,
142
+ "is_image": False,
143
+ "progress_before": None,
144
+ "progress_after": None,
145
+ }
146
+ )
147
+
148
+ return sorted(entries, key=lambda item: str(item.get("created_at") or ""), reverse=True)
149
+
150
+
151
+ def create_app(workspace: str | Path = ".") -> FastAPI:
152
+ workspace = Path(workspace).resolve()
153
+ ensure_workspace(workspace)
154
+ initial_config = load_workspace_config(workspace)
155
+ app_name = initial_config["app_name"]
156
+
157
+ app = FastAPI(title=app_name)
158
+ templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates"))
159
+ templates.env.filters["markdown"] = markdown_filter
160
+
161
+ app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
162
+ app.mount("/assets", StaticFiles(directory=str(workspace / "assets")), name="assets")
163
+
164
+ VIEWS = {"list", "board", "gantt", "calendar", "projects", "milestones"}
165
+ STALE_CLOSED_DAYS = 30
166
+
167
+ def parse_toggle(value: str | None) -> bool:
168
+ return (value or "").strip().lower() in {"1", "true", "yes", "on"}
169
+
170
+ def parse_stale_days(value: str | int | None) -> int:
171
+ try:
172
+ days = int(str(value).strip())
173
+ except (TypeError, ValueError):
174
+ days = STALE_CLOSED_DAYS
175
+ return max(1, days)
176
+
177
+ def parse_calendar_month(value: str | int | None) -> int | None:
178
+ try:
179
+ month = int(str(value).strip())
180
+ except (TypeError, ValueError):
181
+ return None
182
+ return month if 1 <= month <= 12 else None
183
+
184
+ def ui_config() -> dict[str, str]:
185
+ config = load_workspace_config(workspace)
186
+ return {
187
+ "app_name": config["app_name"],
188
+ "workspace_name": config["workspace_name"],
189
+ "workspace_description": config["workspace_description"],
190
+ "export_title": config["export_title"],
191
+ }
192
+
193
+ def parse_calendar_year(value: str | int | None) -> int | None:
194
+ try:
195
+ year = int(str(value).strip())
196
+ except (TypeError, ValueError):
197
+ return None
198
+ return year if 1900 <= year <= 3000 else None
199
+
200
+ def build_query(
201
+ projects: list[str], date_from: str, date_to: str, q: str, view: str = "", sort: str = "",
202
+ milestone: str = "", show_closed: bool = False, stale_days: int = STALE_CLOSED_DAYS,
203
+ calendar_month: int | None = None, calendar_year: int | None = None,
204
+ ) -> str:
205
+ params: list[tuple[str, str]] = [("project", p) for p in projects if p]
206
+ if date_from:
207
+ params.append(("date_from", date_from))
208
+ if date_to:
209
+ params.append(("date_to", date_to))
210
+ if q:
211
+ params.append(("q", q))
212
+ if milestone:
213
+ params.append(("milestone", milestone))
214
+ if sort and sort != "priority":
215
+ params.append(("sort", sort))
216
+ if show_closed:
217
+ params.append(("show_closed", "1"))
218
+ if stale_days != STALE_CLOSED_DAYS:
219
+ params.append(("stale_days", str(stale_days)))
220
+ if calendar_month is not None:
221
+ params.append(("calendar_month", str(calendar_month)))
222
+ if calendar_year is not None:
223
+ params.append(("calendar_year", str(calendar_year)))
224
+ if view:
225
+ params.append(("view", view))
226
+ return urllib.parse.urlencode(params)
227
+
228
+ def context(
229
+ request: Request,
230
+ selected_task: Task | None = None,
231
+ *,
232
+ projects: list[str] | None = None,
233
+ date_from: str = "",
234
+ date_to: str = "",
235
+ q: str = "",
236
+ sort: str = "priority",
237
+ view: str = "board",
238
+ milestone: str = "",
239
+ show_closed: bool = False,
240
+ stale_days: int = STALE_CLOSED_DAYS,
241
+ calendar_month: int | None = None,
242
+ calendar_year: int | None = None,
243
+ git_message: str = "",
244
+ ) -> dict:
245
+ projects = [p for p in (projects or []) if p]
246
+ q = (q or "").strip()
247
+ sort = sort if sort in SORTS else "priority"
248
+ view = view if view in VIEWS else "board"
249
+ query_params = request.query_params
250
+ today = date.today()
251
+ focus_month = parse_calendar_month(calendar_month or query_params.get("calendar_month")) or today.month
252
+ focus_year = parse_calendar_year(calendar_year or query_params.get("calendar_year")) or today.year
253
+ prev_year = focus_year - 1 if focus_month == 1 else focus_year
254
+ prev_month = 12 if focus_month == 1 else focus_month - 1
255
+ next_year = focus_year + 1 if focus_month == 12 else focus_year
256
+ next_month = 1 if focus_month == 12 else focus_month + 1
257
+ year_prev_month = focus_month
258
+ year_prev_year = focus_year - 1
259
+ year_next_month = focus_month
260
+ year_next_year = focus_year + 1
261
+ config = ui_config()
262
+ all_tasks = load_all_tasks(workspace)
263
+ milestones = load_all_milestones(workspace)
264
+ tasks_by_id = {t.id: t for t in all_tasks}
265
+ milestone_rollups = {m.id: milestone_rollup(m, tasks_by_id) for m in milestones}
266
+
267
+ selected_milestone = None
268
+ rollup = None
269
+ candidate_tasks = all_tasks
270
+ milestone = (milestone or "").strip()
271
+ if milestone:
272
+ selected_milestone = next((m for m in milestones if m.id == milestone), None)
273
+ if selected_milestone is not None:
274
+ rollup = milestone_rollup(selected_milestone, tasks_by_id)
275
+ allowed = set(selected_milestone.task_ids)
276
+ candidate_tasks = [t for t in all_tasks if t.id in allowed]
277
+ else:
278
+ milestone = ""
279
+
280
+ filtered = sort_tasks(filter_tasks(candidate_tasks, projects, date_from, date_to, q), sort)
281
+ hidden_closed_count = 0
282
+ if not show_closed:
283
+ filtered, hidden_closed_count = hide_stale_closed_tasks(filtered, stale_days)
284
+ all_projects = load_all_projects(workspace)
285
+ colors = project_colors(all_projects, all_tasks)
286
+
287
+ pills = []
288
+ if selected_milestone is not None:
289
+ pills.append(
290
+ {
291
+ "label": f"Milestone: {selected_milestone.title}",
292
+ "color": "",
293
+ "remove": build_query(projects, date_from, date_to, q, view, sort, show_closed=show_closed, stale_days=stale_days),
294
+ }
295
+ )
296
+ for p in projects:
297
+ others = [x for x in projects if x != p]
298
+ pills.append(
299
+ {
300
+ "label": f"Project: {p}",
301
+ "color": colors.get(p, ""),
302
+ "remove": build_query(others, date_from, date_to, q, view, sort, milestone, show_closed, stale_days),
303
+ }
304
+ )
305
+ if date_from:
306
+ pills.append(
307
+ {
308
+ "label": f"From {date_from}",
309
+ "color": "",
310
+ "remove": build_query(projects, "", date_to, q, view, sort, milestone, show_closed, stale_days),
311
+ }
312
+ )
313
+ if date_to:
314
+ pills.append(
315
+ {
316
+ "label": f"To {date_to}",
317
+ "color": "",
318
+ "remove": build_query(projects, date_from, "", q, view, sort, milestone, show_closed, stale_days),
319
+ }
320
+ )
321
+ if q:
322
+ pills.append(
323
+ {
324
+ "label": f'Search: "{q}"',
325
+ "color": "",
326
+ "remove": build_query(projects, date_from, date_to, "", view, sort, milestone, show_closed, stale_days),
327
+ }
328
+ )
329
+
330
+ if show_closed:
331
+ pills.append(
332
+ {
333
+ "label": f"Show old stuff ({stale_days}d+)",
334
+ "color": "",
335
+ "remove": build_query(projects, date_from, date_to, q, view, sort, milestone, False, stale_days, focus_month, focus_year),
336
+ }
337
+ )
338
+
339
+ return {
340
+ "request": request,
341
+ "app_name": config["app_name"],
342
+ "workspace_name": config["workspace_name"],
343
+ "model": dashboard_model(filtered),
344
+ "statuses": STATUSES,
345
+ "selected_task": selected_task,
346
+ "milestones": milestones,
347
+ "selected_milestone": selected_milestone,
348
+ "rollup": rollup,
349
+ "milestone_rollups": milestone_rollups,
350
+ "workspace": workspace,
351
+ "projects": all_projects,
352
+ "project_colors": colors,
353
+ "sorts": SORTS,
354
+ "calendar": build_calendar(filtered, date_from, date_to, focus_month, focus_year),
355
+ "git": git_status(workspace),
356
+ "git_lfs": git_lfs_status(workspace),
357
+ "git_message": git_message,
358
+ "task_activity_entries": build_task_activity_entries(selected_task),
359
+ "task_index": [
360
+ {
361
+ "id": t.id,
362
+ "title": t.title,
363
+ "status": t.status,
364
+ "project": t.project,
365
+ "due_date": t.due_date or "",
366
+ }
367
+ for t in sort_tasks(all_tasks, "title")
368
+ ],
369
+ "task_titles": {t.id: t.title for t in all_tasks},
370
+ "filters": {
371
+ "projects": projects,
372
+ "date_from": date_from,
373
+ "date_to": date_to,
374
+ "q": q,
375
+ "sort": sort,
376
+ "view": view,
377
+ "milestone": milestone,
378
+ "stale_days": stale_days,
379
+ "calendar_month": focus_month,
380
+ "calendar_year": focus_year,
381
+ "query": build_query(projects, date_from, date_to, q, "", sort, milestone, show_closed, stale_days, focus_month, focus_year),
382
+ "query_no_sort": build_query(projects, date_from, date_to, q, "", "", milestone, show_closed, stale_days, focus_month, focus_year),
383
+ "calendar_query": build_query(projects, date_from, date_to, q, "calendar", sort, milestone, show_closed, stale_days, focus_month, focus_year),
384
+ "calendar_prev_query": build_query(projects, date_from, date_to, q, "calendar", sort, milestone, show_closed, stale_days, prev_month, prev_year),
385
+ "calendar_next_query": build_query(projects, date_from, date_to, q, "calendar", sort, milestone, show_closed, stale_days, next_month, next_year),
386
+ "calendar_year_prev_query": build_query(projects, date_from, date_to, q, "calendar", sort, milestone, show_closed, stale_days, year_prev_month, year_prev_year),
387
+ "calendar_year_next_query": build_query(projects, date_from, date_to, q, "calendar", sort, milestone, show_closed, stale_days, year_next_month, year_next_year),
388
+ "show_closed": show_closed,
389
+ "hidden_closed_count": hidden_closed_count,
390
+ "toggle_closed_query": build_query(projects, date_from, date_to, q, view, sort, milestone, not show_closed, stale_days, focus_month, focus_year),
391
+ "pills": pills,
392
+ },
393
+ }
394
+
395
+ @app.get("/", response_class=HTMLResponse)
396
+ def index(
397
+ request: Request,
398
+ project: list[str] = Query(default=[]),
399
+ date_from: str = "",
400
+ date_to: str = "",
401
+ q: str = "",
402
+ sort: str = "priority",
403
+ view: str = "board",
404
+ milestone: str = "",
405
+ show_closed: str = "",
406
+ stale_days: str = "",
407
+ ) -> HTMLResponse:
408
+ return templates.TemplateResponse(
409
+ request,
410
+ "index.html",
411
+ context(
412
+ request,
413
+ projects=project,
414
+ date_from=date_from,
415
+ date_to=date_to,
416
+ q=q,
417
+ sort=sort,
418
+ view=view,
419
+ milestone=milestone,
420
+ show_closed=parse_toggle(show_closed),
421
+ stale_days=parse_stale_days(stale_days),
422
+ ),
423
+ )
424
+
425
+ @app.get("/partials/main", response_class=HTMLResponse)
426
+ def main_partial(
427
+ request: Request,
428
+ project: list[str] = Query(default=[]),
429
+ date_from: str = "",
430
+ date_to: str = "",
431
+ q: str = "",
432
+ sort: str = "priority",
433
+ view: str = "board",
434
+ milestone: str = "",
435
+ show_closed: str = "",
436
+ stale_days: str = "",
437
+ ) -> HTMLResponse:
438
+ return templates.TemplateResponse(
439
+ request,
440
+ "partials/main.html",
441
+ context(
442
+ request,
443
+ projects=project,
444
+ date_from=date_from,
445
+ date_to=date_to,
446
+ q=q,
447
+ sort=sort,
448
+ view=view,
449
+ milestone=milestone,
450
+ show_closed=parse_toggle(show_closed),
451
+ stale_days=parse_stale_days(stale_days),
452
+ ),
453
+ )
454
+
455
+ @app.get("/tasks/{task_id}/panel", response_class=HTMLResponse)
456
+ def task_panel(
457
+ request: Request,
458
+ task_id: str,
459
+ project: list[str] = Query(default=[]),
460
+ date_from: str = "",
461
+ date_to: str = "",
462
+ q: str = "",
463
+ view: str = "board",
464
+ milestone: str = "",
465
+ show_closed: str = "",
466
+ stale_days: str = "",
467
+ ) -> HTMLResponse:
468
+ task = load_task(workspace, task_id)
469
+ return templates.TemplateResponse(
470
+ request,
471
+ "partials/task_panel.html",
472
+ context(
473
+ request,
474
+ task,
475
+ projects=project,
476
+ date_from=date_from,
477
+ date_to=date_to,
478
+ q=q,
479
+ view=view,
480
+ milestone=milestone,
481
+ show_closed=parse_toggle(show_closed),
482
+ stale_days=parse_stale_days(stale_days),
483
+ ),
484
+ )
485
+
486
+ @app.post("/tasks/create", response_class=HTMLResponse)
487
+ def create_task_route(
488
+ request: Request,
489
+ title: str = Form("New task"),
490
+ f_project: list[str] = Form(default=[]),
491
+ f_from: str = Form(""),
492
+ f_to: str = Form(""),
493
+ f_q: str = Form(""),
494
+ f_view: str = Form("board"),
495
+ f_milestone: str = Form(""),
496
+ f_show_closed: str = Form(""),
497
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
498
+ ) -> HTMLResponse:
499
+ task = create_task(workspace, title)
500
+ if f_milestone:
501
+ add_task_to_milestone(workspace, f_milestone, task.id)
502
+ return templates.TemplateResponse(
503
+ request,
504
+ "partials/main.html",
505
+ context(
506
+ request,
507
+ task,
508
+ projects=f_project,
509
+ date_from=f_from,
510
+ date_to=f_to,
511
+ q=f_q,
512
+ view=f_view,
513
+ milestone=f_milestone,
514
+ show_closed=parse_toggle(f_show_closed),
515
+ stale_days=parse_stale_days(f_stale_days),
516
+ ),
517
+ )
518
+
519
+ @app.post("/tasks/{task_id}/save", response_class=HTMLResponse)
520
+ async def save_task_route(
521
+ request: Request,
522
+ task_id: str,
523
+ title: str = Form(...),
524
+ status: str = Form("backlog"),
525
+ priority: str = Form("normal"),
526
+ project: str = Form(""),
527
+ summary: str = Form(""),
528
+ description: str = Form(""),
529
+ tags: str = Form(""),
530
+ start_date: str = Form(""),
531
+ due_date: str = Form(""),
532
+ completed_date: str = Form(""),
533
+ percent_complete: int = Form(0),
534
+ depends_on: str = Form(""),
535
+ checklist_text: str = Form(""),
536
+ f_project: list[str] = Form(default=[]),
537
+ f_from: str = Form(""),
538
+ f_to: str = Form(""),
539
+ f_q: str = Form(""),
540
+ f_view: str = Form("board"),
541
+ f_milestone: str = Form(""),
542
+ f_show_closed: str = Form(""),
543
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
544
+ ) -> HTMLResponse:
545
+ task = load_task(workspace, task_id)
546
+ task.title = title
547
+ task.status = status # pydantic validation occurs at save roundtrip in raw mode; keep simple for forms
548
+ task.priority = priority
549
+ task.project = project.strip()
550
+ task.summary = summary
551
+ task.description = description
552
+ task.tags = [x.strip() for x in tags.split(",") if x.strip()]
553
+ task.start_date = start_date or None
554
+ task.due_date = due_date or None
555
+ task.completed_date = completed_date or None
556
+ old_progress = task.percent_complete
557
+ task.percent_complete = max(0, min(int(percent_complete), 100))
558
+ log_progress_change(workspace, task, old_progress, task.percent_complete)
559
+ task.depends_on = [x.strip() for x in depends_on.split(",") if x.strip()]
560
+ checklist = []
561
+ for line in checklist_text.splitlines():
562
+ line = line.strip()
563
+ if not line:
564
+ continue
565
+ done = line.startswith("[x]") or line.startswith("[X]")
566
+ text = line[3:].strip() if line[:3].lower() in {"[x]", "[ ]"} else line
567
+ checklist.append(ChecklistItem(text=text, done=done))
568
+ task.checklist = checklist
569
+ save_task(workspace, task)
570
+ register_project(workspace, task.project)
571
+ return templates.TemplateResponse(
572
+ request,
573
+ "partials/main.html",
574
+ context(
575
+ request,
576
+ task,
577
+ projects=f_project,
578
+ date_from=f_from,
579
+ date_to=f_to,
580
+ q=f_q,
581
+ view=f_view,
582
+ milestone=f_milestone,
583
+ show_closed=parse_toggle(f_show_closed),
584
+ stale_days=parse_stale_days(f_stale_days),
585
+ ),
586
+ )
587
+
588
+ @app.post("/tasks/{task_id}/checklist/add", response_class=HTMLResponse)
589
+ async def checklist_add_route(
590
+ request: Request,
591
+ task_id: str,
592
+ item_text: str = Form(""),
593
+ f_project: list[str] = Form(default=[]),
594
+ f_from: str = Form(""),
595
+ f_to: str = Form(""),
596
+ f_q: str = Form(""),
597
+ f_view: str = Form("board"),
598
+ f_milestone: str = Form(""),
599
+ f_show_closed: str = Form(""),
600
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
601
+ ) -> HTMLResponse:
602
+ task = load_task(workspace, task_id)
603
+ text = item_text.strip()
604
+ if text:
605
+ task.checklist.append(ChecklistItem(text=text, done=False))
606
+ save_task(workspace, task)
607
+ return templates.TemplateResponse(
608
+ request,
609
+ "partials/main.html",
610
+ context(
611
+ request,
612
+ task,
613
+ projects=f_project,
614
+ date_from=f_from,
615
+ date_to=f_to,
616
+ q=f_q,
617
+ view=f_view,
618
+ milestone=f_milestone,
619
+ show_closed=parse_toggle(f_show_closed),
620
+ stale_days=parse_stale_days(f_stale_days),
621
+ ),
622
+ )
623
+
624
+ @app.post("/tasks/{task_id}/checklist/{item_index}/toggle", response_class=HTMLResponse)
625
+ async def checklist_toggle_route(
626
+ request: Request,
627
+ task_id: str,
628
+ item_index: int,
629
+ f_project: list[str] = Form(default=[]),
630
+ f_from: str = Form(""),
631
+ f_to: str = Form(""),
632
+ f_q: str = Form(""),
633
+ f_view: str = Form("board"),
634
+ f_milestone: str = Form(""),
635
+ f_show_closed: str = Form(""),
636
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
637
+ ) -> HTMLResponse:
638
+ task = load_task(workspace, task_id)
639
+ if 0 <= item_index < len(task.checklist):
640
+ task.checklist[item_index].done = not task.checklist[item_index].done
641
+ save_task(workspace, task)
642
+ return templates.TemplateResponse(
643
+ request,
644
+ "partials/main.html",
645
+ context(
646
+ request,
647
+ task,
648
+ projects=f_project,
649
+ date_from=f_from,
650
+ date_to=f_to,
651
+ q=f_q,
652
+ view=f_view,
653
+ milestone=f_milestone,
654
+ show_closed=parse_toggle(f_show_closed),
655
+ stale_days=parse_stale_days(f_stale_days),
656
+ ),
657
+ )
658
+
659
+ @app.post("/tasks/{task_id}/checklist/{item_index}/delete", response_class=HTMLResponse)
660
+ async def checklist_delete_route(
661
+ request: Request,
662
+ task_id: str,
663
+ item_index: int,
664
+ f_project: list[str] = Form(default=[]),
665
+ f_from: str = Form(""),
666
+ f_to: str = Form(""),
667
+ f_q: str = Form(""),
668
+ f_view: str = Form("board"),
669
+ f_milestone: str = Form(""),
670
+ f_show_closed: str = Form(""),
671
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
672
+ ) -> HTMLResponse:
673
+ task = load_task(workspace, task_id)
674
+ if 0 <= item_index < len(task.checklist):
675
+ task.checklist.pop(item_index)
676
+ save_task(workspace, task)
677
+ return templates.TemplateResponse(
678
+ request,
679
+ "partials/main.html",
680
+ context(
681
+ request,
682
+ task,
683
+ projects=f_project,
684
+ date_from=f_from,
685
+ date_to=f_to,
686
+ q=f_q,
687
+ view=f_view,
688
+ milestone=f_milestone,
689
+ show_closed=parse_toggle(f_show_closed),
690
+ stale_days=parse_stale_days(f_stale_days),
691
+ ),
692
+ )
693
+
694
+ @app.post("/tasks/{task_id}/raw", response_class=HTMLResponse)
695
+ async def save_raw_json(
696
+ request: Request,
697
+ task_id: str,
698
+ raw_json: str = Form(...),
699
+ f_project: list[str] = Form(default=[]),
700
+ f_from: str = Form(""),
701
+ f_to: str = Form(""),
702
+ f_q: str = Form(""),
703
+ f_view: str = Form("board"),
704
+ f_milestone: str = Form(""),
705
+ f_show_closed: str = Form(""),
706
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
707
+ ) -> HTMLResponse:
708
+ parsed = json.loads(raw_json)
709
+ task = Task.model_validate(parsed)
710
+ save_task(workspace, task)
711
+ register_project(workspace, task.project)
712
+ return templates.TemplateResponse(
713
+ request,
714
+ "partials/main.html",
715
+ context(
716
+ request,
717
+ task,
718
+ projects=f_project,
719
+ date_from=f_from,
720
+ date_to=f_to,
721
+ q=f_q,
722
+ view=f_view,
723
+ milestone=f_milestone,
724
+ show_closed=parse_toggle(f_show_closed),
725
+ stale_days=parse_stale_days(f_stale_days),
726
+ ),
727
+ )
728
+
729
+ @app.post("/tasks/{task_id}/note", response_class=HTMLResponse)
730
+ async def add_note_route(request: Request, task_id: str, body: str = Form("")) -> HTMLResponse:
731
+ task = add_task_activity_note(workspace, task_id, body)
732
+ return templates.TemplateResponse(request, "partials/task_panel.html", context(request, task))
733
+
734
+ @app.post("/tasks/{task_id}/attachment", response_class=HTMLResponse)
735
+ async def upload_attachment_route(
736
+ request: Request,
737
+ task_id: str,
738
+ attachment: UploadFile = File(...),
739
+ description: str = Form(""),
740
+ ) -> HTMLResponse:
741
+ task = add_task_activity_image(
742
+ workspace,
743
+ task_id,
744
+ attachment.filename or "attachment.bin",
745
+ await attachment.read(),
746
+ attachment.content_type,
747
+ description,
748
+ )
749
+ return templates.TemplateResponse(request, "partials/task_panel.html", context(request, task))
750
+
751
+ @app.get("/tasks/{task_id}/burndown.json")
752
+ def task_burndown_json(task_id: str) -> Response:
753
+ task = load_task(workspace, task_id)
754
+ points: list[dict[str, object]] = []
755
+ progress_events = [event for event in task.activity if event.event_type == "progress_update"]
756
+ if not progress_events:
757
+ points.append(
758
+ {
759
+ "x": task.extra.get("created_at", ""),
760
+ "y": 100 - task.percent_complete,
761
+ "label": f"Current: {task.percent_complete}%",
762
+ }
763
+ )
764
+ else:
765
+ for event in sorted(progress_events, key=lambda item: item.created_at):
766
+ points.append(
767
+ {
768
+ "x": event.created_at,
769
+ "y": 100 - (event.progress_after or 0),
770
+ "label": f"{event.progress_before}% → {event.progress_after}%",
771
+ }
772
+ )
773
+ return Response(
774
+ json.dumps({"task_id": task_id, "title": task.title, "points": points}),
775
+ media_type="application/json",
776
+ )
777
+
778
+ @app.post("/tasks/{task_id}/complete", response_class=HTMLResponse)
779
+ async def complete_task_route(
780
+ request: Request,
781
+ task_id: str,
782
+ f_project: list[str] = Form(default=[]),
783
+ f_from: str = Form(""),
784
+ f_to: str = Form(""),
785
+ f_q: str = Form(""),
786
+ f_view: str = Form("board"),
787
+ f_milestone: str = Form(""),
788
+ f_show_closed: str = Form(""),
789
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
790
+ ) -> HTMLResponse:
791
+ task = load_task(workspace, task_id)
792
+ if task.status == "done":
793
+ task.status = "working"
794
+ task.completed_date = None
795
+ else:
796
+ task.status = "done"
797
+ old_progress = task.percent_complete
798
+ task.percent_complete = 100
799
+ log_progress_change(workspace, task, old_progress, task.percent_complete)
800
+ if not task.completed_date:
801
+ task.completed_date = date.today().isoformat()
802
+ save_task(workspace, task)
803
+ return templates.TemplateResponse(
804
+ request,
805
+ "partials/main.html",
806
+ context(
807
+ request,
808
+ task,
809
+ projects=f_project,
810
+ date_from=f_from,
811
+ date_to=f_to,
812
+ q=f_q,
813
+ view=f_view,
814
+ milestone=f_milestone,
815
+ show_closed=parse_toggle(f_show_closed),
816
+ stale_days=parse_stale_days(f_stale_days),
817
+ ),
818
+ )
819
+
820
+ @app.post("/tasks/{task_id}/delete")
821
+ async def delete_task_route(
822
+ task_id: str,
823
+ f_project: list[str] = Form(default=[]),
824
+ f_from: str = Form(""),
825
+ f_to: str = Form(""),
826
+ f_q: str = Form(""),
827
+ f_view: str = Form("board"),
828
+ f_milestone: str = Form(""),
829
+ f_show_closed: str = Form(""),
830
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
831
+ ) -> RedirectResponse:
832
+ delete_task(workspace, task_id)
833
+ params: list[tuple[str, str]] = [("project", p) for p in f_project if p]
834
+ if f_from:
835
+ params.append(("date_from", f_from))
836
+ if f_to:
837
+ params.append(("date_to", f_to))
838
+ if f_q:
839
+ params.append(("q", f_q))
840
+ if f_milestone:
841
+ params.append(("milestone", f_milestone))
842
+ if parse_toggle(f_show_closed):
843
+ params.append(("show_closed", "1"))
844
+ if parse_stale_days(f_stale_days) != STALE_CLOSED_DAYS:
845
+ params.append(("stale_days", str(parse_stale_days(f_stale_days))))
846
+ params.append(("view", f_view))
847
+ return RedirectResponse("/?" + urllib.parse.urlencode(params), status_code=303)
848
+
849
+ @app.post("/projects", response_class=HTMLResponse)
850
+ def add_project_route(
851
+ request: Request,
852
+ name: str = Form(...),
853
+ description: str = Form(""),
854
+ color: str = Form("#2e6fd8"),
855
+ ) -> HTMLResponse:
856
+ upsert_project(workspace, name, description.strip(), color)
857
+ return templates.TemplateResponse(request, "partials/main.html", context(request, view="projects"))
858
+
859
+ # --- Milestones ---------------------------------------------------------
860
+
861
+ @app.post("/milestones/create", response_class=HTMLResponse)
862
+ def create_milestone_route(
863
+ request: Request,
864
+ title: str = Form("New milestone"),
865
+ f_show_closed: str = Form(""),
866
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
867
+ ) -> HTMLResponse:
868
+ milestone = create_milestone(workspace, title)
869
+ return templates.TemplateResponse(
870
+ request,
871
+ "partials/main.html",
872
+ context(
873
+ request,
874
+ view="board",
875
+ milestone=milestone.id,
876
+ show_closed=parse_toggle(f_show_closed),
877
+ stale_days=parse_stale_days(f_stale_days),
878
+ ),
879
+ )
880
+
881
+ @app.get("/milestones/{milestone_id}/panel", response_class=HTMLResponse)
882
+ def milestone_panel_route(
883
+ request: Request,
884
+ milestone_id: str,
885
+ project: list[str] = Query(default=[]),
886
+ date_from: str = "",
887
+ date_to: str = "",
888
+ q: str = "",
889
+ view: str = "board",
890
+ show_closed: str = "",
891
+ stale_days: str = "",
892
+ ) -> HTMLResponse:
893
+ return templates.TemplateResponse(
894
+ request,
895
+ "partials/milestone_panel.html",
896
+ context(
897
+ request,
898
+ projects=project,
899
+ date_from=date_from,
900
+ date_to=date_to,
901
+ q=q,
902
+ view=view,
903
+ milestone=milestone_id,
904
+ show_closed=parse_toggle(show_closed),
905
+ stale_days=parse_stale_days(stale_days),
906
+ ),
907
+ )
908
+
909
+ @app.get("/milestones/{milestone_id}/burndown.json")
910
+ def milestone_burndown_json(milestone_id: str) -> Response:
911
+ milestone = load_milestone(workspace, milestone_id)
912
+ all_tasks = load_all_tasks(workspace)
913
+ tasks_by_id = {task.id: task for task in all_tasks}
914
+ milestone_tasks = [tasks_by_id[task_id] for task_id in milestone.task_ids if task_id in tasks_by_id]
915
+
916
+ all_events: list[tuple[str, Task, object]] = []
917
+ for task in milestone_tasks:
918
+ for event in task.activity:
919
+ if event.event_type == "progress_update":
920
+ all_events.append((event.created_at, task, event))
921
+ all_events.sort(key=lambda item: item[0])
922
+
923
+ task_progress = {task.id: 0 for task in milestone_tasks}
924
+
925
+ points: list[dict[str, object]] = []
926
+ if not all_events:
927
+ if milestone_tasks:
928
+ avg_remaining = round(
929
+ sum(100 - task.percent_complete for task in milestone_tasks) / len(milestone_tasks)
930
+ )
931
+ points.append(
932
+ {
933
+ "x": milestone.start_date or "",
934
+ "y": avg_remaining,
935
+ "label": f"{len(milestone_tasks)} tasks, avg remaining: {avg_remaining}%",
936
+ }
937
+ )
938
+ else:
939
+ for created_at, task, event in all_events:
940
+ task_progress[task.id] = event.progress_after
941
+ avg_remaining = round(sum(100 - progress for progress in task_progress.values()) / len(task_progress))
942
+ points.append(
943
+ {
944
+ "x": created_at,
945
+ "y": avg_remaining,
946
+ "label": f"{task.title}: {event.progress_before}% → {event.progress_after}%",
947
+ }
948
+ )
949
+
950
+ return Response(
951
+ json.dumps({"milestone_id": milestone_id, "title": milestone.title, "points": points}),
952
+ media_type="application/json",
953
+ )
954
+
955
+ @app.post("/milestones/{milestone_id}/save", response_class=HTMLResponse)
956
+ def save_milestone_route(
957
+ request: Request,
958
+ milestone_id: str,
959
+ title: str = Form(...),
960
+ status: str = Form("active"),
961
+ color: str = Form("#3567e0"),
962
+ summary: str = Form(""),
963
+ description: str = Form(""),
964
+ projects: list[str] = Form(default=[]),
965
+ start_date: str = Form(""),
966
+ target_date: str = Form(""),
967
+ f_view: str = Form("board"),
968
+ f_show_closed: str = Form(""),
969
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
970
+ ) -> HTMLResponse:
971
+ milestone = load_milestone(workspace, milestone_id)
972
+ milestone.title = title
973
+ milestone.status = status if status in {"planned", "active", "done"} else "active"
974
+ milestone.color = (color or "").strip() or "#3567e0"
975
+ milestone.summary = summary
976
+ milestone.description = description
977
+ milestone.projects = [p.strip() for p in projects if p.strip()]
978
+ milestone.start_date = start_date or None
979
+ milestone.target_date = target_date or None
980
+ save_milestone(workspace, milestone)
981
+ return templates.TemplateResponse(
982
+ request,
983
+ "partials/main.html",
984
+ context(
985
+ request,
986
+ view=f_view,
987
+ milestone=milestone.id,
988
+ show_closed=parse_toggle(f_show_closed),
989
+ stale_days=parse_stale_days(f_stale_days),
990
+ ),
991
+ )
992
+
993
+ @app.post("/milestones/{milestone_id}/note", response_class=HTMLResponse)
994
+ def milestone_note_route(
995
+ request: Request,
996
+ milestone_id: str,
997
+ body: str = Form(""),
998
+ f_view: str = Form("board"),
999
+ f_show_closed: str = Form(""),
1000
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
1001
+ ) -> HTMLResponse:
1002
+ add_milestone_note(workspace, milestone_id, body)
1003
+ return templates.TemplateResponse(
1004
+ request,
1005
+ "partials/milestone_panel.html",
1006
+ context(
1007
+ request,
1008
+ view=f_view,
1009
+ milestone=milestone_id,
1010
+ show_closed=parse_toggle(f_show_closed),
1011
+ stale_days=parse_stale_days(f_stale_days),
1012
+ ),
1013
+ )
1014
+
1015
+ @app.post("/milestones/{milestone_id}/attachment", response_class=HTMLResponse)
1016
+ async def milestone_attachment_route(
1017
+ request: Request,
1018
+ milestone_id: str,
1019
+ attachment: UploadFile = File(...),
1020
+ description: str = Form(""),
1021
+ f_view: str = Form("board"),
1022
+ f_show_closed: str = Form(""),
1023
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
1024
+ ) -> HTMLResponse:
1025
+ add_milestone_attachment(
1026
+ workspace,
1027
+ milestone_id,
1028
+ attachment.filename or "attachment.bin",
1029
+ await attachment.read(),
1030
+ attachment.content_type,
1031
+ description,
1032
+ )
1033
+ return templates.TemplateResponse(
1034
+ request,
1035
+ "partials/milestone_panel.html",
1036
+ context(
1037
+ request,
1038
+ view=f_view,
1039
+ milestone=milestone_id,
1040
+ show_closed=parse_toggle(f_show_closed),
1041
+ stale_days=parse_stale_days(f_stale_days),
1042
+ ),
1043
+ )
1044
+
1045
+ @app.post("/milestones/{milestone_id}/tasks/new", response_class=HTMLResponse)
1046
+ def milestone_new_task_route(
1047
+ request: Request,
1048
+ milestone_id: str,
1049
+ title: str = Form("New task"),
1050
+ f_view: str = Form("board"),
1051
+ f_show_closed: str = Form(""),
1052
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
1053
+ ) -> HTMLResponse:
1054
+ task = create_task(workspace, title)
1055
+ add_task_to_milestone(workspace, milestone_id, task.id)
1056
+ return templates.TemplateResponse(
1057
+ request,
1058
+ "partials/main.html",
1059
+ context(
1060
+ request,
1061
+ task,
1062
+ view=f_view,
1063
+ milestone=milestone_id,
1064
+ show_closed=parse_toggle(f_show_closed),
1065
+ stale_days=parse_stale_days(f_stale_days),
1066
+ ),
1067
+ )
1068
+
1069
+ @app.post("/milestones/{milestone_id}/tasks/{task_id}/add", response_class=HTMLResponse)
1070
+ def milestone_add_task_route(
1071
+ request: Request,
1072
+ milestone_id: str,
1073
+ task_id: str,
1074
+ f_view: str = "board",
1075
+ f_show_closed: str = "",
1076
+ f_stale_days: str = "",
1077
+ ) -> HTMLResponse:
1078
+ add_task_to_milestone(workspace, milestone_id, task_id)
1079
+ return templates.TemplateResponse(
1080
+ request,
1081
+ "partials/main.html",
1082
+ context(
1083
+ request,
1084
+ view=f_view,
1085
+ milestone=milestone_id,
1086
+ show_closed=parse_toggle(f_show_closed),
1087
+ stale_days=parse_stale_days(f_stale_days),
1088
+ ),
1089
+ )
1090
+
1091
+ @app.post("/milestones/{milestone_id}/tasks/{task_id}/remove", response_class=HTMLResponse)
1092
+ def milestone_remove_task_route(
1093
+ request: Request,
1094
+ milestone_id: str,
1095
+ task_id: str,
1096
+ f_view: str = "board",
1097
+ f_show_closed: str = "",
1098
+ f_stale_days: str = "",
1099
+ ) -> HTMLResponse:
1100
+ remove_task_from_milestone(workspace, milestone_id, task_id)
1101
+ return templates.TemplateResponse(
1102
+ request,
1103
+ "partials/main.html",
1104
+ context(
1105
+ request,
1106
+ view=f_view,
1107
+ milestone=milestone_id,
1108
+ show_closed=parse_toggle(f_show_closed),
1109
+ stale_days=parse_stale_days(f_stale_days),
1110
+ ),
1111
+ )
1112
+
1113
+ @app.post("/milestones/{milestone_id}/delete")
1114
+ async def delete_milestone_route(milestone_id: str) -> RedirectResponse:
1115
+ delete_milestone(workspace, milestone_id)
1116
+ return RedirectResponse("/?view=milestones", status_code=303)
1117
+
1118
+ @app.post("/git/sync", response_class=HTMLResponse)
1119
+ def git_sync_route(
1120
+ request: Request,
1121
+ f_project: list[str] = Form(default=[]),
1122
+ f_from: str = Form(""),
1123
+ f_to: str = Form(""),
1124
+ f_q: str = Form(""),
1125
+ f_view: str = Form("board"),
1126
+ f_sort: str = Form("priority"),
1127
+ f_milestone: str = Form(""),
1128
+ f_show_closed: str = Form(""),
1129
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
1130
+ ) -> HTMLResponse:
1131
+ result = git_sync(workspace)
1132
+ return templates.TemplateResponse(
1133
+ request,
1134
+ "partials/main.html",
1135
+ context(
1136
+ request,
1137
+ projects=f_project,
1138
+ date_from=f_from,
1139
+ date_to=f_to,
1140
+ q=f_q,
1141
+ sort=f_sort,
1142
+ view=f_view,
1143
+ milestone=f_milestone,
1144
+ show_closed=parse_toggle(f_show_closed),
1145
+ stale_days=parse_stale_days(f_stale_days),
1146
+ git_message=result["message"],
1147
+ ),
1148
+ )
1149
+
1150
+ @app.post("/git/lfs/init", response_class=HTMLResponse)
1151
+ def git_lfs_init_route(
1152
+ request: Request,
1153
+ f_project: list[str] = Form(default=[]),
1154
+ f_from: str = Form(""),
1155
+ f_to: str = Form(""),
1156
+ f_q: str = Form(""),
1157
+ f_view: str = Form("board"),
1158
+ f_sort: str = Form("priority"),
1159
+ f_milestone: str = Form(""),
1160
+ f_show_closed: str = Form(""),
1161
+ f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
1162
+ f_calendar_month: str = Form(""),
1163
+ f_calendar_year: str = Form(""),
1164
+ ) -> HTMLResponse:
1165
+ result = git_lfs_init(workspace)
1166
+ return templates.TemplateResponse(
1167
+ request,
1168
+ "partials/main.html",
1169
+ context(
1170
+ request,
1171
+ projects=f_project,
1172
+ date_from=f_from,
1173
+ date_to=f_to,
1174
+ q=f_q,
1175
+ sort=f_sort,
1176
+ view=f_view,
1177
+ milestone=f_milestone,
1178
+ show_closed=parse_toggle(f_show_closed),
1179
+ stale_days=parse_stale_days(f_stale_days),
1180
+ calendar_month=parse_calendar_month(f_calendar_month),
1181
+ calendar_year=parse_calendar_year(f_calendar_year),
1182
+ git_message=result["message"],
1183
+ ),
1184
+ )
1185
+
1186
+ @app.get("/export/csv")
1187
+ def export_csv(
1188
+ project: list[str] = Query(default=[]),
1189
+ date_from: str = "",
1190
+ date_to: str = "",
1191
+ q: str = "",
1192
+ show_closed: str = "",
1193
+ stale_days: str = "",
1194
+ ) -> Response:
1195
+ tasks = filter_tasks(load_all_tasks(workspace), project, date_from, date_to, q)
1196
+ if not parse_toggle(show_closed):
1197
+ tasks, _ = hide_stale_closed_tasks(tasks, parse_stale_days(stale_days))
1198
+ buffer = io.StringIO()
1199
+ writer = csv.writer(buffer)
1200
+ writer.writerow(
1201
+ [
1202
+ "id",
1203
+ "title",
1204
+ "project",
1205
+ "status",
1206
+ "priority",
1207
+ "percent_complete",
1208
+ "start_date",
1209
+ "due_date",
1210
+ "completed_date",
1211
+ "tags",
1212
+ "summary",
1213
+ ]
1214
+ )
1215
+ for task in tasks:
1216
+ writer.writerow(
1217
+ [
1218
+ task.id,
1219
+ task.title,
1220
+ task.project,
1221
+ task.status,
1222
+ task.priority,
1223
+ task.percent_complete,
1224
+ task.start_date or "",
1225
+ task.due_date or "",
1226
+ task.completed_date or "",
1227
+ ", ".join(task.tags),
1228
+ task.summary,
1229
+ ]
1230
+ )
1231
+ return Response(
1232
+ buffer.getvalue(),
1233
+ media_type="text/csv",
1234
+ headers={"Content-Disposition": "attachment; filename=taskunity-export.csv"},
1235
+ )
1236
+
1237
+ @app.get("/export/json")
1238
+ def export_json(
1239
+ project: list[str] = Query(default=[]),
1240
+ date_from: str = "",
1241
+ date_to: str = "",
1242
+ q: str = "",
1243
+ show_closed: str = "",
1244
+ stale_days: str = "",
1245
+ ) -> Response:
1246
+ tasks = filter_tasks(load_all_tasks(workspace), project, date_from, date_to, q)
1247
+ if not parse_toggle(show_closed):
1248
+ tasks, _ = hide_stale_closed_tasks(tasks, parse_stale_days(stale_days))
1249
+ projects = load_all_projects(workspace)
1250
+ ordered_project_names = [p.name for p in available_projects(projects, tasks)]
1251
+ config = ui_config()
1252
+ data = tasks_to_jsonantt(
1253
+ tasks,
1254
+ title=config["export_title"],
1255
+ project_colors=project_colors(projects, tasks),
1256
+ project_order=ordered_project_names,
1257
+ )
1258
+ return Response(
1259
+ json.dumps(data, indent=2, ensure_ascii=False),
1260
+ media_type="application/json",
1261
+ headers={"Content-Disposition": "attachment; filename=taskunity-export.json"},
1262
+ )
1263
+
1264
+ @app.get("/healthz")
1265
+ def healthz() -> Response:
1266
+ return Response("ok", media_type="text/plain")
1267
+
1268
+ return app