Compare commits

...
15 Commits
Author SHA1 Message Date
skrueckenandCursor 846bb31dc9 Add stdio MCP server for token-scoped ticket access.
CI / test-and-build (push) Failing after 1m25s
CI / demo (push) Skipped
Expose tm mcp tools against the local daemon, document agent setup, and
ignore workspace .tm_token files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 16:56:11 +01:00
skrueckenandCursor 2dbf0918de Add local JSON daemon and route TUI/CLI through it.
A per-DataDir background process owns ticket JSON, history, and sync so
multiple TUI instances stay consistent; MCP token APIs are ready for phase 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 16:32:13 +01:00
skrueckenandCursor 98fcb57298 Align the TUI help legend into a fixed-width column grid.
CI / test-and-build (push) Failing after 1m31s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:17:44 +01:00
skrueckenandCursor 229f7a2fe1 Document TUI/web features, sync backends, and server env in the README.
CI / test-and-build (push) Failing after 1m22s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:14:15 +01:00
skrueckenandCursor 69c6747a31 Keep board tickets sized to their content instead of stretching with taller columns.
CI / test-and-build (push) Failing after 1m32s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:11:41 +01:00
skrueckenandCursor efeb4a74a8 Assign readable ticket keys from project code and sequence.
Replace UUID-looking sync labels in TUI and web with keys like TAS-1,
while keeping the remote UUID for task-master matching.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:09:14 +01:00
skrueckenandCursor 26a3723a06 Support ODS, XLS, and other spreadsheet formats in the attachment editor.
Detect editable attachments by extension and write back in the original book type instead of always forcing xlsx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:56:14 +01:00
skrueckenandCursor 6e6b13270a Add ticket file attachments with simple in-browser Excel editing.
Store uploads on disk under DataDir and expose them in the web ticket view so .xlsx files can be previewed and saved as cell values without YouTrack or TUI sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:53:17 +01:00
skrueckenandCursor 614cb94b46 Add visual and Markdown editors for ticket descriptions.
Replace the plain textarea with a dual-mode editor so ticket content can be written as WYSIWYG or Markdown while still storing Markdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:40:55 +01:00
skrueckenandCursor f97856b8a6 Allow dragging tickets between OPEN, WIP, and DONE on the project board.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:29:01 +01:00
skrueckenandCursor 34ab3b9c5b Add date and ticket filters plus entry list to the project time report.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:23:22 +01:00
skrueckenandCursor 2061df63d4 Restyle web buttons with borderless square tabs and accent gradients.
CI / test-and-build (push) Failing after 1m24s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:18:07 +01:00
skrueckenandCursor a594183310 Persist web navigation across reloads via hash routes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:09:17 +01:00
skrueckenandCursor b311a355fd Wire demo seed so TUI connects to Test and Website Relaunch.
Dev seed now issues write tokens and writes local taskmaster meta under TASK_MASTER_HOME, so make run-dev can sync without manual setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:05:10 +01:00
skrueckenandCursor 79b0821837 Allow editing time entry start and end in TUI and web.
Duration is derived from the range so timer sessions can be corrected after the fact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:59:40 +01:00
75 changed files with 6376 additions and 310 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"tm": {
"command": "${workspaceFolder}/bin/tm",
"args": ["mcp"]
}
}
}
+3
View File
@@ -1,5 +1,8 @@
/bin/
.dev-home/
.tm_token
**/.tm_token
*.exe
*.test
*.out
+30
View File
@@ -0,0 +1,30 @@
# Agent notes — task-master
## Tickets über MCP
Tickets lesen und bearbeiten nur über den lokalen MCP-Server von task-master — nicht über JSON-Dateien oder die TUI.
### Setup
1. Binary: `./bin/tm` (oder `tm` im PATH)
2. Projekt-MCP: [`.cursor/mcp.json`](.cursor/mcp.json) (`tm` → `args: ["mcp"]`)
3. Im Repo (oder darüber) `.tm_token` mit dem Projekt, für das die TUI ein Token erzeugt hat (`p` → Projekt wählen → `g`):
```
project=<projekt-id>
token=tm_mcp_…
```
`.tm_token` nicht committen.
### Verhalten
- Ohne gültiges `.tm_token` darf der MCP-Server den Daemon nicht nutzen.
- Mit Token sind alle Tools auf genau dieses eine TM-Projekt beschränkt.
- TUI/`tm sync` brauchen kein Token; die KI schon.
### Typische Tools
`list_projects`, `list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, `move_ticket`, `delete_ticket`, `list_time`, `add_time`, `undo`, `redo`, `sync_pull`, `sync_push`
Mehr Details: [README.md](README.md) (Abschnitt MCP).
+5 -2
View File
@@ -45,12 +45,15 @@ run-dev: build
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
# Web test environment: SQLite demo against tm_server only (no YouTrack).
# Seeds 50 demo projects (Test + samples); login admin/admin (alice/alice as member).
# Seeds 50 demo projects; wires TUI projects Test + Website Relaunch with write tokens.
# Login admin/admin (alice/alice as member). Then: make run-dev → Y pull.
run-server-dev: web-build build-server-go
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \
$(DEV_HOME)/.local/share/task-master/task-master.db-shm
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack \
"$(DEV_HOME)/.local/share/task-master/projects/Test" \
"$(DEV_HOME)/.local/share/task-master/projects/Website Relaunch"
TASK_MASTER_HOME=$(DEV_HOME) TM_ADMIN_USER=admin TM_ADMIN_PASSWORD=admin \
TM_SEED_DEMO=1 TASK_MASTER_SECRET=dev-secret \
./bin/$(BINARY_SERVER) -listen :8080
+74 -22
View File
@@ -1,6 +1,6 @@
# task-master
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea).
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea). Dazu der optionale Web-Server `tm_server` mit Svelte-UI und SQLite.
![Demo](demo.gif)
@@ -8,16 +8,31 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch
## Features
- Projekte & Tickets lokal als JSON (kein Server) — Binary `tm`
- Web-Server `tm_server` mit eingebettetem Svelte-UI, SQLite, Auth und Rechte pro Projekt
**TUI (`tm`)**
- Projekte & Tickets lokal als JSON hinter einem Hintergrund-Daemon (Unix-Socket)
- Mehrere TUI-Instanzen teilen denselben State; `tm stop` beendet den Daemon
- Board-Filter: All / Backlog / In Progress / Done
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
- Timer, manuelle Zeitbuchungen, Zeiteinträge verwalten
- Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren
- Report mit Group-by (Ticket / Tag)
- Undo / Redo über eine Command-History (TUI)
- Vim-Style Keybindings (TUI)
- Undo / Redo über eine Command-History (im Daemon)
- Vim-Style Keybindings
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
- Server: Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
- Sync gegen `tm_server` (Backend `taskmaster`, Projekt-Tokens)
- MCP-Token pro Projekt erzeugen (`g` in der Projektliste) für späteren KI-Zugriff
**Web (`tm_server`)**
- Eingebettetes Svelte-UI, SQLite, Auth und Rechte pro Projekt
- Projektgruppen, Sidebar-Navigation, Hash-Routes
- Drag & Drop auf dem Board (OPEN / WIP / DONE)
- Lesbare Ticket-Keys aus Projektcode + Sequenz (z. B. `WEB-12`)
- Visual- und Markdown-Editor für Ticket-Beschreibungen
- Datei-Anhänge; Tabellen (XLSX/ODS/…) im Browser editierbar
- Report mit Datums- und Ticket-Filtern sowie Eintragsliste
- Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
- Members, Settings (Tokens), Users (Admin)
## Install
@@ -30,24 +45,51 @@ make install # → $(go env GOPATH)/bin/tm
# oder nur bauen:
make build # → ./bin/tm
make build-server # Frontend + → ./bin/tm_server
make build-all # beides
```
## Nutzung
```bash
tm # TUI starten
tm sync pull # Issues von YouTrack holen
tm sync push # lokale Änderungen nach YouTrack
tm # TUI starten (startet Daemon bei Bedarf)
tm sync pull # Issues von YouTrack / tm_server holen
tm sync push # lokale Änderungen pushen
tm sync pull -p inbox # anderes Projekt
tm status # Daemon-Status + Socket-Pfad
tm stop # Hintergrund-Daemon beenden
tm daemon # Daemon im Vordergrund (Debug)
tm version # Build-Version (Git-Hash)
tm help
```
### Lokaler JSON-Daemon
`tm` speichert weiterhin JSON unter dem DataDir, schreibt aber nur noch über einen lokalen Daemon-Prozess (eine Instanz pro DataDir). Socket/PID liegen unter `$XDG_RUNTIME_DIR/task-master-<hash>/` (Fallback: Temp-Dir). Die TUI und `tm sync` verbinden sich als vertrauenswürdige Clients (`X-TM-Client: tui|cli`).
### MCP (`tm mcp`)
1. In der TUI-Projektliste `g` → Token erzeugen (Klartext einmal in der Statuszeile); `G` widerruft.
2. Im Repo-Root (oder darüber) `.tm_token` anlegen — **nicht committen**:
```
project=inbox
token=tm_mcp_…
```
3. Cursor / MCP-Client:
```json
{ "command": "tm", "args": ["mcp"] }
```
Ohne gültiges `.tm_token` macht der MCP-Server keine Daemon-Aufrufe. Mit Token sind Lesen und Schreiben nur für dieses eine TM-Projekt erlaubt.
### Web-Server (`tm_server`)
```bash
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekt „Test“ + Samples
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekte + verdrahtet TUI
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
make run-dev # TUI: Projekte „Test“ + „Website Relaunch“ schon mit Token connected
# Produktion / manuell:
export TM_ADMIN_USER=admin
@@ -59,10 +101,12 @@ export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
./bin/tm_server import-json -owner admin
```
UI: http://localhost:8080 — Dashboard, Board, Report, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
UI: http://localhost:8080 — Dashboard, Board, Report, Attachments, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`.
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
Beim Seed (`TM_SEED_DEMO=1`) werden Demo-Projekte angelegt und für **Test** sowie **Website Relaunch** Write-Tokens unter `.dev-home` als lokale TUI-Projekte (`backend=taskmaster`) hinterlegt — danach `make run-dev` und Sync (`Y` → Pull). Login: `admin`/`admin` (Member: `alice`/`alice`).
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`. Frontend neu bauen ohne Go-Rebuild: `make web-build`.
Sandbox ohne echte Config/Daten:
@@ -75,9 +119,15 @@ TASK_MASTER_HOME=/tmp/tm-sandbox tm
Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~/.config/task-master`.
| Variable | Wirkung |
|----------------------|----------------------------------------------|
|------------------------|---------------------------------------------------|
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
| `TASK_MASTER_DATA_DIR` | Nur Datenverzeichnis überschreiben |
| `TASK_MASTER_SECRET` | Verschlüsselt Remote-Tokens in SQLite |
| `TM_ADMIN_USER` / `TM_ADMIN_PASSWORD` | Bootstrap des ersten Admins |
| `TM_SEED_DEMO=1` | Demo-Projekte + TUI-Verdrahtung (Test / Website Relaunch) |
| `TM_SEED_BASE_URL` | Basis-URL in den geseedeten TUI-`meta.json` |
Server-Flags: `-listen`, `-db`, `-secure` (siehe `tm_server help`).
## Keybindings (Auszug)
@@ -85,13 +135,12 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|---------|------------------------|
| `j`/`k` | Navigieren |
| `n` | Neues Ticket/Projekt |
| `e` | Ticket im Editor |
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack / Sync-Backend) |
| `s` | Status ändern |
| `l` | Board-Filter |
| `/` | Suche |
| `p`/`P` | Projekt / Ticket verschieben |
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack) |
| `Y` | YouTrack-Menü: Pull / Push |
| `Y` | Sync-Menü: Pull / Push |
| `t`/`T` | Timer / Zeit buchen |
| `m` | Zeiteinträge |
| `r` | Report |
@@ -101,15 +150,15 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
Checklisten im Preview: `J`/`K` + `Space`.
### YouTrack
### YouTrack / Sync
1. `p` → Projekt wählen → `e`
2. Remote-URL, Permanent Token und Project-Key setzen (gespeichert in `projects/<id>/meta.json`, Mode `0600`)
2. Backend wählen: YouTrack (Remote-URL, Permanent Token, Project-Key) oder task-master (Server-URL + Projekt-Token)
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
4. `Y` → **Pull** (Issues + Work Items) oder **Push** (Tickets + Zeiteinträge)
4. `Y` → **Pull** oder **Push**
CLI: `tm sync pull` / `tm sync push`
Zeit: lokale Einträge ohne `externalId` werden als YouTrack-Work-Items gepusht; remote Work Items landen lokal beim Pull.
Zeit: lokale Einträge ohne `externalId` werden remote als Work-Items gepusht; remote Work Items landen lokal beim Pull.
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
@@ -118,9 +167,12 @@ Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fi
```bash
make test
make build
make build-server # braucht Node (npm ci + Vite-Build)
make version
```
CI (Gitea): Tests, `tm` + `tm_server`-Build, Demo-GIF als Artifact.
## Demo aufnehmen
Die Walkthrough-Aufnahme steckt in [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)):
+159 -62
View File
@@ -8,14 +8,13 @@ import (
tea "github.com/charmbracelet/bubbletea"
"task/internal/command"
"task/internal/config"
"task/internal/project"
"task/internal/ticket"
"task/internal/daemon"
"task/internal/daemon/api"
"task/internal/daemon/client"
mcpserver "task/internal/mcp"
"task/internal/ui"
"task/internal/version"
"task/internal/tmsync"
"task/internal/ytsync"
)
func main() {
@@ -33,6 +32,30 @@ func main() {
os.Exit(1)
}
return
case "daemon":
if err := runDaemon(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
case "stop":
if err := runStop(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
case "status":
if err := runStatus(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
case "mcp":
if err := runMCP(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printHelp()
@@ -46,35 +69,104 @@ func main() {
}
}
func run() error {
func loadConfig() (*config.Config, error) {
cfg, err := config.LoadOrCreate()
if err != nil {
return fmt.Errorf("config: %w", err)
return nil, fmt.Errorf("config: %w", err)
}
abs, err := daemon.AbsoluteDataDir(cfg.DataDir)
if err != nil {
return nil, err
}
cfg.DataDir = abs
return cfg, nil
}
store := ticket.NewJSONStore(cfg.DataDir)
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
if err := projects.Ensure(cfg.ActiveProject); err != nil {
func ensureClient(kind string) (*config.Config, *client.Client, error) {
cfg, err := loadConfig()
if err != nil {
return nil, nil, err
}
paths, err := daemon.Ensure(cfg)
if err != nil {
return nil, nil, fmt.Errorf("daemon: %w", err)
}
cli := client.Dial(client.Options{Socket: paths.Socket, Kind: kind})
return cfg, cli, nil
}
func run() error {
cfg, cli, err := ensureClient(api.ClientTUI)
if err != nil {
return err
}
if err := cli.ProjectStore().Ensure(cfg.ActiveProject); err != nil {
return fmt.Errorf("ensure project: %w", err)
}
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
if err != nil {
return fmt.Errorf("history: %w", err)
}
model := ui.New(cfg, store, projects, timeStore, history)
model := ui.New(cfg, cli.TicketStore(), cli.ProjectStore(), cli.TimeStore(), cli.Executor(), cli)
prog := tea.NewProgram(model, tea.WithAltScreen())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_ = cli.SubscribeEvents(ctx, func(ev api.Event) {
prog.Send(ui.DaemonChangedMsg{ProjectID: ev.ProjectID})
})
}()
if _, err := prog.Run(); err != nil {
return fmt.Errorf("tui: %w", err)
}
return nil
}
func runSync(args []string) error {
cfg, err := config.LoadOrCreate()
func runDaemon() error {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("config: %w", err)
return err
}
return daemon.Run(cfg)
}
func runStop() error {
cfg, err := loadConfig()
if err != nil {
return err
}
if err := daemon.Stop(cfg); err != nil {
return err
}
fmt.Println("daemon stopped")
return nil
}
func runStatus() error {
cfg, err := loadConfig()
if err != nil {
return err
}
st, err := daemon.StatusOf(cfg)
if err != nil {
return err
}
state := "stopped"
if st.Running {
state = "running"
}
fmt.Printf("status: %s\n", state)
fmt.Printf("socket: %s\n", st.Socket)
fmt.Printf("dataDir: %s\n", st.DataDir)
if st.PID > 0 {
fmt.Printf("pid: %d\n", st.PID)
}
return nil
}
func runSync(args []string) error {
cfg, cli, err := ensureClient(api.ClientCLI)
if err != nil {
return err
}
projectID := cfg.ActiveProject
action := ""
@@ -105,56 +197,61 @@ func runSync(args []string) error {
return fmt.Errorf("project id required")
}
store := ticket.NewJSONStore(cfg.DataDir)
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
if err := projects.Ensure(projectID); err != nil {
return err
}
meta, err := projects.LoadMeta(projectID)
if action == "push" {
out, err := cli.SyncPush(projectID)
if err != nil {
return err
}
var res ytsync.Result
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
return fmt.Errorf("taskmaster not configured for %s", projectID)
}
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
if action == "push" {
res, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
} else {
res, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
}
default:
if action == "push" {
res, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
} else {
res, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
}
}
if err != nil {
return err
}
if action == "push" {
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
projectID, res.Created, res.Updated, res.TimePushed)
} else {
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
projectID, res.Created, res.Updated, res.TimePulled, res.Total)
}
projectID, out.Created, out.Updated, out.TimePushed)
return nil
}
out, err := cli.SyncPull(projectID)
if err != nil {
return err
}
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
projectID, out.Created, out.Updated, out.TimePulled, out.Total)
return nil
}
func runMCP() error {
cwd, err := os.Getwd()
if err != nil {
return err
}
tf, err := mcpserver.FindTokenFile(cwd)
if err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
paths, err := daemon.Ensure(cfg)
if err != nil {
return fmt.Errorf("daemon: %w", err)
}
cli := client.Dial(client.Options{
Socket: paths.Socket,
Kind: api.ClientMCP,
Token: tf.Token,
Project: tf.Project,
})
return mcpserver.ServeStdio(cli, tf)
}
func printHelp() {
fmt.Printf(`%s — offline-first terminal task manager
Usage:
%s Start the TUI
%s sync pull [-p ID] Pull from configured backend (YouTrack or task-master)
%s sync push [-p ID] Push to configured backend
%s Start the TUI (starts local daemon if needed)
%s sync pull [-p ID] Pull via daemon from configured backend
%s sync push [-p ID] Push via daemon to configured backend
%s daemon Run the JSON daemon in the foreground
%s stop Stop the background daemon
%s status Show daemon status and socket path
%s mcp MCP stdio server (requires .tm_token)
%s version Print build version
%s help Show this help
@@ -162,9 +259,9 @@ Env:
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
TASK_MASTER_DATA_DIR Override data directory only
Sync backends:
In the TUI: p → project → e, choose youtrack or taskmaster, set URL/token/id.
Then Y → Pull or Push (manual only; edits stay local until you push).
The TUI talks to a per-DataDir background daemon over a Unix socket so multiple
instances stay consistent. Create an MCP token from the project list (key g);
place project= and token= in a repo .tm_token file, then run: tm mcp
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
}
+8 -3
View File
@@ -88,7 +88,7 @@ func bootstrapAdmin(db *sqlite.DB) error {
}
func runServer(listen, dbPath string, secure bool) error {
db, _, err := openDB(dbPath)
db, cfg, err := openDB(dbPath)
if err != nil {
return err
}
@@ -97,10 +97,14 @@ func runServer(listen, dbPath string, secure bool) error {
return err
}
srv := server.New(db)
srv.DataDir = cfg.DataDir
srv.Secure = secure
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
if err := srv.SeedDemo(); err != nil {
if err := srv.SeedDemo(server.SeedDemoOptions{
ClientDataDir: cfg.DataDir,
BaseURL: server.SeedBaseURL(listen),
}); err != nil {
return fmt.Errorf("seed: %w", err)
}
projects, _ := srv.Projects.List()
@@ -183,6 +187,7 @@ Env:
TASK_MASTER_HOME / TASK_MASTER_DATA_DIR same as tm
TASK_MASTER_SECRET encrypts remote tokens
TM_ADMIN_USER / TM_ADMIN_PASSWORD bootstrap first admin
TM_SEED_DEMO=1 seed local Test projects when DB empty (no YouTrack)
TM_SEED_DEMO=1 seed demo projects + wire TUI (Test, Website Relaunch)
TM_SEED_BASE_URL override base URL written into TUI meta.json
`)
}
+3
View File
@@ -8,6 +8,7 @@ require (
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/google/uuid v1.6.0
github.com/mark3labs/mcp-go v0.32.0
github.com/muesli/termenv v0.16.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0
@@ -42,7 +43,9 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.60.0 // indirect
+24
View File
@@ -38,12 +38,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -54,10 +60,16 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -77,16 +89,26 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
@@ -110,6 +132,8 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
+115
View File
@@ -0,0 +1,115 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/command"
"task/internal/daemon/api"
"task/internal/project"
"task/internal/ticket"
)
func setup(t *testing.T) (*api.Server, string) {
t.Helper()
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
timeStore := ticket.NewJSONTimeStore(dir)
projects := project.NewDirStore(dir)
_ = projects.Ensure("inbox")
hist, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, timeStore)
if err != nil {
t.Fatal(err)
}
return api.New(store, timeStore, projects, hist), dir
}
func TestHealthAndCRUD(t *testing.T) {
srv, _ := setup(t)
h := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("health: %d", rec.Code)
}
body, _ := json.Marshal(ticket.Ticket{Title: "Hi", ProjectID: "inbox", Status: ticket.StatusTodo})
req = httptest.NewRequest(http.MethodPost, "/v1/tickets", bytes.NewReader(body))
req.Header.Set(api.HeaderClient, api.ClientTUI)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created ticket.Ticket
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("missing id")
}
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientTUI)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("list: %d", rec.Code)
}
}
func TestMCPAuthRequired(t *testing.T) {
srv, _ := setup(t)
h := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientMCP)
req.Header.Set("X-TM-Project", "inbox")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
t.Fatalf("expected auth failure, got %d %s", rec.Code, rec.Body.String())
}
// Create token via TUI client
req = httptest.NewRequest(http.MethodPost, "/v1/projects/inbox/mcp-token", bytes.NewReader([]byte("{}")))
req.Header.Set(api.HeaderClient, api.ClientTUI)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("token create: %d %s", rec.Code, rec.Body.String())
}
var tok struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
if tok.Token == "" {
t.Fatal("empty token")
}
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientMCP)
req.Header.Set("Authorization", "Bearer "+tok.Token)
req.Header.Set("X-TM-Project", "inbox")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("mcp list: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientMCP)
req.Header.Set("Authorization", "Bearer wrong")
req.Header.Set("X-TM-Project", "inbox")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 200 {
t.Fatal("wrong token should fail")
}
}
+117
View File
@@ -0,0 +1,117 @@
package api
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
)
type ctxKey int
const (
ctxClientKind ctxKey = iota
ctxMCPProject
)
const (
HeaderClient = "X-TM-Client"
ClientTUI = "tui"
ClientCLI = "cli"
ClientMCP = "mcp"
)
// ClientKind returns the authenticated client kind from context.
func ClientKind(ctx context.Context) string {
v, _ := ctx.Value(ctxClientKind).(string)
return v
}
// MCPProjectID returns the project bound to a validated MCP token, if any.
func MCPProjectID(ctx context.Context) string {
v, _ := ctx.Value(ctxMCPProject).(string)
return v
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/health" {
next.ServeHTTP(w, r)
return
}
kind := strings.ToLower(strings.TrimSpace(r.Header.Get(HeaderClient)))
if kind == "" {
kind = ClientTUI
}
ctx := context.WithValue(r.Context(), ctxClientKind, kind)
if kind == ClientMCP {
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) {
writeErr(w, http.StatusUnauthorized, "mcp requires Authorization: Bearer <token>")
return
}
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
if token == "" {
writeErr(w, http.StatusUnauthorized, "empty bearer token")
return
}
projectID := strings.TrimSpace(r.Header.Get("X-TM-Project"))
if projectID == "" {
projectID = extractProjectID(r)
}
if projectID == "" {
writeErr(w, http.StatusUnauthorized, "mcp requires project id (X-TM-Project or path/body)")
return
}
meta, err := s.projects.LoadMeta(projectID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if meta.McpAuth == nil || meta.McpAuth.TokenHash == "" {
writeErr(w, http.StatusForbidden, "no mcp token configured for project")
return
}
got := hashToken(token)
if subtle.ConstantTimeCompare([]byte(got), []byte(meta.McpAuth.TokenHash)) != 1 {
writeErr(w, http.StatusForbidden, "invalid mcp token")
return
}
ctx = context.WithValue(ctx, ctxMCPProject, projectID)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// extractProjectID best-effort for auth before body is consumed.
// Path patterns: /v1/projects/{id}/...
func extractProjectID(r *http.Request) string {
if q := strings.TrimSpace(r.URL.Query().Get("projectId")); q != "" {
return q
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
// v1 projects {id} ...
if len(parts) >= 3 && parts[0] == "v1" && parts[1] == "projects" {
return parts[2]
}
return ""
}
// requireMCPProject ensures MCP calls stay on the token's project.
func requireMCPProject(r *http.Request, projectID string) bool {
if ClientKind(r.Context()) != ClientMCP {
return true
}
bound := MCPProjectID(r.Context())
return bound != "" && bound == projectID
}
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net/http"
)
func (s *Server) handleHistoryStatus(w http.ResponseWriter, r *http.Request) {
_ = r
writeJSON(w, http.StatusOK, map[string]any{
"canUndo": s.history.CanUndo(),
"canRedo": s.history.CanRedo(),
"lastUndoName": s.history.LastUndoName(),
"lastRedoName": s.history.LastRedoName(),
})
}
func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
_ = r
name := s.history.LastUndoName()
if err := s.history.Undo(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify("")
writeJSON(w, http.StatusOK, map[string]string{"name": name})
}
func (s *Server) handleRedo(w http.ResponseWriter, r *http.Request) {
_ = r
name := s.history.LastRedoName()
if err := s.history.Redo(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify("")
writeJSON(w, http.StatusOK, map[string]string{"name": name})
}
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"sync"
)
// Event is a change notification pushed to SSE clients.
type Event struct {
Type string `json:"type"`
ProjectID string `json:"projectId,omitempty"`
}
type hub struct {
mu sync.Mutex
clients map[chan Event]struct{}
}
func newHub() *hub {
return &hub{clients: map[chan Event]struct{}{}}
}
func (h *hub) subscribe() chan Event {
ch := make(chan Event, 16)
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *hub) unsubscribe(ch chan Event) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *hub) publish(ev Event) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
select {
case ch <- ev:
default:
}
}
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := s.hub.subscribe()
defer s.hub.unsubscribe(ch)
fmt.Fprintf(w, ": ok\n\n")
flusher.Flush()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case ev, ok := <-ch:
if !ok {
return
}
data, _ := json.Marshal(ev)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package api
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(r *http.Request, dst any) error {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
+183
View File
@@ -0,0 +1,183 @@
package api
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"task/internal/project"
)
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
list, err := s.projects.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if ClientKind(r.Context()) == ClientMCP {
pid := MCPProjectID(r.Context())
filtered := make([]project.Project, 0, 1)
for _, p := range list {
if p.ID == pid {
filtered = append(filtered, p)
}
}
list = filtered
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleEnsureProject(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string `json:"id"`
}
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if !requireMCPProject(r, body.ID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
if err := s.projects.Ensure(body.ID); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(body.ID)
writeJSON(w, http.StatusOK, map[string]string{"id": body.ID})
}
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !requireMCPProject(r, id) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
if err := s.timeStore.DeleteByProject(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if t, err := s.timeStore.LoadActiveTimer(); err == nil && t != nil && t.ProjectID == id {
_ = s.timeStore.SaveActiveTimer(nil)
}
if err := s.projects.Delete(id); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(id)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !requireMCPProject(r, id) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, meta)
}
func (s *Server) handleSaveMeta(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !requireMCPProject(r, id) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var meta project.Meta
if err := readJSON(r, &meta); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
// Preserve existing mcpAuth if client omitted it (TUI sync edit).
prev, _ := s.projects.LoadMeta(id)
if meta.McpAuth == nil && prev.McpAuth != nil {
meta.McpAuth = prev.McpAuth
}
if err := s.projects.SaveMeta(id, meta); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
s.notify(id)
writeJSON(w, http.StatusOK, meta)
}
func (s *Server) handleMCPTokenStatus(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if ClientKind(r.Context()) == ClientMCP {
writeErr(w, http.StatusForbidden, "mcp cannot inspect token status")
return
}
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
configured := meta.McpAuth != nil && meta.McpAuth.TokenHash != ""
created := ""
if meta.McpAuth != nil {
created = meta.McpAuth.CreatedAt
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": configured,
"createdAt": created,
})
}
func (s *Server) handleMCPTokenCreate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if ClientKind(r.Context()) == ClientMCP {
writeErr(w, http.StatusForbidden, "mcp cannot create tokens")
return
}
if err := s.projects.Ensure(id); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
raw := make([]byte, 24)
if _, err := rand.Read(raw); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
token := "tm_mcp_" + hex.EncodeToString(raw)
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
created := time.Now().UTC().Format(time.RFC3339)
meta.McpAuth = &project.McpAuth{TokenHash: hashToken(token), CreatedAt: created}
if err := s.projects.SaveMeta(id, meta); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": token,
"project": id,
"createdAt": created,
})
}
func (s *Server) handleMCPTokenRevoke(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if ClientKind(r.Context()) == ClientMCP {
writeErr(w, http.StatusForbidden, "mcp cannot revoke tokens")
return
}
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
meta.McpAuth = nil
if err := s.projects.SaveMeta(id, meta); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net/http"
"task/internal/command"
"task/internal/project"
"task/internal/ticket"
)
// Server is the daemon HTTP API.
type Server struct {
store ticket.Store
timeStore ticket.TimeStore
projects project.Store
history command.Executor
hub *hub
mux *http.ServeMux
}
// New constructs the API server.
func New(store ticket.Store, timeStore ticket.TimeStore, projects project.Store, history command.Executor) *Server {
s := &Server{
store: store,
timeStore: timeStore,
projects: projects,
history: history,
hub: newHub(),
mux: http.NewServeMux(),
}
s.routes()
return s
}
// Handler returns the root HTTP handler with auth middleware.
func (s *Server) Handler() http.Handler {
return s.withAuth(s.mux)
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/projects", s.handleListProjects)
s.mux.HandleFunc("POST /v1/projects", s.handleEnsureProject)
s.mux.HandleFunc("DELETE /v1/projects/{id}", s.handleDeleteProject)
s.mux.HandleFunc("GET /v1/projects/{id}/meta", s.handleGetMeta)
s.mux.HandleFunc("PUT /v1/projects/{id}/meta", s.handleSaveMeta)
s.mux.HandleFunc("GET /v1/projects/{id}/mcp-token", s.handleMCPTokenStatus)
s.mux.HandleFunc("POST /v1/projects/{id}/mcp-token", s.handleMCPTokenCreate)
s.mux.HandleFunc("DELETE /v1/projects/{id}/mcp-token", s.handleMCPTokenRevoke)
s.mux.HandleFunc("GET /v1/tickets", s.handleListTickets)
s.mux.HandleFunc("GET /v1/tickets/{id}", s.handleGetTicket)
s.mux.HandleFunc("POST /v1/tickets", s.handleCreateTicket)
s.mux.HandleFunc("PATCH /v1/tickets/{id}", s.handleUpdateTicket)
s.mux.HandleFunc("DELETE /v1/tickets/{id}", s.handleDeleteTicket)
s.mux.HandleFunc("POST /v1/tickets/{id}/move", s.handleMoveTicket)
s.mux.HandleFunc("GET /v1/time", s.handleListTime)
s.mux.HandleFunc("GET /v1/time/by-ticket/{ticketId}", s.handleListTimeByTicket)
s.mux.HandleFunc("GET /v1/time/sum/{ticketId}", s.handleSumTime)
s.mux.HandleFunc("POST /v1/time", s.handleAddTime)
s.mux.HandleFunc("PATCH /v1/time/{id}", s.handleUpdateTime)
s.mux.HandleFunc("DELETE /v1/time/{id}", s.handleRemoveTime)
s.mux.HandleFunc("GET /v1/timer", s.handleGetTimer)
s.mux.HandleFunc("PUT /v1/timer", s.handleSetTimer)
s.mux.HandleFunc("GET /v1/history", s.handleHistoryStatus)
s.mux.HandleFunc("POST /v1/undo", s.handleUndo)
s.mux.HandleFunc("POST /v1/redo", s.handleRedo)
s.mux.HandleFunc("POST /v1/sync/pull", s.handleSyncPull)
s.mux.HandleFunc("POST /v1/sync/push", s.handleSyncPush)
s.mux.HandleFunc("GET /v1/events", s.handleEvents)
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) notify(projectID string) {
s.hub.publish(Event{Type: "changed", ProjectID: projectID})
}
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"context"
"net/http"
"task/internal/project"
"task/internal/tmsync"
"task/internal/ytsync"
)
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, false)
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, true)
}
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, push bool) {
var body struct {
ProjectID string `json:"projectId"`
}
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if body.ProjectID == "" {
writeErr(w, http.StatusBadRequest, "projectId required")
return
}
if !requireMCPProject(r, body.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
meta, err := s.projects.LoadMeta(body.ProjectID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var res ytsync.Result
ctx := context.Background()
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
writeErr(w, http.StatusBadRequest, "taskmaster not configured")
return
}
syncer := &tmsync.Syncer{Store: s.store, TimeStore: s.timeStore}
if push {
res, err = syncer.Push(ctx, body.ProjectID, *meta.TaskMaster)
} else {
res, err = syncer.Pull(ctx, body.ProjectID, *meta.TaskMaster)
}
default:
if push {
res, err = ytsync.PushProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
} else {
res, err = ytsync.PullProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
}
}
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(body.ProjectID)
writeJSON(w, http.StatusOK, res)
}
+147
View File
@@ -0,0 +1,147 @@
package api
import (
"net/http"
"task/internal/command"
"task/internal/ticket"
)
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
projectID := r.URL.Query().Get("projectId")
if projectID == "" {
writeErr(w, http.StatusBadRequest, "projectId query required")
return
}
if !requireMCPProject(r, projectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
list, err := s.store.List(projectID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
var body ticket.Ticket
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if !requireMCPProject(r, body.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.CreateTicket{Store: s.store, Ticket: body}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(cmd.Ticket.ProjectID)
writeJSON(w, http.StatusCreated, cmd.Ticket)
}
func (s *Server) handleUpdateTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
before, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var after ticket.Ticket
if err := readJSON(r, &after); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
after.ID = id
if after.ProjectID == "" {
after.ProjectID = before.ProjectID
}
if !requireMCPProject(r, after.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.UpdateTicket{Store: s.store, Before: before, After: after}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(after.ProjectID)
writeJSON(w, http.StatusOK, after)
}
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
before, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.DeleteTicket{Store: s.store, Snapshot: before}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(before.ProjectID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
before, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var body struct {
ToProjectID string `json:"toProjectId"`
}
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
// MCP may only move within its project (no cross-project).
if ClientKind(r.Context()) == ClientMCP && body.ToProjectID != MCPProjectID(r.Context()) {
writeErr(w, http.StatusForbidden, "mcp cannot move tickets to another project")
return
}
after := before
after.ProjectID = body.ToProjectID
cmd := &command.MoveTicket{Store: s.store, TimeStore: s.timeStore, Before: before, After: after}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(before.ProjectID)
s.notify(body.ToProjectID)
writeJSON(w, http.StatusOK, cmd.After)
}
+214
View File
@@ -0,0 +1,214 @@
package api
import (
"encoding/json"
"net/http"
"time"
"task/internal/command"
"task/internal/ticket"
)
func (s *Server) handleListTime(w http.ResponseWriter, r *http.Request) {
list, err := s.timeStore.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if ClientKind(r.Context()) == ClientMCP {
pid := MCPProjectID(r.Context())
filtered := make([]ticket.TimeEntry, 0)
for _, e := range list {
if e.ProjectID == pid {
filtered = append(filtered, e)
}
}
list = filtered
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleListTimeByTicket(w http.ResponseWriter, r *http.Request) {
ticketID := r.PathValue("ticketId")
t, err := s.store.Get(ticketID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
list, err := s.timeStore.ListByTicket(ticketID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleSumTime(w http.ResponseWriter, r *http.Request) {
ticketID := r.PathValue("ticketId")
t, err := s.store.Get(ticketID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
d, err := s.timeStore.SumByTicket(ticketID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"duration": int64(d)})
}
func (s *Server) handleAddTime(w http.ResponseWriter, r *http.Request) {
var body ticket.TimeEntry
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if !requireMCPProject(r, body.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.AddTimeEntry{Store: s.timeStore, Entry: body}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(cmd.Entry.ProjectID)
writeJSON(w, http.StatusCreated, cmd.Entry)
}
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
entries, err := s.timeStore.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var before *ticket.TimeEntry
for i := range entries {
if entries[i].ID == id {
before = &entries[i]
break
}
}
if before == nil {
writeErr(w, http.StatusNotFound, "time entry not found")
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var after ticket.TimeEntry
if err := readJSON(r, &after); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
after.ID = id
if after.ProjectID == "" {
after.ProjectID = before.ProjectID
}
cmd := &command.UpdateTimeEntry{Store: s.timeStore, Before: *before, After: after}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(after.ProjectID)
writeJSON(w, http.StatusOK, after)
}
func (s *Server) handleRemoveTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
entries, err := s.timeStore.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var snap *ticket.TimeEntry
for i := range entries {
if entries[i].ID == id {
snap = &entries[i]
break
}
}
if snap == nil {
writeErr(w, http.StatusNotFound, "time entry not found")
return
}
if !requireMCPProject(r, snap.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.RemoveTimeEntry{Store: s.timeStore, Snapshot: *snap}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(snap.ProjectID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
t, err := s.timeStore.LoadActiveTimer()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if t != nil && ClientKind(r.Context()) == ClientMCP && t.ProjectID != MCPProjectID(r.Context()) {
writeJSON(w, http.StatusOK, nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleSetTimer(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var raw map[string]any
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
var body *ticket.ActiveTimer
if raw != nil {
b, err := json.Marshal(raw)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
var t ticket.ActiveTimer
if err := json.Unmarshal(b, &t); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if t.TicketID == "" && t.ProjectID == "" {
body = nil
} else {
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
if t.StartedAt.IsZero() {
t.StartedAt = time.Now()
}
body = &t
}
}
if err := s.timeStore.SaveActiveTimer(body); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if body != nil {
s.notify(body.ProjectID)
} else {
s.notify("")
}
writeJSON(w, http.StatusOK, body)
}
+111
View File
@@ -0,0 +1,111 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"task/internal/daemon/api"
)
// Client talks to the local JSON daemon over a Unix socket.
type Client struct {
http *http.Client
kind string // tui | cli | mcp
token string // bearer for mcp
project string // mcp bound project
}
// Options configures a daemon client.
type Options struct {
Socket string
Kind string
Token string
Project string
}
// Dial creates a client for the given Unix socket.
func Dial(opts Options) *Client {
kind := opts.Kind
if kind == "" {
kind = api.ClientTUI
}
return &Client{
kind: kind,
token: opts.Token,
project: opts.Project,
http: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", opts.Socket)
},
},
},
}
}
func (c *Client) do(method, path string, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, "http://daemon"+path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set(api.HeaderClient, c.kind)
if c.kind == api.ClientMCP {
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if c.project != "" {
req.Header.Set("X-TM-Project", c.project)
}
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
var er struct {
Error string `json:"error"`
}
_ = json.Unmarshal(data, &er)
if er.Error == "" {
er.Error = strings.TrimSpace(string(data))
}
if er.Error == "" {
er.Error = resp.Status
}
return fmt.Errorf("%s", er.Error)
}
if out == nil || resp.StatusCode == http.StatusNoContent || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}
// Health checks /v1/health.
func (c *Client) Health() error {
return c.do(http.MethodGet, "/v1/health", nil, nil)
}
+53
View File
@@ -0,0 +1,53 @@
package client
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"task/internal/daemon/api"
)
// SubscribeEvents streams SSE change events until ctx is cancelled.
func (c *Client) SubscribeEvents(ctx context.Context, emit func(api.Event)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://daemon/v1/events", nil)
if err != nil {
return err
}
req.Header.Set(api.HeaderClient, c.kind)
if c.kind == api.ClientMCP {
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if c.project != "" {
req.Header.Set("X-TM-Project", c.project)
}
}
// No overall timeout for long-lived SSE.
httpClient := *c.http
httpClient.Timeout = 0
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("events: %s", resp.Status)
}
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
var ev api.Event
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil {
continue
}
emit(ev)
}
return sc.Err()
}
+100
View File
@@ -0,0 +1,100 @@
package client
import (
"fmt"
"net/http"
"net/url"
"task/internal/command"
"task/internal/ticket"
)
// Executor implements command.Executor via daemon mutation endpoints.
type Executor struct{ c *Client }
func (c *Client) Executor() *Executor { return &Executor{c: c} }
func (e *Executor) Do(cmd command.Command) error {
switch c := cmd.(type) {
case *command.CreateTicket:
var out ticket.Ticket
if err := e.c.do(http.MethodPost, "/v1/tickets", c.Ticket, &out); err != nil {
return err
}
c.Ticket = out
return nil
case *command.UpdateTicket:
return e.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(c.After.ID), c.After, nil)
case *command.DeleteTicket:
return e.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(c.Snapshot.ID), nil, nil)
case *command.MoveTicket:
var out ticket.Ticket
if err := e.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(c.Before.ID)+"/move", map[string]string{
"toProjectId": c.After.ProjectID,
}, &out); err != nil {
return err
}
c.After = out
return nil
case *command.AddTimeEntry:
var out ticket.TimeEntry
if err := e.c.do(http.MethodPost, "/v1/time", c.Entry, &out); err != nil {
return err
}
c.Entry = out
return nil
case *command.RemoveTimeEntry:
return e.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(c.Snapshot.ID), nil, nil)
case *command.UpdateTimeEntry:
return e.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(c.After.ID), c.After, nil)
default:
return fmt.Errorf("unsupported command type %T", cmd)
}
}
func (e *Executor) Undo() error {
return e.c.do(http.MethodPost, "/v1/undo", map[string]any{}, nil)
}
func (e *Executor) Redo() error {
return e.c.do(http.MethodPost, "/v1/redo", map[string]any{}, nil)
}
func (e *Executor) CanUndo() bool {
st, err := e.status()
if err != nil {
return false
}
return st.CanUndo
}
func (e *Executor) CanRedo() bool {
st, err := e.status()
if err != nil {
return false
}
return st.CanRedo
}
func (e *Executor) LastUndoName() string {
st, _ := e.status()
return st.LastUndoName
}
func (e *Executor) LastRedoName() string {
st, _ := e.status()
return st.LastRedoName
}
type historyStatus struct {
CanUndo bool `json:"canUndo"`
CanRedo bool `json:"canRedo"`
LastUndoName string `json:"lastUndoName"`
LastRedoName string `json:"lastRedoName"`
}
func (e *Executor) status() (historyStatus, error) {
var st historyStatus
err := e.c.do(http.MethodGet, "/v1/history", nil, &st)
return st, err
}
+159
View File
@@ -0,0 +1,159 @@
package client
import (
"fmt"
"net/http"
"net/url"
"time"
"task/internal/project"
"task/internal/ticket"
)
// TicketStore implements ticket.Store against the daemon (reads + MarkSynced).
// Mutations used by the TUI go through Executor; Create/Update/Delete/Move here
// call the daemon mutation endpoints (with undo) for completeness.
type TicketStore struct{ c *Client }
func (c *Client) TicketStore() *TicketStore { return &TicketStore{c: c} }
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
var out []ticket.Ticket
q := url.Values{"projectId": {projectID}}.Encode()
err := s.c.do(http.MethodGet, "/v1/tickets?"+q, nil, &out)
if out == nil {
out = []ticket.Ticket{}
}
return out, err
}
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
var out ticket.Ticket
err := s.c.do(http.MethodGet, "/v1/tickets/"+url.PathEscape(id), nil, &out)
return out, err
}
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
var out ticket.Ticket
err := s.c.do(http.MethodPost, "/v1/tickets", t, &out)
return out, err
}
func (s *TicketStore) Update(t ticket.Ticket) error {
return s.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(t.ID), t, nil)
}
func (s *TicketStore) MarkSynced(id string) error {
return fmt.Errorf("MarkSynced is daemon-internal (use sync endpoints)")
}
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
var out ticket.Ticket
err := s.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(id)+"/move", map[string]string{
"toProjectId": toProjectID,
}, &out)
return out, err
}
func (s *TicketStore) Delete(id string) error {
return s.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(id), nil, nil)
}
// TimeStore implements ticket.TimeStore.
type TimeStore struct{ c *Client }
func (c *Client) TimeStore() *TimeStore { return &TimeStore{c: c} }
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
err := s.c.do(http.MethodGet, "/v1/time", nil, &out)
if out == nil {
out = []ticket.TimeEntry{}
}
return out, err
}
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
err := s.c.do(http.MethodGet, "/v1/time/by-ticket/"+url.PathEscape(ticketID), nil, &out)
if out == nil {
out = []ticket.TimeEntry{}
}
return out, err
}
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
var out ticket.TimeEntry
err := s.c.do(http.MethodPost, "/v1/time", e, &out)
return out, err
}
func (s *TimeStore) Update(e ticket.TimeEntry) error {
return s.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(e.ID), e, nil)
}
func (s *TimeStore) Delete(id string) error {
return s.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(id), nil, nil)
}
func (s *TimeStore) DeleteByProject(projectID string) error {
// Composite delete is done server-side on project delete; not exposed alone.
return fmt.Errorf("DeleteByProject is daemon-internal")
}
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
return fmt.Errorf("RetargetTicket is daemon-internal")
}
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
var out struct {
Duration int64 `json:"duration"`
}
err := s.c.do(http.MethodGet, "/v1/time/sum/"+url.PathEscape(ticketID), nil, &out)
return time.Duration(out.Duration), err
}
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
var out *ticket.ActiveTimer
err := s.c.do(http.MethodGet, "/v1/timer", nil, &out)
return out, err
}
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
if t == nil {
return s.c.do(http.MethodPut, "/v1/timer", map[string]any{}, nil)
}
return s.c.do(http.MethodPut, "/v1/timer", t, nil)
}
// ProjectStore implements project.Store.
type ProjectStore struct{ c *Client }
func (c *Client) ProjectStore() *ProjectStore { return &ProjectStore{c: c} }
func (s *ProjectStore) List() ([]project.Project, error) {
var out []project.Project
err := s.c.do(http.MethodGet, "/v1/projects", nil, &out)
if out == nil {
out = []project.Project{}
}
return out, err
}
func (s *ProjectStore) Ensure(id string) error {
return s.c.do(http.MethodPost, "/v1/projects", map[string]string{"id": id}, nil)
}
func (s *ProjectStore) Delete(id string) error {
return s.c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(id), nil, nil)
}
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
var out project.Meta
err := s.c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(id)+"/meta", nil, &out)
return out, err
}
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
return s.c.do(http.MethodPut, "/v1/projects/"+url.PathEscape(id)+"/meta", meta, nil)
}
+47
View File
@@ -0,0 +1,47 @@
package client
import (
"net/http"
"net/url"
"task/internal/ytsync"
)
// SyncPull runs daemon sync pull for projectID.
func (c *Client) SyncPull(projectID string) (ytsync.Result, error) {
var out ytsync.Result
err := c.do(http.MethodPost, "/v1/sync/pull", map[string]string{"projectId": projectID}, &out)
return out, err
}
// SyncPush runs daemon sync push for projectID.
func (c *Client) SyncPush(projectID string) (ytsync.Result, error) {
var out ytsync.Result
err := c.do(http.MethodPost, "/v1/sync/push", map[string]string{"projectId": projectID}, &out)
return out, err
}
// CreateMCPToken creates a new MCP token for projectID (plaintext once).
func (c *Client) CreateMCPToken(projectID string) (token, createdAt string, err error) {
var out struct {
Token string `json:"token"`
CreatedAt string `json:"createdAt"`
}
err = c.do(http.MethodPost, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", map[string]any{}, &out)
return out.Token, out.CreatedAt, err
}
// RevokeMCPToken removes the MCP token for projectID.
func (c *Client) RevokeMCPToken(projectID string) error {
return c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, nil)
}
// MCPTokenStatus returns whether a token is configured.
func (c *Client) MCPTokenStatus(projectID string) (configured bool, createdAt string, err error) {
var out struct {
Configured bool `json:"configured"`
CreatedAt string `json:"createdAt"`
}
err = c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, &out)
return out.Configured, out.CreatedAt, err
}
+159
View File
@@ -0,0 +1,159 @@
package daemon
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"task/internal/config"
)
// Status describes whether a daemon is reachable for cfg.DataDir.
type Status struct {
Running bool
PID int
Socket string
DataDir string
}
// StatusOf returns daemon status for the given config.
func StatusOf(cfg *config.Config) (Status, error) {
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return Status{}, err
}
st := Status{Socket: p.Socket, DataDir: cfg.DataDir}
pid, err := readPID(p.PIDFile)
if err == nil && processAlive(pid) {
st.PID = pid
}
if Healthy(p.Socket) {
st.Running = true
return st, nil
}
return st, nil
}
// Healthy reports whether the daemon HTTP health endpoint responds on socket.
func Healthy(socket string) bool {
cli := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socket)
},
},
Timeout: 500 * time.Millisecond,
}
resp, err := cli.Get("http://daemon/v1/health")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// Ensure starts the daemon if needed and waits until it is healthy.
func Ensure(cfg *config.Config) (Paths, error) {
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return Paths{}, err
}
if Healthy(p.Socket) {
return p, nil
}
cleanupStale(p)
if err := Spawn(cfg, p); err != nil {
return Paths{}, err
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if Healthy(p.Socket) {
return p, nil
}
time.Sleep(50 * time.Millisecond)
}
return Paths{}, fmt.Errorf("daemon did not become healthy at %s", p.Socket)
}
// Stop terminates the daemon for cfg.DataDir if running.
func Stop(cfg *config.Config) error {
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return err
}
pid, err := readPID(p.PIDFile)
if err != nil {
if os.IsNotExist(err) {
cleanupStale(p)
return nil
}
return err
}
if processAlive(pid) {
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return fmt.Errorf("signal daemon: %w", err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if !processAlive(pid) {
break
}
time.Sleep(50 * time.Millisecond)
}
if processAlive(pid) {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
cleanupStale(p)
return nil
}
// Spawn starts a detached daemon process for cfg.
func Spawn(cfg *config.Config, p Paths) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("executable: %w", err)
}
logFile, err := os.OpenFile(p.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
defer logFile.Close()
cmd := exec.Command(exe, "daemon")
cmd.Env = os.Environ()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("spawn daemon: %w", err)
}
// Detach: do not wait; child writes its own PID file when listening.
_ = cmd.Process.Release()
return nil
}
func cleanupStale(p Paths) {
_ = os.Remove(p.Socket)
_ = os.Remove(p.PIDFile)
}
// AbsoluteDataDir ensures DataDir is absolute for path hashing.
func AbsoluteDataDir(dataDir string) (string, error) {
if filepath.IsAbs(dataDir) {
return filepath.Clean(dataDir), nil
}
abs, err := filepath.Abs(dataDir)
if err != nil {
return "", err
}
return abs, nil
}
+91
View File
@@ -0,0 +1,91 @@
package daemon
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
const (
sockName = "daemon.sock"
pidName = "daemon.pid"
logName = "daemon.log"
)
// Paths holds filesystem locations for one DataDir's daemon instance.
type Paths struct {
RuntimeDir string
Socket string
PIDFile string
LogFile string
}
// PathsForDataDir returns runtime paths unique to dataDir.
func PathsForDataDir(dataDir string) (Paths, error) {
dataDir = filepath.Clean(dataDir)
sum := sha256.Sum256([]byte(dataDir))
id := hex.EncodeToString(sum[:8])
dir, err := mkdirRuntime(id, dataDir)
if err != nil {
return Paths{}, err
}
return Paths{
RuntimeDir: dir,
Socket: filepath.Join(dir, sockName),
PIDFile: filepath.Join(dir, pidName),
LogFile: filepath.Join(dir, logName),
}, nil
}
func mkdirRuntime(id, dataDir string) (string, error) {
candidates := []string{}
if r := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); r != "" {
candidates = append(candidates, filepath.Join(r, "task-master-"+id))
}
candidates = append(candidates,
filepath.Join(os.TempDir(), fmt.Sprintf("task-master-%d-%s", os.Getuid(), id)),
filepath.Join(dataDir, ".runtime"),
)
var last error
for _, dir := range candidates {
if err := os.MkdirAll(dir, 0o700); err != nil {
last = err
continue
}
return dir, nil
}
if last == nil {
last = fmt.Errorf("no runtime dir candidate")
}
return "", fmt.Errorf("create runtime dir: %w", last)
}
func writePID(path string, pid int) error {
return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600)
}
func readPID(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(data))
if s == "" {
return 0, fmt.Errorf("empty pid file")
}
return strconv.Atoi(s)
}
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil
}
+79
View File
@@ -0,0 +1,79 @@
package daemon
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"task/internal/command"
"task/internal/config"
"task/internal/daemon/api"
"task/internal/project"
"task/internal/ticket"
)
// Run starts the foreground daemon for cfg until signal or context cancel.
func Run(cfg *config.Config) error {
abs, err := AbsoluteDataDir(cfg.DataDir)
if err != nil {
return err
}
cfg.DataDir = abs
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return err
}
cleanupStale(p)
store := ticket.NewJSONStore(cfg.DataDir)
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
if err != nil {
return fmt.Errorf("history: %w", err)
}
srvAPI := api.New(store, timeStore, projects, history)
server := &http.Server{Handler: srvAPI.Handler()}
ln, err := net.Listen("unix", p.Socket)
if err != nil {
return fmt.Errorf("listen %s: %w", p.Socket, err)
}
if err := os.Chmod(p.Socket, 0o600); err != nil {
_ = ln.Close()
return fmt.Errorf("chmod socket: %w", err)
}
if err := writePID(p.PIDFile, os.Getpid()); err != nil {
_ = ln.Close()
return fmt.Errorf("pid file: %w", err)
}
defer cleanupStale(p)
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(ln)
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case <-sigCh:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
return nil
case err := <-errCh:
if err == http.ErrServerClosed {
return nil
}
return err
}
}
+278
View File
@@ -0,0 +1,278 @@
package mcp
import (
"context"
"encoding/json"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"task/internal/daemon/client"
"task/internal/ticket"
)
// ServeStdio runs the MCP server on stdin/stdout using auth from .tm_token.
func ServeStdio(cli *client.Client, tf TokenFile) error {
s := server.NewMCPServer(
"task-master",
"1.0.0",
server.WithToolCapabilities(true),
)
registerTools(s, cli, tf)
return server.ServeStdio(s)
}
func registerTools(s *server.MCPServer, cli *client.Client, tf TokenFile) {
jsonResult := func(v any) (*mcp.CallToolResult, error) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(string(b)), nil
}
fail := func(err error) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError(err.Error()), nil
}
s.AddTool(mcp.NewTool("list_projects",
mcp.WithDescription("List the TM project bound to .tm_token"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
list, err := cli.ProjectStore().List()
if err != nil {
return fail(err)
}
return jsonResult(list)
})
s.AddTool(mcp.NewTool("list_tickets",
mcp.WithDescription("List tickets in the token project"),
mcp.WithString("status", mcp.Description("Optional status filter: todo, in_progress, done")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
list, err := cli.TicketStore().List(tf.Project)
if err != nil {
return fail(err)
}
status := req.GetString("status", "")
if status != "" {
filtered := list[:0]
for _, t := range list {
if string(t.Status) == status {
filtered = append(filtered, t)
}
}
list = filtered
}
return jsonResult(list)
})
s.AddTool(mcp.NewTool("get_ticket",
mcp.WithDescription("Get one ticket by id"),
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
t, err := cli.TicketStore().Get(id)
if err != nil {
return fail(err)
}
return jsonResult(t)
})
s.AddTool(mcp.NewTool("create_ticket",
mcp.WithDescription("Create a ticket in the token project"),
mcp.WithString("title", mcp.Required(), mcp.Description("Ticket title")),
mcp.WithString("description", mcp.Description("Markdown body")),
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
title, err := req.RequireString("title")
if err != nil {
return fail(err)
}
st := ticket.Status(req.GetString("status", string(ticket.StatusTodo)))
t := ticket.Ticket{
Title: title,
Description: req.GetString("description", ""),
Status: st,
ProjectID: tf.Project,
}
created, err := cli.TicketStore().Create(t)
if err != nil {
return fail(err)
}
return jsonResult(created)
})
s.AddTool(mcp.NewTool("update_ticket",
mcp.WithDescription("Update ticket title, description, and/or status"),
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
mcp.WithString("title", mcp.Description("New title")),
mcp.WithString("description", mcp.Description("New description")),
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
before, err := cli.TicketStore().Get(id)
if err != nil {
return fail(err)
}
after := before
if v := req.GetString("title", ""); v != "" {
after.Title = v
}
if _, ok := req.GetArguments()["description"]; ok {
after.Description = req.GetString("description", "")
}
if v := req.GetString("status", ""); v != "" {
after.Status = ticket.Status(v)
}
if err := cli.TicketStore().Update(after); err != nil {
return fail(err)
}
return jsonResult(after)
})
s.AddTool(mcp.NewTool("move_ticket",
mcp.WithDescription("Move a ticket (MCP only allows same project id)"),
mcp.WithString("id", mcp.Required()),
mcp.WithString("to_project_id", mcp.Required()),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
to, err := req.RequireString("to_project_id")
if err != nil {
return fail(err)
}
moved, err := cli.TicketStore().Move(id, to)
if err != nil {
return fail(err)
}
return jsonResult(moved)
})
s.AddTool(mcp.NewTool("delete_ticket",
mcp.WithDescription("Delete a ticket by id"),
mcp.WithString("id", mcp.Required()),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
if err := cli.TicketStore().Delete(id); err != nil {
return fail(err)
}
return mcp.NewToolResultText("deleted"), nil
})
s.AddTool(mcp.NewTool("list_time",
mcp.WithDescription("List time entries for the token project"),
mcp.WithString("ticket_id", mcp.Description("Optional ticket filter")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
ticketID := req.GetString("ticket_id", "")
var list []ticket.TimeEntry
var err error
if ticketID != "" {
list, err = cli.TimeStore().ListByTicket(ticketID)
} else {
list, err = cli.TimeStore().List()
}
if err != nil {
return fail(err)
}
return jsonResult(list)
})
s.AddTool(mcp.NewTool("add_time",
mcp.WithDescription("Add a manual time entry"),
mcp.WithString("ticket_id", mcp.Required()),
mcp.WithNumber("minutes", mcp.Required(), mcp.Description("Duration in minutes")),
mcp.WithString("note", mcp.Description("Optional note")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
ticketID, err := req.RequireString("ticket_id")
if err != nil {
return fail(err)
}
mins, err := req.RequireFloat("minutes")
if err != nil {
return fail(err)
}
tk, err := cli.TicketStore().Get(ticketID)
if err != nil {
return fail(err)
}
dur := time.Duration(mins * float64(time.Minute))
end := time.Now()
start := end.Add(-dur)
entry := ticket.TimeEntry{
TicketID: ticketID,
ProjectID: tk.ProjectID,
Start: start,
End: end,
Duration: dur,
Source: ticket.SourceManual,
Note: req.GetString("note", ""),
}
created, err := cli.TimeStore().Create(entry)
if err != nil {
return fail(err)
}
return jsonResult(created)
})
s.AddTool(mcp.NewTool("undo",
mcp.WithDescription("Undo the last daemon command"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
if err := cli.Executor().Undo(); err != nil {
return fail(err)
}
return mcp.NewToolResultText("ok"), nil
})
s.AddTool(mcp.NewTool("redo",
mcp.WithDescription("Redo the last undone daemon command"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
if err := cli.Executor().Redo(); err != nil {
return fail(err)
}
return mcp.NewToolResultText("ok"), nil
})
s.AddTool(mcp.NewTool("sync_pull",
mcp.WithDescription("Pull tickets/time from the configured backend for the token project"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
res, err := cli.SyncPull(tf.Project)
if err != nil {
return fail(err)
}
return jsonResult(res)
})
s.AddTool(mcp.NewTool("sync_push",
mcp.WithDescription("Push tickets/time to the configured backend for the token project"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
res, err := cli.SyncPush(tf.Project)
if err != nil {
return fail(err)
}
return jsonResult(res)
})
}
+79
View File
@@ -0,0 +1,79 @@
package mcp
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
const tokenFileName = ".tm_token"
// TokenFile holds credentials from a workspace .tm_token file.
type TokenFile struct {
Project string
Token string
Path string
}
// FindTokenFile walks from start (usually cwd) upward looking for .tm_token.
func FindTokenFile(start string) (TokenFile, error) {
dir, err := filepath.Abs(start)
if err != nil {
return TokenFile{}, err
}
for {
path := filepath.Join(dir, tokenFileName)
if st, err := os.Stat(path); err == nil && !st.IsDir() {
tf, err := ParseTokenFile(path)
if err != nil {
return TokenFile{}, err
}
return tf, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return TokenFile{}, fmt.Errorf("no %s found (create an MCP token in the TUI with g, then place project=… and token=… in %s)", tokenFileName, tokenFileName)
}
// ParseTokenFile reads project= and token= lines from path.
func ParseTokenFile(path string) (TokenFile, error) {
f, err := os.Open(path)
if err != nil {
return TokenFile{}, err
}
defer f.Close()
tf := TokenFile{Path: path}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(strings.ToLower(key))
val = strings.TrimSpace(val)
switch key {
case "project":
tf.Project = val
case "token":
tf.Token = val
}
}
if err := sc.Err(); err != nil {
return TokenFile{}, err
}
if tf.Project == "" || tf.Token == "" {
return TokenFile{}, fmt.Errorf("%s must contain project= and token= lines", path)
}
return tf, nil
}
+43
View File
@@ -0,0 +1,43 @@
package mcp
import (
"os"
"path/filepath"
"testing"
)
func TestParseAndFindTokenFile(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "a", "b")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, ".tm_token")
content := "# comment\nproject=inbox\ntoken=tm_mcp_abc\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
tf, err := FindTokenFile(sub)
if err != nil {
t.Fatal(err)
}
if tf.Project != "inbox" || tf.Token != "tm_mcp_abc" {
t.Fatalf("got %+v", tf)
}
_, err = FindTokenFile(t.TempDir())
if err == nil {
t.Fatal("expected missing token error")
}
}
func TestParseTokenFileIncomplete(t *testing.T) {
path := filepath.Join(t.TempDir(), ".tm_token")
if err := os.WriteFile(path, []byte("project=only\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ParseTokenFile(path); err == nil {
t.Fatal("expected error")
}
}
+7
View File
@@ -54,11 +54,18 @@ func (t TaskMaster) Configured() bool {
strings.TrimSpace(t.ProjectID) != ""
}
// McpAuth holds the hashed local MCP token for a project (plaintext never stored).
type McpAuth struct {
TokenHash string `json:"tokenHash"`
CreatedAt string `json:"createdAt,omitempty"`
}
// Meta is per-project metadata stored beside tickets.json.
type Meta struct {
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
YouTrack *YouTrack `json:"youtrack,omitempty"`
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
McpAuth *McpAuth `json:"mcpAuth,omitempty"`
}
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
+1
View File
@@ -4,5 +4,6 @@ package project
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Code string `json:"code,omitempty"`
GroupID string `json:"groupId,omitempty"`
}
+275
View File
@@ -0,0 +1,275 @@
package server
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"unicode"
"task/internal/sqlite"
)
const maxAttachmentBytes = 10 << 20 // 10 MiB
func (s *Server) attachmentsRoot() string {
root := strings.TrimSpace(s.DataDir)
if root == "" {
root = "."
}
return filepath.Join(root, "attachments")
}
func (s *Server) attachmentPath(ticketID, attachmentID string) string {
return filepath.Join(s.attachmentsRoot(), ticketID, attachmentID)
}
func (s *Server) removeTicketAttachmentDir(ticketID string) {
_ = os.RemoveAll(filepath.Join(s.attachmentsRoot(), ticketID))
}
func sanitizeFilename(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == ".." {
return "file"
}
var b strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
case r == '.', r == '-', r == '_', r == ' ':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := strings.TrimSpace(b.String())
if out == "" || out == "." || out == ".." {
return "file"
}
return out
}
func (s *Server) loadTicketAttachment(projectID, ticketID, attachmentID string) (sqlite.Attachment, error) {
t, err := s.Tickets.Get(ticketID)
if err != nil || t.ProjectID != projectID {
return sqlite.Attachment{}, fmt.Errorf("ticket not found")
}
a, err := s.Attachments.Get(attachmentID)
if err != nil || a.TicketID != ticketID || a.ProjectID != projectID {
return sqlite.Attachment{}, fmt.Errorf("attachment not found")
}
return a, nil
}
func (s *Server) handleListAttachments(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
list, err := s.Attachments.ListByTicket(tid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+512)
if err := r.ParseMultipartForm(maxAttachmentBytes + 512); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file")
return
}
defer file.Close()
if hdr.Size > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
filename := sanitizeFilename(hdr.Filename)
contentType := hdr.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
a, err := s.Attachments.Create(sqlite.Attachment{
TicketID: tid,
ProjectID: id,
Filename: filename,
ContentType: contentType,
SizeBytes: 0,
CreatedBy: u.ID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
dir := filepath.Join(s.attachmentsRoot(), tid)
if err := os.MkdirAll(dir, 0o755); err != nil {
_ = s.Attachments.Delete(a.ID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
path := s.attachmentPath(tid, a.ID)
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
_ = s.Attachments.Delete(a.ID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
n, copyErr := io.Copy(dst, io.LimitReader(file, maxAttachmentBytes+1))
closeErr := dst.Close()
if copyErr != nil || closeErr != nil || n > maxAttachmentBytes {
_ = os.Remove(path)
_ = s.Attachments.Delete(a.ID)
if n > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
if copyErr != nil {
writeError(w, http.StatusInternalServerError, copyErr.Error())
return
}
writeError(w, http.StatusInternalServerError, closeErr.Error())
return
}
_ = s.Attachments.UpdateSize(a.ID, n)
a.SizeBytes = n
writeJSON(w, http.StatusCreated, a)
}
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
a, err := s.loadTicketAttachment(id, tid, aid)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
path := s.attachmentPath(tid, aid)
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusNotFound, "file missing")
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, strings.ReplaceAll(a.Filename, `"`, "")))
w.Header().Set("Content-Length", fmt.Sprintf("%d", st.Size()))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
func (s *Server) handlePutAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
a, err := s.loadTicketAttachment(id, tid, aid)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+1)
data, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
if int64(len(data)) > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
path := s.attachmentPath(tid, aid)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
size := int64(len(data))
if err := s.Attachments.UpdateSize(aid, size); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.SizeBytes = size
ct := r.Header.Get("Content-Type")
if ct != "" && ct != "application/octet-stream" {
a.ContentType = ct
}
writeJSON(w, http.StatusOK, a)
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, err := s.loadTicketAttachment(id, tid, aid); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
_ = os.Remove(s.attachmentPath(tid, aid))
if err := s.Attachments.Delete(aid); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+171
View File
@@ -0,0 +1,171 @@
package server
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestAttachmentUploadListDownloadPutDelete(t *testing.T) {
tmp := t.TempDir()
db, err := sqlite.Open(filepath.Join(tmp, "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(tmp, "data")
srv := New(db)
srv.DataDir = dataDir
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("file", "notes.txt")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("hello attach")); err != nil {
t.Fatal(err)
}
_ = mw.Close()
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("upload %d %s", rec.Code, rec.Body.String())
}
var att map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &att)
aid, _ := att["id"].(string)
if aid == "" {
t.Fatal("no attachment id")
}
if att["filename"] != "notes.txt" {
t.Fatalf("filename %v", att["filename"])
}
if int64(att["sizeBytes"].(float64)) != 12 {
t.Fatalf("size %v", att["sizeBytes"])
}
diskPath := filepath.Join(dataDir, "attachments", tid, aid)
if _, err := os.Stat(diskPath); err != nil {
t.Fatalf("disk file: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list %d %s", rec.Code, rec.Body.String())
}
var list []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 1 {
t.Fatalf("list len %d", len(list))
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("download %d %s", rec.Code, rec.Body.String())
}
got, _ := io.ReadAll(rec.Body)
if string(got) != "hello attach" {
t.Fatalf("download body %q", got)
}
putBody := []byte("updated content!!")
req = httptest.NewRequest(http.MethodPut, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, bytes.NewReader(putBody))
req.Header.Set("Content-Type", "text/plain")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("put %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &att)
if int64(att["sizeBytes"].(float64)) != int64(len(putBody)) {
t.Fatalf("put size %v", att["sizeBytes"])
}
disk, err := os.ReadFile(diskPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(disk, putBody) {
t.Fatalf("disk after put %q", disk)
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete %d %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(diskPath); !os.IsNotExist(err) {
t.Fatalf("file should be gone: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("list after delete %d", len(list))
}
}
+40
View File
@@ -2,6 +2,7 @@ package server
import (
"net/http"
"sort"
"task/internal/sqlite"
"task/internal/ticket"
@@ -29,11 +30,46 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
return
}
from, err := ticket.ParseReportDay(r.URL.Query().Get("from"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
toDay, err := ticket.ParseReportDay(r.URL.Query().Get("to"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var toBound = toDay
if !toDay.IsZero() {
toBound = ticket.EndOfReportDay(toDay)
}
filter := ticket.TimeFilter{
From: from,
To: toBound,
TicketID: r.URL.Query().Get("ticketId"),
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
entries = ticket.FilterEntries(entries, filter)
sort.Slice(entries, func(i, j int) bool {
ai, aj := entries[i].Start, entries[j].Start
if ai.IsZero() {
ai = entries[i].End
}
if aj.IsZero() {
aj = entries[j].End
}
if !ai.Equal(aj) {
return ai.After(aj)
}
return entries[i].ID > entries[j].ID
})
tickets, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -64,5 +100,9 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
"totalDurationNs": int64(total),
"rows": out,
"entryCount": len(entries),
"entries": entries,
"from": r.URL.Query().Get("from"),
"to": r.URL.Query().Get("to"),
"ticketId": filter.TicketID,
})
}
+109
View File
@@ -0,0 +1,109 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func TestProjectReportFilters(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var tk map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &tk)
tid, _ := tk["id"].(string)
day := time.Date(2026, 10, 18, 14, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: day,
End: day.Add(45 * time.Minute),
Duration: 45 * time.Minute,
Source: ticket.SourceManual,
Note: "filtered",
})
if err != nil {
t.Fatal(err)
}
other := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: other,
End: other.Add(time.Hour),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
url := "/api/v1/projects/" + pid + "/report?groupBy=ticket&from=2026-10-18&to=2026-10-18&ticketId=" + tid
req = httptest.NewRequest(http.MethodGet, url, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("report %d %s", rec.Code, rec.Body.String())
}
var out struct {
EntryCount int `json:"entryCount"`
TotalDurationNs int64 `json:"totalDurationNs"`
Entries []ticket.TimeEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.EntryCount != 1 || len(out.Entries) != 1 {
t.Fatalf("want 1 entry, got count=%d entries=%d", out.EntryCount, len(out.Entries))
}
if out.TotalDurationNs != int64(45*time.Minute) {
t.Fatalf("total=%d", out.TotalDurationNs)
}
if out.Entries[0].Note != "filtered" {
t.Fatalf("note=%q", out.Entries[0].Note)
}
}
+111 -2
View File
@@ -3,16 +3,31 @@ package server
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"task/internal/config"
"task/internal/project"
"task/internal/sqlite"
"task/internal/ticket"
)
// SeedDemoOptions controls optional client wiring during demo seed.
type SeedDemoOptions struct {
// ClientDataDir, when set, writes local TUI projects (meta.json + tokens)
// for "Test" and "Website Relaunch" under that data directory.
ClientDataDir string
// BaseURL is the tm_server URL stored in client meta (default http://127.0.0.1:8080).
BaseURL string
}
// tuiSeedProjects are server project names that get a write token and local TUI wiring.
var tuiSeedProjects = []string{"Test", "Website Relaunch"}
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
func (s *Server) SeedDemo() error {
func (s *Server) SeedDemo(opts SeedDemoOptions) error {
projects, err := s.Projects.List()
if err != nil {
return err
@@ -58,7 +73,7 @@ func (s *Server) SeedDemo() error {
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
{Title: "TUI anbinden", Desc: "Settings → Token (write) erzeugen → in `tm` Projekt-Edit backend=taskmaster eintragen (URL, Token, diese Project-ID).", Status: ticket.StatusTodo},
{Title: "TUI Sync", Desc: "Lokal unter `.dev-home` schon mit Token verdrahtet — in `tm` (`make run-dev`) Pull/Push testen.", Status: ticket.StatusTodo},
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
},
},
@@ -157,5 +172,99 @@ func (s *Server) SeedDemo() error {
}
fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
if dir := strings.TrimSpace(opts.ClientDataDir); dir != "" {
if err := s.wireDemoTUI(dir, opts.BaseURL, created, admin.ID); err != nil {
return fmt.Errorf("seed tui: %w", err)
}
}
return nil
}
func (s *Server) wireDemoTUI(dataDir, baseURL string, created []struct{ ID, Name string }, adminID string) error {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = "http://127.0.0.1:8080"
}
byName := make(map[string]string, len(created))
for _, c := range created {
byName[c.Name] = c.ID
}
store := project.NewDirStore(dataDir)
wired := make([]string, 0, len(tuiSeedProjects))
for _, name := range tuiSeedProjects {
remoteID, ok := byName[name]
if !ok {
return fmt.Errorf("missing seeded project %q", name)
}
tok, err := s.DB.CreateProjectToken(remoteID, "dev-tui", sqlite.TokenScopeWrite, adminID)
if err != nil {
return fmt.Errorf("token for %s: %w", name, err)
}
// Fresh local dir so stale tickets from a previous DB wipe cannot confuse sync.
_ = os.RemoveAll(filepath.Join(dataDir, "projects", name))
meta := project.Meta{
Backend: project.BackendTaskMaster,
TaskMaster: &project.TaskMaster{
BaseURL: baseURL,
Token: tok.Token,
ProjectID: remoteID,
},
}
if err := store.SaveMeta(name, meta); err != nil {
return fmt.Errorf("meta %s: %w", name, err)
}
wired = append(wired, name)
fmt.Fprintf(os.Stderr, "tui wired: %s → %s (project %s, token %s…)\n",
name, baseURL, remoteID, trimTokenPrefix(tok.Token))
}
if err := setActiveProjectIfSameDataDir(dataDir, tuiSeedProjects[0]); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "tui ready: %s (make run-dev → Y pull)\n", strings.Join(wired, ", "))
return nil
}
func trimTokenPrefix(tok string) string {
if len(tok) <= 10 {
return tok
}
return tok[:10]
}
func setActiveProjectIfSameDataDir(dataDir, active string) error {
cfg, err := config.LoadOrCreate()
if err != nil {
return fmt.Errorf("config: %w", err)
}
if filepath.Clean(cfg.DataDir) != filepath.Clean(dataDir) {
return nil
}
cfg.ActiveProject = active
if err := config.Save(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// SeedBaseURL derives the public base URL for seeded TUI meta from -listen / env.
func SeedBaseURL(listen string) string {
if u := strings.TrimSpace(os.Getenv("TM_SEED_BASE_URL")); u != "" {
return strings.TrimRight(u, "/")
}
host := strings.TrimSpace(listen)
if host == "" {
host = ":8080"
}
switch {
case strings.HasPrefix(host, "http://"), strings.HasPrefix(host, "https://"):
return strings.TrimRight(host, "/")
case strings.HasPrefix(host, ":"):
return "http://127.0.0.1" + host
default:
return "http://" + host
}
}
+74 -2
View File
@@ -1,9 +1,11 @@
package server
import (
"os"
"path/filepath"
"testing"
"task/internal/project"
"task/internal/sqlite"
)
@@ -17,7 +19,7 @@ func TestSeedDemo(t *testing.T) {
t.Fatal(err)
}
srv := New(db)
if err := srv.SeedDemo(); err != nil {
if err := srv.SeedDemo(SeedDemoOptions{}); err != nil {
t.Fatal(err)
}
projects, err := srv.Projects.List()
@@ -55,7 +57,7 @@ func TestSeedDemo(t *testing.T) {
t.Fatalf("expected no remote connections, got %v", conns)
}
// idempotent when projects already exist
if err := srv.SeedDemo(); err != nil {
if err := srv.SeedDemo(SeedDemoOptions{}); err != nil {
t.Fatal(err)
}
projects2, _ := srv.Projects.List()
@@ -63,3 +65,73 @@ func TestSeedDemo(t *testing.T) {
t.Fatalf("re-seed changed project count")
}
}
func TestSeedDemoWiresTUI(t *testing.T) {
home := t.TempDir()
t.Setenv("TASK_MASTER_HOME", home)
dataDir := filepath.Join(home, ".local", "share", "task-master")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
db, err := sqlite.Open(filepath.Join(dataDir, "seed.db"), []byte("s"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "admin", true); err != nil {
t.Fatal(err)
}
srv := New(db)
if err := srv.SeedDemo(SeedDemoOptions{
ClientDataDir: dataDir,
BaseURL: "http://127.0.0.1:8080",
}); err != nil {
t.Fatal(err)
}
store := project.NewDirStore(dataDir)
for _, name := range tuiSeedProjects {
meta, err := store.LoadMeta(name)
if err != nil {
t.Fatalf("LoadMeta %s: %v", name, err)
}
if meta.EffectiveBackend() != project.BackendTaskMaster {
t.Fatalf("%s backend=%q", name, meta.EffectiveBackend())
}
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
t.Fatalf("%s taskmaster not configured: %+v", name, meta.TaskMaster)
}
if meta.TaskMaster.BaseURL != "http://127.0.0.1:8080" {
t.Fatalf("baseURL=%q", meta.TaskMaster.BaseURL)
}
auth, err := db.ResolveBearerToken(meta.TaskMaster.Token)
if err != nil {
t.Fatalf("token resolve %s: %v", name, err)
}
if auth.ProjectID != meta.TaskMaster.ProjectID {
t.Fatalf("token project mismatch")
}
if !sqlite.TokenAllows(auth.Scope, sqlite.TokenScopeWrite) {
t.Fatalf("want write scope, got %q", auth.Scope)
}
tokens, err := db.ListProjectTokens(meta.TaskMaster.ProjectID)
if err != nil || len(tokens) != 1 || tokens[0].Name != "dev-tui" {
t.Fatalf("tokens=%v err=%v", tokens, err)
}
}
}
func TestSeedBaseURL(t *testing.T) {
t.Setenv("TM_SEED_BASE_URL", "")
if got := SeedBaseURL(":8080"); got != "http://127.0.0.1:8080" {
t.Fatalf("got %q", got)
}
if got := SeedBaseURL("0.0.0.0:9000"); got != "http://0.0.0.0:9000" {
t.Fatalf("got %q", got)
}
t.Setenv("TM_SEED_BASE_URL", "http://localhost:8080/")
if got := SeedBaseURL(":9999"); got != "http://localhost:8080" {
t.Fatalf("env override got %q", got)
}
}
+9
View File
@@ -17,7 +17,9 @@ type Server struct {
Projects *sqlite.ProjectStore
Tickets *sqlite.TicketStore
Times *sqlite.TimeStore
Attachments *sqlite.AttachmentStore
Sync *syncbackend.Registry
DataDir string // root for on-disk attachments (cfg.DataDir)
Secure bool // set Secure on cookies when behind TLS
Static fs.FS
}
@@ -29,6 +31,7 @@ func New(db *sqlite.DB) *Server {
Projects: sqlite.NewProjectStore(db),
Tickets: sqlite.NewTicketStore(db),
Times: sqlite.NewTimeStore(db),
Attachments: sqlite.NewAttachmentStore(db),
Sync: syncbackend.NewRegistry(),
Static: webembed.FS(),
}
@@ -89,6 +92,12 @@ func (s *Server) routes(mux *http.ServeMux) {
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleListAttachments))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleUploadAttachment))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleGetAttachment))
api.HandleFunc("PUT /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handlePutAttachment))
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleDeleteAttachment))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
+1
View File
@@ -125,6 +125,7 @@ func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.removeTicketAttachmentDir(tid)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+16 -1
View File
@@ -63,6 +63,8 @@ func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
Start *time.Time `json:"start"`
End *time.Time `json:"end"`
Duration int64 `json:"durationNs"`
Note string `json:"note"`
}
@@ -86,8 +88,21 @@ func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not found")
return
}
if body.Duration > 0 {
if body.Start != nil || body.End != nil {
if body.Start != nil {
found.Start = body.Start.UTC()
}
if body.End != nil {
found.End = body.End.UTC()
}
if !found.End.After(found.Start) {
writeError(w, http.StatusBadRequest, "end must be after start")
return
}
found.Duration = found.End.Sub(found.Start)
} else if body.Duration > 0 {
found.Duration = time.Duration(body.Duration)
found.End = found.Start.Add(found.Duration)
}
found.Note = body.Note
if err := s.Times.Update(*found); err != nil {
+130
View File
@@ -0,0 +1,130 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"task/internal/sqlite"
)
func TestUpdateTimeStartEnd(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
timeBody, _ := json.Marshal(map[string]any{
"ticketId": tid,
"durationNs": int64(30 * time.Minute),
"source": "manual",
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/time", bytes.NewReader(timeBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create time %d %s", rec.Code, rec.Body.String())
}
var entry map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
eid, _ := entry["id"].(string)
if eid == "" {
t.Fatal("no entry id")
}
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)
end := start.Add(90 * time.Minute)
patchBody, _ := json.Marshal(map[string]any{
"start": start,
"end": end,
})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch start/end %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
dur, _ := entry["duration"].(float64)
if time.Duration(dur) != 90*time.Minute {
t.Fatalf("duration want 90m got %v", time.Duration(dur))
}
// duration-only still works
patchBody, _ = json.Marshal(map[string]any{"durationNs": int64(45 * time.Minute)})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch duration %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
dur, _ = entry["duration"].(float64)
if time.Duration(dur) != 45*time.Minute {
t.Fatalf("duration want 45m got %v", time.Duration(dur))
}
// invalid range
badEnd := start.Add(-time.Minute)
patchBody, _ = json.Marshal(map[string]any{"start": start, "end": badEnd})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid range want 400 got %d %s", rec.Code, rec.Body.String())
}
}
+131
View File
@@ -0,0 +1,131 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
// Attachment is metadata for a file attached to a ticket.
type Attachment struct {
ID string `json:"id"`
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
Filename string `json:"filename"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
// AttachmentStore persists attachment metadata.
type AttachmentStore struct {
db *DB
}
// NewAttachmentStore returns an attachment metadata store.
func NewAttachmentStore(db *DB) *AttachmentStore {
return &AttachmentStore{db: db}
}
const attachmentCols = `id, ticket_id, project_id, filename, content_type, size_bytes, created_by, created_at`
func scanAttachment(scanner interface{ Scan(dest ...any) error }) (Attachment, error) {
var a Attachment
var created string
err := scanner.Scan(
&a.ID, &a.TicketID, &a.ProjectID, &a.Filename, &a.ContentType,
&a.SizeBytes, &a.CreatedBy, &created,
)
if err != nil {
return Attachment{}, err
}
a.CreatedAt = parseTime(created)
return a, nil
}
// ListByTicket returns attachments for a ticket, newest first.
func (s *AttachmentStore) ListByTicket(ticketID string) ([]Attachment, error) {
rows, err := s.db.SQL.Query(
`SELECT `+attachmentCols+` FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at DESC`,
ticketID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
if out == nil {
out = []Attachment{}
}
return out, rows.Err()
}
// Get returns one attachment by id.
func (s *AttachmentStore) Get(id string) (Attachment, error) {
row := s.db.SQL.QueryRow(`SELECT `+attachmentCols+` FROM ticket_attachments WHERE id = ?`, id)
a, err := scanAttachment(row)
if err == sql.ErrNoRows {
return Attachment{}, fmt.Errorf("attachment not found")
}
return a, err
}
// Create inserts attachment metadata.
func (s *AttachmentStore) Create(a Attachment) (Attachment, error) {
if a.ID == "" {
a.ID = uuid.NewString()
}
if a.Filename == "" {
return Attachment{}, fmt.Errorf("filename required")
}
if a.ContentType == "" {
a.ContentType = "application/octet-stream"
}
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now().UTC()
}
_, err := s.db.SQL.Exec(
`INSERT INTO ticket_attachments (`+attachmentCols+`) VALUES (?,?,?,?,?,?,?,?)`,
a.ID, a.TicketID, a.ProjectID, a.Filename, a.ContentType, a.SizeBytes, a.CreatedBy, formatTime(a.CreatedAt),
)
if err != nil {
return Attachment{}, err
}
return a, nil
}
// UpdateSize updates size after a replace/overwrite.
func (s *AttachmentStore) UpdateSize(id string, sizeBytes int64) error {
res, err := s.db.SQL.Exec(`UPDATE ticket_attachments SET size_bytes = ? WHERE id = ?`, sizeBytes, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("attachment not found")
}
return nil
}
// Delete removes attachment metadata.
func (s *AttachmentStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM ticket_attachments WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("attachment not found")
}
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package sqlite
import (
"database/sql"
"fmt"
)
// backfillTicketKeys assigns project codes and sequential ticket keys for rows
// that predate migration 005. Safe to run repeatedly (no-ops when already set).
func (db *DB) backfillTicketKeys() error {
if err := db.backfillProjectCodes(); err != nil {
return err
}
return db.backfillTicketNumbers()
}
func (db *DB) backfillProjectCodes() error {
rows, err := db.SQL.Query(`SELECT id, name, code FROM projects`)
if err != nil {
return fmt.Errorf("backfill codes: list: %w", err)
}
defer rows.Close()
type row struct {
id, name, code string
}
var all []row
used := map[string]bool{}
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.name, &r.code); err != nil {
return err
}
all = append(all, r)
if r.code != "" {
used[r.code] = true
}
}
if err := rows.Err(); err != nil {
return err
}
for _, r := range all {
if r.code != "" {
continue
}
code := nextUniqueCode(r.name, used)
used[code] = true
if _, err := db.SQL.Exec(`UPDATE projects SET code = ? WHERE id = ?`, code, r.id); err != nil {
return fmt.Errorf("backfill code %s: %w", r.id, err)
}
}
return nil
}
func (db *DB) backfillTicketNumbers() error {
type proj struct {
id, code string
seq int
}
var projects []proj
{
projRows, err := db.SQL.Query(`SELECT id, code, ticket_seq FROM projects`)
if err != nil {
return fmt.Errorf("backfill tickets: projects: %w", err)
}
for projRows.Next() {
var p proj
if err := projRows.Scan(&p.id, &p.code, &p.seq); err != nil {
projRows.Close()
return err
}
projects = append(projects, p)
}
err = projRows.Err()
projRows.Close()
if err != nil {
return err
}
}
for _, p := range projects {
type trow struct {
id, key string
number sql.NullInt64
}
var tickets []trow
{
tRows, err := db.SQL.Query(`
SELECT id, number, key FROM tickets
WHERE project_id = ?
ORDER BY created_at ASC, id ASC`, p.id)
if err != nil {
return err
}
for tRows.Next() {
var tr trow
if err := tRows.Scan(&tr.id, &tr.number, &tr.key); err != nil {
tRows.Close()
return err
}
tickets = append(tickets, tr)
}
err = tRows.Err()
tRows.Close()
if err != nil {
return err
}
}
seq := p.seq
for _, tr := range tickets {
if tr.number.Valid && tr.key != "" {
if int(tr.number.Int64) > seq {
seq = int(tr.number.Int64)
}
continue
}
seq++
newKey := fmt.Sprintf("%s-%d", p.code, seq)
if _, err := db.SQL.Exec(`UPDATE tickets SET number = ?, key = ? WHERE id = ?`, seq, newKey, tr.id); err != nil {
return fmt.Errorf("backfill ticket %s: %w", tr.id, err)
}
}
if seq != p.seq {
if _, err := db.SQL.Exec(`UPDATE projects SET ticket_seq = ? WHERE id = ?`, seq, p.id); err != nil {
return err
}
}
}
return nil
}
+4
View File
@@ -44,6 +44,10 @@ func Open(path string, secret []byte) (*DB, error) {
_ = sqlDB.Close()
return nil, err
}
if err := db.backfillTicketKeys(); err != nil {
_ = sqlDB.Close()
return nil, err
}
return db, nil
}
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS ticket_attachments (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON ticket_attachments(ticket_id);
@@ -0,0 +1,9 @@
ALTER TABLE projects ADD COLUMN code TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN ticket_seq INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tickets ADD COLUMN number INTEGER;
ALTER TABLE tickets ADD COLUMN key TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code) WHERE code != '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_key ON tickets(key) WHERE key != '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_project_number ON tickets(project_id, number) WHERE number IS NOT NULL;
+49
View File
@@ -0,0 +1,49 @@
package sqlite
import (
"fmt"
"strings"
"unicode"
)
// DeriveProjectCode builds a 3-letter uppercase code from a project name.
// Non-letters are skipped; short results are padded with X.
func DeriveProjectCode(name string) string {
var b strings.Builder
for _, r := range strings.ToUpper(name) {
if unicode.IsLetter(r) && r < unicode.MaxASCII {
b.WriteRune(r)
if b.Len() == 3 {
break
}
}
}
code := b.String()
for len(code) < 3 {
code += "X"
}
return code
}
// nextUniqueCode returns base or a nearby 3-char variant until unused.
// used maps existing codes (uppercase) → true.
func nextUniqueCode(base string, used map[string]bool) string {
base = DeriveProjectCode(base)
if !used[base] {
return base
}
prefix := base[:2]
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" {
cand := prefix + string(c)
if !used[cand] {
return cand
}
}
for i := 0; i < 1000; i++ {
cand := fmt.Sprintf("%c%02d", base[0], i)
if !used[cand] {
return cand
}
}
return fmt.Sprintf("X%02d", len(used)%100)
}
+32
View File
@@ -0,0 +1,32 @@
package sqlite
import "testing"
func TestDeriveProjectCode(t *testing.T) {
cases := []struct {
name, want string
}{
{"Alpha", "ALP"},
{"task-master", "TAS"},
{"AB", "ABX"},
{"12", "XXX"},
{"Büro Projekt", "BRO"}, // non-ASCII letters skipped
{"my project", "MYP"},
}
for _, tc := range cases {
if got := DeriveProjectCode(tc.name); got != tc.want {
t.Errorf("DeriveProjectCode(%q)=%q want %q", tc.name, got, tc.want)
}
}
}
func TestNextUniqueCode(t *testing.T) {
used := map[string]bool{"ALP": true, "ALA": true}
got := nextUniqueCode("Alpha", used)
if got == "ALP" || got == "ALA" {
t.Fatalf("expected unique code, got %q", got)
}
if len(got) != 3 {
t.Fatalf("len=%d code=%q", len(got), got)
}
}
+44 -10
View File
@@ -25,7 +25,7 @@ func NewProjectStore(db *DB) *ProjectStore {
func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
var p project.Project
var groupID sql.NullString
if err := scanner.Scan(&p.ID, &p.Name, &groupID); err != nil {
if err := scanner.Scan(&p.ID, &p.Name, &p.Code, &groupID); err != nil {
return project.Project{}, err
}
if groupID.Valid {
@@ -34,9 +34,11 @@ func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project,
return p, nil
}
const projectCols = `id, name, code, group_id`
// List returns all projects sorted by name.
func (s *ProjectStore) List() ([]project.Project, error) {
rows, err := s.db.SQL.Query(`SELECT id, name, group_id FROM projects ORDER BY name COLLATE NOCASE`)
rows, err := s.db.SQL.Query(`SELECT ` + projectCols + ` FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
@@ -55,19 +57,46 @@ func (s *ProjectStore) List() ([]project.Project, error) {
return out, rows.Err()
}
func (s *ProjectStore) usedCodes() (map[string]bool, error) {
rows, err := s.db.SQL.Query(`SELECT code FROM projects WHERE code != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
used := map[string]bool{}
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
used[c] = true
}
return used, rows.Err()
}
// Ensure creates the project if missing.
func (s *ProjectStore) Ensure(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("project id is required")
}
used, err := s.usedCodes()
if err != nil {
return err
}
code := nextUniqueCode(id, used)
now := formatTime(time.Now().UTC())
_, err := s.db.SQL.Exec(`
INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`, id, id, now, now)
_, err = s.db.SQL.Exec(`
INSERT INTO projects(id, name, code, created_at, updated_at) VALUES(?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`, id, id, code, now, now)
if err != nil {
return fmt.Errorf("ensure project %s: %w", id, err)
}
var existing string
_ = s.db.SQL.QueryRow(`SELECT code FROM projects WHERE id = ?`, id).Scan(&existing)
if existing == "" {
return s.db.backfillProjectCodes()
}
return nil
}
@@ -81,13 +110,18 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
if id == "" {
id = uuid.NewString()
}
used, err := s.usedCodes()
if err != nil {
return project.Project{}, err
}
code := nextUniqueCode(name, used)
now := formatTime(time.Now().UTC())
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`,
id, name, now, now)
_, err = s.db.SQL.Exec(`INSERT INTO projects(id, name, code, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, ?, NULL)`,
id, name, code, now, now)
if err != nil {
return project.Project{}, fmt.Errorf("create project: %w", err)
}
return project.Project{ID: id, Name: name}, nil
return project.Project{ID: id, Name: name, Code: code}, nil
}
// SetGroup assigns a project to a group. Empty groupID clears the assignment.
@@ -151,7 +185,7 @@ func (s *ProjectStore) Delete(id string) error {
// Get returns one project.
func (s *ProjectStore) Get(id string) (project.Project, error) {
p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id))
p, err := scanProject(s.db.SQL.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id))
if err == sql.ErrNoRows {
return project.Project{}, fmt.Errorf("project %s: not found", id)
}
@@ -291,7 +325,7 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
return s.List()
}
rows, err := s.db.SQL.Query(`
SELECT p.id, p.name, p.group_id FROM projects p
SELECT p.id, p.name, p.code, p.group_id FROM projects p
INNER JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ?
ORDER BY p.name COLLATE NOCASE`, userID)
+124
View File
@@ -0,0 +1,124 @@
package sqlite
import (
"path/filepath"
"testing"
"task/internal/ticket"
)
func TestTicketKeysSequential(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "keys.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
p, err := ps.Create("", "Task Master")
if err != nil {
t.Fatal(err)
}
if p.Code != "TAS" {
t.Fatalf("code=%q want TAS", p.Code)
}
ts := NewTicketStore(db)
a, err := ts.Create(ticket.Ticket{Title: "One", ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
b, err := ts.Create(ticket.Ticket{Title: "Two", ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
if a.Key != "TAS-1" || a.Number != 1 {
t.Fatalf("first=%+v", a)
}
if b.Key != "TAS-2" || b.Number != 2 {
t.Fatalf("second=%+v", b)
}
got, err := ts.Get(a.ID)
if err != nil || got.Key != "TAS-1" {
t.Fatalf("get=%+v err=%v", got, err)
}
// Update must preserve key.
got.Title = "One*"
if err := ts.Update(got); err != nil {
t.Fatal(err)
}
got, _ = ts.Get(a.ID)
if got.Key != "TAS-1" || got.Title != "One*" {
t.Fatalf("after update=%+v", got)
}
}
func TestTicketKeyBackfill(t *testing.T) {
path := filepath.Join(t.TempDir(), "backfill.db")
db, err := Open(path, []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
// Insert legacy-shaped rows as if pre-migration (columns exist after Open).
_, err = db.SQL.Exec(`INSERT INTO projects(id, name, code, ticket_seq, created_at, updated_at)
VALUES('p1', 'Inbox', '', 0, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')`)
if err != nil {
t.Fatal(err)
}
_, err = db.SQL.Exec(`INSERT INTO tickets(id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key)
VALUES('t1', 'Old', '', 'todo', 'p1', '2020-01-02T00:00:00Z', '2020-01-02T00:00:00Z', '', '', '', '', NULL, '')`)
if err != nil {
t.Fatal(err)
}
if err := db.backfillTicketKeys(); err != nil {
t.Fatal(err)
}
var code string
var seq int
if err := db.SQL.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id='p1'`).Scan(&code, &seq); err != nil {
t.Fatal(err)
}
if code != "INB" || seq != 1 {
t.Fatalf("project code=%q seq=%d", code, seq)
}
var key string
var num int
if err := db.SQL.QueryRow(`SELECT key, number FROM tickets WHERE id='t1'`).Scan(&key, &num); err != nil {
t.Fatal(err)
}
if key != "INB-1" || num != 1 {
t.Fatalf("ticket key=%q num=%d", key, num)
}
_ = db.Close()
}
func TestMoveReassignsKey(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "move.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
a, err := ps.Create("", "Alpha")
if err != nil {
t.Fatal(err)
}
b, err := ps.Create("", "Beta")
if err != nil {
t.Fatal(err)
}
ts := NewTicketStore(db)
tk, err := ts.Create(ticket.Ticket{Title: "Move me", ProjectID: a.ID})
if err != nil {
t.Fatal(err)
}
moved, err := ts.Move(tk.ID, b.ID)
if err != nil {
t.Fatal(err)
}
if moved.ProjectID != b.ID || moved.Key != b.Code+"-1" {
t.Fatalf("moved=%+v betaCode=%s", moved, b.Code)
}
}
+100 -14
View File
@@ -24,9 +24,11 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er
var t ticket.Ticket
var created, updated, synced string
var status string
var number sql.NullInt64
err := scanner.Scan(
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
&number, &t.Key,
)
if err != nil {
return ticket.Ticket{}, err
@@ -35,10 +37,13 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er
t.CreatedAt = parseTime(created)
t.UpdatedAt = parseTime(updated)
t.SyncedAt = parseTime(synced)
if number.Valid {
t.Number = int(number.Int64)
}
return t, nil
}
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key`
// List returns tickets for a project.
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
@@ -74,7 +79,7 @@ func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
return t, nil
}
// Create inserts a ticket.
// Create inserts a ticket and assigns the next project key atomically.
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
if t.ProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
@@ -95,24 +100,63 @@ func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
_, err := s.db.SQL.Exec(`
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
tx, err := s.db.SQL.Begin()
if err != nil {
return ticket.Ticket{}, err
}
defer func() { _ = tx.Rollback() }()
if t.Number == 0 || t.Key == "" {
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, t.ProjectID)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: bump seq: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s not found", t.ProjectID)
}
var code string
var seq int
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, t.ProjectID).Scan(&code, &seq); err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: read seq: %w", err)
}
if code == "" {
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s has no code", t.ProjectID)
}
t.Number = seq
t.Key = fmt.Sprintf("%s-%d", code, seq)
}
var number any
if t.Number > 0 {
number = t.Number
}
_, err = tx.Exec(`
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Title, t.Description, string(t.Status), t.ProjectID,
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
number, t.Key,
)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
}
if err := tx.Commit(); err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Update replaces a ticket and bumps UpdatedAt.
// Update replaces a ticket and bumps UpdatedAt. Number and Key are preserved from DB.
func (s *TicketStore) Update(t ticket.Ticket) error {
if t.ID == "" || t.ProjectID == "" {
return fmt.Errorf("id and projectId are required")
}
var created string
err := s.db.SQL.QueryRow(`SELECT created_at FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).Scan(&created)
var number sql.NullInt64
var key string
err := s.db.SQL.QueryRow(`SELECT created_at, number, key FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).
Scan(&created, &number, &key)
if err == sql.ErrNoRows {
return fmt.Errorf("ticket %s: not found", t.ID)
}
@@ -121,12 +165,16 @@ func (s *TicketStore) Update(t ticket.Ticket) error {
}
t.CreatedAt = parseTime(created)
t.UpdatedAt = time.Now().UTC()
if number.Valid {
t.Number = int(number.Int64)
}
t.Key = key
res, err := s.db.SQL.Exec(`
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
source=?, external_id=?, external_url=?, synced_at=?
source=?, external_id=?, external_url=?, synced_at=?, number=?, key=?
WHERE id=?`,
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), t.ID,
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), nullInt(t.Number), t.Key, t.ID,
)
if err != nil {
return err
@@ -138,6 +186,13 @@ func (s *TicketStore) Update(t ticket.Ticket) error {
return nil
}
func nullInt(n int) any {
if n == 0 {
return nil
}
return n
}
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
func (s *TicketStore) MarkSynced(id string) error {
if id == "" {
@@ -154,7 +209,7 @@ func (s *TicketStore) MarkSynced(id string) error {
return nil
}
// Move relocates a ticket to another project.
// Move relocates a ticket to another project and assigns a new key there.
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
if id == "" {
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
@@ -162,17 +217,48 @@ func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
if toProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
t, err := s.Get(id)
tx, err := s.db.SQL.Begin()
if err != nil {
return ticket.Ticket{}, err
}
t.ProjectID = toProjectID
t.UpdatedAt = time.Now().UTC()
_, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`,
toProjectID, formatTime(t.UpdatedAt), id)
defer func() { _ = tx.Rollback() }()
row := tx.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
t, err := scanTicket(row)
if err == sql.ErrNoRows {
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
if err != nil {
return ticket.Ticket{}, err
}
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, toProjectID)
if err != nil {
return ticket.Ticket{}, err
}
n, _ := res.RowsAffected()
if n == 0 {
return ticket.Ticket{}, fmt.Errorf("project %s: not found", toProjectID)
}
var code string
var seq int
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, toProjectID).Scan(&code, &seq); err != nil {
return ticket.Ticket{}, err
}
t.ProjectID = toProjectID
t.Number = seq
t.Key = fmt.Sprintf("%s-%d", code, seq)
t.UpdatedAt = time.Now().UTC()
_, err = tx.Exec(`UPDATE tickets SET project_id=?, updated_at=?, number=?, key=? WHERE id=?`,
toProjectID, formatTime(t.UpdatedAt), t.Number, t.Key, id)
if err != nil {
return ticket.Ticket{}, err
}
if err := tx.Commit(); err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
+15
View File
@@ -0,0 +1,15 @@
package ticket
import "testing"
func TestDisplayID(t *testing.T) {
if got := (Ticket{Key: "TAS-1", ExternalID: "uuid"}).DisplayID(); got != "TAS-1" {
t.Fatalf("got %q", got)
}
if got := (Ticket{ExternalID: "YT-9"}).DisplayID(); got != "YT-9" {
t.Fatalf("got %q", got)
}
if got := (Ticket{}).DisplayID(); got != "" {
t.Fatalf("got %q", got)
}
}
+60
View File
@@ -3,6 +3,7 @@ package ticket
import (
"fmt"
"sort"
"strings"
"time"
)
@@ -155,3 +156,62 @@ func TotalDuration(entries []TimeEntry) time.Duration {
}
return sum
}
// TimeFilter selects which time entries belong in a report.
// From/To are inclusive bounds on the entry's work instant (Start, else End).
// Zero times mean no bound on that side.
type TimeFilter struct {
From time.Time
To time.Time
TicketID string
}
// FilterEntries returns entries matching f. The input slice is not modified.
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
out := make([]TimeEntry, len(entries))
copy(out, entries)
return out
}
out := make([]TimeEntry, 0, len(entries))
for _, e := range entries {
if f.TicketID != "" && e.TicketID != f.TicketID {
continue
}
when := e.Start
if when.IsZero() {
when = e.End
}
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
continue
}
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
continue
}
out = append(out, e)
}
return out
}
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
func ParseReportDay(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, nil
}
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return time.Time{}, fmt.Errorf("invalid date %q", s)
}
return t, nil
}
// EndOfReportDay returns the last instant of the calendar day of t in local time.
func EndOfReportDay(t time.Time) time.Time {
if t.IsZero() {
return t
}
local := t.In(time.Local)
y, m, d := local.Date()
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
}
+45
View File
@@ -70,3 +70,48 @@ func TestAggregateByDay(t *testing.T) {
t.Fatalf("older day dur=%v", rows[1].Duration)
}
}
func TestFilterEntries(t *testing.T) {
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
d2 := time.Date(2026, 10, 20, 11, 0, 0, 0, time.Local)
entries := []ticket.TimeEntry{
{ID: "1", TicketID: "a", Start: d1, Duration: time.Hour},
{ID: "2", TicketID: "b", Start: d2, Duration: 30 * time.Minute},
{ID: "3", TicketID: "a", Start: d2, Duration: 15 * time.Minute},
}
byTicket := ticket.FilterEntries(entries, ticket.TimeFilter{TicketID: "a"})
if len(byTicket) != 2 {
t.Fatalf("ticket filter=%d", len(byTicket))
}
from := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
to := ticket.EndOfReportDay(time.Date(2026, 10, 20, 0, 0, 0, 0, time.Local))
byRange := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to})
if len(byRange) != 2 {
t.Fatalf("range filter=%d", len(byRange))
}
both := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to, TicketID: "a"})
if len(both) != 1 || both[0].ID != "3" {
t.Fatalf("combined=%+v", both)
}
}
func TestParseReportDay(t *testing.T) {
got, err := ticket.ParseReportDay("2026-10-18")
if err != nil {
t.Fatal(err)
}
y, m, d := got.Date()
if y != 2026 || m != 10 || d != 18 || got.Hour() != 0 {
t.Fatalf("got %v", got)
}
empty, err := ticket.ParseReportDay("")
if err != nil || !empty.IsZero() {
t.Fatalf("empty: %v %v", empty, err)
}
if _, err := ticket.ParseReportDay("nope"); err == nil {
t.Fatal("expected error")
}
}
+13
View File
@@ -68,9 +68,22 @@ type Ticket struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Readable key assigned by the server (e.g. "ABC-42"). Optional on local-only tickets.
Number int `json:"number,omitempty"`
Key string `json:"key,omitempty"`
// External sync (optional). Local UUID stays in ID.
Source string `json:"source,omitempty"`
ExternalID string `json:"externalId,omitempty"`
ExternalURL string `json:"externalUrl,omitempty"`
SyncedAt time.Time `json:"syncedAt,omitempty"`
}
// DisplayID returns the human-readable ticket label for UI.
// Prefers the server key; falls back to ExternalID (e.g. YouTrack idReadable).
func (t Ticket) DisplayID() string {
if t.Key != "" {
return t.Key
}
return t.ExternalID
}
+7 -1
View File
@@ -87,6 +87,8 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas
next.Status = rt.Status
next.Source = ticket.SourceTaskMaster
next.ExternalURL = extURL
next.Number = rt.Number
next.Key = rt.Key
if sameFields(prev, next) {
_ = s.Store.MarkSynced(prev.ID)
res.Skipped++
@@ -104,6 +106,7 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas
Title: rt.Title, Description: rt.Description, Status: rt.Status,
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
ExternalID: rt.ID, ExternalURL: extURL,
Number: rt.Number, Key: rt.Key,
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
})
if err != nil {
@@ -142,6 +145,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas
return res, err
}
t.ExternalID = created.ID
t.Number = created.Number
t.Key = created.Key
t.Source = ticket.SourceTaskMaster
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
if err := s.Store.Update(t); err != nil {
@@ -166,7 +171,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas
}
func sameFields(a, b ticket.Ticket) bool {
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status &&
a.Key == b.Key && a.Number == b.Number
}
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
+110 -44
View File
@@ -19,11 +19,23 @@ import (
"task/internal/ticket"
"task/internal/ui/views"
"task/internal/version"
"task/internal/tmsync"
"task/internal/youtrack"
"task/internal/ytsync"
)
// DaemonChangedMsg is sent when the daemon publishes a change event (SSE).
type DaemonChangedMsg struct {
ProjectID string
}
// RemoteOps is the optional daemon client surface for sync and MCP tokens.
type RemoteOps interface {
SyncPull(projectID string) (ytsync.Result, error)
SyncPush(projectID string) (ytsync.Result, error)
CreateMCPToken(projectID string) (token, createdAt string, err error)
RevokeMCPToken(projectID string) error
}
// timerTickMsg refreshes the header while a timer is running.
type timerTickMsg time.Time
@@ -88,6 +100,7 @@ type Model struct {
timeEntries views.TimeEntriesView
timeEntriesTicket string // ticket ID for ModeTimeEntries
editingTimeEntry ticket.TimeEntry
timeEdit views.TimeEditForm
activeTimer *ticket.ActiveTimer
spentByID map[string]time.Duration
width int
@@ -105,10 +118,11 @@ type Model struct {
projectEdit views.ProjectEditForm
projectEditMeta project.Meta // snapshot when opening the form
syncing bool
remote RemoteOps
}
// New creates the root UI model.
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model {
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor, remote RemoteOps) Model {
styles := DefaultStyles()
if exec == nil {
exec = command.NewHistory()
@@ -120,6 +134,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, timeSto
timeStore: timeStore,
projects: projects,
exec: exec,
remote: remote,
mode: ModeList,
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
reportGroupBy: ticket.GroupByProject,
@@ -166,6 +181,15 @@ func timerTickCmd() tea.Cmd {
// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case DaemonChangedMsg:
_ = m.reloadTickets()
if m.activeTimer != nil && m.timeStore != nil {
if t, err := m.timeStore.LoadActiveTimer(); err == nil {
m.activeTimer = t
}
}
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
@@ -207,8 +231,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch m.mode {
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
case ModeCreate, ModeSearch, ModeTimeManual:
return m.updateInput(msg)
case ModeTimeEdit:
return m.updateTimeEdit(msg)
case ModeEditProject:
return m.updateEditProject(msg)
case ModeProjects:
@@ -238,8 +264,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.mode {
case ModeProjects, ModeMoveTicket:
m.projectList, cmd = m.projectList.Update(msg)
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
case ModeCreate, ModeSearch, ModeTimeManual:
m.input, cmd = m.input.Update(msg)
case ModeTimeEdit:
cmd = m.timeEdit.Update(msg)
case ModeEditProject:
cmd = m.projectEdit.Update(msg)
}
@@ -585,8 +613,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case wasProject:
m.mode = ModeProjects
case m.mode == ModeTimeEdit:
m.mode = ModeTimeEntries
default:
m.mode = ModeList
}
@@ -608,16 +634,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Enter):
value := strings.TrimSpace(m.input.Value())
if m.mode == ModeTimeEdit {
if err := m.saveEditedTime(value); err != nil {
m.setError(err)
return m, nil
}
m.input.Blur()
m.mode = ModeTimeEntries
m.setStatus("time updated")
return m, nil
}
if m.mode == ModeTimeManual {
if err := m.addManualTime(value); err != nil {
m.setError(err)
@@ -708,10 +724,8 @@ func (m Model) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.editingTimeEntry = e
m.timeEdit = views.NewTimeEditForm(e, m.width)
m.mode = ModeTimeEdit
m.input = views.NewTimeManualInput()
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Undo):
@@ -929,6 +943,10 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
m.setStatus("sync already running…")
return m, nil
}
if m.remote == nil {
m.setError(fmt.Errorf("daemon client not available"))
return m, nil
}
m.clearStatus()
m.syncing = true
switch action {
@@ -937,33 +955,15 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
default:
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
}
store := m.store
timeStore := m.timeStore
projects := m.projects
remote := m.remote
return m, func() tea.Msg {
var pull, push ytsync.Result
var err error
meta, merr := projects.LoadMeta(projectID)
if merr != nil {
err = merr
} else {
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
switch action {
case views.SyncActionPush:
push, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
push, err = remote.SyncPush(projectID)
default:
pull, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
}
default:
switch action {
case views.SyncActionPush:
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
default:
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
}
}
pull, err = remote.SyncPull(projectID)
}
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
}
@@ -1040,6 +1040,41 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.openSyncMenu(item.Project.ID)
return m, nil
case key.Matches(msg, m.keys.MCPToken):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
if m.remote == nil {
m.setError(fmt.Errorf("daemon client not available"))
return m, nil
}
token, _, err := m.remote.CreateMCPToken(item.Project.ID)
if err != nil {
m.setError(err)
return m, nil
}
m.setStatus(fmt.Sprintf("MCP token for %s (save as .tm_token): project=%s token=%s", item.Project.ID, item.Project.ID, token))
return m, nil
case key.Matches(msg, m.keys.MCPTokenRevoke):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
if m.remote == nil {
m.setError(fmt.Errorf("daemon client not available"))
return m, nil
}
if err := m.remote.RevokeMCPToken(item.Project.ID); err != nil {
m.setError(err)
return m, nil
}
m.setStatus(fmt.Sprintf("revoked MCP token for %s", item.Project.ID))
return m, nil
case key.Matches(msg, m.keys.DeleteForce):
m.clearStatus()
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
@@ -1717,18 +1752,49 @@ func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error {
return nil
}
func (m *Model) saveEditedTime(raw string) error {
func (m Model) updateTimeEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeTimeEntries
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Tab), msg.Type == tea.KeyDown:
m.timeEdit.NextField()
return m, textinput.Blink
case key.Matches(msg, m.keys.ShiftTab), msg.Type == tea.KeyUp:
m.timeEdit.PrevField()
return m, textinput.Blink
case key.Matches(msg, m.keys.Enter):
m.clearStatus()
if err := m.saveEditedTime(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeTimeEntries
m.setStatus("time updated")
return m, nil
}
cmd := m.timeEdit.Update(msg)
return m, cmd
}
func (m *Model) saveEditedTime() error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")
}
dur, err := ticket.ParseDurationFlexible(raw)
start, end, err := m.timeEdit.Parse()
if err != nil {
return err
}
before := m.editingTimeEntry
after := before
after.Duration = dur
after.End = after.Start.Add(dur)
after.Start = start
after.End = end
after.Duration = end.Sub(start)
if err := m.exec.Do(&command.UpdateTimeEntry{
Store: m.timeStore,
Before: before,
+50 -31
View File
@@ -87,13 +87,14 @@ func (m Model) helpEntries() []helpEntry {
case ModeTimeEntries:
return []helpEntry{
{"j/k", "navigate"},
{"e", "edit"},
{"e", "edit times"},
{"x", "delete"},
{"u/U", "undo/redo"},
{"esc", "back"},
}
case ModeTimeEdit:
return []helpEntry{
{"tab/↑↓", "fields"},
{"enter", "save"},
{"esc", "cancel"},
}
@@ -151,42 +152,60 @@ func (m Model) helpSep() string {
return sep
}
// wrapHelpParts packs styled help snippets into lines that fit within width.
func wrapHelpParts(parts []string, width int, sep string) []string {
// padHelpCell pads a styled help snippet to cellW visible columns.
func padHelpCell(part string, cellW int) string {
pad := cellW - lipgloss.Width(part)
if pad < 0 {
pad = 0
}
return part + strings.Repeat(" ", pad)
}
// tableHelpParts lays out styled help snippets as a fixed-width column grid.
func tableHelpParts(parts []string, width int, sep string) []string {
if len(parts) == 0 {
return []string{""}
}
if width < 1 {
width = 1
}
sepW := lipgloss.Width(sep)
var lines []string
var cur string
cellW := 0
for _, p := range parts {
pw := lipgloss.Width(p)
if cur == "" {
cur = p
// Oversized single entry still gets its own line so nothing is dropped.
if pw > width {
lines = append(lines, cur)
cur = ""
}
continue
}
if lipgloss.Width(cur)+sepW+pw <= width {
cur = cur + sep + p
continue
}
lines = append(lines, cur)
cur = p
if pw > width {
lines = append(lines, cur)
cur = ""
if w := lipgloss.Width(p); w > cellW {
cellW = w
}
}
if cur != "" {
lines = append(lines, cur)
if cellW < 1 {
cellW = 1
}
if len(lines) == 0 {
return []string{""}
sepW := lipgloss.Width(sep)
cols := 1
if cellW <= width {
cols = (width + sepW) / (cellW + sepW)
if cols < 1 {
cols = 1
}
}
var lines []string
for i := 0; i < len(parts); i += cols {
end := i + cols
if end > len(parts) {
end = len(parts)
}
row := parts[i:end]
cells := make([]string, len(row))
for j, p := range row {
// Pad every cell except a lone oversized entry on its own row.
if len(row) == 1 && lipgloss.Width(p) > width {
cells[j] = p
} else {
cells[j] = padHelpCell(p, cellW)
}
}
lines = append(lines, strings.Join(cells, sep))
}
return lines
}
@@ -202,8 +221,8 @@ func (m Model) renderFooter() string {
sep := m.helpSep()
parts := m.helpParts()
// Wrap help across the full width so all bindings stay visible.
lines := wrapHelpParts(parts, width, sep)
// Table layout so key/desc columns stay aligned across rows.
lines := tableHelpParts(parts, width, sep)
// Place version on the last line to the right when it fits; otherwise
// put it on an extra right-aligned line.
+11 -1
View File
@@ -25,6 +25,8 @@ type KeyMap struct {
TimeEntries key.Binding // m — manage time entries
Report key.Binding // r — time report
Sync key.Binding // Y — pull YouTrack issues
MCPToken key.Binding // g — create MCP token for project
MCPTokenRevoke key.Binding // G — revoke MCP token
Undo key.Binding
Redo key.Binding
Quit key.Binding
@@ -121,6 +123,14 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("Y"),
key.WithHelp("Y", "YouTrack pull/push"),
),
MCPToken: key.NewBinding(
key.WithKeys("g"),
key.WithHelp("g", "MCP token"),
),
MCPTokenRevoke: key.NewBinding(
key.WithKeys("G"),
key.WithHelp("G", "revoke MCP token"),
),
Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"),
@@ -154,7 +164,7 @@ func DefaultKeyMap() KeyMap {
// ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.MCPToken, k.MCPTokenRevoke, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
}
// FullHelp implements help.KeyMap.
+1 -3
View File
@@ -60,9 +60,7 @@ func (m Model) renderBody(height int) string {
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeTimeEdit:
prompt := m.styles.InputPrompt.Render("Edit duration:")
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.timeEdit.View())
case ModeTimeEntries:
m.timeEntries.SetSize(m.width, height)
+2 -2
View File
@@ -177,8 +177,8 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
badge := l.statusBadge(t.Status)
ext := ""
if t.ExternalID != "" {
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
if id := t.DisplayID(); id != "" {
ext = lipgloss.NewStyle().Faint(true).Render(id) + " "
}
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
+154
View File
@@ -0,0 +1,154 @@
package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// Time edit field indices.
const (
FieldTimeStart = iota
FieldTimeEnd
TimeEditFieldCount
)
const timeEditLayout = "2006-01-02 15:04"
// TimeEditStyles styles the time-entry edit form.
type TimeEditStyles struct {
Label lipgloss.Style
Focused lipgloss.Style
Blurred lipgloss.Style
Hint lipgloss.Style
Title lipgloss.Style
}
// DefaultTimeEditStyles returns sensible defaults.
func DefaultTimeEditStyles() TimeEditStyles {
return TimeEditStyles{
Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")),
Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
Title: lipgloss.NewStyle().Bold(true),
}
}
// TimeEditForm edits start and end of a time entry.
type TimeEditForm struct {
Inputs []textinput.Model
Focus int
Styles TimeEditStyles
Width int
}
// NewTimeEditForm builds a form prefilled from an entry.
func NewTimeEditForm(e ticket.TimeEntry, width int) TimeEditForm {
styles := DefaultTimeEditStyles()
inputs := make([]textinput.Model, TimeEditFieldCount)
mk := func(placeholder string) textinput.Model {
ti := textinput.New()
ti.Placeholder = placeholder
ti.CharLimit = 16
ti.Width = max(20, width-10)
return ti
}
inputs[FieldTimeStart] = mk(timeEditLayout)
inputs[FieldTimeEnd] = mk(timeEditLayout)
if !e.Start.IsZero() {
inputs[FieldTimeStart].SetValue(e.Start.Local().Format(timeEditLayout))
}
if !e.End.IsZero() {
inputs[FieldTimeEnd].SetValue(e.End.Local().Format(timeEditLayout))
} else if !e.Start.IsZero() && e.Duration > 0 {
inputs[FieldTimeEnd].SetValue(e.Start.Add(e.Duration).Local().Format(timeEditLayout))
}
f := TimeEditForm{
Inputs: inputs,
Focus: FieldTimeStart,
Styles: styles,
Width: width,
}
f.focusIndex(FieldTimeStart)
return f
}
func (f *TimeEditForm) focusIndex(i int) {
f.Focus = i
for idx := range f.Inputs {
if idx == i {
f.Inputs[idx].Focus()
f.Inputs[idx].PromptStyle = f.Styles.Focused
f.Inputs[idx].TextStyle = f.Styles.Focused
} else {
f.Inputs[idx].Blur()
f.Inputs[idx].PromptStyle = f.Styles.Blurred
f.Inputs[idx].TextStyle = f.Styles.Blurred
}
}
}
// NextField moves focus to the next field.
func (f *TimeEditForm) NextField() {
f.focusIndex((f.Focus + 1) % TimeEditFieldCount)
}
// PrevField moves focus to the previous field.
func (f *TimeEditForm) PrevField() {
f.focusIndex((f.Focus - 1 + TimeEditFieldCount) % TimeEditFieldCount)
}
// Update forwards a message to the focused text input.
func (f *TimeEditForm) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
return cmd
}
// Parse returns start and end in UTC, or an error.
func (f TimeEditForm) Parse() (start, end time.Time, err error) {
startRaw := strings.TrimSpace(f.Inputs[FieldTimeStart].Value())
endRaw := strings.TrimSpace(f.Inputs[FieldTimeEnd].Value())
if startRaw == "" || endRaw == "" {
return time.Time{}, time.Time{}, fmt.Errorf("start and end are required")
}
start, err = time.ParseInLocation(timeEditLayout, startRaw, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("start: use %s", timeEditLayout)
}
end, err = time.ParseInLocation(timeEditLayout, endRaw, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("end: use %s", timeEditLayout)
}
if !end.After(start) {
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
}
return start.UTC(), end.UTC(), nil
}
// View renders the form.
func (f TimeEditForm) View() string {
var b strings.Builder
b.WriteString(f.Styles.Title.Render("Edit time entry"))
b.WriteString("\n\n")
labels := []string{"Start", "End"}
for i, label := range labels {
b.WriteString(f.Styles.Label.Render(label+":"))
b.WriteString(" ")
b.WriteString(f.Inputs[i].View())
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(f.Styles.Hint.Render("format " + timeEditLayout + " · tab fields · enter save · esc cancel"))
return b.String()
}
+34
View File
@@ -0,0 +1,34 @@
package views
import (
"testing"
"time"
"task/internal/ticket"
)
func TestTimeEditFormParse(t *testing.T) {
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
end := start.Add(90 * time.Minute)
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: end, Duration: 90 * time.Minute}, 80)
gotStart, gotEnd, err := f.Parse()
if err != nil {
t.Fatal(err)
}
if !gotStart.Equal(start.UTC()) {
t.Fatalf("start: got %v want %v", gotStart, start.UTC())
}
if !gotEnd.Equal(end.UTC()) {
t.Fatalf("end: got %v want %v", gotEnd, end.UTC())
}
}
func TestTimeEditFormRejectsBadRange(t *testing.T) {
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: start, Duration: time.Minute}, 80)
f.Inputs[FieldTimeEnd].SetValue(start.Format(timeEditLayout))
if _, _, err := f.Parse(); err == nil {
t.Fatal("expected error for end == start")
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ func (v TimeEntriesView) View() string {
title = "ticket"
}
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
hint := v.Styles.Hint.Render("j/k navigate · e edit start/end · x delete · esc back")
innerW := max(20, v.Width-2)
var lines []string
+146
View File
@@ -7,6 +7,12 @@
"": {
"name": "web",
"version": "0.0.0",
"dependencies": {
"marked": "^18.1.2",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
@@ -63,6 +69,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@oxc-project/types": {
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
@@ -403,6 +415,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -423,6 +444,19 @@
"node": ">= 0.4"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -433,6 +467,27 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -503,6 +558,15 @@
}
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -818,6 +882,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/marked": {
"version": "18.1.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.2.tgz",
"integrity": "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -944,6 +1020,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/svelte": {
"version": "5.57.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz",
@@ -998,6 +1086,25 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
@@ -1096,6 +1203,45 @@
}
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
+6
View File
@@ -12,5 +12,11 @@
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
"vite": "^8.3.5"
},
"dependencies": {
"marked": "^18.1.2",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"xlsx": "^0.18.5"
}
}
+532 -34
View File
@@ -1,6 +1,10 @@
<script>
import { onMount } from 'svelte'
import { api } from './lib/api.js'
import { parseRoute, buildHash, currentHash } from './lib/route.js'
import MarkdownEditor from './lib/MarkdownEditor.svelte'
import ExcelEditor from './lib/ExcelEditor.svelte'
import { isSpreadsheetName } from './lib/spreadsheet.js'
let user = $state(null)
let loading = $state(true)
@@ -22,11 +26,15 @@
let msg = $state('')
let report = $state(null)
let reportGroupBy = $state('ticket')
let reportFrom = $state('')
let reportTo = $state('')
let reportTicketId = $state('')
let dash = $state({ projects: [], openTickets: [] })
let tokens = $state([])
let newTokenSecret = $state('')
let renameName = $state('')
let editGroupId = $state('')
let applyingRoute = false
let loginUser = $state('')
let loginPass = $state('')
@@ -43,19 +51,113 @@
let memberUserId = $state('')
let memberRole = $state('member')
let timeMinutes = $state(30)
let editingTimeId = $state('')
let editTimeStart = $state('')
let editTimeEnd = $state('')
let tokenName = $state('tui')
let tokenScope = $state('write')
let dragTicketId = $state('')
let dragOverStatus = $state('')
let suppressTicketClick = false
let attachments = $state([])
let excelAtt = $state(null)
let attachBusy = $state(false)
const boardStatuses = [
['todo', 'OPEN'],
['in_progress', 'WIP'],
['done', 'DONE'],
]
function desiredHash() {
return buildHash({
topTab,
projectId,
projectView,
ticketId: selected?.id || '',
})
}
function routeMatches(r) {
if (r.topTab !== topTab) return false
if (r.topTab !== 'projects') return true
if (r.projectId !== projectId) return false
if (r.projectView === 'ticket') {
return projectView === 'ticket' && selected?.id === r.ticketId
}
return projectView === r.projectView
}
function syncRoute() {
if (applyingRoute || !user) return
const next = desiredHash()
if (currentHash() === next) return
location.hash = next
}
async function applyRoute() {
if (!user || applyingRoute) return
const r = parseRoute()
if (routeMatches(r) && !loading) return
applyingRoute = true
error = ''
try {
if (r.topTab === 'dashboard') {
await showDashboard()
} else if (r.topTab === 'users') {
if (user.isAdmin) await showAdmin()
else await showDashboard()
} else if (r.topTab === 'settings') {
showGlobalSettings()
} else if (r.topTab === 'projects') {
if (!r.projectId) {
await showProjects()
} else if (r.projectView === 'ticket' && r.ticketId) {
await showProject(r.projectId)
const t = tickets.find((x) => x.id === r.ticketId)
if (t) await showTicket(t)
else await showProject(r.projectId)
} else if (r.projectView === 'report') {
await showProject(r.projectId)
await showReport()
} else if (r.projectView === 'members') {
await showProject(r.projectId)
await showMembers()
} else if (r.projectView === 'settings') {
await showProject(r.projectId)
await showProjectSettings()
} else {
await showProject(r.projectId)
}
} else {
await showDashboard()
}
} catch (e) {
error = e.message || String(e)
await showDashboard()
} finally {
const next = desiredHash()
if (currentHash() !== next) history.replaceState(null, '', next)
applyingRoute = false
}
}
onMount(async () => {
const onHash = () => {
if (!user) return
applyRoute()
}
window.addEventListener('hashchange', onHash)
try {
user = await api.me()
await openDashboard()
await applyRoute()
await refreshTimer()
} catch {
user = null
} finally {
loading = false
}
return () => window.removeEventListener('hashchange', onHash)
})
function formatDuration(ns) {
@@ -67,11 +169,86 @@
return `${m}m`
}
function toLocalInput(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function refreshTicketTime() {
if (!selected) return
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === selected.id)
}
function beginEditTime(e) {
editingTimeId = e.id
editTimeStart = toLocalInput(e.start)
editTimeEnd = toLocalInput(e.end || (e.start && e.duration
? new Date(new Date(e.start).getTime() + Number(e.duration)).toISOString()
: ''))
}
function cancelEditTime() {
editingTimeId = ''
editTimeStart = ''
editTimeEnd = ''
}
async function saveTimeEntry() {
if (!projectId || !editingTimeId) return
if (!editTimeStart || !editTimeEnd) {
error = 'Start and end are required'
return
}
const start = new Date(editTimeStart)
const end = new Date(editTimeEnd)
if (!(end > start)) {
error = 'End must be after start'
return
}
error = ''
await api.patchTime(projectId, editingTimeId, {
start: start.toISOString(),
end: end.toISOString(),
})
cancelEditTime()
if (projectView === 'report') await loadReport()
else await refreshTicketTime()
msg = 'Time updated'
}
async function removeTimeEntry(eid) {
if (!confirm('Delete time entry?')) return
await api.deleteTime(projectId, eid)
if (editingTimeId === eid) cancelEditTime()
if (projectView === 'report') await loadReport()
else await refreshTicketTime()
msg = 'Time deleted'
}
function ticketTitle(tid) {
const t = tickets.find((x) => x.id === tid)
return t?.title || tid || '—'
}
function reportQuery() {
return {
groupBy: reportGroupBy,
from: reportFrom,
to: reportTo,
ticketId: reportTicketId,
}
}
async function doLogin() {
error = ''
try {
user = await api.login(loginUser, loginPass)
await openDashboard()
await applyRoute()
await refreshTimer()
} catch (e) {
error = e.message
}
@@ -82,6 +259,10 @@
user = null
projects = []
groups = []
selected = null
projectId = ''
projectView = ''
topTab = 'dashboard'
}
async function refreshProjects() {
@@ -127,7 +308,7 @@
timer = data.timer
}
async function openDashboard() {
async function showDashboard() {
topTab = 'dashboard'
projectId = ''
projectView = ''
@@ -136,16 +317,30 @@
await refreshProjects()
}
async function openProjects() {
async function openDashboard() {
await showDashboard()
syncRoute()
}
async function showProjects() {
topTab = 'projects'
projectId = ''
projectView = ''
selected = null
error = ''
msg = ''
await refreshProjects()
}
async function openProject(id, name = '') {
async function openProjects() {
await showProjects()
syncRoute()
}
async function showProject(id, name = '') {
topTab = 'projects'
projectId = id
if (!projects.length) await refreshProjects()
projectName = name || projects.find((p) => p.id === id)?.name || id
projectView = 'board'
selected = null
@@ -153,6 +348,11 @@
tickets = await api.listTickets(id)
}
async function openProject(id, name = '') {
await showProject(id, name)
syncRoute()
}
async function createProject() {
error = ''
const name = newProjectName.trim()
@@ -177,19 +377,63 @@
tickets = await api.listTickets(projectId)
}
async function openTicket(t) {
async function showTicket(t) {
selected = t
editTitle = t.title
editDesc = t.description || ''
editStatus = t.status
excelAtt = null
cancelEditTime()
revisions = await api.listRevisions(projectId, t.id)
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id)
attachments = await api.listAttachments(projectId, t.id)
await refreshTimer()
topTab = 'projects'
projectView = 'ticket'
}
function formatBytes(n) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
async function onAttachFile(e) {
const file = e.target?.files?.[0]
e.target.value = ''
if (!file || !selected) return
attachBusy = true
error = ''
try {
await api.uploadAttachment(projectId, selected.id, file)
attachments = await api.listAttachments(projectId, selected.id)
msg = 'Uploaded'
} catch (err) {
error = err.message
} finally {
attachBusy = false
}
}
async function removeAttachment(a) {
if (!selected || !confirm(`Delete ${a.filename}?`)) return
await api.deleteAttachment(projectId, selected.id, a.id)
if (excelAtt?.id === a.id) excelAtt = null
attachments = await api.listAttachments(projectId, selected.id)
msg = 'Attachment deleted'
}
async function refreshAttachments() {
if (!selected) return
attachments = await api.listAttachments(projectId, selected.id)
}
async function openTicket(t) {
await showTicket(t)
syncRoute()
}
async function saveTicket() {
selected = await api.patchTicket(projectId, selected.id, {
title: editTitle,
@@ -207,6 +451,7 @@
selected = null
projectView = 'board'
tickets = await api.listTickets(projectId)
syncRoute()
}
async function restoreRev(rid) {
@@ -226,8 +471,7 @@
durationNs: timeMinutes * 60 * 1e9,
source: 'manual',
})
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === selected.id)
await refreshTicketTime()
msg = `Booked ${timeMinutes}m`
}
@@ -239,23 +483,51 @@
await api.startTimer(selected.id, projectId)
}
await refreshTimer()
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === selected.id)
await refreshTicketTime()
}
async function openReport() {
async function loadReport() {
report = await api.report(projectId, reportQuery())
}
async function showReport() {
topTab = 'projects'
projectView = 'report'
error = ''
report = await api.report(projectId, reportGroupBy)
await loadReport()
}
async function openReport() {
await showReport()
syncRoute()
}
async function changeReportGroup(g) {
reportGroupBy = g
report = await api.report(projectId, reportGroupBy)
await loadReport()
}
async function openProjectSettings() {
async function applyReportFilters() {
error = ''
try {
await loadReport()
} catch (e) {
error = e.message || String(e)
}
}
function clearReportFilters() {
reportFrom = ''
reportTo = ''
reportTicketId = ''
}
async function resetReportFilters() {
clearReportFilters()
await applyReportFilters()
}
async function showProjectSettings() {
topTab = 'projects'
projectView = 'settings'
renameName = projectName
@@ -265,6 +537,11 @@
await refreshProjects()
}
async function openProjectSettings() {
await showProjectSettings()
syncRoute()
}
async function saveProjectGroup() {
const p = await api.patchProject(projectId, { groupId: editGroupId })
msg = 'Gruppe gespeichert'
@@ -272,12 +549,20 @@
if (p.groupId !== undefined) editGroupId = p.groupId || ''
}
function openGlobalSettings() {
function showGlobalSettings() {
topTab = 'settings'
projectId = ''
projectView = ''
selected = null
error = ''
msg = ''
}
function openGlobalSettings() {
showGlobalSettings()
syncRoute()
}
async function saveRename() {
const name = renameName.trim()
if (!name) return
@@ -299,13 +584,21 @@
tokens = await api.listTokens(projectId)
}
async function openAdmin() {
async function showAdmin() {
topTab = 'users'
projectId = ''
projectView = ''
selected = null
error = ''
msg = ''
users = await api.listUsers()
}
async function openAdmin() {
await showAdmin()
syncRoute()
}
async function createUser() {
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
newUserName = ''
@@ -330,13 +623,18 @@
users = await api.listUsers()
}
async function openMembers() {
async function showMembers() {
topTab = 'projects'
projectView = 'members'
members = await api.listMembers(projectId)
users = await api.listUsers()
}
async function openMembers() {
await showMembers()
syncRoute()
}
async function addMember() {
await api.setMember(projectId, memberUserId, memberRole)
members = await api.listMembers(projectId)
@@ -346,6 +644,63 @@
return tickets.filter((t) => t.status === status)
}
async function setTicketStatus(ticket, status) {
if (!ticket || ticket.status === status) return
error = ''
const prev = ticket.status
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status } : t))
try {
await api.patchTicket(projectId, ticket.id, { status })
if (selected?.id === ticket.id) {
selected = { ...selected, status }
editStatus = status
}
} catch (e) {
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status: prev } : t))
error = e.message
}
}
function onTicketDragStart(e, ticket) {
dragTicketId = ticket.id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', ticket.id)
}
function onTicketDragEnd() {
dragTicketId = ''
dragOverStatus = ''
// Click fires after dragend on some browsers; suppress open-once.
suppressTicketClick = true
setTimeout(() => {
suppressTicketClick = false
}, 0)
}
function onColumnDragOver(e, status) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
dragOverStatus = status
}
function onColumnDragLeave(e, status) {
if (dragOverStatus === status) dragOverStatus = ''
}
async function onColumnDrop(e, status) {
e.preventDefault()
const id = e.dataTransfer.getData('text/plain') || dragTicketId
dragTicketId = ''
dragOverStatus = ''
const ticket = tickets.find((t) => t.id === id)
if (ticket) await setTicketStatus(ticket, status)
}
function onBoardTicketClick(t) {
if (suppressTicketClick) return
openTicket(t)
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text)
@@ -385,21 +740,23 @@
{:else}
<div class="app-shell">
<header class="topbar">
<div class="brand">task-master</div>
<div class="topbar-start">
<button type="button" class="brand" onclick={openDashboard}>task-master</button>
<nav class="nav">
<button class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
<button class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
<button type="button" class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
<button type="button" class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
{#if user.isAdmin}
<button class:active={topTab === 'users'} onclick={openAdmin}>User</button>
<button type="button" class:active={topTab === 'users'} onclick={openAdmin}>User</button>
{/if}
<button class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
<button type="button" class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
</nav>
<div class="row">
</div>
<div class="topbar-meta row">
{#if timer}
<span class="muted">⏱ {timer.ticketId.slice(0, 8)}…</span>
{/if}
<span class="muted">{user.username}{user.isAdmin ? ' (admin)' : ''}</span>
<button onclick={doLogout}>Logout</button>
<button type="button" onclick={doLogout}>Logout</button>
</div>
</header>
@@ -528,15 +885,34 @@
</div>
</div>
<div class="grid cols-3">
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
<div class="stack">
{#each boardStatuses as [st, label]}
<div
class="board-col stack"
class:drag-over={dragOverStatus === st}
ondragover={(e) => onColumnDragOver(e, st)}
ondragleave={(e) => onColumnDragLeave(e, st)}
ondrop={(e) => onColumnDrop(e, st)}
role="list"
aria-label={label}
>
<h3>{label}</h3>
{#each byStatus(st) as t}
<button class="ticket" onclick={() => openTicket(t)}>
<span class="badge {t.status}">{label}</span>
<div
class="ticket"
class:dragging={dragTicketId === t.id}
draggable="true"
role="listitem"
ondragstart={(e) => onTicketDragStart(e, t)}
ondragend={onTicketDragEnd}
>
<button type="button" class="ticket-open" onclick={() => onBoardTicketClick(t)}>
<div>{t.title}</div>
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
{#if t.key || t.externalId}<div class="muted">{t.key || t.externalId}</div>{/if}
</button>
<span class="drag-handle" title="Ziehen" aria-hidden="true">⠿</span>
</div>
{:else}
<div class="board-empty muted">Drop hier</div>
{/each}
</div>
{/each}
@@ -545,9 +921,12 @@
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
<section class="panel stack">
<div class="row">
<button onclick={() => (projectView = 'board')}>← Board</button>
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
<button class="danger" onclick={removeTicket}>Delete</button>
</div>
{#if selected.key || selected.externalId}
<div class="muted">{selected.key || selected.externalId}</div>
{/if}
<label>Title<input bind:value={editTitle} /></label>
<label>Status
<select bind:value={editStatus}>
@@ -556,9 +935,50 @@
<option value="done">DONE</option>
</select>
</label>
<label>Description<textarea bind:value={editDesc}></textarea></label>
<MarkdownEditor bind:value={editDesc} label="Description" />
<button class="primary" onclick={saveTicket}>Save</button>
<h3>Attachments</h3>
<div class="row" style="flex-wrap: wrap; gap: 0.5rem; align-items: center;">
<label class="attach-upload">
<span class="buttonish">{attachBusy ? 'Uploading…' : 'Upload file'}</span>
<input type="file" disabled={attachBusy} onchange={onAttachFile} />
</label>
<span class="muted">Max 10 MiB</span>
</div>
{#if excelAtt}
<ExcelEditor
projectId={projectId}
ticketId={selected.id}
attachment={excelAtt}
onclose={() => (excelAtt = null)}
onsaved={refreshAttachments}
/>
{/if}
<ul class="attach-list">
{#each attachments as a}
<li class="attach-item">
<div class="attach-meta">
<span class="attach-name">{a.filename}</span>
<span class="muted">{formatBytes(a.sizeBytes)}</span>
</div>
<div class="row" style="width: auto; justify-content: flex-end;">
{#if isSpreadsheetName(a.filename)}
<button class="primary" onclick={() => (excelAtt = a)}>Edit</button>
{/if}
<a
class="buttonish"
href={api.downloadAttachmentUrl(projectId, selected.id, a.id)}
download={a.filename}
>Download</a>
<button class="danger" onclick={() => removeAttachment(a)}>Delete</button>
</div>
</li>
{:else}
<li class="muted">No attachments yet.</li>
{/each}
</ul>
<h3>Time</h3>
<div class="row">
<button class="primary" onclick={toggleTimer}>
@@ -570,16 +990,37 @@
<button onclick={bookTime}>Book</button>
</div>
<table>
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
<thead><tr><th>When</th><th>Duration</th><th>Source</th><th></th></tr></thead>
<tbody>
{#each ticketTime as e}
{#if editingTimeId === e.id}
<tr>
<td colspan="4">
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
<label class="row" style="width: auto;">Start
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
</label>
<label class="row" style="width: auto;">End
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
</label>
<button class="primary" onclick={saveTimeEntry}>Save</button>
<button onclick={cancelEditTime}>Cancel</button>
</div>
</td>
</tr>
{:else}
<tr>
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
<td>{formatDuration(e.duration)}</td>
<td>{e.source}</td>
<td class="row" style="width: auto; justify-content: flex-end;">
<button onclick={() => beginEditTime(e)}>Edit</button>
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
</td>
</tr>
{/if}
{:else}
<tr><td colspan="3" class="muted">No time yet.</td></tr>
<tr><td colspan="4" class="muted">No time yet.</td></tr>
{/each}
</tbody>
</table>
@@ -601,7 +1042,7 @@
</section>
{:else if topTab === 'projects' && projectView === 'report'}
<section class="panel stack">
<div class="row" style="justify-content: space-between;">
<div class="row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.75rem;">
<h2>Report — {projectName}</h2>
<div class="row">
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
@@ -609,8 +1050,27 @@
{/each}
</div>
</div>
<div class="row" style="flex-wrap: wrap; gap: 0.75rem; align-items: flex-end;">
<label style="width: auto;">From
<input type="date" bind:value={reportFrom} style="width: auto;" />
</label>
<label style="width: auto;">To
<input type="date" bind:value={reportTo} style="width: auto;" />
</label>
<label style="width: auto; flex: 1; min-width: 12rem;">Ticket
<select bind:value={reportTicketId}>
<option value="">All tickets</option>
{#each tickets as t}
<option value={t.id}>{t.title}</option>
{/each}
</select>
</label>
<button class="primary" onclick={applyReportFilters}>Filter</button>
<button onclick={resetReportFilters}>Reset</button>
</div>
{#if report}
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
<h3>Summary</h3>
<table>
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
<tbody>
@@ -621,6 +1081,44 @@
{/each}
</tbody>
</table>
<h3>Entries</h3>
<table>
<thead><tr><th>When</th><th>Ticket</th><th>Duration</th><th>Source</th><th>Note</th><th></th></tr></thead>
<tbody>
{#each report.entries || [] as e}
{#if editingTimeId === e.id}
<tr>
<td colspan="6">
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
<label class="row" style="width: auto;">Start
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
</label>
<label class="row" style="width: auto;">End
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
</label>
<button class="primary" onclick={saveTimeEntry}>Save</button>
<button onclick={cancelEditTime}>Cancel</button>
</div>
</td>
</tr>
{:else}
<tr>
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
<td>{ticketTitle(e.ticketId)}</td>
<td>{formatDuration(e.duration)}</td>
<td>{e.source}</td>
<td class="muted">{e.note || ''}</td>
<td class="row" style="width: auto; justify-content: flex-end;">
<button onclick={() => beginEditTime(e)}>Edit</button>
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
</td>
</tr>
{/if}
{:else}
<tr><td colspan="6" class="muted">No entries for this filter.</td></tr>
{/each}
</tbody>
</table>
{/if}
</section>
{:else if topTab === 'projects' && projectView === 'settings'}
+474 -38
View File
@@ -30,24 +30,66 @@ button, input, select, textarea {
}
button {
cursor: pointer;
border: 1px solid var(--line);
background: var(--panel-2);
border-radius: 6px;
padding: 0.4rem 0.75rem;
color: var(--ink);
}
button:hover { border-color: #3d4a58; }
button.primary {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
border: none;
border-radius: 0;
background: transparent;
padding: 0.45rem 0.85rem;
color: var(--muted);
font-weight: 600;
}
button.primary:hover { filter: brightness(1.08); }
button:hover {
border: none;
color: var(--ink);
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button:disabled:hover {
color: var(--muted);
background: transparent;
}
button.primary {
border: none;
color: var(--ink);
font-weight: 600;
background: linear-gradient(
to right,
color-mix(in srgb, var(--accent) 65%, transparent) 0%,
color-mix(in srgb, var(--accent) 22%, transparent) 55%,
color-mix(in srgb, var(--accent) 8%, transparent) 100%
);
}
button.primary:hover {
border: none;
color: var(--ink);
background: linear-gradient(
to right,
color-mix(in srgb, var(--accent) 80%, transparent) 0%,
color-mix(in srgb, var(--accent) 32%, transparent) 55%,
color-mix(in srgb, var(--accent) 12%, transparent) 100%
);
}
button.danger {
border: none;
color: var(--danger);
border-color: #5a3030;
background: #241818;
background: linear-gradient(
to right,
color-mix(in srgb, var(--danger) 35%, transparent) 0%,
color-mix(in srgb, var(--danger) 10%, transparent) 55%,
transparent 100%
);
}
button.danger:hover {
border: none;
color: #f0a0a0;
background: linear-gradient(
to right,
color-mix(in srgb, var(--danger) 50%, transparent) 0%,
color-mix(in srgb, var(--danger) 16%, transparent) 55%,
transparent 100%
);
}
input, select, textarea {
width: 100%;
@@ -67,6 +109,13 @@ code {
padding: 0.05em 0.35em;
font-size: 0.9em;
}
pre code {
background: none;
border: none;
border-radius: 0;
padding: 0;
font-size: inherit;
}
.app-shell {
width: 100%;
@@ -75,30 +124,86 @@ code {
padding: 0;
}
.topbar {
--topbar-height: 3.25rem;
position: sticky;
top: 0;
z-index: 100;
display: flex;
gap: 1rem;
align-items: center;
align-items: stretch;
justify-content: space-between;
width: 100%;
height: var(--topbar-height);
margin: 0 0 1.25rem;
padding: 0.85rem 1.25rem;
/* Same inset as .app-body — nav tabs start on the sidebar’s left edge. */
padding: 0 1.25rem;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
}
.topbar-start {
display: flex;
align-items: stretch;
min-width: 0;
}
.brand {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: 100%;
margin: 0;
padding: 0 1.1rem 0 0;
border: none;
border-radius: 0;
background: transparent;
font-weight: 700;
letter-spacing: 0.04em;
color: #c9d6e0;
text-align: left;
}
.brand:hover {
border: none;
color: #f0f4f8;
background: transparent;
}
.nav {
display: flex;
align-items: stretch;
gap: 0;
height: 100%;
flex-wrap: nowrap;
}
.nav button {
height: 100%;
margin: 0;
padding: 0 1.15rem;
border: none;
border-radius: 0;
background: transparent;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.02em;
}
.nav button:hover {
border: none;
color: var(--ink);
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
}
.nav { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.nav button.active {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
border: none;
color: var(--ink);
background: linear-gradient(
to top,
color-mix(in srgb, var(--accent) 65%, transparent) 0%,
color-mix(in srgb, var(--accent) 18%, transparent) 42%,
transparent 100%
);
}
.topbar-meta {
display: flex;
align-items: center;
align-self: center;
flex: 0 0 auto;
padding-left: 1rem;
}
.app-body {
display: grid;
@@ -150,15 +255,16 @@ code {
width: 100%;
text-align: left;
background: transparent;
border-color: transparent;
padding: 0.3rem 0.5rem;
border: none;
border-radius: 0;
padding: 0.45rem 0.65rem;
color: var(--muted);
font-weight: 600;
font-size: 0.86rem;
}
.sidebar-group:hover {
background: var(--panel-2);
border-color: var(--line);
border: none;
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
color: var(--ink);
}
.sidebar-twist {
@@ -198,17 +304,27 @@ code {
width: 100%;
text-align: left;
background: transparent;
border-color: transparent;
border: none;
border-radius: 0;
padding: 0.45rem 0.65rem;
color: var(--muted);
font-weight: 600;
}
.sidebar-item:hover {
background: var(--panel-2);
border-color: var(--line);
border: none;
color: var(--ink);
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
}
.sidebar-item.active {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
border: none;
color: var(--ink);
font-weight: 600;
background: linear-gradient(
to right,
color-mix(in srgb, var(--accent) 65%, transparent) 0%,
color-mix(in srgb, var(--accent) 18%, transparent) 42%,
transparent 100%
);
}
.sidebar-create {
flex: 0 0 auto;
@@ -241,18 +357,71 @@ code {
.error { color: var(--danger); margin: 0.5rem 0; }
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.stack { display: grid; gap: 0.6rem; }
.ticket {
border: 1px solid var(--line);
.board-col {
min-height: 8rem;
padding: 0.55rem;
border-radius: 8px;
padding: 0.65rem 0.75rem;
background: var(--panel-2);
cursor: pointer;
border: 1px dashed transparent;
transition: border-color 0.15s ease, background 0.15s ease;
align-content: start;
}
.board-col.drag-over {
border-color: color-mix(in srgb, var(--accent) 70%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.board-empty {
padding: 0.75rem 0.4rem;
font-size: 0.85rem;
text-align: center;
border: 1px dashed var(--line);
border-radius: 6px;
}
.ticket {
border: none;
border-radius: 6px;
padding: 0.55rem 0.7rem 0.4rem;
background: color-mix(in srgb, var(--panel-2) 55%, transparent);
cursor: grab;
text-align: left;
width: 100%;
color: var(--ink);
font-weight: 500;
display: grid;
gap: 0.25rem;
}
.ticket:hover {
border-color: var(--accent);
background: #222b34;
background: linear-gradient(
to right,
color-mix(in srgb, var(--accent) 35%, transparent) 0%,
color-mix(in srgb, var(--accent) 10%, transparent) 45%,
color-mix(in srgb, var(--panel-2) 70%, transparent) 100%
);
}
.ticket.dragging {
opacity: 0.45;
cursor: grabbing;
}
.ticket-open {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.05rem 0;
text-align: left;
color: inherit;
font-weight: inherit;
background: transparent;
}
.ticket-open:hover {
color: inherit;
background: transparent;
}
.drag-handle {
justify-self: end;
color: var(--muted);
font-size: 0.95rem;
letter-spacing: -0.08em;
line-height: 1;
user-select: none;
}
.badge {
display: inline-block;
@@ -283,3 +452,270 @@ td, th {
th { color: var(--muted); font-weight: 600; }
label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; }
label > :is(input, select, textarea) { color: var(--ink); }
.md-editor {
display: grid;
gap: 0.35rem;
}
.md-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.md-editor-label {
color: var(--muted);
font-size: 0.9rem;
}
.md-editor-modes {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
background: #10151a;
}
.md-editor-modes button {
padding: 0.3rem 0.75rem;
border-radius: 0;
font-size: 0.82rem;
color: var(--muted);
background: transparent;
}
.md-editor-modes button:hover {
color: var(--ink);
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
}
.md-editor-modes button.active {
color: var(--ink);
background: color-mix(in srgb, var(--accent) 28%, transparent);
}
.md-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
padding: 0.25rem;
border: 1px solid var(--line);
border-bottom: none;
border-radius: 6px 6px 0 0;
background: var(--panel-2);
}
.md-toolbar button {
padding: 0.3rem 0.55rem;
font-size: 0.82rem;
border-radius: 4px;
color: var(--muted);
}
.md-toolbar button:hover {
color: var(--ink);
background: color-mix(in srgb, var(--panel) 80%, transparent);
}
.md-visual,
.md-source {
min-height: 14rem;
border: 1px solid var(--line);
border-radius: 0 0 6px 6px;
padding: 0.75rem 0.85rem;
background: #10151a;
color: var(--ink);
outline: none;
}
.md-source {
border-radius: 6px;
resize: vertical;
font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace;
font-size: 0.9rem;
line-height: 1.5;
}
.md-visual {
overflow-y: auto;
line-height: 1.55;
}
.md-visual:focus,
.md-source:focus {
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
}
.md-visual :first-child { margin-top: 0; }
.md-visual :last-child { margin-bottom: 0; }
.md-visual h1,
.md-visual h2,
.md-visual h3 {
margin: 0.85em 0 0.4em;
font-weight: 650;
color: #f0f4f8;
}
.md-visual h1 { font-size: 1.35rem; }
.md-visual h2 { font-size: 1.15rem; }
.md-visual h3 { font-size: 1.05rem; }
.md-visual p { margin: 0.5em 0; }
.md-visual ul,
.md-visual ol {
margin: 0.5em 0;
padding-left: 1.4rem;
}
.md-visual li { margin: 0.2em 0; }
.md-visual li > input[type="checkbox"] {
width: auto;
margin-right: 0.4rem;
accent-color: var(--accent);
vertical-align: middle;
}
.md-visual a { color: var(--accent); }
.md-visual pre,
.md-visual code {
font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace;
font-size: 0.88em;
}
.md-visual pre {
margin: 0.6em 0;
padding: 0.65rem 0.75rem;
border: 1px solid var(--line);
border-radius: 4px;
background: #0c1014;
overflow-x: auto;
white-space: pre-wrap;
}
.md-visual pre code {
background: none;
border: none;
padding: 0;
font-size: inherit;
color: inherit;
}
.md-visual blockquote {
margin: 0.6em 0;
padding: 0.15rem 0 0.15rem 0.75rem;
border-left: 3px solid color-mix(in srgb, var(--accent) 50%, var(--line));
color: var(--muted);
}
.md-visual hr {
border: none;
border-top: 1px solid var(--line);
margin: 1em 0;
}
.attach-upload {
position: relative;
display: inline-block;
width: auto;
cursor: pointer;
}
.attach-upload input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.buttonish {
display: inline-flex;
align-items: center;
padding: 0.45rem 0.85rem;
color: var(--muted);
font-weight: 600;
text-decoration: none;
cursor: pointer;
background: transparent;
border: none;
border-radius: 0;
}
.buttonish:hover {
color: var(--ink);
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
}
.attach-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.attach-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.45rem 0.55rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #10151a;
}
.attach-meta {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.attach-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.excel-editor {
display: grid;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
}
.excel-sheets {
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
}
.excel-sheets button {
padding: 0.3rem 0.7rem;
font-size: 0.82rem;
border-radius: 4px 4px 0 0;
}
.excel-sheets button.active {
color: var(--ink);
background: color-mix(in srgb, var(--accent) 28%, transparent);
}
.excel-scroll {
overflow: auto;
max-height: min(60vh, 28rem);
border: 1px solid var(--line);
border-radius: 4px;
background: #0c1014;
}
.excel-grid {
border-collapse: collapse;
width: max-content;
min-width: 100%;
font-size: 0.85rem;
}
.excel-grid th,
.excel-grid td {
border: 1px solid var(--line);
padding: 0;
vertical-align: middle;
}
.excel-rownum {
width: 2.5rem;
text-align: center;
color: var(--muted);
background: var(--panel-2);
font-weight: 600;
user-select: none;
}
.excel-grid td input {
width: 7.5rem;
min-width: 4rem;
border: none;
border-radius: 0;
padding: 0.35rem 0.45rem;
background: transparent;
color: var(--ink);
}
.excel-grid td input:focus {
outline: 1px solid color-mix(in srgb, var(--accent) 55%, var(--line));
background: #10151a;
}
+170
View File
@@ -0,0 +1,170 @@
<script>
import { api } from './api.js'
import { spreadsheetWriteOptions } from './spreadsheet.js'
let {
projectId,
ticketId,
attachment,
onclose = () => {},
onsaved = () => {},
} = $props()
let loading = $state(true)
let saving = $state(false)
let error = $state('')
let workbook = $state(null)
let sheetNames = $state([])
let activeSheet = $state('')
/** @type {string[][]} */
let grid = $state([])
let XLSX = null
async function ensureXlsx() {
if (!XLSX) XLSX = await import('xlsx')
return XLSX
}
function sheetToGrid(wb, name) {
const ws = wb.Sheets[name]
if (!ws) return [['']]
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', raw: false })
if (!rows.length) return [['']]
const width = Math.max(...rows.map((r) => r.length), 1)
return rows.map((r) => {
const row = Array.from({ length: width }, (_, i) => String(r[i] ?? ''))
return row
})
}
function gridToSheet(rows) {
return XLSX.utils.aoa_to_sheet(rows)
}
async function load() {
loading = true
error = ''
try {
await ensureXlsx()
const buf = await api.downloadAttachment(projectId, ticketId, attachment.id)
const wb = XLSX.read(buf, { type: 'array' })
workbook = wb
sheetNames = wb.SheetNames.slice()
activeSheet = sheetNames[0] || ''
grid = activeSheet ? sheetToGrid(wb, activeSheet) : [['']]
} catch (e) {
error = e.message || String(e)
} finally {
loading = false
}
}
function selectSheet(name) {
if (!workbook || name === activeSheet) return
workbook.Sheets[activeSheet] = gridToSheet(grid)
activeSheet = name
grid = sheetToGrid(workbook, name)
}
function setCell(ri, ci, value) {
grid = grid.map((row, i) =>
i === ri ? row.map((c, j) => (j === ci ? value : c)) : row,
)
}
function addRow() {
const width = grid[0]?.length || 1
grid = [...grid, Array.from({ length: width }, () => '')]
}
function addCol() {
grid = grid.map((row) => [...row, ''])
}
async function save() {
if (!workbook) return
saving = true
error = ''
try {
await ensureXlsx()
workbook.Sheets[activeSheet] = gridToSheet(grid)
const { bookType, contentType } = spreadsheetWriteOptions(attachment.filename)
const out = XLSX.write(workbook, { bookType, type: 'array' })
await api.putAttachment(projectId, ticketId, attachment.id, out, { contentType })
onsaved()
msgOk()
} catch (e) {
error = e.message || String(e)
} finally {
saving = false
}
}
let justSaved = $state(false)
function msgOk() {
justSaved = true
setTimeout(() => {
justSaved = false
}, 1500)
}
$effect(() => {
if (attachment?.id) load()
})
</script>
<div class="excel-editor">
<div class="excel-editor-head row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.5rem;">
<div class="row" style="width: auto; gap: 0.5rem; flex-wrap: wrap;">
<strong>{attachment.filename}</strong>
{#if justSaved}<span class="muted">Saved</span>{/if}
{#if error}<span class="error">{error}</span>{/if}
</div>
<div class="row" style="width: auto;">
<button class="primary" disabled={loading || saving || !!error && !workbook} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
<button onclick={onclose}>Close</button>
</div>
</div>
{#if loading}
<p class="muted">Loading spreadsheet…</p>
{:else if workbook}
<div class="excel-sheets" role="tablist">
{#each sheetNames as name}
<button
type="button"
role="tab"
class:active={name === activeSheet}
aria-selected={name === activeSheet}
onclick={() => selectSheet(name)}
>{name}</button>
{/each}
</div>
<div class="excel-toolbar row" style="width: auto;">
<button type="button" onclick={addRow}>+ Row</button>
<button type="button" onclick={addCol}>+ Column</button>
</div>
<div class="excel-scroll">
<table class="excel-grid">
<tbody>
{#each grid as row, ri}
<tr>
<th class="excel-rownum">{ri + 1}</th>
{#each row as cell, ci}
<td>
<input
value={cell}
oninput={(e) => setCell(ri, ci, e.currentTarget.value)}
aria-label={`R${ri + 1}C${ci + 1}`}
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+136
View File
@@ -0,0 +1,136 @@
<script>
import { markdownToHtml, htmlToMarkdown } from './markdown.js'
let { value = $bindable(''), label = 'Description' } = $props()
let mode = $state('visual')
let visualEl = $state(null)
let lastApplied = ''
let appliedTo = null
function enableCheckboxes(root) {
if (!root) return
root.querySelectorAll('input[type="checkbox"]').forEach((box) => {
box.disabled = false
box.onchange = readVisual
})
}
function applyVisual(md) {
if (!visualEl) return
visualEl.innerHTML = markdownToHtml(md || '')
enableCheckboxes(visualEl)
lastApplied = md || ''
appliedTo = visualEl
}
function readVisual() {
if (!visualEl) return
value = htmlToMarkdown(visualEl.innerHTML)
lastApplied = value
}
function setMode(next) {
if (next === mode) return
if (mode === 'visual') readVisual()
mode = next
}
$effect(() => {
if (mode !== 'visual') return
const el = visualEl
if (!el) return
const md = value ?? ''
// Remount (mode switch) or external value change (e.g. restore revision)
if (el !== appliedTo || md !== lastApplied) applyVisual(md)
})
function exec(cmd, arg) {
visualEl?.focus()
document.execCommand(cmd, false, arg)
readVisual()
}
function insertChecklist() {
visualEl?.focus()
document.execCommand(
'insertHTML',
false,
'<ul><li><input type="checkbox"> Task</li></ul>',
)
enableCheckboxes(visualEl)
readVisual()
}
function promptLink() {
const url = window.prompt('URL')
if (!url) return
exec('createLink', url)
}
function formatCodeBlock() {
visualEl?.focus()
const sel = window.getSelection()
const text = sel?.toString() || 'code'
// Prefer <pre><code> so turndown emits a fenced markdown block.
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
document.execCommand('insertHTML', false, `<pre><code>${escaped}</code></pre>`)
readVisual()
}
</script>
<div class="md-editor">
<div class="md-editor-head">
<span class="md-editor-label">{label}</span>
<div class="md-editor-modes" role="tablist">
<button
type="button"
role="tab"
class:active={mode === 'visual'}
aria-selected={mode === 'visual'}
onclick={() => setMode('visual')}
>Visual</button>
<button
type="button"
role="tab"
class:active={mode === 'markdown'}
aria-selected={mode === 'markdown'}
onclick={() => setMode('markdown')}
>Markdown</button>
</div>
</div>
{#if mode === 'visual'}
<div class="md-toolbar" role="toolbar" aria-label="Formatting">
<button type="button" title="Bold" onclick={() => exec('bold')}><b>B</b></button>
<button type="button" title="Italic" onclick={() => exec('italic')}><i>I</i></button>
<button type="button" title="Heading" onclick={() => exec('formatBlock', 'h2')}>H</button>
<button type="button" title="Bullet list" onclick={() => exec('insertUnorderedList')}>• List</button>
<button type="button" title="Numbered list" onclick={() => exec('insertOrderedList')}>1.</button>
<button type="button" title="Checklist" onclick={insertChecklist}>☑</button>
<button type="button" title="Link" onclick={promptLink}>Link</button>
<button type="button" title="Code block" onclick={formatCodeBlock}>Code</button>
</div>
<div
class="md-visual"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-label={label}
bind:this={visualEl}
oninput={readVisual}
onblur={readVisual}
></div>
{:else}
<textarea
class="md-source"
bind:value
aria-label="{label} (Markdown)"
placeholder="Write Markdown…"
spellcheck="true"
></textarea>
{/if}
</div>
+62 -3
View File
@@ -1,9 +1,29 @@
async function request(path, opts = {}) {
const headers = { ...(opts.headers || {}) }
if (opts.body != null && !(opts.body instanceof FormData) && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(`/api/v1${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
headers,
})
if (opts.raw) {
if (!res.ok) {
const text = await res.text()
let data = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = { error: text }
}
const err = new Error((data && data.error) || res.statusText)
err.status = res.status
err.data = data
throw err
}
return res
}
const text = await res.text()
let data = null
try {
@@ -71,11 +91,21 @@ export const api = {
body: '{}',
}),
report: (projectId, groupBy = 'ticket') =>
request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`),
report: (projectId, { groupBy = 'ticket', from = '', to = '', ticketId = '' } = {}) => {
const q = new URLSearchParams()
q.set('groupBy', groupBy)
if (from) q.set('from', from)
if (to) q.set('to', to)
if (ticketId) q.set('ticketId', ticketId)
return request(`/projects/${projectId}/report?${q}`)
},
listTime: (projectId) => request(`/projects/${projectId}/time`),
createTime: (projectId, body) =>
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
patchTime: (projectId, eid, body) =>
request(`/projects/${projectId}/time/${eid}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteTime: (projectId, eid) =>
request(`/projects/${projectId}/time/${eid}`, { method: 'DELETE' }),
getTimer: () => request('/timer'),
startTimer: (ticketId, projectId) =>
request('/timer/start', { method: 'POST', body: JSON.stringify({ ticketId, projectId }) }),
@@ -85,4 +115,33 @@ export const api = {
createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }),
patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteUser: (id) => request(`/users/${id}`, { method: 'DELETE' }),
listAttachments: (projectId, tid) =>
request(`/projects/${projectId}/tickets/${tid}/attachments`),
uploadAttachment: (projectId, tid, file) => {
const fd = new FormData()
fd.append('file', file)
return request(`/projects/${projectId}/tickets/${tid}/attachments`, {
method: 'POST',
body: fd,
})
},
downloadAttachment: async (projectId, tid, aid) => {
const res = await request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
raw: true,
})
return res.arrayBuffer()
},
downloadAttachmentUrl: (projectId, tid, aid) =>
`/api/v1/projects/${projectId}/tickets/${tid}/attachments/${aid}`,
putAttachment: (projectId, tid, aid, body, { contentType } = {}) =>
request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
method: 'PUT',
headers: contentType ? { 'Content-Type': contentType } : {},
body,
}),
deleteAttachment: (projectId, tid, aid) =>
request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
method: 'DELETE',
}),
}
+42
View File
@@ -0,0 +1,42 @@
import { marked } from 'marked'
import TurndownService from 'turndown'
import { gfm } from 'turndown-plugin-gfm'
marked.setOptions({
gfm: true,
breaks: false,
})
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*',
})
turndown.use(gfm)
// Browsers' formatBlock('pre') often yields bare <pre> without <code>.
// Default turndown only fences <pre><code>; keep bare <pre> as fenced blocks.
turndown.addRule('barePre', {
filter(node) {
return (
node.nodeName === 'PRE' &&
!(node.firstChild && node.firstChild.nodeName === 'CODE')
)
},
replacement(_content, node) {
const text = node.textContent.replace(/\n$/, '')
return `\n\n\`\`\`\n${text}\n\`\`\`\n\n`
},
})
export function markdownToHtml(md) {
if (!md || !md.trim()) return '<p><br></p>'
return marked.parse(md)
}
export function htmlToMarkdown(html) {
if (!html) return ''
const md = turndown.turndown(html)
return md.replace(/\n{3,}/g, '\n\n').trim()
}
+64
View File
@@ -0,0 +1,64 @@
/** Hash routes for the SPA. Examples:
* #/dashboard
* #/projects
* #/projects/:id
* #/projects/:id/tickets/:ticketId
* #/projects/:id/report|members|settings
* #/users
* #/settings
*/
export function parseRoute(hash = typeof location !== 'undefined' ? location.hash : '') {
const raw = String(hash || '')
.replace(/^#\/?/, '')
.replace(/\/+$/, '')
const parts = raw ? raw.split('/').filter(Boolean) : []
if (parts.length === 0 || parts[0] === 'dashboard') {
return { topTab: 'dashboard', projectId: '', projectView: '', ticketId: '' }
}
if (parts[0] === 'users') {
return { topTab: 'users', projectId: '', projectView: '', ticketId: '' }
}
if (parts[0] === 'settings') {
return { topTab: 'settings', projectId: '', projectView: '', ticketId: '' }
}
if (parts[0] === 'projects') {
const projectId = parts[1] || ''
if (!projectId) {
return { topTab: 'projects', projectId: '', projectView: '', ticketId: '' }
}
const sub = parts[2] || 'board'
if (sub === 'tickets' && parts[3]) {
return { topTab: 'projects', projectId, projectView: 'ticket', ticketId: parts[3] }
}
if (sub === 'report' || sub === 'members' || sub === 'settings' || sub === 'board') {
return { topTab: 'projects', projectId, projectView: sub, ticketId: '' }
}
return { topTab: 'projects', projectId, projectView: 'board', ticketId: '' }
}
return { topTab: 'dashboard', projectId: '', projectView: '', ticketId: '' }
}
export function buildHash({ topTab, projectId, projectView, ticketId }) {
if (topTab === 'users') return '#/users'
if (topTab === 'settings') return '#/settings'
if (topTab === 'projects') {
if (!projectId) return '#/projects'
if (projectView === 'ticket' && ticketId) {
return `#/projects/${projectId}/tickets/${ticketId}`
}
if (projectView === 'report') return `#/projects/${projectId}/report`
if (projectView === 'members') return `#/projects/${projectId}/members`
if (projectView === 'settings') return `#/projects/${projectId}/settings`
return `#/projects/${projectId}`
}
return '#/dashboard'
}
export function currentHash() {
const h = location.hash || ''
if (!h || h === '#') return '#/dashboard'
return h.startsWith('#/') ? h : `#/${h.replace(/^#/, '')}`
}
+45
View File
@@ -0,0 +1,45 @@
/** Extensions SheetJS can read/write for simple cell editing. */
const FORMATS = {
xlsx: {
bookType: 'xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
xlsm: {
bookType: 'xlsm',
contentType: 'application/vnd.ms-excel.sheet.macroEnabled.12',
},
xlsb: {
bookType: 'xlsb',
contentType: 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
},
xls: {
bookType: 'xls',
contentType: 'application/vnd.ms-excel',
},
ods: {
bookType: 'ods',
contentType: 'application/vnd.oasis.opendocument.spreadsheet',
},
fods: {
bookType: 'fods',
contentType: 'application/vnd.oasis.opendocument.spreadsheet',
},
csv: {
bookType: 'csv',
contentType: 'text/csv',
},
}
const EXT_RE = /\.([a-z0-9]+)$/i
export function isSpreadsheetName(name) {
const m = String(name || '').match(EXT_RE)
if (!m) return false
return Object.prototype.hasOwnProperty.call(FORMATS, m[1].toLowerCase())
}
export function spreadsheetWriteOptions(filename) {
const m = String(filename || '').match(EXT_RE)
const ext = m ? m[1].toLowerCase() : 'xlsx'
return FORMATS[ext] || FORMATS.xlsx
}