diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 77fd8d7..cf676d1 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -369,13 +369,24 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) { continue } - n := &store.Note{CreatedBy: user.ID, Body: string(body)} + text, wrote, changed := readFrontMatter(string(body)) + + n := &store.Note{CreatedBy: user.ID, Body: text} if err := s.db.SaveNote(r.Context(), n); err != nil { s.log.Warn("import note", "err", err, "file", name) skipped++ continue } + // After the save, not as part of it: SaveNote owns `updated_at` and is right + // to, so the dates a note had before this program existed are written over + // the top rather than passed through it. + if !wrote.IsZero() || !changed.IsZero() { + if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil { + s.log.Warn("import note dates", "err", err, "file", name) + } + } + // 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. @@ -466,6 +477,67 @@ func (s *Server) attachToNote( return body, attached } +// readFrontMatter takes the YAML block off the top of a Markdown file and reads +// the two dates out of it, returning the rest of the text. +// +// Not a YAML parser, and deliberately not: the block this reads is the one this +// program writes, plus whatever Obsidian and Bear write, and all of them use the +// same handful of `key: value` lines. Pulling in a parser to read two dates would +// be a dependency that can fail in ways nobody here would recognise. +// +// A file with no front matter comes back untouched — that is the normal case, and +// it must never lose its first line to a rule about a block that is not there. +func readFrontMatter(body string) (text string, created, modified time.Time) { + if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") { + return body, time.Time{}, time.Time{} + } + rest := body[strings.Index(body, "\n")+1:] + + end := strings.Index(rest, "\n---") + if end < 0 { + // An opening fence with no close is not front matter; it is a horizontal + // rule somebody wrote at the top of their note. + return body, time.Time{}, time.Time{} + } + block := rest[:end] + after := rest[end+len("\n---"):] + after = strings.TrimPrefix(after, "\r") + after = strings.TrimPrefix(after, "\n") + + for _, line := range strings.Split(block, "\n") { + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`)) + switch strings.ToLower(strings.TrimSpace(key)) { + case "created", "created_at", "date": + created = parseNoteTime(value) + case "modified", "updated", "updated_at": + modified = parseNoteTime(value) + } + } + return strings.TrimLeft(after, "\n"), created, modified +} + +// parseNoteTime accepts the shapes these files actually carry. A date without a +// time is that day at midnight, local — the note was written on that day, and +// pretending to know the hour would be worse than not having one. +func parseNoteTime(v string) time.Time { + for _, layout := range []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + } { + if t, err := time.ParseInLocation(layout, v, time.Local); err == nil { + return t + } + } + return time.Time{} +} + // 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 { diff --git a/internal/store/notes.go b/internal/store/notes.go index f387219..b27d7dd 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -247,6 +247,37 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, LIMIT ?`, expr, limit) } +// SetNoteTimes writes the dates a note had somewhere else. +// +// Only the import calls this, and it exists because SaveNote is right to refuse: +// `updated_at` is when this program last wrote the note, and letting any caller +// set it would make the column mean whatever the last writer felt like. A note +// brought in from Apple Notes is the one case where the dates are facts from +// before this database existed — and without them a move of twelve hundred notes +// is a pile that was all written the same evening, which throws away the order +// they were written in. That order is half of what an archive is. +func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time.Time) error { + if created.IsZero() && updated.IsZero() { + return nil + } + // COALESCE, so a note carrying only one of the two keeps what it already had + // for the other rather than being stamped with the zero time — which would sort + // it to the year one. + var c, u any + if !created.IsZero() { + c = created.Unix() + } + if !updated.IsZero() { + u = updated.Unix() + } + _, err := db.ExecContext(ctx, ` + UPDATE notes + SET created_at = COALESCE(?, created_at), + updated_at = COALESCE(?, updated_at) + WHERE id = ?`, c, u, id) + return err +} + // DeleteNote puts it in the trash. Nothing here removes a row. func (db *DB) DeleteNote(ctx context.Context, id string) error { _, err := db.ExecContext(ctx, diff --git a/tools/apple-notes-til-markdown.py b/tools/apple-notes-til-markdown.py index f0cb598..863be42 100755 --- a/tools/apple-notes-til-markdown.py +++ b/tools/apple-notes-til-markdown.py @@ -31,6 +31,9 @@ import sys from html.parser import HTMLParser BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "} +# Fra lukkemærke tilbage til åbnemærke, når et mærke skal deles over to linjer. +OPENER = {"**": "**", "*": "*", "": ""} +MARKS_ONLY = re.compile(r"(?:\*\*|\*|~~|||\s)*") FORBIDDEN = set('/\\:*?"<>|') @@ -69,8 +72,26 @@ class NoteParser(HTMLParser): # --- linjer --------------------------------------------------------------- def _flush_line(self): + # Et mærke, der står åbent, når linjen slutter, lukkes her og åbnes igen på + # den næste. + # + # Noter skriver Møde forberedelse
: linjeskiftet ligger *inde i* + # det fede. Skrev man linjen ud uden videre, blev de to stjerner, der lukker, + # til den næste linjes første tegn — "**Møde forberedelse" og så en linje med + # "**" på. Markdown er linjebaseret; et mærke, der skal over et linjeskift, + # findes ikke, og skal derfor deles i to. + reopen = list(self.marks) + for closer in reversed(self.marks): + self.line.append(closer) + text = "".join(self.line).rstrip() - self.line = [] + + # En linje, der kun er mærker, er en tom linje. Det sker for den sidste + # stump af et fedt afsnit, hvor der ikke står andet end linjeskiftet. + if MARKS_ONLY.fullmatch(text): + text = "" + + self.line = [OPENER[c] for c in reopen] mono, self.mono = self.mono, False if mono: @@ -121,11 +142,42 @@ class NoteParser(HTMLParser): self.lines.append("") self.mono_lines = [] + def _open(self, opener: str, closer: str): + """Åbner et mærke — eller fortryder det, der lige lukkede. + + Noter deler også fremhævet tekst op i kørsler: "Møde" står som + Møde, fordi der er en attributkørsel bag hver stump. Skrevet + ligeud bliver det **M****øde**, som er ét ord i fed sagt på den grimmest + mulige måde — og for kursiv bliver det *M**øde*, hvor de to stjerner i + midten læses som fed af enhver Markdown-læser. Ordet kom ud forkert. + + Lukkes et mærke og åbnes det samme igen med det samme, er der ikke sket + noget imellem. Så tages lukningen tilbage, og de to kørsler er ét mærke. + """ + if self.line and self.line[-1] == closer: + self.line.pop() + else: + self.line.append(opener) + self.marks.append(closer) + # --- tags ----------------------------------------------------------------- def handle_starttag(self, tag, attrs): if tag in BLOCK_PREFIX: - self._flush_line() + # En overskrift, der fortsætter den forrige, er den samme linje. + # + # Noter deler en linje op i så mange stumper, den har brug for — en + # overskrift skrevet i ét hug står som + #

