From 3132bba7e526e5180575d863929297f80bd94ac5 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 6 Oct 2026 12:27:46 +0200 Subject: [PATCH] feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To ønsker, meldt sammen. --- eksport af det hele --- Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip, og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten downloads for at flytte sine egne opgaver, hvilket er en eksport, der teknisk set findes, og som ingen bruger. `GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så det er den form, der importeres i den anden ende — og den form, man kan hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som noteeksporten, der er en mappe med Markdown og ikke én lang fil. `GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og ikke en zip, fordi en kalender er lavet til at rumme mange ting, og projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato er en linje, de fleste læsere taber på gulvet. Prøven ser I filerne frem for på statuskoden: 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. To projekter, ikke ét, så en løkke der kun eksporterer det sidste ikke består. --- en indsat liste bliver til opgaver --- Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk ved indsætning i et `` — så en kopieret liste blev til én opgave med punkterne mast sammen. Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem for at være gættet til to eller fire: en liste fra Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med et andet, og begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste, der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for. Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add` nu et `parent_id`: en indrykket linje skal parses som enhver anden. En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket linje honoreres derfor ikke, og det siges højt som `unknown_project` frem for at ordet bare forsvinder fra titlen. Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks dér. Det er "enten opgaver eller undertasks" afgjort af hvor man indsætter, uden at nogen skal vælge noget i en menu. Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal opdage bagefter — og visningen er også svaret på "forstod den indrykningen", stillet mens der stadig er en Fortryd-knap. --- rettet undervejs --- Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på forælderen og regnes ud ved hentning, og serveren sender ikke forælderen igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun den nye. Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 48 +++++ internal/httpapi/import_handlers.go | 106 +++++++++++ internal/httpapi/server.go | 2 + internal/httpapi/tasks_handlers.go | 35 ++++ internal/httpapi/tasks_test.go | 173 ++++++++++++++++++ web/e2e/pastelist.spec.js | 83 +++++++++ web/e2e/smoke.spec.js | 126 +++++++++++++ web/src/lib/api.js | 11 +- web/src/lib/components/QuickAdd.svelte | 127 ++++++++++++- web/src/lib/components/TaskDetail.svelte | 131 ++++++++++++- web/src/lib/locales/da.js | 5 + web/src/lib/locales/en.js | 5 + web/src/lib/pastelist.js | 64 +++++++ web/src/lib/stores.svelte.js | 4 +- .../routes/indstillinger/data/+page.svelte | 4 + 15 files changed, 918 insertions(+), 6 deletions(-) create mode 100644 web/e2e/pastelist.spec.js create mode 100644 web/src/lib/pastelist.js 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..0f60037 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -5094,6 +5094,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} +
+
    + {#each pending as row, i (i)} +
  • {row.text}
  • + {/each} +
+
+ + +
+
+ {/if} + {t('data.exportAccount')} {t('data.exportNotes')} + {t('data.exportAllCSV')} + {t('data.exportAllICS')}