From 11fdbefe1b690c861d110c1b4685b94471deb637 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 19:03:25 +0200 Subject: [PATCH] feat(notes): hver note som en Markdown-fil, i et zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Det er løftet, hele designet blev lagt til rette omkring: noten på disken ER den fil, man ville eksportere. Der konverteres ingenting på vejen ud, så der er ingenting, konverteringen kan tage fejl af — filerne åbner i Obsidian, i en teksteditor, i hvad som helst. To ting, en eksport kan tabe stille, og som prøven holder fast: Et skråstreg i en titel ville lave en mappe på Mac og Linux og blive afvist på Windows. De tegn, ingen af de tre vil have, bliver til bindestreger. Og to noter med samme titel er to noter — et arkiv med den samme post to gange er et arkiv, der har mistet den ene. Den anden får et nummer. Fejler skrivningen midtvejs, stopper den frem for at rydde op. Svaret er allerede på vej ud, så der er ingen statuskode tilbage at sende: et afkortet zip siger selv, at det er afkortet, hvilket er bedre end en komplet fil med en note færre. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 16 ++++ internal/httpapi/notes_handlers.go | 76 +++++++++++++++++ internal/httpapi/notes_test.go | 85 +++++++++++++++++++ internal/httpapi/server.go | 1 + web/src/lib/api.js | 1 + web/src/lib/locales/da.js | 1 + web/src/lib/locales/en.js | 1 + .../routes/indstillinger/data/+page.svelte | 1 + 8 files changed, 182 insertions(+) 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')}