Compare commits

..
7 Commits
Author SHA1 Message Date
skrueckenandCursor 69c6747a31 Keep board tickets sized to their content instead of stretching with taller columns.
CI / demo (push) Skipped
CI / test-and-build (push) Failing after 1m32s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:11:41 +01:00
skrueckenandCursor efeb4a74a8 Assign readable ticket keys from project code and sequence.
Replace UUID-looking sync labels in TUI and web with keys like TAS-1,
while keeping the remote UUID for task-master matching.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:40:55 +01:00
skrueckenandCursor f97856b8a6 Allow dragging tickets between OPEN, WIP, and DONE on the project board.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:29:01 +01:00
skrueckenandCursor 34ab3b9c5b Add date and ticket filters plus entry list to the project time report.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:23:22 +01:00
33 changed files with 2612 additions and 62 deletions
+1
View File
@@ -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") {
+1
View File
@@ -4,5 +4,6 @@ package project
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Code string `json:"code,omitempty"`
GroupID string `json:"groupId,omitempty"`
}
+275
View File
@@ -0,0 +1,275 @@
package server
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"unicode"
"task/internal/sqlite"
)
const maxAttachmentBytes = 10 << 20 // 10 MiB
func (s *Server) attachmentsRoot() string {
root := strings.TrimSpace(s.DataDir)
if root == "" {
root = "."
}
return filepath.Join(root, "attachments")
}
func (s *Server) attachmentPath(ticketID, attachmentID string) string {
return filepath.Join(s.attachmentsRoot(), ticketID, attachmentID)
}
func (s *Server) removeTicketAttachmentDir(ticketID string) {
_ = os.RemoveAll(filepath.Join(s.attachmentsRoot(), ticketID))
}
func sanitizeFilename(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == ".." {
return "file"
}
var b strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
case r == '.', r == '-', r == '_', r == ' ':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := strings.TrimSpace(b.String())
if out == "" || out == "." || out == ".." {
return "file"
}
return out
}
func (s *Server) loadTicketAttachment(projectID, ticketID, attachmentID string) (sqlite.Attachment, error) {
t, err := s.Tickets.Get(ticketID)
if err != nil || t.ProjectID != projectID {
return sqlite.Attachment{}, fmt.Errorf("ticket not found")
}
a, err := s.Attachments.Get(attachmentID)
if err != nil || a.TicketID != ticketID || a.ProjectID != projectID {
return sqlite.Attachment{}, fmt.Errorf("attachment not found")
}
return a, nil
}
func (s *Server) handleListAttachments(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
list, err := s.Attachments.ListByTicket(tid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+512)
if err := r.ParseMultipartForm(maxAttachmentBytes + 512); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file")
return
}
defer file.Close()
if hdr.Size > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
filename := sanitizeFilename(hdr.Filename)
contentType := hdr.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
a, err := s.Attachments.Create(sqlite.Attachment{
TicketID: tid,
ProjectID: id,
Filename: filename,
ContentType: contentType,
SizeBytes: 0,
CreatedBy: u.ID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
dir := filepath.Join(s.attachmentsRoot(), tid)
if err := os.MkdirAll(dir, 0o755); err != nil {
_ = s.Attachments.Delete(a.ID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
path := s.attachmentPath(tid, a.ID)
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
_ = s.Attachments.Delete(a.ID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
n, copyErr := io.Copy(dst, io.LimitReader(file, maxAttachmentBytes+1))
closeErr := dst.Close()
if copyErr != nil || closeErr != nil || n > maxAttachmentBytes {
_ = os.Remove(path)
_ = s.Attachments.Delete(a.ID)
if n > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
if copyErr != nil {
writeError(w, http.StatusInternalServerError, copyErr.Error())
return
}
writeError(w, http.StatusInternalServerError, closeErr.Error())
return
}
_ = s.Attachments.UpdateSize(a.ID, n)
a.SizeBytes = n
writeJSON(w, http.StatusCreated, a)
}
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
a, err := s.loadTicketAttachment(id, tid, aid)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
path := s.attachmentPath(tid, aid)
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusNotFound, "file missing")
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, strings.ReplaceAll(a.Filename, `"`, "")))
w.Header().Set("Content-Length", fmt.Sprintf("%d", st.Size()))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
func (s *Server) handlePutAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
a, err := s.loadTicketAttachment(id, tid, aid)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+1)
data, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
if int64(len(data)) > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
path := s.attachmentPath(tid, aid)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
size := int64(len(data))
if err := s.Attachments.UpdateSize(aid, size); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.SizeBytes = size
ct := r.Header.Get("Content-Type")
if ct != "" && ct != "application/octet-stream" {
a.ContentType = ct
}
writeJSON(w, http.StatusOK, a)
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, err := s.loadTicketAttachment(id, tid, aid); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
_ = os.Remove(s.attachmentPath(tid, aid))
if err := s.Attachments.Delete(aid); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+171
View File
@@ -0,0 +1,171 @@
package server
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestAttachmentUploadListDownloadPutDelete(t *testing.T) {
tmp := t.TempDir()
db, err := sqlite.Open(filepath.Join(tmp, "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(tmp, "data")
srv := New(db)
srv.DataDir = dataDir
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("file", "notes.txt")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("hello attach")); err != nil {
t.Fatal(err)
}
_ = mw.Close()
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("upload %d %s", rec.Code, rec.Body.String())
}
var att map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &att)
aid, _ := att["id"].(string)
if aid == "" {
t.Fatal("no attachment id")
}
if att["filename"] != "notes.txt" {
t.Fatalf("filename %v", att["filename"])
}
if int64(att["sizeBytes"].(float64)) != 12 {
t.Fatalf("size %v", att["sizeBytes"])
}
diskPath := filepath.Join(dataDir, "attachments", tid, aid)
if _, err := os.Stat(diskPath); err != nil {
t.Fatalf("disk file: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list %d %s", rec.Code, rec.Body.String())
}
var list []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 1 {
t.Fatalf("list len %d", len(list))
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("download %d %s", rec.Code, rec.Body.String())
}
got, _ := io.ReadAll(rec.Body)
if string(got) != "hello attach" {
t.Fatalf("download body %q", got)
}
putBody := []byte("updated content!!")
req = httptest.NewRequest(http.MethodPut, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, bytes.NewReader(putBody))
req.Header.Set("Content-Type", "text/plain")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("put %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &att)
if int64(att["sizeBytes"].(float64)) != int64(len(putBody)) {
t.Fatalf("put size %v", att["sizeBytes"])
}
disk, err := os.ReadFile(diskPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(disk, putBody) {
t.Fatalf("disk after put %q", disk)
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete %d %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(diskPath); !os.IsNotExist(err) {
t.Fatalf("file should be gone: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("list after delete %d", len(list))
}
}
+40
View File
@@ -2,6 +2,7 @@ package server
import (
"net/http"
"sort"
"task/internal/sqlite"
"task/internal/ticket"
@@ -29,11 +30,46 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
return
}
from, err := ticket.ParseReportDay(r.URL.Query().Get("from"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
toDay, err := ticket.ParseReportDay(r.URL.Query().Get("to"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var toBound = toDay
if !toDay.IsZero() {
toBound = ticket.EndOfReportDay(toDay)
}
filter := ticket.TimeFilter{
From: from,
To: toBound,
TicketID: r.URL.Query().Get("ticketId"),
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
entries = ticket.FilterEntries(entries, filter)
sort.Slice(entries, func(i, j int) bool {
ai, aj := entries[i].Start, entries[j].Start
if ai.IsZero() {
ai = entries[i].End
}
if aj.IsZero() {
aj = entries[j].End
}
if !ai.Equal(aj) {
return ai.After(aj)
}
return entries[i].ID > entries[j].ID
})
tickets, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -64,5 +100,9 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
"totalDurationNs": int64(total),
"rows": out,
"entryCount": len(entries),
"entries": entries,
"from": r.URL.Query().Get("from"),
"to": r.URL.Query().Get("to"),
"ticketId": filter.TicketID,
})
}
+109
View File
@@ -0,0 +1,109 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func TestProjectReportFilters(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var tk map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &tk)
tid, _ := tk["id"].(string)
day := time.Date(2026, 10, 18, 14, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: day,
End: day.Add(45 * time.Minute),
Duration: 45 * time.Minute,
Source: ticket.SourceManual,
Note: "filtered",
})
if err != nil {
t.Fatal(err)
}
other := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: other,
End: other.Add(time.Hour),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
url := "/api/v1/projects/" + pid + "/report?groupBy=ticket&from=2026-10-18&to=2026-10-18&ticketId=" + tid
req = httptest.NewRequest(http.MethodGet, url, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("report %d %s", rec.Code, rec.Body.String())
}
var out struct {
EntryCount int `json:"entryCount"`
TotalDurationNs int64 `json:"totalDurationNs"`
Entries []ticket.TimeEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.EntryCount != 1 || len(out.Entries) != 1 {
t.Fatalf("want 1 entry, got count=%d entries=%d", out.EntryCount, len(out.Entries))
}
if out.TotalDurationNs != int64(45*time.Minute) {
t.Fatalf("total=%d", out.TotalDurationNs)
}
if out.Entries[0].Note != "filtered" {
t.Fatalf("note=%q", out.Entries[0].Note)
}
}
+22 -13
View File
@@ -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))
+1
View File
@@ -125,6 +125,7 @@ func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.removeTicketAttachmentDir(tid)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+131
View File
@@ -0,0 +1,131 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
// Attachment is metadata for a file attached to a ticket.
type Attachment struct {
ID string `json:"id"`
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
Filename string `json:"filename"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
// AttachmentStore persists attachment metadata.
type AttachmentStore struct {
db *DB
}
// NewAttachmentStore returns an attachment metadata store.
func NewAttachmentStore(db *DB) *AttachmentStore {
return &AttachmentStore{db: db}
}
const attachmentCols = `id, ticket_id, project_id, filename, content_type, size_bytes, created_by, created_at`
func scanAttachment(scanner interface{ Scan(dest ...any) error }) (Attachment, error) {
var a Attachment
var created string
err := scanner.Scan(
&a.ID, &a.TicketID, &a.ProjectID, &a.Filename, &a.ContentType,
&a.SizeBytes, &a.CreatedBy, &created,
)
if err != nil {
return Attachment{}, err
}
a.CreatedAt = parseTime(created)
return a, nil
}
// ListByTicket returns attachments for a ticket, newest first.
func (s *AttachmentStore) ListByTicket(ticketID string) ([]Attachment, error) {
rows, err := s.db.SQL.Query(
`SELECT `+attachmentCols+` FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at DESC`,
ticketID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
if out == nil {
out = []Attachment{}
}
return out, rows.Err()
}
// Get returns one attachment by id.
func (s *AttachmentStore) Get(id string) (Attachment, error) {
row := s.db.SQL.QueryRow(`SELECT `+attachmentCols+` FROM ticket_attachments WHERE id = ?`, id)
a, err := scanAttachment(row)
if err == sql.ErrNoRows {
return Attachment{}, fmt.Errorf("attachment not found")
}
return a, err
}
// Create inserts attachment metadata.
func (s *AttachmentStore) Create(a Attachment) (Attachment, error) {
if a.ID == "" {
a.ID = uuid.NewString()
}
if a.Filename == "" {
return Attachment{}, fmt.Errorf("filename required")
}
if a.ContentType == "" {
a.ContentType = "application/octet-stream"
}
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now().UTC()
}
_, err := s.db.SQL.Exec(
`INSERT INTO ticket_attachments (`+attachmentCols+`) VALUES (?,?,?,?,?,?,?,?)`,
a.ID, a.TicketID, a.ProjectID, a.Filename, a.ContentType, a.SizeBytes, a.CreatedBy, formatTime(a.CreatedAt),
)
if err != nil {
return Attachment{}, err
}
return a, nil
}
// UpdateSize updates size after a replace/overwrite.
func (s *AttachmentStore) UpdateSize(id string, sizeBytes int64) error {
res, err := s.db.SQL.Exec(`UPDATE ticket_attachments SET size_bytes = ? WHERE id = ?`, sizeBytes, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("attachment not found")
}
return nil
}
// Delete removes attachment metadata.
func (s *AttachmentStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM ticket_attachments WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("attachment not found")
}
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package sqlite
import (
"database/sql"
"fmt"
)
// backfillTicketKeys assigns project codes and sequential ticket keys for rows
// that predate migration 005. Safe to run repeatedly (no-ops when already set).
func (db *DB) backfillTicketKeys() error {
if err := db.backfillProjectCodes(); err != nil {
return err
}
return db.backfillTicketNumbers()
}
func (db *DB) backfillProjectCodes() error {
rows, err := db.SQL.Query(`SELECT id, name, code FROM projects`)
if err != nil {
return fmt.Errorf("backfill codes: list: %w", err)
}
defer rows.Close()
type row struct {
id, name, code string
}
var all []row
used := map[string]bool{}
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.name, &r.code); err != nil {
return err
}
all = append(all, r)
if r.code != "" {
used[r.code] = true
}
}
if err := rows.Err(); err != nil {
return err
}
for _, r := range all {
if r.code != "" {
continue
}
code := nextUniqueCode(r.name, used)
used[code] = true
if _, err := db.SQL.Exec(`UPDATE projects SET code = ? WHERE id = ?`, code, r.id); err != nil {
return fmt.Errorf("backfill code %s: %w", r.id, err)
}
}
return nil
}
func (db *DB) backfillTicketNumbers() error {
type proj struct {
id, code string
seq int
}
var projects []proj
{
projRows, err := db.SQL.Query(`SELECT id, code, ticket_seq FROM projects`)
if err != nil {
return fmt.Errorf("backfill tickets: projects: %w", err)
}
for projRows.Next() {
var p proj
if err := projRows.Scan(&p.id, &p.code, &p.seq); err != nil {
projRows.Close()
return err
}
projects = append(projects, p)
}
err = projRows.Err()
projRows.Close()
if err != nil {
return err
}
}
for _, p := range projects {
type trow struct {
id, key string
number sql.NullInt64
}
var tickets []trow
{
tRows, err := db.SQL.Query(`
SELECT id, number, key FROM tickets
WHERE project_id = ?
ORDER BY created_at ASC, id ASC`, p.id)
if err != nil {
return err
}
for tRows.Next() {
var tr trow
if err := tRows.Scan(&tr.id, &tr.number, &tr.key); err != nil {
tRows.Close()
return err
}
tickets = append(tickets, tr)
}
err = tRows.Err()
tRows.Close()
if err != nil {
return err
}
}
seq := p.seq
for _, tr := range tickets {
if tr.number.Valid && tr.key != "" {
if int(tr.number.Int64) > seq {
seq = int(tr.number.Int64)
}
continue
}
seq++
newKey := fmt.Sprintf("%s-%d", p.code, seq)
if _, err := db.SQL.Exec(`UPDATE tickets SET number = ?, key = ? WHERE id = ?`, seq, newKey, tr.id); err != nil {
return fmt.Errorf("backfill ticket %s: %w", tr.id, err)
}
}
if seq != p.seq {
if _, err := db.SQL.Exec(`UPDATE projects SET ticket_seq = ? WHERE id = ?`, seq, p.id); err != nil {
return err
}
}
}
return nil
}
+4
View File
@@ -44,6 +44,10 @@ func Open(path string, secret []byte) (*DB, error) {
_ = sqlDB.Close()
return nil, err
}
if err := db.backfillTicketKeys(); err != nil {
_ = sqlDB.Close()
return nil, err
}
return db, nil
}
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS ticket_attachments (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON ticket_attachments(ticket_id);
@@ -0,0 +1,9 @@
ALTER TABLE projects ADD COLUMN code TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN ticket_seq INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tickets ADD COLUMN number INTEGER;
ALTER TABLE tickets ADD COLUMN key TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code) WHERE code != '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_key ON tickets(key) WHERE key != '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_project_number ON tickets(project_id, number) WHERE number IS NOT NULL;
+49
View File
@@ -0,0 +1,49 @@
package sqlite
import (
"fmt"
"strings"
"unicode"
)
// DeriveProjectCode builds a 3-letter uppercase code from a project name.
// Non-letters are skipped; short results are padded with X.
func DeriveProjectCode(name string) string {
var b strings.Builder
for _, r := range strings.ToUpper(name) {
if unicode.IsLetter(r) && r < unicode.MaxASCII {
b.WriteRune(r)
if b.Len() == 3 {
break
}
}
}
code := b.String()
for len(code) < 3 {
code += "X"
}
return code
}
// nextUniqueCode returns base or a nearby 3-char variant until unused.
// used maps existing codes (uppercase) → true.
func nextUniqueCode(base string, used map[string]bool) string {
base = DeriveProjectCode(base)
if !used[base] {
return base
}
prefix := base[:2]
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" {
cand := prefix + string(c)
if !used[cand] {
return cand
}
}
for i := 0; i < 1000; i++ {
cand := fmt.Sprintf("%c%02d", base[0], i)
if !used[cand] {
return cand
}
}
return fmt.Sprintf("X%02d", len(used)%100)
}
+32
View File
@@ -0,0 +1,32 @@
package sqlite
import "testing"
func TestDeriveProjectCode(t *testing.T) {
cases := []struct {
name, want string
}{
{"Alpha", "ALP"},
{"task-master", "TAS"},
{"AB", "ABX"},
{"12", "XXX"},
{"Büro Projekt", "BRO"}, // non-ASCII letters skipped
{"my project", "MYP"},
}
for _, tc := range cases {
if got := DeriveProjectCode(tc.name); got != tc.want {
t.Errorf("DeriveProjectCode(%q)=%q want %q", tc.name, got, tc.want)
}
}
}
func TestNextUniqueCode(t *testing.T) {
used := map[string]bool{"ALP": true, "ALA": true}
got := nextUniqueCode("Alpha", used)
if got == "ALP" || got == "ALA" {
t.Fatalf("expected unique code, got %q", got)
}
if len(got) != 3 {
t.Fatalf("len=%d code=%q", len(got), got)
}
}
+44 -10
View File
@@ -25,7 +25,7 @@ func NewProjectStore(db *DB) *ProjectStore {
func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
var p project.Project
var groupID sql.NullString
if err := scanner.Scan(&p.ID, &p.Name, &groupID); err != nil {
if err := scanner.Scan(&p.ID, &p.Name, &p.Code, &groupID); err != nil {
return project.Project{}, err
}
if groupID.Valid {
@@ -34,9 +34,11 @@ func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project,
return p, nil
}
const projectCols = `id, name, code, group_id`
// List returns all projects sorted by name.
func (s *ProjectStore) List() ([]project.Project, error) {
rows, err := s.db.SQL.Query(`SELECT id, name, group_id FROM projects ORDER BY name COLLATE NOCASE`)
rows, err := s.db.SQL.Query(`SELECT ` + projectCols + ` FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
@@ -55,19 +57,46 @@ func (s *ProjectStore) List() ([]project.Project, error) {
return out, rows.Err()
}
func (s *ProjectStore) usedCodes() (map[string]bool, error) {
rows, err := s.db.SQL.Query(`SELECT code FROM projects WHERE code != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
used := map[string]bool{}
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
used[c] = true
}
return used, rows.Err()
}
// Ensure creates the project if missing.
func (s *ProjectStore) Ensure(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("project id is required")
}
used, err := s.usedCodes()
if err != nil {
return err
}
code := nextUniqueCode(id, used)
now := formatTime(time.Now().UTC())
_, err := s.db.SQL.Exec(`
INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`, id, id, now, now)
_, err = s.db.SQL.Exec(`
INSERT INTO projects(id, name, code, created_at, updated_at) VALUES(?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`, id, id, code, now, now)
if err != nil {
return fmt.Errorf("ensure project %s: %w", id, err)
}
var existing string
_ = s.db.SQL.QueryRow(`SELECT code FROM projects WHERE id = ?`, id).Scan(&existing)
if existing == "" {
return s.db.backfillProjectCodes()
}
return nil
}
@@ -81,13 +110,18 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
if id == "" {
id = uuid.NewString()
}
used, err := s.usedCodes()
if err != nil {
return project.Project{}, err
}
code := nextUniqueCode(name, used)
now := formatTime(time.Now().UTC())
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`,
id, name, now, now)
_, err = s.db.SQL.Exec(`INSERT INTO projects(id, name, code, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, ?, NULL)`,
id, name, code, now, now)
if err != nil {
return project.Project{}, fmt.Errorf("create project: %w", err)
}
return project.Project{ID: id, Name: name}, nil
return project.Project{ID: id, Name: name, Code: code}, nil
}
// SetGroup assigns a project to a group. Empty groupID clears the assignment.
@@ -151,7 +185,7 @@ func (s *ProjectStore) Delete(id string) error {
// Get returns one project.
func (s *ProjectStore) Get(id string) (project.Project, error) {
p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id))
p, err := scanProject(s.db.SQL.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id))
if err == sql.ErrNoRows {
return project.Project{}, fmt.Errorf("project %s: not found", id)
}
@@ -291,7 +325,7 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
return s.List()
}
rows, err := s.db.SQL.Query(`
SELECT p.id, p.name, p.group_id FROM projects p
SELECT p.id, p.name, p.code, p.group_id FROM projects p
INNER JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ?
ORDER BY p.name COLLATE NOCASE`, userID)
+124
View File
@@ -0,0 +1,124 @@
package sqlite
import (
"path/filepath"
"testing"
"task/internal/ticket"
)
func TestTicketKeysSequential(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "keys.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
p, err := ps.Create("", "Task Master")
if err != nil {
t.Fatal(err)
}
if p.Code != "TAS" {
t.Fatalf("code=%q want TAS", p.Code)
}
ts := NewTicketStore(db)
a, err := ts.Create(ticket.Ticket{Title: "One", ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
b, err := ts.Create(ticket.Ticket{Title: "Two", ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
if a.Key != "TAS-1" || a.Number != 1 {
t.Fatalf("first=%+v", a)
}
if b.Key != "TAS-2" || b.Number != 2 {
t.Fatalf("second=%+v", b)
}
got, err := ts.Get(a.ID)
if err != nil || got.Key != "TAS-1" {
t.Fatalf("get=%+v err=%v", got, err)
}
// Update must preserve key.
got.Title = "One*"
if err := ts.Update(got); err != nil {
t.Fatal(err)
}
got, _ = ts.Get(a.ID)
if got.Key != "TAS-1" || got.Title != "One*" {
t.Fatalf("after update=%+v", got)
}
}
func TestTicketKeyBackfill(t *testing.T) {
path := filepath.Join(t.TempDir(), "backfill.db")
db, err := Open(path, []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
// Insert legacy-shaped rows as if pre-migration (columns exist after Open).
_, err = db.SQL.Exec(`INSERT INTO projects(id, name, code, ticket_seq, created_at, updated_at)
VALUES('p1', 'Inbox', '', 0, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')`)
if err != nil {
t.Fatal(err)
}
_, err = db.SQL.Exec(`INSERT INTO tickets(id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key)
VALUES('t1', 'Old', '', 'todo', 'p1', '2020-01-02T00:00:00Z', '2020-01-02T00:00:00Z', '', '', '', '', NULL, '')`)
if err != nil {
t.Fatal(err)
}
if err := db.backfillTicketKeys(); err != nil {
t.Fatal(err)
}
var code string
var seq int
if err := db.SQL.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id='p1'`).Scan(&code, &seq); err != nil {
t.Fatal(err)
}
if code != "INB" || seq != 1 {
t.Fatalf("project code=%q seq=%d", code, seq)
}
var key string
var num int
if err := db.SQL.QueryRow(`SELECT key, number FROM tickets WHERE id='t1'`).Scan(&key, &num); err != nil {
t.Fatal(err)
}
if key != "INB-1" || num != 1 {
t.Fatalf("ticket key=%q num=%d", key, num)
}
_ = db.Close()
}
func TestMoveReassignsKey(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "move.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
a, err := ps.Create("", "Alpha")
if err != nil {
t.Fatal(err)
}
b, err := ps.Create("", "Beta")
if err != nil {
t.Fatal(err)
}
ts := NewTicketStore(db)
tk, err := ts.Create(ticket.Ticket{Title: "Move me", ProjectID: a.ID})
if err != nil {
t.Fatal(err)
}
moved, err := ts.Move(tk.ID, b.ID)
if err != nil {
t.Fatal(err)
}
if moved.ProjectID != b.ID || moved.Key != b.Code+"-1" {
t.Fatalf("moved=%+v betaCode=%s", moved, b.Code)
}
}
+100 -14
View File
@@ -24,9 +24,11 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er
var t ticket.Ticket
var created, updated, synced string
var status string
var number sql.NullInt64
err := scanner.Scan(
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
&number, &t.Key,
)
if err != nil {
return ticket.Ticket{}, err
@@ -35,10 +37,13 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er
t.CreatedAt = parseTime(created)
t.UpdatedAt = parseTime(updated)
t.SyncedAt = parseTime(synced)
if number.Valid {
t.Number = int(number.Int64)
}
return t, nil
}
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key`
// List returns tickets for a project.
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
@@ -74,7 +79,7 @@ func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
return t, nil
}
// Create inserts a ticket.
// Create inserts a ticket and assigns the next project key atomically.
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
if t.ProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
@@ -95,24 +100,63 @@ func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
_, err := s.db.SQL.Exec(`
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
tx, err := s.db.SQL.Begin()
if err != nil {
return ticket.Ticket{}, err
}
defer func() { _ = tx.Rollback() }()
if t.Number == 0 || t.Key == "" {
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, t.ProjectID)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: bump seq: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s not found", t.ProjectID)
}
var code string
var seq int
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, t.ProjectID).Scan(&code, &seq); err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: read seq: %w", err)
}
if code == "" {
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s has no code", t.ProjectID)
}
t.Number = seq
t.Key = fmt.Sprintf("%s-%d", code, seq)
}
var number any
if t.Number > 0 {
number = t.Number
}
_, err = tx.Exec(`
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Title, t.Description, string(t.Status), t.ProjectID,
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
number, t.Key,
)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
}
if err := tx.Commit(); err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Update replaces a ticket and bumps UpdatedAt.
// Update replaces a ticket and bumps UpdatedAt. Number and Key are preserved from DB.
func (s *TicketStore) Update(t ticket.Ticket) error {
if t.ID == "" || t.ProjectID == "" {
return fmt.Errorf("id and projectId are required")
}
var created string
err := s.db.SQL.QueryRow(`SELECT created_at FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).Scan(&created)
var number sql.NullInt64
var key string
err := s.db.SQL.QueryRow(`SELECT created_at, number, key FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).
Scan(&created, &number, &key)
if err == sql.ErrNoRows {
return fmt.Errorf("ticket %s: not found", t.ID)
}
@@ -121,12 +165,16 @@ func (s *TicketStore) Update(t ticket.Ticket) error {
}
t.CreatedAt = parseTime(created)
t.UpdatedAt = time.Now().UTC()
if number.Valid {
t.Number = int(number.Int64)
}
t.Key = key
res, err := s.db.SQL.Exec(`
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
source=?, external_id=?, external_url=?, synced_at=?
source=?, external_id=?, external_url=?, synced_at=?, number=?, key=?
WHERE id=?`,
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), t.ID,
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), nullInt(t.Number), t.Key, t.ID,
)
if err != nil {
return err
@@ -138,6 +186,13 @@ func (s *TicketStore) Update(t ticket.Ticket) error {
return nil
}
func nullInt(n int) any {
if n == 0 {
return nil
}
return n
}
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
func (s *TicketStore) MarkSynced(id string) error {
if id == "" {
@@ -154,7 +209,7 @@ func (s *TicketStore) MarkSynced(id string) error {
return nil
}
// Move relocates a ticket to another project.
// Move relocates a ticket to another project and assigns a new key there.
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
if id == "" {
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
@@ -162,17 +217,48 @@ func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
if toProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
t, err := s.Get(id)
tx, err := s.db.SQL.Begin()
if err != nil {
return ticket.Ticket{}, err
}
t.ProjectID = toProjectID
t.UpdatedAt = time.Now().UTC()
_, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`,
toProjectID, formatTime(t.UpdatedAt), id)
defer func() { _ = tx.Rollback() }()
row := tx.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
t, err := scanTicket(row)
if err == sql.ErrNoRows {
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
if err != nil {
return ticket.Ticket{}, err
}
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, toProjectID)
if err != nil {
return ticket.Ticket{}, err
}
n, _ := res.RowsAffected()
if n == 0 {
return ticket.Ticket{}, fmt.Errorf("project %s: not found", toProjectID)
}
var code string
var seq int
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, toProjectID).Scan(&code, &seq); err != nil {
return ticket.Ticket{}, err
}
t.ProjectID = toProjectID
t.Number = seq
t.Key = fmt.Sprintf("%s-%d", code, seq)
t.UpdatedAt = time.Now().UTC()
_, err = tx.Exec(`UPDATE tickets SET project_id=?, updated_at=?, number=?, key=? WHERE id=?`,
toProjectID, formatTime(t.UpdatedAt), t.Number, t.Key, id)
if err != nil {
return ticket.Ticket{}, err
}
if err := tx.Commit(); err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
+15
View File
@@ -0,0 +1,15 @@
package ticket
import "testing"
func TestDisplayID(t *testing.T) {
if got := (Ticket{Key: "TAS-1", ExternalID: "uuid"}).DisplayID(); got != "TAS-1" {
t.Fatalf("got %q", got)
}
if got := (Ticket{ExternalID: "YT-9"}).DisplayID(); got != "YT-9" {
t.Fatalf("got %q", got)
}
if got := (Ticket{}).DisplayID(); got != "" {
t.Fatalf("got %q", got)
}
}
+60
View File
@@ -3,6 +3,7 @@ package ticket
import (
"fmt"
"sort"
"strings"
"time"
)
@@ -155,3 +156,62 @@ func TotalDuration(entries []TimeEntry) time.Duration {
}
return sum
}
// TimeFilter selects which time entries belong in a report.
// From/To are inclusive bounds on the entry's work instant (Start, else End).
// Zero times mean no bound on that side.
type TimeFilter struct {
From time.Time
To time.Time
TicketID string
}
// FilterEntries returns entries matching f. The input slice is not modified.
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
out := make([]TimeEntry, len(entries))
copy(out, entries)
return out
}
out := make([]TimeEntry, 0, len(entries))
for _, e := range entries {
if f.TicketID != "" && e.TicketID != f.TicketID {
continue
}
when := e.Start
if when.IsZero() {
when = e.End
}
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
continue
}
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
continue
}
out = append(out, e)
}
return out
}
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
func ParseReportDay(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, nil
}
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return time.Time{}, fmt.Errorf("invalid date %q", s)
}
return t, nil
}
// EndOfReportDay returns the last instant of the calendar day of t in local time.
func EndOfReportDay(t time.Time) time.Time {
if t.IsZero() {
return t
}
local := t.In(time.Local)
y, m, d := local.Date()
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
}
+45
View File
@@ -70,3 +70,48 @@ func TestAggregateByDay(t *testing.T) {
t.Fatalf("older day dur=%v", rows[1].Duration)
}
}
func TestFilterEntries(t *testing.T) {
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
d2 := time.Date(2026, 10, 20, 11, 0, 0, 0, time.Local)
entries := []ticket.TimeEntry{
{ID: "1", TicketID: "a", Start: d1, Duration: time.Hour},
{ID: "2", TicketID: "b", Start: d2, Duration: 30 * time.Minute},
{ID: "3", TicketID: "a", Start: d2, Duration: 15 * time.Minute},
}
byTicket := ticket.FilterEntries(entries, ticket.TimeFilter{TicketID: "a"})
if len(byTicket) != 2 {
t.Fatalf("ticket filter=%d", len(byTicket))
}
from := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
to := ticket.EndOfReportDay(time.Date(2026, 10, 20, 0, 0, 0, 0, time.Local))
byRange := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to})
if len(byRange) != 2 {
t.Fatalf("range filter=%d", len(byRange))
}
both := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to, TicketID: "a"})
if len(both) != 1 || both[0].ID != "3" {
t.Fatalf("combined=%+v", both)
}
}
func TestParseReportDay(t *testing.T) {
got, err := ticket.ParseReportDay("2026-10-18")
if err != nil {
t.Fatal(err)
}
y, m, d := got.Date()
if y != 2026 || m != 10 || d != 18 || got.Hour() != 0 {
t.Fatalf("got %v", got)
}
empty, err := ticket.ParseReportDay("")
if err != nil || !empty.IsZero() {
t.Fatalf("empty: %v %v", empty, err)
}
if _, err := ticket.ParseReportDay("nope"); err == nil {
t.Fatal("expected error")
}
}
+13
View File
@@ -68,9 +68,22 @@ type Ticket struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Readable key assigned by the server (e.g. "ABC-42"). Optional on local-only tickets.
Number int `json:"number,omitempty"`
Key string `json:"key,omitempty"`
// External sync (optional). Local UUID stays in ID.
Source string `json:"source,omitempty"`
ExternalID string `json:"externalId,omitempty"`
ExternalURL string `json:"externalUrl,omitempty"`
SyncedAt time.Time `json:"syncedAt,omitempty"`
}
// DisplayID returns the human-readable ticket label for UI.
// Prefers the server key; falls back to ExternalID (e.g. YouTrack idReadable).
func (t Ticket) DisplayID() string {
if t.Key != "" {
return t.Key
}
return t.ExternalID
}
+7 -1
View File
@@ -87,6 +87,8 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas
next.Status = rt.Status
next.Source = ticket.SourceTaskMaster
next.ExternalURL = extURL
next.Number = rt.Number
next.Key = rt.Key
if sameFields(prev, next) {
_ = s.Store.MarkSynced(prev.ID)
res.Skipped++
@@ -104,6 +106,7 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas
Title: rt.Title, Description: rt.Description, Status: rt.Status,
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
ExternalID: rt.ID, ExternalURL: extURL,
Number: rt.Number, Key: rt.Key,
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
})
if err != nil {
@@ -142,6 +145,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas
return res, err
}
t.ExternalID = created.ID
t.Number = created.Number
t.Key = created.Key
t.Source = ticket.SourceTaskMaster
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
if err := s.Store.Update(t); err != nil {
@@ -166,7 +171,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas
}
func sameFields(a, b ticket.Ticket) bool {
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status &&
a.Key == b.Key && a.Number == b.Number
}
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
+2 -2
View File
@@ -177,8 +177,8 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
badge := l.statusBadge(t.Status)
ext := ""
if t.ExternalID != "" {
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
if id := t.DisplayID(); id != "" {
ext = lipgloss.NewStyle().Faint(true).Render(id) + " "
}
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
+146
View File
@@ -7,6 +7,12 @@
"": {
"name": "web",
"version": "0.0.0",
"dependencies": {
"marked": "^18.1.2",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
@@ -63,6 +69,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@oxc-project/types": {
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
@@ -403,6 +415,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -423,6 +444,19 @@
"node": ">= 0.4"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -433,6 +467,27 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -503,6 +558,15 @@
}
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -818,6 +882,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/marked": {
"version": "18.1.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.2.tgz",
"integrity": "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -944,6 +1020,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/svelte": {
"version": "5.57.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz",
@@ -998,6 +1086,25 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
@@ -1096,6 +1203,45 @@
}
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
+6
View File
@@ -12,5 +12,11 @@
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
"vite": "^8.3.5"
},
"dependencies": {
"marked": "^18.1.2",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"xlsx": "^0.18.5"
}
}
+287 -14
View File
@@ -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
View File
@@ -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;
}
+170
View File
@@ -0,0 +1,170 @@
<script>
import { api } from './api.js'
import { spreadsheetWriteOptions } from './spreadsheet.js'
let {
projectId,
ticketId,
attachment,
onclose = () => {},
onsaved = () => {},
} = $props()
let loading = $state(true)
let saving = $state(false)
let error = $state('')
let workbook = $state(null)
let sheetNames = $state([])
let activeSheet = $state('')
/** @type {string[][]} */
let grid = $state([])
let XLSX = null
async function ensureXlsx() {
if (!XLSX) XLSX = await import('xlsx')
return XLSX
}
function sheetToGrid(wb, name) {
const ws = wb.Sheets[name]
if (!ws) return [['']]
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', raw: false })
if (!rows.length) return [['']]
const width = Math.max(...rows.map((r) => r.length), 1)
return rows.map((r) => {
const row = Array.from({ length: width }, (_, i) => String(r[i] ?? ''))
return row
})
}
function gridToSheet(rows) {
return XLSX.utils.aoa_to_sheet(rows)
}
async function load() {
loading = true
error = ''
try {
await ensureXlsx()
const buf = await api.downloadAttachment(projectId, ticketId, attachment.id)
const wb = XLSX.read(buf, { type: 'array' })
workbook = wb
sheetNames = wb.SheetNames.slice()
activeSheet = sheetNames[0] || ''
grid = activeSheet ? sheetToGrid(wb, activeSheet) : [['']]
} catch (e) {
error = e.message || String(e)
} finally {
loading = false
}
}
function selectSheet(name) {
if (!workbook || name === activeSheet) return
workbook.Sheets[activeSheet] = gridToSheet(grid)
activeSheet = name
grid = sheetToGrid(workbook, name)
}
function setCell(ri, ci, value) {
grid = grid.map((row, i) =>
i === ri ? row.map((c, j) => (j === ci ? value : c)) : row,
)
}
function addRow() {
const width = grid[0]?.length || 1
grid = [...grid, Array.from({ length: width }, () => '')]
}
function addCol() {
grid = grid.map((row) => [...row, ''])
}
async function save() {
if (!workbook) return
saving = true
error = ''
try {
await ensureXlsx()
workbook.Sheets[activeSheet] = gridToSheet(grid)
const { bookType, contentType } = spreadsheetWriteOptions(attachment.filename)
const out = XLSX.write(workbook, { bookType, type: 'array' })
await api.putAttachment(projectId, ticketId, attachment.id, out, { contentType })
onsaved()
msgOk()
} catch (e) {
error = e.message || String(e)
} finally {
saving = false
}
}
let justSaved = $state(false)
function msgOk() {
justSaved = true
setTimeout(() => {
justSaved = false
}, 1500)
}
$effect(() => {
if (attachment?.id) load()
})
</script>
<div class="excel-editor">
<div class="excel-editor-head row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.5rem;">
<div class="row" style="width: auto; gap: 0.5rem; flex-wrap: wrap;">
<strong>{attachment.filename}</strong>
{#if justSaved}<span class="muted">Saved</span>{/if}
{#if error}<span class="error">{error}</span>{/if}
</div>
<div class="row" style="width: auto;">
<button class="primary" disabled={loading || saving || !!error && !workbook} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
<button onclick={onclose}>Close</button>
</div>
</div>
{#if loading}
<p class="muted">Loading spreadsheet…</p>
{:else if workbook}
<div class="excel-sheets" role="tablist">
{#each sheetNames as name}
<button
type="button"
role="tab"
class:active={name === activeSheet}
aria-selected={name === activeSheet}
onclick={() => selectSheet(name)}
>{name}</button>
{/each}
</div>
<div class="excel-toolbar row" style="width: auto;">
<button type="button" onclick={addRow}>+ Row</button>
<button type="button" onclick={addCol}>+ Column</button>
</div>
<div class="excel-scroll">
<table class="excel-grid">
<tbody>
{#each grid as row, ri}
<tr>
<th class="excel-rownum">{ri + 1}</th>
{#each row as cell, ci}
<td>
<input
value={cell}
oninput={(e) => setCell(ri, ci, e.currentTarget.value)}
aria-label={`R${ri + 1}C${ci + 1}`}
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+136
View File
@@ -0,0 +1,136 @@
<script>
import { markdownToHtml, htmlToMarkdown } from './markdown.js'
let { value = $bindable(''), label = 'Description' } = $props()
let mode = $state('visual')
let visualEl = $state(null)
let lastApplied = ''
let appliedTo = null
function enableCheckboxes(root) {
if (!root) return
root.querySelectorAll('input[type="checkbox"]').forEach((box) => {
box.disabled = false
box.onchange = readVisual
})
}
function applyVisual(md) {
if (!visualEl) return
visualEl.innerHTML = markdownToHtml(md || '')
enableCheckboxes(visualEl)
lastApplied = md || ''
appliedTo = visualEl
}
function readVisual() {
if (!visualEl) return
value = htmlToMarkdown(visualEl.innerHTML)
lastApplied = value
}
function setMode(next) {
if (next === mode) return
if (mode === 'visual') readVisual()
mode = next
}
$effect(() => {
if (mode !== 'visual') return
const el = visualEl
if (!el) return
const md = value ?? ''
// Remount (mode switch) or external value change (e.g. restore revision)
if (el !== appliedTo || md !== lastApplied) applyVisual(md)
})
function exec(cmd, arg) {
visualEl?.focus()
document.execCommand(cmd, false, arg)
readVisual()
}
function insertChecklist() {
visualEl?.focus()
document.execCommand(
'insertHTML',
false,
'<ul><li><input type="checkbox"> Task</li></ul>',
)
enableCheckboxes(visualEl)
readVisual()
}
function promptLink() {
const url = window.prompt('URL')
if (!url) return
exec('createLink', url)
}
function formatCodeBlock() {
visualEl?.focus()
const sel = window.getSelection()
const text = sel?.toString() || 'code'
// Prefer <pre><code> so turndown emits a fenced markdown block.
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
document.execCommand('insertHTML', false, `<pre><code>${escaped}</code></pre>`)
readVisual()
}
</script>
<div class="md-editor">
<div class="md-editor-head">
<span class="md-editor-label">{label}</span>
<div class="md-editor-modes" role="tablist">
<button
type="button"
role="tab"
class:active={mode === 'visual'}
aria-selected={mode === 'visual'}
onclick={() => setMode('visual')}
>Visual</button>
<button
type="button"
role="tab"
class:active={mode === 'markdown'}
aria-selected={mode === 'markdown'}
onclick={() => setMode('markdown')}
>Markdown</button>
</div>
</div>
{#if mode === 'visual'}
<div class="md-toolbar" role="toolbar" aria-label="Formatting">
<button type="button" title="Bold" onclick={() => exec('bold')}><b>B</b></button>
<button type="button" title="Italic" onclick={() => exec('italic')}><i>I</i></button>
<button type="button" title="Heading" onclick={() => exec('formatBlock', 'h2')}>H</button>
<button type="button" title="Bullet list" onclick={() => exec('insertUnorderedList')}>• List</button>
<button type="button" title="Numbered list" onclick={() => exec('insertOrderedList')}>1.</button>
<button type="button" title="Checklist" onclick={insertChecklist}>☑</button>
<button type="button" title="Link" onclick={promptLink}>Link</button>
<button type="button" title="Code block" onclick={formatCodeBlock}>Code</button>
</div>
<div
class="md-visual"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-label={label}
bind:this={visualEl}
oninput={readVisual}
onblur={readVisual}
></div>
{:else}
<textarea
class="md-source"
bind:value
aria-label="{label} (Markdown)"
placeholder="Write Markdown…"
spellcheck="true"
></textarea>
{/if}
</div>
+58 -3
View File
@@ -1,9 +1,29 @@
async function request(path, opts = {}) {
const headers = { ...(opts.headers || {}) }
if (opts.body != null && !(opts.body instanceof FormData) && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(`/api/v1${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
headers,
})
if (opts.raw) {
if (!res.ok) {
const text = await res.text()
let data = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = { error: text }
}
const err = new Error((data && data.error) || res.statusText)
err.status = res.status
err.data = data
throw err
}
return res
}
const text = await res.text()
let data = null
try {
@@ -71,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',
}),
}
+42
View File
@@ -0,0 +1,42 @@
import { marked } from 'marked'
import TurndownService from 'turndown'
import { gfm } from 'turndown-plugin-gfm'
marked.setOptions({
gfm: true,
breaks: false,
})
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*',
})
turndown.use(gfm)
// Browsers' formatBlock('pre') often yields bare <pre> without <code>.
// Default turndown only fences <pre><code>; keep bare <pre> as fenced blocks.
turndown.addRule('barePre', {
filter(node) {
return (
node.nodeName === 'PRE' &&
!(node.firstChild && node.firstChild.nodeName === 'CODE')
)
},
replacement(_content, node) {
const text = node.textContent.replace(/\n$/, '')
return `\n\n\`\`\`\n${text}\n\`\`\`\n\n`
},
})
export function markdownToHtml(md) {
if (!md || !md.trim()) return '<p><br></p>'
return marked.parse(md)
}
export function htmlToMarkdown(html) {
if (!html) return ''
const md = turndown.turndown(html)
return md.replace(/\n{3,}/g, '\n\n').trim()
}
+45
View File
@@ -0,0 +1,45 @@
/** Extensions SheetJS can read/write for simple cell editing. */
const FORMATS = {
xlsx: {
bookType: 'xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
xlsm: {
bookType: 'xlsm',
contentType: 'application/vnd.ms-excel.sheet.macroEnabled.12',
},
xlsb: {
bookType: 'xlsb',
contentType: 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
},
xls: {
bookType: 'xls',
contentType: 'application/vnd.ms-excel',
},
ods: {
bookType: 'ods',
contentType: 'application/vnd.oasis.opendocument.spreadsheet',
},
fods: {
bookType: 'fods',
contentType: 'application/vnd.oasis.opendocument.spreadsheet',
},
csv: {
bookType: 'csv',
contentType: 'text/csv',
},
}
const EXT_RE = /\.([a-z0-9]+)$/i
export function isSpreadsheetName(name) {
const m = String(name || '').match(EXT_RE)
if (!m) return false
return Object.prototype.hasOwnProperty.call(FORMATS, m[1].toLowerCase())
}
export function spreadsheetWriteOptions(filename) {
const m = String(filename || '').match(EXT_RE)
const ext = m ? m[1].toLowerCase() : 'xlsx'
return FORMATS[ext] || FORMATS.xlsx
}