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>
This commit is contained in:
@@ -97,6 +97,7 @@ func runServer(listen, dbPath string, secure bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
srv := server.New(db)
|
srv := server.New(db)
|
||||||
|
srv.DataDir = cfg.DataDir
|
||||||
srv.Secure = secure
|
srv.Secure = secure
|
||||||
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
|
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
|
||||||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
|
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-13
@@ -13,24 +13,27 @@ import (
|
|||||||
|
|
||||||
// Server is the HTTP API + embedded UI.
|
// Server is the HTTP API + embedded UI.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
DB *sqlite.DB
|
DB *sqlite.DB
|
||||||
Projects *sqlite.ProjectStore
|
Projects *sqlite.ProjectStore
|
||||||
Tickets *sqlite.TicketStore
|
Tickets *sqlite.TicketStore
|
||||||
Times *sqlite.TimeStore
|
Times *sqlite.TimeStore
|
||||||
Sync *syncbackend.Registry
|
Attachments *sqlite.AttachmentStore
|
||||||
Secure bool // set Secure on cookies when behind TLS
|
Sync *syncbackend.Registry
|
||||||
Static fs.FS
|
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.
|
// New constructs a server from an open DB.
|
||||||
func New(db *sqlite.DB) *Server {
|
func New(db *sqlite.DB) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
DB: db,
|
DB: db,
|
||||||
Projects: sqlite.NewProjectStore(db),
|
Projects: sqlite.NewProjectStore(db),
|
||||||
Tickets: sqlite.NewTicketStore(db),
|
Tickets: sqlite.NewTicketStore(db),
|
||||||
Times: sqlite.NewTimeStore(db),
|
Times: sqlite.NewTimeStore(db),
|
||||||
Sync: syncbackend.NewRegistry(),
|
Attachments: sqlite.NewAttachmentStore(db),
|
||||||
Static: webembed.FS(),
|
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("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
|
||||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
|
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("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
|
||||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
|
api.HandleFunc("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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.removeTicketAttachmentDir(tid)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
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,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);
|
||||||
Generated
+105
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"marked": "^18.1.2",
|
"marked": "^18.1.2",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"turndown-plugin-gfm": "^1.0.2"
|
"turndown-plugin-gfm": "^1.0.2",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||||
@@ -414,6 +415,15 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||||
@@ -434,6 +444,19 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -444,6 +467,27 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -514,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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -967,6 +1020,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/svelte": {
|
||||||
"version": "5.57.3",
|
"version": "5.57.3",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz",
|
||||||
@@ -1138,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": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
||||||
|
|||||||
+2
-1
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"marked": "^18.1.2",
|
"marked": "^18.1.2",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"turndown-plugin-gfm": "^1.0.2"
|
"turndown-plugin-gfm": "^1.0.2",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { api } from './lib/api.js'
|
import { api } from './lib/api.js'
|
||||||
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
||||||
import MarkdownEditor from './lib/MarkdownEditor.svelte'
|
import MarkdownEditor from './lib/MarkdownEditor.svelte'
|
||||||
|
import ExcelEditor from './lib/ExcelEditor.svelte'
|
||||||
|
|
||||||
let user = $state(null)
|
let user = $state(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
let dragTicketId = $state('')
|
let dragTicketId = $state('')
|
||||||
let dragOverStatus = $state('')
|
let dragOverStatus = $state('')
|
||||||
let suppressTicketClick = false
|
let suppressTicketClick = false
|
||||||
|
let attachments = $state([])
|
||||||
|
let excelAtt = $state(null)
|
||||||
|
let attachBusy = $state(false)
|
||||||
|
|
||||||
const boardStatuses = [
|
const boardStatuses = [
|
||||||
['todo', 'OPEN'],
|
['todo', 'OPEN'],
|
||||||
@@ -377,15 +381,57 @@
|
|||||||
editTitle = t.title
|
editTitle = t.title
|
||||||
editDesc = t.description || ''
|
editDesc = t.description || ''
|
||||||
editStatus = t.status
|
editStatus = t.status
|
||||||
|
excelAtt = null
|
||||||
cancelEditTime()
|
cancelEditTime()
|
||||||
revisions = await api.listRevisions(projectId, t.id)
|
revisions = await api.listRevisions(projectId, t.id)
|
||||||
const all = await api.listTime(projectId)
|
const all = await api.listTime(projectId)
|
||||||
ticketTime = all.filter((e) => e.ticketId === t.id)
|
ticketTime = all.filter((e) => e.ticketId === t.id)
|
||||||
|
attachments = await api.listAttachments(projectId, t.id)
|
||||||
await refreshTimer()
|
await refreshTimer()
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'ticket'
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExcelName(name) {
|
||||||
|
return /\.xlsx$/i.test(name || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function openTicket(t) {
|
||||||
await showTicket(t)
|
await showTicket(t)
|
||||||
syncRoute()
|
syncRoute()
|
||||||
@@ -892,6 +938,47 @@
|
|||||||
<MarkdownEditor bind:value={editDesc} label="Description" />
|
<MarkdownEditor bind:value={editDesc} label="Description" />
|
||||||
<button class="primary" onclick={saveTicket}>Save</button>
|
<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 isExcelName(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>
|
<h3>Time</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" onclick={toggleTimer}>
|
<button class="primary" onclick={toggleTimer}>
|
||||||
|
|||||||
+126
@@ -592,3 +592,129 @@ label > :is(input, select, textarea) { color: var(--ink); }
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
margin: 1em 0;
|
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,171 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from './api.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 out = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
|
||||||
|
await api.putAttachment(projectId, ticketId, attachment.id, out, {
|
||||||
|
contentType:
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
})
|
||||||
|
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>
|
||||||
+50
-1
@@ -1,9 +1,29 @@
|
|||||||
async function request(path, opts = {}) {
|
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}`, {
|
const res = await fetch(`/api/v1${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
|
||||||
...opts,
|
...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()
|
const text = await res.text()
|
||||||
let data = null
|
let data = null
|
||||||
try {
|
try {
|
||||||
@@ -95,4 +115,33 @@ export const api = {
|
|||||||
createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }),
|
createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
deleteUser: (id) => request(`/users/${id}`, { method: 'DELETE' }),
|
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',
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user