feat(notes): formateringen overlever, og monospace bliver til en terminal

Konverteringen læser nu Apple Noters HTML frem for at flade den ud med textutil.
Overskrifter, fed, kursiv, understreget, lister og monospace kommer med. En note er
ikke kun sine ord — et referat med overskrifter er ikke det samme referat som en
klump — og ti prøvenoter viste hvad det kostede.

Skrevet om til Python, fordi HTML skal læses og ikke gættes med regulære udtryk.

Den alvorligste fejl, prøvekørslen fandt: navn og krop blev hentet i hvert sit
kald, og Noter sorterer listen efter ændringsdato. Flyttede noget sig imellem de
to kald, fik en note én titel og en andens indhold. På ti noter er det synligt; på
tolv hundrede ville det være ubemærket rod. Der hentes nu ved id, og navn og krop
kommer i samme kald.

Sammenhængende monospace-linjer bliver til én blok. Hver linje i Noter er pakket
ind i sin egen div, hvis starttag tømmer den forrige — så mellem to tt-linjer kom
der altid en tom flush forbi, og hver linje i en terminaludskrift blev sin egen
blok.

Og i editoren ser en kodeblok ud som en terminal: mørk flade, monospace, sproget i
hjørnet, farver på strenge, tal, kommentarer, nøgleord og prompten. Monospace ER
kode i praksis, og en mørk flade siger det på afstand.

Farverne lægges på de blokke, markøren IKKE står i. At farve mens nogen skriver
betyder at bygge indholdet om ved hvert tastetryk og sætte markøren tilbage
bagefter — det virker ni gange ud af ti og flytter markøren midt i en sætning den
tiende.

