Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c6747a31 | ||
|
|
efeb4a74a8 | ||
|
|
26a3723a06 | ||
|
|
6e6b13270a | ||
|
|
614cb94b46 | ||
|
|
f97856b8a6 | ||
|
|
34ab3b9c5b |
@@ -97,6 +97,7 @@ func runServer(listen, dbPath string, secure bool) error {
|
||||
return err
|
||||
}
|
||||
srv := server.New(db)
|
||||
srv.DataDir = cfg.DataDir
|
||||
srv.Secure = secure
|
||||
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
|
||||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
|
||||
|
||||
@@ -4,5 +4,6 @@ package project
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code,omitempty"`
|
||||
GroupID string `json:"groupId,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
const maxAttachmentBytes = 10 << 20 // 10 MiB
|
||||
|
||||
func (s *Server) attachmentsRoot() string {
|
||||
root := strings.TrimSpace(s.DataDir)
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
return filepath.Join(root, "attachments")
|
||||
}
|
||||
|
||||
func (s *Server) attachmentPath(ticketID, attachmentID string) string {
|
||||
return filepath.Join(s.attachmentsRoot(), ticketID, attachmentID)
|
||||
}
|
||||
|
||||
func (s *Server) removeTicketAttachmentDir(ticketID string) {
|
||||
_ = os.RemoveAll(filepath.Join(s.attachmentsRoot(), ticketID))
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "file"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '-', r == '_', r == ' ':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if out == "" || out == "." || out == ".." {
|
||||
return "file"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadTicketAttachment(projectID, ticketID, attachmentID string) (sqlite.Attachment, error) {
|
||||
t, err := s.Tickets.Get(ticketID)
|
||||
if err != nil || t.ProjectID != projectID {
|
||||
return sqlite.Attachment{}, fmt.Errorf("ticket not found")
|
||||
}
|
||||
a, err := s.Attachments.Get(attachmentID)
|
||||
if err != nil || a.TicketID != ticketID || a.ProjectID != projectID {
|
||||
return sqlite.Attachment{}, fmt.Errorf("attachment not found")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
list, err := s.Attachments.ListByTicket(tid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+512)
|
||||
if err := r.ParseMultipartForm(maxAttachmentBytes + 512); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
file, hdr, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if hdr.Size > maxAttachmentBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(hdr.Filename)
|
||||
contentType := hdr.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
a, err := s.Attachments.Create(sqlite.Attachment{
|
||||
TicketID: tid,
|
||||
ProjectID: id,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: 0,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Join(s.attachmentsRoot(), tid)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
_ = s.Attachments.Delete(a.ID)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
path := s.attachmentPath(tid, a.ID)
|
||||
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
_ = s.Attachments.Delete(a.ID)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
n, copyErr := io.Copy(dst, io.LimitReader(file, maxAttachmentBytes+1))
|
||||
closeErr := dst.Close()
|
||||
if copyErr != nil || closeErr != nil || n > maxAttachmentBytes {
|
||||
_ = os.Remove(path)
|
||||
_ = s.Attachments.Delete(a.ID)
|
||||
if n > maxAttachmentBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
if copyErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, copyErr.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, closeErr.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Attachments.UpdateSize(a.ID, n)
|
||||
a.SizeBytes = n
|
||||
writeJSON(w, http.StatusCreated, a)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
aid := r.PathValue("aid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
a, err := s.loadTicketAttachment(id, tid, aid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
path := s.attachmentPath(tid, aid)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "file missing")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, strings.ReplaceAll(a.Filename, `"`, "")))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", st.Size()))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
aid := r.PathValue("aid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
a, err := s.loadTicketAttachment(id, tid, aid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+1)
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
if int64(len(data)) > maxAttachmentBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
|
||||
path := s.attachmentPath(tid, aid)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
size := int64(len(data))
|
||||
if err := s.Attachments.UpdateSize(aid, size); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.SizeBytes = size
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct != "" && ct != "application/octet-stream" {
|
||||
a.ContentType = ct
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
aid := r.PathValue("aid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, err := s.loadTicketAttachment(id, tid, aid); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
_ = os.Remove(s.attachmentPath(tid, aid))
|
||||
if err := s.Attachments.Delete(aid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestAttachmentUploadListDownloadPutDelete(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := sqlite.Open(filepath.Join(tmp, "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(tmp, "data")
|
||||
srv := New(db)
|
||||
srv.DataDir = dataDir
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid, _ := proj["id"].(string)
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var ticket map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
|
||||
tid, _ := ticket["id"].(string)
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("file", "notes.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte("hello attach")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = mw.Close()
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("upload %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var att map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &att)
|
||||
aid, _ := att["id"].(string)
|
||||
if aid == "" {
|
||||
t.Fatal("no attachment id")
|
||||
}
|
||||
if att["filename"] != "notes.txt" {
|
||||
t.Fatalf("filename %v", att["filename"])
|
||||
}
|
||||
if int64(att["sizeBytes"].(float64)) != 12 {
|
||||
t.Fatalf("size %v", att["sizeBytes"])
|
||||
}
|
||||
|
||||
diskPath := filepath.Join(dataDir, "attachments", tid, aid)
|
||||
if _, err := os.Stat(diskPath); err != nil {
|
||||
t.Fatalf("disk file: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var list []map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("list len %d", len(list))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, _ := io.ReadAll(rec.Body)
|
||||
if string(got) != "hello attach" {
|
||||
t.Fatalf("download body %q", got)
|
||||
}
|
||||
|
||||
putBody := []byte("updated content!!")
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, bytes.NewReader(putBody))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &att)
|
||||
if int64(att["sizeBytes"].(float64)) != int64(len(putBody)) {
|
||||
t.Fatalf("put size %v", att["sizeBytes"])
|
||||
}
|
||||
disk, err := os.ReadFile(diskPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(disk, putBody) {
|
||||
t.Fatalf("disk after put %q", disk)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(diskPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("file should be gone: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("list after delete %d", len(list))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
@@ -29,11 +30,46 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
from, err := ticket.ParseReportDay(r.URL.Query().Get("from"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
toDay, err := ticket.ParseReportDay(r.URL.Query().Get("to"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var toBound = toDay
|
||||
if !toDay.IsZero() {
|
||||
toBound = ticket.EndOfReportDay(toDay)
|
||||
}
|
||||
filter := ticket.TimeFilter{
|
||||
From: from,
|
||||
To: toBound,
|
||||
TicketID: r.URL.Query().Get("ticketId"),
|
||||
}
|
||||
|
||||
entries, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
entries = ticket.FilterEntries(entries, filter)
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
ai, aj := entries[i].Start, entries[j].Start
|
||||
if ai.IsZero() {
|
||||
ai = entries[i].End
|
||||
}
|
||||
if aj.IsZero() {
|
||||
aj = entries[j].End
|
||||
}
|
||||
if !ai.Equal(aj) {
|
||||
return ai.After(aj)
|
||||
}
|
||||
return entries[i].ID > entries[j].ID
|
||||
})
|
||||
|
||||
tickets, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -64,5 +100,9 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
|
||||
"totalDurationNs": int64(total),
|
||||
"rows": out,
|
||||
"entryCount": len(entries),
|
||||
"entries": entries,
|
||||
"from": r.URL.Query().Get("from"),
|
||||
"to": r.URL.Query().Get("to"),
|
||||
"ticketId": filter.TicketID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestProjectReportFilters(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid, _ := proj["id"].(string)
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var tk map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tk)
|
||||
tid, _ := tk["id"].(string)
|
||||
|
||||
day := time.Date(2026, 10, 18, 14, 0, 0, 0, time.Local)
|
||||
_, err = srv.Times.Create(ticket.TimeEntry{
|
||||
TicketID: tid,
|
||||
ProjectID: pid,
|
||||
Start: day,
|
||||
End: day.Add(45 * time.Minute),
|
||||
Duration: 45 * time.Minute,
|
||||
Source: ticket.SourceManual,
|
||||
Note: "filtered",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
|
||||
_, err = srv.Times.Create(ticket.TimeEntry{
|
||||
TicketID: tid,
|
||||
ProjectID: pid,
|
||||
Start: other,
|
||||
End: other.Add(time.Hour),
|
||||
Duration: time.Hour,
|
||||
Source: ticket.SourceManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
url := "/api/v1/projects/" + pid + "/report?groupBy=ticket&from=2026-10-18&to=2026-10-18&ticketId=" + tid
|
||||
req = httptest.NewRequest(http.MethodGet, url, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("report %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
EntryCount int `json:"entryCount"`
|
||||
TotalDurationNs int64 `json:"totalDurationNs"`
|
||||
Entries []ticket.TimeEntry `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.EntryCount != 1 || len(out.Entries) != 1 {
|
||||
t.Fatalf("want 1 entry, got count=%d entries=%d", out.EntryCount, len(out.Entries))
|
||||
}
|
||||
if out.TotalDurationNs != int64(45*time.Minute) {
|
||||
t.Fatalf("total=%d", out.TotalDurationNs)
|
||||
}
|
||||
if out.Entries[0].Note != "filtered" {
|
||||
t.Fatalf("note=%q", out.Entries[0].Note)
|
||||
}
|
||||
}
|
||||
+22
-13
@@ -13,24 +13,27 @@ import (
|
||||
|
||||
// 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
|
||||
DB *sqlite.DB
|
||||
Projects *sqlite.ProjectStore
|
||||
Tickets *sqlite.TicketStore
|
||||
Times *sqlite.TimeStore
|
||||
Attachments *sqlite.AttachmentStore
|
||||
Sync *syncbackend.Registry
|
||||
DataDir string // root for on-disk attachments (cfg.DataDir)
|
||||
Secure bool // set Secure on cookies when behind TLS
|
||||
Static fs.FS
|
||||
}
|
||||
|
||||
// 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(),
|
||||
DB: db,
|
||||
Projects: sqlite.NewProjectStore(db),
|
||||
Tickets: sqlite.NewTicketStore(db),
|
||||
Times: sqlite.NewTimeStore(db),
|
||||
Attachments: sqlite.NewAttachmentStore(db),
|
||||
Sync: syncbackend.NewRegistry(),
|
||||
Static: webembed.FS(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +92,12 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleListAttachments))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleUploadAttachment))
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleGetAttachment))
|
||||
api.HandleFunc("PUT /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handlePutAttachment))
|
||||
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleDeleteAttachment))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeTicketAttachmentDir(tid)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Attachment is metadata for a file attached to a ticket.
|
||||
type Attachment struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"contentType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// AttachmentStore persists attachment metadata.
|
||||
type AttachmentStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewAttachmentStore returns an attachment metadata store.
|
||||
func NewAttachmentStore(db *DB) *AttachmentStore {
|
||||
return &AttachmentStore{db: db}
|
||||
}
|
||||
|
||||
const attachmentCols = `id, ticket_id, project_id, filename, content_type, size_bytes, created_by, created_at`
|
||||
|
||||
func scanAttachment(scanner interface{ Scan(dest ...any) error }) (Attachment, error) {
|
||||
var a Attachment
|
||||
var created string
|
||||
err := scanner.Scan(
|
||||
&a.ID, &a.TicketID, &a.ProjectID, &a.Filename, &a.ContentType,
|
||||
&a.SizeBytes, &a.CreatedBy, &created,
|
||||
)
|
||||
if err != nil {
|
||||
return Attachment{}, err
|
||||
}
|
||||
a.CreatedAt = parseTime(created)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListByTicket returns attachments for a ticket, newest first.
|
||||
func (s *AttachmentStore) ListByTicket(ticketID string) ([]Attachment, error) {
|
||||
rows, err := s.db.SQL.Query(
|
||||
`SELECT `+attachmentCols+` FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at DESC`,
|
||||
ticketID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if out == nil {
|
||||
out = []Attachment{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get returns one attachment by id.
|
||||
func (s *AttachmentStore) Get(id string) (Attachment, error) {
|
||||
row := s.db.SQL.QueryRow(`SELECT `+attachmentCols+` FROM ticket_attachments WHERE id = ?`, id)
|
||||
a, err := scanAttachment(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return Attachment{}, fmt.Errorf("attachment not found")
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
// Create inserts attachment metadata.
|
||||
func (s *AttachmentStore) Create(a Attachment) (Attachment, error) {
|
||||
if a.ID == "" {
|
||||
a.ID = uuid.NewString()
|
||||
}
|
||||
if a.Filename == "" {
|
||||
return Attachment{}, fmt.Errorf("filename required")
|
||||
}
|
||||
if a.ContentType == "" {
|
||||
a.ContentType = "application/octet-stream"
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
_, err := s.db.SQL.Exec(
|
||||
`INSERT INTO ticket_attachments (`+attachmentCols+`) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
a.ID, a.TicketID, a.ProjectID, a.Filename, a.ContentType, a.SizeBytes, a.CreatedBy, formatTime(a.CreatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return Attachment{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateSize updates size after a replace/overwrite.
|
||||
func (s *AttachmentStore) UpdateSize(id string, sizeBytes int64) error {
|
||||
res, err := s.db.SQL.Exec(`UPDATE ticket_attachments SET size_bytes = ? WHERE id = ?`, sizeBytes, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("attachment not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes attachment metadata.
|
||||
func (s *AttachmentStore) Delete(id string) error {
|
||||
res, err := s.db.SQL.Exec(`DELETE FROM ticket_attachments WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("attachment not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// backfillTicketKeys assigns project codes and sequential ticket keys for rows
|
||||
// that predate migration 005. Safe to run repeatedly (no-ops when already set).
|
||||
func (db *DB) backfillTicketKeys() error {
|
||||
if err := db.backfillProjectCodes(); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.backfillTicketNumbers()
|
||||
}
|
||||
|
||||
func (db *DB) backfillProjectCodes() error {
|
||||
rows, err := db.SQL.Query(`SELECT id, name, code FROM projects`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfill codes: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
id, name, code string
|
||||
}
|
||||
var all []row
|
||||
used := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.id, &r.name, &r.code); err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, r)
|
||||
if r.code != "" {
|
||||
used[r.code] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range all {
|
||||
if r.code != "" {
|
||||
continue
|
||||
}
|
||||
code := nextUniqueCode(r.name, used)
|
||||
used[code] = true
|
||||
if _, err := db.SQL.Exec(`UPDATE projects SET code = ? WHERE id = ?`, code, r.id); err != nil {
|
||||
return fmt.Errorf("backfill code %s: %w", r.id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) backfillTicketNumbers() error {
|
||||
type proj struct {
|
||||
id, code string
|
||||
seq int
|
||||
}
|
||||
var projects []proj
|
||||
{
|
||||
projRows, err := db.SQL.Query(`SELECT id, code, ticket_seq FROM projects`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfill tickets: projects: %w", err)
|
||||
}
|
||||
for projRows.Next() {
|
||||
var p proj
|
||||
if err := projRows.Scan(&p.id, &p.code, &p.seq); err != nil {
|
||||
projRows.Close()
|
||||
return err
|
||||
}
|
||||
projects = append(projects, p)
|
||||
}
|
||||
err = projRows.Err()
|
||||
projRows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
type trow struct {
|
||||
id, key string
|
||||
number sql.NullInt64
|
||||
}
|
||||
var tickets []trow
|
||||
{
|
||||
tRows, err := db.SQL.Query(`
|
||||
SELECT id, number, key FROM tickets
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at ASC, id ASC`, p.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for tRows.Next() {
|
||||
var tr trow
|
||||
if err := tRows.Scan(&tr.id, &tr.number, &tr.key); err != nil {
|
||||
tRows.Close()
|
||||
return err
|
||||
}
|
||||
tickets = append(tickets, tr)
|
||||
}
|
||||
err = tRows.Err()
|
||||
tRows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seq := p.seq
|
||||
for _, tr := range tickets {
|
||||
if tr.number.Valid && tr.key != "" {
|
||||
if int(tr.number.Int64) > seq {
|
||||
seq = int(tr.number.Int64)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seq++
|
||||
newKey := fmt.Sprintf("%s-%d", p.code, seq)
|
||||
if _, err := db.SQL.Exec(`UPDATE tickets SET number = ?, key = ? WHERE id = ?`, seq, newKey, tr.id); err != nil {
|
||||
return fmt.Errorf("backfill ticket %s: %w", tr.id, err)
|
||||
}
|
||||
}
|
||||
if seq != p.seq {
|
||||
if _, err := db.SQL.Exec(`UPDATE projects SET ticket_seq = ? WHERE id = ?`, seq, p.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,6 +44,10 @@ func Open(path string, secret []byte) (*DB, error) {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := db.backfillTicketKeys(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS ticket_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON ticket_attachments(ticket_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE projects ADD COLUMN code TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE projects ADD COLUMN ticket_seq INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE tickets ADD COLUMN number INTEGER;
|
||||
ALTER TABLE tickets ADD COLUMN key TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code) WHERE code != '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_key ON tickets(key) WHERE key != '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_project_number ON tickets(project_id, number) WHERE number IS NOT NULL;
|
||||
@@ -0,0 +1,49 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// DeriveProjectCode builds a 3-letter uppercase code from a project name.
|
||||
// Non-letters are skipped; short results are padded with X.
|
||||
func DeriveProjectCode(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(name) {
|
||||
if unicode.IsLetter(r) && r < unicode.MaxASCII {
|
||||
b.WriteRune(r)
|
||||
if b.Len() == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
code := b.String()
|
||||
for len(code) < 3 {
|
||||
code += "X"
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// nextUniqueCode returns base or a nearby 3-char variant until unused.
|
||||
// used maps existing codes (uppercase) → true.
|
||||
func nextUniqueCode(base string, used map[string]bool) string {
|
||||
base = DeriveProjectCode(base)
|
||||
if !used[base] {
|
||||
return base
|
||||
}
|
||||
prefix := base[:2]
|
||||
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" {
|
||||
cand := prefix + string(c)
|
||||
if !used[cand] {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
for i := 0; i < 1000; i++ {
|
||||
cand := fmt.Sprintf("%c%02d", base[0], i)
|
||||
if !used[cand] {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("X%02d", len(used)%100)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package sqlite
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeriveProjectCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, want string
|
||||
}{
|
||||
{"Alpha", "ALP"},
|
||||
{"task-master", "TAS"},
|
||||
{"AB", "ABX"},
|
||||
{"12", "XXX"},
|
||||
{"Büro Projekt", "BRO"}, // non-ASCII letters skipped
|
||||
{"my project", "MYP"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := DeriveProjectCode(tc.name); got != tc.want {
|
||||
t.Errorf("DeriveProjectCode(%q)=%q want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextUniqueCode(t *testing.T) {
|
||||
used := map[string]bool{"ALP": true, "ALA": true}
|
||||
got := nextUniqueCode("Alpha", used)
|
||||
if got == "ALP" || got == "ALA" {
|
||||
t.Fatalf("expected unique code, got %q", got)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len=%d code=%q", len(got), got)
|
||||
}
|
||||
}
|
||||
+44
-10
@@ -25,7 +25,7 @@ func NewProjectStore(db *DB) *ProjectStore {
|
||||
func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
|
||||
var p project.Project
|
||||
var groupID sql.NullString
|
||||
if err := scanner.Scan(&p.ID, &p.Name, &groupID); err != nil {
|
||||
if err := scanner.Scan(&p.ID, &p.Name, &p.Code, &groupID); err != nil {
|
||||
return project.Project{}, err
|
||||
}
|
||||
if groupID.Valid {
|
||||
@@ -34,9 +34,11 @@ func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project,
|
||||
return p, nil
|
||||
}
|
||||
|
||||
const projectCols = `id, name, code, group_id`
|
||||
|
||||
// List returns all projects sorted by name.
|
||||
func (s *ProjectStore) List() ([]project.Project, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT id, name, group_id FROM projects ORDER BY name COLLATE NOCASE`)
|
||||
rows, err := s.db.SQL.Query(`SELECT ` + projectCols + ` FROM projects ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
@@ -55,19 +57,46 @@ func (s *ProjectStore) List() ([]project.Project, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProjectStore) usedCodes() (map[string]bool, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT code FROM projects WHERE code != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
used := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used[c] = true
|
||||
}
|
||||
return used, rows.Err()
|
||||
}
|
||||
|
||||
// Ensure creates the project if missing.
|
||||
func (s *ProjectStore) Ensure(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
used, err := s.usedCodes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code := nextUniqueCode(id, used)
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`, id, id, now, now)
|
||||
_, err = s.db.SQL.Exec(`
|
||||
INSERT INTO projects(id, name, code, created_at, updated_at) VALUES(?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`, id, id, code, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure project %s: %w", id, err)
|
||||
}
|
||||
var existing string
|
||||
_ = s.db.SQL.QueryRow(`SELECT code FROM projects WHERE id = ?`, id).Scan(&existing)
|
||||
if existing == "" {
|
||||
return s.db.backfillProjectCodes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -81,13 +110,18 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
used, err := s.usedCodes()
|
||||
if err != nil {
|
||||
return project.Project{}, err
|
||||
}
|
||||
code := nextUniqueCode(name, used)
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`,
|
||||
id, name, now, now)
|
||||
_, err = s.db.SQL.Exec(`INSERT INTO projects(id, name, code, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, ?, NULL)`,
|
||||
id, name, code, now, now)
|
||||
if err != nil {
|
||||
return project.Project{}, fmt.Errorf("create project: %w", err)
|
||||
}
|
||||
return project.Project{ID: id, Name: name}, nil
|
||||
return project.Project{ID: id, Name: name, Code: code}, nil
|
||||
}
|
||||
|
||||
// SetGroup assigns a project to a group. Empty groupID clears the assignment.
|
||||
@@ -151,7 +185,7 @@ func (s *ProjectStore) Delete(id string) error {
|
||||
|
||||
// Get returns one project.
|
||||
func (s *ProjectStore) Get(id string) (project.Project, error) {
|
||||
p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id))
|
||||
p, err := scanProject(s.db.SQL.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return project.Project{}, fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
@@ -291,7 +325,7 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
|
||||
return s.List()
|
||||
}
|
||||
rows, err := s.db.SQL.Query(`
|
||||
SELECT p.id, p.name, p.group_id FROM projects p
|
||||
SELECT p.id, p.name, p.code, p.group_id FROM projects p
|
||||
INNER JOIN project_members m ON m.project_id = p.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`, userID)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestTicketKeysSequential(t *testing.T) {
|
||||
db, err := Open(filepath.Join(t.TempDir(), "keys.db"), []byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ps := NewProjectStore(db)
|
||||
p, err := ps.Create("", "Task Master")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Code != "TAS" {
|
||||
t.Fatalf("code=%q want TAS", p.Code)
|
||||
}
|
||||
|
||||
ts := NewTicketStore(db)
|
||||
a, err := ts.Create(ticket.Ticket{Title: "One", ProjectID: p.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := ts.Create(ticket.Ticket{Title: "Two", ProjectID: p.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Key != "TAS-1" || a.Number != 1 {
|
||||
t.Fatalf("first=%+v", a)
|
||||
}
|
||||
if b.Key != "TAS-2" || b.Number != 2 {
|
||||
t.Fatalf("second=%+v", b)
|
||||
}
|
||||
|
||||
got, err := ts.Get(a.ID)
|
||||
if err != nil || got.Key != "TAS-1" {
|
||||
t.Fatalf("get=%+v err=%v", got, err)
|
||||
}
|
||||
|
||||
// Update must preserve key.
|
||||
got.Title = "One*"
|
||||
if err := ts.Update(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = ts.Get(a.ID)
|
||||
if got.Key != "TAS-1" || got.Title != "One*" {
|
||||
t.Fatalf("after update=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketKeyBackfill(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "backfill.db")
|
||||
db, err := Open(path, []byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Insert legacy-shaped rows as if pre-migration (columns exist after Open).
|
||||
_, err = db.SQL.Exec(`INSERT INTO projects(id, name, code, ticket_seq, created_at, updated_at)
|
||||
VALUES('p1', 'Inbox', '', 0, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.SQL.Exec(`INSERT INTO tickets(id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key)
|
||||
VALUES('t1', 'Old', '', 'todo', 'p1', '2020-01-02T00:00:00Z', '2020-01-02T00:00:00Z', '', '', '', '', NULL, '')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.backfillTicketKeys(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var code string
|
||||
var seq int
|
||||
if err := db.SQL.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id='p1'`).Scan(&code, &seq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code != "INB" || seq != 1 {
|
||||
t.Fatalf("project code=%q seq=%d", code, seq)
|
||||
}
|
||||
var key string
|
||||
var num int
|
||||
if err := db.SQL.QueryRow(`SELECT key, number FROM tickets WHERE id='t1'`).Scan(&key, &num); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key != "INB-1" || num != 1 {
|
||||
t.Fatalf("ticket key=%q num=%d", key, num)
|
||||
}
|
||||
_ = db.Close()
|
||||
}
|
||||
|
||||
func TestMoveReassignsKey(t *testing.T) {
|
||||
db, err := Open(filepath.Join(t.TempDir(), "move.db"), []byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ps := NewProjectStore(db)
|
||||
a, err := ps.Create("", "Alpha")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := ps.Create("", "Beta")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := NewTicketStore(db)
|
||||
tk, err := ts.Create(ticket.Ticket{Title: "Move me", ProjectID: a.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, err := ts.Move(tk.ID, b.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.ProjectID != b.ID || moved.Key != b.Code+"-1" {
|
||||
t.Fatalf("moved=%+v betaCode=%s", moved, b.Code)
|
||||
}
|
||||
}
|
||||
+100
-14
@@ -24,9 +24,11 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er
|
||||
var t ticket.Ticket
|
||||
var created, updated, synced string
|
||||
var status string
|
||||
var number sql.NullInt64
|
||||
err := scanner.Scan(
|
||||
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
|
||||
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
|
||||
&number, &t.Key,
|
||||
)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
@@ -35,10 +37,13 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er
|
||||
t.CreatedAt = parseTime(created)
|
||||
t.UpdatedAt = parseTime(updated)
|
||||
t.SyncedAt = parseTime(synced)
|
||||
if number.Valid {
|
||||
t.Number = int(number.Int64)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
|
||||
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key`
|
||||
|
||||
// List returns tickets for a project.
|
||||
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
|
||||
@@ -74,7 +79,7 @@ func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Create inserts a ticket.
|
||||
// Create inserts a ticket and assigns the next project key atomically.
|
||||
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
|
||||
if t.ProjectID == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
||||
@@ -95,24 +100,63 @@ func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
|
||||
tx, err := s.db.SQL.Begin()
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if t.Number == 0 || t.Key == "" {
|
||||
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, t.ProjectID)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, fmt.Errorf("create ticket: bump seq: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s not found", t.ProjectID)
|
||||
}
|
||||
var code string
|
||||
var seq int
|
||||
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, t.ProjectID).Scan(&code, &seq); err != nil {
|
||||
return ticket.Ticket{}, fmt.Errorf("create ticket: read seq: %w", err)
|
||||
}
|
||||
if code == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s has no code", t.ProjectID)
|
||||
}
|
||||
t.Number = seq
|
||||
t.Key = fmt.Sprintf("%s-%d", code, seq)
|
||||
}
|
||||
|
||||
var number any
|
||||
if t.Number > 0 {
|
||||
number = t.Number
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Title, t.Description, string(t.Status), t.ProjectID,
|
||||
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
|
||||
number, t.Key,
|
||||
)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Update replaces a ticket and bumps UpdatedAt.
|
||||
// Update replaces a ticket and bumps UpdatedAt. Number and Key are preserved from DB.
|
||||
func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||
if t.ID == "" || t.ProjectID == "" {
|
||||
return fmt.Errorf("id and projectId are required")
|
||||
}
|
||||
var created string
|
||||
err := s.db.SQL.QueryRow(`SELECT created_at FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).Scan(&created)
|
||||
var number sql.NullInt64
|
||||
var key string
|
||||
err := s.db.SQL.QueryRow(`SELECT created_at, number, key FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).
|
||||
Scan(&created, &number, &key)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
||||
}
|
||||
@@ -121,12 +165,16 @@ func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||
}
|
||||
t.CreatedAt = parseTime(created)
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
if number.Valid {
|
||||
t.Number = int(number.Int64)
|
||||
}
|
||||
t.Key = key
|
||||
res, err := s.db.SQL.Exec(`
|
||||
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
|
||||
source=?, external_id=?, external_url=?, synced_at=?
|
||||
source=?, external_id=?, external_url=?, synced_at=?, number=?, key=?
|
||||
WHERE id=?`,
|
||||
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
|
||||
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), t.ID,
|
||||
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), nullInt(t.Number), t.Key, t.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -138,6 +186,13 @@ func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullInt(n int) any {
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
|
||||
func (s *TicketStore) MarkSynced(id string) error {
|
||||
if id == "" {
|
||||
@@ -154,7 +209,7 @@ func (s *TicketStore) MarkSynced(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move relocates a ticket to another project.
|
||||
// Move relocates a ticket to another project and assigns a new key there.
|
||||
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
|
||||
if id == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
|
||||
@@ -162,17 +217,48 @@ func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
|
||||
if toProjectID == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
t, err := s.Get(id)
|
||||
|
||||
tx, err := s.db.SQL.Begin()
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
t.ProjectID = toProjectID
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
_, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`,
|
||||
toProjectID, formatTime(t.UpdatedAt), id)
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
row := tx.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
|
||||
t, err := scanTicket(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, toProjectID)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ticket.Ticket{}, fmt.Errorf("project %s: not found", toProjectID)
|
||||
}
|
||||
var code string
|
||||
var seq int
|
||||
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, toProjectID).Scan(&code, &seq); err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
t.ProjectID = toProjectID
|
||||
t.Number = seq
|
||||
t.Key = fmt.Sprintf("%s-%d", code, seq)
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
|
||||
_, err = tx.Exec(`UPDATE tickets SET project_id=?, updated_at=?, number=?, key=? WHERE id=?`,
|
||||
toProjectID, formatTime(t.UpdatedAt), t.Number, t.Key, id)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ticket
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDisplayID(t *testing.T) {
|
||||
if got := (Ticket{Key: "TAS-1", ExternalID: "uuid"}).DisplayID(); got != "TAS-1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := (Ticket{ExternalID: "YT-9"}).DisplayID(); got != "YT-9" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := (Ticket{}).DisplayID(); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package ticket
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -155,3 +156,62 @@ func TotalDuration(entries []TimeEntry) time.Duration {
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// TimeFilter selects which time entries belong in a report.
|
||||
// From/To are inclusive bounds on the entry's work instant (Start, else End).
|
||||
// Zero times mean no bound on that side.
|
||||
type TimeFilter struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
TicketID string
|
||||
}
|
||||
|
||||
// FilterEntries returns entries matching f. The input slice is not modified.
|
||||
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
|
||||
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
|
||||
out := make([]TimeEntry, len(entries))
|
||||
copy(out, entries)
|
||||
return out
|
||||
}
|
||||
out := make([]TimeEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if f.TicketID != "" && e.TicketID != f.TicketID {
|
||||
continue
|
||||
}
|
||||
when := e.Start
|
||||
if when.IsZero() {
|
||||
when = e.End
|
||||
}
|
||||
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
|
||||
continue
|
||||
}
|
||||
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
|
||||
func ParseReportDay(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid date %q", s)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// EndOfReportDay returns the last instant of the calendar day of t in local time.
|
||||
func EndOfReportDay(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return t
|
||||
}
|
||||
local := t.In(time.Local)
|
||||
y, m, d := local.Date()
|
||||
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
|
||||
}
|
||||
|
||||
@@ -70,3 +70,48 @@ func TestAggregateByDay(t *testing.T) {
|
||||
t.Fatalf("older day dur=%v", rows[1].Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEntries(t *testing.T) {
|
||||
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
|
||||
d2 := time.Date(2026, 10, 20, 11, 0, 0, 0, time.Local)
|
||||
entries := []ticket.TimeEntry{
|
||||
{ID: "1", TicketID: "a", Start: d1, Duration: time.Hour},
|
||||
{ID: "2", TicketID: "b", Start: d2, Duration: 30 * time.Minute},
|
||||
{ID: "3", TicketID: "a", Start: d2, Duration: 15 * time.Minute},
|
||||
}
|
||||
|
||||
byTicket := ticket.FilterEntries(entries, ticket.TimeFilter{TicketID: "a"})
|
||||
if len(byTicket) != 2 {
|
||||
t.Fatalf("ticket filter=%d", len(byTicket))
|
||||
}
|
||||
|
||||
from := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
|
||||
to := ticket.EndOfReportDay(time.Date(2026, 10, 20, 0, 0, 0, 0, time.Local))
|
||||
byRange := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to})
|
||||
if len(byRange) != 2 {
|
||||
t.Fatalf("range filter=%d", len(byRange))
|
||||
}
|
||||
|
||||
both := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to, TicketID: "a"})
|
||||
if len(both) != 1 || both[0].ID != "3" {
|
||||
t.Fatalf("combined=%+v", both)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReportDay(t *testing.T) {
|
||||
got, err := ticket.ParseReportDay("2026-10-18")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
y, m, d := got.Date()
|
||||
if y != 2026 || m != 10 || d != 18 || got.Hour() != 0 {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
empty, err := ticket.ParseReportDay("")
|
||||
if err != nil || !empty.IsZero() {
|
||||
t.Fatalf("empty: %v %v", empty, err)
|
||||
}
|
||||
if _, err := ticket.ParseReportDay("nope"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,22 @@ type Ticket struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Readable key assigned by the server (e.g. "ABC-42"). Optional on local-only tickets.
|
||||
Number int `json:"number,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
|
||||
// External sync (optional). Local UUID stays in ID.
|
||||
Source string `json:"source,omitempty"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
ExternalURL string `json:"externalUrl,omitempty"`
|
||||
SyncedAt time.Time `json:"syncedAt,omitempty"`
|
||||
}
|
||||
|
||||
// DisplayID returns the human-readable ticket label for UI.
|
||||
// Prefers the server key; falls back to ExternalID (e.g. YouTrack idReadable).
|
||||
func (t Ticket) DisplayID() string {
|
||||
if t.Key != "" {
|
||||
return t.Key
|
||||
}
|
||||
return t.ExternalID
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas
|
||||
next.Status = rt.Status
|
||||
next.Source = ticket.SourceTaskMaster
|
||||
next.ExternalURL = extURL
|
||||
next.Number = rt.Number
|
||||
next.Key = rt.Key
|
||||
if sameFields(prev, next) {
|
||||
_ = s.Store.MarkSynced(prev.ID)
|
||||
res.Skipped++
|
||||
@@ -104,6 +106,7 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas
|
||||
Title: rt.Title, Description: rt.Description, Status: rt.Status,
|
||||
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
|
||||
ExternalID: rt.ID, ExternalURL: extURL,
|
||||
Number: rt.Number, Key: rt.Key,
|
||||
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -142,6 +145,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas
|
||||
return res, err
|
||||
}
|
||||
t.ExternalID = created.ID
|
||||
t.Number = created.Number
|
||||
t.Key = created.Key
|
||||
t.Source = ticket.SourceTaskMaster
|
||||
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
|
||||
if err := s.Store.Update(t); err != nil {
|
||||
@@ -166,7 +171,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas
|
||||
}
|
||||
|
||||
func sameFields(a, b ticket.Ticket) bool {
|
||||
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status
|
||||
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status &&
|
||||
a.Key == b.Key && a.Number == b.Number
|
||||
}
|
||||
|
||||
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
|
||||
|
||||
@@ -177,8 +177,8 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
||||
|
||||
badge := l.statusBadge(t.Status)
|
||||
ext := ""
|
||||
if t.ExternalID != "" {
|
||||
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
|
||||
if id := t.DisplayID(); id != "" {
|
||||
ext = lipgloss.NewStyle().Faint(true).Render(id) + " "
|
||||
}
|
||||
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
|
||||
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
|
||||
|
||||
Generated
+146
@@ -7,6 +7,12 @@
|
||||
"": {
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"marked": "^18.1.2",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"svelte": "^5.57.2",
|
||||
@@ -63,6 +69,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
@@ -403,6 +415,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
@@ -423,6 +444,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -433,6 +467,27 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -503,6 +558,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -818,6 +882,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.1.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.2.tgz",
|
||||
"integrity": "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
@@ -944,6 +1020,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.57.3",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz",
|
||||
@@ -998,6 +1086,25 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown-plugin-gfm": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
|
||||
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
@@ -1096,6 +1203,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
||||
|
||||
@@ -12,5 +12,11 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"svelte": "^5.57.2",
|
||||
"vite": "^8.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.1.2",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
+287
-14
@@ -2,6 +2,9 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from './lib/api.js'
|
||||
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
||||
import MarkdownEditor from './lib/MarkdownEditor.svelte'
|
||||
import ExcelEditor from './lib/ExcelEditor.svelte'
|
||||
import { isSpreadsheetName } from './lib/spreadsheet.js'
|
||||
|
||||
let user = $state(null)
|
||||
let loading = $state(true)
|
||||
@@ -23,6 +26,9 @@
|
||||
let msg = $state('')
|
||||
let report = $state(null)
|
||||
let reportGroupBy = $state('ticket')
|
||||
let reportFrom = $state('')
|
||||
let reportTo = $state('')
|
||||
let reportTicketId = $state('')
|
||||
let dash = $state({ projects: [], openTickets: [] })
|
||||
let tokens = $state([])
|
||||
let newTokenSecret = $state('')
|
||||
@@ -50,6 +56,18 @@
|
||||
let editTimeEnd = $state('')
|
||||
let tokenName = $state('tui')
|
||||
let tokenScope = $state('write')
|
||||
let dragTicketId = $state('')
|
||||
let dragOverStatus = $state('')
|
||||
let suppressTicketClick = false
|
||||
let attachments = $state([])
|
||||
let excelAtt = $state(null)
|
||||
let attachBusy = $state(false)
|
||||
|
||||
const boardStatuses = [
|
||||
['todo', 'OPEN'],
|
||||
['in_progress', 'WIP'],
|
||||
['done', 'DONE'],
|
||||
]
|
||||
|
||||
function desiredHash() {
|
||||
return buildHash({
|
||||
@@ -180,7 +198,7 @@
|
||||
}
|
||||
|
||||
async function saveTimeEntry() {
|
||||
if (!selected || !editingTimeId) return
|
||||
if (!projectId || !editingTimeId) return
|
||||
if (!editTimeStart || !editTimeEnd) {
|
||||
error = 'Start and end are required'
|
||||
return
|
||||
@@ -197,7 +215,8 @@
|
||||
end: end.toISOString(),
|
||||
})
|
||||
cancelEditTime()
|
||||
await refreshTicketTime()
|
||||
if (projectView === 'report') await loadReport()
|
||||
else await refreshTicketTime()
|
||||
msg = 'Time updated'
|
||||
}
|
||||
|
||||
@@ -205,10 +224,25 @@
|
||||
if (!confirm('Delete time entry?')) return
|
||||
await api.deleteTime(projectId, eid)
|
||||
if (editingTimeId === eid) cancelEditTime()
|
||||
await refreshTicketTime()
|
||||
if (projectView === 'report') await loadReport()
|
||||
else await refreshTicketTime()
|
||||
msg = 'Time deleted'
|
||||
}
|
||||
|
||||
function ticketTitle(tid) {
|
||||
const t = tickets.find((x) => x.id === tid)
|
||||
return t?.title || tid || '—'
|
||||
}
|
||||
|
||||
function reportQuery() {
|
||||
return {
|
||||
groupBy: reportGroupBy,
|
||||
from: reportFrom,
|
||||
to: reportTo,
|
||||
ticketId: reportTicketId,
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
error = ''
|
||||
try {
|
||||
@@ -348,15 +382,53 @@
|
||||
editTitle = t.title
|
||||
editDesc = t.description || ''
|
||||
editStatus = t.status
|
||||
excelAtt = null
|
||||
cancelEditTime()
|
||||
revisions = await api.listRevisions(projectId, t.id)
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === t.id)
|
||||
attachments = await api.listAttachments(projectId, t.id)
|
||||
await refreshTimer()
|
||||
topTab = 'projects'
|
||||
projectView = 'ticket'
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function onAttachFile(e) {
|
||||
const file = e.target?.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || !selected) return
|
||||
attachBusy = true
|
||||
error = ''
|
||||
try {
|
||||
await api.uploadAttachment(projectId, selected.id, file)
|
||||
attachments = await api.listAttachments(projectId, selected.id)
|
||||
msg = 'Uploaded'
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
attachBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAttachment(a) {
|
||||
if (!selected || !confirm(`Delete ${a.filename}?`)) return
|
||||
await api.deleteAttachment(projectId, selected.id, a.id)
|
||||
if (excelAtt?.id === a.id) excelAtt = null
|
||||
attachments = await api.listAttachments(projectId, selected.id)
|
||||
msg = 'Attachment deleted'
|
||||
}
|
||||
|
||||
async function refreshAttachments() {
|
||||
if (!selected) return
|
||||
attachments = await api.listAttachments(projectId, selected.id)
|
||||
}
|
||||
|
||||
async function openTicket(t) {
|
||||
await showTicket(t)
|
||||
syncRoute()
|
||||
@@ -414,11 +486,15 @@
|
||||
await refreshTicketTime()
|
||||
}
|
||||
|
||||
async function loadReport() {
|
||||
report = await api.report(projectId, reportQuery())
|
||||
}
|
||||
|
||||
async function showReport() {
|
||||
topTab = 'projects'
|
||||
projectView = 'report'
|
||||
error = ''
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
await loadReport()
|
||||
}
|
||||
|
||||
async function openReport() {
|
||||
@@ -428,7 +504,27 @@
|
||||
|
||||
async function changeReportGroup(g) {
|
||||
reportGroupBy = g
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
await loadReport()
|
||||
}
|
||||
|
||||
async function applyReportFilters() {
|
||||
error = ''
|
||||
try {
|
||||
await loadReport()
|
||||
} catch (e) {
|
||||
error = e.message || String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function clearReportFilters() {
|
||||
reportFrom = ''
|
||||
reportTo = ''
|
||||
reportTicketId = ''
|
||||
}
|
||||
|
||||
async function resetReportFilters() {
|
||||
clearReportFilters()
|
||||
await applyReportFilters()
|
||||
}
|
||||
|
||||
async function showProjectSettings() {
|
||||
@@ -548,6 +644,63 @@
|
||||
return tickets.filter((t) => t.status === status)
|
||||
}
|
||||
|
||||
async function setTicketStatus(ticket, status) {
|
||||
if (!ticket || ticket.status === status) return
|
||||
error = ''
|
||||
const prev = ticket.status
|
||||
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status } : t))
|
||||
try {
|
||||
await api.patchTicket(projectId, ticket.id, { status })
|
||||
if (selected?.id === ticket.id) {
|
||||
selected = { ...selected, status }
|
||||
editStatus = status
|
||||
}
|
||||
} catch (e) {
|
||||
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status: prev } : t))
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function onTicketDragStart(e, ticket) {
|
||||
dragTicketId = ticket.id
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', ticket.id)
|
||||
}
|
||||
|
||||
function onTicketDragEnd() {
|
||||
dragTicketId = ''
|
||||
dragOverStatus = ''
|
||||
// Click fires after dragend on some browsers; suppress open-once.
|
||||
suppressTicketClick = true
|
||||
setTimeout(() => {
|
||||
suppressTicketClick = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function onColumnDragOver(e, status) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
dragOverStatus = status
|
||||
}
|
||||
|
||||
function onColumnDragLeave(e, status) {
|
||||
if (dragOverStatus === status) dragOverStatus = ''
|
||||
}
|
||||
|
||||
async function onColumnDrop(e, status) {
|
||||
e.preventDefault()
|
||||
const id = e.dataTransfer.getData('text/plain') || dragTicketId
|
||||
dragTicketId = ''
|
||||
dragOverStatus = ''
|
||||
const ticket = tickets.find((t) => t.id === id)
|
||||
if (ticket) await setTicketStatus(ticket, status)
|
||||
}
|
||||
|
||||
function onBoardTicketClick(t) {
|
||||
if (suppressTicketClick) return
|
||||
openTicket(t)
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -732,15 +885,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid cols-3">
|
||||
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
|
||||
<div class="stack">
|
||||
{#each boardStatuses as [st, label]}
|
||||
<div
|
||||
class="board-col stack"
|
||||
class:drag-over={dragOverStatus === st}
|
||||
ondragover={(e) => onColumnDragOver(e, st)}
|
||||
ondragleave={(e) => onColumnDragLeave(e, st)}
|
||||
ondrop={(e) => onColumnDrop(e, st)}
|
||||
role="list"
|
||||
aria-label={label}
|
||||
>
|
||||
<h3>{label}</h3>
|
||||
{#each byStatus(st) as t}
|
||||
<button class="ticket" onclick={() => openTicket(t)}>
|
||||
<span class="badge {t.status}">{label}</span>
|
||||
<div>{t.title}</div>
|
||||
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
|
||||
</button>
|
||||
<div
|
||||
class="ticket"
|
||||
class:dragging={dragTicketId === t.id}
|
||||
draggable="true"
|
||||
role="listitem"
|
||||
ondragstart={(e) => onTicketDragStart(e, t)}
|
||||
ondragend={onTicketDragEnd}
|
||||
>
|
||||
<button type="button" class="ticket-open" onclick={() => onBoardTicketClick(t)}>
|
||||
<div>{t.title}</div>
|
||||
{#if t.key || t.externalId}<div class="muted">{t.key || t.externalId}</div>{/if}
|
||||
</button>
|
||||
<span class="drag-handle" title="Ziehen" aria-hidden="true">⠿</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="board-empty muted">Drop hier</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -752,6 +924,9 @@
|
||||
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
|
||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||
</div>
|
||||
{#if selected.key || selected.externalId}
|
||||
<div class="muted">{selected.key || selected.externalId}</div>
|
||||
{/if}
|
||||
<label>Title<input bind:value={editTitle} /></label>
|
||||
<label>Status
|
||||
<select bind:value={editStatus}>
|
||||
@@ -760,9 +935,50 @@
|
||||
<option value="done">DONE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Description<textarea bind:value={editDesc}></textarea></label>
|
||||
<MarkdownEditor bind:value={editDesc} label="Description" />
|
||||
<button class="primary" onclick={saveTicket}>Save</button>
|
||||
|
||||
<h3>Attachments</h3>
|
||||
<div class="row" style="flex-wrap: wrap; gap: 0.5rem; align-items: center;">
|
||||
<label class="attach-upload">
|
||||
<span class="buttonish">{attachBusy ? 'Uploading…' : 'Upload file'}</span>
|
||||
<input type="file" disabled={attachBusy} onchange={onAttachFile} />
|
||||
</label>
|
||||
<span class="muted">Max 10 MiB</span>
|
||||
</div>
|
||||
{#if excelAtt}
|
||||
<ExcelEditor
|
||||
projectId={projectId}
|
||||
ticketId={selected.id}
|
||||
attachment={excelAtt}
|
||||
onclose={() => (excelAtt = null)}
|
||||
onsaved={refreshAttachments}
|
||||
/>
|
||||
{/if}
|
||||
<ul class="attach-list">
|
||||
{#each attachments as a}
|
||||
<li class="attach-item">
|
||||
<div class="attach-meta">
|
||||
<span class="attach-name">{a.filename}</span>
|
||||
<span class="muted">{formatBytes(a.sizeBytes)}</span>
|
||||
</div>
|
||||
<div class="row" style="width: auto; justify-content: flex-end;">
|
||||
{#if isSpreadsheetName(a.filename)}
|
||||
<button class="primary" onclick={() => (excelAtt = a)}>Edit</button>
|
||||
{/if}
|
||||
<a
|
||||
class="buttonish"
|
||||
href={api.downloadAttachmentUrl(projectId, selected.id, a.id)}
|
||||
download={a.filename}
|
||||
>Download</a>
|
||||
<button class="danger" onclick={() => removeAttachment(a)}>Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="muted">No attachments yet.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<h3>Time</h3>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={toggleTimer}>
|
||||
@@ -826,7 +1042,7 @@
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'report'}
|
||||
<section class="panel stack">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.75rem;">
|
||||
<h2>Report — {projectName}</h2>
|
||||
<div class="row">
|
||||
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
|
||||
@@ -834,8 +1050,27 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="flex-wrap: wrap; gap: 0.75rem; align-items: flex-end;">
|
||||
<label style="width: auto;">From
|
||||
<input type="date" bind:value={reportFrom} style="width: auto;" />
|
||||
</label>
|
||||
<label style="width: auto;">To
|
||||
<input type="date" bind:value={reportTo} style="width: auto;" />
|
||||
</label>
|
||||
<label style="width: auto; flex: 1; min-width: 12rem;">Ticket
|
||||
<select bind:value={reportTicketId}>
|
||||
<option value="">All tickets</option>
|
||||
{#each tickets as t}
|
||||
<option value={t.id}>{t.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary" onclick={applyReportFilters}>Filter</button>
|
||||
<button onclick={resetReportFilters}>Reset</button>
|
||||
</div>
|
||||
{#if report}
|
||||
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
|
||||
<h3>Summary</h3>
|
||||
<table>
|
||||
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -846,6 +1081,44 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Entries</h3>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Ticket</th><th>Duration</th><th>Source</th><th>Note</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each report.entries || [] as e}
|
||||
{#if editingTimeId === e.id}
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
|
||||
<label class="row" style="width: auto;">Start
|
||||
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
|
||||
</label>
|
||||
<label class="row" style="width: auto;">End
|
||||
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
|
||||
</label>
|
||||
<button class="primary" onclick={saveTimeEntry}>Save</button>
|
||||
<button onclick={cancelEditTime}>Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
||||
<td>{ticketTitle(e.ticketId)}</td>
|
||||
<td>{formatDuration(e.duration)}</td>
|
||||
<td>{e.source}</td>
|
||||
<td class="muted">{e.note || ''}</td>
|
||||
<td class="row" style="width: auto; justify-content: flex-end;">
|
||||
<button onclick={() => beginEditTime(e)}>Edit</button>
|
||||
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{:else}
|
||||
<tr><td colspan="6" class="muted">No entries for this filter.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'settings'}
|
||||
|
||||
+324
-5
@@ -109,6 +109,13 @@ code {
|
||||
padding: 0.05em 0.35em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
@@ -350,20 +357,39 @@ code {
|
||||
.error { color: var(--danger); margin: 0.5rem 0; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.stack { display: grid; gap: 0.6rem; }
|
||||
.board-col {
|
||||
min-height: 8rem;
|
||||
padding: 0.55rem;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed transparent;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
align-content: start;
|
||||
}
|
||||
.board-col.drag-over {
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
.board-empty {
|
||||
padding: 0.75rem 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ticket {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem 0.4rem;
|
||||
background: color-mix(in srgb, var(--panel-2) 55%, transparent);
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ticket:hover {
|
||||
border: none;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--accent) 35%, transparent) 0%,
|
||||
@@ -371,6 +397,32 @@ code {
|
||||
color-mix(in srgb, var(--panel-2) 70%, transparent) 100%
|
||||
);
|
||||
}
|
||||
.ticket.dragging {
|
||||
opacity: 0.45;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.ticket-open {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
width: 100%;
|
||||
padding: 0.05rem 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
.ticket-open:hover {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
.drag-handle {
|
||||
justify-self: end;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.08em;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
@@ -400,3 +452,270 @@ td, th {
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; }
|
||||
label > :is(input, select, textarea) { color: var(--ink); }
|
||||
|
||||
.md-editor {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.md-editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.md-editor-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.md-editor-modes {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #10151a;
|
||||
}
|
||||
.md-editor-modes button {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
.md-editor-modes button:hover {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
|
||||
}
|
||||
.md-editor-modes button.active {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.md-toolbar button {
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.md-toolbar button:hover {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--panel) 80%, transparent);
|
||||
}
|
||||
.md-visual,
|
||||
.md-source {
|
||||
min-height: 14rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0 0 6px 6px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #10151a;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
.md-source {
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.md-visual {
|
||||
overflow-y: auto;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.md-visual:focus,
|
||||
.md-source:focus {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
}
|
||||
.md-visual :first-child { margin-top: 0; }
|
||||
.md-visual :last-child { margin-bottom: 0; }
|
||||
.md-visual h1,
|
||||
.md-visual h2,
|
||||
.md-visual h3 {
|
||||
margin: 0.85em 0 0.4em;
|
||||
font-weight: 650;
|
||||
color: #f0f4f8;
|
||||
}
|
||||
.md-visual h1 { font-size: 1.35rem; }
|
||||
.md-visual h2 { font-size: 1.15rem; }
|
||||
.md-visual h3 { font-size: 1.05rem; }
|
||||
.md-visual p { margin: 0.5em 0; }
|
||||
.md-visual ul,
|
||||
.md-visual ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
.md-visual li { margin: 0.2em 0; }
|
||||
.md-visual li > input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-right: 0.4rem;
|
||||
accent-color: var(--accent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.md-visual a { color: var(--accent); }
|
||||
.md-visual pre,
|
||||
.md-visual code {
|
||||
font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.md-visual pre {
|
||||
margin: 0.6em 0;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: #0c1014;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.md-visual pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.md-visual blockquote {
|
||||
margin: 0.6em 0;
|
||||
padding: 0.15rem 0 0.15rem 0.75rem;
|
||||
border-left: 3px solid color-mix(in srgb, var(--accent) 50%, var(--line));
|
||||
color: var(--muted);
|
||||
}
|
||||
.md-visual hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.attach-upload {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attach-upload input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.buttonish {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.buttonish:hover {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
|
||||
}
|
||||
.attach-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.attach-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #10151a;
|
||||
}
|
||||
.attach-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.attach-name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.excel-editor {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.excel-sheets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
.excel-sheets button {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.excel-sheets button.active {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
.excel-scroll {
|
||||
overflow: auto;
|
||||
max-height: min(60vh, 28rem);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: #0c1014;
|
||||
}
|
||||
.excel-grid {
|
||||
border-collapse: collapse;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.excel-grid th,
|
||||
.excel-grid td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.excel-rownum {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: var(--panel-2);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.excel-grid td input {
|
||||
width: 7.5rem;
|
||||
min-width: 4rem;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.35rem 0.45rem;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.excel-grid td input:focus {
|
||||
outline: 1px solid color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
background: #10151a;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script>
|
||||
import { api } from './api.js'
|
||||
import { spreadsheetWriteOptions } from './spreadsheet.js'
|
||||
|
||||
let {
|
||||
projectId,
|
||||
ticketId,
|
||||
attachment,
|
||||
onclose = () => {},
|
||||
onsaved = () => {},
|
||||
} = $props()
|
||||
|
||||
let loading = $state(true)
|
||||
let saving = $state(false)
|
||||
let error = $state('')
|
||||
let workbook = $state(null)
|
||||
let sheetNames = $state([])
|
||||
let activeSheet = $state('')
|
||||
/** @type {string[][]} */
|
||||
let grid = $state([])
|
||||
let XLSX = null
|
||||
|
||||
async function ensureXlsx() {
|
||||
if (!XLSX) XLSX = await import('xlsx')
|
||||
return XLSX
|
||||
}
|
||||
|
||||
function sheetToGrid(wb, name) {
|
||||
const ws = wb.Sheets[name]
|
||||
if (!ws) return [['']]
|
||||
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', raw: false })
|
||||
if (!rows.length) return [['']]
|
||||
const width = Math.max(...rows.map((r) => r.length), 1)
|
||||
return rows.map((r) => {
|
||||
const row = Array.from({ length: width }, (_, i) => String(r[i] ?? ''))
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
function gridToSheet(rows) {
|
||||
return XLSX.utils.aoa_to_sheet(rows)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
await ensureXlsx()
|
||||
const buf = await api.downloadAttachment(projectId, ticketId, attachment.id)
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
workbook = wb
|
||||
sheetNames = wb.SheetNames.slice()
|
||||
activeSheet = sheetNames[0] || ''
|
||||
grid = activeSheet ? sheetToGrid(wb, activeSheet) : [['']]
|
||||
} catch (e) {
|
||||
error = e.message || String(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectSheet(name) {
|
||||
if (!workbook || name === activeSheet) return
|
||||
workbook.Sheets[activeSheet] = gridToSheet(grid)
|
||||
activeSheet = name
|
||||
grid = sheetToGrid(workbook, name)
|
||||
}
|
||||
|
||||
function setCell(ri, ci, value) {
|
||||
grid = grid.map((row, i) =>
|
||||
i === ri ? row.map((c, j) => (j === ci ? value : c)) : row,
|
||||
)
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const width = grid[0]?.length || 1
|
||||
grid = [...grid, Array.from({ length: width }, () => '')]
|
||||
}
|
||||
|
||||
function addCol() {
|
||||
grid = grid.map((row) => [...row, ''])
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!workbook) return
|
||||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
await ensureXlsx()
|
||||
workbook.Sheets[activeSheet] = gridToSheet(grid)
|
||||
const { bookType, contentType } = spreadsheetWriteOptions(attachment.filename)
|
||||
const out = XLSX.write(workbook, { bookType, type: 'array' })
|
||||
await api.putAttachment(projectId, ticketId, attachment.id, out, { contentType })
|
||||
onsaved()
|
||||
msgOk()
|
||||
} catch (e) {
|
||||
error = e.message || String(e)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
let justSaved = $state(false)
|
||||
function msgOk() {
|
||||
justSaved = true
|
||||
setTimeout(() => {
|
||||
justSaved = false
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (attachment?.id) load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="excel-editor">
|
||||
<div class="excel-editor-head row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<div class="row" style="width: auto; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<strong>{attachment.filename}</strong>
|
||||
{#if justSaved}<span class="muted">Saved</span>{/if}
|
||||
{#if error}<span class="error">{error}</span>{/if}
|
||||
</div>
|
||||
<div class="row" style="width: auto;">
|
||||
<button class="primary" disabled={loading || saving || !!error && !workbook} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onclick={onclose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading spreadsheet…</p>
|
||||
{:else if workbook}
|
||||
<div class="excel-sheets" role="tablist">
|
||||
{#each sheetNames as name}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class:active={name === activeSheet}
|
||||
aria-selected={name === activeSheet}
|
||||
onclick={() => selectSheet(name)}
|
||||
>{name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="excel-toolbar row" style="width: auto;">
|
||||
<button type="button" onclick={addRow}>+ Row</button>
|
||||
<button type="button" onclick={addCol}>+ Column</button>
|
||||
</div>
|
||||
<div class="excel-scroll">
|
||||
<table class="excel-grid">
|
||||
<tbody>
|
||||
{#each grid as row, ri}
|
||||
<tr>
|
||||
<th class="excel-rownum">{ri + 1}</th>
|
||||
{#each row as cell, ci}
|
||||
<td>
|
||||
<input
|
||||
value={cell}
|
||||
oninput={(e) => setCell(ri, ci, e.currentTarget.value)}
|
||||
aria-label={`R${ri + 1}C${ci + 1}`}
|
||||
/>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script>
|
||||
import { markdownToHtml, htmlToMarkdown } from './markdown.js'
|
||||
|
||||
let { value = $bindable(''), label = 'Description' } = $props()
|
||||
|
||||
let mode = $state('visual')
|
||||
let visualEl = $state(null)
|
||||
let lastApplied = ''
|
||||
let appliedTo = null
|
||||
|
||||
function enableCheckboxes(root) {
|
||||
if (!root) return
|
||||
root.querySelectorAll('input[type="checkbox"]').forEach((box) => {
|
||||
box.disabled = false
|
||||
box.onchange = readVisual
|
||||
})
|
||||
}
|
||||
|
||||
function applyVisual(md) {
|
||||
if (!visualEl) return
|
||||
visualEl.innerHTML = markdownToHtml(md || '')
|
||||
enableCheckboxes(visualEl)
|
||||
lastApplied = md || ''
|
||||
appliedTo = visualEl
|
||||
}
|
||||
|
||||
function readVisual() {
|
||||
if (!visualEl) return
|
||||
value = htmlToMarkdown(visualEl.innerHTML)
|
||||
lastApplied = value
|
||||
}
|
||||
|
||||
function setMode(next) {
|
||||
if (next === mode) return
|
||||
if (mode === 'visual') readVisual()
|
||||
mode = next
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (mode !== 'visual') return
|
||||
const el = visualEl
|
||||
if (!el) return
|
||||
const md = value ?? ''
|
||||
// Remount (mode switch) or external value change (e.g. restore revision)
|
||||
if (el !== appliedTo || md !== lastApplied) applyVisual(md)
|
||||
})
|
||||
|
||||
function exec(cmd, arg) {
|
||||
visualEl?.focus()
|
||||
document.execCommand(cmd, false, arg)
|
||||
readVisual()
|
||||
}
|
||||
|
||||
function insertChecklist() {
|
||||
visualEl?.focus()
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
'<ul><li><input type="checkbox"> Task</li></ul>',
|
||||
)
|
||||
enableCheckboxes(visualEl)
|
||||
readVisual()
|
||||
}
|
||||
|
||||
function promptLink() {
|
||||
const url = window.prompt('URL')
|
||||
if (!url) return
|
||||
exec('createLink', url)
|
||||
}
|
||||
|
||||
function formatCodeBlock() {
|
||||
visualEl?.focus()
|
||||
const sel = window.getSelection()
|
||||
const text = sel?.toString() || 'code'
|
||||
// Prefer <pre><code> so turndown emits a fenced markdown block.
|
||||
const escaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
document.execCommand('insertHTML', false, `<pre><code>${escaped}</code></pre>`)
|
||||
readVisual()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="md-editor">
|
||||
<div class="md-editor-head">
|
||||
<span class="md-editor-label">{label}</span>
|
||||
<div class="md-editor-modes" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class:active={mode === 'visual'}
|
||||
aria-selected={mode === 'visual'}
|
||||
onclick={() => setMode('visual')}
|
||||
>Visual</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class:active={mode === 'markdown'}
|
||||
aria-selected={mode === 'markdown'}
|
||||
onclick={() => setMode('markdown')}
|
||||
>Markdown</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mode === 'visual'}
|
||||
<div class="md-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<button type="button" title="Bold" onclick={() => exec('bold')}><b>B</b></button>
|
||||
<button type="button" title="Italic" onclick={() => exec('italic')}><i>I</i></button>
|
||||
<button type="button" title="Heading" onclick={() => exec('formatBlock', 'h2')}>H</button>
|
||||
<button type="button" title="Bullet list" onclick={() => exec('insertUnorderedList')}>• List</button>
|
||||
<button type="button" title="Numbered list" onclick={() => exec('insertOrderedList')}>1.</button>
|
||||
<button type="button" title="Checklist" onclick={insertChecklist}>☑</button>
|
||||
<button type="button" title="Link" onclick={promptLink}>Link</button>
|
||||
<button type="button" title="Code block" onclick={formatCodeBlock}>Code</button>
|
||||
</div>
|
||||
<div
|
||||
class="md-visual"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={label}
|
||||
bind:this={visualEl}
|
||||
oninput={readVisual}
|
||||
onblur={readVisual}
|
||||
></div>
|
||||
{:else}
|
||||
<textarea
|
||||
class="md-source"
|
||||
bind:value
|
||||
aria-label="{label} (Markdown)"
|
||||
placeholder="Write Markdown…"
|
||||
spellcheck="true"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
+58
-3
@@ -1,9 +1,29 @@
|
||||
async function request(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) }
|
||||
if (opts.body != null && !(opts.body instanceof FormData) && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
const res = await fetch(`/api/v1${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
...opts,
|
||||
headers,
|
||||
})
|
||||
if (opts.raw) {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
let data = null
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
data = { error: text }
|
||||
}
|
||||
const err = new Error((data && data.error) || res.statusText)
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return res
|
||||
}
|
||||
const text = await res.text()
|
||||
let data = null
|
||||
try {
|
||||
@@ -71,8 +91,14 @@ export const api = {
|
||||
body: '{}',
|
||||
}),
|
||||
|
||||
report: (projectId, groupBy = 'ticket') =>
|
||||
request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`),
|
||||
report: (projectId, { groupBy = 'ticket', from = '', to = '', ticketId = '' } = {}) => {
|
||||
const q = new URLSearchParams()
|
||||
q.set('groupBy', groupBy)
|
||||
if (from) q.set('from', from)
|
||||
if (to) q.set('to', to)
|
||||
if (ticketId) q.set('ticketId', ticketId)
|
||||
return request(`/projects/${projectId}/report?${q}`)
|
||||
},
|
||||
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
||||
createTime: (projectId, body) =>
|
||||
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -89,4 +115,33 @@ export const api = {
|
||||
createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listAttachments: (projectId, tid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/attachments`),
|
||||
uploadAttachment: (projectId, tid, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return request(`/projects/${projectId}/tickets/${tid}/attachments`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
})
|
||||
},
|
||||
downloadAttachment: async (projectId, tid, aid) => {
|
||||
const res = await request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
|
||||
raw: true,
|
||||
})
|
||||
return res.arrayBuffer()
|
||||
},
|
||||
downloadAttachmentUrl: (projectId, tid, aid) =>
|
||||
`/api/v1/projects/${projectId}/tickets/${tid}/attachments/${aid}`,
|
||||
putAttachment: (projectId, tid, aid, body, { contentType } = {}) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
|
||||
method: 'PUT',
|
||||
headers: contentType ? { 'Content-Type': contentType } : {},
|
||||
body,
|
||||
}),
|
||||
deleteAttachment: (projectId, tid, aid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { marked } from 'marked'
|
||||
import TurndownService from 'turndown'
|
||||
import { gfm } from 'turndown-plugin-gfm'
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
})
|
||||
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
emDelimiter: '*',
|
||||
})
|
||||
turndown.use(gfm)
|
||||
|
||||
// Browsers' formatBlock('pre') often yields bare <pre> without <code>.
|
||||
// Default turndown only fences <pre><code>; keep bare <pre> as fenced blocks.
|
||||
turndown.addRule('barePre', {
|
||||
filter(node) {
|
||||
return (
|
||||
node.nodeName === 'PRE' &&
|
||||
!(node.firstChild && node.firstChild.nodeName === 'CODE')
|
||||
)
|
||||
},
|
||||
replacement(_content, node) {
|
||||
const text = node.textContent.replace(/\n$/, '')
|
||||
return `\n\n\`\`\`\n${text}\n\`\`\`\n\n`
|
||||
},
|
||||
})
|
||||
|
||||
export function markdownToHtml(md) {
|
||||
if (!md || !md.trim()) return '<p><br></p>'
|
||||
return marked.parse(md)
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(html) {
|
||||
if (!html) return ''
|
||||
const md = turndown.turndown(html)
|
||||
return md.replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Extensions SheetJS can read/write for simple cell editing. */
|
||||
const FORMATS = {
|
||||
xlsx: {
|
||||
bookType: 'xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
},
|
||||
xlsm: {
|
||||
bookType: 'xlsm',
|
||||
contentType: 'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
},
|
||||
xlsb: {
|
||||
bookType: 'xlsb',
|
||||
contentType: 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
|
||||
},
|
||||
xls: {
|
||||
bookType: 'xls',
|
||||
contentType: 'application/vnd.ms-excel',
|
||||
},
|
||||
ods: {
|
||||
bookType: 'ods',
|
||||
contentType: 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
},
|
||||
fods: {
|
||||
bookType: 'fods',
|
||||
contentType: 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
},
|
||||
csv: {
|
||||
bookType: 'csv',
|
||||
contentType: 'text/csv',
|
||||
},
|
||||
}
|
||||
|
||||
const EXT_RE = /\.([a-z0-9]+)$/i
|
||||
|
||||
export function isSpreadsheetName(name) {
|
||||
const m = String(name || '').match(EXT_RE)
|
||||
if (!m) return false
|
||||
return Object.prototype.hasOwnProperty.call(FORMATS, m[1].toLowerCase())
|
||||
}
|
||||
|
||||
export function spreadsheetWriteOptions(filename) {
|
||||
const m = String(filename || '').match(EXT_RE)
|
||||
const ext = m ? m[1].toLowerCase() : 'xlsx'
|
||||
return FORMATS[ext] || FORMATS.xlsx
|
||||
}
|
||||
Reference in New Issue
Block a user