A

alb

org Kom

mune

, fordi der er en + # attributkørsel bag hver stump. Læste man et linjeskift ved hver

, + # blev "Aalborg Kommune" til fire overskrifter oven på hinanden, og + # noten så ødelagt ud på den mest synlige måde, der findes: i sin egen + # titel. + # + # Der brydes derfor kun, når blokken skifter til en anden slags. Selve + # linjeskiftene kommer fra
og
, som er dem, der betyder det. + if self.block != tag: + self._flush_line() self.block = tag elif tag in ("ul", "ol"): self._flush_line() @@ -138,23 +190,24 @@ class NoteParser(HTMLParser): # Mærket sættes på linjen og aflæses, når den skrives ud. self.mono = True elif tag in ("b", "strong"): - self.marks.append("**") - self.line.append("**") + self._open("**", "**") elif tag in ("i", "em"): - self.marks.append("*") - self.line.append("*") + self._open("*", "*") elif tag == "u": - self.marks.append("
") - self.line.append("") + self._open("", "") elif tag == "br": self._flush_line() + self.block = None elif tag == "div": self._flush_line() + self.block = None def handle_endtag(self, tag): if tag in BLOCK_PREFIX: - self._flush_line() - self.block = None + # Ikke her. Lukkes overskriften ved sit eget sluttag, kan den næste + # stump af samme linje ikke se, at den hører til — det er
og
, + # der afgør, hvor linjen holder op. + pass elif tag in ("ul", "ol"): self._flush_line() if self.list_kind: @@ -172,6 +225,7 @@ class NoteParser(HTMLParser): self.line.append(self.marks.pop()) elif tag == "div": self._flush_line() + self.block = None def handle_data(self, data): self.line.append(data) @@ -208,6 +262,95 @@ def safe_name(title: str, fallback: str) -> str: return name[:80] or fallback +# Skilletegn, der ikke står i en note: enhedsadskillere fra ASCII, som ingen +# skriver med et tastatur. +FIELD, RECORD = "\u241f", "\u241e" + + +def osascript_file(script: str) -> str: + """Som osascript(), men til et manuskript, der er for stort til -e.""" + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".applescript", delete=False, + encoding="utf-8") as f: + f.write(script) + path = f.name + try: + out = subprocess.run(["osascript", path], capture_output=True, text=True, + timeout=600, check=False) + return out.stdout + except subprocess.SubprocessError: + return "" + finally: + os.unlink(path) + + +def fetch(ids: list[str]) -> list[dict]: + """Navn, krop og datoer for en håndfuld noter i ét kald. + + Ét kald pr. note var det oplagte, og det tog halvanden time for tolv hundrede + — ikke fordi Noter er langsom, men fordi hvert kald er en proces, der skal + startes. Det gjorde det umuligt at rette en fejl i oversættelsen og se + resultatet: hver runde var en frokostpause. Nu hentes de i klumper, og hele + eksporten tager minutter. + + Klumper frem for alle på én gang, fordi en enkelt uleselig note ellers ville + tage hele kørslen med sig — og fordi svaret skal kunne være i hukommelsen. + + Datoerne læses som tal frem for som tekst. `creation date` giver en dato + formateret efter maskinens sprog, og den ville skulle gættes tilbage; year, + month og day er de samme tal på enhver maskine. + """ + quoted = ", ".join('"' + i.replace('"', '\\"') + '"' for i in ids) + script = f''' +tell application "Notes" + set out to "" + repeat with theId in {{{quoted}}} + try + set n to note id theId + set c to creation date of n + set m to modification date of n + set out to out & (name of n) & "{FIELD}" & ¬ + ((year of c) & "-" & (month of c as integer) & "-" & (day of c) & "T" & ¬ + (hours of c) & ":" & (minutes of c) & ":" & (seconds of c)) & "{FIELD}" & ¬ + ((year of m) & "-" & (month of m as integer) & "-" & (day of m) & "T" & ¬ + (hours of m) & ":" & (minutes of m) & ":" & (seconds of m)) & "{FIELD}" & ¬ + (count of attachments of n) & "{FIELD}" & ¬ + (body of n) & "{RECORD}" + on error + set out to out & "{FIELD}{FIELD}{FIELD}{FIELD}" & "{RECORD}" + end try + end repeat + return out +end tell +''' + raw = osascript_file(script) + rows = [] + for chunk in raw.split(RECORD): + if not chunk.strip("\n "): + continue + parts = chunk.split(FIELD) + if len(parts) < 5: + rows.append({"name": "", "created": "", "modified": "", "files": 0, "body": ""}) + continue + rows.append({ + "name": parts[0].lstrip("\n"), "created": parts[1], "modified": parts[2], + "files": int(parts[3]) if parts[3].strip().isdigit() else 0, + "body": parts[4], + }) + return rows + + +def iso(stamp: str) -> str: + """"2026-8-17T12:55:14" → "2026-08-17T12:55:14". Nul foran, så det sorterer.""" + try: + date, _, time = stamp.partition("T") + y, mo, d = (int(x) for x in date.split("-")) + h, mi, se = (int(x) for x in time.split(":")) + return f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{se:02d}" + except (ValueError, AttributeError): + return "" + + def main() -> int: out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/Desktop/apple-noter") limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0 @@ -238,16 +381,15 @@ def main() -> int: used: dict[str, int] = {} attachments_dir = os.path.join(out_dir, "vedhaeftninger") - for i, note_id in enumerate(ids, start=1): - # Navn og krop i ét kald, adskilt af noget, der ikke står i en note. - # Hentet hver for sig kunne de nå at komme fra hver sin note. - both = osascript( - f'tell application "Notes" to tell note id "{note_id}" ' - f'to return name & "\u241E" & body' - ) - title, _, body = both.partition("\u241E") - title = title.strip() or f"uden titel {i}" - text = to_markdown(body) + CHUNK = 40 + fetched: list[dict] = [] + for start in range(0, len(ids), CHUNK): + fetched.extend(fetch(ids[start:start + CHUNK])) + print(f" hentet {min(start + CHUNK, len(ids))} / {total}") + + for i, (note_id, row) in enumerate(zip(ids, fetched), start=1): + title = row["name"].strip() or f"uden titel {i}" + text = to_markdown(row["body"]) # Kroppen begynder med notens egen titel. Skrives der en overskrift # ovenover, står titlen to gange i hver eneste note. @@ -293,18 +435,34 @@ def main() -> int: used[name] = used.get(name, 0) + 1 path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md" + # Datoerne i et hoved, som enhver anden Markdown-læser kender. + # + # Uden dem er en flytning af tolv hundrede noter en bunke, der alle er + # skrevet samme aften: rækkefølgen, som er halvdelen af hvad et arkiv er, + # findes ikke længere. Obsidian, Bear og alt andet læser den her blok, og + # verdandes egen import gør nu også. + created, modified = iso(row["created"]), iso(row["modified"]) + head = "" + if created or modified: + head = "---\n" + if created: + head += f"created: {created}\n" + if modified: + head += f"modified: {modified}\n" + head += "---\n\n" + with open(path, "w", encoding="utf-8") as f: - f.write(f"# {title}\n\n{text}\n") + f.write(f"{head}# {title}\n\n{text}\n") # Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en # note, der kommer over som ord alene, er ikke den note. - count = osascript( - f'tell application "Notes" to return count of attachments of note id "{note_id}"' - ) - if count.isdigit() and int(count) > 0: + # Antallet kom med i klumpen. Det var ét kald pr. note, og da kun hver + # tiende note har et bilag, var de ni af dem en proces startet for at få + # svaret nul. + if row["files"] > 0: os.makedirs(attachments_dir, exist_ok=True) with open(path, "a", encoding="utf-8") as f: - for j in range(1, int(count) + 1): + for j in range(1, row["files"] + 1): a_name = osascript( f'tell application "Notes" to return name of attachment {j} ' f'of note id "{note_id}"' diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index 0e94666..9a58860 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -805,6 +805,16 @@ margin-bottom: var(--s1); } + /* Et billede fylder sin egen bredde og ikke mere. Et foto fra en telefon er + fire tusind pixels bredt, og uden det her stikker det ud af arket. */ + .page :global(img) { + max-width: 100%; + height: auto; + border-radius: var(--radius-sm); + display: block; + margin: var(--s2) 0; + } + .page :global(blockquote) { margin: 0 0 var(--s2); padding-left: var(--s3); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 10d1170..91804b2 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -25,6 +25,10 @@ export const da = { 'notes.bold': 'Fed', 'notes.italic': 'Kursiv', 'notes.underline': 'Understreget', + 'notes.sortBy': 'Rækkefølge', + 'notes.sortUpdated': 'Senest rørt', + 'notes.sortCreated': 'Oprettet', + 'notes.sortTitle': 'Navn', 'notes.strike': 'Gennemstreget', 'notes.showSource': 'Vis som kode', 'notes.source': 'Notens kildetekst', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 19e1206..cec3a21 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -21,6 +21,10 @@ export const en = { 'notes.bold': 'Bold', 'notes.italic': 'Italic', 'notes.underline': 'Underline', + 'notes.sortBy': 'Order', + 'notes.sortUpdated': 'Last touched', + 'notes.sortCreated': 'Created', + 'notes.sortTitle': 'Name', 'notes.strike': 'Strikethrough', 'notes.showSource': 'Show as code', 'notes.source': 'Note source', diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js index 942ecae..6ecd648 100644 --- a/web/src/lib/richtext.js +++ b/web/src/lib/richtext.js @@ -39,12 +39,26 @@ const escapeHtml = (s) => /** Inline marks, innermost first so a bold word inside italics survives both. */ function inlineToHtml(text) { - return escapeHtml(text) - .replace(/<u>(.+?)<\/u>/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); + return ( + escapeHtml(text) + // Billedet først, og før alt andet. + // + // Der var ingen regel for det overhovedet, så en note fra Apple Noter + // viste `![](/api/v1/attachments/…)` som ord. Billedet var hentet, gemt og + // hæftet på noten — det eneste, der manglede, var at nogen tegnede det. + // + // Kun vores egen adresse. Et `![](http://…)` fra en indsat tekst ville + // ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at + // den er blevet læst — og en note er det sidste sted, man vil have en + // sporingspixel. Alt andet står som den tekst, det er. + .replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g, + (_, alt, src) => `${alt}`) + .replace(/<u>(.+?)<\/u>/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2') + ); } /** @@ -184,6 +198,11 @@ function inlineToMarkdown(node) { const inner = [...node.childNodes].map(inlineToMarkdown).join(''); switch (node.tagName) { + // Et billede har ingen tekst i sig, så uden det her læses det som ingenting + // — og den første gemning ville tage hvert billede ud af noten uden at + // nogen havde rørt det. + case 'IMG': + return `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`; case 'STRONG': case 'B': return inner ? `**${inner}**` : ''; diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index b884ed2..88283a1 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -19,6 +19,50 @@ import NoteEditor from '$lib/components/NoteEditor.svelte'; let notes = $state([]); + + /** + * Rækkefølgen, og hvorfor den kan vælges. + * + * Serveren svarer i én rækkefølge — senest rørt øverst — og det er den rigtige, + * når man arbejder: den note, man var i gang med, er den, man skal tilbage til. + * Den er også den eneste, der ikke kan svare på "hvad skrev jeg dengang". + * Tolv hundrede noter flyttet ind fra et andet program er et arkiv, og et arkiv + * læses efter, hvornår noget blev skrevet, og efter navn. + * + * Sorteret her frem for i en forespørgsel: hele listen er allerede hentet, den + * fylder ingenting, og et valg, der ikke koster et kald, kan skiftes så hurtigt + * som man kan ombestemme sig. + */ + const ORDERS = [ + { key: 'updated', label: 'notes.sortUpdated' }, + { key: 'created', label: 'notes.sortCreated' }, + { key: 'title', label: 'notes.sortTitle' } + ]; + + let order = $state('updated'); + + $effect(() => { + const saved = localStorage.getItem('notes.order'); + if (saved && ORDERS.some((o) => o.key === saved)) order = saved; + }); + + function setOrder(next) { + order = next; + localStorage.setItem('notes.order', next); + } + + // Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en + // note til favorit, og en sortering, der blandede dem ind igen, ville tage den. + let ordered = $derived( + [...notes].sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + if (order === 'title') { + return (a.title || '').localeCompare(b.title || '', 'da', { sensitivity: 'base' }); + } + const field = order === 'created' ? 'created_at' : 'updated_at'; + return new Date(b[field]).getTime() - new Date(a[field]).getTime(); + }) + ); let selected = $state(null); let query = $state(''); let status = $state('loading'); @@ -233,7 +277,22 @@