Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at 11:49, twice from another at 10:45 — and there is no way to find out what any of them were. verdande logs the real cause of every 500, but on a Rune the log belongs to the container and the container is replaced on every restart. So an operator is told that something broke and given nothing to act on, which is the worst of both: alarming and useless. Every 500 now writes a row: what the handler was attempting in its own words, the error it hit, the method and path, who was asking, and the request id that ties it back to the log line for whoever still has it. The "what" is the part a status code cannot give — "list projects" and "move task" are the difference between a fault you can find and one you can only count. Written best-effort and never checked. This is the path that is already reporting a failure, and a diagnostic that can itself fail the request would turn one broken screen into two. Threading the request through `internal()` and `storeError()` touched 108 call sites across 19 files, which is the price of not having had it there from the start. It is mechanical and the compiler checked all of it. Kept for thirty days by the nightly sweep: long enough to answer "what happened last week", short enough that a fault loop cannot fill the volume the tasks live on. Read at Indstillinger → Fejl, administrators only — it carries paths, account names and the server's own error strings. The empty state says "Ingen fejl registreret. Det er den rigtige tilstand", because a blank panel should not read as a broken one. The test drops a table and asks for the endpoint that reads it: the same code path a disk error takes, and the row that comes back has to name the operation, the account and the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
477 lines
14 KiB
Go
477 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/ics"
|
|
"github.com/kristianwind/verdande/internal/quickadd"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
"github.com/kristianwind/verdande/internal/todoist"
|
|
)
|
|
|
|
// maxImportBytes caps an uploaded CSV at 10 MiB, which is far more than any real
|
|
// Todoist export — the largest personal accounts run to a few hundred kilobytes.
|
|
const maxImportBytes = 10 << 20
|
|
|
|
type importResult struct {
|
|
ProjectID string `json:"project_id"`
|
|
Projects int `json:"projects"`
|
|
Tasks int `json:"tasks"`
|
|
Sections int `json:"sections"`
|
|
Comments int `json:"comments"`
|
|
// Warnings are the things that could not be brought across exactly — an
|
|
// assignee who is not a member here, a date that was natural language in a
|
|
// language we do not parse. Reported rather than silently dropped, because a
|
|
// silent partial import is discovered weeks later.
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
|
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
|
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
|
"the file is too large")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeFieldErrors(w, map[string]string{"file": "required"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
rows, err := todoist.Parse(file)
|
|
if err != nil {
|
|
writeFieldErrors(w, map[string]string{"file": err.Error()})
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
if name == "" {
|
|
// Todoist names the file after the project, which is the best guess
|
|
// available when the form did not say.
|
|
name = strings.TrimSuffix(safeFilename(header.Filename), ".csv")
|
|
}
|
|
if name == "" {
|
|
name = "Importeret"
|
|
}
|
|
|
|
project := todoist.FromRows(rows, name)
|
|
result, err := s.importProject(r, user, project)
|
|
if err != nil {
|
|
s.internal(w, r, "import project", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, result)
|
|
}
|
|
|
|
// importProject writes a parsed project into the database.
|
|
//
|
|
// Tasks are created one at a time rather than in a single transaction: an import of
|
|
// two thousand tasks that fails on the last one would otherwise leave nothing, and
|
|
// a partial import that says what it managed is far more useful than an empty
|
|
// project and an error.
|
|
func (s *Server) importProject(r *http.Request, user *store.User, p todoist.Project) (*importResult, error) {
|
|
ctx := r.Context()
|
|
result := &importResult{Warnings: []string{}}
|
|
|
|
project := &store.Project{Name: p.Name, OwnerID: user.ID}
|
|
if err := s.db.CreateProject(ctx, project); err != nil {
|
|
return nil, err
|
|
}
|
|
result.ProjectID = project.ID
|
|
result.Projects = 1
|
|
|
|
loc := userLocation(user.Timezone)
|
|
|
|
var addTask func(t todoist.Task, sectionID, parentID string) error
|
|
addTask = func(t todoist.Task, sectionID, parentID string) error {
|
|
task := &store.Task{
|
|
ProjectID: project.ID, SectionID: sectionID, ParentID: parentID,
|
|
Content: t.Content, Description: t.Description, Priority: t.Priority,
|
|
CreatedBy: user.ID,
|
|
}
|
|
|
|
if t.Date != "" {
|
|
if date, remaining, ok := todoist.ParseDate(t.Date); ok {
|
|
task.DueDate = date
|
|
} else if remaining != "" {
|
|
// Todoist stores what somebody typed, so most dates are natural
|
|
// language. The quick-add parser already understands a great deal
|
|
// of it, in both languages — and a recurrence phrase like
|
|
// "every Monday" comes across as a real repeating task.
|
|
parsed := quickadd.Parse(remaining, time.Now().In(loc), user.Locale)
|
|
task.DueDate = parsed.DueDate
|
|
task.RecurrenceRule = parsed.Recurrence
|
|
if task.DueDate == "" && task.RecurrenceRule == "" {
|
|
result.Warnings = append(result.Warnings,
|
|
fmt.Sprintf("%q: datoen %q kunne ikke læses", t.Content, t.Date))
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
|
return err
|
|
}
|
|
result.Tasks++
|
|
|
|
if t.Assignee != "" {
|
|
result.Warnings = append(result.Warnings,
|
|
fmt.Sprintf("%q var tildelt %q, som ikke er medlem her", t.Content, t.Assignee))
|
|
}
|
|
|
|
for _, note := range t.Comments {
|
|
if _, err := s.db.CreateComment(ctx, task.ID, user.ID, note); err != nil {
|
|
return err
|
|
}
|
|
result.Comments++
|
|
}
|
|
for _, child := range t.Children {
|
|
if err := addTask(child, sectionID, task.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for _, t := range p.Tasks {
|
|
if err := addTask(t, "", ""); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
for _, section := range p.Sections {
|
|
s3 := &store.Section{ProjectID: project.ID, Name: section.Name}
|
|
if err := s.db.CreateSection(ctx, s3); err != nil {
|
|
return result, err
|
|
}
|
|
result.Sections++
|
|
for _, t := range section.Tasks {
|
|
if err := addTask(t, s3.ID, ""); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
}
|
|
|
|
s.activity(r, project.ID, "", "project.imported", map[string]any{
|
|
"name": project.Name, "tasks": result.Tasks,
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
// --- generic CSV -------------------------------------------------------------------
|
|
|
|
type genericImportRequest struct {
|
|
Name string `json:"name"`
|
|
// Mapping says which column holds what: {"content": "Opgave", "due": "Frist"}.
|
|
// Supplied by the mapping UI after it has shown the file's own headers.
|
|
Mapping map[string]string `json:"mapping"`
|
|
Rows []map[string]string `json:"rows"`
|
|
}
|
|
|
|
// handleImportCSV takes a file that is not a Todoist export at all, with the caller
|
|
// saying which column means what.
|
|
//
|
|
// The parsing happens in the browser and the mapped rows arrive as JSON. That keeps
|
|
// the column-mapping interface — which needs to show a preview and let somebody
|
|
// change their mind — from requiring the file to be uploaded twice.
|
|
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|
var req genericImportRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
contentColumn := req.Mapping["content"]
|
|
if contentColumn == "" {
|
|
writeFieldErrors(w, map[string]string{"mapping": "der skal peges på en kolonne med opgaveteksten"})
|
|
return
|
|
}
|
|
if len(req.Rows) == 0 {
|
|
writeFieldErrors(w, map[string]string{"rows": "der er ingen rækker"})
|
|
return
|
|
}
|
|
if len(req.Rows) > 5000 {
|
|
writeFieldErrors(w, map[string]string{"rows": "højst 5000 rækker ad gangen"})
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
name = "Importeret"
|
|
}
|
|
|
|
project := todoist.Project{Name: name}
|
|
for _, row := range req.Rows {
|
|
content := strings.TrimSpace(row[contentColumn])
|
|
if content == "" {
|
|
continue
|
|
}
|
|
task := todoist.Task{
|
|
Content: content,
|
|
Description: row[req.Mapping["description"]],
|
|
Date: row[req.Mapping["due"]],
|
|
Priority: 4,
|
|
}
|
|
if col := req.Mapping["priority"]; col != "" {
|
|
// Whatever the source called it, 1 through 4 is read as verdande's own
|
|
// numbering — an arbitrary foreign scale cannot be guessed at.
|
|
switch strings.TrimSpace(row[col]) {
|
|
case "1":
|
|
task.Priority = 1
|
|
case "2":
|
|
task.Priority = 2
|
|
case "3":
|
|
task.Priority = 3
|
|
}
|
|
}
|
|
project.Tasks = append(project.Tasks, task)
|
|
}
|
|
|
|
result, err := s.importProject(r, user, project)
|
|
if err != nil {
|
|
s.internal(w, r, "import csv", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, result)
|
|
}
|
|
|
|
// --- export --------------------------------------------------------------------------
|
|
|
|
// handleExportProject writes one project as a Todoist-compatible CSV.
|
|
func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
projectID := chi.URLParam(r, "projectID")
|
|
|
|
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get project", err)
|
|
return
|
|
}
|
|
exported, err := s.buildExport(r, user, project)
|
|
if err != nil {
|
|
s.internal(w, r, "export project", err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition",
|
|
`attachment; filename="`+strings.TrimSuffix(ics.Filename(project.Name), ".ics")+`.csv"`)
|
|
if err := todoist.Write(w, todoist.ToRows(exported)); err != nil {
|
|
s.log.Error("write export", "err", err)
|
|
}
|
|
}
|
|
|
|
// buildExport turns a stored project back into the neutral shape the CSV writer
|
|
// takes, rebuilding the task tree from parent ids.
|
|
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
|
ctx := r.Context()
|
|
|
|
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
|
ProjectIDs: []string{project.ID}, IncludeCompleted: true, Limit: 5000,
|
|
})
|
|
if err != nil {
|
|
return todoist.Project{}, err
|
|
}
|
|
sections, err := s.db.ListSections(ctx, project.ID)
|
|
if err != nil {
|
|
return todoist.Project{}, err
|
|
}
|
|
|
|
// Children indexed by parent, so the tree can be built in one pass.
|
|
children := map[string][]store.Task{}
|
|
for _, t := range tasks {
|
|
if t.ParentID != "" {
|
|
children[t.ParentID] = append(children[t.ParentID], t)
|
|
}
|
|
}
|
|
|
|
var build func(t store.Task) todoist.Task
|
|
build = func(t store.Task) todoist.Task {
|
|
out := todoist.Task{
|
|
Content: t.Content, Description: t.Description, Priority: t.Priority,
|
|
Date: t.DueDate, Author: user.Name,
|
|
}
|
|
if comments, err := s.db.ListComments(ctx, t.ID); err == nil {
|
|
for _, c := range comments {
|
|
out.Comments = append(out.Comments, c.Body)
|
|
}
|
|
}
|
|
for _, child := range children[t.ID] {
|
|
out.Children = append(out.Children, build(child))
|
|
}
|
|
return out
|
|
}
|
|
|
|
exported := todoist.Project{Name: project.Name}
|
|
bySection := map[string][]todoist.Task{}
|
|
for _, t := range tasks {
|
|
if t.ParentID != "" {
|
|
continue // reached through its parent
|
|
}
|
|
built := build(t)
|
|
if t.SectionID == "" {
|
|
exported.Tasks = append(exported.Tasks, built)
|
|
} else {
|
|
bySection[t.SectionID] = append(bySection[t.SectionID], built)
|
|
}
|
|
}
|
|
for _, section := range sections {
|
|
exported.Sections = append(exported.Sections, todoist.Section{
|
|
Name: section.Name, Tasks: bySection[section.ID],
|
|
})
|
|
}
|
|
return exported, nil
|
|
}
|
|
|
|
// handleExportAccount writes everything: every project, task, comment and label, as
|
|
// JSON.
|
|
//
|
|
// This is the "you can leave" guarantee. It is deliberately the raw shape rather
|
|
// than a tidied one — a backup that has been prettified is a backup that has lost
|
|
// something, and this is what an importer on the other side would want.
|
|
func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
ctx := r.Context()
|
|
|
|
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
|
if err != nil {
|
|
s.internal(w, r, "export projects", err)
|
|
return
|
|
}
|
|
|
|
type exportedTask struct {
|
|
taskJSON
|
|
Comments []commentJSON `json:"comments,omitempty"`
|
|
}
|
|
type exportedProject struct {
|
|
projectJSON
|
|
Sections []sectionJSON `json:"sections"`
|
|
Tasks []exportedTask `json:"tasks"`
|
|
Activity []any `json:"activity,omitempty"`
|
|
}
|
|
|
|
out := struct {
|
|
Version int `json:"version"`
|
|
ExportedAt string `json:"exported_at"`
|
|
User userJSON `json:"user"`
|
|
Projects []exportedProject `json:"projects"`
|
|
Labels []labelJSON `json:"labels"`
|
|
Filters []filterJSON `json:"filters"`
|
|
}{
|
|
Version: 1,
|
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
|
User: toUserJSON(user),
|
|
Projects: []exportedProject{},
|
|
Labels: []labelJSON{},
|
|
Filters: []filterJSON{},
|
|
}
|
|
|
|
for _, p := range projects {
|
|
ep := exportedProject{projectJSON: toProjectJSON(p), Sections: []sectionJSON{}, Tasks: []exportedTask{}}
|
|
|
|
if sections, err := s.db.ListSections(ctx, p.ID); err == nil {
|
|
for _, sec := range sections {
|
|
ep.Sections = append(ep.Sections, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
|
}
|
|
}
|
|
|
|
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
|
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
|
})
|
|
if err != nil {
|
|
s.internal(w, r, "export tasks", err)
|
|
return
|
|
}
|
|
for _, t := range tasks {
|
|
et := exportedTask{taskJSON: toTaskJSON(t)}
|
|
if comments, err := s.db.ListComments(ctx, t.ID); err == nil {
|
|
for _, c := range comments {
|
|
et.Comments = append(et.Comments, toCommentJSON(c))
|
|
}
|
|
}
|
|
ep.Tasks = append(ep.Tasks, et)
|
|
}
|
|
|
|
if entries, err := s.db.ListActivity(ctx, p.ID, 200); err == nil {
|
|
for _, a := range entries {
|
|
ep.Activity = append(ep.Activity, map[string]any{
|
|
"event": a.Event, "user": a.UserName, "payload": a.Payload,
|
|
"created_at": a.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
out.Projects = append(out.Projects, ep)
|
|
}
|
|
|
|
if labels, err := s.db.ListLabels(ctx, user.ID); err == nil {
|
|
for _, l := range labels {
|
|
out.Labels = append(out.Labels, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, l.TaskCount})
|
|
}
|
|
}
|
|
if filters, err := s.db.ListFilters(ctx, user.ID); err == nil {
|
|
for _, f := range filters {
|
|
out.Filters = append(out.Filters, toFilterJSON(f))
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Content-Disposition",
|
|
`attachment; filename="verdande-`+time.Now().Format("2006-01-02")+`.json"`)
|
|
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(out); err != nil {
|
|
s.log.Error("write account export", "err", err)
|
|
}
|
|
}
|
|
|
|
// handleExportProjectICS writes one project as a calendar file, for a one-off
|
|
// import into something else. The subscribable feed is a different thing: this is a
|
|
// snapshot somebody downloads.
|
|
func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
projectID := chi.URLParam(r, "projectID")
|
|
|
|
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get project", err)
|
|
return
|
|
}
|
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
|
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
|
})
|
|
if err != nil {
|
|
s.internal(w, r, "export ics", err)
|
|
return
|
|
}
|
|
|
|
cal := ics.Calendar{Name: project.Name, Domain: feedDomain(s.cfg.BaseURL)}
|
|
for _, t := range tasks {
|
|
if t.DueDate == "" {
|
|
continue
|
|
}
|
|
cal.Tasks = append(cal.Tasks, ics.Task{
|
|
ID: t.ID, Content: t.Content, Description: t.Description,
|
|
ProjectName: project.Name, DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
|
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
|
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
|
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+ics.Filename(project.Name)+`"`)
|
|
w.Write([]byte(ics.Render(cal)))
|
|
}
|