diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4caeb57..b894d46 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2507,6 +2507,22 @@ paths: created: { type: integer } "502": { $ref: "#/components/responses/Upstream" } + /api/v1/export/notes.zip: + get: + tags: [notes] + summary: Every note as a Markdown file, in a zip. + description: > + One file per note, named after its title, containing exactly what is + stored. Nothing is converted on the way out — the note on disk is already + the file you would export — so it opens in Obsidian, in a text editor, or + in anything else that reads Markdown. + responses: + "200": + description: A zip archive. + content: + application/zip: + schema: { type: string, format: binary } + /api/v1/notes: get: tags: [notes] diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index ddf1bed..7cc5d47 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -1,8 +1,12 @@ package httpapi import ( + "archive/zip" + "fmt" + "io" "net/http" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/kristianwind/verdande/internal/store" @@ -208,3 +212,75 @@ func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)}) } + +// handleExportNotes writes every note this person can see as a zip of Markdown +// files, one per note. +// +// A zip of plain files rather than one bundle in a format of our own: what comes +// out is what was stored, and it opens in Obsidian, in a text editor, in anything. +// That is the promise the whole design was arranged around — the note on disk is +// already the file you would export — and this is where it is either kept or not. +func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + notes, err := s.db.AllNotes(r.Context(), user.ID) + if err != nil { + s.internal(w, r, "export notes", err) + return + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", + `attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`) + + zw := zip.NewWriter(w) + defer zw.Close() + + // Names collide: two notes called "Møde" are two notes, and a zip with one entry + // twice is a zip that loses one of them. + used := map[string]int{} + for _, n := range notes { + name := noteFilename(n.Title) + used[name]++ + if used[name] > 1 { + name = fmt.Sprintf("%s (%d)", name, used[name]) + } + + f, err := zw.CreateHeader(&zip.FileHeader{ + Name: name + ".md", + Method: zip.Deflate, + Modified: n.UpdatedAt, + }) + 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 says is truncated — + // better than a complete-looking file with a note missing from it. + s.log.Error("export notes", "err", err, "note", n.ID) + return + } + if _, err := io.WriteString(f, n.Body); err != nil { + s.log.Error("export notes", "err", err, "note", n.ID) + return + } + } +} + +// noteFilename makes a title safe to be a filename on any of the three systems +// people actually use, without turning it into something unrecognisable. +func noteFilename(title string) string { + name := strings.TrimSpace(title) + if name == "" { + name = "uden titel" + } + // Windows refuses these outright; a slash would make directories on the others. + name = strings.Map(func(r rune) rune { + if strings.ContainsRune(`/\:*?"<>|`, r) || r < 0x20 { + return '-' + } + return r + }, name) + // Long titles are the first line of a note, which can be a whole sentence. + if len([]rune(name)) > 80 { + name = string([]rune(name)[:80]) + } + return strings.TrimRight(name, " .") +} diff --git a/internal/httpapi/notes_test.go b/internal/httpapi/notes_test.go index b3b90fd..18a2644 100644 --- a/internal/httpapi/notes_test.go +++ b/internal/httpapi/notes_test.go @@ -1,6 +1,9 @@ package httpapi import ( + "archive/zip" + "bytes" + "io" "net/http" "testing" ) @@ -113,3 +116,85 @@ func TestChangingANoteChangesWhatItPointsAt(t *testing.T) { t.Errorf("%d notes still point at Firma", got) } } + +// The export is the promise the whole design was arranged around: the note on +// disk is already the file you would export, so nothing is converted on the way +// out and nothing can be lost in the conversion. +func TestNotesExportAsMarkdownFiles(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + bodies := []string{ + "# Møde med Anders\n\nHan vil gerne have **kaffe** hver uge.", + "Slash/i/titlen\n\nog noget tekst", + "# Møde med Anders\n\nen anden note med samme titel", + } + for _, body := range bodies { + if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body}); resp.StatusCode != http.StatusCreated { + t.Fatalf("create: %d", resp.StatusCode) + } + } + + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/notes.zip", nil) + req.Header.Set("Sec-Fetch-Site", "same-origin") + for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) { + req.AddCookie(c) + } + resp, err := ts.client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("export: status %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/zip" { + t.Errorf("content type is %q", ct) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatalf("the archive does not open: %v", err) + } + if len(zr.File) != 3 { + t.Fatalf("the archive holds %d files, want 3", len(zr.File)) + } + + byName := map[string]string{} + for _, f := range zr.File { + r, err := f.Open() + if err != nil { + t.Fatal(err) + } + text, _ := io.ReadAll(r) + r.Close() + byName[f.Name] = string(text) + } + + // Exactly what was stored, byte for byte. A converted export is an export that + // can be wrong. + if got := byName["Møde med Anders.md"]; got != bodies[0] { + t.Errorf("the note came out as %q", got) + } + // A slash in a title would make a directory, and on Windows it is refused + // outright. + if _, ok := byName["Slash-i-titlen.md"]; !ok { + t.Errorf("the awkward title became %v", keysOf(byName)) + } + // Two notes with the same title are two notes; one entry twice is one lost. + if _, ok := byName["Møde med Anders (2).md"]; !ok { + t.Errorf("the second note of the same name is missing: %v", keysOf(byName)) + } +} + +func keysOf(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 729cf68..4780501 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -321,6 +321,7 @@ 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/{projectID}.csv", s.handleExportProject) r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS) diff --git a/web/src/lib/api.js b/web/src/lib/api.js index e7cae3e..f845b95 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -391,6 +391,7 @@ export const api = { importTodoist: (file) => upload('/import/todoist', file), importCSV: (data) => post('/import/csv', data), exportAccountURL: () => '/api/v1/export/account', + exportNotesURL: () => '/api/v1/export/notes.zip', exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`, exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`, diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 9e2ec6a..cefb3f3 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -365,6 +365,7 @@ export const da = { 'data.swept': 'ryddet væk', 'data.empty': 'tom', 'data.export': 'Eksport', + 'data.exportNotes': 'Alle noter som Markdown-filer', 'data.exportAccount': 'Hele kontoen som JSON', 'data.exportOne': 'Ét projekt', 'data.pickProject': 'Vælg et projekt', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 3271049..c38371c 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -360,6 +360,7 @@ export const en = { 'data.swept': 'swept away', 'data.empty': 'empty', 'data.export': 'Export', + 'data.exportNotes': 'Every note as Markdown files', 'data.exportAccount': 'The whole account as JSON', 'data.exportOne': 'One project', 'data.pickProject': 'Pick a project', diff --git a/web/src/routes/indstillinger/data/+page.svelte b/web/src/routes/indstillinger/data/+page.svelte index af46fd3..0d775dc 100644 --- a/web/src/routes/indstillinger/data/+page.svelte +++ b/web/src/routes/indstillinger/data/+page.svelte @@ -341,6 +341,7 @@ {t('data.exportAccount')} + {t('data.exportNotes')}