feat(notes): billeder og bilag følger med ind — og ud
Release / Build and publish image (push) Failing after 9m58s

Du spurgte, hvad der sker med bilag og indsatte billeder. Svaret var: de forsvandt
stille. Scriptet konverterede til ren tekst, og en note fra Apple Noter, der kommer
over som ord alene, er ikke den note — den er et referat af den.

En note kan nu bære filer, i samme tabel som opgaver og grupper allerede bruger.
Filerne er indholdsadresserede og deduplikeres på disken, upload-vejen er skrevet
og afprøvet, og oprydningen af forældreløse filer går allerede den tabel igennem.
En tabel mere ville være et alt-sammen mere.

Importen læser arkivet i to omgange. Alt, der ikke er Markdown, gemmes først, så
en note, der bliver læst, allerede har adresser at få sine links rettet til —
noter og billeder ligger i den rækkefølge, arkivet nu har dem, og ét gennemløb
ville efterlade halvdelen af henvisningerne pegende på ingenting.

Kun de filer, noten faktisk nævner. Et arkiv er én mappe til mange noter, og at
hænge hvert billede på hver note ville gøre hundrede noter til ti tusind bilag.

Hentningen af en note-fil gav 404, fordi adgangskontrollen ikke kendte noter som
forælder. Et billede midt i den tekst, der navngav det, svarede altså "findes
ikke". Den følger nu noten, som følger sit projekt — samme kæde som alt andet.

AppleScriptet gemmer nu hver vedhæftning og skriver en henvisning til den i noten.
Vedhæftninger uden fil bag sig — links til websider, kort — springes over frem for
at vælte kørslen.

Migration 0021.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-19 19:12:54 +02:00
co-authored by Claude Opus 5
parent 11fdbefe1b
commit 414a35eaba
12 changed files with 615 additions and 8 deletions
+31
View File
@@ -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
+24
View File
@@ -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
+173
View File
@@ -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"
}
+166
View File
@@ -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")
}
}
}
+1
View File
@@ -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)
+24 -8
View File
@@ -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, &noteID, &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
@@ -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;
+98
View File
@@ -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")\""
+3
View File
@@ -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`,
+3
View File
@@ -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',
+3
View File
@@ -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',
@@ -1,4 +1,5 @@
<script>
/** Import, export and project templates. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
@@ -57,6 +58,34 @@
// --- Todoist import ------------------------------------------------------------
let importing = $state(false);
let importingNotes = $state(false);
/**
* A zip of Markdown files becomes notes.
*
* The whole archive in one request rather than a file at a time: the pictures
* have to be in the same request as the notes that name them, or the links
* cannot be rewritten.
*/
async function importNotes(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
importingNotes = true;
try {
const result = await api.importNotes(file);
app.toast(
t('data.notesImported', {
n: result.created ?? 0,
files: result.files ?? 0
})
);
} catch (e) {
app.toast(humanMessage(e));
} finally {
importingNotes = false;
event.target.value = '';
}
}
let result = $state(null);
async function importTodoist(event) {
@@ -344,6 +373,21 @@
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
</div>
<!-- The way back in, and from anywhere else: a folder of Markdown is what
Obsidian, Bear and iA Writer all produce, and what the Apple Notes script in
tools/ writes. Pictures beside the files come with them. -->
<div class="field">
<label for="import-notes">{t('data.importNotes')}</label>
<input
id="import-notes"
type="file"
accept=".zip,application/zip"
onchange={importNotes}
disabled={importingNotes}
/>
<p class="hint">{t('data.importNotesHint')}</p>
</div>
<div class="field">
<label for="export-project">{t('data.exportOne')}</label>
<select id="export-project" bind:value={exportProject}>