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} +
+
    + {#each pending as row, i (i)} +
  • {row.text}
  • + {/each} +
+
+ + +
+
+ {/if} + {t('data.exportAccount')} {t('data.exportNotes')} + {t('data.exportAllCSV')} + {t('data.exportAllICS')}