Add tm_server with SQLite, Svelte UI, and task-master TUI sync.

Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:23:11 +01:00
co-authored by Cursor
parent 98d2253f44
commit 4adfcdb95a
67 changed files with 7188 additions and 129 deletions
+6 -1
View File
@@ -29,6 +29,9 @@ jobs:
- name: Build - name: Build
run: make build run: make build
- name: Build server
run: make build-server
- name: Version - name: Version
run: make version run: make version
@@ -36,7 +39,9 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: tm name: tm
path: ./bin/tm path: |
./bin/tm
./bin/tm_server
- run: echo "This job's status is ${{ job.status }}." - run: echo "This job's status is ${{ job.status }}."
+5
View File
@@ -6,3 +6,8 @@
# Generated by `make demo` / CI — commit demo.gif so the README preview works on Gitea # Generated by `make demo` / CI — commit demo.gif so the README preview works on Gitea
demo.mp4 demo.mp4
demo.webm demo.webm
web/node_modules/
web/dist/
*.db
*.db-wal
*.db-shm
+29 -2
View File
@@ -1,5 +1,7 @@
BINARY := tm BINARY := tm
BINARY_SERVER := tm_server
CMD := ./cmd/task-master CMD := ./cmd/task-master
CMD_SERVER := ./cmd/tm-server
VERSION_PKG := task/internal/version VERSION_PKG := task/internal/version
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH) LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
@@ -10,18 +12,43 @@ PREFIX ?= $(GOPATH)
INSTALL_BIN := $(PREFIX)/bin INSTALL_BIN := $(PREFIX)/bin
DEV_HOME := $(CURDIR)/.dev-home DEV_HOME := $(CURDIR)/.dev-home
.PHONY: build run run-dev install uninstall test version demo .PHONY: build build-server build-all web run run-dev run-server-dev install uninstall test version demo
build: build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD) go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
web:
cd web && npm ci && npm run build
build-server: web
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
# Build server binary without rebuilding frontend (uses committed/placeholder dist).
build-server-go:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
build-all: build build-server
run: build run: build
./bin/$(BINARY) ./bin/$(BINARY)
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config) # Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config).
# YouTrack is not part of the local test environment — use tm_server instead.
run-dev: build run-dev: build
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY) TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
# Web test environment: SQLite demo against tm_server only (no YouTrack).
# Seeds project "Test" + samples; login admin/admin (alice/alice as member).
run-server-dev: 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
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
install: build install: build
install -d $(INSTALL_BIN) install -d $(INSTALL_BIN)
install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY) install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY)
+28 -4
View File
@@ -8,18 +8,20 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch
## Features ## Features
- Projekte & Tickets lokal als JSON (kein Server) - Projekte & Tickets lokal als JSON (kein Server) — Binary `tm`
- Web-Server `tm_server` mit eingebettetem Svelte-UI, SQLite, Auth und Rechte pro Projekt
- Board-Filter: All / Backlog / In Progress / Done - Board-Filter: All / Backlog / In Progress / Done
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten) - Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
- Timer, manuelle Zeitbuchungen, Zeiteinträge verwalten - Timer, manuelle Zeitbuchungen, Zeiteinträge verwalten
- Report mit Group-by (Ticket / Tag) - Report mit Group-by (Ticket / Tag)
- Undo / Redo über eine Command-History - Undo / Redo über eine Command-History (TUI)
- Vim-Style Keybindings - Vim-Style Keybindings (TUI)
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt) - YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
- Server: Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
## Install ## Install
Voraussetzung: Go (siehe `go.mod`). Voraussetzung: Go (siehe `go.mod`). Für `tm_server` zusätzlich Node.js (Frontend-Build).
```bash ```bash
git clone git@git.aloxdrim.de:skruecken/task-master.git git clone git@git.aloxdrim.de:skruecken/task-master.git
@@ -27,6 +29,7 @@ cd task-master
make install # → $(go env GOPATH)/bin/tm make install # → $(go env GOPATH)/bin/tm
# oder nur bauen: # oder nur bauen:
make build # → ./bin/tm make build # → ./bin/tm
make build-server # Frontend + → ./bin/tm_server
``` ```
## Nutzung ## Nutzung
@@ -40,6 +43,27 @@ tm version # Build-Version (Git-Hash)
tm help tm help
``` ```
### Web-Server (`tm_server`)
```bash
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekt „Test“ + Samples
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
# Produktion / manuell:
export TM_ADMIN_USER=admin
export TM_ADMIN_PASSWORD='change-me'
export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
./bin/tm_server -listen :8080
# JSON-Daten der TUI einmalig importieren:
./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.
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`.
Sandbox ohne echte Config/Daten: Sandbox ohne echte Config/Daten:
```bash ```bash
+30 -12
View File
@@ -14,6 +14,7 @@ import (
"task/internal/ticket" "task/internal/ticket"
"task/internal/ui" "task/internal/ui"
"task/internal/version" "task/internal/version"
"task/internal/tmsync"
"task/internal/ytsync" "task/internal/ytsync"
) )
@@ -111,19 +112,36 @@ func runSync(args []string) error {
return err return err
} }
switch action { meta, err := projects.LoadMeta(projectID)
case "push":
res, err := ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
if err != nil { if err != nil {
return err 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", fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
projectID, res.Created, res.Updated, res.TimePushed) projectID, res.Created, res.Updated, res.TimePushed)
default: } else {
res, err := ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
if err != nil {
return err
}
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n", fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
projectID, res.Created, res.Updated, res.TimePulled, res.Total) projectID, res.Created, res.Updated, res.TimePulled, res.Total)
} }
@@ -135,8 +153,8 @@ func printHelp() {
Usage: Usage:
%s Start the TUI %s Start the TUI
%s sync pull [-p ID] Pull issues from YouTrack %s sync pull [-p ID] Pull from configured backend (YouTrack or task-master)
%s sync push [-p ID] Push local changes to YouTrack %s sync push [-p ID] Push to configured backend
%s version Print build version %s version Print build version
%s help Show this help %s help Show this help
@@ -144,8 +162,8 @@ Env:
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox) TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
TASK_MASTER_DATA_DIR Override data directory only TASK_MASTER_DATA_DIR Override data directory only
YouTrack: Sync backends:
In the TUI: p → project → e to set remote URL, token, project key. 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). Then Y → Pull or Push (manual only; edits stay local until you push).
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName) `, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"task/internal/config"
"task/internal/server"
"task/internal/sqlite"
"task/internal/version"
)
func main() {
listen := flag.String("listen", ":8080", "HTTP listen address")
dbPath := flag.String("db", "", "SQLite database path (default: <dataDir>/task-master.db)")
secure := flag.Bool("secure", false, "Set Secure flag on session cookies")
flag.Parse()
args := flag.Args()
if len(args) > 0 {
switch args[0] {
case "version", "--version", "-v":
fmt.Printf("tm_server %s\n", version.BuildVersion)
return
case "help", "--help", "-h":
printHelp()
return
case "import-json":
if err := runImportJSON(args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
printHelp()
os.Exit(1)
}
}
if err := runServer(*listen, *dbPath, *secure); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func openDB(dbPath string) (*sqlite.DB, *config.Config, error) {
cfg, err := config.LoadOrCreate()
if err != nil {
return nil, nil, fmt.Errorf("config: %w", err)
}
path := dbPath
if path == "" {
path = filepath.Join(cfg.DataDir, "task-master.db")
}
secret := []byte(os.Getenv("TASK_MASTER_SECRET"))
db, err := sqlite.Open(path, secret)
if err != nil {
return nil, nil, err
}
return db, cfg, nil
}
func bootstrapAdmin(db *sqlite.DB) error {
n, err := db.CountUsers()
if err != nil {
return err
}
if n > 0 {
return nil
}
user := strings.TrimSpace(os.Getenv("TM_ADMIN_USER"))
pass := os.Getenv("TM_ADMIN_PASSWORD")
if user == "" || pass == "" {
fmt.Fprintln(os.Stderr, "no users in database; set TM_ADMIN_USER and TM_ADMIN_PASSWORD to create the first admin")
return nil
}
u, err := db.CreateUser(user, pass, true)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "created bootstrap admin %q (%s)\n", u.Username, u.ID)
return nil
}
func runServer(listen, dbPath string, secure bool) error {
db, _, err := openDB(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := bootstrapAdmin(db); err != nil {
return err
}
srv := server.New(db)
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 {
return fmt.Errorf("seed: %w", err)
}
projects, _ := srv.Projects.List()
fmt.Fprintf(os.Stderr, "demo seed ready (%d projects; login admin/admin, alice/alice)\n", len(projects))
}
fmt.Fprintf(os.Stderr, "tm_server %s listening on %s\n", version.BuildVersion, listen)
return http.ListenAndServe(listen, srv.Handler())
}
func runImportJSON(args []string) error {
fs := flag.NewFlagSet("import-json", flag.ExitOnError)
dataDir := fs.String("data", "", "JSON DataDir to import (default from config)")
dbPath := fs.String("db", "", "SQLite database path")
owner := fs.String("owner", "", "username to own imported projects")
_ = fs.Parse(args)
db, cfg, err := openDB(*dbPath)
if err != nil {
return err
}
defer db.Close()
if err := bootstrapAdmin(db); err != nil {
return err
}
dir := *dataDir
if dir == "" {
dir = cfg.DataDir
}
var ownerID string
if *owner != "" {
u, err := db.GetUserByUsername(*owner)
if err != nil {
return fmt.Errorf("owner: %w", err)
}
ownerID = u.ID
} else {
users, err := db.ListUsers()
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("no users; create admin first (TM_ADMIN_USER/TM_ADMIN_PASSWORD)")
}
for _, u := range users {
if u.IsAdmin {
ownerID = u.ID
break
}
}
if ownerID == "" {
ownerID = users[0].ID
}
}
srv := server.New(db)
fmt.Fprintf(os.Stderr, "importing from %s …\n", dir)
if err := srv.ImportJSONDataDir(dir, ownerID); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "import done")
return nil
}
func printHelp() {
fmt.Print(`tm_server — task-master web server
Usage:
tm_server [flags]
tm_server import-json [-data DIR] [-db PATH] [-owner USER]
tm_server version
tm_server help
Flags:
-listen ADDR HTTP listen address (default :8080)
-db PATH SQLite path (default <dataDir>/task-master.db)
-secure Set Secure cookie flag
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)
`)
}
+13 -5
View File
@@ -9,6 +9,8 @@ require (
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0
) )
require ( require (
@@ -25,23 +27,29 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.60.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/term v0.36.0 // indirect golang.org/x/term v0.47.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.43.0 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
) )
+56 -11
View File
@@ -40,20 +40,26 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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/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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -69,6 +75,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 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/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/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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -81,15 +91,50 @@ 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 v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
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=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+65
View File
@@ -10,6 +10,12 @@ import (
const metaFileName = "meta.json" const metaFileName = "meta.json"
const (
BackendNone = "none"
BackendYouTrack = "youtrack"
BackendTaskMaster = "taskmaster"
)
// YouTrack holds remote sync settings for a project. // YouTrack holds remote sync settings for a project.
type YouTrack struct { type YouTrack struct {
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
@@ -34,9 +40,59 @@ func (y YouTrack) IssueQuery() string {
return "project: " + strings.TrimSpace(y.ProjectKey) return "project: " + strings.TrimSpace(y.ProjectKey)
} }
// TaskMaster holds sync settings for a remote tm_server project.
type TaskMaster struct {
BaseURL string `json:"baseUrl"`
Token string `json:"token"`
ProjectID string `json:"projectId"`
}
// Configured reports whether task-master sync can run.
func (t TaskMaster) Configured() bool {
return strings.TrimSpace(t.BaseURL) != "" &&
strings.TrimSpace(t.Token) != "" &&
strings.TrimSpace(t.ProjectID) != ""
}
// Meta is per-project metadata stored beside tickets.json. // Meta is per-project metadata stored beside tickets.json.
type Meta struct { type Meta struct {
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
YouTrack *YouTrack `json:"youtrack,omitempty"` YouTrack *YouTrack `json:"youtrack,omitempty"`
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
}
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
func (m Meta) EffectiveBackend() string {
b := strings.TrimSpace(strings.ToLower(m.Backend))
switch b {
case BackendYouTrack, BackendTaskMaster, BackendNone:
return b
}
if m.YouTrack != nil && m.YouTrack.Configured() {
return BackendYouTrack
}
if m.TaskMaster != nil && m.TaskMaster.Configured() {
return BackendTaskMaster
}
if m.YouTrack != nil {
return BackendYouTrack
}
if m.TaskMaster != nil {
return BackendTaskMaster
}
return BackendNone
}
// SyncConfigured reports whether the active backend is fully set up.
func (m Meta) SyncConfigured() bool {
switch m.EffectiveBackend() {
case BackendYouTrack:
return m.YouTrack != nil && m.YouTrack.Configured()
case BackendTaskMaster:
return m.TaskMaster != nil && m.TaskMaster.Configured()
default:
return false
}
} }
func (s *DirStore) metaPath(id string) string { func (s *DirStore) metaPath(id string) string {
@@ -63,6 +119,12 @@ func (s *DirStore) LoadMeta(id string) (Meta, error) {
if err := json.Unmarshal(data, &meta); err != nil { if err := json.Unmarshal(data, &meta); err != nil {
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err) return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
} }
if meta.Backend == "" && meta.YouTrack != nil {
meta.Backend = BackendYouTrack
}
if meta.Backend == "" && meta.TaskMaster != nil {
meta.Backend = BackendTaskMaster
}
return meta, nil return meta, nil
} }
@@ -74,6 +136,9 @@ func (s *DirStore) SaveMeta(id string, meta Meta) error {
if err := s.Ensure(id); err != nil { if err := s.Ensure(id); err != nil {
return err return err
} }
if meta.Backend == "" {
meta.Backend = meta.EffectiveBackend()
}
path := s.metaPath(id) path := s.metaPath(id)
data, err := json.MarshalIndent(meta, "", " ") data, err := json.MarshalIndent(meta, "", " ")
if err != nil { if err != nil {
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"net/http"
"strings"
"time"
"task/internal/sqlite"
)
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Username = strings.TrimSpace(body.Username)
u, err := s.DB.GetUserByUsername(body.Username)
if err != nil || !sqlite.CheckPassword(body.Password, u.PasswordHash) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, exp, err := s.DB.CreateSession(u.ID, 7*24*time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "session failed")
return
}
s.setSessionCookie(w, token, exp)
u.PasswordHash = ""
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
_ = s.DB.DeleteSession(c.Value)
}
s.clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.DB.ListUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
u, err := s.DB.CreateUser(body.Username, body.Password, body.IsAdmin)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
u.PasswordHash = ""
writeJSON(w, http.StatusCreated, u)
}
+72
View File
@@ -0,0 +1,72 @@
package server
import (
"fmt"
"task/internal/project"
"task/internal/ticket"
)
// ImportJSONDataDir copies projects/tickets/time from a local JSON DataDir into SQLite.
func (s *Server) ImportJSONDataDir(dataDir string, ownerUserID string) error {
ps := project.NewDirStore(dataDir)
ts := ticket.NewJSONStore(dataDir)
timeStore := ticket.NewJSONTimeStore(dataDir)
projects, err := ps.List()
if err != nil {
return err
}
for _, p := range projects {
if err := s.Projects.Ensure(p.ID); err != nil {
return fmt.Errorf("project %s: %w", p.ID, err)
}
if ownerUserID != "" {
_ = s.DB.SetMember(p.ID, ownerUserID, "owner")
}
meta, err := ps.LoadMeta(p.ID)
if err != nil {
return err
}
if meta.YouTrack != nil && meta.YouTrack.Configured() {
if err := s.Projects.SaveMeta(p.ID, meta); err != nil {
return fmt.Errorf("meta %s: %w", p.ID, err)
}
}
tickets, err := ts.List(p.ID)
if err != nil {
return err
}
for _, t := range tickets {
if _, err := s.Tickets.Get(t.ID); err == nil {
continue // already imported
}
if _, err := s.Tickets.Create(t); err != nil {
return fmt.Errorf("ticket %s: %w", t.ID, err)
}
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "import", ownerUserID, t)
}
}
entries, err := timeStore.List()
if err != nil {
return err
}
existing, _ := s.Times.List()
have := map[string]bool{}
for _, e := range existing {
have[e.ID] = true
}
for _, e := range entries {
if have[e.ID] {
continue
}
if _, err := s.Times.Create(e); err != nil {
return fmt.Errorf("time %s: %w", e.ID, err)
}
}
if active, err := timeStore.LoadActiveTimer(); err == nil && active != nil {
_ = s.Times.SaveActiveTimer(active)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"os"
"path/filepath"
"testing"
"task/internal/project"
"task/internal/sqlite"
"task/internal/ticket"
)
func TestImportJSONDataDir(t *testing.T) {
dataDir := t.TempDir()
ps := project.NewDirStore(dataDir)
if err := ps.Ensure("inbox"); err != nil {
t.Fatal(err)
}
ts := ticket.NewJSONStore(dataDir)
created, err := ts.Create(ticket.Ticket{Title: "From JSON", ProjectID: "inbox"})
if err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(t.TempDir(), "import.db")
db, err := sqlite.Open(dbPath, []byte("sec"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
u, err := db.CreateUser("admin", "x", true)
if err != nil {
t.Fatal(err)
}
srv := New(db)
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
t.Fatal(err)
}
got, err := srv.Tickets.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "From JSON" {
t.Fatalf("title=%q", got.Title)
}
role, _ := db.MemberRole("inbox", u.ID)
if role != sqlite.RoleOwner {
t.Fatalf("role=%s", role)
}
// second import is idempotent
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
t.Fatal(err)
}
_ = os.RemoveAll(dataDir) // ensure we don't rely on JSON after import for this test cleanup
}
+23
View File
@@ -0,0 +1,23 @@
package server
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 writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(r *http.Request, dest any) error {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dest)
}
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"context"
"net/http"
"strings"
"task/internal/sqlite"
)
type ctxKey int
const (
userKey ctxKey = 1
tokenAuthKey ctxKey = 2
)
func (s *Server) currentUser(r *http.Request) (sqlite.User, bool) {
u, ok := r.Context().Value(userKey).(sqlite.User)
return u, ok
}
func (s *Server) currentTokenAuth(r *http.Request) (sqlite.TokenAuth, bool) {
t, ok := r.Context().Value(tokenAuthKey).(sqlite.TokenAuth)
return t, ok
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := s.authenticate(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r.WithContext(ctx))
}
}
func (s *Server) authenticate(r *http.Request) (context.Context, bool) {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
raw := strings.TrimSpace(auth[len("Bearer "):])
ta, err := s.DB.ResolveBearerToken(raw)
if err != nil {
return r.Context(), false
}
ctx := context.WithValue(r.Context(), userKey, ta.User)
ctx = context.WithValue(ctx, tokenAuthKey, ta)
return ctx, true
}
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return r.Context(), false
}
u, err := s.DB.UserFromSession(c.Value)
if err != nil {
return r.Context(), false
}
u.PasswordHash = ""
return context.WithValue(r.Context(), userKey, u), true
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "admin required")
return
}
u, _ := s.currentUser(r)
if !u.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
next(w, r)
})
}
func (s *Server) requireProjectRole(projectID string, minRole string, r *http.Request) (sqlite.User, bool, int) {
u, ok := s.currentUser(r)
if !ok {
return sqlite.User{}, false, http.StatusUnauthorized
}
if ta, ok := s.currentTokenAuth(r); ok {
if ta.ProjectID != projectID {
return u, false, http.StatusForbidden
}
need := sqlite.TokenScopeRead
if minRole == sqlite.RoleMember || minRole == sqlite.RoleAdmin || minRole == sqlite.RoleOwner {
need = sqlite.TokenScopeWrite
}
// viewer → read; member+ → write
if minRole == sqlite.RoleViewer {
need = sqlite.TokenScopeRead
}
if !sqlite.TokenAllows(ta.Scope, need) {
return u, false, http.StatusForbidden
}
return u, true, 0
}
if u.IsAdmin {
return u, true, 0
}
role, err := s.DB.MemberRole(projectID, u.ID)
if err != nil || role == "" {
return u, false, http.StatusForbidden
}
if !sqlite.RoleAtLeast(role, minRole) {
return u, false, http.StatusForbidden
}
return u, true, 0
}
+139
View File
@@ -0,0 +1,139 @@
package server
import (
"net/http"
"strings"
"task/internal/sqlite"
)
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Name = strings.TrimSpace(body.Name)
if body.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
p, err := s.Projects.Create("", body.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
p, err := s.Projects.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Projects.Rename(id, body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
p, _ := s.Projects.Get(id)
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleOwner, r); !ok {
writeError(w, code, "forbidden")
return
}
_ = s.Times.DeleteByProject(id)
if err := s.Projects.Delete(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
members, err := s.DB.ListMembers(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
userID := r.PathValue("userId")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Role string `json:"role"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.DB.SetMember(id, userID, body.Role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
userID := r.PathValue("userId")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if err := s.DB.RemoveMember(id, userID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"net/http"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
groupBy := ticket.GroupBy(r.URL.Query().Get("groupBy"))
if groupBy == "" {
groupBy = ticket.GroupByTicket
}
valid := false
for _, g := range ticket.AllGroupBys {
if g == groupBy {
valid = true
break
}
}
if !valid {
writeError(w, http.StatusBadRequest, "invalid groupBy")
return
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tickets, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
byID := make(map[string]ticket.Ticket, len(tickets))
for _, t := range tickets {
byID[t.ID] = t
}
rows := ticket.Aggregate(entries, byID, groupBy)
total := ticket.TotalDuration(entries)
type rowJSON struct {
Key string `json:"key"`
Label string `json:"label"`
DurationNs int64 `json:"durationNs"`
}
out := make([]rowJSON, 0, len(rows))
for _, row := range rows {
out = append(out, rowJSON{
Key: row.Key,
Label: row.Label,
DurationNs: int64(row.Duration),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"groupBy": groupBy,
"totalDurationNs": int64(total),
"rows": out,
"entryCount": len(entries),
})
}
+130
View File
@@ -0,0 +1,130 @@
package server
import (
"fmt"
"os"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
// 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 {
projects, err := s.Projects.List()
if err != nil {
return err
}
if len(projects) > 0 {
return nil
}
users, err := s.DB.ListUsers()
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("seed: no users (bootstrap admin first)")
}
admin := users[0]
for _, u := range users {
if u.IsAdmin {
admin = u
break
}
}
member, err := s.DB.CreateUser("alice", "alice", false)
if err != nil {
member, err = s.DB.GetUserByUsername("alice")
if err != nil {
return fmt.Errorf("seed alice: %w", err)
}
}
type seedTicket struct {
Title, Desc string
Status ticket.Status
}
demos := []struct {
Name string
Tickets []seedTicket
}{
{
Name: "Test",
Tickets: []seedTicket{
{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: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
},
},
{
Name: "Website Relaunch",
Tickets: []seedTicket{
{Title: "Landing Hero entwerfen", Desc: "Dunkles Layout, klare CTAs.\n\n- [ ] Moodboard\n- [ ] Wireframe", Status: ticket.StatusInProgress},
{Title: "CMS-Inhalte migrieren", Desc: "Alte Seiten nach Markdown überführen.", Status: ticket.StatusTodo},
{Title: "Impressum & Datenschutz", Desc: "Rechtstexte finalisieren.", Status: ticket.StatusDone},
},
},
{
Name: "API Platform",
Tickets: []seedTicket{
{Title: "Auth-Middleware härten", Desc: "Session-Cookies dokumentieren.", Status: ticket.StatusInProgress},
{Title: "Rate-Limits festlegen", Desc: "Pro User und pro IP.", Status: ticket.StatusTodo},
{Title: "Health-Check", Desc: "Bereits live.", Status: ticket.StatusDone},
},
},
}
now := time.Now().UTC()
for _, d := range demos {
p, err := s.Projects.Create("", d.Name)
if err != nil {
return err
}
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
// Explicit local-only sync (no remote backend in the test environment).
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
ProjectID: p.ID,
Kind: "none",
})
var firstID string
for i, st := range d.Tickets {
t, err := s.Tickets.Create(ticket.Ticket{
Title: st.Title,
Description: st.Desc,
Status: st.Status,
ProjectID: p.ID,
})
if err != nil {
return err
}
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "seed", admin.ID, t)
if i == 0 {
firstID = t.ID
}
}
if firstID != "" {
_, err := s.Times.Create(ticket.TimeEntry{
TicketID: firstID,
ProjectID: p.ID,
Start: now.Add(-2 * time.Hour),
End: now.Add(-90 * time.Minute),
Duration: 30 * time.Minute,
Source: ticket.SourceManual,
Note: "seed",
})
if err != nil {
return err
}
}
}
fmt.Fprintf(os.Stderr, "seeded demo: Test + sample projects (users %s + alice/alice), sync=none\n", admin.Username)
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package server
import (
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestSeedDemo(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "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(); err != nil {
t.Fatal(err)
}
projects, err := srv.Projects.List()
if err != nil || len(projects) < 3 {
t.Fatalf("projects=%v err=%v", projects, err)
}
var testID string
for _, p := range projects {
if p.Name == "Test" {
testID = p.ID
break
}
}
if testID == "" {
t.Fatal("missing Test project")
}
sync, err := srv.Projects.GetSyncBackend(testID)
if err != nil || sync.Kind != "none" {
t.Fatalf("sync=%+v err=%v", sync, err)
}
tickets, err := srv.Tickets.List(testID)
if err != nil || len(tickets) == 0 {
t.Fatalf("tickets empty")
}
conns, err := db.ListConnections()
if err != nil || len(conns) != 0 {
t.Fatalf("expected no remote connections, got %v", conns)
}
// idempotent when projects already exist
if err := srv.SeedDemo(); err != nil {
t.Fatal(err)
}
projects2, _ := srv.Projects.List()
if len(projects2) != len(projects) {
t.Fatalf("re-seed changed project count")
}
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"io/fs"
"net/http"
"strings"
"time"
"task/internal/sqlite"
"task/internal/syncbackend"
"task/internal/webembed"
)
// Server is the HTTP API + embedded UI.
type Server struct {
DB *sqlite.DB
Projects *sqlite.ProjectStore
Tickets *sqlite.TicketStore
Times *sqlite.TimeStore
Sync *syncbackend.Registry
Secure bool // set Secure on cookies when behind TLS
Static fs.FS
}
// New constructs a server from an open DB.
func New(db *sqlite.DB) *Server {
return &Server{
DB: db,
Projects: sqlite.NewProjectStore(db),
Tickets: sqlite.NewTicketStore(db),
Times: sqlite.NewTimeStore(db),
Sync: syncbackend.NewRegistry(),
Static: webembed.FS(),
}
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return withCORS(mux)
}
func (s *Server) routes(mux *http.ServeMux) {
api := http.NewServeMux()
api.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
api.HandleFunc("POST /auth/login", s.handleLogin)
api.HandleFunc("POST /auth/logout", s.requireAuth(s.handleLogout))
api.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
api.HandleFunc("GET /users", s.requireAdmin(s.handleListUsers))
api.HandleFunc("POST /users", s.requireAdmin(s.handleCreateUser))
api.HandleFunc("PATCH /users/{id}", s.requireAdmin(s.handlePatchUser))
api.HandleFunc("DELETE /users/{id}", s.requireAdmin(s.handleDeleteUser))
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
api.HandleFunc("PATCH /projects/{id}", s.requireAuth(s.handlePatchProject))
api.HandleFunc("DELETE /projects/{id}", s.requireAuth(s.handleDeleteProject))
api.HandleFunc("GET /projects/{id}/members", s.requireAuth(s.handleListMembers))
api.HandleFunc("PUT /projects/{id}/members/{userId}", s.requireAuth(s.handleSetMember))
api.HandleFunc("DELETE /projects/{id}/members/{userId}", s.requireAuth(s.handleRemoveMember))
api.HandleFunc("GET /projects/{id}/tokens", s.requireAuth(s.handleListTokens))
api.HandleFunc("POST /projects/{id}/tokens", s.requireAuth(s.handleCreateToken))
api.HandleFunc("DELETE /projects/{id}/tokens/{tid}", s.requireAuth(s.handleRevokeToken))
api.HandleFunc("GET /projects/{id}/sync", s.requireAuth(s.handleGetSync))
api.HandleFunc("PUT /projects/{id}/sync", s.requireAuth(s.handlePutSync))
api.HandleFunc("POST /projects/{id}/sync/pull", s.requireAuth(s.handleSyncPull))
api.HandleFunc("POST /projects/{id}/sync/push", s.requireAuth(s.handleSyncPush))
api.HandleFunc("GET /projects/{id}/tickets", s.requireAuth(s.handleListTickets))
api.HandleFunc("POST /projects/{id}/tickets", s.requireAuth(s.handleCreateTicket))
api.HandleFunc("GET /projects/{id}/tickets/{tid}", s.requireAuth(s.handleGetTicket))
api.HandleFunc("PATCH /projects/{id}/tickets/{tid}", s.requireAuth(s.handlePatchTicket))
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}/revisions", s.requireAuth(s.handleListTicketRevisions))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
api.HandleFunc("GET /projects/{id}/report", s.requireAuth(s.handleProjectReport))
api.HandleFunc("GET /projects/{id}/time", s.requireAuth(s.handleListProjectTime))
api.HandleFunc("POST /projects/{id}/time", s.requireAuth(s.handleCreateTime))
api.HandleFunc("PATCH /projects/{id}/time/{eid}", s.requireAuth(s.handleUpdateTime))
api.HandleFunc("DELETE /projects/{id}/time/{eid}", s.requireAuth(s.handleDeleteTime))
api.HandleFunc("GET /timer", s.requireAuth(s.handleGetTimer))
api.HandleFunc("POST /timer/start", s.requireAuth(s.handleStartTimer))
api.HandleFunc("POST /timer/stop", s.requireAuth(s.handleStopTimer))
api.HandleFunc("GET /connections", s.requireAdmin(s.handleListConnections))
api.HandleFunc("POST /connections", s.requireAdmin(s.handleCreateConnection))
api.HandleFunc("DELETE /connections/{id}", s.requireAdmin(s.handleDeleteConnection))
api.HandleFunc("GET /connections/{id}/projects", s.requireAdmin(s.handleConnectionProjects))
api.HandleFunc("POST /connections/{id}/import", s.requireAdmin(s.handleImportProject))
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
mux.Handle("/", s.spaHandler())
}
const sessionCookie = "tm_session"
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.Secure,
Expires: expires,
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.Secure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) spaHandler() http.Handler {
static := s.Static
if static == nil {
static = webembed.FS()
}
fileServer := http.FileServer(http.FS(static))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if f, err := static.Open(path); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback
r2 := r.Clone(r.Context())
r2.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r2)
})
}
+90
View File
@@ -0,0 +1,90 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestAPIAuthAndTickets(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()
if len(cookie) == 0 {
t.Fatal("no session cookie")
}
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[0])
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)
if pid == "" {
t.Fatal("expected auto project id")
}
ticketBody, _ := json.Marshal(map[string]string{"title": "First", "description": "hi"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie[0])
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)
if tid == "" {
t.Fatal("no ticket id")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/revisions", nil)
req.AddCookie(cookie[0])
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("revisions %d", rec.Code)
}
var revs []any
_ = json.Unmarshal(rec.Body.Bytes(), &revs)
if len(revs) < 1 {
t.Fatal("expected revision")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health %d", rec.Code)
}
}
+237
View File
@@ -0,0 +1,237 @@
package server
import (
"net/http"
"strings"
"task/internal/sqlite"
"task/internal/syncbackend"
)
func (s *Server) handleGetSync(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
b, err := s.Projects.GetSyncBackend(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, b)
}
func (s *Server) handlePutSync(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body sqlite.SyncBackend
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.ProjectID = id
if body.Kind == "" {
body.Kind = "none"
}
if err := s.Projects.SetSyncBackend(body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, body)
}
func (s *Server) projectSyncConfig(projectID string) (syncbackend.ProjectConfig, error) {
b, err := s.Projects.GetSyncBackend(projectID)
if err != nil {
return syncbackend.ProjectConfig{}, err
}
cfg := syncbackend.ProjectConfig{
Kind: b.Kind,
BaseURL: b.BaseURL,
ProjectKey: b.ProjectKey,
Query: b.Query,
}
if b.ConnectionID != "" {
conn, err := s.DB.GetConnection(b.ConnectionID)
if err != nil {
return cfg, err
}
cfg.Token = conn.Token
if cfg.BaseURL == "" {
cfg.BaseURL = conn.BaseURL
}
}
return cfg, nil
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, true)
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, false)
}
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, pull bool) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
cfg, err := s.projectSyncConfig(id)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
backend, err := s.Sync.Get(cfg.Kind)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var res syncbackend.Result
if pull {
res, err = backend.Pull(r.Context(), id, cfg, s.Tickets, s.Times)
} else {
res, err = backend.Push(r.Context(), id, cfg, s.Tickets, s.Times)
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
op := "sync_push"
if pull {
op = "sync_pull"
}
tickets, _ := s.Tickets.List(id)
for _, t := range tickets {
_, _ = s.DB.AppendRevision("ticket", t.ID, id, op, u.ID, t)
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListConnections()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateConnection(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Scope string `json:"scope"`
Token string `json:"token"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Kind == "" {
body.Kind = "youtrack"
}
c, err := s.DB.CreateConnection(body.Name, body.Kind, body.BaseURL, body.Scope, body.Token, u.ID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, c)
}
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.DB.DeleteConnection(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleConnectionProjects(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
conn, err := s.DB.GetConnection(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
backend, err := s.Sync.Get(conn.Kind)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
projects, err := backend.ListProjects(r.Context(), syncbackend.Connection{
Kind: conn.Kind, BaseURL: conn.BaseURL, Token: conn.Token, Scope: conn.Scope,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleImportProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
connID := r.PathValue("id")
var body struct {
RemoteKey string `json:"remoteKey"`
LocalID string `json:"localId"`
Name string `json:"name"`
Pull bool `json:"pull"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.RemoteKey = strings.TrimSpace(body.RemoteKey)
if body.RemoteKey == "" {
writeError(w, http.StatusBadRequest, "remoteKey required")
return
}
conn, err := s.DB.GetConnection(connID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
name := body.Name
if name == "" {
name = body.RemoteKey
}
// LocalID optional; empty → auto UUID. Explicit id only for advanced clients.
p, err := s.Projects.Create(strings.TrimSpace(body.LocalID), name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
ProjectID: p.ID,
Kind: conn.Kind,
BaseURL: conn.BaseURL,
ProjectKey: body.RemoteKey,
ConnectionID: conn.ID,
})
result := map[string]any{"project": p}
if body.Pull {
cfg, err := s.projectSyncConfig(p.ID)
if err == nil {
if backend, err := s.Sync.Get(cfg.Kind); err == nil {
res, err := backend.Pull(r.Context(), p.ID, cfg, s.Tickets, s.Times)
if err != nil {
result["pullError"] = err.Error()
} else {
result["pull"] = res
}
}
}
}
writeJSON(w, http.StatusCreated, result)
}
+234
View File
@@ -0,0 +1,234 @@
package server
import (
"encoding/json"
"net/http"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
list, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Title string `json:"title"`
Description string `json:"description"`
Status ticket.Status `json:"status"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
t, err := s.Tickets.Create(ticket.Ticket{
Title: body.Title,
Description: body.Description,
Status: body.Status,
ProjectID: id,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "create", u.ID, t)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleGetTicket(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
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handlePatchTicket(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
}
var body struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *ticket.Status `json:"status"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Title != nil {
t.Title = *body.Title
}
if body.Description != nil {
t.Description = *body.Description
}
if body.Status != nil {
t.Status = *body.Status
}
if err := s.Tickets.Update(t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
t, _ = s.Tickets.Get(tid)
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "update", u.ID, t)
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleDeleteTicket(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
}
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "delete", u.ID, t)
if err := s.Tickets.Delete(tid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMoveTicket(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
}
var body struct {
ToProjectID string `json:"toProjectId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, ok, code := s.requireProjectRole(body.ToProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden target project")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
moved, err := s.Tickets.Move(tid, body.ToProjectID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.Times.RetargetTicket(tid, body.ToProjectID)
_, _ = s.DB.AppendRevision("ticket", moved.ID, body.ToProjectID, "move", u.ID, moved)
writeJSON(w, http.StatusOK, moved)
}
func (s *Server) handleListTicketRevisions(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
}
revs, err := s.DB.ListRevisions("ticket", tid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, revs)
}
func (s *Server) handleRestoreTicketRevision(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
rid := r.PathValue("rid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var revID int64
if _, err := parseInt64(rid, &revID); err != nil {
writeError(w, http.StatusBadRequest, "invalid revision id")
return
}
rev, err := s.DB.GetRevision(revID)
if err != nil || rev.EntityID != tid {
writeError(w, http.StatusNotFound, "revision not found")
return
}
var snap ticket.Ticket
if err := json.Unmarshal(rev.Snapshot, &snap); err != nil {
writeError(w, http.StatusInternalServerError, "bad snapshot")
return
}
snap.ID = tid
snap.ProjectID = id
if err := s.Tickets.Update(snap); err != nil {
// recreate if deleted
if _, cerr := s.Tickets.Create(snap); cerr != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
t, _ := s.Tickets.Get(tid)
_, _ = s.DB.AppendRevision("ticket", tid, id, "restore", u.ID, t)
writeJSON(w, http.StatusOK, t)
}
func parseInt64(s string, dest *int64) (int64, error) {
var n int64
for _, c := range s {
if c < '0' || c > '9' {
return 0, errInvalid
}
n = n*10 + int64(c-'0')
}
*dest = n
return n, nil
}
type invalidErr struct{}
func (invalidErr) Error() string { return "invalid" }
var errInvalid = invalidErr{}
+197
View File
@@ -0,0 +1,197 @@
package server
import (
"net/http"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleListProjectTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
list, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
TicketID string `json:"ticketId"`
Duration int64 `json:"durationNs"`
Note string `json:"note"`
Source string `json:"source"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: body.TicketID,
ProjectID: id,
Duration: time.Duration(body.Duration),
Note: body.Note,
Source: body.Source,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "create", u.ID, e)
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Duration int64 `json:"durationNs"`
Note string `json:"note"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var found *ticket.TimeEntry
for i := range entries {
if entries[i].ID == eid {
found = &entries[i]
break
}
}
if found == nil {
writeError(w, http.StatusNotFound, "not found")
return
}
if body.Duration > 0 {
found.Duration = time.Duration(body.Duration)
}
found.Note = body.Note
if err := s.Times.Update(*found); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", found.ID, id, "update", u.ID, found)
writeJSON(w, http.StatusOK, found)
}
func (s *Server) handleDeleteTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
entries, _ := s.Times.ListByProject(id)
for _, e := range entries {
if e.ID == eid {
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "delete", u.ID, e)
break
}
}
if err := s.Times.Delete(eid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
t, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if t == nil {
writeJSON(w, http.StatusOK, map[string]any{"timer": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{"timer": t})
}
func (s *Server) handleStartTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, ok, code := s.requireProjectRole(body.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
t := &ticket.ActiveTimer{
TicketID: body.TicketID,
ProjectID: body.ProjectID,
StartedAt: time.Now().UTC(),
}
if err := s.Times.SaveActiveTimer(t); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = u
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleStopTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
active, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if active == nil {
writeError(w, http.StatusBadRequest, "no active timer")
return
}
if _, ok, code := s.requireProjectRole(active.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
dur := time.Since(active.StartedAt)
if dur < time.Second {
dur = time.Second
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: active.TicketID,
ProjectID: active.ProjectID,
Start: active.StartedAt,
End: time.Now().UTC(),
Duration: dur,
Source: ticket.SourceTimer,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.Times.SaveActiveTimer(nil)
_, _ = s.DB.AppendRevision("time_entry", e.ID, active.ProjectID, "create", u.ID, e)
writeJSON(w, http.StatusOK, e)
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"net/http"
"task/internal/sqlite"
)
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
list, err := s.DB.ListProjectTokens(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r)
if !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
var body struct {
Name string `json:"name"`
Scope string `json:"scope"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Scope == "" {
body.Scope = sqlite.TokenScopeWrite
}
t, err := s.DB.CreateProjectToken(id, body.Name, body.Scope, u.ID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
if err := s.DB.RevokeProjectToken(id, tid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
me, _ := s.currentUser(r)
var body struct {
IsAdmin *bool `json:"isAdmin"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.IsAdmin == nil {
writeError(w, http.StatusBadRequest, "isAdmin required")
return
}
if id == me.ID && !*body.IsAdmin {
writeError(w, http.StatusBadRequest, "cannot remove your own admin flag")
return
}
if err := s.DB.SetUserAdmin(id, *body.IsAdmin); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
u, err := s.DB.GetUserByID(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
u.PasswordHash = ""
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
me, _ := s.currentUser(r)
if id == me.ID {
writeError(w, http.StatusBadRequest, "cannot delete yourself")
return
}
if err := s.DB.DeleteUser(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleDashboard returns a lightweight overview for the front page.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type openTicket struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
ProjectID string `json:"projectId"`
Project string `json:"projectName"`
}
type projSum struct {
ID string `json:"id"`
Name string `json:"name"`
OpenCount int `json:"openCount"`
DurationNs int64 `json:"durationNs"`
}
open := make([]openTicket, 0)
sums := make([]projSum, 0, len(projects))
for _, p := range projects {
tickets, err := s.Tickets.List(p.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
openCount := 0
for _, t := range tickets {
if string(t.Status) != "done" {
openCount++
if len(open) < 20 {
open = append(open, openTicket{
ID: t.ID, Title: t.Title, Status: string(t.Status),
ProjectID: p.ID, Project: p.Name,
})
}
}
}
entries, _ := s.Times.ListByProject(p.ID)
var dur int64
for _, e := range entries {
dur += int64(e.Duration)
}
sums = append(sums, projSum{ID: p.ID, Name: p.Name, OpenCount: openCount, DurationNs: dur})
}
writeJSON(w, http.StatusOK, map[string]any{
"projects": sums,
"openTickets": open,
})
}
+104
View File
@@ -0,0 +1,104 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestProjectTokenBearerAuth(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
admin, err := db.CreateUser("admin", "pass", true)
if 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)
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)
tokBody, _ := json.Marshal(map[string]string{"name": "tui", "scope": "write"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tokens", bytes.NewReader(tokBody))
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 token %d %s", rec.Code, rec.Body.String())
}
var tok map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
raw := tok["token"].(string)
if raw == "" {
t.Fatal("expected raw token")
}
ticketBody, _ := json.Marshal(map[string]string{"title": "Via token"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+raw)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("bearer create ticket %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer list %d", rec.Code)
}
// delete other user
_, _ = db.CreateUser("bob", "x", false)
users, _ := db.ListUsers()
var bobID string
for _, u := range users {
if u.Username == "bob" {
bobID = u.ID
}
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/users/"+bobID, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete user %d %s", rec.Code, rec.Body.String())
}
patch, _ := json.Marshal(map[string]bool{"isAdmin": false})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/users/"+admin.ID, bytes.NewReader(patch))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatal("should not demote self")
}
}
+280
View File
@@ -0,0 +1,280 @@
package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
)
// Role constants for project membership.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleViewer = "viewer"
)
// User is a local account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
IsAdmin bool `json:"isAdmin"`
CreatedAt time.Time `json:"createdAt"`
}
// Member is a project membership.
type Member struct {
ProjectID string `json:"projectId"`
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
Role string `json:"role"`
}
// HashPassword hashes a password with argon2id.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return fmt.Sprintf("%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// CheckPassword verifies a password against a stored hash.
func CheckPassword(password, encoded string) bool {
parts := strings.SplitN(encoded, "$", 2)
if len(parts) != 2 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
if len(got) != len(want) {
return false
}
var v byte
for i := range got {
v |= got[i] ^ want[i]
}
return v == 0
}
// CreateUser inserts a user.
func (db *DB) CreateUser(username, password string, isAdmin bool) (User, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return User{}, fmt.Errorf("username and password required")
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
u := User{
ID: uuid.NewString(),
Username: username,
PasswordHash: hash,
IsAdmin: isAdmin,
CreatedAt: time.Now().UTC(),
}
_, err = db.SQL.Exec(`INSERT INTO users(id, username, password_hash, is_admin, created_at) VALUES(?,?,?,?,?)`,
u.ID, u.Username, u.PasswordHash, boolInt(isAdmin), formatTime(u.CreatedAt))
if err != nil {
return User{}, fmt.Errorf("create user: %w", err)
}
return u, nil
}
// CountUsers returns the number of users.
func (db *DB) CountUsers() (int, error) {
var n int
err := db.SQL.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// GetUserByUsername looks up a user.
func (db *DB) GetUserByUsername(username string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE username = ? COLLATE NOCASE`, username))
}
// GetUserByID looks up a user by id.
func (db *DB) GetUserByID(id string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE id = ?`, id))
}
// ListUsers returns all users.
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.SQL.Query(`SELECT id, username, password_hash, is_admin, created_at FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := db.scanUser(rows)
if err != nil {
return nil, err
}
u.PasswordHash = ""
out = append(out, u)
}
if out == nil {
out = []User{}
}
return out, rows.Err()
}
func (db *DB) scanUser(scanner interface{ Scan(dest ...any) error }) (User, error) {
var u User
var admin int
var created string
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &created)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("user not found")
}
if err != nil {
return User{}, err
}
u.IsAdmin = admin != 0
u.CreatedAt = parseTime(created)
return u, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// CreateSession creates a session and returns the raw token (only once).
func (db *DB) CreateSession(userID string, ttl time.Duration) (rawToken string, expiresAt time.Time, err error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", time.Time{}, err
}
rawToken = base64.RawURLEncoding.EncodeToString(buf)
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
expiresAt = time.Now().UTC().Add(ttl)
_, err = db.SQL.Exec(`INSERT INTO sessions(id, user_id, token_hash, expires_at, created_at) VALUES(?,?,?,?,?)`,
uuid.NewString(), userID, tokenHash, formatTime(expiresAt), formatTime(time.Now().UTC()))
return rawToken, expiresAt, err
}
// UserFromSession resolves a session token to a user.
func (db *DB) UserFromSession(rawToken string) (User, error) {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
var userID, expires string
err := db.SQL.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token_hash = ?`, tokenHash).
Scan(&userID, &expires)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("invalid session")
}
if err != nil {
return User{}, err
}
if parseTime(expires).Before(time.Now().UTC()) {
_, _ = db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return User{}, fmt.Errorf("session expired")
}
return db.GetUserByID(userID)
}
// DeleteSession removes a session by raw token.
func (db *DB) DeleteSession(rawToken string) error {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
_, err := db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return err
}
// SetMember sets or updates a project membership.
func (db *DB) SetMember(projectID, userID, role string) error {
switch role {
case RoleOwner, RoleAdmin, RoleMember, RoleViewer:
default:
return fmt.Errorf("invalid role %q", role)
}
_, err := db.SQL.Exec(`
INSERT INTO project_members(project_id, user_id, role) VALUES(?,?,?)
ON CONFLICT(project_id, user_id) DO UPDATE SET role=excluded.role`,
projectID, userID, role)
return err
}
// RemoveMember deletes a membership.
func (db *DB) RemoveMember(projectID, userID string) error {
_, err := db.SQL.Exec(`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
return err
}
// ListMembers lists members of a project.
func (db *DB) ListMembers(projectID string) ([]Member, error) {
rows, err := db.SQL.Query(`
SELECT m.project_id, m.user_id, u.username, m.role
FROM project_members m
JOIN users u ON u.id = m.user_id
WHERE m.project_id = ?
ORDER BY u.username COLLATE NOCASE`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Member
for rows.Next() {
var m Member
if err := rows.Scan(&m.ProjectID, &m.UserID, &m.Username, &m.Role); err != nil {
return nil, err
}
out = append(out, m)
}
if out == nil {
out = []Member{}
}
return out, rows.Err()
}
// MemberRole returns the role for a user on a project, or empty if none.
func (db *DB) MemberRole(projectID, userID string) (string, error) {
var role string
err := db.SQL.QueryRow(`SELECT role FROM project_members WHERE project_id = ? AND user_id = ?`,
projectID, userID).Scan(&role)
if err == sql.ErrNoRows {
return "", nil
}
return role, err
}
// RoleAtLeast reports whether role meets the required minimum.
func RoleAtLeast(role, required string) bool {
rank := map[string]int{
RoleViewer: 1,
RoleMember: 2,
RoleAdmin: 3,
RoleOwner: 4,
}
return rank[role] >= rank[required]
}
+118
View File
@@ -0,0 +1,118 @@
package sqlite
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Connection is a remote backend credential (instance or project scope).
type Connection struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Scope string `json:"scope"` // instance | project
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
Token string `json:"-"` // only populated when decrypted for sync
}
// CreateConnection stores an encrypted token.
func (db *DB) CreateConnection(name, kind, baseURL, scope, token, createdBy string) (Connection, error) {
name = strings.TrimSpace(name)
kind = strings.TrimSpace(kind)
baseURL = strings.TrimSpace(baseURL)
scope = strings.TrimSpace(scope)
if name == "" || kind == "" || baseURL == "" || token == "" {
return Connection{}, fmt.Errorf("name, kind, baseUrl and token required")
}
if scope == "" {
scope = "instance"
}
enc, err := db.EncryptToken(token)
if err != nil {
return Connection{}, err
}
c := Connection{
ID: uuid.NewString(),
Name: name,
Kind: kind,
BaseURL: baseURL,
Scope: scope,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
}
_, err = db.SQL.Exec(`
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
VALUES(?,?,?,?,?,?,?,?)`,
c.ID, c.Name, c.Kind, c.BaseURL, c.Scope, enc, c.CreatedBy, formatTime(c.CreatedAt))
if err != nil {
return Connection{}, err
}
return c, nil
}
// ListConnections returns connections without tokens.
func (db *DB) ListConnections() ([]Connection, error) {
rows, err := db.SQL.Query(`
SELECT id, name, kind, base_url, scope, created_by, created_at
FROM connections ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Connection
for rows.Next() {
var c Connection
var created string
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &c.CreatedBy, &created); err != nil {
return nil, err
}
c.CreatedAt = parseTime(created)
out = append(out, c)
}
if out == nil {
out = []Connection{}
}
return out, rows.Err()
}
// GetConnection loads a connection and decrypts its token.
func (db *DB) GetConnection(id string) (Connection, error) {
var c Connection
var created string
var blob []byte
err := db.SQL.QueryRow(`
SELECT id, name, kind, base_url, scope, encrypted_token, created_by, created_at
FROM connections WHERE id = ?`, id).
Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &blob, &c.CreatedBy, &created)
if err == sql.ErrNoRows {
return Connection{}, fmt.Errorf("connection %s: not found", id)
}
if err != nil {
return Connection{}, err
}
c.CreatedAt = parseTime(created)
c.Token, err = db.DecryptToken(blob)
if err != nil {
return Connection{}, err
}
return c, nil
}
// DeleteConnection removes a connection.
func (db *DB) DeleteConnection(id string) error {
res, err := db.SQL.Exec(`DELETE FROM connections WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("connection %s: not found", id)
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package sqlite
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
)
func normalizeSecret(secret []byte) []byte {
if len(secret) == 0 {
// Deterministic fallback for local/dev; production should set TASK_MASTER_SECRET.
sum := sha256.Sum256([]byte("task-master-dev-secret"))
return sum[:]
}
sum := sha256.Sum256(secret)
return sum[:]
}
// EncryptToken seals plaintext with AES-GCM.
func (db *DB) EncryptToken(plain string) ([]byte, error) {
block, err := aes.NewCipher(db.Secret)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
}
// DecryptToken opens an AES-GCM blob.
func (db *DB) DecryptToken(blob []byte) (string, error) {
block, err := aes.NewCipher(db.Secret)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(blob) < gcm.NonceSize() {
return "", fmt.Errorf("token ciphertext too short")
}
nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
+129
View File
@@ -0,0 +1,129 @@
package sqlite
import (
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// DB wraps a SQLite connection and domain helpers.
type DB struct {
SQL *sql.DB
Secret []byte // AES-256 key material for connection tokens (derived/padded)
}
// Open opens (or creates) the database at path and runs migrations.
func Open(path string, secret []byte) (*DB, error) {
if path == "" {
return nil, fmt.Errorf("sqlite: path required")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("sqlite: create dir: %w", err)
}
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, fmt.Errorf("sqlite: open: %w", err)
}
sqlDB.SetMaxOpenConns(1) // SQLite writer safety
if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("sqlite: ping: %w", err)
}
db := &DB{SQL: sqlDB, Secret: normalizeSecret(secret)}
if err := db.migrate(); err != nil {
_ = sqlDB.Close()
return nil, err
}
return db, nil
}
// Close closes the underlying connection.
func (db *DB) Close() error {
if db == nil || db.SQL == nil {
return nil
}
return db.SQL.Close()
}
func (db *DB) migrate() error {
if _, err := db.SQL.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("sqlite: schema_migrations: %w", err)
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("sqlite: list migrations: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
var exists string
err := db.SQL.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&exists)
if err == nil {
continue
}
if err != sql.ErrNoRows {
return fmt.Errorf("sqlite: check migration %s: %w", name, err)
}
body, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("sqlite: read migration %s: %w", name, err)
}
tx, err := db.SQL.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(body)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("sqlite: apply %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(name, applied_at) VALUES(?, ?)`,
name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("sqlite: record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
+115
View File
@@ -0,0 +1,115 @@
-- schema version tracked in app_meta
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_members_user ON project_members(user_id);
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
external_url TEXT NOT NULL DEFAULT '',
synced_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id);
CREATE INDEX IF NOT EXISTS idx_tickets_external ON tickets(external_id);
CREATE TABLE IF NOT EXISTS time_entries (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL,
project_id TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
duration_ns INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_time_ticket ON time_entries(ticket_id);
CREATE INDEX IF NOT EXISTS idx_time_project ON time_entries(project_id);
CREATE TABLE IF NOT EXISTS active_timer (
id INTEGER PRIMARY KEY CHECK (id = 1),
ticket_id TEXT NOT NULL,
project_id TEXT NOT NULL,
started_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
base_url TEXT NOT NULL,
scope TEXT NOT NULL,
encrypted_token BLOB NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_backends (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'none',
base_url TEXT NOT NULL DEFAULT '',
project_key TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
connection_id TEXT REFERENCES connections(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
rev INTEGER NOT NULL,
at TEXT NOT NULL,
actor_user_id TEXT,
op TEXT NOT NULL,
snapshot_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revisions_entity ON revisions(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_revisions_project ON revisions(project_id);
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS project_tokens (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_project_tokens_project ON project_tokens(project_id);
CREATE INDEX IF NOT EXISTS idx_project_tokens_hash ON project_tokens(token_hash);
+323
View File
@@ -0,0 +1,323 @@
package sqlite
import (
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"task/internal/project"
)
// ProjectStore implements project.Store on SQLite.
type ProjectStore struct {
db *DB
}
// NewProjectStore returns a project store.
func NewProjectStore(db *DB) *ProjectStore {
return &ProjectStore{db: db}
}
// List returns all projects sorted by name.
func (s *ProjectStore) List() ([]project.Project, error) {
rows, err := s.db.SQL.Query(`SELECT id, name FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
defer rows.Close()
var out []project.Project
for rows.Next() {
var p project.Project
if err := rows.Scan(&p.ID, &p.Name); err != nil {
return nil, err
}
out = append(out, p)
}
if out == nil {
out = []project.Project{}
}
return out, 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")
}
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)
if err != nil {
return fmt.Errorf("ensure project %s: %w", id, err)
}
return nil
}
// Create creates a project. Empty id is replaced with a new UUID.
func (s *ProjectStore) Create(id, name string) (project.Project, error) {
id = strings.TrimSpace(id)
name = strings.TrimSpace(name)
if name == "" {
return project.Project{}, fmt.Errorf("project name is required")
}
if id == "" {
id = uuid.NewString()
}
now := formatTime(time.Now().UTC())
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)`,
id, name, now, now)
if err != nil {
return project.Project{}, fmt.Errorf("create project: %w", err)
}
return project.Project{ID: id, Name: name}, nil
}
// Rename updates the display name.
func (s *ProjectStore) Rename(id, name string) error {
name = strings.TrimSpace(name)
if id == "" || name == "" {
return fmt.Errorf("id and name are required")
}
res, err := s.db.SQL.Exec(`UPDATE projects SET name = ?, updated_at = ? WHERE id = ?`,
name, formatTime(time.Now().UTC()), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Delete removes a project and cascaded rows.
func (s *ProjectStore) Delete(id string) error {
if id == "" {
return fmt.Errorf("project id is required")
}
res, err := s.db.SQL.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete project %s: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Get returns one project.
func (s *ProjectStore) Get(id string) (project.Project, error) {
var p project.Project
err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name)
if err == sql.ErrNoRows {
return project.Project{}, fmt.Errorf("project %s: not found", id)
}
if err != nil {
return project.Project{}, err
}
return p, nil
}
// LoadMeta maps sync_backends (+ connection token) into project.Meta.
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
if id == "" {
return project.Meta{}, fmt.Errorf("project id is required")
}
var kind, baseURL, projectKey, query string
var connID sql.NullString
err := s.db.SQL.QueryRow(`
SELECT kind, base_url, project_key, query, connection_id
FROM sync_backends WHERE project_id = ?`, id).
Scan(&kind, &baseURL, &projectKey, &query, &connID)
if err == sql.ErrNoRows {
return project.Meta{}, nil
}
if err != nil {
return project.Meta{}, fmt.Errorf("load meta %s: %w", id, err)
}
if kind != "youtrack" {
return project.Meta{}, nil
}
yt := &project.YouTrack{
BaseURL: baseURL,
ProjectKey: projectKey,
Query: query,
}
if connID.Valid && connID.String != "" {
token, err := s.db.connectionToken(connID.String)
if err != nil {
return project.Meta{}, err
}
yt.Token = token
}
return project.Meta{YouTrack: yt}, nil
}
// SaveMeta writes YouTrack settings into sync_backends (creates inline connection if token set).
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
if id == "" {
return fmt.Errorf("project id is required")
}
if err := s.Ensure(id); err != nil {
return err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() && strings.TrimSpace(meta.YouTrack.BaseURL) == "" {
_, err := s.db.SQL.Exec(`DELETE FROM sync_backends WHERE project_id = ?`, id)
return err
}
yt := meta.YouTrack
var connID sql.NullString
if strings.TrimSpace(yt.Token) != "" {
cid, err := s.db.ensureProjectConnection(id, yt.BaseURL, yt.Token)
if err != nil {
return err
}
connID = sql.NullString{String: cid, Valid: true}
}
_, err := s.db.SQL.Exec(`
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
VALUES(?, 'youtrack', ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
kind=excluded.kind,
base_url=excluded.base_url,
project_key=excluded.project_key,
query=excluded.query,
connection_id=COALESCE(excluded.connection_id, sync_backends.connection_id)`,
id, yt.BaseURL, yt.ProjectKey, yt.Query, connID)
return err
}
// SyncBackend is the persisted per-project sync config (API-facing).
type SyncBackend struct {
ProjectID string `json:"projectId"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
ProjectKey string `json:"projectKey"`
Query string `json:"query,omitempty"`
ConnectionID string `json:"connectionId,omitempty"`
}
// GetSyncBackend returns sync config for a project.
func (s *ProjectStore) GetSyncBackend(projectID string) (SyncBackend, error) {
var b SyncBackend
var connID sql.NullString
err := s.db.SQL.QueryRow(`
SELECT project_id, kind, base_url, project_key, query, connection_id
FROM sync_backends WHERE project_id = ?`, projectID).
Scan(&b.ProjectID, &b.Kind, &b.BaseURL, &b.ProjectKey, &b.Query, &connID)
if err == sql.ErrNoRows {
return SyncBackend{ProjectID: projectID, Kind: "none"}, nil
}
if err != nil {
return SyncBackend{}, err
}
if connID.Valid {
b.ConnectionID = connID.String
}
return b, nil
}
// SetSyncBackend upserts sync config.
func (s *ProjectStore) SetSyncBackend(b SyncBackend) error {
if b.ProjectID == "" {
return fmt.Errorf("projectId required")
}
if b.Kind == "" {
b.Kind = "none"
}
var connID sql.NullString
if b.ConnectionID != "" {
connID = sql.NullString{String: b.ConnectionID, Valid: true}
}
_, err := s.db.SQL.Exec(`
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
VALUES(?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
kind=excluded.kind,
base_url=excluded.base_url,
project_key=excluded.project_key,
query=excluded.query,
connection_id=excluded.connection_id`,
b.ProjectID, b.Kind, b.BaseURL, b.ProjectKey, b.Query, connID)
return err
}
// ListIDsForUser returns project IDs the user can access (all if admin).
func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Project, error) {
if isAdmin {
return s.List()
}
rows, err := s.db.SQL.Query(`
SELECT p.id, p.name 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)
if err != nil {
return nil, err
}
defer rows.Close()
var out []project.Project
for rows.Next() {
var p project.Project
if err := rows.Scan(&p.ID, &p.Name); err != nil {
return nil, err
}
out = append(out, p)
}
if out == nil {
out = []project.Project{}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, rows.Err()
}
func (db *DB) ensureProjectConnection(projectID, baseURL, token string) (string, error) {
// Reuse existing sync_backends connection if present.
var existing sql.NullString
_ = db.SQL.QueryRow(`SELECT connection_id FROM sync_backends WHERE project_id = ?`, projectID).Scan(&existing)
if existing.Valid && existing.String != "" {
enc, err := db.EncryptToken(token)
if err != nil {
return "", err
}
_, err = db.SQL.Exec(`UPDATE connections SET encrypted_token = ?, base_url = ? WHERE id = ?`,
enc, baseURL, existing.String)
if err != nil {
return "", err
}
return existing.String, nil
}
id := "conn_" + projectID
enc, err := db.EncryptToken(token)
if err != nil {
return "", err
}
_, err = db.SQL.Exec(`
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
VALUES(?, ?, 'youtrack', ?, 'project', ?, NULL, ?)
ON CONFLICT(id) DO UPDATE SET encrypted_token=excluded.encrypted_token, base_url=excluded.base_url`,
id, "project:"+projectID, baseURL, enc, formatTime(time.Now().UTC()))
if err != nil {
return "", err
}
return id, nil
}
func (db *DB) connectionToken(id string) (string, error) {
var blob []byte
err := db.SQL.QueryRow(`SELECT encrypted_token FROM connections WHERE id = ?`, id).Scan(&blob)
if err == sql.ErrNoRows {
return "", fmt.Errorf("connection %s not found", id)
}
if err != nil {
return "", err
}
return db.DecryptToken(blob)
}
+120
View File
@@ -0,0 +1,120 @@
package sqlite
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Revision is a versioned snapshot of an entity.
type Revision struct {
ID int64 `json:"id"`
EntityType string `json:"entityType"`
EntityID string `json:"entityId"`
ProjectID string `json:"projectId"`
Rev int `json:"rev"`
At time.Time `json:"at"`
ActorUserID string `json:"actorUserId,omitempty"`
Op string `json:"op"`
Snapshot json.RawMessage `json:"snapshot"`
}
// AppendRevision stores a new revision snapshot.
func (db *DB) AppendRevision(entityType, entityID, projectID, op, actorUserID string, snapshot any) (Revision, error) {
data, err := json.Marshal(snapshot)
if err != nil {
return Revision{}, err
}
var maxRev sql.NullInt64
_ = db.SQL.QueryRow(`SELECT MAX(rev) FROM revisions WHERE entity_type = ? AND entity_id = ?`,
entityType, entityID).Scan(&maxRev)
rev := 1
if maxRev.Valid {
rev = int(maxRev.Int64) + 1
}
at := time.Now().UTC()
var actor any
if actorUserID != "" {
actor = actorUserID
}
res, err := db.SQL.Exec(`
INSERT INTO revisions(entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json)
VALUES(?,?,?,?,?,?,?,?)`,
entityType, entityID, projectID, rev, formatTime(at), actor, op, string(data))
if err != nil {
return Revision{}, err
}
id, _ := res.LastInsertId()
return Revision{
ID: id,
EntityType: entityType,
EntityID: entityID,
ProjectID: projectID,
Rev: rev,
At: at,
ActorUserID: actorUserID,
Op: op,
Snapshot: data,
}, nil
}
// ListRevisions returns revisions for an entity, newest first.
func (db *DB) ListRevisions(entityType, entityID string) ([]Revision, error) {
rows, err := db.SQL.Query(`
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
FROM revisions WHERE entity_type = ? AND entity_id = ?
ORDER BY rev DESC`, entityType, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRevisions(rows)
}
// GetRevision returns one revision by id.
func (db *DB) GetRevision(id int64) (Revision, error) {
row := db.SQL.QueryRow(`
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
FROM revisions WHERE id = ?`, id)
var r Revision
var at string
var actor sql.NullString
var snap string
err := row.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap)
if err == sql.ErrNoRows {
return Revision{}, fmt.Errorf("revision %d: not found", id)
}
if err != nil {
return Revision{}, err
}
r.At = parseTime(at)
if actor.Valid {
r.ActorUserID = actor.String
}
r.Snapshot = json.RawMessage(snap)
return r, nil
}
func scanRevisions(rows *sql.Rows) ([]Revision, error) {
var out []Revision
for rows.Next() {
var r Revision
var at string
var actor sql.NullString
var snap string
if err := rows.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap); err != nil {
return nil, err
}
r.At = parseTime(at)
if actor.Valid {
r.ActorUserID = actor.String
}
r.Snapshot = json.RawMessage(snap)
out = append(out, r)
}
if out == nil {
out = []Revision{}
}
return out, rows.Err()
}
+98
View File
@@ -0,0 +1,98 @@
package sqlite
import (
"path/filepath"
"testing"
"time"
"task/internal/ticket"
)
func TestStoresRoundTrip(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "test.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
if err := ps.Ensure("inbox"); err != nil {
t.Fatal(err)
}
ts := NewTicketStore(db)
created, err := ts.Create(ticket.Ticket{Title: "Hello", ProjectID: "inbox", Status: ticket.StatusTodo})
if err != nil {
t.Fatal(err)
}
got, err := ts.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "Hello" {
t.Fatalf("title=%q", got.Title)
}
got.Title = "Updated"
if err := ts.Update(got); err != nil {
t.Fatal(err)
}
if err := ts.MarkSynced(got.ID); err != nil {
t.Fatal(err)
}
synced, _ := ts.Get(got.ID)
if synced.SyncedAt.IsZero() || !synced.SyncedAt.Equal(synced.UpdatedAt) {
t.Fatalf("syncedAt=%v updatedAt=%v", synced.SyncedAt, synced.UpdatedAt)
}
timeStore := NewTimeStore(db)
_, err = timeStore.Create(ticket.TimeEntry{
TicketID: created.ID,
ProjectID: "inbox",
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
sum, err := timeStore.SumByTicket(created.ID)
if err != nil || sum != time.Hour {
t.Fatalf("sum=%v err=%v", sum, err)
}
u, err := db.CreateUser("admin", "secret", true)
if err != nil {
t.Fatal(err)
}
token, _, err := db.CreateSession(u.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
me, err := db.UserFromSession(token)
if err != nil || me.Username != "admin" {
t.Fatalf("session user=%v err=%v", me, err)
}
if err := db.SetMember("inbox", u.ID, RoleOwner); err != nil {
t.Fatal(err)
}
role, _ := db.MemberRole("inbox", u.ID)
if role != RoleOwner {
t.Fatalf("role=%s", role)
}
_, err = db.AppendRevision("ticket", created.ID, "inbox", "update", u.ID, synced)
if err != nil {
t.Fatal(err)
}
revs, err := db.ListRevisions("ticket", created.ID)
if err != nil || len(revs) != 1 {
t.Fatalf("revs=%v err=%v", revs, err)
}
conn, err := db.CreateConnection("YT", "youtrack", "https://yt.example", "instance", "tok-123", u.ID)
if err != nil {
t.Fatal(err)
}
loaded, err := db.GetConnection(conn.ID)
if err != nil || loaded.Token != "tok-123" {
t.Fatalf("token=%q err=%v", loaded.Token, err)
}
}
+190
View File
@@ -0,0 +1,190 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"task/internal/ticket"
)
// TicketStore implements ticket.Store on SQLite.
type TicketStore struct {
db *DB
}
// NewTicketStore returns a ticket store.
func NewTicketStore(db *DB) *TicketStore {
return &TicketStore{db: db}
}
func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, error) {
var t ticket.Ticket
var created, updated, synced string
var status string
err := scanner.Scan(
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
)
if err != nil {
return ticket.Ticket{}, err
}
t.Status = ticket.Status(status)
t.CreatedAt = parseTime(created)
t.UpdatedAt = parseTime(updated)
t.SyncedAt = parseTime(synced)
return t, nil
}
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
// List returns tickets for a project.
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
rows, err := s.db.SQL.Query(`SELECT `+ticketCols+` FROM tickets WHERE project_id = ? ORDER BY updated_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ticket.Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
if out == nil {
out = []ticket.Ticket{}
}
return out, rows.Err()
}
// Get finds a ticket by ID.
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
row := s.db.SQL.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
}
return t, nil
}
// Create inserts a ticket.
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
if t.ProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
if t.Title == "" {
return ticket.Ticket{}, fmt.Errorf("title is required")
}
if t.ID == "" {
t.ID = uuid.NewString()
}
if t.Status == "" {
t.Status = ticket.StatusTodo
}
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
_, err := s.db.SQL.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),
)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
}
return t, nil
}
// Update replaces a ticket and bumps UpdatedAt.
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)
if err == sql.ErrNoRows {
return fmt.Errorf("ticket %s: not found", t.ID)
}
if err != nil {
return err
}
t.CreatedAt = parseTime(created)
t.UpdatedAt = time.Now().UTC()
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=?
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,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", t.ID)
}
return nil
}
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
func (s *TicketStore) MarkSynced(id string) error {
if id == "" {
return fmt.Errorf("ticket id is required")
}
res, err := s.db.SQL.Exec(`UPDATE tickets SET synced_at = updated_at WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", id)
}
return nil
}
// Move relocates a ticket to another project.
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
if id == "" {
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
}
if toProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
t, err := s.Get(id)
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)
if err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Delete removes a ticket.
func (s *TicketStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM tickets WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", id)
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"task/internal/ticket"
)
// TimeStore implements ticket.TimeStore on SQLite.
type TimeStore struct {
db *DB
}
// NewTimeStore returns a time store.
func NewTimeStore(db *DB) *TimeStore {
return &TimeStore{db: db}
}
func scanEntry(scanner interface{ Scan(dest ...any) error }) (ticket.TimeEntry, error) {
var e ticket.TimeEntry
var start, end string
var dur int64
err := scanner.Scan(&e.ID, &e.TicketID, &e.ProjectID, &start, &end, &dur, &e.Source, &e.Note, &e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
e.Start = parseTime(start)
e.End = parseTime(end)
e.Duration = time.Duration(dur)
return e, nil
}
const entryCols = `id, ticket_id, project_id, start, end, duration_ns, source, note, external_id`
// List returns all time entries.
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT ` + entryCols + ` FROM time_entries ORDER BY start DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByTicket returns entries for one ticket.
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE ticket_id = ? ORDER BY start DESC`, ticketID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByProject returns entries for a project.
func (s *TimeStore) ListByProject(projectID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE project_id = ? ORDER BY start DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
func collectEntries(rows *sql.Rows) ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
if out == nil {
out = []ticket.TimeEntry{}
}
return out, rows.Err()
}
// Create adds a time entry.
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
if e.TicketID == "" {
return ticket.TimeEntry{}, fmt.Errorf("ticketId is required")
}
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
e.Duration = e.End.Sub(e.Start)
}
if e.Duration <= 0 {
return ticket.TimeEntry{}, fmt.Errorf("duration must be positive")
}
if e.ID == "" {
e.ID = uuid.NewString()
}
if e.Source == "" {
e.Source = ticket.SourceManual
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
if e.End.IsZero() {
e.End = e.Start.Add(e.Duration)
}
_, err := s.db.SQL.Exec(`INSERT INTO time_entries(`+entryCols+`) VALUES(?,?,?,?,?,?,?,?,?)`,
e.ID, e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End),
int64(e.Duration), e.Source, e.Note, e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
return e, nil
}
// Update replaces a time entry.
func (s *TimeStore) Update(e ticket.TimeEntry) error {
if e.ID == "" {
return fmt.Errorf("time entry id is required")
}
if e.Duration <= 0 {
return fmt.Errorf("duration must be positive")
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
e.End = e.Start.Add(e.Duration)
res, err := s.db.SQL.Exec(`
UPDATE time_entries SET ticket_id=?, project_id=?, start=?, end=?, duration_ns=?, source=?, note=?, external_id=?
WHERE id=?`,
e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End), int64(e.Duration), e.Source, e.Note, e.ExternalID, e.ID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", e.ID)
}
return nil
}
// Delete removes a time entry.
func (s *TimeStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", id)
}
return nil
}
// RetargetTicket updates project_id for all entries of a ticket.
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
if ticketID == "" || projectID == "" {
return fmt.Errorf("ticketId and projectId are required")
}
_, err := s.db.SQL.Exec(`UPDATE time_entries SET project_id = ? WHERE ticket_id = ?`, projectID, ticketID)
return err
}
// DeleteByProject removes all entries for a project.
func (s *TimeStore) DeleteByProject(projectID string) error {
if projectID == "" {
return fmt.Errorf("projectId is required")
}
_, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE project_id = ?`, projectID)
return err
}
// SumByTicket returns total duration.
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
var sum sql.NullInt64
err := s.db.SQL.QueryRow(`SELECT SUM(duration_ns) FROM time_entries WHERE ticket_id = ?`, ticketID).Scan(&sum)
if err != nil {
return 0, err
}
if !sum.Valid {
return 0, nil
}
return time.Duration(sum.Int64), nil
}
// LoadActiveTimer returns the running timer or nil.
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
var t ticket.ActiveTimer
var started string
err := s.db.SQL.QueryRow(`SELECT ticket_id, project_id, started_at FROM active_timer WHERE id = 1`).
Scan(&t.TicketID, &t.ProjectID, &started)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
t.StartedAt = parseTime(started)
if t.TicketID == "" {
return nil, nil
}
return &t, nil
}
// SaveActiveTimer writes or clears the active timer.
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
if t == nil {
_, err := s.db.SQL.Exec(`DELETE FROM active_timer WHERE id = 1`)
return err
}
_, err := s.db.SQL.Exec(`
INSERT INTO active_timer(id, ticket_id, project_id, started_at) VALUES(1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ticket_id=excluded.ticket_id, project_id=excluded.project_id, started_at=excluded.started_at`,
t.TicketID, t.ProjectID, formatTime(t.StartedAt))
return err
}
+226
View File
@@ -0,0 +1,226 @@
package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
TokenScopeRead = "read"
TokenScopeWrite = "write" // includes read
)
// ProjectToken is a project-scoped API credential (secret only on create).
type ProjectToken struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Scope string `json:"scope"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
Token string `json:"token,omitempty"` // raw secret, only set on create
}
// CreateProjectToken issues a new token; raw value is returned once in Token.
func (db *DB) CreateProjectToken(projectID, name, scope, createdBy string) (ProjectToken, error) {
name = strings.TrimSpace(name)
scope = strings.TrimSpace(scope)
if projectID == "" || name == "" {
return ProjectToken{}, fmt.Errorf("projectId and name required")
}
if scope != TokenScopeRead && scope != TokenScopeWrite {
return ProjectToken{}, fmt.Errorf("scope must be read or write")
}
raw, err := randomToken()
if err != nil {
return ProjectToken{}, err
}
hash := hashToken(raw)
t := ProjectToken{
ID: uuid.NewString(),
ProjectID: projectID,
Name: name,
Scope: scope,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
Token: raw,
}
_, err = db.SQL.Exec(`
INSERT INTO project_tokens(id, project_id, name, token_hash, scope, created_by, created_at)
VALUES(?,?,?,?,?,?,?)`,
t.ID, t.ProjectID, t.Name, hash, t.Scope, nullStr(createdBy), formatTime(t.CreatedAt))
if err != nil {
return ProjectToken{}, err
}
return t, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return "tm_" + base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return base64.RawStdEncoding.EncodeToString(sum[:])
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
// ListProjectTokens returns non-secret token metadata for a project.
func (db *DB) ListProjectTokens(projectID string) ([]ProjectToken, error) {
rows, err := db.SQL.Query(`
SELECT id, project_id, name, scope, created_by, created_at, revoked_at
FROM project_tokens WHERE project_id = ?
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProjectToken
for rows.Next() {
t, err := scanProjectToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
if out == nil {
out = []ProjectToken{}
}
return out, rows.Err()
}
// RevokeProjectToken soft-revokes a token.
func (db *DB) RevokeProjectToken(projectID, tokenID string) error {
res, err := db.SQL.Exec(`
UPDATE project_tokens SET revoked_at = ? WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
formatTime(time.Now().UTC()), tokenID, projectID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("token not found or already revoked")
}
return nil
}
// TokenAuth is the resolved bearer identity.
type TokenAuth struct {
User User
ProjectID string
Scope string
TokenID string
}
// ResolveBearerToken validates a raw API token.
func (db *DB) ResolveBearerToken(raw string) (TokenAuth, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return TokenAuth{}, fmt.Errorf("empty token")
}
hash := hashToken(raw)
var (
tokenID, projectID, scope string
createdBy sql.NullString
revoked sql.NullString
)
err := db.SQL.QueryRow(`
SELECT id, project_id, scope, created_by, revoked_at
FROM project_tokens WHERE token_hash = ?`, hash).
Scan(&tokenID, &projectID, &scope, &createdBy, &revoked)
if err == sql.ErrNoRows {
return TokenAuth{}, fmt.Errorf("invalid token")
}
if err != nil {
return TokenAuth{}, err
}
if revoked.Valid && revoked.String != "" {
return TokenAuth{}, fmt.Errorf("token revoked")
}
var u User
if createdBy.Valid && createdBy.String != "" {
u, err = db.GetUserByID(createdBy.String)
if err != nil {
// creator deleted — synthetic limited principal
u = User{ID: createdBy.String, Username: "token", IsAdmin: false}
}
} else {
u = User{ID: "token:" + tokenID, Username: "token", IsAdmin: false}
}
u.PasswordHash = ""
return TokenAuth{User: u, ProjectID: projectID, Scope: scope, TokenID: tokenID}, nil
}
// TokenAllows reports whether scope permits the operation (write implies read).
func TokenAllows(scope, need string) bool {
if need == TokenScopeRead {
return scope == TokenScopeRead || scope == TokenScopeWrite
}
return scope == TokenScopeWrite
}
func scanProjectToken(scanner interface{ Scan(dest ...any) error }) (ProjectToken, error) {
var t ProjectToken
var createdBy sql.NullString
var created, revoked sql.NullString
err := scanner.Scan(&t.ID, &t.ProjectID, &t.Name, &t.Scope, &createdBy, &created, &revoked)
if err != nil {
return ProjectToken{}, err
}
if createdBy.Valid {
t.CreatedBy = createdBy.String
}
t.CreatedAt = parseTime(created.String)
if revoked.Valid && revoked.String != "" {
tm := parseTime(revoked.String)
t.RevokedAt = &tm
}
return t, nil
}
// DeleteUser removes a user and their sessions.
func (db *DB) DeleteUser(id string) error {
if id == "" {
return fmt.Errorf("user id required")
}
res, err := db.SQL.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}
// SetUserAdmin updates the is_admin flag.
func (db *DB) SetUserAdmin(id string, isAdmin bool) error {
res, err := db.SQL.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package syncbackend
import (
"context"
"fmt"
"task/internal/project"
"task/internal/ticket"
"task/internal/ytsync"
)
// RemoteProject is a project discovered on a remote backend.
type RemoteProject struct {
Key string `json:"key"`
Name string `json:"name"`
ID string `json:"id,omitempty"`
}
// Connection credentials for a remote instance or project.
type Connection struct {
Kind string
BaseURL string
Token string
Scope string
}
// ProjectConfig is the per-local-project sync settings.
type ProjectConfig struct {
Kind string
BaseURL string
Token string
ProjectKey string
Query string
}
// Result reuses ytsync.Result for pull/push summaries.
type Result = ytsync.Result
// Backend syncs tickets with an external system.
type Backend interface {
Kind() string
ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error)
Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
}
// Registry maps kind → Backend.
type Registry struct {
backends map[string]Backend
}
// NewRegistry returns a registry with built-in backends.
func NewRegistry() *Registry {
r := &Registry{backends: map[string]Backend{}}
r.Register(&NoneBackend{})
r.Register(&YouTrackBackend{})
return r
}
// Register adds a backend.
func (r *Registry) Register(b Backend) {
r.backends[b.Kind()] = b
}
// Get returns a backend by kind.
func (r *Registry) Get(kind string) (Backend, error) {
if kind == "" {
kind = "none"
}
b, ok := r.backends[kind]
if !ok {
return nil, fmt.Errorf("unknown sync backend %q", kind)
}
return b, nil
}
// NoneBackend disables sync.
type NoneBackend struct{}
func (NoneBackend) Kind() string { return "none" }
func (NoneBackend) ListProjects(context.Context, Connection) ([]RemoteProject, error) {
return nil, fmt.Errorf("no remote projects for kind none")
}
func (NoneBackend) Pull(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
return Result{}, fmt.Errorf("sync disabled")
}
func (NoneBackend) Push(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
return Result{}, fmt.Errorf("sync disabled")
}
// YouTrackBackend wraps existing ytsync.
type YouTrackBackend struct{}
func (YouTrackBackend) Kind() string { return "youtrack" }
func (YouTrackBackend) ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error) {
return ListYouTrackProjects(ctx, conn.BaseURL, conn.Token)
}
func (YouTrackBackend) Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
yt := toYouTrack(cfg)
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Pull(ctx, projectID, yt)
}
func (YouTrackBackend) Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
yt := toYouTrack(cfg)
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Push(ctx, projectID, yt)
}
func toYouTrack(cfg ProjectConfig) project.YouTrack {
return project.YouTrack{
BaseURL: cfg.BaseURL,
Token: cfg.Token,
ProjectKey: cfg.ProjectKey,
Query: cfg.Query,
}
}
+29
View File
@@ -0,0 +1,29 @@
package syncbackend
import (
"context"
"task/internal/youtrack"
)
// ListYouTrackProjects lists projects via a master/instance token.
func ListYouTrackProjects(ctx context.Context, baseURL, token string) ([]RemoteProject, error) {
client := &youtrack.Client{BaseURL: baseURL, Token: token}
projects, err := client.ListProjects(ctx)
if err != nil {
return nil, err
}
out := make([]RemoteProject, 0, len(projects))
for _, p := range projects {
name := p.Name
if name == "" {
name = p.ShortName
}
out = append(out, RemoteProject{
Key: p.ShortName,
Name: name,
ID: p.ID,
})
}
return out, nil
}
+2
View File
@@ -53,6 +53,8 @@ func (s Status) Index() int {
const ( const (
// SourceYouTrack marks tickets imported from YouTrack. // SourceYouTrack marks tickets imported from YouTrack.
SourceYouTrack = "youtrack" SourceYouTrack = "youtrack"
// SourceTaskMaster marks tickets synced from a task-master server.
SourceTaskMaster = "taskmaster"
) )
// Ticket is the domain model for a work item. // Ticket is the domain model for a work item.
+330
View File
@@ -0,0 +1,330 @@
package tmsync
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"task/internal/project"
"task/internal/ticket"
"task/internal/ytsync"
)
// Result reuses ytsync.Result for consistent CLI/UI messaging.
type Result = ytsync.Result
// Syncer syncs a local project with a remote tm_server project.
type Syncer struct {
Store ticket.Store
TimeStore ticket.TimeStore
HTTP *http.Client
}
func (s *Syncer) http() *http.Client {
if s.HTTP != nil {
return s.HTTP
}
return http.DefaultClient
}
// NeedsPush reports whether a local ticket should be pushed to task-master.
func NeedsPush(t ticket.Ticket) bool {
if t.ExternalID == "" {
return true
}
if t.Source != ticket.SourceTaskMaster {
return false
}
if t.SyncedAt.IsZero() {
return true
}
return t.UpdatedAt.After(t.SyncedAt)
}
// Pull imports remote tickets/time into localProjectID.
func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
if !tm.Configured() {
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
remote, err := s.listTickets(ctx, tm)
if err != nil {
return Result{}, err
}
existing, err := s.Store.List(localProjectID)
if err != nil {
return Result{}, err
}
byExt := map[string]ticket.Ticket{}
for _, t := range existing {
if t.ExternalID != "" {
byExt[t.ExternalID] = t
}
}
var res Result
res.Total = len(remote)
for _, rt := range remote {
if rt.ID == "" {
res.Skipped++
continue
}
extURL := strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + rt.ID
if prev, ok := byExt[rt.ID]; ok {
if NeedsPush(prev) {
res.Skipped++
continue
}
next := prev
next.Title = rt.Title
next.Description = rt.Description
next.Status = rt.Status
next.Source = ticket.SourceTaskMaster
next.ExternalURL = extURL
if sameFields(prev, next) {
_ = s.Store.MarkSynced(prev.ID)
res.Skipped++
continue
}
if err := s.Store.Update(next); err != nil {
return res, err
}
_ = s.Store.MarkSynced(next.ID)
res.Updated++
continue
}
now := time.Now().UTC()
_, err := s.Store.Create(ticket.Ticket{
Title: rt.Title, Description: rt.Description, Status: rt.Status,
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
ExternalID: rt.ID, ExternalURL: extURL,
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
})
if err != nil {
return res, err
}
res.Created++
}
if err := s.pullTime(ctx, tm, localProjectID, &res); err != nil {
return res, err
}
return res, nil
}
// Push sends dirty local tickets/time to the remote project.
func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
if !tm.Configured() {
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
existing, err := s.Store.List(localProjectID)
if err != nil {
return Result{}, err
}
var res Result
for _, t := range existing {
if !NeedsPush(t) {
res.Skipped++
continue
}
res.Total++
if t.ExternalID == "" {
created, err := s.createRemote(ctx, tm, t)
if err != nil {
return res, err
}
t.ExternalID = created.ID
t.Source = ticket.SourceTaskMaster
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
if err := s.Store.Update(t); err != nil {
return res, err
}
_ = s.Store.MarkSynced(t.ID)
res.Created++
res.Pushed++
continue
}
if err := s.patchRemote(ctx, tm, t); err != nil {
return res, err
}
_ = s.Store.MarkSynced(t.ID)
res.Updated++
res.Pushed++
}
if err := s.pushTime(ctx, tm, localProjectID, &res); err != nil {
return res, err
}
return res, nil
}
func sameFields(a, b ticket.Ticket) bool {
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status
}
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
var out []ticket.Ticket
err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/tickets", nil, &out)
return out, err
}
func (s *Syncer) createRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) (ticket.Ticket, error) {
body := map[string]any{
"title": t.Title, "description": t.Description, "status": t.Status,
}
var created ticket.Ticket
err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/tickets", body, &created)
return created, err
}
func (s *Syncer) patchRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) error {
body := map[string]any{
"title": t.Title, "description": t.Description, "status": t.Status,
}
return s.doJSON(ctx, tm, http.MethodPatch,
"/projects/"+tm.ProjectID+"/tickets/"+t.ExternalID, body, nil)
}
func (s *Syncer) pullTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
var remote []ticket.TimeEntry
if err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/time", nil, &remote); err != nil {
return err
}
localTickets, err := s.Store.List(localProjectID)
if err != nil {
return err
}
localByExt := map[string]ticket.Ticket{}
for _, t := range localTickets {
if t.ExternalID != "" {
localByExt[t.ExternalID] = t
}
}
existing, err := s.TimeStore.List()
if err != nil {
return err
}
have := map[string]bool{}
for _, e := range existing {
if e.ExternalID != "" {
have[e.ExternalID] = true
}
}
for _, e := range remote {
if e.ID == "" || have[e.ID] {
continue
}
lt, ok := localByExt[e.TicketID]
if !ok {
continue
}
_, err := s.TimeStore.Create(ticket.TimeEntry{
TicketID: lt.ID, ProjectID: localProjectID,
Start: e.Start, End: e.End, Duration: e.Duration,
Source: ticket.SourceTaskMaster, Note: e.Note, ExternalID: e.ID,
})
if err != nil {
return err
}
res.TimePulled++
}
return nil
}
func (s *Syncer) pushTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(localProjectID)
if err != nil {
return err
}
byID := map[string]ticket.Ticket{}
for _, t := range tickets {
byID[t.ID] = t
}
entries, err := s.TimeStore.List()
if err != nil {
return err
}
for _, e := range entries {
if e.ProjectID != localProjectID || e.ExternalID != "" {
continue
}
t, ok := byID[e.TicketID]
if !ok || t.ExternalID == "" {
continue
}
body := map[string]any{
"ticketId": t.ExternalID,
"durationNs": int64(e.Duration),
"note": e.Note,
"source": e.Source,
}
var created ticket.TimeEntry
if err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/time", body, &created); err != nil {
return err
}
e.ExternalID = created.ID
if err := s.TimeStore.Update(e); err != nil {
return err
}
res.TimePushed++
}
return nil
}
func (s *Syncer) doJSON(ctx context.Context, tm project.TaskMaster, method, path string, body any, dest any) error {
var rdr io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(data)
}
url := strings.TrimRight(strings.TrimSpace(tm.BaseURL), "/") + "/api/v1" + path
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tm.Token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := s.http().Do(req)
if err != nil {
return fmt.Errorf("taskmaster: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var eresp struct {
Error string `json:"error"`
}
_ = json.Unmarshal(data, &eresp)
msg := eresp.Error
if msg == "" {
msg = string(data)
}
return fmt.Errorf("taskmaster: %s: %s", resp.Status, msg)
}
if dest == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, dest)
}
+39 -5
View File
@@ -19,6 +19,7 @@ import (
"task/internal/ticket" "task/internal/ticket"
"task/internal/ui/views" "task/internal/ui/views"
"task/internal/version" "task/internal/version"
"task/internal/tmsync"
"task/internal/youtrack" "task/internal/youtrack"
"task/internal/ytsync" "task/internal/ytsync"
) )
@@ -826,6 +827,14 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.projectEdit.PrevField() m.projectEdit.PrevField()
return m, textinput.Blink return m, textinput.Blink
case msg.Type == tea.KeyLeft:
m.projectEdit.CycleBackend(-1)
return m, nil
case msg.Type == tea.KeyRight:
m.projectEdit.CycleBackend(1)
return m, nil
case key.Matches(msg, m.keys.Enter): case key.Matches(msg, m.keys.Enter):
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil { if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
m.setError(err) m.setError(err)
@@ -842,10 +851,10 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.mode = ModeProjects m.mode = ModeProjects
if meta.YouTrack != nil && meta.YouTrack.Configured() { if meta.SyncConfigured() {
m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id)) m.setStatus(fmt.Sprintf("saved %s sync for %s", meta.EffectiveBackend(), id))
} else { } else {
m.setStatus(fmt.Sprintf("cleared YouTrack settings for %s", id)) m.setStatus(fmt.Sprintf("cleared sync settings for %s", id))
} }
return m, nil return m, nil
} }
@@ -866,12 +875,21 @@ func (m *Model) openProjectEdit(projectID string) error {
func (m *Model) openSyncMenu(projectID string) { func (m *Model) openSyncMenu(projectID string) {
m.syncProjectID = projectID m.syncProjectID = projectID
label := "Sync"
if meta, err := m.projects.LoadMeta(projectID); err == nil {
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
label = "task-master"
case project.BackendYouTrack:
label = "YouTrack"
}
}
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{ m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox, Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle, Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected, Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal, Normal: m.styles.StatusMenuNormal,
}) }, label)
m.mode = ModeSync m.mode = ModeSync
} }
@@ -925,12 +943,28 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
return m, func() tea.Msg { return m, func() tea.Msg {
var pull, push ytsync.Result var pull, push ytsync.Result
var err error 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)
default:
pull, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
}
default:
switch action { switch action {
case views.SyncActionPush: case views.SyncActionPush:
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID) push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
default: default:
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID) pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
} }
}
}
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err} return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
} }
} }
@@ -1451,7 +1485,7 @@ func (m *Model) openProjects() error {
if err != nil { if err != nil {
return err return err
} }
yt[p.ID] = meta.YouTrack != nil && meta.YouTrack.Configured() yt[p.ID] = meta.SyncConfigured()
} }
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH) m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
return nil return nil
+2 -2
View File
@@ -34,7 +34,7 @@ func (m Model) helpEntries() []helpEntry {
{"enter", "select"}, {"enter", "select"},
{"n", "new project"}, {"n", "new project"},
{"e", "edit"}, {"e", "edit"},
{"Y", "YouTrack"}, {"Y", "Sync"},
{"x", "delete"}, {"x", "delete"},
{"X", "force delete"}, {"X", "force delete"},
{"esc", "cancel"}, {"esc", "cancel"},
@@ -121,7 +121,7 @@ func (m Model) helpEntries() []helpEntry {
{"t/T", "timer/log"}, {"t/T", "timer/log"},
{"m", "entries"}, {"m", "entries"},
{"r", "report"}, {"r", "report"},
{"Y", "YouTrack"}, {"Y", "Sync"},
{"u/U", "undo/redo"}, {"u/U", "undo/redo"},
{"/", "search"}, {"/", "search"},
{"p", "project"}, {"p", "project"},
+165 -29
View File
@@ -11,7 +11,7 @@ import (
"task/internal/project" "task/internal/project"
) )
// ProjectEdit field indices. // ProjectEdit field indices (backend picker is separate).
const ( const (
FieldRemoteURL = iota FieldRemoteURL = iota
FieldToken FieldToken
@@ -19,6 +19,12 @@ const (
ProjectEditFieldCount ProjectEditFieldCount
) )
var backendOptions = []string{
project.BackendNone,
project.BackendYouTrack,
project.BackendTaskMaster,
}
// ProjectEditStyles styles the project edit form. // ProjectEditStyles styles the project edit form.
type ProjectEditStyles struct { type ProjectEditStyles struct {
Label lipgloss.Style Label lipgloss.Style
@@ -39,9 +45,12 @@ func DefaultProjectEditStyles() ProjectEditStyles {
} }
} }
// ProjectEditForm is a multi-field editor for YouTrack project settings. // ProjectEditForm is a multi-field editor for sync backend settings.
type ProjectEditForm struct { type ProjectEditForm struct {
ProjectID string ProjectID string
Backend string
BackendIdx int
FocusBackend bool // true when selecting backend
Inputs []textinput.Model Inputs []textinput.Model
Focus int Focus int
Styles ProjectEditStyles Styles ProjectEditStyles
@@ -67,11 +76,37 @@ func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectE
return ti return ti
} }
backend := meta.EffectiveBackend()
if backend == "" {
backend = project.BackendNone
}
bIdx := 0
for i, b := range backendOptions {
if b == backend {
bIdx = i
break
}
}
hadToken := false
switch backend {
case project.BackendTaskMaster:
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
inputs[FieldToken] = mk("tm_… project token", 512, true)
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
if meta.TaskMaster != nil {
tm := meta.TaskMaster
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
inputs[FieldProjectKey].SetValue(tm.ProjectID)
if tm.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
}
}
default:
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false) inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
inputs[FieldToken] = mk("permanent token", 512, true) inputs[FieldToken] = mk("permanent token", 512, true)
inputs[FieldProjectKey] = mk("PROJ", 64, false) inputs[FieldProjectKey] = mk("PROJ", 64, false)
hadToken := false
if meta.YouTrack != nil { if meta.YouTrack != nil {
yt := meta.YouTrack yt := meta.YouTrack
inputs[FieldRemoteURL].SetValue(yt.BaseURL) inputs[FieldRemoteURL].SetValue(yt.BaseURL)
@@ -81,20 +116,42 @@ func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectE
inputs[FieldToken].Placeholder = "(unchanged — type to replace)" inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
} }
} }
}
f := ProjectEditForm{ f := ProjectEditForm{
ProjectID: projectID, ProjectID: projectID,
Backend: backend,
BackendIdx: bIdx,
FocusBackend: true,
Inputs: inputs, Inputs: inputs,
Focus: FieldRemoteURL, Focus: FieldRemoteURL,
Styles: styles, Styles: styles,
Width: width, Width: width,
hadToken: hadToken, hadToken: hadToken,
} }
f.focusIndex(FieldRemoteURL)
return f return f
} }
func (f *ProjectEditForm) applyBackendPlaceholders() {
f.Backend = backendOptions[f.BackendIdx]
switch f.Backend {
case project.BackendTaskMaster:
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
f.Inputs[FieldToken].Placeholder = "tm_… project token"
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
case project.BackendYouTrack:
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
f.Inputs[FieldToken].Placeholder = "permanent token"
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
default:
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
f.Inputs[FieldToken].Placeholder = "(unused)"
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
}
}
func (f *ProjectEditForm) focusIndex(i int) { func (f *ProjectEditForm) focusIndex(i int) {
f.FocusBackend = false
f.Focus = i f.Focus = i
for idx := range f.Inputs { for idx := range f.Inputs {
if idx == i { if idx == i {
@@ -111,16 +168,52 @@ func (f *ProjectEditForm) focusIndex(i int) {
// NextField moves focus to the next field. // NextField moves focus to the next field.
func (f *ProjectEditForm) NextField() { func (f *ProjectEditForm) NextField() {
if f.FocusBackend {
if f.Backend == project.BackendNone {
return
}
f.focusIndex(FieldRemoteURL)
return
}
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount) f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
} }
// PrevField moves focus to the previous field. // PrevField moves focus to the previous field.
func (f *ProjectEditForm) PrevField() { func (f *ProjectEditForm) PrevField() {
if f.FocusBackend {
return
}
if f.Focus == FieldRemoteURL {
f.blurInputs()
f.FocusBackend = true
return
}
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount) f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
} }
func (f *ProjectEditForm) blurInputs() {
for idx := range f.Inputs {
f.Inputs[idx].Blur()
f.Inputs[idx].PromptStyle = f.Styles.Blurred
f.Inputs[idx].TextStyle = f.Styles.Blurred
}
}
// CycleBackend changes the selected backend when focusing the picker.
func (f *ProjectEditForm) CycleBackend(delta int) {
if !f.FocusBackend {
return
}
n := len(backendOptions)
f.BackendIdx = (f.BackendIdx + delta + n) % n
f.applyBackendPlaceholders()
}
// Update forwards a message to the focused text input. // Update forwards a message to the focused text input.
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd { func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
if f.FocusBackend || f.Backend == project.BackendNone {
return nil
}
var cmd tea.Cmd var cmd tea.Cmd
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg) f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" { if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
@@ -131,60 +224,103 @@ func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
// Meta builds the Meta to persist. Empty token keeps the previous token. // Meta builds the Meta to persist. Empty token keeps the previous token.
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta { func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
backend := backendOptions[f.BackendIdx]
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value()) baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
token := strings.TrimSpace(f.Inputs[FieldToken].Value()) token := strings.TrimSpace(f.Inputs[FieldToken].Value())
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value()) key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
switch backend {
case project.BackendNone:
return project.Meta{Backend: project.BackendNone}
case project.BackendTaskMaster:
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
if token == "" && previous.TaskMaster != nil {
tm.Token = previous.TaskMaster.Token
}
if baseURL == "" && token == "" && key == "" { if baseURL == "" && token == "" && key == "" {
return project.Meta{YouTrack: nil} return project.Meta{Backend: project.BackendNone}
}
yt := &project.YouTrack{
BaseURL: baseURL,
ProjectKey: key,
Token: token,
} }
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
default:
yt := &project.YouTrack{BaseURL: baseURL, ProjectKey: key, Token: token}
if token == "" && previous.YouTrack != nil { if token == "" && previous.YouTrack != nil {
yt.Token = previous.YouTrack.Token yt.Token = previous.YouTrack.Token
} }
if previous.YouTrack != nil && previous.YouTrack.Query != "" { if previous.YouTrack != nil && previous.YouTrack.Query != "" {
yt.Query = previous.YouTrack.Query yt.Query = previous.YouTrack.Query
} }
return project.Meta{YouTrack: yt} if baseURL == "" && token == "" && key == "" {
return project.Meta{Backend: project.BackendNone}
}
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
}
} }
// Validate ensures partial YouTrack configs are rejected. // Validate ensures partial configs are rejected.
func (f ProjectEditForm) Validate(previous project.Meta) error { func (f ProjectEditForm) Validate(previous project.Meta) error {
meta := f.Meta(previous) meta := f.Meta(previous)
if meta.YouTrack == nil { switch meta.EffectiveBackend() {
case project.BackendNone:
return nil
case project.BackendTaskMaster:
tm := meta.TaskMaster
if tm == nil {
return nil return nil
} }
if !tm.Configured() {
return fmt.Errorf("incomplete taskmaster config: need base URL, token, project id")
}
return nil
default:
yt := meta.YouTrack yt := meta.YouTrack
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != "" if yt == nil {
if any && !yt.Configured() { return nil
missing := []string{}
if yt.BaseURL == "" {
missing = append(missing, "remote URL")
} }
if yt.Token == "" { if !yt.Configured() {
missing = append(missing, "token") return fmt.Errorf("incomplete YouTrack config: need remote URL, token, project key")
}
if yt.ProjectKey == "" {
missing = append(missing, "project key")
}
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
} }
return nil return nil
}
} }
// View renders the form. // View renders the form.
func (f ProjectEditForm) View() string { func (f ProjectEditForm) View() string {
labels := []string{"Remote URL", "Token", "Project key"}
var b strings.Builder var b strings.Builder
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID))) b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel")) b.WriteString(f.Styles.Hint.Render("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
b.WriteString("\n\n") b.WriteString("\n\n")
backendLine := "Backend: "
for i, opt := range backendOptions {
label := opt
if i == f.BackendIdx {
if f.FocusBackend {
label = f.Styles.Focused.Render("[" + opt + "]")
} else {
label = f.Styles.Focused.Render(opt)
}
} else {
label = f.Styles.Blurred.Render(opt)
}
if i > 0 {
backendLine += " "
}
backendLine += label
}
b.WriteString(backendLine)
b.WriteString("\n\n")
if f.Backend == project.BackendNone {
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
b.WriteString("\n")
return b.String()
}
labels := []string{"Remote URL", "Token", "Project key"}
if f.Backend == project.BackendTaskMaster {
labels = []string{"Server URL", "Project token", "Remote project ID"}
}
for i, label := range labels { for i, label := range labels {
b.WriteString(f.Styles.Label.Render(label + ":")) b.WriteString(f.Styles.Label.Render(label + ":"))
b.WriteString("\n") b.WriteString("\n")
+19 -1
View File
@@ -9,6 +9,7 @@ import (
func TestProjectEditMetaKeepsToken(t *testing.T) { func TestProjectEditMetaKeepsToken(t *testing.T) {
prev := project.Meta{ prev := project.Meta{
Backend: project.BackendYouTrack,
YouTrack: &project.YouTrack{ YouTrack: &project.YouTrack{
BaseURL: "https://yt.example", BaseURL: "https://yt.example",
Token: "secret", Token: "secret",
@@ -31,10 +32,27 @@ func TestProjectEditMetaKeepsToken(t *testing.T) {
} }
func TestProjectEditValidateIncomplete(t *testing.T) { func TestProjectEditValidateIncomplete(t *testing.T) {
f := views.NewProjectEditForm("inbox", project.Meta{}, 80) f := views.NewProjectEditForm("inbox", project.Meta{Backend: project.BackendYouTrack}, 80)
f.BackendIdx = 1 // youtrack
f.Backend = project.BackendYouTrack
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example") f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
// missing token + key // missing token + key
if err := f.Validate(project.Meta{}); err == nil { if err := f.Validate(project.Meta{}); err == nil {
t.Fatal("expected incomplete error") t.Fatal("expected incomplete error")
} }
} }
func TestProjectEditTaskMaster(t *testing.T) {
prev := project.Meta{
Backend: project.BackendTaskMaster,
TaskMaster: &project.TaskMaster{
BaseURL: "http://127.0.0.1:8080", Token: "tm_secret", ProjectID: "abc",
},
}
f := views.NewProjectEditForm("inbox", prev, 80)
f.Inputs[views.FieldToken].SetValue("")
meta := f.Meta(prev)
if meta.TaskMaster == nil || meta.TaskMaster.Token != "tm_secret" {
t.Fatalf("expected kept tm token, got %+v", meta.TaskMaster)
}
}
+19 -16
View File
@@ -6,7 +6,7 @@ import (
"unicode" "unicode"
) )
// SyncAction is a YouTrack sync menu choice. // SyncAction is a sync menu choice.
type SyncAction string type SyncAction string
const ( const (
@@ -21,27 +21,30 @@ type SyncOption struct {
Shortcut rune Shortcut rune
} }
// AllSyncOptions is the ordered sync menu.
var AllSyncOptions = []SyncOption{
{Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'},
{Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'},
}
// SyncMenu renders a pull/push picker overlay. // SyncMenu renders a pull/push picker overlay.
type SyncMenu struct { type SyncMenu struct {
Cursor int Cursor int
Width int Width int
Title string
Options []SyncOption
Styles StatusMenuStyles Styles StatusMenuStyles
} }
// NewSyncMenu creates a sync action menu. // NewSyncMenu creates a sync action menu for the given backend label.
func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu { func NewSyncMenu(width int, styles StatusMenuStyles, backendLabel string) SyncMenu {
return SyncMenu{Cursor: 0, Width: width, Styles: styles} if backendLabel == "" {
backendLabel = "Sync"
}
opts := []SyncOption{
{Action: SyncActionPull, Label: "Pull from " + backendLabel, Shortcut: 'p'},
{Action: SyncActionPush, Label: "Push to " + backendLabel, Shortcut: 'u'},
}
return SyncMenu{Cursor: 0, Width: width, Title: backendLabel, Options: opts, Styles: styles}
} }
// MoveDown moves the selection down. // MoveDown moves the selection down.
func (m *SyncMenu) MoveDown() { func (m *SyncMenu) MoveDown() {
if m.Cursor < len(AllSyncOptions)-1 { if m.Cursor < len(m.Options)-1 {
m.Cursor++ m.Cursor++
} }
} }
@@ -56,7 +59,7 @@ func (m *SyncMenu) MoveUp() {
// JumpToShortcut selects the option whose shortcut matches r. // JumpToShortcut selects the option whose shortcut matches r.
func (m *SyncMenu) JumpToShortcut(r rune) bool { func (m *SyncMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r) r = unicode.ToLower(r)
for i, opt := range AllSyncOptions { for i, opt := range m.Options {
if opt.Shortcut == r { if opt.Shortcut == r {
m.Cursor = i m.Cursor = i
return true return true
@@ -67,17 +70,17 @@ func (m *SyncMenu) JumpToShortcut(r rune) bool {
// Selected returns the highlighted action. // Selected returns the highlighted action.
func (m SyncMenu) Selected() SyncAction { func (m SyncMenu) Selected() SyncAction {
if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) { if m.Cursor < 0 || m.Cursor >= len(m.Options) {
return SyncActionPull return SyncActionPull
} }
return AllSyncOptions[m.Cursor].Action return m.Options[m.Cursor].Action
} }
// View renders the sync menu. // View renders the sync menu.
func (m SyncMenu) View() string { func (m SyncMenu) View() string {
var lines []string var lines []string
lines = append(lines, m.Styles.Title.Render("YouTrack")) lines = append(lines, m.Styles.Title.Render(m.Title))
for i, opt := range AllSyncOptions { for i, opt := range m.Options {
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label) entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
if i == m.Cursor { if i == m.Cursor {
lines = append(lines, m.Styles.Selected.Render("▸ "+entry)) lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
+18
View File
@@ -0,0 +1,18 @@
package webembed
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// FS returns the embedded static frontend filesystem (contents of dist/).
func FS() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return dist
}
return sub
}
+43
View File
@@ -516,6 +516,49 @@ func (c *Client) findProjectViaIssue(ctx context.Context, shortName string) (str
return issues[0].Project.ID, nil return issues[0].Project.ID, nil
} }
// Project is a YouTrack project summary for the register/import UI.
type Project struct {
ID string
ShortName string
Name string
}
// ListProjects lists admin-accessible projects (requires sufficient token scope).
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
if err := c.ensureAuth(); err != nil {
return nil, err
}
var all []Project
for page := 0; page < maxPages; page++ {
skip := page * defaultPageSize
u, err := url.Parse(c.base() + "/api/admin/projects")
if err != nil {
return nil, err
}
q := u.Query()
q.Set("fields", "id,shortName,name")
q.Set("$top", fmt.Sprintf("%d", defaultPageSize))
q.Set("$skip", fmt.Sprintf("%d", skip))
u.RawQuery = q.Encode()
var raw []struct {
ID string `json:"id"`
ShortName string `json:"shortName"`
Name string `json:"name"`
}
if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil {
return nil, err
}
for _, p := range raw {
all = append(all, Project{ID: p.ID, ShortName: p.ShortName, Name: p.Name})
}
if len(raw) < defaultPageSize {
break
}
}
return all, nil
}
// IssueURL builds the browser URL for an issue. // IssueURL builds the browser URL for an issue.
func (c *Client) IssueURL(idReadable string) string { func (c *Client) IssueURL(idReadable string) string {
return c.base() + "/issue/" + idReadable return c.base() + "/issue/" + idReadable
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+43
View File
@@ -0,0 +1,43 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>task-master</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}
+1107
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
"vite": "^8.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+586
View File
@@ -0,0 +1,586 @@
<script>
import { onMount } from 'svelte'
import { api } from './lib/api.js'
let user = $state(null)
let loading = $state(true)
let error = $state('')
let view = $state('dashboard')
let projects = $state([])
let projectId = $state('')
let projectName = $state('')
let tickets = $state([])
let selected = $state(null)
let revisions = $state([])
let ticketTime = $state([])
let users = $state([])
let members = $state([])
let timer = $state(null)
let msg = $state('')
let report = $state(null)
let reportGroupBy = $state('ticket')
let dash = $state({ projects: [], openTickets: [] })
let tokens = $state([])
let newTokenSecret = $state('')
let renameName = $state('')
let loginUser = $state('')
let loginPass = $state('')
let newProjectName = $state('')
let newTicketTitle = $state('')
let editTitle = $state('')
let editDesc = $state('')
let editStatus = $state('todo')
let newUserName = $state('')
let newUserPass = $state('')
let newUserAdmin = $state(false)
let memberUserId = $state('')
let memberRole = $state('member')
let timeMinutes = $state(30)
let tokenName = $state('tui')
let tokenScope = $state('write')
onMount(async () => {
try {
user = await api.me()
await openDashboard()
await refreshTimer()
} catch {
user = null
} finally {
loading = false
}
})
function formatDuration(ns) {
const totalMin = Math.max(0, Math.round(Number(ns) / 6e10))
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
if (h > 0 && m > 0) return `${h}h ${m}m`
if (h > 0) return `${h}h`
return `${m}m`
}
async function doLogin() {
error = ''
try {
user = await api.login(loginUser, loginPass)
await openDashboard()
} catch (e) {
error = e.message
}
}
async function doLogout() {
await api.logout()
user = null
projects = []
}
async function refreshProjects() {
projects = await api.listProjects()
}
async function refreshTimer() {
const data = await api.getTimer()
timer = data.timer
}
async function openDashboard() {
view = 'dashboard'
projectId = ''
dash = await api.dashboard()
await refreshProjects()
}
async function openProject(id, name = '') {
projectId = id
projectName = name || projects.find((p) => p.id === id)?.name || id
view = 'board'
error = ''
tickets = await api.listTickets(id)
}
async function createProject() {
error = ''
const name = newProjectName.trim()
if (!name) {
error = 'Name required'
return
}
try {
await api.createProject(name)
newProjectName = ''
await openDashboard()
} catch (e) {
error = e.message
}
}
async function createTicket() {
if (!newTicketTitle.trim()) return
await api.createTicket(projectId, { title: newTicketTitle, status: 'todo' })
newTicketTitle = ''
tickets = await api.listTickets(projectId)
}
async function openTicket(t) {
selected = t
editTitle = t.title
editDesc = t.description || ''
editStatus = t.status
revisions = await api.listRevisions(projectId, t.id)
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id)
await refreshTimer()
view = 'ticket'
}
async function saveTicket() {
selected = await api.patchTicket(projectId, selected.id, {
title: editTitle,
description: editDesc,
status: editStatus,
})
revisions = await api.listRevisions(projectId, selected.id)
tickets = await api.listTickets(projectId)
msg = 'Saved'
}
async function removeTicket() {
if (!confirm('Delete ticket?')) return
await api.deleteTicket(projectId, selected.id)
selected = null
view = 'board'
tickets = await api.listTickets(projectId)
}
async function restoreRev(rid) {
selected = await api.restoreRevision(projectId, selected.id, rid)
editTitle = selected.title
editDesc = selected.description || ''
editStatus = selected.status
revisions = await api.listRevisions(projectId, selected.id)
tickets = await api.listTickets(projectId)
msg = 'Restored'
}
async function bookTime() {
if (!selected) return
await api.createTime(projectId, {
ticketId: selected.id,
durationNs: timeMinutes * 60 * 1e9,
source: 'manual',
})
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === selected.id)
msg = `Booked ${timeMinutes}m`
}
async function toggleTimer() {
if (!selected) return
if (timer && timer.ticketId === selected.id) {
await api.stopTimer()
} else {
await api.startTimer(selected.id, projectId)
}
await refreshTimer()
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === selected.id)
}
async function openReport() {
view = 'report'
error = ''
report = await api.report(projectId, reportGroupBy)
}
async function changeReportGroup(g) {
reportGroupBy = g
report = await api.report(projectId, reportGroupBy)
}
async function openSettings() {
view = 'settings'
renameName = projectName
newTokenSecret = ''
tokens = await api.listTokens(projectId)
}
async function saveRename() {
const name = renameName.trim()
if (!name) return
const p = await api.patchProject(projectId, { name })
projectName = p.name
msg = 'Renamed'
await refreshProjects()
}
async function createToken() {
const t = await api.createToken(projectId, { name: tokenName || 'token', scope: tokenScope })
newTokenSecret = t.token
tokens = await api.listTokens(projectId)
msg = 'Token created — copy it now'
}
async function revokeToken(tid) {
await api.revokeToken(projectId, tid)
tokens = await api.listTokens(projectId)
}
async function openAdmin() {
view = 'admin'
users = await api.listUsers()
}
async function createUser() {
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
newUserName = ''
newUserPass = ''
newUserAdmin = false
users = await api.listUsers()
}
async function toggleAdmin(u) {
if (u.id === user.id) {
error = 'Cannot change your own admin flag here'
return
}
await api.patchUser(u.id, { isAdmin: !u.isAdmin })
users = await api.listUsers()
}
async function deleteUser(u) {
if (u.id === user.id) return
if (!confirm(`Delete user ${u.username}?`)) return
await api.deleteUser(u.id)
users = await api.listUsers()
}
async function openMembers() {
view = 'members'
members = await api.listMembers(projectId)
users = await api.listUsers()
}
async function addMember() {
await api.setMember(projectId, memberUserId, memberRole)
members = await api.listMembers(projectId)
}
function byStatus(status) {
return tickets.filter((t) => t.status === status)
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text)
msg = 'Copied'
} catch {
msg = text
}
}
</script>
{#if loading}
<div class="login-wrap"><p class="muted">Loading…</p></div>
{:else if !user}
<div class="login-wrap">
<div class="panel login-card stack">
<h1>task-master</h1>
<p class="muted">Sign in to manage projects and tickets.</p>
{#if error}<p class="error">{error}</p>{/if}
<label>Username<input bind:value={loginUser} autocomplete="username" /></label>
<label>Password<input type="password" bind:value={loginPass} autocomplete="current-password" /></label>
<button class="primary" onclick={doLogin}>Login</button>
</div>
</div>
{:else}
<div class="app-shell">
<header class="topbar">
<div class="brand">task-master</div>
<nav class="nav">
<button class:active={view === 'dashboard'} onclick={openDashboard}>Dashboard</button>
{#if projectId}
<button class:active={view === 'board'} onclick={() => openProject(projectId, projectName)}>Board</button>
<button class:active={view === 'report'} onclick={openReport}>Report</button>
<button class:active={view === 'members'} onclick={openMembers}>Members</button>
<button class:active={view === 'settings'} onclick={openSettings}>Settings</button>
{/if}
{#if user.isAdmin}
<button class:active={view === 'admin'} onclick={openAdmin}>Users</button>
{/if}
</nav>
<div class="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>
</div>
</header>
{#if error}<p class="error">{error}</p>{/if}
{#if msg}<p class="muted">{msg}</p>{/if}
{#if view === 'dashboard'}
<div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;">
<section class="panel stack">
<h2>Projects</h2>
{#each dash.projects as p}
<button class="ticket" onclick={() => openProject(p.id, p.name)}>
<strong>{p.name}</strong>
<div class="muted">{p.openCount} open · {formatDuration(p.durationNs)}</div>
</button>
{:else}
<p class="muted">No projects yet.</p>
{/each}
<h3>New project</h3>
<div class="row">
<input placeholder="Project name" bind:value={newProjectName} />
<button class="primary" onclick={createProject}>Create</button>
</div>
</section>
<div class="stack">
<section class="panel stack">
<h2>Open tickets</h2>
{#each dash.openTickets as t}
<button class="ticket" onclick={async () => { await openProject(t.projectId, t.projectName); const full = (await api.listTickets(t.projectId)).find(x => x.id === t.id); if (full) openTicket(full) }}>
<span class="badge {t.status}">{t.status}</span>
<div>{t.title}</div>
<div class="muted">{t.projectName}</div>
</button>
{:else}
<p class="muted">Nothing open.</p>
{/each}
</section>
<section class="panel stack">
<h2>Time by project</h2>
<table>
<thead><tr><th>Project</th><th>Time</th></tr></thead>
<tbody>
{#each dash.projects as p}
<tr><td>{p.name}</td><td>{formatDuration(p.durationNs)}</td></tr>
{/each}
</tbody>
</table>
</section>
</div>
</div>
{:else if view === 'board'}
<section class="panel stack">
<div class="row" style="justify-content: space-between;">
<h2>{projectName}</h2>
<div class="row">
<input placeholder="New ticket title" bind:value={newTicketTitle} style="width: 240px;" />
<button class="primary" onclick={createTicket}>Add</button>
</div>
</div>
<div class="grid cols-3">
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
<div class="stack">
<h3>{label}</h3>
{#each byStatus(st) as t}
<button class="ticket" onclick={() => openTicket(t)}>
<span class="badge {t.status}">{label}</span>
<div>{t.title}</div>
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
</button>
{/each}
</div>
{/each}
</div>
</section>
{:else if view === 'ticket' && selected}
<section class="panel stack">
<div class="row">
<button onclick={() => (view = 'board')}>← Board</button>
<button class="danger" onclick={removeTicket}>Delete</button>
</div>
<label>Title<input bind:value={editTitle} /></label>
<label>Status
<select bind:value={editStatus}>
<option value="todo">OPEN</option>
<option value="in_progress">WIP</option>
<option value="done">DONE</option>
</select>
</label>
<label>Description<textarea bind:value={editDesc}></textarea></label>
<button class="primary" onclick={saveTicket}>Save</button>
<h3>Time</h3>
<div class="row">
<button class="primary" onclick={toggleTimer}>
{timer && timer.ticketId === selected.id ? 'Stop timer' : 'Start timer'}
</button>
<label class="row" style="width: auto;">Minutes
<input type="number" min="1" bind:value={timeMinutes} style="width: 5rem;" />
</label>
<button onclick={bookTime}>Book</button>
</div>
<table>
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
<tbody>
{#each ticketTime as e}
<tr>
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
<td>{formatDuration(e.duration)}</td>
<td>{e.source}</td>
</tr>
{:else}
<tr><td colspan="3" class="muted">No time yet.</td></tr>
{/each}
</tbody>
</table>
<h3>History</h3>
<table>
<thead><tr><th>Rev</th><th>Op</th><th>At</th><th></th></tr></thead>
<tbody>
{#each revisions as rev}
<tr>
<td>{rev.rev}</td>
<td>{rev.op}</td>
<td class="muted">{new Date(rev.at).toLocaleString()}</td>
<td><button onclick={() => restoreRev(rev.id)}>Restore</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{:else if view === 'report'}
<section class="panel stack">
<div class="row" style="justify-content: space-between;">
<h2>Report — {projectName}</h2>
<div class="row">
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
<button class:primary={reportGroupBy === g} onclick={() => changeReportGroup(g)}>{label}</button>
{/each}
</div>
</div>
{#if report}
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
<table>
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
<tbody>
{#each report.rows as row}
<tr><td>{row.label}</td><td>{formatDuration(row.durationNs)}</td></tr>
{:else}
<tr><td colspan="2" class="muted">No time logged.</td></tr>
{/each}
</tbody>
</table>
{/if}
</section>
{:else if view === 'settings'}
<section class="panel stack">
<h2>Settings — {projectName}</h2>
<label>Project ID
<div class="row">
<code style="flex:1">{projectId}</code>
<button onclick={() => copyText(projectId)}>Copy</button>
</div>
</label>
<label>Name
<div class="row">
<input bind:value={renameName} />
<button class="primary" onclick={saveRename}>Rename</button>
</div>
</label>
<h3>API tokens (for TUI)</h3>
<p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p>
{#if newTokenSecret}
<div class="panel stack" style="border-color: var(--accent);">
<strong>New token (shown once)</strong>
<code>{newTokenSecret}</code>
<button onclick={() => copyText(newTokenSecret)}>Copy token</button>
</div>
{/if}
<div class="row">
<input placeholder="name" bind:value={tokenName} style="width: 8rem;" />
<select bind:value={tokenScope} style="width: auto;">
<option value="read">read</option>
<option value="write">write</option>
</select>
<button class="primary" onclick={createToken}>Create token</button>
</div>
<table>
<thead><tr><th>Name</th><th>Scope</th><th>Created</th><th></th></tr></thead>
<tbody>
{#each tokens as t}
<tr>
<td>{t.name}{#if t.revokedAt} <span class="muted">(revoked)</span>{/if}</td>
<td>{t.scope}</td>
<td class="muted">{new Date(t.createdAt).toLocaleString()}</td>
<td>
{#if !t.revokedAt}
<button class="danger" onclick={() => revokeToken(t.id)}>Revoke</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
{:else if view === 'admin'}
<section class="panel stack">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
<tbody>
{#each users as u}
<tr>
<td>{u.username}</td>
<td>
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
{u.isAdmin ? 'yes' : 'no'}
</button>
</td>
<td>
{#if u.id !== user.id}
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<h3>Create user</h3>
<input placeholder="username" bind:value={newUserName} />
<input type="password" placeholder="password" bind:value={newUserPass} />
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
<button class="primary" onclick={createUser}>Create</button>
</section>
{:else if view === 'members'}
<section class="panel stack">
<h2>Members — {projectName}</h2>
<table>
<thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
<tbody>
{#each members as m}
<tr>
<td>{m.username}</td>
<td>{m.role}</td>
<td><button class="danger" onclick={async () => { await api.removeMember(projectId, m.userId); members = await api.listMembers(projectId) }}>Remove</button></td>
</tr>
{/each}
</tbody>
</table>
<div class="row">
<select bind:value={memberUserId} style="width: auto;">
<option value="">user…</option>
{#each users as u}<option value={u.id}>{u.username}</option>{/each}
</select>
<select bind:value={memberRole} style="width: auto;">
<option value="viewer">viewer</option>
<option value="member">member</option>
<option value="admin">admin</option>
<option value="owner">owner</option>
</select>
<button class="primary" onclick={addMember}>Add</button>
</div>
</section>
{/if}
</div>
{/if}
+151
View File
@@ -0,0 +1,151 @@
:root {
--bg: #0f1216;
--bg-glow: #182028;
--panel: #171c22;
--panel-2: #1d242c;
--ink: #e6edf3;
--muted: #8b97a5;
--line: #2a333d;
--accent: #3d9a80;
--accent-ink: #061510;
--danger: #e07070;
--todo: #9aa5b1;
--wip: #d4a017;
--done: #3d9a80;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(ellipse at top left, #1a2830 0%, transparent 50%),
radial-gradient(ellipse at bottom right, #152018 0%, transparent 45%),
var(--bg);
line-height: 1.45;
color-scheme: dark;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: transparent; }
button, input, select, textarea {
font: inherit;
color: inherit;
}
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);
font-weight: 600;
}
button.primary:hover { filter: brightness(1.08); }
button.danger {
color: var(--danger);
border-color: #5a3030;
background: #241818;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.45rem 0.6rem;
background: #10151a;
color: var(--ink);
}
input::placeholder, textarea::placeholder { color: #5f6b78; }
textarea { min-height: 8rem; resize: vertical; }
a { color: var(--accent); }
code {
background: #10151a;
border: 1px solid var(--line);
border-radius: 4px;
padding: 0.05em 0.35em;
font-size: 0.9em;
}
.app-shell {
max-width: 1100px;
margin: 0 auto;
padding: 1.25rem;
}
.topbar {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--line);
}
.brand {
font-weight: 700;
letter-spacing: 0.04em;
color: #c9d6e0;
}
.nav { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.nav button.active {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
}
.panel {
background: linear-gradient(180deg, var(--panel) 0%, #14191f 100%);
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.grid { display: grid; gap: 1rem; }
.cols-3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 800px) { .cols-3 { grid-template-columns: 1fr; } }
.muted { color: var(--muted); font-size: 0.92rem; }
.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);
border-radius: 8px;
padding: 0.65rem 0.75rem;
background: var(--panel-2);
cursor: pointer;
text-align: left;
width: 100%;
}
.ticket:hover {
border-color: var(--accent);
background: #222b34;
}
.badge {
display: inline-block;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--line);
background: #10151a;
}
.badge.todo { color: var(--todo); }
.badge.in_progress { color: var(--wip); }
.badge.done { color: var(--done); }
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
}
.login-card { width: min(380px, 100%); }
h1, h2, h3 { margin: 0 0 0.75rem; font-weight: 650; color: #f0f4f8; }
table { width: 100%; border-collapse: collapse; }
td, th {
text-align: left;
padding: 0.4rem;
border-bottom: 1px solid var(--line);
font-size: 0.95rem;
}
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); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+5
View File
@@ -0,0 +1,5 @@
<script>
let count = $state(0)
</script>
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
+78
View File
@@ -0,0 +1,78 @@
async function request(path, opts = {}) {
const res = await fetch(`/api/v1${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
})
const text = await res.text()
let data = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = { error: text }
}
if (!res.ok) {
const err = new Error((data && data.error) || res.statusText)
err.status = res.status
err.data = data
throw err
}
return data
}
export const api = {
me: () => request('/auth/me'),
login: (username, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => request('/auth/logout', { method: 'POST', body: '{}' }),
dashboard: () => request('/dashboard'),
listProjects: () => request('/projects'),
createProject: (name) =>
request('/projects', { method: 'POST', body: JSON.stringify({ name }) }),
patchProject: (id, body) =>
request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
listMembers: (id) => request(`/projects/${id}/members`),
setMember: (id, userId, role) =>
request(`/projects/${id}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
removeMember: (id, userId) =>
request(`/projects/${id}/members/${userId}`, { method: 'DELETE' }),
listTokens: (id) => request(`/projects/${id}/tokens`),
createToken: (id, body) =>
request(`/projects/${id}/tokens`, { method: 'POST', body: JSON.stringify(body) }),
revokeToken: (id, tid) => request(`/projects/${id}/tokens/${tid}`, { method: 'DELETE' }),
listTickets: (projectId) => request(`/projects/${projectId}/tickets`),
createTicket: (projectId, body) =>
request(`/projects/${projectId}/tickets`, { method: 'POST', body: JSON.stringify(body) }),
getTicket: (projectId, tid) => request(`/projects/${projectId}/tickets/${tid}`),
patchTicket: (projectId, tid, body) =>
request(`/projects/${projectId}/tickets/${tid}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteTicket: (projectId, tid) =>
request(`/projects/${projectId}/tickets/${tid}`, { method: 'DELETE' }),
listRevisions: (projectId, tid) =>
request(`/projects/${projectId}/tickets/${tid}/revisions`),
restoreRevision: (projectId, tid, rid) =>
request(`/projects/${projectId}/tickets/${tid}/revisions/${rid}/restore`, {
method: 'POST',
body: '{}',
}),
report: (projectId, groupBy = 'ticket') =>
request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`),
listTime: (projectId) => request(`/projects/${projectId}/time`),
createTime: (projectId, body) =>
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
getTimer: () => request('/timer'),
startTimer: (ticketId, projectId) =>
request('/timer/start', { method: 'POST', body: JSON.stringify({ ticketId, projectId }) }),
stopTimer: () => request('/timer/stop', { method: 'POST', body: '{}' }),
listUsers: () => request('/users'),
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' }),
}
+5
View File
@@ -0,0 +1,5 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
mount(App, { target: document.getElementById('app') })
+2
View File
@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: {
outDir: '../internal/webembed/dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})