diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index fbc93ff..c55c14d 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -848,6 +848,20 @@ paths:
project_id:
type: string
description: Where it goes when the text names no project.
+ section_id:
+ type: string
+ description: >
+ The section the box was opened in. A "/section" in the text
+ wins, because typing it is a choice and standing there is a
+ circumstance.
+ parent_id:
+ type: string
+ description: >
+ Makes it a sub-task. A sub-task lives in its parent's
+ project, so a "#project" on the same line is not honoured —
+ it comes back as `unknown_project` rather than being
+ silently dropped. Dates, labels and priority are read the
+ same way as for any other line.
responses:
"201":
description: Created
@@ -3143,6 +3157,40 @@ paths:
to: { type: string, format: date }
"422": { $ref: "#/components/responses/Validation" }
+ /api/v1/export/projects.zip:
+ get:
+ tags: [projects]
+ summary: Every project's tasks as a CSV, in a zip.
+ description: >
+ One CSV per project, in the column layout Todoist writes — so the files
+ import into Todoist and into anything else that reads that shape. A zip
+ rather than one wide file because that is the shape on the other side,
+ and because a single project can be lifted out of it without opening a
+ spreadsheet. The per-project link does the same for one project; this is
+ the whole account in one download.
+ responses:
+ "200":
+ description: A zip archive.
+ content:
+ application/zip:
+ schema: { type: string, format: binary }
+
+ /api/v1/export/tasks.ics:
+ get:
+ tags: [projects]
+ summary: Every dated task as a calendar of VTODOs.
+ description: >
+ One file, not a zip: a calendar is made to hold many things, and each
+ task carries its project with it, so nothing is lost by putting them
+ together. Tasks without a due date are left out — a VTODO with no date
+ is a line most readers drop on the floor.
+ responses:
+ "200":
+ description: An iCalendar file.
+ content:
+ text/calendar:
+ schema: { type: string }
+
/api/v1/export/notes.zip:
get:
tags: [notes]
diff --git a/internal/httpapi/import_handlers.go b/internal/httpapi/import_handlers.go
index e461fc9..7528042 100644
--- a/internal/httpapi/import_handlers.go
+++ b/internal/httpapi/import_handlers.go
@@ -1,6 +1,7 @@
package httpapi
import (
+ "archive/zip"
"encoding/json"
"fmt"
"net/http"
@@ -272,6 +273,111 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
}
}
+// handleExportAllCSV is every project at once, one CSV per project in a zip.
+//
+// A zip rather than one wide CSV with a project column: Todoist's own export is a
+// file per project, so that is the shape an importer on the other side expects —
+// and the shape somebody can pick a single project out of without opening a
+// spreadsheet. Same reasoning as the notes export, which is a folder of Markdown
+// rather than one long file.
+//
+// It exists because the per-project links are per project: fifteen projects meant
+// fifteen downloads to move your own tasks somewhere else, which is an export that
+// technically works and nobody uses.
+func (s *Server) handleExportAllCSV(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+
+ projects, err := s.db.ListProjects(r.Context(), user.ID, true)
+ if err != nil {
+ s.internal(w, r, "export projects", err)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/zip")
+ w.Header().Set("Content-Disposition",
+ `attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.zip"`)
+
+ zw := zip.NewWriter(w)
+ defer zw.Close()
+
+ // Two projects can share a name — they are still two projects, and a zip with
+ // one entry written twice loses one of them. Same guard as the notes export.
+ used := map[string]int{}
+ for _, project := range projects {
+ exported, err := s.buildExport(r, user, &project)
+ if err != nil {
+ // The response is already streaming, so there is no status left to send.
+ // Stopping leaves a truncated zip, which a zip tool reports as truncated —
+ // better than a complete-looking file with a project missing from it.
+ s.log.Error("export projects", "err", err, "project", project.ID)
+ return
+ }
+
+ name := strings.TrimSuffix(ics.Filename(project.Name), ".ics")
+ used[name]++
+ if used[name] > 1 {
+ name = fmt.Sprintf("%s (%d)", name, used[name])
+ }
+
+ f, err := zw.CreateHeader(&zip.FileHeader{Name: name + ".csv", Method: zip.Deflate})
+ if err != nil {
+ s.log.Error("export projects", "err", err, "project", project.ID)
+ return
+ }
+ if err := todoist.Write(f, todoist.ToRows(exported)); err != nil {
+ s.log.Error("export projects", "err", err, "project", project.ID)
+ return
+ }
+ }
+}
+
+// handleExportAllICS is every dated task in one calendar.
+//
+// One file and not a zip, unlike the CSV above, because a calendar is made to hold
+// many things and every reader takes a file with hundreds of VTODOs. The project
+// travels with each task as its own field, so nothing is lost by merging them.
+func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ ctx := r.Context()
+
+ projects, err := s.db.ListProjects(ctx, user.ID, true)
+ if err != nil {
+ s.internal(w, r, "export ics", err)
+ return
+ }
+ names := map[string]string{}
+ for _, p := range projects {
+ names[p.ID] = p.Name
+ }
+
+ tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
+ IncludeCompleted: true, Limit: 5000,
+ })
+ if err != nil {
+ s.internal(w, r, "export ics", err)
+ return
+ }
+
+ cal := ics.Calendar{Name: "Verdande", 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: names[t.ProjectID], 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="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.ics"`)
+ w.Write([]byte(ics.Render(cal)))
+}
+
// 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) {
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 6a164a2..579b588 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -456,6 +456,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
+ r.Get("/export/projects.zip", s.handleExportAllCSV)
+ r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go
index 872218b..abe55e6 100644
--- a/internal/httpapi/tasks_handlers.go
+++ b/internal/httpapi/tasks_handlers.go
@@ -501,6 +501,18 @@ type quickAddRequest struct {
// the foot of one. Same rule as the project: a "/section" in the text wins,
// because typing it is a choice and standing there is a circumstance.
SectionID string `json:"section_id,omitempty"`
+
+ // ParentID makes the new task a sub-task of an existing one. It is what a
+ // pasted list uses for its indented lines, and what the sub-task field at the
+ // foot of a task uses — both want the same parsing of dates, #project and
+ // @labels that a top-level task gets, so they come through here rather than
+ // through a second, dumber path.
+ //
+ // A sub-task lives in its parent's project. Saying otherwise would describe a
+ // task whose parent is somewhere else, which the list views cannot draw and
+ // nobody means; a "#project" on an indented line is therefore not honoured,
+ // and the caller is told so the word does not just vanish.
+ ParentID string `json:"parent_id,omitempty"`
}
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
@@ -517,6 +529,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
return
}
+ // A parent decides the project before anything else is read: a sub-task
+ // somewhere other than its parent is not a shape this program has.
+ parent := (*store.Task)(nil)
+ if req.ParentID != "" {
+ found, err := s.db.GetTask(r.Context(), req.ParentID, user.ID)
+ if err != nil {
+ writeError(w, http.StatusNotFound, CodeNotFound, "not found")
+ return
+ }
+ parent = found
+ }
+
projectID := req.ProjectID
// Set when the text named a project that does not exist. The task is still
// created — see below — but the caller is told, because the alternative is
@@ -532,6 +556,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
unknownProject = parsed.Project
}
}
+ // The parent has the last word, and the word it overrules is reported the same
+ // way an unknown project is — the task is created either way.
+ if parent != nil {
+ if parsed.Project != "" && projectID != parent.ProjectID {
+ unknownProject = parsed.Project
+ }
+ projectID = parent.ProjectID
+ }
if projectID == "" {
var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
@@ -570,6 +602,9 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence,
}
+ if parent != nil {
+ t.ParentID = parent.ID
+ }
if parsed.DueDate != "" {
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
if err == nil {
diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go
index 830e403..8ceba18 100644
--- a/internal/httpapi/tasks_test.go
+++ b/internal/httpapi/tasks_test.go
@@ -1,6 +1,7 @@
package httpapi
import (
+ "archive/zip"
"bytes"
"encoding/json"
"io"
@@ -1562,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
}
// The whole point of import and export together: what goes in comes back out.
+
+// TestQuickAddSubTask covers the parent a pasted list's indented lines use.
+//
+// The line still goes through the same parser as a top-level one — a sub-task
+// with a date is an ordinary thing to paste, and a second, dumber creation path
+// for indented lines would be a second place for "i morgen" to mean something
+// different.
+func TestQuickAddSubTask(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ _, parent := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
+ "text": "Flytte garagen",
+ })
+ parentID, _ := parent["id"].(string)
+ if parentID == "" {
+ t.Fatalf("no parent: %v", parent)
+ }
+
+ _, child := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
+ "text": "Rydde hylderne p1", "parent_id": parentID,
+ })
+ if got := child["parent_id"]; got != parentID {
+ t.Errorf("parent_id = %v, want %v", got, parentID)
+ }
+ // The line was parsed, not stored raw: the priority marker is gone from the
+ // text and present as a priority.
+ if got := child["content"]; got != "Rydde hylderne" {
+ t.Errorf("content = %v, want the line without its marker", got)
+ }
+ if got := child["priority"]; got != float64(1) {
+ t.Errorf("priority = %v, want 1", got)
+ }
+
+ t.Run("a sub-task cannot be put in another project", func(t *testing.T) {
+ _, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsteds"})
+ otherID, _ := other["id"].(string)
+
+ _, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
+ "text": "Hente nøglen #Andetsteds", "parent_id": parentID,
+ })
+ if got := moved["project_id"]; got == otherID {
+ t.Errorf("the sub-task left its parent's project")
+ }
+ // And it is said out loud rather than the word just disappearing from the
+ // title — the failure this reports is exactly the one that used to be silent.
+ if got := moved["unknown_project"]; got != "Andetsteds" {
+ t.Errorf("unknown_project = %v, want the name that could not be honoured", got)
+ }
+ })
+}
+
+// raw fetches a download — a zip or a calendar — as bytes.
+//
+// `do` decodes JSON, which these two are not: it would report an empty body and a
+// parse error where the interesting thing is the bytes themselves.
+func (ts *testServer) raw(t *testing.T, path string) ([]byte, string) {
+ t.Helper()
+ req, _ := http.NewRequest("GET", ts.URL+path, 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("%s: status %d", path, resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return body, resp.Header.Get("Content-Type")
+}
+
+// TestExportEverythingAtOnce covers the two whole-account exports.
+//
+// The per-project links worked and were unusable at any size: fifteen projects
+// meant fifteen downloads to move your own tasks somewhere else. These two are
+// the same data without the fifteen clicks.
+//
+// Asserted on the CONTENTS rather than on the status: a zip with no entries and a
+// calendar with no VTODOs are both 200 with a plausible Content-Type, and that is
+// exactly the shape an export bug takes.
+func TestExportEverythingAtOnce(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ // Two projects, so the zip has to hold more than the one that happens to be
+ // first — a loop that exports only the last project passes a one-project test.
+ _, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
+ _, second := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
+ firstID, _ := first["id"].(string)
+ secondID, _ := second["id"].(string)
+
+ ts.do(t, "POST", "/api/v1/tasks", map[string]any{
+ "content": "Betal moms", "project_id": firstID, "due_date": "2026-11-03",
+ })
+ ts.do(t, "POST", "/api/v1/tasks", map[string]any{
+ "content": "Skifte daek", "project_id": secondID,
+ })
+
+ t.Run("csv zip holds one file per project", func(t *testing.T) {
+ body, ct := ts.raw(t, "/api/v1/export/projects.zip")
+ if ct != "application/zip" {
+ t.Fatalf("content-type %q", ct)
+ }
+ zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
+ if err != nil {
+ t.Fatalf("not a zip: %v", err)
+ }
+
+ found := map[string]string{}
+ for _, f := range zr.File {
+ rc, err := f.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ text, _ := io.ReadAll(rc)
+ rc.Close()
+ found[f.Name] = string(text)
+ }
+ if len(found) < 2 {
+ t.Fatalf("want a file per project, got %d: %v", len(found), found)
+ }
+
+ // The task has to be IN the file named after its own project. A zip with the
+ // right filenames and every task in the wrong one is the failure this is for.
+ var sawMoms, sawDaek bool
+ for name, text := range found {
+ // Filenames are lower-cased by ics.Filename, so the comparison is too —
+ // asserting on the stored capitalisation would be asserting on a detail
+ // the exporter is free to change.
+ lower := strings.ToLower(name)
+ if strings.Contains(text, "Betal moms") {
+ sawMoms = true
+ if !strings.Contains(lower, "firma") {
+ t.Errorf("Betal moms landed in %q, not in Firma", name)
+ }
+ }
+ if strings.Contains(text, "Skifte daek") {
+ sawDaek = true
+ if !strings.Contains(lower, "hjem") {
+ t.Errorf("Skifte daek landed in %q, not in Hjem", name)
+ }
+ }
+ }
+ if !sawMoms || !sawDaek {
+ t.Errorf("a task is missing from the zip: moms=%v daek=%v", sawMoms, sawDaek)
+ }
+ })
+
+ t.Run("ics holds every dated task across projects", func(t *testing.T) {
+ body, ct := ts.raw(t, "/api/v1/export/tasks.ics")
+ if !strings.HasPrefix(ct, "text/calendar") {
+ t.Fatalf("content-type %q", ct)
+ }
+ text := string(body)
+ if n := strings.Count(text, "BEGIN:VTODO"); n != 1 {
+ t.Fatalf("want the one dated task, got %d VTODOs:\n%s", n, text)
+ }
+ if !strings.Contains(text, "Betal moms") {
+ t.Errorf("the dated task is missing:\n%s", text)
+ }
+ // Undated tasks are deliberately left out — a VTODO with no date is a line
+ // most readers drop. Asserted so the omission stays a decision.
+ if strings.Contains(text, "Skifte daek") {
+ t.Errorf("an undated task was exported:\n%s", text)
+ }
+ })
+}
+
func TestTodoistImportExportRoundTrip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
diff --git a/web/e2e/pastelist.spec.js b/web/e2e/pastelist.spec.js
new file mode 100644
index 0000000..8cdf62f
--- /dev/null
+++ b/web/e2e/pastelist.spec.js
@@ -0,0 +1,83 @@
+import { test, expect } from '@playwright/test';
+import { readList } from '../src/lib/pastelist.js';
+
+/**
+ * Læsningen af en indsat liste, prøvet for sig.
+ *
+ * Reglerne er små og mange, og de er billigere at stille her end gennem en
+ * browser: hvad er et mærke, hvad er indhold, og hvor dybt står en linje. Flowet
+ * gennem fladen har sin egen prøve i smoke.spec.js — den viser, at det hænger
+ * sammen, ikke at hvert tilfælde er rigtigt.
+ */
+test.describe('en indsat liste læses som opgaver og underopgaver', () => {
+ test('mærker fjernes, teksten bliver', () => {
+ expect(readList('- køb mælk\n* ring til Per\n+ hent pakken\n')).toEqual([
+ { depth: 0, text: 'køb mælk', done: false },
+ { depth: 0, text: 'ring til Per', done: false },
+ { depth: 0, text: 'hent pakken', done: false }
+ ]);
+ });
+
+ test('nummererede lister er også lister', () => {
+ expect(readList('1. først\n2) så\n10. til sidst')).toEqual([
+ { depth: 0, text: 'først', done: false },
+ { depth: 0, text: 'så', done: false },
+ { depth: 0, text: 'til sidst', done: false }
+ ]);
+ });
+
+ test('en afkrydsning er indhold, ikke notation', () => {
+ // Det er hele grunden til at læse boksen: en halvt afkrydset liste, der
+ // kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for.
+ expect(readList('- [x] betalt\n- [ ] ikke betalt\n- [X] også betalt')).toEqual([
+ { depth: 0, text: 'betalt', done: true },
+ { depth: 0, text: 'ikke betalt', done: false },
+ { depth: 0, text: 'også betalt', done: true }
+ ]);
+ });
+
+ test('indrykning bærer niveauet, uanset hvor bred den er', () => {
+ // To mellemrum i den ene liste, fire i den anden, en tabulator i den tredje:
+ // alle tre betyder ét niveau. Bredden gættes ikke, den læses af teksten.
+ const to = readList('a\n b\nc');
+ const fire = readList('a\n b\nc');
+ const tab = readList('a\n\tb\nc');
+ const want = [
+ { depth: 0, text: 'a', done: false },
+ { depth: 1, text: 'b', done: false },
+ { depth: 0, text: 'c', done: false }
+ ];
+ expect(to).toEqual(want);
+ expect(fire).toEqual(want);
+ expect(tab).toEqual(want);
+ });
+
+ test('en liste, der er rykket ind som helhed, begynder stadig på niveau nul', () => {
+ // Kopierer man fra et dokument, følger der ofte indrykning med på alle
+ // linjer. Den er ikke et niveau — der er ikke noget at være under.
+ expect(readList(' a\n b')).toEqual([
+ { depth: 0, text: 'a', done: false },
+ { depth: 1, text: 'b', done: false }
+ ]);
+ });
+
+ test('tomme linjer er luft i det kopierede, ikke punkter', () => {
+ // Og de nulstiller ikke niveauet: en liste med luft mellem punkterne er én
+ // liste.
+ expect(readList('a\n\n b\n\n c')).toEqual([
+ { depth: 0, text: 'a', done: false },
+ { depth: 1, text: 'b', done: false },
+ { depth: 1, text: 'c', done: false }
+ ]);
+ });
+
+ test('teksten røres ikke ud over mærket', () => {
+ // Datoer, #projekt og @etiket læses af quickadd på serveren, præcis som når
+ // linjen er skrevet i hånden. To steder at forstå en opgavelinje er ét for
+ // mange.
+ expect(readList('- betal moms i morgen #Firma p1\n- andet')).toEqual([
+ { depth: 0, text: 'betal moms i morgen #Firma p1', done: false },
+ { depth: 0, text: 'andet', done: false }
+ ]);
+ });
+});
diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index c892f41..f5dbb37 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -5035,6 +5035,47 @@ test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ pa
expect(trouble).toEqual([]);
});
+/**
+ * ⌘B gør teksten fed i en note — den folder ikke sidebjælken.
+ *
+ * Fladen tog ⌘B til sidebjælken med den begrundelse, at den "ikke flytter noget i
+ * feltet". Det passer for et , hvor ⌘B ikke gør noget. I en contenteditable
+ * ER ⌘B fed tekst, og `preventDefault()` stoppede browseren i at gøre den — så i
+ * noterne foldede menuen sig, hver gang man ville fremhæve et ord.
+ *
+ * Begge halvdele måles. En rettelse, der bare holdt op med at folde, ville være
+ * halvt færdig: ordet skal også blive fedt.
+ */
+test('⌘B gør teksten fed i en note i stedet for at folde sidebjælken', async ({ page }) => {
+ const trouble = watchForTrouble(page);
+ await page.goto('/noter');
+
+ const foldet = () => page.locator('.shell.sidebar-collapsed').count();
+ expect(await foldet()).toBe(0);
+
+ await nyNote(page);
+ await page.keyboard.type('Fremhævet');
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('ordet');
+ await page.keyboard.press('ControlOrMeta+a');
+
+ const ed = page.getByRole('textbox', { name: 'Notens tekst' });
+ await page.keyboard.press('ControlOrMeta+b');
+
+ // Enten eller : browserens egen fed er , omformeren skriver
+ // , og begge læses som fed på vejen tilbage.
+ await expect(ed.locator('b, strong')).toContainText('ordet');
+ expect(await foldet()).toBe(0);
+
+ // Og uden for et skrivefelt gør den stadig det, den er sat til.
+ await page.getByRole('heading', { name: 'Noter' }).click();
+ await page.keyboard.press('ControlOrMeta+b');
+ expect(await foldet()).toBe(1);
+ await page.keyboard.press('ControlOrMeta+b');
+
+ expect(trouble).toEqual([]);
+});
+
/**
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
*
@@ -5094,6 +5135,132 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
expect(trouble).toEqual([]);
});
+/**
+ * Hele kontoens opgaver kan hentes i én omgang.
+ *
+ * Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
+ * projekter betød femten downloads for at flytte sine egne opgaver et andet sted
+ * hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
+ *
+ * Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
+ * zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
+ * det er præcis den form, en fejl i en eksport har.
+ */
+test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
+ const trouble = watchForTrouble(page);
+
+ await page.goto('/');
+ await page.getByLabel('Ny opgave').fill('Betal moms i dag');
+ await page.getByLabel('Ny opgave').press('Enter');
+ await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
+
+ await page.goto('/indstillinger/data');
+
+ const hent = async (navn) => {
+ const venter = page.waitForEvent('download');
+ await page.getByRole('link', { name: navn }).click();
+ const fil = await venter;
+ const sti = await fil.path();
+ return { sti, navn: fil.suggestedFilename() };
+ };
+
+ const zip = await hent('Alle opgaver (CSV)');
+ expect(zip.navn).toMatch(/\.zip$/);
+ // En zip er ikke tom, når den har mere end den afsluttende blok i sig.
+ const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
+ expect(size).toBeGreaterThan(100);
+
+ const kal = await hent('Alle opgaver (kalender)');
+ expect(kal.navn).toMatch(/\.ics$/);
+ const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
+ expect(tekst).toContain('BEGIN:VTODO');
+ expect(tekst).toContain('Betal moms');
+
+ expect(trouble).toEqual([]);
+});
+
+/**
+ * En indsat liste bliver til opgaver og underopgaver.
+ *
+ * Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
+ * man indsætter i et `` — så en kopieret liste blev til én opgave med det
+ * hele mast sammen.
+ *
+ * Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
+ * oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
+ * lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
+ * billigere at stille.
+ */
+test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
+ page
+}) => {
+ const trouble = watchForTrouble(page);
+ await page.goto('/');
+
+ const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
+ '\n'
+ );
+
+ await page.getByLabel('Ny opgave').click();
+ // En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
+ // netop det over, prøven handler om — browseren smider linjeskiftene væk.
+ await page.evaluate((tekst) => {
+ const dt = new DataTransfer();
+ dt.setData('text/plain', tekst);
+ document
+ .querySelector('[data-quickadd]')
+ .dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
+ }, liste);
+
+ // Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
+ // man skal opdage bagefter.
+ const visning = page.getByRole('group', { name: 'Indsat liste' });
+ await expect(visning.locator('li')).toHaveCount(4);
+ await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
+
+ await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
+ await expect(visning).toHaveCount(0);
+
+ // Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
+ await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
+ await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
+ await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
+
+ // Og de indrykkede hænger under den linje, de stod under — ikke under den
+ // første, den bedste. Det ses i opgavens egen rude.
+ await page.getByText('Flytte garagen', { exact: true }).click();
+ await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
+ await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
+
+ // Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
+ // én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
+ // er det, serveren har gemt, stregen er, hvordan den tegnes.
+ await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
+
+ // Og "Male døren" hang ikke under noget: den stod uden indrykning.
+ await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
+
+ // Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
+ // afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
+ // undertasks under den — uden at nogen skal vælge noget.
+ await page.evaluate((tekst) => {
+ const dt = new DataTransfer();
+ dt.setData('text/plain', tekst);
+ document
+ .querySelector('input[aria-label="Ny undertask"]')
+ .dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
+ }, '- Købe maling\n- Låne stige');
+
+ const flere = page.getByRole('group', { name: 'Indsat liste' });
+ await expect(flere.locator('li')).toHaveCount(2);
+ await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
+
+ // To mere under den samme opgave: fire undertasks, én af dem lukket.
+ await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
+
+ expect(trouble).toEqual([]);
+});
+
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
index a72cbce..924f380 100644
--- a/web/src/lib/api.js
+++ b/web/src/lib/api.js
@@ -365,8 +365,13 @@ export const api = {
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
- quickAdd: (text, projectId, sectionId) =>
- post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
+ quickAdd: (text, projectId, sectionId, parentId) =>
+ post('/tasks/quick-add', {
+ text,
+ project_id: projectId,
+ section_id: sectionId,
+ parent_id: parentId
+ }),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
@@ -511,6 +516,8 @@ export const api = {
importCSV: (data) => post('/import/csv', data),
exportAccountURL: () => '/api/v1/export/account',
exportNotesURL: () => '/api/v1/export/notes.zip',
+ exportAllCSVURL: () => '/api/v1/export/projects.zip',
+ exportAllICSURL: () => '/api/v1/export/tasks.ics',
// Through `upload`, not `request`: a multipart body has to set its own boundary,
// and the browser only does that when nothing has claimed the header.
importNotes: (file) => upload('/notes/import', file),
diff --git a/web/src/lib/components/QuickAdd.svelte b/web/src/lib/components/QuickAdd.svelte
index e01db7c..7366b99 100644
--- a/web/src/lib/components/QuickAdd.svelte
+++ b/web/src/lib/components/QuickAdd.svelte
@@ -15,6 +15,7 @@
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
+ import { readList } from '$lib/pastelist.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
@@ -97,6 +98,60 @@
return out;
});
+ /**
+ * En indsat liste bliver til opgaver frem for til én opgave med det hele i.
+ *
+ * Browseren smider linjeskiftene væk, når man indsætter i et ``, så uden
+ * det her blev en kopieret liste til én opgave med punkterne mast sammen. En
+ * enkelt linje røres ikke — det er almindelig indsætning, og den skal opføre sig
+ * som altid.
+ *
+ * Vist før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal
+ * opdage bagefter, og listen er også svaret på "forstod den indrykningen" —
+ * stillet mens der stadig er en Fortryd-knap.
+ */
+ let pending = $state([]);
+
+ function onpaste(event) {
+ const pasted = event.clipboardData?.getData('text/plain') ?? '';
+ if (!pasted.includes('\n')) return;
+
+ const rows = readList(pasted);
+ if (rows.length < 2) return;
+
+ event.preventDefault();
+ pending = rows;
+ }
+
+ async function createPending() {
+ if (submitting) return;
+ submitting = true;
+
+ // Den seneste opgave på hvert niveau, så en indrykket linje havner under den
+ // linje, den stod under — og ikke under den første, den bedste.
+ const parents = [];
+ let made = 0;
+ for (const row of pending) {
+ const parentId = row.depth > 0 ? parents[row.depth - 1] : undefined;
+ const task = await app.quickAdd(row.text, projectId, undefined, parentId);
+ // Stopper ved den første, der ikke kunne laves. At køre videre ville lægge
+ // resten af listen ind uden den, de hang under, og det er værre end at
+ // standse et sted, man kan se.
+ if (!task) break;
+ parents[row.depth] = task.id;
+ parents.length = row.depth + 1;
+ if (row.done) await app.complete(task.id);
+ onadded?.(task);
+ made++;
+ }
+
+ submitting = false;
+ // Det, der ikke nåede at blive lavet, bliver stående, så man kan se hvor langt
+ // den kom og prøve resten igen.
+ pending = pending.slice(made);
+ input?.focus();
+ }
+
async function submit(event) {
event?.preventDefault();
const value = text.trim();
@@ -164,13 +219,32 @@
aria-label={t('task.new')}
autocomplete="off"
spellcheck="false"
+ {onpaste}
/>
- {#if text.trim()}
+ {#if text.trim() && !pending.length}
{/if}
+ {#if pending.length}
+