en linje → linjeskift
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
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
, og dens starttag tømmer den forrige linje — så mellem to
#
-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: står inde i den , 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("")
self.line.append("
")
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 -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
,
# 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: ligger inde i en
, 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})\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())