Compare commits
15
Commits
afbf86dcf2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846bb31dc9 | ||
|
|
2dbf0918de | ||
|
|
98fcb57298 | ||
|
|
229f7a2fe1 | ||
|
|
69c6747a31 | ||
|
|
efeb4a74a8 | ||
|
|
26a3723a06 | ||
|
|
6e6b13270a | ||
|
|
614cb94b46 | ||
|
|
f97856b8a6 | ||
|
|
34ab3b9c5b | ||
|
|
2061df63d4 | ||
|
|
a594183310 | ||
|
|
b311a355fd | ||
|
|
79b0821837 |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tm": {
|
||||
"command": "${workspaceFolder}/bin/tm",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/bin/
|
||||
.dev-home/
|
||||
.tm_token
|
||||
**/.tm_token
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+146
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
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
@@ -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',
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(/^#/, '')}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user