From f6c6bdccbbff173b35d575210cc6f41e426597cb Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 21:44:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(notes):=20projektmenuen=20st=C3=A5r=20ved?= =?UTF-8?q?=20det=20#=20der=20bliver=20skrevet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/pak-noter-i-portioner.py | 88 ++++++++++++++++++++++++ web/src/lib/components/NoteEditor.svelte | 51 ++++++++++++-- 2 files changed, 134 insertions(+), 5 deletions(-) create mode 100755 tools/pak-noter-i-portioner.py diff --git a/tools/pak-noter-i-portioner.py b/tools/pak-noter-i-portioner.py new file mode 100755 index 0000000..67ef828 --- /dev/null +++ b/tools/pak-noter-i-portioner.py @@ -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()) diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index f509b09..0e94666 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -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 @@ } -
+
@@ -475,10 +511,15 @@ >
{#if suggestions.length} - -
    + +
      {#each suggestions as project, i (project.id)}