Et API-token er ét ord, der er bredere end skærmen. Uden min-width: 0 skubbede en
sådan note hele listen bredere og klemte editoren sammen — på en note, man ikke
engang havde åben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-19 20:31:10 +02:00
co-authored by Claude Opus 5
parent 08fc355bf9
commit 43ad60a84a
7 changed files with 633 additions and 141 deletions
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Apple Noter → Markdown, med formateringen i behold.
Den første udgave af det her brugte `textutil -convert txt`, som er hurtigt at
skrive og smider alt væk: overskrifter, fed, monospace, lister. Ti prøvenoter
viste hvad det koster — en note er ikke kun sine ord, den er også hvordan de er
sat op, og et referat med overskrifter er ikke det samme referat som en klump.
Apple Noter er HTML indeni, og HTML kan læses. Kortet er lille, fordi Noter selv
bruger et lille sæt:
<h1> <h2> <h3> overskrifter → # ## ###
<b> <i> <u> fremhævning → ** * <u>
<tt> monospace → ```-blok
<ul><li> punktliste → -
<ol><li> nummereret → 1.
<div> en linje → linjeskift
<br> en tom linje → tom linje
Kører på din egen maskine og sender ingenting nogen steder.
./apple-notes-til-markdown.py ~/Desktop/noter # alle
./apple-notes-til-markdown.py ~/Desktop/noter 10 # de første ti
"""
import html
import os
import re
import subprocess
import sys
from html.parser import HTMLParser
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
FORBIDDEN = set('/\\:*?"<>|')
def osascript(script: str) -> str:
"""Ét kald til Noter. Fejl er tomme frem for fatale: en enkelt note, der ikke
kan læses, skal ikke stoppe en kørsel på tolv hundrede."""
try:
out = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=60, check=False,
)
return out.stdout.rstrip("\n")
except subprocess.SubprocessError:
return ""
class NoteParser(HTMLParser):
"""Apple Noters HTML til Markdown.
Linjebaseret, fordi Noter er linjebaseret: hver <div> er en linje, og det er
dét, personen skrev. En rigtig Markdown-gengiver ville slå linjer sammen til
afsnit, hvilket er korrekt Markdown og forkert i forhold til noten.
"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.lines: list[str] = []
self.line: list[str] = []
self.block: str | None = None # h1, h2 …
self.mono = False # den aktuelle linje er monospace
self.mono_lines: list[str] = [] # samlet op til én kodeblok
self.list_kind: list[str] = [] # ul/ol i lag
self.list_no: list[int] = []
self.marks: list[str] = [] # b, i, u i lag
# --- linjer ---------------------------------------------------------------
def _flush_line(self):
text = "".join(self.line).rstrip()
self.line = []
mono, self.mono = self.mono, False
if mono:
# Monospace samles op og skrives som én blok, ikke som en række
# enkeltlinjer: en terminaludskrift er én ting.
self.mono_lines.append(text)
return
# En tom linje lukker ikke en kodeblok. Hver linje i Noter er pakket ind i
# sin egen <div>, og dens starttag tømmer den forrige linje — så mellem to
# <tt>-linjer kommer der altid en tom flush forbi. Lod man den lukke
# blokken, blev hver eneste linje i en terminaludskrift sin egen blok.
if not text and self.mono_lines:
return
self._close_mono()
if self.block:
self.lines.append(BLOCK_PREFIX[self.block] + text if text else "")
return
if self.list_kind:
if not text:
return
if self.list_kind[-1] == "ol":
self.list_no[-1] += 1
self.lines.append(f"{self.list_no[-1]}. {text}")
else:
self.lines.append(f"- {text}")
return
self.lines.append(text)
def _close_mono(self):
if not self.mono_lines:
return
# Tomme linjer i enderne er afstand omkring blokken, ikke en del af koden.
block = self.mono_lines
while block and not block[0].strip():
block.pop(0)
while block and not block[-1].strip():
block.pop()
if block:
self.lines.append("```")
self.lines.extend(block)
self.lines.append("```")
# Luft efter blokken. Uden den klæber den næste sætning til den
# afsluttende hegnslinje, og en Markdown-læser er i sin gode ret til
# at læse dem som ét.
self.lines.append("")
self.mono_lines = []
# --- tags -----------------------------------------------------------------
def handle_starttag(self, tag, attrs):
if tag in BLOCK_PREFIX:
self._flush_line()
self.block = tag
elif tag in ("ul", "ol"):
self._flush_line()
self.list_kind.append(tag)
self.list_no.append(0)
elif tag == "li":
self._flush_line()
elif tag in ("tt", "code", "pre"):
# Ikke et linjeskift: <tt> står inde i den <div>, der ER linjen.
# 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("**")
elif tag in ("i", "em"):
self.marks.append("*")
self.line.append("*")
elif tag == "u":
self.marks.append("</u>")
self.line.append("<u>")
elif tag == "br":
self._flush_line()
elif tag == "div":
self._flush_line()
def handle_endtag(self, tag):
if tag in BLOCK_PREFIX:
self._flush_line()
self.block = None
elif tag in ("ul", "ol"):
self._flush_line()
if self.list_kind:
self.list_kind.pop()
self.list_no.pop()
elif tag == "li":
self._flush_line()
elif tag in ("tt", "code", "pre"):
# Blokken lukkes IKKE her. To <tt>-linjer efter hinanden er én
# terminaludskrift, ikke to; den lukkes, når en linje uden monospace
# skrives ud, hvilket _flush_line sørger for.
pass
elif tag in ("b", "strong", "i", "em", "u"):
if self.marks:
self.line.append(self.marks.pop())
elif tag == "div":
self._flush_line()
def handle_data(self, data):
self.line.append(data)
def result(self) -> str:
self._flush_line()
self._close_mono()
# Tomme linjer i stimer bliver til én: Noter er fuld af <div><br></div>,
# og fem tomme linjer i træk er ikke afstand, det er rod.
out, blanks = [], 0
for line in self.lines:
if line.strip():
blanks = 0
out.append(line)
else:
blanks += 1
if blanks == 1:
out.append("")
while out and not out[-1].strip():
out.pop()
return "\n".join(out)
def to_markdown(body: str) -> str:
p = NoteParser()
p.feed(body)
p.close()
return p.result()
def safe_name(title: str, fallback: str) -> str:
name = "".join("-" if c in FORBIDDEN or ord(c) < 32 else c for c in title).strip()
name = name.rstrip(" .")
return name[:80] or fallback
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
os.makedirs(out_dir, exist_ok=True)
# Id'erne først, i ét kald.
#
# Ikke "note 1", "note 2" …: Noter sorterer listen efter ændringsdato, så et
# indeks peger på noget andet, hvis noget flytter sig imellem to kald — og
# navn og krop blev hentet hver for sig. Resultatet var en note med én titel
# og en andens indhold, hvilket først ses, når man læser dem igennem. Et id
# peger på den samme note hver gang.
ids = osascript(
'tell application "Notes" to return id of every note'
)
if not ids:
print("Noter svarer ikke. Første gang skal macOS have lov — se efter dialogen.")
return 1
ids = [x.strip() for x in ids.split(",") if x.strip()]
total = len(ids)
print(f"Fandt {total} noter.")
if limit and limit < total:
ids = ids[:limit]
total = limit
print(f"Tager de første {total}.")
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)
# Kroppen begynder med notens egen titel. Skrives der en overskrift
# ovenover, står titlen to gange i hver eneste note.
#
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
# starttag tømmer linjen før den — så første linje er tom, og
# sammenligningen så på ingenting.
lines = text.split("\n")
while lines and not lines[0].strip():
lines.pop(0)
if lines and lines[0].lstrip("# ").strip() == title.strip():
lines = lines[1:]
while lines and not lines[0].strip():
lines.pop(0)
text = "\n".join(lines)
name = safe_name(title, f"note-{i}")
used[name] = used.get(name, 0) + 1
path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
with open(path, "w", encoding="utf-8") as f:
f.write(f"# {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:
os.makedirs(attachments_dir, exist_ok=True)
with open(path, "a", encoding="utf-8") as f:
for j in range(1, int(count) + 1):
a_name = osascript(
f'tell application "Notes" to return name of attachment {j} '
f'of note id "{note_id}"'
) or f"bilag-{i}-{j}"
a_file = f"{i}-{j}-" + safe_name(a_name, f"bilag-{i}-{j}")
target = os.path.join(attachments_dir, a_file)
# Nogle vedhæftninger — links til websider, kort — har ingen
# fil bag sig og fejler her. De springes over.
osascript(
f'tell application "Notes" to save attachment {j} '
f'of note id "{note_id}" in POSIX file "{target}"'
)
if os.path.exists(target):
f.write(f"\n![](vedhaeftninger/{os.path.basename(target)})\n")
if i % 25 == 0:
print(f" {i} / {total}")
print(f"\nFærdig. {total} noter ligger i:\n {out_dir}\n")
print("Pak dem og importér zip'en under Indstillinger → Data:")
print(f' cd "{os.path.dirname(out_dir)}" && zip -r "{os.path.basename(out_dir)}.zip" "{os.path.basename(out_dir)}"')
return 0
if __name__ == "__main__":
sys.exit(main())