Release / Build and publish image (push) Failing after 9m54s
Fundet ved at importere rigtige noter frem for ti prøvenoter. Linket blev skrevet halvt om. attachToNote kendte to måder, en note kan nævne en fil på — den bare filnavn og stien i forhold til noten — men ikke den, en Apple Noter-eksport faktisk skriver: `vedhaeftninger/751-2-IMG_0615.heic`, med noten i roden ved siden af mappen. Kun det bare navn matchede, så kun det blev erstattet, og hver eneste billede i hver eneste importerede note endte som `vedhaeftninger//api/v1/attachments/…`, som er ingenting. Filerne var gemt og hæftet rigtigt på; det var linket, der var forkert. Længste match først, ellers bliver mappen stående foran adressen. Og billederne kunne alligevel ikke vises. Alt blev serveret som application/octet-stream med Content-Disposition: attachment, hvilket er en god regel med en god grund — en uploadet SVG eller HTML vist inline ville køre sit eget script på dette domæne med sessionscookien på. Men en note bærer sine billeder i teksten som ``, og under den regel er hvert eneste af dem et brudt billede. Hele importen findes for at tage dem med. En kort liste af rasterformater vises nu inline med deres egen type. Det er ikke en opblødning: listen er navngivet ét format ad gangen frem for matchet på "image/", fordi præfikset ville lukke image/svg+xml ind — et dokument, der kan køre script — og en liste, der vokser ved et uheld, er præcis sådan det sker. nosniff står, så browseren skal læse filen som netop det billede eller slet ikke. Titlen stod to gange i fjorten noter. Noter kalder noten det, der står på de første linjer sat sammen med mellemrum, så en note, hvis to første linjer er "CPU" og "CAN", hedder "CPU CAN" — og sammenligningen med kun den første linje gik fri. Overskrifterne øverst tages nu med, så længe de tilsammen stadig er begyndelsen på titlen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334 lines
13 KiB
Python
Executable File
334 lines
13 KiB
Python
Executable File
#!/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.
|
|
# Titlen kan være delt over flere overskrifter.
|
|
#
|
|
# Noter kalder noten det, der står på de første linjer, sat sammen med
|
|
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
|
|
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
|
|
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
|
|
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
|
|
#
|
|
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
|
|
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
|
|
lines = text.split("\n")
|
|
while lines and not lines[0].strip():
|
|
lines.pop(0)
|
|
|
|
taken, joined = 0, ""
|
|
for line in lines:
|
|
if not line.strip():
|
|
taken += 1
|
|
continue
|
|
if not line.lstrip().startswith("#"):
|
|
break
|
|
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
|
|
if not title.strip().startswith(candidate):
|
|
break
|
|
joined, taken = candidate, taken + 1
|
|
if joined == title.strip():
|
|
break
|
|
|
|
if joined == title.strip():
|
|
lines = lines[taken:]
|
|
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())
|