Compare commits

...
2 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 2ffbd9533b fix(notes): billederne i en importeret note pegede på ingenting
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 <[email protected]>
2026-08-19 22:02:20 +02:00
Kristian WindandClaude Opus 5 f6c6bdccbb fix(notes): projektmenuen står ved det # der bliver skrevet
Den var lagt fast øverst i ruden, med den begrundelse at en menu, der følger
markøren, rykker sig et par pixels for hvert bogstav. Det er rigtigt, og det var
alligevel forkert: i en note på en skærmfuld skriver man `#` nederst og får en
menu i toppen — fire hundrede pixels fra det, øjet ser på, og langt nok væk til
at man ikke opdager, at den er der.

Begge dele kan lade sig gøre. Positionen måles, når menuen åbner, og ikke igen
mens listen snævres ind: den står ved tegnet, og den står stille.

Målt på `#term` frem for på markøren, fordi et sammenfaldet område ikke har nogen
kasse på en tom linje. Klemt inden for ruden, og vendt op over linjen, når der
ikke er plads under — ellers ville en note, der er skrevet helt ned i bunden,
lægge menuen uden for skærmen.

Og pak-noter-i-portioner.py, fordi en Apple Noter-eksport er hundrede og
enoghalvfems megabyte, hvoraf teksten er fire: importen tager 64 MB ad gangen, og
tunnelen foran den tager mindre. Delt efter hvad en note vejer med sine billeder,
og altid med billederne i samme portion som den note, der peger på dem.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 21:44:03 +02:00
5 changed files with 225 additions and 18 deletions
+42 -6
View File
@@ -394,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
}
defer f.Close()
// Everything is served as a download rather than rendered. An uploaded SVG or
// HTML file displayed inline would run its own script on this origin, with the
// session cookie attached — attachments are the one place a user supplies
// content that another user opens.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
// Almost everything is served as a download rather than rendered. An uploaded
// SVG or HTML file displayed inline would run its own script on this origin,
// with the session cookie attached — attachments are the one place a user
// supplies content that another user opens.
//
// The exception is a short list of raster images, and it is not a softening of
// that rule but what makes a note work: a note carries its pictures in the text
// as `![](…)`, and served as octet-stream every one of them is a broken image.
// The whole Apple Notes import exists to bring those pictures along.
//
// Safe because it is an allowlist of formats that cannot carry script, sent as
// the exact type with `nosniff` — so the browser must treat it as that image or
// not at all, and can never be talked into reading it as HTML. SVG is
// deliberately absent: it is a document, not a picture, and it is the one image
// type that can run script.
disposition := "attachment"
contentType := "application/octet-stream"
if inlineImage[a.MimeType] {
disposition = "inline"
contentType = a.MimeType
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
@@ -408,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
}
// inlineImage is what may be shown rather than downloaded.
//
// Raster formats only, and named one by one rather than matched on an "image/"
// prefix: the prefix would let image/svg+xml through, which is a document that
// can run script, and a list that grows by accident is how that happens.
var inlineImage = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
"image/avif": true,
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
"image/heic": true,
"image/heif": true,
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
+22 -5
View File
@@ -7,6 +7,7 @@ import (
"mime"
"net/http"
"path"
"sort"
"strings"
"time"
@@ -421,10 +422,20 @@ func (s *Server) attachToNote(
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.
// The three ways a Markdown file names a neighbouring image: by its path
// inside the archive, by the path relative to the note, and by its bare name.
//
// The archive path was missing, and it is the one an Apple Notes export
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
// root beside the folder. Only the bare name matched, so only the bare name
// was replaced, and every picture in every imported note ended up pointing at
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
// stored and attached correctly; it was the link that was wrong.
relative := strings.TrimPrefix(path.Join(dir, base), "./")
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
refs := []string{f.Name, relative, base}
if !strings.Contains(body, f.Name) &&
!strings.Contains(body, relative) &&
!strings.Contains(body, base) {
continue
}
@@ -441,9 +452,15 @@ func (s *Server) attachToNote(
continue
}
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
// stående foran adressen, og linket peger på ingenting.
url := "/api/v1/attachments/" + a.ID
body = strings.ReplaceAll(body, relative, url)
body = strings.ReplaceAll(body, base, url)
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
for _, ref := range refs {
if ref != "" {
body = strings.ReplaceAll(body, ref, url)
}
}
attached++
}
return body, attached
+27 -2
View File
@@ -255,11 +255,36 @@ def main() -> int:
# 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)
if lines and lines[0].lstrip("# ").strip() == title.strip():
lines = lines[1:]
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)
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
gangen, og tunnelen foran den tager mindre — så det skal deles.
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
får hver deres. En note og dens billeder ligger altid i samme portion, for
linkene i teksten er relative, og en note, hvis billeder kom med i næste
portion, ville pege på ingenting.
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
"""
import os
import re
import sys
import zipfile
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
# portion mere end en, der ryger på målstregen.
DEFAULT_LIMIT_MB = 40
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
def main() -> int:
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
out_dir = src.rstrip("/") + "-portioner"
os.makedirs(out_dir, exist_ok=True)
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
if not notes:
print(f"Ingen .md-filer i {src}")
return 1
# Hvad hver note vejer, og hvad den trækker med sig.
weighed = []
for name in notes:
path = os.path.join(src, name)
text = open(path, encoding="utf-8", errors="replace").read()
files = []
size = os.path.getsize(path)
for rel in LINK.findall(text):
f = os.path.join(src, rel)
if os.path.exists(f):
files.append(rel)
size += os.path.getsize(f)
weighed.append((name, files, size))
# En note, der alene er større end grænsen, får sin egen portion frem for at
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
# kan sendes.
batches, current, current_size = [], [], 0
for item in weighed:
if current and current_size + item[2] > limit:
batches.append(current)
current, current_size = [], 0
current.append(item)
current_size += item[2]
if current:
batches.append(current)
print(f"{len(notes)} noter → {len(batches)} portioner\n")
for i, batch in enumerate(batches, start=1):
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
seen = set()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
for name, files, _ in batch:
z.write(os.path.join(src, name), name)
for rel in files:
if rel in seen:
continue
seen.add(rel)
z.write(os.path.join(src, rel), rel)
mb = os.path.getsize(zip_path) / 1024 / 1024
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
print(f"\nLigger i:\n {out_dir}")
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
return 0
if __name__ == "__main__":
sys.exit(main())
+46 -5
View File
@@ -191,6 +191,9 @@
let suggestions = $state([]);
let chosen = $state(0);
// Hvor forslagslisten skal stå, og wrappen den måles imod.
let menuAt = $state(null);
let wrap;
/**
* The word being typed after a #, if that is what is happening.
@@ -210,6 +213,33 @@
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
}
/**
* Hvor `#` står, målt i wrappens koordinater.
*
* Målt på selve tegnet og ikke på markøren: et sammenfaldet område har ingen
* kasse på en tom linje, mens `#term` altid har en, fordi der står noget i den.
*
* Klemt inden for ruden, så en note skrevet helt ude i højre side ikke sender
* menuen ud over kanten, og vendt op over linjen, når der ikke er plads under.
*/
function tagPosition(partial) {
if (!wrap) return null;
const range = document.createRange();
range.setStart(partial.node, Math.max(0, partial.start - 1));
range.setEnd(partial.node, partial.start + partial.term.length);
const at = range.getBoundingClientRect();
const box = wrap.getBoundingClientRect();
if (!at.width && !at.height) return null;
const MENU = { w: 210, h: 240 };
const below = at.bottom - box.top + 6;
const flip = at.bottom + MENU.h > box.bottom && at.top - box.top > MENU.h;
return {
top: flip ? at.top - box.top - MENU.h - 6 : below,
left: Math.max(0, Math.min(at.left - box.left, box.width - MENU.w))
};
}
function readSuggestions() {
const partial = partialTag();
if (!partial) {
@@ -218,6 +248,12 @@
}
const term = partial.term.toLowerCase();
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
// man skriver — den stod før øverst i ruden, hvilket i en lang note er et
// helt andet sted end det, øjet er — men den må heller ikke rykke sig et par
// pixels for hvert bogstav, mens listen snævres ind.
if (!suggestions.length) menuAt = tagPosition(partial);
// Alle der passer, ikke de seks første.
//
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
@@ -371,7 +407,7 @@
}
</script>
<div class="wrap">
<div class="wrap" bind:this={wrap}>
<!-- Nothing in here may take focus. A click that moves focus out of the editor
collapses the selection first, so the format lands on nothing — the button
appears to do nothing at all, which is how it read the first time. -->
@@ -475,10 +511,15 @@
></div>
{#if suggestions.length}
<!-- Anchored to the editor rather than to the caret. Following the caret means
measuring it, and a list that jumps a few pixels as you type is worse than
one that sits still. -->
<ul class="suggestions" role="listbox" bind:this={list}>
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
står stille, mens listen snævres ind. -->
<ul
class="suggestions"
role="listbox"
bind:this={list}
style:top={menuAt ? `${menuAt.top}px` : null}
style:left={menuAt ? `${menuAt.left}px` : null}
>
{#each suggestions as project, i (project.id)}
<li>
<button