To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.
Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.
SÆTNINGEN
Hurtig tilføjelse forstår nu alarmen:
ring til tandlægen i morgen kl 9 #hjem mind mig en time før
Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.
Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.
Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.
STANDARDEN
En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.
Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.
Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.
Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.
TRE TING LÆSNINGEN FANGEDE
* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
(`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
havde givet en alarm en time for sent, og det er den ene retning en
påmindelse ikke må tage fejl i. Samme konvention hele vejen —
indstillingen, parseren og kolonnen — for to fortegnsregler for det
samme begreb viser sig kun som en alarm på et forkert tidspunkt.
* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
`AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
med en dag men ingen klokketid ville have lagt en række, der aldrig
fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
Det er den ene slags fejl en påmindelse ikke må have, fordi intet
bagefter afslører den: der står ingen række at savne.
* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
første, der bruger den i praksis. Den skriver nu "1 time før".
PRØVET
Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.
Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.
Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.
Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
893 lines
30 KiB
Go
893 lines
30 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/quickadd"
|
|
"github.com/kristianwind/verdande/internal/recurrence"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
type taskJSON struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
SectionID string `json:"section_id,omitempty"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
Content string `json:"content"`
|
|
Description string `json:"description,omitempty"`
|
|
Priority int `json:"priority"`
|
|
DueDate string `json:"due_date,omitempty"`
|
|
DueDatetime string `json:"due_datetime,omitempty"`
|
|
DurationMin *int `json:"duration_min,omitempty"`
|
|
Recurrence string `json:"recurrence_rule,omitempty"`
|
|
RepeatText string `json:"recurrence_text,omitempty"`
|
|
AssigneeID string `json:"assignee_id,omitempty"`
|
|
Labels []string `json:"labels"`
|
|
Completed bool `json:"completed"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
SnoozedUntil string `json:"snoozed_until,omitempty"`
|
|
CreatedBy string `json:"created_by"`
|
|
// What the row can say without being opened. Sent with every task rather than
|
|
// fetched when one is opened, because the point is that you see it in the list.
|
|
SubtaskCount int `json:"subtask_count,omitempty"`
|
|
SubtaskDone int `json:"subtask_done,omitempty"`
|
|
AttachmentCount int `json:"attachment_count,omitempty"`
|
|
SortOrder float64 `json:"sort_order"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func toTaskJSON(t store.Task) taskJSON {
|
|
j := taskJSON{
|
|
ID: t.ID, ProjectID: t.ProjectID, SectionID: t.SectionID, ParentID: t.ParentID,
|
|
Content: t.Content, Description: t.Description, Priority: t.Priority,
|
|
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
|
|
RepeatText: recurrence.Describe(t.RecurrenceRule),
|
|
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
|
|
SubtaskCount: t.SubtaskCount, SubtaskDone: t.SubtaskDone,
|
|
AttachmentCount: t.AttachmentCount,
|
|
SortOrder: t.SortOrder,
|
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
|
// Never nil: a client iterating labels should not have to null-check first.
|
|
Labels: t.Labels,
|
|
}
|
|
if j.Labels == nil {
|
|
j.Labels = []string{}
|
|
}
|
|
if t.DueDatetime != nil {
|
|
j.DueDatetime = t.DueDatetime.Format(time.RFC3339)
|
|
}
|
|
if t.CompletedAt != nil {
|
|
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
|
}
|
|
if t.SnoozedUntil != nil {
|
|
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
|
}
|
|
return j
|
|
}
|
|
|
|
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
q := r.URL.Query()
|
|
|
|
f := store.TaskFilter{
|
|
SectionID: q.Get("section_id"),
|
|
ParentID: q.Get("parent_id"),
|
|
Search: q.Get("q"),
|
|
IncludeCompleted: q.Get("completed") == "include",
|
|
CompletedOnly: q.Get("completed") == "only",
|
|
TopLevelOnly: q.Get("top_level") == "true",
|
|
NoDate: q.Get("no_date") == "true",
|
|
DueBefore: q.Get("due_before"),
|
|
DueFrom: q.Get("due_from"),
|
|
LabelID: q.Get("label_id"),
|
|
Limit: parseLimit(q.Get("limit"), 200, 500),
|
|
}
|
|
if v := q.Get("project_id"); v != "" {
|
|
f.ProjectIDs = []string{v}
|
|
}
|
|
if v := q.Get("assignee_id"); v != "" {
|
|
// "me" saves the client having to know its own id to ask the question it
|
|
// actually has, which is "what is on my plate".
|
|
if v == "me" {
|
|
v = user.ID
|
|
}
|
|
f.AssigneeID = v
|
|
}
|
|
if v := q.Get("priority"); v != "" {
|
|
if p, err := strconv.Atoi(v); err == nil && p >= 1 && p <= 4 {
|
|
f.Priority = p
|
|
}
|
|
}
|
|
|
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
|
if err != nil {
|
|
s.internal(w, r, "list tasks", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
|
}
|
|
|
|
func toTaskList(tasks []store.Task) []taskJSON {
|
|
out := make([]taskJSON, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
out = append(out, toTaskJSON(t))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
|
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
|
}
|
|
|
|
type taskRequest struct {
|
|
ProjectID *string `json:"project_id"`
|
|
SectionID *string `json:"section_id"`
|
|
ParentID *string `json:"parent_id"`
|
|
Content *string `json:"content"`
|
|
Description *string `json:"description"`
|
|
Priority *int `json:"priority"`
|
|
DueDate *string `json:"due_date"`
|
|
DueTime *string `json:"due_time"`
|
|
DurationMin *int `json:"duration_min"`
|
|
Recurrence *string `json:"recurrence_rule"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
Labels []string `json:"labels"`
|
|
SortOrder *float64 `json:"sort_order"`
|
|
}
|
|
|
|
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|
var req taskRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
content := ""
|
|
if req.Content != nil {
|
|
content = strings.TrimSpace(*req.Content)
|
|
}
|
|
if fields := validateTaskContent(content, req.Priority); len(fields) > 0 {
|
|
writeFieldErrors(w, fields)
|
|
return
|
|
}
|
|
|
|
projectID := ""
|
|
if req.ProjectID != nil {
|
|
projectID = *req.ProjectID
|
|
}
|
|
// No project means the Inbox — which is the whole point of having one.
|
|
if projectID == "" {
|
|
var err error
|
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
|
s.internal(w, r, "inbox", err)
|
|
return
|
|
}
|
|
}
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
t := &store.Task{
|
|
ProjectID: projectID, Content: content, CreatedBy: user.ID, Priority: 4,
|
|
}
|
|
if req.SectionID != nil {
|
|
t.SectionID = *req.SectionID
|
|
}
|
|
if req.ParentID != nil {
|
|
t.ParentID = *req.ParentID
|
|
}
|
|
if req.Description != nil {
|
|
t.Description = *req.Description
|
|
}
|
|
if req.Priority != nil {
|
|
t.Priority = *req.Priority
|
|
}
|
|
if req.AssigneeID != nil {
|
|
t.AssigneeID = *req.AssigneeID
|
|
}
|
|
if req.Recurrence != nil {
|
|
// Refused here rather than discovered when somebody ticks the task off and
|
|
// it cannot be advanced — at which point the failure is a mystery and the
|
|
// task is stuck.
|
|
if !recurrence.Valid(*req.Recurrence) {
|
|
writeFieldErrors(w, map[string]string{"recurrence_rule": "is not a valid RRULE"})
|
|
return
|
|
}
|
|
t.RecurrenceRule = *req.Recurrence
|
|
}
|
|
if req.DurationMin != nil {
|
|
t.DurationMin = req.DurationMin
|
|
}
|
|
if req.SortOrder != nil {
|
|
t.SortOrder = *req.SortOrder
|
|
}
|
|
if req.DueDate != nil {
|
|
due, when, err := resolveDue(*req.DueDate, valueOr(req.DueTime, ""), user.Timezone)
|
|
if err != nil {
|
|
writeFieldErrors(w, map[string]string{"due_date": err.Error()})
|
|
return
|
|
}
|
|
t.DueDate, t.DueDatetime, t.DueTimezone = due, when, user.Timezone
|
|
}
|
|
|
|
// A sub-task must live in the same project as its parent, or the two would
|
|
// disagree about who can see it.
|
|
if t.ParentID != "" {
|
|
parent, err := s.db.GetTask(r.Context(), t.ParentID, user.ID)
|
|
if err != nil {
|
|
writeFieldErrors(w, map[string]string{"parent_id": "not found"})
|
|
return
|
|
}
|
|
t.ProjectID = parent.ProjectID
|
|
}
|
|
|
|
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
|
s.internal(w, r, "create task", err)
|
|
return
|
|
}
|
|
// The store wrote the labels; the struct it was handed does not know that, and
|
|
// the response has to describe the task that now exists.
|
|
t.Labels = req.Labels
|
|
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
|
s.internal(w, r, "default reminder", err)
|
|
return
|
|
}
|
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
|
// Lavet til en anden med det samme. Det er den samme besked som at blive
|
|
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
|
|
// ind i "det her er dit nu" giver besked og den anden ikke.
|
|
s.notifyAssigned(r, t)
|
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
|
}
|
|
|
|
func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|
var req taskRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
taskID := chi.URLParam(r, "taskID")
|
|
user := userFrom(r.Context())
|
|
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
// A change of project is a move, not a field: it has to renumber the task
|
|
// among its new neighbours. Done before the rest so a request that both moves
|
|
// and edits ends up entirely applied or entirely refused.
|
|
if req.ProjectID != nil {
|
|
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
|
|
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
|
|
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
|
|
// det hver gang nogen retter en overskrift.
|
|
wasAssigned := ""
|
|
if req.AssigneeID != nil {
|
|
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
|
|
wasAssigned = before.AssigneeID
|
|
}
|
|
}
|
|
|
|
u := store.TaskUpdate{
|
|
Description: req.Description, Priority: req.Priority,
|
|
SectionID: req.SectionID, ParentID: req.ParentID,
|
|
AssigneeID: req.AssigneeID, RecurrenceRule: req.Recurrence,
|
|
DurationMin: req.DurationMin, SortOrder: req.SortOrder,
|
|
}
|
|
if req.Content != nil {
|
|
content := strings.TrimSpace(*req.Content)
|
|
if fields := validateTaskContent(content, req.Priority); len(fields) > 0 {
|
|
writeFieldErrors(w, fields)
|
|
return
|
|
}
|
|
u.Content = &content
|
|
}
|
|
if req.Priority != nil && (*req.Priority < 1 || *req.Priority > 4) {
|
|
writeFieldErrors(w, map[string]string{"priority": "must be 1, 2, 3 or 4"})
|
|
return
|
|
}
|
|
if req.Recurrence != nil && !recurrence.Valid(*req.Recurrence) {
|
|
writeFieldErrors(w, map[string]string{"recurrence_rule": "is not a valid RRULE"})
|
|
return
|
|
}
|
|
// Sending due_date at all means the due date is being set — including to
|
|
// nothing, which is how a date is cleared.
|
|
if req.DueDate != nil {
|
|
u.SetDue = true
|
|
if *req.DueDate != "" {
|
|
due, when, err := resolveDue(*req.DueDate, valueOr(req.DueTime, ""), user.Timezone)
|
|
if err != nil {
|
|
writeFieldErrors(w, map[string]string{"due_date": err.Error()})
|
|
return
|
|
}
|
|
u.DueDate, u.DueDatetime, u.DueTimezone = due, when, user.Timezone
|
|
}
|
|
}
|
|
if req.Labels != nil {
|
|
u.SetLabels = true
|
|
u.Labels = req.Labels
|
|
}
|
|
|
|
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
|
s.storeError(w, r, "update task", err)
|
|
return
|
|
}
|
|
|
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
// Et klokkeslu00e6t kan ogsu00e5 komme til EFTER at opgaven er lavet, og det er den
|
|
// halvdel, der ellers mangler i et lu00f8fte om "alle opgaver med en dato og tid".
|
|
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
|
s.internal(w, r, "default reminder", err)
|
|
return
|
|
}
|
|
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
|
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
|
s.notifyAssigned(r, t)
|
|
}
|
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
|
}
|
|
|
|
func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskID")
|
|
user := userFrom(r.Context())
|
|
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "complete task", err)
|
|
return
|
|
}
|
|
|
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
|
|
// A repeating task that moved forward is still recorded as a completion: "what
|
|
// did I get done this week" has to include the chores that repeat.
|
|
s.activity(r, t.ProjectID, t.ID, "task.completed", map[string]any{
|
|
"content": t.Content,
|
|
"recurred": result.Recurred,
|
|
"next_due": result.NextDue,
|
|
})
|
|
|
|
payload := taskWithRecurrence{taskJSON: toTaskJSON(*t), Recurred: result.Recurred, NextDue: result.NextDue}
|
|
s.publish(t.ProjectID, "task.completed", payload)
|
|
writeJSON(w, http.StatusOK, payload)
|
|
}
|
|
|
|
// taskWithRecurrence is the completion response. The two extra fields are what let
|
|
// the interface say "flyttet til mandag" instead of animating a row away that is
|
|
// about to come straight back.
|
|
type taskWithRecurrence struct {
|
|
taskJSON
|
|
Recurred bool `json:"recurred"`
|
|
NextDue string `json:"next_due,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskID")
|
|
user := userFrom(r.Context())
|
|
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
|
s.storeError(w, r, "reopen task", err)
|
|
return
|
|
}
|
|
|
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
|
s.publish(t.ProjectID, "task.reopened", toTaskJSON(*t))
|
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
|
}
|
|
|
|
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskID")
|
|
user := userFrom(r.Context())
|
|
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
|
s.storeError(w, r, "delete task", err)
|
|
return
|
|
}
|
|
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
|
s.publish(t.ProjectID, "task.deleted", map[string]string{"id": taskID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type moveRequest struct {
|
|
ProjectID string `json:"project_id"`
|
|
SectionID string `json:"section_id"`
|
|
AfterID string `json:"after_id"`
|
|
BeforeID string `json:"before_id"`
|
|
}
|
|
|
|
// handleMoveTask is the drag-and-drop endpoint: the client says which two tasks the
|
|
// dropped one now sits between, and the server works out a position. Sending
|
|
// neighbours rather than an index means two people reordering at once cannot
|
|
// produce an off-by-one against a list that changed underneath them.
|
|
func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
|
var req moveRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
taskID := chi.URLParam(r, "taskID")
|
|
user := userFrom(r.Context())
|
|
|
|
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
projectID := req.ProjectID
|
|
if projectID == "" {
|
|
projectID = current.ProjectID
|
|
}
|
|
// Moving into another project needs permission on the destination too.
|
|
if projectID != current.ProjectID {
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
|
s.storeError(w, r, "move task", err)
|
|
return
|
|
}
|
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
|
}
|
|
|
|
// --- quick add ------------------------------------------------------------------
|
|
|
|
type quickAddRequest struct {
|
|
Text string `json:"text"`
|
|
// ProjectID is the view the user was looking at. A "#project" in the text
|
|
// wins over it: what somebody typed beats where they happened to be standing.
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
// SectionID is the section the box was opened in, for the field that sits at
|
|
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
|
// because typing it is a choice and standing there is a circumstance.
|
|
SectionID string `json:"section_id,omitempty"`
|
|
|
|
// ParentID makes the new task a sub-task of an existing one. It is what a
|
|
// pasted list uses for its indented lines, and what the sub-task field at the
|
|
// foot of a task uses — both want the same parsing of dates, #project and
|
|
// @labels that a top-level task gets, so they come through here rather than
|
|
// through a second, dumber path.
|
|
//
|
|
// A sub-task lives in its parent's project. Saying otherwise would describe a
|
|
// task whose parent is somewhere else, which the list views cannot draw and
|
|
// nobody means; a "#project" on an indented line is therefore not honoured,
|
|
// and the caller is told so the word does not just vanish.
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|
var req quickAddRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
loc := userLocation(user.Timezone)
|
|
parsed := quickadd.Parse(req.Text, time.Now().In(loc), user.Locale)
|
|
if strings.TrimSpace(parsed.Content) == "" {
|
|
writeFieldErrors(w, map[string]string{"text": "there is no task in that"})
|
|
return
|
|
}
|
|
|
|
// A parent decides the project before anything else is read: a sub-task
|
|
// somewhere other than its parent is not a shape this program has.
|
|
parent := (*store.Task)(nil)
|
|
if req.ParentID != "" {
|
|
found, err := s.db.GetTask(r.Context(), req.ParentID, user.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
parent = found
|
|
}
|
|
|
|
projectID := req.ProjectID
|
|
// Set when the text named a project that does not exist. The task is still
|
|
// created — see below — but the caller is told, because the alternative is
|
|
// what it used to be: "#Dekoration" disappears from the title, the task lands
|
|
// somewhere else, and nothing anywhere says why.
|
|
unknownProject := ""
|
|
if parsed.Project != "" {
|
|
// An unknown "#name" is not an error: the task still gets created, in the
|
|
// Inbox, rather than a thought being thrown away over a typo.
|
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
|
projectID = id
|
|
} else {
|
|
unknownProject = parsed.Project
|
|
}
|
|
}
|
|
// The parent has the last word, and the word it overrules is reported the same
|
|
// way an unknown project is — the task is created either way.
|
|
if parent != nil {
|
|
if parsed.Project != "" && projectID != parent.ProjectID {
|
|
unknownProject = parsed.Project
|
|
}
|
|
projectID = parent.ProjectID
|
|
}
|
|
if projectID == "" {
|
|
var err error
|
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
|
s.internal(w, r, "inbox", err)
|
|
return
|
|
}
|
|
}
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
// Resolved after the project, because a section belongs to one project and the
|
|
// same name can exist in several. A name that matches nothing is reported the
|
|
// same way an unknown project is: the task is still created, in the project
|
|
// without a section, and the caller is told which word did not land.
|
|
sectionID, unknownSection := "", ""
|
|
// The section the box belongs to, unless the text names one. Checked against
|
|
// the project the task actually landed in, so a "#project" that moved it
|
|
// elsewhere cannot drag a section id from another project along with it.
|
|
if req.SectionID != "" {
|
|
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
|
|
sectionID = req.SectionID
|
|
}
|
|
}
|
|
if parsed.Section != "" {
|
|
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
|
|
sectionID = id
|
|
} else {
|
|
unknownSection = parsed.Section
|
|
}
|
|
}
|
|
|
|
t := &store.Task{
|
|
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
|
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
|
RecurrenceRule: parsed.Recurrence,
|
|
}
|
|
if parent != nil {
|
|
t.ParentID = parent.ID
|
|
}
|
|
if parsed.DueDate != "" {
|
|
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
|
if err == nil {
|
|
t.DueDatetime, t.DueTimezone = when, user.Timezone
|
|
}
|
|
}
|
|
|
|
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
|
s.internal(w, r, "create task", err)
|
|
return
|
|
}
|
|
t.Labels = parsed.Labels
|
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
|
|
|
// The alarm the line asked for, after the task because a reminder needs its id.
|
|
//
|
|
// Set when the line asked to be reminded and the task has nothing to count back
|
|
// from. An offset is relative to the due moment, and DueReminders selects the
|
|
// offset form only `AND t.due_datetime IS NOT NULL` — so on a task with a day
|
|
// but no clock there is nothing to be an hour before, and the row would sit in
|
|
// the table forever without firing. Reported the same way an unknown project
|
|
// is: the task is created either way, and the caller is told what could not be
|
|
// honoured. An alarm that silently never rings is the one way a reminder must
|
|
// not fail, because nothing afterwards reveals it.
|
|
reminderNotSet := ""
|
|
switch {
|
|
case len(parsed.ReminderAt) == len("2006-01-02T15:04"):
|
|
day, clock := parsed.ReminderAt[:10], parsed.ReminderAt[11:]
|
|
if _, when, err := resolveDue(day, clock, user.Timezone); err == nil && when != nil {
|
|
if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, when, nil); err != nil {
|
|
s.internal(w, r, "create reminder", err)
|
|
return
|
|
}
|
|
}
|
|
case parsed.ReminderOffsetMin != nil:
|
|
if t.DueDatetime == nil {
|
|
reminderNotSet = "no due time to count back from"
|
|
} else if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, nil, parsed.ReminderOffsetMin); err != nil {
|
|
s.internal(w, r, "create reminder", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Standarden til sidst, og efter den udtrykkelige: "mind mig en time fu00f8r" har
|
|
// allerede lagt sin, og applyDefaultReminder lu00e6gger ikke en oven pu00e5. "ingen
|
|
// alarm" er den anden vej ud u2014 den eneste mu00e5de at sige nej til standarden,
|
|
// mens man skriver opgaven, frem for at u00e5bne den bagefter og slette alarmen.
|
|
if !parsed.NoReminder {
|
|
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
|
s.internal(w, r, "default reminder", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if unknownProject != "" || unknownSection != "" || reminderNotSet != "" {
|
|
// Alongside the task rather than instead of it: the task was created, and
|
|
// this says what could not be honoured while creating it.
|
|
writeJSON(w, http.StatusCreated, struct {
|
|
taskJSON
|
|
UnknownProject string `json:"unknown_project,omitempty"`
|
|
UnknownSection string `json:"unknown_section,omitempty"`
|
|
ReminderNotSet string `json:"reminder_not_set,omitempty"`
|
|
}{toTaskJSON(*t), unknownProject, unknownSection, reminderNotSet})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
|
}
|
|
|
|
// handleQuickAddPreview parses without saving, so the input box can highlight what
|
|
// it understood while the user is still typing.
|
|
func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
text := r.URL.Query().Get("text")
|
|
|
|
loc := userLocation(user.Timezone)
|
|
writeJSON(w, http.StatusOK, quickadd.Parse(text, time.Now().In(loc), user.Locale))
|
|
}
|
|
|
|
// --- helpers --------------------------------------------------------------------
|
|
|
|
// moveTaskToProject moves a task to another project, if that is really what was
|
|
// asked for.
|
|
//
|
|
// Shared by the REST handler and the MCP tool, because they had drifted: both
|
|
// accepted project_id on an update, both checked the caller's rights on the
|
|
// destination — which reads as "understood" — and then neither passed it on,
|
|
// because store.TaskUpdate has no such field. The task stayed where it was and
|
|
// the response said OK. An argument that is validated and then dropped is worse
|
|
// than one that is refused.
|
|
//
|
|
// A move rather than a field assignment: sort_order is per project, so landing
|
|
// in a new one means being given a position among its tasks.
|
|
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
|
|
if projectID == "" {
|
|
return nil
|
|
}
|
|
current, err := s.db.GetTask(ctx, taskID, userID)
|
|
if err != nil {
|
|
return errors.New("not found")
|
|
}
|
|
if current.ProjectID == projectID {
|
|
return nil // already there; not worth a write or a renumber
|
|
}
|
|
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
|
|
return errors.New("not found")
|
|
}
|
|
|
|
// No neighbours: it lands at the start of the destination, which is the only
|
|
// answer available when the caller did not say where. Section is cleared —
|
|
// a section belongs to the project being left behind.
|
|
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
|
|
return errors.New("not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTaskContent(content string, priority *int) map[string]string {
|
|
fields := map[string]string{}
|
|
switch {
|
|
case content == "":
|
|
fields["content"] = "required"
|
|
case len([]rune(content)) > 2000:
|
|
fields["content"] = "must be 2000 characters or fewer"
|
|
}
|
|
if priority != nil && (*priority < 1 || *priority > 4) {
|
|
fields["priority"] = "must be 1, 2, 3 or 4"
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// resolveDue turns a date and optional time into the pair stored on a task: the
|
|
// calendar day as written, and — only when a clock time was given — the exact
|
|
// moment, resolved in the user's own timezone.
|
|
func resolveDue(date, clock, timezone string) (string, *time.Time, error) {
|
|
day, err := time.Parse("2006-01-02", date)
|
|
if err != nil {
|
|
return "", nil, errInvalidDate
|
|
}
|
|
if clock == "" {
|
|
return day.Format("2006-01-02"), nil, nil
|
|
}
|
|
|
|
hm, err := time.Parse("15:04", clock)
|
|
if err != nil {
|
|
return "", nil, errInvalidDate
|
|
}
|
|
loc := userLocation(timezone)
|
|
moment := time.Date(day.Year(), day.Month(), day.Day(), hm.Hour(), hm.Minute(), 0, 0, loc).UTC()
|
|
return day.Format("2006-01-02"), &moment, nil
|
|
}
|
|
|
|
type dateError struct{}
|
|
|
|
func (dateError) Error() string { return "must be a date like 2026-03-15" }
|
|
|
|
var errInvalidDate = dateError{}
|
|
|
|
// userLocation falls back to UTC rather than failing: a bad timezone in a profile
|
|
// must not stop somebody creating a task.
|
|
func userLocation(name string) *time.Location {
|
|
if name == "" {
|
|
return time.UTC
|
|
}
|
|
loc, err := time.LoadLocation(name)
|
|
if err != nil {
|
|
return time.UTC
|
|
}
|
|
return loc
|
|
}
|
|
|
|
func valueOr(p *string, def string) string {
|
|
if p == nil {
|
|
return def
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func parseLimit(raw string, def, max int) int {
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 {
|
|
return def
|
|
}
|
|
if n > max {
|
|
return max
|
|
}
|
|
return n
|
|
}
|
|
|
|
type snoozeRequest struct {
|
|
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
|
Until string `json:"until"`
|
|
}
|
|
|
|
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
|
// but when the task wants to be seen — its due date and everything else stay put.
|
|
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
|
var req snoozeRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
taskID := chi.URLParam(r, "taskID")
|
|
user := userFrom(r.Context())
|
|
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
var until *time.Time
|
|
if strings.TrimSpace(req.Until) != "" {
|
|
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
|
if perr != nil {
|
|
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
|
return
|
|
}
|
|
until = &parsed
|
|
}
|
|
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
|
s.storeError(w, r, "snooze task", err)
|
|
return
|
|
}
|
|
|
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
verb := "task.snoozed"
|
|
if until == nil {
|
|
verb = "task.unsnoozed"
|
|
}
|
|
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
|
}
|
|
|
|
// applyDefaultReminder gives a task the alarm the account asks for without being
|
|
// asked each time.
|
|
//
|
|
// Three things make it a no-op, and each of them is the point rather than a guard:
|
|
//
|
|
// - The account has the default off. That is where every account starts, and it
|
|
// stays there until somebody turns it on: switching it on in a migration would
|
|
// have given every existing user alarms they never asked for, on tasks already
|
|
// lying there, in software other people run themselves.
|
|
// - The task has no clock. A task due "tomorrow" has no moment to ring at, and
|
|
// DueReminders selects the offset form only `AND t.due_datetime IS NOT NULL` —
|
|
// so the row would sit there and never fire.
|
|
// - The task already has a reminder. The line may have asked for one itself, or
|
|
// the task may be being edited for the second time; either way the default is
|
|
// a default and must not stack a second alarm on top of a stated one.
|
|
//
|
|
// Called on every path a task can gain a clock — created, edited, or quick-added —
|
|
// because a default that only applies to one of them is a setting that is true on
|
|
// Tuesdays. CalDAV is deliberately not one of them: a task arriving from Apple
|
|
// Reminders brings its own alarm, and two alarms for one task is worse than none.
|
|
func (s *Server) applyDefaultReminder(ctx context.Context, t *store.Task, user *store.User) error {
|
|
if user.DefaultReminderMin == nil || t == nil || t.DueDatetime == nil {
|
|
return nil
|
|
}
|
|
existing, err := s.db.ListReminders(ctx, t.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(existing) > 0 {
|
|
return nil
|
|
}
|
|
_, err = s.db.CreateReminder(ctx, t.ID, user.ID, nil, user.DefaultReminderMin)
|
|
return err
|
|
}
|