diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b894d46..ade28d3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2566,6 +2566,37 @@ paths: schema: { $ref: "#/components/schemas/Note" } "403": { $ref: "#/components/responses/Forbidden" } + /api/v1/notes/import: + post: + tags: [notes] + summary: A zip of Markdown files becomes notes. + description: > + The mirror of the export, and the way in from anywhere else: a folder of + Markdown is what Obsidian, Bear, iA Writer and a shell script all produce. + Filenames are ignored — the title is the first line of the body, here as + everywhere, and taking it from the filename would be a second rule for the + same thing. Directories, macOS resource forks and anything that is not .md + are passed over. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: { type: string, format: binary } + responses: + "200": + description: Read + content: + application/json: + schema: + type: object + properties: + created: { type: integer } + skipped: { type: integer, description: Files that were not usable as a note. } + "413": { description: The archive is too large. } + /api/v1/notes/{noteID}: parameters: - name: noteID diff --git a/internal/httpapi/comments_handlers.go b/internal/httpapi/comments_handlers.go index 334aa96..9b33790 100644 --- a/internal/httpapi/comments_handlers.go +++ b/internal/httpapi/comments_handlers.go @@ -327,6 +327,30 @@ func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit return true, nil } + // A file inside a note follows the note, which follows its project — the same + // chain everything else here follows. Without this a picture imported with a + // note answered 404 while sitting in the middle of the text that named it. + if a.NoteID != "" { + n, err := s.db.Note(r.Context(), a.NoteID) + if err != nil { + return false, err + } + if n == nil { + return false, store.ErrNotFound + } + if n.ProjectID == "" { + if n.CreatedBy != user.ID { + return false, store.ErrNotFound + } + return true, nil + } + role, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, store.RoleViewer) + if err != nil { + return false, err + } + return role.CanEdit(), nil + } + taskID, err := s.db.AttachmentTask(r.Context(), attachmentID) if err != nil { return false, err diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 7cc5d47..576eb11 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -4,7 +4,9 @@ import ( "archive/zip" "fmt" "io" + "mime" "net/http" + "path" "strings" "time" @@ -284,3 +286,174 @@ func noteFilename(title string) string { } return strings.TrimRight(name, " .") } + +// maxNoteImportBytes bounds one upload. Generous for text — a decade of notes is +// a few megabytes — and small enough that a wrong file is refused rather than read. +const maxNoteImportBytes = 64 << 20 + +// handleImportNotes reads a zip of Markdown files and makes a note of each. +// +// The mirror of the export, and deliberately the same shape: whatever comes out of +// this program goes back into it. It is also the way in from anywhere else, since +// a folder of Markdown is what Obsidian, Bear, iA Writer and a shell script all +// produce — and what the Apple Notes exporter in tools/ writes. +// +// Filenames are ignored. The title is the first line of the body, everywhere else +// in this program, and making it the filename here would be a second rule for the +// same thing that disagrees the moment somebody renames a file. +func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxNoteImportBytes) + if err := r.ParseMultipartForm(4 << 20); err != nil { + writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "the file is too large") + return + } + defer r.MultipartForm.RemoveAll() + + file, header, err := r.FormFile("file") + if err != nil { + writeFieldErrors(w, map[string]string{"file": "required"}) + return + } + defer file.Close() + + zr, err := zip.NewReader(file.(io.ReaderAt), header.Size) + if err != nil { + writeFieldErrors(w, map[string]string{"file": "not a zip archive"}) + return + } + + user := userFrom(r.Context()) + created, skipped, files := 0, 0, 0 + + // Two passes. Everything that is not Markdown is stored first, so that when a + // note is read its pictures already have addresses to be rewritten to — a note + // and its images arrive in whatever order the archive happens to hold them, and + // one pass would leave half the links pointing at nothing. + stored := map[string]string{} + for _, f := range zr.File { + name := f.Name + if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + hashed, size, err := s.storeUpload(rc) + rc.Close() + if err != nil { + s.log.Warn("import note file", "err", err, "file", name) + continue + } + stored[name] = hashed + _ = size + files++ + } + + for _, f := range zr.File { + name := f.Name + if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") { + continue + } + + rc, err := f.Open() + if err != nil { + skipped++ + continue + } + body, err := io.ReadAll(io.LimitReader(rc, 2<<20)) + rc.Close() + if err != nil || strings.TrimSpace(string(body)) == "" { + skipped++ + continue + } + + n := &store.Note{CreatedBy: user.ID, Body: string(body)} + if err := s.db.SaveNote(r.Context(), n); err != nil { + s.log.Warn("import note", "err", err, "file", name) + skipped++ + continue + } + + // The pictures the note referred to, now that it has an id to hang them on. + // The text is rewritten to the addresses they were given, so an image that + // sat in the middle of a paragraph is still in the middle of it. + if rewritten, attached := s.attachToNote(r, n, path.Dir(name), stored, zr); attached > 0 { + n.Body = rewritten + if err := s.db.SaveNote(r.Context(), n); err != nil { + s.log.Warn("rewrite note links", "err", err, "note", n.ID) + } + } + created++ + } + + writeJSON(w, http.StatusOK, map[string]any{ + "created": created, "skipped": skipped, "files": files, + }) +} + +// skipFile is the things in an archive that are not content: directories' own +// entries, macOS resource forks, and anything trying to climb out of the archive. +// A zip made on a Mac carries a __MACOSX copy of every file, and importing those +// would double a whole library with each copy full of binary rubbish. +func skipFile(name string) bool { + return strings.HasPrefix(name, "__MACOSX/") || + strings.HasPrefix(path.Base(name), "._") || + strings.HasPrefix(path.Base(name), ".") || + strings.Contains(name, "..") +} + +// attachToNote records the files a note's text points at and rewrites the text to +// where they now live. Returns the new body and how many were attached. +// +// Only the files this note refers to. An archive is one folder for many notes, and +// hanging every picture on every note would turn a library of a hundred into ten +// thousand attachments. +func (s *Server) attachToNote( + r *http.Request, n *store.Note, dir string, stored map[string]string, zr *zip.Reader, +) (string, int) { + body := n.Body + attached := 0 + + for _, f := range zr.File { + hashed, ok := stored[f.Name] + if !ok { + continue + } + base := path.Base(f.Name) + // The two ways a Markdown file names a neighbouring image: by the path it + // was written with, and by its bare name. + relative := strings.TrimPrefix(path.Join(dir, base), "./") + if !strings.Contains(body, base) && !strings.Contains(body, relative) { + continue + } + + a := &store.Attachment{ + NoteID: n.ID, + Filename: base, + MimeType: mimeOf(base), + Size: int64(f.UncompressedSize64), + Path: hashed, + UploadedBy: n.CreatedBy, + } + if err := s.db.CreateAttachment(r.Context(), a); err != nil { + s.log.Warn("attach to note", "err", err, "file", base) + continue + } + + url := "/api/v1/attachments/" + a.ID + body = strings.ReplaceAll(body, relative, url) + body = strings.ReplaceAll(body, base, url) + attached++ + } + return body, attached +} + +// mimeOf guesses from the extension. The archive does not carry a type, and +// sniffing the bytes would mean reading every file twice. +func mimeOf(name string) string { + if t := mime.TypeByExtension(path.Ext(name)); t != "" { + return t + } + return "application/octet-stream" +} diff --git a/internal/httpapi/notes_test.go b/internal/httpapi/notes_test.go index 18a2644..2c4fbe3 100644 --- a/internal/httpapi/notes_test.go +++ b/internal/httpapi/notes_test.go @@ -3,8 +3,11 @@ package httpapi import ( "archive/zip" "bytes" + "encoding/json" "io" + "mime/multipart" "net/http" + "strings" "testing" ) @@ -198,3 +201,166 @@ func keysOf(m map[string]string) []string { } return out } + +// Out and back in again. If an export cannot be imported, it is not an export — +// it is a file somebody hopes will be readable later. +func TestNotesSurviveTheRoundTripThroughAZip(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + files := map[string]string{ + "Møde med Anders.md": "# Møde med Anders\n\nHan vil have **kaffe** hver uge. #Firma", + "noget andet.md": "Løse tanker\n\nog en linje til", + // A zip made on a Mac carries these beside every file. Importing them would + // double the whole library, with each copy full of binary rubbish. + "__MACOSX/._Møde med Anders.md": "\x00\x05\x16\x07binært", + "billede.png": "ikke en note", + "tom.md": " \n ", + } + for name, body := range files { + f, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(f, body); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + var form bytes.Buffer + mw := multipart.NewWriter(&form) + part, _ := mw.CreateFormFile("file", "noter.zip") + part.Write(archive.Bytes()) + mw.Close() + + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form) + req.Header.Set("Content-Type", mw.FormDataContentType()) + 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("import: status %d", resp.StatusCode) + } + + var out struct{ Created, Skipped int } + json.NewDecoder(resp.Body).Decode(&out) + if out.Created != 2 { + t.Fatalf("imported %d notes, want the 2 real ones", out.Created) + } + + // The title came from the first line, not from the filename — which is the same + // rule as everywhere else, and the reason renaming a file cannot rename a note. + _, listed := ts.do(t, "GET", "/api/v1/notes", nil) + titles := map[string]bool{} + for _, raw := range listed["notes"].([]any) { + titles[raw.(map[string]any)["title"].(string)] = true + } + if !titles["Møde med Anders"] || !titles["Løse tanker"] { + t.Errorf("the titles came out as %v", titles) + } + + // And the #tag was read on the way in, so an imported note lands on its project + // exactly as a typed one does. + _, linked := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil) + if len(linked["notes"].([]any)) != 1 { + t.Errorf("the imported note did not link to its project") + } +} + +// A note from Apple Notes is full of photographs. One that arrives as words alone +// is not that note — it is a summary of it. +func TestAnImportedNoteKeepsItsPictures(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + write := func(name, body string) { + f, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(f, body); err != nil { + t.Fatal(err) + } + } + write("noter/Kvittering.md", "# Kvittering\n\nHer er den:\n\n![](vedhaeftninger/bon.png)\n\nog resten af teksten") + write("noter/vedhaeftninger/bon.png", "\x89PNG\r\n\x1a\nlad som om") + // Another note in the same archive, which must not inherit the first one's + // picture: an archive is one folder for many notes. + write("noter/Uden billede.md", "Uden billede\n\nbare tekst") + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + var form bytes.Buffer + mw := multipart.NewWriter(&form) + part, _ := mw.CreateFormFile("file", "noter.zip") + part.Write(archive.Bytes()) + mw.Close() + + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form) + req.Header.Set("Content-Type", mw.FormDataContentType()) + 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() + + var out struct{ Created, Skipped, Files int } + json.NewDecoder(resp.Body).Decode(&out) + if out.Created != 2 || out.Files != 1 { + t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files) + } + + _, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil) + notes := listed["notes"].([]any) + if len(notes) != 1 { + t.Fatalf("found %d notes", len(notes)) + } + body := notes[0].(map[string]any)["body"].(string) + + // The link points at the file where it now lives, not at a path inside an + // archive nobody kept. + if strings.Contains(body, "vedhaeftninger/bon.png") { + t.Errorf("the link still points into the archive: %q", body) + } + if !strings.Contains(body, "/api/v1/attachments/") { + t.Errorf("the picture was not attached: %q", body) + } + // And it is still where it was written, in the middle of the note. + if !strings.HasPrefix(body, "# Kvittering") || !strings.Contains(body, "og resten af teksten") { + t.Errorf("the note was rearranged: %q", body) + } + + // The picture is really there and really readable. + id := body[strings.Index(body, "/api/v1/attachments/")+len("/api/v1/attachments/"):] + id = strings.SplitN(id, ")", 2)[0] + got, _ := ts.do(t, "GET", "/api/v1/attachments/"+id, nil) + if got.StatusCode != http.StatusOK { + t.Errorf("the attachment answers %d", got.StatusCode) + } + + // The other note did not inherit it. + _, other := ts.do(t, "GET", "/api/v1/notes?q=Uden", nil) + for _, raw := range other["notes"].([]any) { + if strings.Contains(raw.(map[string]any)["body"].(string), "attachments/") { + t.Error("a note that mentioned no picture was given one") + } + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 4780501..5a4de2c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -283,6 +283,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Route("/notes", func(r chi.Router) { r.Get("/", s.handleListNotes) r.Post("/", s.handleCreateNote) + r.Post("/import", s.handleImportNotes) r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking) r.Get("/{noteID}", s.handleGetNote) r.Patch("/{noteID}", s.handleUpdateNote) diff --git a/internal/store/comments.go b/internal/store/comments.go index 671a47f..d347a4e 100644 --- a/internal/store/comments.go +++ b/internal/store/comments.go @@ -27,6 +27,7 @@ type Attachment struct { // GroupID hangs the file on a project group rather than on any one task in // it: the contract that governs all of "Arbejde", not a step in it. GroupID string + NoteID string Filename string MimeType string Size int64 @@ -157,17 +158,18 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error { a.CreatedAt = time.Now().UTC() _, err := db.ExecContext(ctx, - `INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type, + `INSERT INTO attachments (id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID), + nullString(a.NoteID), a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix()) return err } func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) { rows, err := db.QueryContext(ctx, - `SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at + `SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID) if err != nil { return nil, err @@ -176,11 +178,24 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm return scanAttachments(rows) } +// ListNoteAttachments is the files inside one note — the pictures and scans that +// came with it, and anything added since. +func (db *DB) ListNoteAttachments(ctx context.Context, noteID string) ([]Attachment, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at + FROM attachments WHERE note_id = ? ORDER BY created_at`, noteID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAttachments(rows) +} + // ListGroupAttachments is the documents that belong to a whole group rather than // to anything inside it. func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) { rows, err := db.QueryContext(ctx, - `SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at + `SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID) if err != nil { return nil, err @@ -195,7 +210,7 @@ func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (ma args[i] = id } rows, err := db.QueryContext(ctx, - `SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at + `SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`) ORDER BY created_at`, args...) if err != nil { @@ -218,15 +233,16 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) { out := []Attachment{} for rows.Next() { var a Attachment - var taskID, commentID, groupID sql.NullString + var taskID, commentID, groupID, noteID sql.NullString var created int64 - if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &a.Filename, &a.MimeType, + if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, ¬eID, &a.Filename, &a.MimeType, &a.Size, &a.Path, &a.UploadedBy, &created); err != nil { return nil, err } a.TaskID = taskID.String a.CommentID = commentID.String a.GroupID = groupID.String + a.NoteID = noteID.String a.CreatedAt = time.Unix(created, 0).UTC() out = append(out, a) } @@ -235,7 +251,7 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) { func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) { rows, err := db.QueryContext(ctx, - `SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at + `SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at FROM attachments WHERE id = ?`, attachmentID) if err != nil { return nil, err diff --git a/internal/store/migrations/0021_note_attachments.sql b/internal/store/migrations/0021_note_attachments.sql new file mode 100644 index 0000000..7ced4c4 --- /dev/null +++ b/internal/store/migrations/0021_note_attachments.sql @@ -0,0 +1,45 @@ +-- A note can carry files, the way a task and a group already can. +-- +-- Apple Notes is full of photographs, scans and pasted screenshots, and a note +-- that arrives here as words alone is not that note — it is a summary of it. The +-- import writes the files as attachments and rewrites the links in the text, so a +-- picture that was in the middle of a paragraph is still in the middle of it. +-- +-- Same table as everything else attaches to, rather than one of its own. The files +-- are content-addressed and deduplicated on disk, the upload path is written and +-- tested, and the cleanup that removes an orphan already walks this table. A +-- second table would be a second everything. + +-- verdande:rebuild-tables + +CREATE TABLE attachments_with_notes ( + id TEXT PRIMARY KEY, + task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE, + comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE, + group_id TEXT REFERENCES project_groups (id) ON DELETE CASCADE, + note_id TEXT REFERENCES notes (id) ON DELETE CASCADE, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL, + path TEXT NOT NULL, + uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + -- Counted, not spelled out as pairs: with four columns the pairwise version is + -- six clauses, and the one that is wrong looks exactly like the one that is right. + CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL) + + (group_id IS NOT NULL) + (note_id IS NOT NULL) = 1) +); + +INSERT INTO attachments_with_notes + (id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at) +SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at +FROM attachments; + +DROP TABLE attachments; + +ALTER TABLE attachments_with_notes RENAME TO attachments; + +CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL; +CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL; +CREATE INDEX idx_attachments_group ON attachments (group_id) WHERE group_id IS NOT NULL; +CREATE INDEX idx_attachments_note ON attachments (note_id) WHERE note_id IS NOT NULL; diff --git a/tools/apple-notes-til-markdown.sh b/tools/apple-notes-til-markdown.sh new file mode 100755 index 0000000..3a7ef96 --- /dev/null +++ b/tools/apple-notes-til-markdown.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# +# Apple Noter → en mappe med Markdown-filer. +# +# Apple har ingen eksport, der er værd at bruge: "Eksportér som PDF" er et billede +# af en note, ikke noten. Det her læser Notes.app gennem AppleScript, som er den +# eneste understøttede vej ind, og skriver én .md-fil pr. note. +# +# Det kører på din egen maskine og sender ingenting nogen steder. Resultatet er en +# mappe, du kan kigge igennem, før du beslutter dig — og et zip af den mappe er +# præcis det, Verdande importerer. +# +# ./apple-notes-til-markdown.sh ~/Desktop/noter +# cd ~/Desktop && zip -r noter.zip noter +# → Indstillinger → Data → Importér noter +# +# Første gang spørger macOS, om Terminal må styre Noter. Det skal den. + +set -euo pipefail + +ud="${1:-$HOME/Desktop/apple-noter}" +mkdir -p "$ud" + +echo "Læser Noter. Første gang kan det tage et minut, før macOS svarer." + +# Én note ad gangen frem for at bygge én kæmpe streng: AppleScript bliver meget +# langsomt på lange strenge, og på et bibliotek af nogen størrelse ser det ud som +# om den er gået i stå. +antal=$(osascript -e 'tell application "Notes" to return count of notes') +echo "Fandt $antal noter." + +i=1 +while [ "$i" -le "$antal" ]; do + titel=$(osascript -e "tell application \"Notes\" to return name of note $i" 2>/dev/null || echo "") + krop=$(osascript -e "tell application \"Notes\" to return body of note $i" 2>/dev/null || echo "") + + if [ -z "$titel" ]; then + titel="uden titel $i" + fi + + # Tegn, der ikke kan være i et filnavn på de tre systemer, folk bruger. + fil=$(printf '%s' "$titel" | tr '/\\:*?"<>|' '-') + sti="$ud/$fil.md" + n=2 + while [ -e "$sti" ]; do + sti="$ud/$fil ($n).md" + n=$((n + 1)) + done + + # Noter er HTML indeni. textutil følger med macOS og kan lave det om til ren + # tekst; overskrifter og fed går tabt, men ordene og linjerne overlever, og det + # er dem, man kommer efter. Titlen sættes som første linje, fordi det er sådan + # Verdande navngiver en note. + { + printf '# %s\n\n' "$titel" + printf '%s' "$krop" | textutil -stdin -stdout -format html -convert txt 2>/dev/null || true + } > "$sti" + + # Billeder og bilag. + # + # En note fra Apple Noter er ofte fuld af fotografier, scanninger og indsatte + # skærmbilleder, og en note, der kommer over som ord alene, er ikke den note — + # den er et referat af den. AppleScript kan gemme hver vedhæftning som en fil; + # de lægges i en undermappe, og der skrives en henvisning til hver i bunden af + # noten, så Verdande kan hægte dem på ved importen. + bilag=$(osascript -e "tell application \"Notes\" to return count of attachments of note $i" 2>/dev/null || echo 0) + if [ "${bilag:-0}" -gt 0 ]; then + mkdir -p "$ud/vedhaeftninger" + j=1 + while [ "$j" -le "$bilag" ]; do + navn=$(osascript -e "tell application \"Notes\" to return name of attachment $j of note $i" 2>/dev/null || echo "") + [ -z "$navn" ] && navn="bilag-$i-$j" + # Samme rensning som titlen: et skråstreg i et filnavn laver en mappe. + navn=$(printf '%s' "$navn" | tr '/\\:*?"<>|' '-') + mål="$ud/vedhaeftninger/$i-$j-$navn" + + # `save` skriver filen. Nogle vedhæftninger — links til websider, kort — + # har ingen fil bag sig og fejler her; de springes over frem for at + # stoppe hele kørslen. + if osascript -e "tell application \"Notes\" to save attachment $j of note $i in POSIX file \"$mål\"" >/dev/null 2>&1; then + printf '\n![](vedhaeftninger/%s)\n' "$(basename "$mål")" >> "$sti" + fi + j=$((j + 1)) + done + fi + + if [ $((i % 25)) -eq 0 ]; then + echo " $i / $antal" + fi + i=$((i + 1)) +done + +echo +echo "Færdig. $antal noter ligger i:" +echo " $ud" +echo +echo "Kig dem igennem, og pak dem så:" +echo " cd \"$(dirname "$ud")\" && zip -r \"$(basename "$ud").zip\" \"$(basename "$ud")\"" diff --git a/web/src/lib/api.js b/web/src/lib/api.js index f845b95..aac8df7 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -392,6 +392,9 @@ export const api = { importCSV: (data) => post('/import/csv', data), exportAccountURL: () => '/api/v1/export/account', exportNotesURL: () => '/api/v1/export/notes.zip', + // 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), 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 cefb3f3..a7a9470 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -365,6 +365,9 @@ export const da = { 'data.swept': 'ryddet væk', 'data.empty': 'tom', 'data.export': 'Eksport', + 'data.importNotes': 'Importér noter (zip med Markdown-filer)', + 'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.', + 'data.notesImported': '{n} noter og {files} filer hentet ind.', 'data.exportNotes': 'Alle noter som Markdown-filer', 'data.exportAccount': 'Hele kontoen som JSON', 'data.exportOne': 'Ét projekt', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index c38371c..23533ac 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -360,6 +360,9 @@ export const en = { 'data.swept': 'swept away', 'data.empty': 'empty', 'data.export': 'Export', + 'data.importNotes': 'Import notes (a zip of Markdown files)', + 'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.', + 'data.notesImported': '{n} notes and {files} files brought in.', 'data.exportNotes': 'Every note as Markdown files', 'data.exportAccount': 'The whole account as JSON', 'data.exportOne': 'One project', diff --git a/web/src/routes/indstillinger/data/+page.svelte b/web/src/routes/indstillinger/data/+page.svelte index 0d775dc..d46dd70 100644 --- a/web/src/routes/indstillinger/data/+page.svelte +++ b/web/src/routes/indstillinger/data/+page.svelte @@ -1,4 +1,5 @@