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 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-19 21:44:03 +02:00
co-authored by Claude Opus 5
parent 9e687185fb
commit f6c6bdccbb
2 changed files with 134 additions and 5 deletions
+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