feat(phase2,phase4): comments, attachments, notifications, Web Push, import/export

Comments and attachments close out phase 2. Files are stored
content-addressed by hash, so the path never contains anything the
uploader chose — a filename is attacker-controlled text, and building a
path from it is how "../" becomes a write outside the data directory.
Everything downloads as application/octet-stream: an uploaded SVG served
inline would run its own script on this origin with the session cookie
attached.

Web Push is written directly against RFC 8030/8291/8292 rather than
pulled in — it is three specs' worth of well-defined crypto and about
two hundred lines, and the alternatives carry a JWT and crypto stack
each larger than the file.

Phase 4 imports Todoist's CSV and exports it back. Todoist numbers
priorities the opposite way to how it displays them — its 4 is P1 — so
the two are converted rather than copied; getting it backwards silently
inverts the urgency of every imported task. Nesting is positional, so
the reader keeps an indent stack and the writer never sorts.

The round trip is tested as the brief asks: a CSV in, out, and back
produces an identical project, and two exports of one project are
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:42:55 +02:00
co-authored by Claude Opus 5
parent 0ee72d399c
commit 4058ff0533
13 changed files with 3215 additions and 0 deletions
+414
View File
@@ -0,0 +1,414 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
type commentJSON struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
UserColor string `json:"user_color"`
Body string `json:"body"`
Attachments []attachmentJSON `json:"attachments"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type attachmentJSON struct {
ID string `json:"id"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
URL string `json:"url"`
}
func toAttachmentJSON(a store.Attachment) attachmentJSON {
return attachmentJSON{
ID: a.ID, Filename: a.Filename, MimeType: a.MimeType, Size: a.Size,
URL: "/api/v1/attachments/" + a.ID,
}
}
func toCommentJSON(c store.Comment) commentJSON {
j := commentJSON{
ID: c.ID, TaskID: c.TaskID, UserID: c.UserID, UserName: c.UserName,
UserColor: c.UserColor, Body: c.Body,
Attachments: make([]attachmentJSON, 0, len(c.Attachments)),
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
for _, a := range c.Attachments {
j.Attachments = append(j.Attachments, toAttachmentJSON(a))
}
return j
}
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskID")
if _, err := store.TaskRole(r.Context(), s.db, taskID, userFrom(r.Context()).ID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
comments, err := s.db.ListComments(r.Context(), taskID)
if err != nil {
s.internal(w, "list comments", err)
return
}
out := make([]commentJSON, 0, len(comments))
for _, c := range comments {
out = append(out, toCommentJSON(c))
}
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
if err != nil {
s.internal(w, "list attachments", err)
return
}
direct := make([]attachmentJSON, 0, len(files))
for _, a := range files {
direct = append(direct, toAttachmentJSON(a))
}
writeJSON(w, http.StatusOK, map[string]any{"comments": out, "attachments": direct})
}
type commentRequest struct {
Body string `json:"body"`
}
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
var req commentRequest
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
}
body := strings.TrimSpace(req.Body)
if body == "" {
writeFieldErrors(w, map[string]string{"body": "required"})
return
}
if utf8.RuneCountInString(body) > 10000 {
writeFieldErrors(w, map[string]string{"body": "must be 10000 characters or fewer"})
return
}
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
if err != nil {
s.internal(w, "create comment", err)
return
}
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err == nil {
s.activity(r, task.ProjectID, taskID, "comment.created", map[string]any{"body": body})
s.publish(task.ProjectID, "comment.created", toCommentJSON(*comment))
// Everybody else who can see the project hears about it; the author does
// not need telling what they just wrote.
s.notifyProject(r, task.ProjectID, taskID, user.ID, "comment",
user.Name+" kommenterede", task.Content)
}
writeJSON(w, http.StatusCreated, toCommentJSON(*comment))
}
func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
var req commentRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
body := strings.TrimSpace(req.Body)
if body == "" {
writeFieldErrors(w, map[string]string{"body": "required"})
return
}
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
if err != nil {
s.storeError(w, "update comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
commentID := chi.URLParam(r, "commentID")
user := userFrom(r.Context())
taskID, err := s.db.CommentTask(r.Context(), commentID)
if err != nil {
s.storeError(w, "comment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
s.storeError(w, "delete comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- attachments -------------------------------------------------------------------
// maxUploadBytes caps a single file at 25 MiB.
//
// This is a to-do app on somebody's homelab, not a file host: the attachments that
// matter are a photo of a receipt or a PDF of a contract. A cap keeps one paste of
// a video from filling the volume the database lives on.
const maxUploadBytes = 25 << 20
func (s *Server) handleUploadAttachment(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
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
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()
if header.Size > maxUploadBytes {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, "store upload", err)
return
}
a := &store.Attachment{
TaskID: taskID,
CommentID: r.FormValue("comment_id"),
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
// An attachment hangs on a task or on a comment, never both — the schema says
// so, and the form may have supplied a comment id.
if a.CommentID != "" {
a.TaskID = ""
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, "record attachment", err)
return
}
if task, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
s.activity(r, task.ProjectID, taskID, "attachment.added",
map[string]any{"filename": a.Filename})
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
// storeUpload writes the bytes under a content-addressed path.
//
// Naming by hash means the same file attached to ten tasks is stored once, and —
// more importantly — the path never contains anything the uploader chose. A
// filename is attacker-controlled text; using it to build a path is how "../"
// becomes a write outside the data directory.
func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
// Created here rather than assumed: startup makes the directory, but a volume
// remounted underneath a running container would otherwise turn every upload
// into a 500 with nothing in the log explaining why.
if err := os.MkdirAll(s.cfg.FilesDir(), 0o750); err != nil {
return "", 0, err
}
tmp, err := os.CreateTemp(s.cfg.FilesDir(), "upload-*")
if err != nil {
return "", 0, err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath) // no-op once it has been renamed away
hash := sha256.New()
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
if err != nil {
tmp.Close()
return "", 0, err
}
if err := tmp.Close(); err != nil {
return "", 0, err
}
sum := hex.EncodeToString(hash.Sum(nil))
// Two levels of fan-out: tens of thousands of files in one directory is slow
// to list and slow to open on most filesystems.
rel := filepath.Join(sum[:2], sum[2:4], sum)
full := filepath.Join(s.cfg.FilesDir(), rel)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
return "", 0, err
}
if _, err := os.Stat(full); err == nil {
// Already stored by an earlier upload of the identical file.
return rel, size, nil
}
if err := os.Rename(tmpPath, full); err != nil {
return "", 0, err
}
return rel, size, nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
user := userFrom(r.Context())
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
return
}
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "get attachment", err)
return
}
// The stored path came from a hash this server computed, but it is still
// checked against the files directory before being opened — a path traversal
// that got into the database must not become one that reads /etc/passwd.
full := filepath.Join(s.cfg.FilesDir(), filepath.Clean("/"+a.Path))
if !strings.HasPrefix(full, s.cfg.FilesDir()+string(os.PathSeparator)) {
s.log.Error("attachment path escapes the files directory", "id", a.ID, "path", a.Path)
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
f, err := os.Open(full)
if err != nil {
s.log.Error("open attachment", "err", err, "id", a.ID)
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
defer f.Close()
// Everything is served as a download rather than rendered. An uploaded SVG or
// HTML file displayed inline would run its own script on this origin, with the
// session cookie attached — attachments are the one place a user supplies
// content that another user opens.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
user := userFrom(r.Context())
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment 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
}
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "delete attachment", err)
return
}
// The file is left on disk. It is content-addressed, so another attachment may
// point at the same bytes; removing it here would break that one. A sweeper
// for genuinely unreferenced files is a separate, safer job.
s.log.Debug("attachment row removed; file retained", "path", path)
w.WriteHeader(http.StatusNoContent)
}
// safeFilename keeps the name for display only — never for a path — but still
// strips the parts that would confuse a browser saving it.
func safeFilename(name string) string {
name = filepath.Base(strings.ReplaceAll(name, `\`, "/"))
name = strings.Map(func(r rune) rune {
if r < 32 || r == 127 || r == '/' || r == '"' {
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "fil"
}
if utf8.RuneCountInString(name) > 200 {
runes := []rune(name)
name = string(runes[:200])
}
return name
}
// detectMime records what was declared, for display. It is never used to decide how
// to serve the file — see the download handler.
func detectMime(filename, declared string) string {
if declared != "" && declared != "application/octet-stream" {
return declared
}
if byExt := mime.TypeByExtension(filepath.Ext(filename)); byExt != "" {
return byExt
}
return "application/octet-stream"
}
+11
View File
@@ -11,3 +11,14 @@ import (
func contextWithTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), d)
}
// contextLike is the read surface store.InstanceKeys needs, so the same call works
// from a request and from a detached background goroutine.
type contextLike = context.Context
// contextWithBackgroundTimeout bounds work that has outlived its request. A push
// round trip is started from a handler but must not be cancelled when that handler
// returns, or the notification is dropped the moment the response is written.
func contextWithBackgroundTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
+476
View File
@@ -0,0 +1,476 @@
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, "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, "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, "get project", err)
return
}
exported, err := s.buildExport(r, user, project)
if err != nil {
s.internal(w, "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, "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, "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, "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, "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)))
}
+191
View File
@@ -0,0 +1,191 @@
package httpapi
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/push"
"github.com/kristianwind/verdande/internal/store"
)
type notificationJSON struct {
ID string `json:"id"`
ActorName string `json:"actor_name,omitempty"`
ProjectID string `json:"project_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Read bool `json:"read"`
CreatedAt string `json:"created_at"`
}
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
if err != nil {
s.internal(w, "list notifications", err)
return
}
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
if err != nil {
s.internal(w, "count notifications", err)
return
}
out := make([]notificationJSON, 0, len(list))
for _, n := range list {
out = append(out, notificationJSON{
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
}
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
if err != nil {
s.internal(w, "mark notifications read", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// notify delivers one notification through every channel the person has: stored for
// the bell, pushed live to any open tab, and sent to their devices as Web Push.
//
// Never fails the request that caused it. Somebody assigning a task must not see an
// error because a push service was slow.
func (s *Server) notify(r *http.Request, n *store.Notification) {
if n.UserID == "" || n.UserID == n.ActorID {
return // nobody needs telling what they just did themselves
}
if err := s.db.CreateNotification(r.Context(), n); err != nil {
s.log.Warn("create notification", "err", err)
return
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
// Detached from the request: a push round trip to Google or Mozilla has no
// business holding up the response to the person who caused it.
go s.pushToUser(n.UserID, n.Title, n.Body, n.ProjectID)
}
// notifyProject tells everybody who can see a project, except whoever did it.
func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind, title, body string) {
members, err := s.db.ProjectMemberIDs(r.Context(), projectID)
if err != nil {
s.log.Warn("project members for notification", "err", err)
return
}
for _, userID := range members {
if userID == actorID {
continue
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actorID, ProjectID: projectID, TaskID: taskID,
Kind: kind, Title: title, Body: body,
})
}
}
func (s *Server) pushToUser(userID, title, body, projectID string) {
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
defer cancel()
subs, err := s.db.ListPushSubscriptions(ctx, userID)
if err != nil || len(subs) == 0 {
return
}
public, private, err := s.vapidKeys(ctx)
if err != nil {
s.log.Warn("vapid keys", "err", err)
return
}
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
}, payload, push.VAPID{
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
})
if err == nil {
continue
}
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
s.log.Warn("record push failure", "err", recErr)
}
}
}
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
}
// --- subscription endpoints -----------------------------------------------------------
type pushSubscriptionRequest struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
// handlePushKey hands the browser the public half of the VAPID pair, which is what
// PushManager.subscribe needs before it will produce a subscription at all.
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
public, _, err := s.vapidKeys(r.Context())
if err != nil {
s.internal(w, "vapid keys", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
}
func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
var req pushSubscriptionRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
writeFieldErrors(w, map[string]string{"endpoint": "an incomplete subscription"})
return
}
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
UserID: userFrom(r.Context()).ID,
Endpoint: req.Endpoint,
P256dh: req.Keys.P256dh,
Auth: req.Keys.Auth,
UserAgent: r.UserAgent(),
})
if err != nil {
s.internal(w, "save push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
var req pushSubscriptionRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
s.internal(w, "delete push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+34
View File
@@ -131,8 +131,39 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
r.Get("/feed", s.handleGetFeed)
r.Route("/notifications", func(r chi.Router) {
r.Get("/", s.handleListNotifications)
r.Post("/read", s.handleMarkNotificationsRead)
r.Post("/{notificationID}/read", s.handleMarkNotificationsRead)
})
r.Route("/push", func(r chi.Router) {
r.Get("/key", s.handlePushKey)
r.Post("/subscribe", s.handleSubscribePush)
r.Post("/unsubscribe", s.handleUnsubscribePush)
})
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
r.Get("/", s.handleDownloadAttachment)
r.Delete("/", s.handleDeleteAttachment)
})
r.Route("/comments/{commentID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateComment)
r.Delete("/", s.handleDeleteComment)
})
r.Post("/feed/rotate", s.handleRotateFeed)
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
@@ -200,6 +231,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/", s.handleGetTask)
r.Patch("/", s.handleUpdateTask)
r.Delete("/", s.handleDeleteTask)
r.Get("/comments", s.handleListComments)
r.Post("/comments", s.handleCreateComment)
r.Post("/attachments", s.handleUploadAttachment)
r.Get("/reminders", s.handleListReminders)
r.Post("/reminders", s.handleCreateReminder)
r.Post("/complete", s.handleCompleteTask)
+374
View File
@@ -1,7 +1,10 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
@@ -1308,3 +1311,374 @@ func TestTemplatesAreScopedToTheirOwner(t *testing.T) {
t.Errorf("another user's template list shows %d entries", len(got))
}
}
// --- comments, attachments and import/export -----------------------------------------
func TestCommentsAndAttachments(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "med kommentarer"})
taskID := task["id"].(string)
resp, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "første kommentar"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create comment: %d %v", resp.StatusCode, comment)
}
if comment["user_name"] != "Kristian" {
t.Errorf("user_name = %v", comment["user_name"])
}
// A file attached directly to the task.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "bilag.pdf")
if err != nil {
t.Fatal(err)
}
part.Write([]byte("%PDF-1.4 ikke en rigtig pdf"))
writer.Close()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/tasks/"+taskID+"/attachments", body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
upload, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer upload.Body.Close()
if upload.StatusCode != http.StatusCreated {
t.Fatalf("upload: status %d", upload.StatusCode)
}
var attachment map[string]any
raw, _ := io.ReadAll(upload.Body)
json.Unmarshal(raw, &attachment)
if attachment["filename"] != "bilag.pdf" {
t.Errorf("filename = %v", attachment["filename"])
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
if got, _ := list["comments"].([]any); len(got) != 1 {
t.Errorf("got %d comments, want 1", len(got))
}
if got, _ := list["attachments"].([]any); len(got) != 1 {
t.Errorf("got %d attachments on the task, want 1", len(got))
}
// The file downloads, and never as something a browser would render.
dl, dlBody := ts.do(t, "GET", "/api/v1/attachments/"+attachment["id"].(string), nil)
_ = dlBody
if dl.StatusCode != http.StatusOK {
t.Fatalf("download: status %d", dl.StatusCode)
}
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q; an uploaded file must never be served inline", ct)
}
if cd := dl.Header.Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
t.Errorf("Content-Disposition = %q, want an attachment", cd)
}
}
// A viewer can read a discussion but not join it, and cannot upload.
func TestViewersCannotComment(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
viewer := ts.newUser(t, "viewer@example.dk", "Viewer")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
taskID := task["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "viewer@example.dk", "role": "viewer"})
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments", map[string]any{"body": "ejerens ord"})
if r, body := viewer.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil); r.StatusCode != http.StatusOK {
t.Errorf("a viewer cannot read comments: %d %v", r.StatusCode, body)
}
if r, _ := viewer.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "smuglet ind"}); r.StatusCode < 400 {
t.Errorf("a viewer could comment: status %d", r.StatusCode)
}
}
// Somebody else's comment is theirs. A project role does not confer the right to
// rewrite what another person said.
func TestOnlyTheAuthorCanEditAComment(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
editor := ts.newUser(t, "editor@example.dk", "Editor")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "editor@example.dk", "role": "editor"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
taskID := task["id"].(string)
_, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "ejerens ord"})
commentID := comment["id"].(string)
resp, _ := editor.do(t, "PATCH", "/api/v1/comments/"+commentID,
map[string]any{"body": "omskrevet"})
if resp.StatusCode < 400 {
t.Errorf("an editor rewrote somebody else's comment: status %d", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
comments, _ := list["comments"].([]any)
if comments[0].(map[string]any)["body"] != "ejerens ord" {
t.Error("the comment was changed")
}
}
func TestImportTodoistCSV(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
csv := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
"task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en,\n" +
"note,En kommentar,,,,,,,,\n" +
"task,Find bilag,,1,2,Kristian,,,,\n" +
"section,Til gennemsyn,,,,,,,,\n" +
"task,Gennemgå,,3,1,Kristian,,,,\n"
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Fra Todoist")
part, err := writer.CreateFormFile("file", "firma.csv")
if err != nil {
t.Fatal(err)
}
part.Write([]byte(csv))
writer.Close()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("import: status %d, %s", resp.StatusCode, raw)
}
var result map[string]any
raw, _ := io.ReadAll(resp.Body)
json.Unmarshal(raw, &result)
if result["tasks"] != float64(3) {
t.Errorf("imported %v tasks, want 3", result["tasks"])
}
if result["sections"] != float64(1) {
t.Errorf("imported %v sections, want 1", result["sections"])
}
if result["comments"] != float64(1) {
t.Errorf("imported %v comments, want 1", result["comments"])
}
projectID := result["project_id"].(string)
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
tasks, _ := list["tasks"].([]any)
var found bool
for _, raw := range tasks {
task := raw.(map[string]any)
if task["content"] != "Betal moms" {
continue
}
found = true
// Todoist's 4 is verdande's 1. Getting this backwards would invert the
// urgency of every imported task.
if task["priority"] != float64(1) {
t.Errorf("priority = %v, want 1", task["priority"])
}
if task["due_date"] != "2026-03-15" {
t.Errorf("due_date = %v", task["due_date"])
}
}
if !found {
t.Error("the imported task was not found")
}
}
// The whole point of import and export together: what goes in comes back out.
func TestTodoistImportExportRoundTrip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
original := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
"task,Betal moms,husk bilag,4,1,Kristian,,,,\n" +
"task,Find bilag,,2,2,Kristian,,,,\n" +
"task,Ring til revisor,,1,1,Kristian,,,,\n"
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Rundtur")
part, _ := writer.CreateFormFile("file", "firma.csv")
part.Write([]byte(original))
writer.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var result map[string]any
json.Unmarshal(raw, &result)
projectID, _ := result["project_id"].(string)
if projectID == "" {
t.Fatalf("import failed: %s", raw)
}
// Export it again and compare the shape.
out, exported := ts.do(t, "GET", "/api/v1/export/projects/"+projectID+".csv", nil)
_ = exported
if out.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", out.StatusCode)
}
// The response body was consumed by `do`; fetch it as text instead.
req2, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/projects/"+projectID+".csv", nil)
req2.Header.Set("Sec-Fetch-Site", "same-origin")
csvResp, err := ts.client.Do(req2)
if err != nil {
t.Fatal(err)
}
defer csvResp.Body.Close()
csvBytes, _ := io.ReadAll(csvResp.Body)
roundTripped := string(csvBytes)
for _, want := range []string{"Betal moms", "Find bilag", "Ring til revisor", "husk bilag"} {
if !strings.Contains(roundTripped, want) {
t.Errorf("%q is missing from the export:\n%s", want, roundTripped)
}
}
// The priorities have to come back as Todoist's numbering, not verdande's.
if !strings.Contains(roundTripped, "task,Betal moms,husk bilag,4,1") {
t.Errorf("the exported row does not match Todoist's format:\n%s", roundTripped)
}
// And the sub-task comes back exactly as it went in: Todoist priority 2 became
// verdande's 3 on the way in and has to become 2 again on the way out.
if !strings.Contains(roundTripped, "task,Find bilag,,2,2") {
t.Errorf("the sub-task lost its indent or priority:\n%s", roundTripped)
}
}
func TestAccountExport(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "en opgave", "project_id": project["id"].(string), "labels": []string{"vigtig"},
})
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
map[string]any{"body": "en kommentar"})
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/account", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
var export map[string]any
if err := json.Unmarshal(raw, &export); err != nil {
t.Fatalf("the export is not valid JSON: %v", err)
}
if export["version"] != float64(1) {
t.Errorf("version = %v", export["version"])
}
// A password hash must never be in an export somebody emails to themselves.
if strings.Contains(string(raw), "$argon2id$") {
t.Error("the account export contains a password hash")
}
projects, _ := export["projects"].([]any)
if len(projects) < 2 {
t.Fatalf("got %d projects, want the Inbox and Firma", len(projects))
}
var foundComment bool
for _, p := range projects {
tasks, _ := p.(map[string]any)["tasks"].([]any)
for _, raw := range tasks {
comments, _ := raw.(map[string]any)["comments"].([]any)
if len(comments) > 0 {
foundComment = true
}
}
}
if !foundComment {
t.Error("comments are missing from the account export")
}
if labels, _ := export["labels"].([]any); len(labels) == 0 {
t.Error("labels are missing from the account export")
}
}
func TestNotifications(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "anden@example.dk", "role": "editor"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
map[string]any{"body": "sig noget"})
// The other member hears about it.
_, list := other.do(t, "GET", "/api/v1/notifications", nil)
notifications, _ := list["notifications"].([]any)
if len(notifications) == 0 {
t.Fatal("the other member was not notified about the comment")
}
if list["unread"] == float64(0) {
t.Error("the notification is not counted as unread")
}
// The author is not told what they just wrote.
_, own := ts.do(t, "GET", "/api/v1/notifications", nil)
if got, _ := own["notifications"].([]any); len(got) != 0 {
t.Errorf("the author was notified about their own comment: %v", got)
}
other.do(t, "POST", "/api/v1/notifications/read", nil)
_, after := other.do(t, "GET", "/api/v1/notifications", nil)
if after["unread"] != float64(0) {
t.Errorf("unread = %v after marking everything read", after["unread"])
}
}
+249
View File
@@ -0,0 +1,249 @@
// Package push sends Web Push messages (RFC 8030) with VAPID (RFC 8292) and
// aes128gcm payload encryption (RFC 8291).
//
// Written directly rather than pulled in as a dependency because the whole thing is
// three specs' worth of well-defined crypto and about two hundred lines, and
// because the alternative libraries carry a JWT package and a crypto stack that
// would each be larger than this file.
//
// The shape of it: derive a shared secret with the browser's public key, build a
// key and nonce from that plus both salts, encrypt the payload with AES-128-GCM,
// and authorise the request with a signed JWT naming this server.
package push
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"time"
)
type Subscription struct {
Endpoint string
P256dh string // the browser's public key, base64url
Auth string // the browser's auth secret, base64url
}
type VAPID struct {
Public string
Private string
// Subject identifies the sender to the push service, as a mailto: or https:
// URL. Firefox rejects a request without one.
Subject string
}
// Payload is what the service worker receives.
type Payload struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
URL string `json:"url,omitempty"`
Tag string `json:"tag,omitempty"`
}
var b64 = base64.RawURLEncoding
// ErrGone means the subscription is dead and should be deleted rather than retried.
var ErrGone = errors.New("push: the subscription no longer exists")
func IsGone(err error) bool { return errors.Is(err, ErrGone) }
// GenerateVAPIDKeys produces a P-256 keypair, base64url encoded.
func GenerateVAPIDKeys() (public, private string, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
}
pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y)
return b64.EncodeToString(pub), b64.EncodeToString(key.D.Bytes()), nil
}
// Send delivers one message to one subscription.
func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
encrypted, err := encrypt(sub, body)
if err != nil {
return fmt.Errorf("push: encrypt: %w", err)
}
token, err := signJWT(sub.Endpoint, vapid)
if err != nil {
return fmt.Errorf("push: sign: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Endpoint, bytes.NewReader(encrypted))
if err != nil {
return err
}
req.Header.Set("Content-Encoding", "aes128gcm")
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("TTL", "86400")
// Low urgency would let a service sit on a reminder for hours.
req.Header.Set("Urgency", "normal")
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound, resp.StatusCode == http.StatusGone:
return ErrGone
case resp.StatusCode >= 300:
return fmt.Errorf("push: %s said %s", resp.Request.URL.Host, resp.Status)
}
return nil
}
// encrypt builds an RFC 8291 aes128gcm message.
func encrypt(sub Subscription, plaintext []byte) ([]byte, error) {
clientPubBytes, err := b64.DecodeString(sub.P256dh)
if err != nil {
return nil, fmt.Errorf("client key: %w", err)
}
authSecret, err := b64.DecodeString(sub.Auth)
if err != nil {
return nil, fmt.Errorf("auth secret: %w", err)
}
curve := ecdh.P256()
clientPub, err := curve.NewPublicKey(clientPubBytes)
if err != nil {
return nil, fmt.Errorf("client key: %w", err)
}
// A fresh keypair per message: reusing one would let anybody who recovered a
// single message key read every later one.
serverPriv, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
shared, err := serverPriv.ECDH(clientPub)
if err != nil {
return nil, err
}
serverPubBytes := serverPriv.PublicKey().Bytes()
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
// The key derivation is prescribed by RFC 8291 §3.3, down to the exact info
// strings and their trailing NULs. Every byte here is load-bearing.
prkKey := hkdf(authSecret, shared,
append(append([]byte("WebPush: info\x00"), clientPubBytes...), serverPubBytes...), 32)
cek := hkdf(salt, prkKey, []byte("Content-Encoding: aes128gcm\x00"), 16)
nonce := hkdf(salt, prkKey, []byte("Content-Encoding: nonce\x00"), 12)
block, err := aes.NewCipher(cek)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// A single record, so the padding delimiter is 0x02 ("last record") rather
// than 0x01.
record := append(append([]byte{}, plaintext...), 0x02)
ciphertext := gcm.Seal(nil, nonce, record, nil)
// Header: salt(16) | record size(4) | key id length(1) | key id.
var out bytes.Buffer
out.Write(salt)
binary.Write(&out, binary.BigEndian, uint32(4096))
out.WriteByte(byte(len(serverPubBytes)))
out.Write(serverPubBytes)
out.Write(ciphertext)
return out.Bytes(), nil
}
// hkdf is HKDF-SHA256 for outputs of at most one hash block, which is all the sizes
// used here. Written out rather than imported because the full generalised form
// would be more code than this.
func hkdf(salt, ikm, info []byte, length int) []byte {
extract := hmac.New(sha256.New, salt)
extract.Write(ikm)
prk := extract.Sum(nil)
expand := hmac.New(sha256.New, prk)
expand.Write(info)
expand.Write([]byte{0x01})
return expand.Sum(nil)[:length]
}
// signJWT builds the VAPID authorisation token: an ES256 JWT whose audience is the
// push service's origin.
func signJWT(endpoint string, vapid VAPID) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
subject := vapid.Subject
if subject == "" || strings.HasSuffix(subject, "@localhost") {
// Firefox rejects a missing or obviously bogus subject. A generic mailto
// is better than being refused outright on an instance with no mail set up.
subject = "mailto:verdande@example.invalid"
}
header := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`))
claims, err := json.Marshal(map[string]any{
"aud": u.Scheme + "://" + u.Host,
// Twelve hours. The spec caps this at 24; shorter limits how long a
// captured token is worth anything.
"exp": time.Now().Add(12 * time.Hour).Unix(),
"sub": subject,
})
if err != nil {
return "", err
}
signingInput := header + "." + b64.EncodeToString(claims)
privBytes, err := b64.DecodeString(vapid.Private)
if err != nil {
return "", err
}
key := &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{Curve: elliptic.P256()},
D: new(big.Int).SetBytes(privBytes),
}
key.PublicKey.X, key.PublicKey.Y = elliptic.P256().ScalarBaseMult(privBytes)
sum := sha256.Sum256([]byte(signingInput))
r, sSig, err := ecdsa.Sign(rand.Reader, key, sum[:])
if err != nil {
return "", err
}
// JOSE wants the two halves as fixed-width 32-byte values, not the ASN.1
// encoding ecdsa.Sign's other form produces — a short R silently breaks
// verification on some services and not others.
signature := make([]byte, 64)
r.FillBytes(signature[:32])
sSig.FillBytes(signature[32:])
return signingInput + "." + b64.EncodeToString(signature), nil
}
+271
View File
@@ -0,0 +1,271 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
type Comment struct {
ID string
TaskID string
UserID string
UserName string
UserColor string
Body string
CreatedAt time.Time
UpdatedAt time.Time
Attachments []Attachment
}
type Attachment struct {
ID string
TaskID string
CommentID string
Filename string
MimeType string
Size int64
Path string
UploadedBy string
CreatedAt time.Time
}
func (db *DB) ListComments(ctx context.Context, taskID string) ([]Comment, error) {
rows, err := db.QueryContext(ctx, `
SELECT c.id, c.task_id, c.user_id, u.name, u.avatar_color, c.body,
c.created_at, c.updated_at
FROM comments c JOIN users u ON u.id = c.user_id
WHERE c.task_id = ? AND c.deleted_at IS NULL
ORDER BY c.created_at`, taskID)
if err != nil {
return nil, err
}
comments := []Comment{}
ids := []string{}
for rows.Next() {
var c Comment
var created, updated int64
if err := rows.Scan(&c.ID, &c.TaskID, &c.UserID, &c.UserName, &c.UserColor,
&c.Body, &created, &updated); err != nil {
rows.Close()
return nil, err
}
c.CreatedAt = time.Unix(created, 0).UTC()
c.UpdatedAt = time.Unix(updated, 0).UTC()
comments = append(comments, c)
ids = append(ids, c.ID)
}
err = rows.Err()
// Closed before the attachment lookup: an open result set holds its connection.
rows.Close()
if err != nil {
return nil, err
}
if len(ids) == 0 {
return comments, nil
}
byComment, err := db.attachmentsByComment(ctx, ids)
if err != nil {
return nil, err
}
for i := range comments {
comments[i].Attachments = byComment[comments[i].ID]
}
return comments, nil
}
func (db *DB) CreateComment(ctx context.Context, taskID, userID, body string) (*Comment, error) {
c := &Comment{ID: NewID(), TaskID: taskID, UserID: userID, Body: body}
now := time.Now().UTC()
c.CreatedAt, c.UpdatedAt = now, now
_, err := db.ExecContext(ctx,
`INSERT INTO comments (id, task_id, user_id, body, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
c.ID, taskID, userID, body, now.Unix(), now.Unix())
if err != nil {
return nil, err
}
return c, nil
}
// UpdateComment is scoped to the author. Editing somebody else's words is not
// something a project role should confer, however senior.
func (db *DB) UpdateComment(ctx context.Context, commentID, userID, body string) error {
res, err := db.ExecContext(ctx,
`UPDATE comments SET body = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
body, time.Now().Unix(), commentID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteComment allows the author, or the project's owner — who has to be able to
// remove something inappropriate from a project they are responsible for.
func (db *DB) DeleteComment(ctx context.Context, commentID, userID string, isProjectOwner bool) error {
query := `UPDATE comments SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`
args := []any{time.Now().Unix(), commentID}
if !isProjectOwner {
query += ` AND user_id = ?`
args = append(args, userID)
}
res, err := db.ExecContext(ctx, query, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CommentTask reports which task a comment belongs to, so a handler can check the
// project's permissions before touching it.
func (db *DB) CommentTask(ctx context.Context, commentID string) (string, error) {
var taskID string
err := db.QueryRowContext(ctx,
`SELECT task_id FROM comments WHERE id = ? AND deleted_at IS NULL`, commentID).Scan(&taskID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return taskID, err
}
// --- attachments -----------------------------------------------------------------
// CreateAttachment records a file that has already been written to disk. The row is
// written after the bytes, so a crash between the two leaves an orphan file rather
// than a row pointing at nothing — the first is invisible, the second is a broken
// download.
func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
if a.ID == "" {
a.ID = NewID()
}
a.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO attachments (id, task_id, comment_id, filename, mime_type, size,
path, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, nullString(a.TaskID), nullString(a.CommentID), a.Filename,
a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
return err
}
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAttachments(rows)
}
func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (map[string][]Attachment, error) {
args := make([]any, len(commentIDs))
for i, id := range commentIDs {
args[i] = id
}
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
ORDER BY created_at`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
list, err := scanAttachments(rows)
if err != nil {
return nil, err
}
out := map[string][]Attachment{}
for _, a := range list {
out[a.CommentID] = append(out[a.CommentID], a)
}
return out, nil
}
func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
out := []Attachment{}
for rows.Next() {
var a Attachment
var taskID, commentID sql.NullString
var created int64
if err := rows.Scan(&a.ID, &taskID, &commentID, &a.Filename, &a.MimeType,
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
return nil, err
}
a.TaskID = taskID.String
a.CommentID = commentID.String
a.CreatedAt = time.Unix(created, 0).UTC()
out = append(out, a)
}
return out, rows.Err()
}
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE id = ?`, attachmentID)
if err != nil {
return nil, err
}
defer rows.Close()
list, err := scanAttachments(rows)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, ErrNotFound
}
return &list[0], nil
}
// AttachmentTask resolves an attachment to the task whose project governs it —
// directly, or through the comment it hangs on.
func (db *DB) AttachmentTask(ctx context.Context, attachmentID string) (string, error) {
var taskID sql.NullString
err := db.QueryRowContext(ctx, `
SELECT COALESCE(a.task_id, c.task_id)
FROM attachments a
LEFT JOIN comments c ON c.id = a.comment_id
WHERE a.id = ?`, attachmentID).Scan(&taskID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if !taskID.Valid {
return "", ErrNotFound
}
return taskID.String, nil
}
// DeleteAttachment removes the row and returns the path, so the caller can delete
// the file. The row goes first: a file with no row is invisible, a row with no file
// is a download that fails.
func (db *DB) DeleteAttachment(ctx context.Context, attachmentID string) (string, error) {
a, err := db.GetAttachment(ctx, attachmentID)
if err != nil {
return "", err
}
if _, err := db.ExecContext(ctx, `DELETE FROM attachments WHERE id = ?`, attachmentID); err != nil {
return "", err
}
return a.Path, nil
}
@@ -0,0 +1,66 @@
-- Notifications, Web Push, and the mail-to-task address.
-- What a person needs to be told about: an assignment, a comment on something they
-- are in, an invite accepted. Stored rather than only pushed, so the bell icon has
-- a history and a notification survives the browser being shut.
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
actor_id TEXT REFERENCES users (id) ON DELETE SET NULL,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
read_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC);
CREATE INDEX idx_notifications_unread ON notifications (user_id) WHERE read_at IS NULL;
-- Web Push (RFC 8030) subscriptions. One row per browser per device: the same
-- person signed in on a laptop and a phone has two, and revoking one must not
-- silence the other.
CREATE TABLE push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used_at INTEGER,
-- A push service returns 404 or 410 when a subscription is dead. Counting the
-- failures lets a flaky network be forgiven while a genuinely gone endpoint is
-- eventually dropped.
failures INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_push_user ON push_subscriptions (user_id);
-- The VAPID keypair identifying this instance to push services. One row, ever.
-- Generated on first use rather than configured: it is not a secret an operator
-- should have to produce, and rotating it only costs re-subscribing.
CREATE TABLE instance_keys (
id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- The per-user address that turns an email into a task: todo+<token>@domain.
ALTER TABLE users ADD COLUMN mail_token TEXT;
CREATE UNIQUE INDEX idx_users_mail_token ON users (mail_token) WHERE mail_token IS NOT NULL;
-- Settings that belong to a person rather than to the instance: which AI provider
-- they use and with whose key, what their Gmail connection is. Kept as JSON for
-- the same reason templates are — written whole, read whole, and it would
-- otherwise be a migration every time an integration gains an option.
CREATE TABLE user_settings (
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'general',
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
+232
View File
@@ -0,0 +1,232 @@
package store
import (
"context"
"database/sql"
"time"
)
type Notification struct {
ID string
UserID string
ActorID string
ActorName string
ProjectID string
TaskID string
Kind string
Title string
Body string
ReadAt *time.Time
CreatedAt time.Time
}
func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
if n.ID == "" {
n.ID = NewID()
}
n.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind,
title, body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
return err
}
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
rows, err := db.QueryContext(ctx, `
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
n.kind, n.title, n.body, n.read_at, n.created_at
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = ?
ORDER BY n.created_at DESC
LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Notification{}
for rows.Next() {
var n Notification
var actorID, projectID, taskID sql.NullString
var readAt sql.NullInt64
var created int64
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
return nil, err
}
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String
if readAt.Valid {
v := time.Unix(readAt.Int64, 0).UTC()
n.ReadAt = &v
}
n.CreatedAt = time.Unix(created, 0).UTC()
out = append(out, n)
}
return out, rows.Err()
}
func (db *DB) UnreadNotificationCount(ctx context.Context, userID string) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM notifications WHERE user_id = ? AND read_at IS NULL`,
userID).Scan(&n)
return n, err
}
// MarkNotificationsRead marks one, or all of them when id is empty.
func (db *DB) MarkNotificationsRead(ctx context.Context, userID, id string) error {
query := `UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL`
args := []any{time.Now().Unix(), userID}
if id != "" {
query += ` AND id = ?`
args = append(args, id)
}
_, err := db.ExecContext(ctx, query, args...)
return err
}
// ProjectMemberIDs is everyone who can see a project, owner included — the audience
// for a notification about something that happened inside it.
func (db *DB) ProjectMemberIDs(ctx context.Context, projectID string) ([]string, error) {
rows, err := db.QueryContext(ctx, `
SELECT owner_id FROM projects WHERE id = ?
UNION
SELECT user_id FROM project_members WHERE project_id = ?`, projectID, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// --- Web Push subscriptions --------------------------------------------------------
type PushSubscription struct {
ID string
UserID string
Endpoint string
P256dh string
Auth string
UserAgent string
Failures int
}
// SavePushSubscription is an upsert on the endpoint: a browser that re-subscribes
// produces the same endpoint, and a second row would mean two identical
// notifications on one device.
func (db *DB) SavePushSubscription(ctx context.Context, sub *PushSubscription) error {
if sub.ID == "" {
sub.ID = NewID()
}
_, err := db.ExecContext(ctx,
`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint) DO UPDATE SET
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
failures = 0`,
sub.ID, sub.UserID, sub.Endpoint, sub.P256dh, sub.Auth, sub.UserAgent,
time.Now().Unix())
return err
}
func (db *DB) ListPushSubscriptions(ctx context.Context, userID string) ([]PushSubscription, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, endpoint, p256dh, auth, user_agent, failures
FROM push_subscriptions WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []PushSubscription{}
for rows.Next() {
var s PushSubscription
if err := rows.Scan(&s.ID, &s.UserID, &s.Endpoint, &s.P256dh, &s.Auth,
&s.UserAgent, &s.Failures); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error {
_, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
return err
}
// RecordPushFailure counts a delivery failure and drops the subscription once it
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
// but it also has bad days — forgiving a few failures keeps a flaky network from
// silently unsubscribing somebody's phone.
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
if permanent {
return db.DeletePushSubscription(ctx, endpoint)
}
_, err := db.ExecContext(ctx,
`UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint)
if err != nil {
return err
}
_, err = db.ExecContext(ctx,
`DELETE FROM push_subscriptions WHERE endpoint = ? AND failures >= 10`, endpoint)
return err
}
// --- VAPID keypair ------------------------------------------------------------------
// InstanceKeys returns the VAPID keypair, generating it on first use.
//
// Generated rather than configured: it identifies this instance to push services
// and is not something an operator should have to produce. Rotating it only costs
// every browser re-subscribing.
func (db *DB) InstanceKeys(ctx context.Context, generate func() (public, private string, err error)) (string, string, error) {
var public, private string
err := db.QueryRowContext(ctx,
`SELECT public_key, private_key FROM instance_keys LIMIT 1`).Scan(&public, &private)
if err == nil {
return public, private, nil
}
if err != sql.ErrNoRows {
return "", "", err
}
public, private, err = generate()
if err != nil {
return "", "", err
}
_, err = db.ExecContext(ctx,
`INSERT INTO instance_keys (id, public_key, private_key, created_at) VALUES (?, ?, ?, ?)`,
NewID(), public, private, time.Now().Unix())
if err != nil {
// Another request generated one first; take theirs so the two agree.
var existingPub, existingPriv string
if qErr := db.QueryRowContext(ctx,
`SELECT public_key, private_key FROM instance_keys LIMIT 1`).
Scan(&existingPub, &existingPriv); qErr == nil {
return existingPub, existingPriv, nil
}
return "", "", err
}
return public, private, nil
}
+180
View File
@@ -0,0 +1,180 @@
package todoist
import (
"sort"
"strings"
)
// Project is the shape both sides of the conversion work in: a project with
// sections and a tree of tasks. It is deliberately not verdande's store.Task —
// keeping the CSV layer ignorant of the database is what lets it be tested without
// one, and what makes the round trip a pure function.
type Project struct {
Name string
Sections []Section
Tasks []Task
}
type Section struct {
Name string
Tasks []Task
}
type Task struct {
Content string
Description string
Priority int // verdande's numbering: 1 highest, 4 none
Date string
Assignee string
Author string
Comments []string
Children []Task
}
// ToRows flattens a project into CSV rows.
//
// Order is the data here — a task's parent is whichever row above it has a smaller
// indent — so this walks the tree depth-first and never sorts.
func ToRows(p Project) []Row {
var rows []Row
appendTask := func(t Task, indent int) {}
appendTask = func(t Task, indent int) {
rows = append(rows, Row{
Type: TypeTask,
Content: t.Content,
Description: t.Description,
Priority: PriorityFromVerdande(t.Priority),
Indent: indent,
Author: t.Author,
Responsible: t.Assignee,
Date: t.Date,
DateLang: dateLang(t.Date),
Timezone: "",
})
// A comment belongs to the task above it, so notes go immediately after
// their task and before any of its children.
for _, note := range t.Comments {
rows = append(rows, Row{Type: TypeNote, Content: note})
}
for _, child := range t.Children {
// Todoist caps display nesting at four levels; deeper tasks are still
// written, flattened to the deepest level it will show.
next := indent + 1
if next > 4 {
next = 4
}
appendTask(child, next)
}
}
for _, t := range p.Tasks {
appendTask(t, 1)
}
for _, section := range p.Sections {
// Todoist separates sections with a blank row; its own exports have one
// and its importer is happier with it.
rows = append(rows, Row{})
rows = append(rows, Row{Type: TypeSection, Content: section.Name})
for _, t := range section.Tasks {
appendTask(t, 1)
}
}
return rows
}
// FromRows rebuilds the project from CSV rows.
//
// The indent stack is the whole algorithm: each task attaches to the last task seen
// at one level shallower, and a task at indent 1 starts a new top-level branch.
func FromRows(rows []Row, projectName string) Project {
p := Project{Name: projectName}
// Pointers into the tree at each depth, so a child can be appended to whoever
// is currently open one level up.
var stack []*Task
var currentSection *Section
// The most recently added task, which is what a note attaches to.
var lastTask *Task
addTask := func(t Task, indent int) *Task {
if indent < 1 {
indent = 1
}
if indent > len(stack)+1 {
// A file that jumps from indent 1 to indent 3 — possible after
// hand-editing. Treat it as one level deeper than what is open,
// rather than dropping the task.
indent = len(stack) + 1
}
stack = stack[:indent-1]
if indent == 1 {
if currentSection != nil {
currentSection.Tasks = append(currentSection.Tasks, t)
added := &currentSection.Tasks[len(currentSection.Tasks)-1]
stack = append(stack, added)
return added
}
p.Tasks = append(p.Tasks, t)
added := &p.Tasks[len(p.Tasks)-1]
stack = append(stack, added)
return added
}
parent := stack[indent-2]
parent.Children = append(parent.Children, t)
added := &parent.Children[len(parent.Children)-1]
stack = append(stack, added)
return added
}
for _, row := range rows {
switch row.Type {
case TypeSection:
p.Sections = append(p.Sections, Section{Name: row.Content})
currentSection = &p.Sections[len(p.Sections)-1]
// A new section resets the tree: nothing above it can be a parent of
// anything below.
stack = nil
lastTask = nil
case TypeTask:
if strings.TrimSpace(row.Content) == "" {
continue
}
lastTask = addTask(Task{
Content: row.Content,
Description: row.Description,
Priority: PriorityToVerdande(row.Priority),
Date: row.Date,
Assignee: row.Responsible,
Author: row.Author,
}, row.Indent)
case TypeNote:
if lastTask != nil && strings.TrimSpace(row.Content) != "" {
lastTask.Comments = append(lastTask.Comments, row.Content)
}
}
}
return p
}
// dateLang is the language Todoist should parse the DATE string in. Only set when
// there is a date to parse; an empty column with a language beside it confuses its
// importer.
func dateLang(date string) string {
if strings.TrimSpace(date) == "" {
return ""
}
return "en"
}
// SortSections puts sections in a stable order for export, so two exports of an
// unchanged project produce identical bytes.
func SortSections(p *Project) {
sort.SliceStable(p.Sections, func(i, j int) bool { return p.Sections[i].Name < p.Sections[j].Name })
}
+281
View File
@@ -0,0 +1,281 @@
// Package todoist reads and writes Todoist's own CSV template format.
//
// The format is what Todoist's "export as template" produces and what its "import
// from template" accepts, so a file written here opens there and a file exported
// from there opens here. That is the whole point: leaving a hosted product should
// not mean leaving the data behind, and it should not be a one-way door either.
//
// The shape of a Todoist CSV is unusual and worth stating, because it explains most
// of this file. Every row has the same eight columns, but the TYPE column decides
// what a row *is*:
//
// task — a task; INDENT gives its depth, so 2 is a sub-task of the task above
// section — a section heading; only CONTENT is meaningful
// note — a comment on the task above it
// "" — a blank spacer row, which Todoist emits between sections
//
// Nesting is therefore positional: a row's parent is the nearest preceding row with
// a smaller indent. Reordering the rows changes the tree, which is why the writer
// walks the tree in order rather than sorting by anything.
package todoist
import (
"encoding/csv"
"fmt"
"io"
"strconv"
"strings"
"time"
)
// Columns, in Todoist's order. The header has to match exactly or Todoist refuses
// the file.
var header = []string{
"TYPE", "CONTENT", "DESCRIPTION", "PRIORITY", "INDENT", "AUTHOR", "RESPONSIBLE", "DATE", "DATE_LANG", "TIMEZONE",
}
type Row struct {
Type string
Content string
Description string
Priority int // Todoist's own numbering: 4 is highest, 1 is none
Indent int
Author string
Responsible string
Date string
DateLang string
Timezone string
}
const (
TypeTask = "task"
TypeSection = "section"
TypeNote = "note"
)
// Parse reads a Todoist CSV.
//
// It is lenient about what it accepts and strict about what it produces: a file
// that has been through a spreadsheet — reordered columns, a BOM, semicolons
// instead of commas, missing trailing fields — still reads, because that is what
// actually arrives when somebody exports and then opens the file "just to look".
func Parse(r io.Reader) ([]Row, error) {
reader := csv.NewReader(newBOMStripper(r))
// Rows genuinely vary in length once a spreadsheet has touched them.
reader.FieldsPerRecord = -1
reader.LazyQuotes = true
records, err := reader.ReadAll()
if err != nil {
// Retry with semicolons: that is what Excel writes in a Danish locale, and
// it is the single most common reason a re-saved export will not read.
if semi, semiErr := readSemicolon(r); semiErr == nil {
records = semi
} else {
return nil, fmt.Errorf("todoist: read csv: %w", err)
}
}
if len(records) == 0 {
return nil, fmt.Errorf("todoist: the file is empty")
}
index, err := mapColumns(records[0])
if err != nil {
return nil, err
}
var rows []Row
for _, record := range records[1:] {
get := func(name string) string {
i, ok := index[name]
if !ok || i >= len(record) {
return ""
}
return strings.TrimSpace(record[i])
}
row := Row{
Type: strings.ToLower(get("TYPE")),
Content: get("CONTENT"),
Description: get("DESCRIPTION"),
Author: get("AUTHOR"),
Responsible: get("RESPONSIBLE"),
Date: get("DATE"),
DateLang: get("DATE_LANG"),
Timezone: get("TIMEZONE"),
}
row.Priority, _ = strconv.Atoi(get("PRIORITY"))
row.Indent, _ = strconv.Atoi(get("INDENT"))
// Blank spacer rows carry nothing; keeping them would produce empty tasks.
if row.Type == "" && row.Content == "" {
continue
}
rows = append(rows, row)
}
return rows, nil
}
func readSemicolon(r io.Reader) ([][]string, error) {
seeker, ok := r.(io.Seeker)
if !ok {
return nil, fmt.Errorf("cannot re-read the input")
}
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
return nil, err
}
reader := csv.NewReader(newBOMStripper(r))
reader.Comma = ';'
reader.FieldsPerRecord = -1
reader.LazyQuotes = true
return reader.ReadAll()
}
// mapColumns finds each column by name rather than by position, so a file whose
// columns have been reordered still reads.
func mapColumns(headerRow []string) (map[string]int, error) {
index := map[string]int{}
for i, name := range headerRow {
index[strings.ToUpper(strings.TrimSpace(strings.Trim(name, `"`)))] = i
}
if _, ok := index["CONTENT"]; !ok {
return nil, fmt.Errorf("todoist: this does not look like a Todoist export — there is no CONTENT column")
}
return index, nil
}
// Write emits a Todoist-compatible CSV.
func Write(w io.Writer, rows []Row) error {
writer := csv.NewWriter(w)
if err := writer.Write(header); err != nil {
return err
}
for _, row := range rows {
record := []string{
row.Type, row.Content, row.Description,
// Todoist writes an empty priority for a row that is not a task, and
// so must this: a "4" on a section makes its own importer complain.
priorityField(row), indentField(row),
row.Author, row.Responsible, row.Date, row.DateLang, row.Timezone,
}
if err := writer.Write(record); err != nil {
return err
}
}
writer.Flush()
return writer.Error()
}
func priorityField(row Row) string {
if row.Type != TypeTask || row.Priority == 0 {
return ""
}
return strconv.Itoa(row.Priority)
}
func indentField(row Row) string {
if row.Type != TypeTask {
return ""
}
if row.Indent == 0 {
return "1"
}
return strconv.Itoa(row.Indent)
}
// --- priority ------------------------------------------------------------------
// Todoist numbers priorities the opposite way round to how it displays them: the
// CSV says 4 for what the interface calls P1. verdande stores what the interface
// means, so the two have to be converted rather than copied — and getting this
// backwards would silently invert the urgency of every imported task.
func PriorityToVerdande(todoist int) int {
switch todoist {
case 4:
return 1
case 3:
return 2
case 2:
return 3
default:
return 4
}
}
func PriorityFromVerdande(verdande int) int {
switch verdande {
case 1:
return 4
case 2:
return 3
case 3:
return 2
default:
return 1
}
}
// --- dates ---------------------------------------------------------------------
// ParseDate reads the DATE column, which Todoist fills with whatever the person
// typed — "tomorrow", "every Monday", "2026-03-15", "15 Mar" — because it stores
// the natural-language string rather than a date.
//
// Only unambiguous machine formats are converted here. Anything else is handed back
// as text for the caller's own natural-language parser, which already understands
// far more of it and does so in the user's timezone.
func ParseDate(value string) (date string, remaining string, ok bool) {
value = strings.TrimSpace(value)
if value == "" {
return "", "", false
}
for _, layout := range []string{
"2006-01-02", "2006-01-02 15:04", "2006-01-02T15:04:05Z07:00",
"02-01-2006", "02/01/2006", "Jan 2 2006", "2 Jan 2006",
} {
if t, err := time.Parse(layout, value); err == nil {
return t.Format("2006-01-02"), "", true
}
}
return "", value, false
}
// --- BOM ------------------------------------------------------------------------
// newBOMStripper drops a UTF-8 byte order mark. Excel writes one, and left in place
// it becomes part of the first column's name — so "TYPE" is not found and the file
// reads as having no recognisable columns at all.
func newBOMStripper(r io.Reader) io.Reader {
return &bomStripper{r: r}
}
type bomStripper struct {
r io.Reader
checked bool
buf []byte
}
func (b *bomStripper) Read(p []byte) (int, error) {
if !b.checked {
b.checked = true
head := make([]byte, 3)
n, err := io.ReadFull(b.r, head)
if n > 0 {
head = head[:n]
if n == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF {
head = nil
}
b.buf = head
}
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return 0, err
}
}
if len(b.buf) > 0 {
n := copy(p, b.buf)
b.buf = b.buf[n:]
return n, nil
}
return b.r.Read(p)
}
+436
View File
@@ -0,0 +1,436 @@
package todoist
import (
"bytes"
"strings"
"testing"
)
// A real Todoist template export, with the shapes that matter: a task tree, a
// section, notes, priorities and dates.
const sampleCSV = `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE
task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en,
note,Bilag ligger i Dropbox,,,,,,,,
task,Find bilag,,3,2,Kristian,,,,
task,Scan dem,,1,3,Kristian,,,,
task,Ring til revisor,,1,1,Kristian,Anders,,,
,,,,,,,,,
section,Til gennemsyn,,,,,,,,
task,Gennemgå kvartal,,2,1,Kristian,,,,
task,Underopgave,,1,2,Kristian,,,,
`
func TestParseReadsTheShape(t *testing.T) {
rows, err := Parse(strings.NewReader(sampleCSV))
if err != nil {
t.Fatalf("Parse: %v", err)
}
// The blank spacer row is dropped; everything else survives.
var tasks, sections, notes int
for _, r := range rows {
switch r.Type {
case TypeTask:
tasks++
case TypeSection:
sections++
case TypeNote:
notes++
}
}
if tasks != 6 {
t.Errorf("got %d tasks, want 6", tasks)
}
if sections != 1 {
t.Errorf("got %d sections, want 1", sections)
}
if notes != 1 {
t.Errorf("got %d notes, want 1", notes)
}
}
// Todoist's CSV numbers priorities the opposite way to how its interface shows
// them. Getting this backwards silently inverts the urgency of every task.
func TestPriorityIsInverted(t *testing.T) {
cases := map[int]int{4: 1, 3: 2, 2: 3, 1: 4, 0: 4}
for todoist, want := range cases {
if got := PriorityToVerdande(todoist); got != want {
t.Errorf("PriorityToVerdande(%d) = %d, want %d", todoist, got, want)
}
}
for verdande, want := range map[int]int{1: 4, 2: 3, 3: 2, 4: 1} {
if got := PriorityFromVerdande(verdande); got != want {
t.Errorf("PriorityFromVerdande(%d) = %d, want %d", verdande, got, want)
}
}
// And the two are inverses, which is what the round trip depends on.
for v := 1; v <= 4; v++ {
if got := PriorityToVerdande(PriorityFromVerdande(v)); got != v {
t.Errorf("priority %d survived a round trip as %d", v, got)
}
}
}
func TestFromRowsBuildsTheTree(t *testing.T) {
rows, err := Parse(strings.NewReader(sampleCSV))
if err != nil {
t.Fatal(err)
}
p := FromRows(rows, "Firma")
if p.Name != "Firma" {
t.Errorf("Name = %q", p.Name)
}
if len(p.Tasks) != 2 {
t.Fatalf("got %d top-level tasks, want 2", len(p.Tasks))
}
moms := p.Tasks[0]
if moms.Content != "Betal moms" {
t.Fatalf("first task = %q", moms.Content)
}
if moms.Priority != 1 {
t.Errorf("priority = %d, want 1 (Todoist's 4)", moms.Priority)
}
if moms.Date != "2026-03-15" {
t.Errorf("date = %q", moms.Date)
}
if len(moms.Comments) != 1 || moms.Comments[0] != "Bilag ligger i Dropbox" {
t.Errorf("comments = %v", moms.Comments)
}
// Indent 2 is a child of the indent-1 task above it; indent 3 is a child of that.
if len(moms.Children) != 1 {
t.Fatalf("got %d children, want 1", len(moms.Children))
}
if moms.Children[0].Content != "Find bilag" {
t.Errorf("child = %q", moms.Children[0].Content)
}
if len(moms.Children[0].Children) != 1 {
t.Fatalf("got %d grandchildren, want 1", len(moms.Children[0].Children))
}
if moms.Children[0].Children[0].Content != "Scan dem" {
t.Errorf("grandchild = %q", moms.Children[0].Children[0].Content)
}
// The assignee survives.
if p.Tasks[1].Assignee != "Anders" {
t.Errorf("assignee = %q, want Anders", p.Tasks[1].Assignee)
}
// A section resets the tree, and its own tasks nest inside it.
if len(p.Sections) != 1 {
t.Fatalf("got %d sections", len(p.Sections))
}
section := p.Sections[0]
if section.Name != "Til gennemsyn" {
t.Errorf("section name = %q", section.Name)
}
if len(section.Tasks) != 1 {
t.Fatalf("the section has %d top-level tasks, want 1", len(section.Tasks))
}
if len(section.Tasks[0].Children) != 1 {
t.Errorf("the section's task has %d children, want 1", len(section.Tasks[0].Children))
}
}
// The requirement from the brief, stated directly: a Todoist CSV in, and out again,
// has to be the same thing. This is the test that stops an import from quietly
// losing a sub-task, a comment or a priority.
func TestRoundTripIsLossless(t *testing.T) {
rows, err := Parse(strings.NewReader(sampleCSV))
if err != nil {
t.Fatal(err)
}
project := FromRows(rows, "Firma")
var out bytes.Buffer
if err := Write(&out, ToRows(project)); err != nil {
t.Fatalf("Write: %v", err)
}
// Read what was written and rebuild. The two projects must be identical.
reparsed, err := Parse(bytes.NewReader(out.Bytes()))
if err != nil {
t.Fatalf("re-parsing our own output: %v", err)
}
again := FromRows(reparsed, "Firma")
assertSameProject(t, project, again)
}
// And a second pass must produce byte-identical output, or "export" is not a
// repeatable operation and diffing two exports is meaningless.
func TestExportIsStable(t *testing.T) {
rows, err := Parse(strings.NewReader(sampleCSV))
if err != nil {
t.Fatal(err)
}
project := FromRows(rows, "Firma")
var first, second bytes.Buffer
if err := Write(&first, ToRows(project)); err != nil {
t.Fatal(err)
}
reparsed, err := Parse(bytes.NewReader(first.Bytes()))
if err != nil {
t.Fatal(err)
}
if err := Write(&second, ToRows(FromRows(reparsed, "Firma"))); err != nil {
t.Fatal(err)
}
if first.String() != second.String() {
t.Errorf("two exports of the same project differ:\n--- first ---\n%s\n--- second ---\n%s",
first.String(), second.String())
}
}
// What is written has to be readable by Todoist, which means the exact header and
// no priority or indent on rows that are not tasks.
func TestWrittenFileMatchesTodoistsFormat(t *testing.T) {
var out bytes.Buffer
err := Write(&out, ToRows(Project{
Name: "Firma",
Tasks: []Task{{Content: "En opgave", Priority: 1}},
Sections: []Section{{
Name: "En sektion",
Tasks: []Task{{Content: "I sektionen", Priority: 4, Comments: []string{"en note"}}},
}},
}))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
want := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE"
if strings.TrimSpace(lines[0]) != want {
t.Errorf("header:\n got %q\nwant %q", lines[0], want)
}
for _, line := range lines[1:] {
fields := strings.Split(line, ",")
if len(fields) < 5 {
continue
}
// Only task rows carry a priority and an indent.
if fields[0] != TypeTask && (fields[3] != "" || fields[4] != "") {
t.Errorf("a %q row carries a priority or indent: %q", fields[0], line)
}
if fields[0] == TypeTask && fields[4] == "" {
t.Errorf("a task row has no indent: %q", line)
}
}
}
// Files arrive having been opened in a spreadsheet, which is where BOMs, semicolons
// and reordered columns come from. Refusing those means refusing most real exports.
func TestParseSurvivesRealWorldFiles(t *testing.T) {
t.Run("utf-8 BOM", func(t *testing.T) {
withBOM := "\xEF\xBB\xBF" + sampleCSV
rows, err := Parse(strings.NewReader(withBOM))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(rows) == 0 {
t.Fatal("no rows were read")
}
if rows[0].Content != "Betal moms" {
t.Errorf("first row = %+v; the BOM was probably left in the header", rows[0])
}
})
t.Run("reordered columns", func(t *testing.T) {
reordered := "CONTENT,TYPE,INDENT,PRIORITY,DESCRIPTION,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
"Betal moms,task,1,4,,Kristian,,2026-03-15,en,\n"
rows, err := Parse(strings.NewReader(reordered))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(rows) != 1 {
t.Fatalf("got %d rows", len(rows))
}
if rows[0].Content != "Betal moms" || rows[0].Type != TypeTask || rows[0].Priority != 4 {
t.Errorf("columns were read by position, not by name: %+v", rows[0])
}
})
t.Run("short rows", func(t *testing.T) {
short := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT\ntask,Kort række\n"
rows, err := Parse(strings.NewReader(short))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(rows) != 1 || rows[0].Content != "Kort række" {
t.Errorf("rows = %+v", rows)
}
})
t.Run("danish characters and commas in content", func(t *testing.T) {
var out bytes.Buffer
original := Project{Tasks: []Task{
{Content: `Køb mælk, brød og smør`, Description: "på vej hjem", Priority: 2},
}}
if err := Write(&out, ToRows(original)); err != nil {
t.Fatal(err)
}
rows, err := Parse(bytes.NewReader(out.Bytes()))
if err != nil {
t.Fatal(err)
}
back := FromRows(rows, "")
if back.Tasks[0].Content != `Køb mælk, brød og smør` {
t.Errorf("content = %q; the comma was probably not quoted", back.Tasks[0].Content)
}
})
}
func TestParseRejectsWhatIsNotAnExport(t *testing.T) {
for _, in := range []string{
"",
"navn;pris\nkaffe;40\n",
"just some text\n",
} {
t.Run(in, func(t *testing.T) {
if _, err := Parse(strings.NewReader(in)); err == nil {
t.Error("a file that is not a Todoist export was accepted")
}
})
}
}
// A hand-edited file can jump from indent 1 to indent 3. Dropping those tasks would
// lose work silently; attaching them one level deeper than what is open does not.
func TestBrokenIndentationDoesNotLoseTasks(t *testing.T) {
broken := `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE
task,Forælder,,1,1,,,,,
task,Springer til tre,,1,3,,,,,
task,Tilbage til en,,1,1,,,,,
`
rows, err := Parse(strings.NewReader(broken))
if err != nil {
t.Fatal(err)
}
p := FromRows(rows, "x")
if got := countTasks(p); got != 3 {
t.Errorf("%d tasks survived, want all 3", got)
}
if len(p.Tasks) != 2 {
t.Errorf("got %d top-level tasks, want 2", len(p.Tasks))
}
}
func TestParseDate(t *testing.T) {
cases := []struct {
in string
wantDate string
wantOK bool
}{
{"2026-03-15", "2026-03-15", true},
{"2026-03-15 10:00", "2026-03-15", true},
{"15-03-2026", "2026-03-15", true},
{"15/03/2026", "2026-03-15", true},
// Todoist stores what was typed, so most dates are natural language and
// belong to the caller's own parser.
{"every Monday", "", false},
{"tomorrow", "", false},
{"i morgen", "", false},
{"", "", false},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
date, remaining, ok := ParseDate(tc.in)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if date != tc.wantDate {
t.Errorf("date = %q, want %q", date, tc.wantDate)
}
if !ok && tc.in != "" && remaining != tc.in {
t.Errorf("remaining = %q, want the original text back", remaining)
}
})
}
}
// --- helpers ---------------------------------------------------------------------
func assertSameProject(t *testing.T, want, got Project) {
t.Helper()
if len(want.Tasks) != len(got.Tasks) {
t.Fatalf("top-level tasks: %d vs %d", len(want.Tasks), len(got.Tasks))
}
for i := range want.Tasks {
assertSameTask(t, "task["+want.Tasks[i].Content+"]", want.Tasks[i], got.Tasks[i])
}
if len(want.Sections) != len(got.Sections) {
t.Fatalf("sections: %d vs %d", len(want.Sections), len(got.Sections))
}
for i := range want.Sections {
if want.Sections[i].Name != got.Sections[i].Name {
t.Errorf("section name: %q vs %q", want.Sections[i].Name, got.Sections[i].Name)
}
if len(want.Sections[i].Tasks) != len(got.Sections[i].Tasks) {
t.Fatalf("section %q tasks: %d vs %d", want.Sections[i].Name,
len(want.Sections[i].Tasks), len(got.Sections[i].Tasks))
}
for j := range want.Sections[i].Tasks {
assertSameTask(t, "section "+want.Sections[i].Name,
want.Sections[i].Tasks[j], got.Sections[i].Tasks[j])
}
}
}
func assertSameTask(t *testing.T, where string, want, got Task) {
t.Helper()
if want.Content != got.Content {
t.Errorf("%s content: %q vs %q", where, want.Content, got.Content)
}
if want.Description != got.Description {
t.Errorf("%s description: %q vs %q", where, want.Description, got.Description)
}
if want.Priority != got.Priority {
t.Errorf("%s priority: %d vs %d", where, want.Priority, got.Priority)
}
if want.Date != got.Date {
t.Errorf("%s date: %q vs %q", where, want.Date, got.Date)
}
if want.Assignee != got.Assignee {
t.Errorf("%s assignee: %q vs %q", where, want.Assignee, got.Assignee)
}
if len(want.Comments) != len(got.Comments) {
t.Errorf("%s comments: %v vs %v", where, want.Comments, got.Comments)
} else {
for i := range want.Comments {
if want.Comments[i] != got.Comments[i] {
t.Errorf("%s comment %d: %q vs %q", where, i, want.Comments[i], got.Comments[i])
}
}
}
if len(want.Children) != len(got.Children) {
t.Fatalf("%s children: %d vs %d", where, len(want.Children), len(got.Children))
}
for i := range want.Children {
assertSameTask(t, where+" > "+want.Children[i].Content, want.Children[i], got.Children[i])
}
}
func countTasks(p Project) int {
var count func([]Task) int
count = func(tasks []Task) int {
n := 0
for _, t := range tasks {
n += 1 + count(t.Children)
}
return n
}
total := count(p.Tasks)
for _, s := range p.Sections {
total += count(s.Tasks)
}
return total
}