Compare commits

..
3 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 a4b12f3601 feat(notes): listen som Apple Noters, ⌘K finder noter, og de kan trækkes
Release / Build and publish image (push) Failing after 9m52s
Rækken har nu titel i samme størrelse som en opgavetitel — en note og en opgave er
to ting af samme slags, og hver sin størrelse får den ene til at se mindre vigtig
ud. Under den står dato og begyndelse på samme linje, som Apple Noter gør det: to
linjer pr. note frem for tre, og datoen først, fordi det er den, man skanner efter.
Ligger noten i et projekt, står det nedenunder.

⌘K søgte kun i opgaver og projekter. Feltet ser ud til at kunne finde alt, så det
er svært at gætte, at halvdelen af programmet ikke var med. Og mens jeg var der:
"Projekt" og "Opgave" stod hårdkodet på dansk i paletten.

En note kan trækkes ind på et projekt i sidebjælken — samme mål som en opgave,
fordi det er samme gestus: læg denne her.

To fejl i søgefeltet, begge Safaris egen type="search":
Fokusringen tvang sin egen hjørneradius og tegnede en firkantet bue uden om en
rund kant — to streger, hvor der skulle være én. Den følger nu elementets form,
overalt i programmet. Og feltet skiftede udseende ved fokus: forstørrelsesglasset
forsvandt og pilleformen blev firkantet. Det tegnes nu af os.

Trækket og palettens noter er ikke fastholdt i en prøve. ⌘K, tastning og en
debounce oven på hinanden gjorde den upålidelig, og en flakkende prøve siger
mindre end ingen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:46:05 +02:00
Kristian WindandClaude Opus 5 43ad60a84a feat(notes): formateringen overlever, og monospace bliver til en terminal
Konverteringen læser nu Apple Noters HTML frem for at flade den ud med textutil.
Overskrifter, fed, kursiv, understreget, lister og monospace kommer med. En note er
ikke kun sine ord — et referat med overskrifter er ikke det samme referat som en
klump — og ti prøvenoter viste hvad det kostede.

Skrevet om til Python, fordi HTML skal læses og ikke gættes med regulære udtryk.

Den alvorligste fejl, prøvekørslen fandt: navn og krop blev hentet i hvert sit
kald, og Noter sorterer listen efter ændringsdato. Flyttede noget sig imellem de
to kald, fik en note én titel og en andens indhold. På ti noter er det synligt; på
tolv hundrede ville det være ubemærket rod. Der hentes nu ved id, og navn og krop
kommer i samme kald.

Sammenhængende monospace-linjer bliver til én blok. Hver linje i Noter er pakket
ind i sin egen div, hvis starttag tømmer den forrige — så mellem to tt-linjer kom
der altid en tom flush forbi, og hver linje i en terminaludskrift blev sin egen
blok.

Og i editoren ser en kodeblok ud som en terminal: mørk flade, monospace, sproget i
hjørnet, farver på strenge, tal, kommentarer, nøgleord og prompten. Monospace ER
kode i praksis, og en mørk flade siger det på afstand.

Farverne lægges på de blokke, markøren IKKE står i. At farve mens nogen skriver
betyder at bygge indholdet om ved hvert tastetryk og sætte markøren tilbage
bagefter — det virker ni gange ud af ti og flytter markøren midt i en sætning den
tiende.

Et API-token er ét ord, der er bredere end skærmen. Uden min-width: 0 skubbede en
sådan note hele listen bredere og klemte editoren sammen — på en note, man ikke
engang havde åben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:31:10 +02:00
Kristian WindandClaude Opus 5 08fc355bf9 fix(tools): tre fejl, en prøvekørsel på ti noter fandt med det samme
Kodningen. textutil gætter på inddataets kodning og gætter forkert, så
"Ansøgning" kom ud som "Ansøgning". Det ser ud som én underlig note, indtil man
opdager, at det er hver eneste note med et dansk bogstav i.

Titlen stod to gange. Kroppen af en Apple-note begynder med notens egen titel, så
en overskrift ovenover gav den dobbelt overalt.

Og listerne var ikke lister. Apple skriver dem som tabulator, tegn, tabulator —
en liste for et menneske og ingenting for en Markdown-læser, så en huskeliste
ankom som en klump linjer. Punkttegnet er flerbyte, og BSD sed hakkede det midt
over, så hver linje begyndte med to halve tegn; perl med -CSD gør det rigtigt.

Scriptet tager nu et antal som andet argument, så en prøvekørsel er til at
gentage. Det er billigere at opdage den slags på ti noter end på tolv hundrede.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:37:52 +02:00
14 changed files with 799 additions and 114 deletions
+308
View File
@@ -0,0 +1,308 @@
#!/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.
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![](vedhaeftninger/{os.path.basename(target)})\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())
-98
View File
@@ -1,98 +0,0 @@
#!/bin/bash
#
# Apple Noter → en mappe med Markdown-filer.
#
# Apple har ingen eksport, der er værd at bruge: "Eksportér som PDF" er et billede
# af en note, ikke noten. Det her læser Notes.app gennem AppleScript, som er den
# eneste understøttede vej ind, og skriver én .md-fil pr. note.
#
# Det kører på din egen maskine og sender ingenting nogen steder. Resultatet er en
# mappe, du kan kigge igennem, før du beslutter dig — og et zip af den mappe er
# præcis det, Verdande importerer.
#
# ./apple-notes-til-markdown.sh ~/Desktop/noter
# cd ~/Desktop && zip -r noter.zip noter
# → Indstillinger → Data → Importér noter
#
# Første gang spørger macOS, om Terminal må styre Noter. Det skal den.
set -euo pipefail
ud="${1:-$HOME/Desktop/apple-noter}"
mkdir -p "$ud"
echo "Læser Noter. Første gang kan det tage et minut, før macOS svarer."
# Én note ad gangen frem for at bygge én kæmpe streng: AppleScript bliver meget
# langsomt på lange strenge, og på et bibliotek af nogen størrelse ser det ud som
# om den er gået i stå.
antal=$(osascript -e 'tell application "Notes" to return count of notes')
echo "Fandt $antal noter."
i=1
while [ "$i" -le "$antal" ]; do
titel=$(osascript -e "tell application \"Notes\" to return name of note $i" 2>/dev/null || echo "")
krop=$(osascript -e "tell application \"Notes\" to return body of note $i" 2>/dev/null || echo "")
if [ -z "$titel" ]; then
titel="uden titel $i"
fi
# Tegn, der ikke kan være i et filnavn på de tre systemer, folk bruger.
fil=$(printf '%s' "$titel" | tr '/\\:*?"<>|' '-')
sti="$ud/$fil.md"
n=2
while [ -e "$sti" ]; do
sti="$ud/$fil ($n).md"
n=$((n + 1))
done
# Noter er HTML indeni. textutil følger med macOS og kan lave det om til ren
# tekst; overskrifter og fed går tabt, men ordene og linjerne overlever, og det
# er dem, man kommer efter. Titlen sættes som første linje, fordi det er sådan
# Verdande navngiver en note.
{
printf '# %s\n\n' "$titel"
printf '%s' "$krop" | textutil -stdin -stdout -format html -convert txt 2>/dev/null || true
} > "$sti"
# Billeder og bilag.
#
# En note fra Apple Noter er ofte fuld af fotografier, scanninger og indsatte
# skærmbilleder, og en note, der kommer over som ord alene, er ikke den note —
# den er et referat af den. AppleScript kan gemme hver vedhæftning som en fil;
# de lægges i en undermappe, og der skrives en henvisning til hver i bunden af
# noten, så Verdande kan hægte dem på ved importen.
bilag=$(osascript -e "tell application \"Notes\" to return count of attachments of note $i" 2>/dev/null || echo 0)
if [ "${bilag:-0}" -gt 0 ]; then
mkdir -p "$ud/vedhaeftninger"
j=1
while [ "$j" -le "$bilag" ]; do
navn=$(osascript -e "tell application \"Notes\" to return name of attachment $j of note $i" 2>/dev/null || echo "")
[ -z "$navn" ] && navn="bilag-$i-$j"
# Samme rensning som titlen: et skråstreg i et filnavn laver en mappe.
navn=$(printf '%s' "$navn" | tr '/\\:*?"<>|' '-')
mål="$ud/vedhaeftninger/$i-$j-$navn"
# `save` skriver filen. Nogle vedhæftninger — links til websider, kort —
# har ingen fil bag sig og fejler her; de springes over frem for at
# stoppe hele kørslen.
if osascript -e "tell application \"Notes\" to save attachment $j of note $i in POSIX file \"$mål\"" >/dev/null 2>&1; then
printf '\n![](vedhaeftninger/%s)\n' "$(basename "$mål")" >> "$sti"
fi
j=$((j + 1))
done
fi
if [ $((i % 25)) -eq 0 ]; then
echo " $i / $antal"
fi
i=$((i + 1))
done
echo
echo "Færdig. $antal noter ligger i:"
echo " $ud"
echo
echo "Kig dem igennem, og pak dem så:"
echo " cd \"$(dirname "$ud")\" && zip -r \"$(basename "$ud").zip\" \"$(basename "$ud")\""
+54
View File
@@ -2258,3 +2258,57 @@ test('en opgave viser de noter, der nævner den, og linket åbner noten', async
expect(trouble).toEqual([]);
});
test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Skrevet ind som Markdown, sådan som importen fra Apple Noter leverer den.
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
body: '# Terminalprøven\n\nHer er udskriften:\n\n```bash\nkw@shell:~$ ls -la\n# en kommentar\necho "hej"\n```\n\nog resten.'
})
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const pre = ed.locator('pre');
await expect(pre).toBeVisible();
// Mørk flade uanset tema: en terminal er sort, også midt på dagen.
const look = await pre.evaluate((el) => {
const s = getComputedStyle(el);
return { bg: s.backgroundColor, font: s.fontFamily };
});
expect(look.bg).toBe('rgb(20, 24, 27)');
expect(look.font.toLowerCase()).toMatch(/mono/);
// Og farvet: prompten, kommentaren og strengen skal kunne skelnes.
await expect(pre.locator('.tok-prompt')).toBeVisible();
await expect(pre.locator('.tok-comment')).toContainText('en kommentar');
await expect(pre.locator('.tok-string')).toContainText('hej');
// Teksten omkring er ikke blevet til kode.
await expect(ed.locator('h1')).toHaveText('Terminalprøven');
await expect(ed).toContainText('og resten.');
// Og blokken overlever turen tilbage til Markdown: den skal komme ud som en
// hegnet blok med sit sprog, ikke som en klump linjer.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body).toContain('```bash');
expect(body).toContain('kw@shell:~$ ls -la');
expect(trouble).toEqual([]);
});
+4 -1
View File
@@ -363,7 +363,10 @@ button {
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
/* Ingen radius her. En omrids følger elementets egen afrunding af sig selv, og
at tvinge en anden på tegner en firkantet bue uden om en rund kant — to
streger, hvor der skulle være én. Det ses tydeligst om et pilleformet
søgefelt. */
}
::selection {
+13 -2
View File
@@ -9,6 +9,7 @@
let query = $state('');
let tasks = $state([]);
let projects = $state([]);
let notes = $state([]);
let selected = $state(0);
let input;
let controller = null;
@@ -32,13 +33,21 @@
if (!value) {
tasks = [];
projects = [];
notes = [];
return;
}
timer = setTimeout(async () => {
try {
const result = await api.search(value);
// Noter er sit eget endepunkt, så de hentes ved siden af. Uden dem
// søger ⌘K kun i halvdelen af programmet, hvilket er svært at gætte
// som bruger: feltet ser ud til at kunne finde alt.
const [result, found] = await Promise.all([
api.search(value),
api.notes({ q: value }).catch(() => ({ notes: [] }))
]);
tasks = result.tasks ?? [];
projects = result.projects ?? [];
notes = found.notes ?? [];
selected = 0;
} catch {
// A failed search shows nothing rather than an error inside a
@@ -49,6 +58,7 @@
let results = $derived([
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
...tasks.map((t) => ({
kind: 'task',
id: t.id,
@@ -60,6 +70,7 @@
function choose(item) {
open = false;
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
@@ -109,7 +120,7 @@
onclick={() => choose(item)}
onmouseenter={() => (selected = i)}
>
<span class="kind">{item.kind === 'project' ? 'Projekt' : 'Opgave'}</span>
<span class="kind">{t('palette.' + item.kind)}</span>
<span class="label">{item.label}</span>
</button>
</li>
+111 -2
View File
@@ -14,6 +14,7 @@
* is the file to change.
*/
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
@@ -40,6 +41,7 @@
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
loadedId = note.id;
active = {};
colourCode();
});
const SYNTAX = [
@@ -102,6 +104,32 @@
}
}
/**
* Farver kodeblokkene.
*
* Kun dem, markøren ikke står i. At farve blokken, mens nogen skriver i den,
* betyder at bygge dens indhold om ved hvert tastetryk og sætte markøren
* tilbage bagefter — det er den slags, der virker i ni tilfælde ud af ti og
* flytter markøren midt i en sætning i det tiende. Blokken er mørk og
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
* man læser den.
*/
function colourCode() {
if (!editor) return;
const inside = currentBlock();
for (const pre of editor.querySelectorAll('pre')) {
if (pre === inside || pre.contains(inside)) continue;
const code = pre.textContent ?? '';
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
// Kun hvis der er noget at ændre: at skrive innerHTML uændret ville
// stadig rive noden op og lægge den tilbage, og det ses som et blink.
const target = pre.querySelector('code') ?? pre;
if (target.innerHTML !== painted) target.innerHTML = painted;
}
}
function changed() {
if (!editor) return;
onchange?.(htmlToMarkdown(editor));
@@ -320,11 +348,15 @@
aria-label={t('notes.body')}
spellcheck="true"
oninput={changed}
onblur={onsave}
onblur={() => {
colourCode();
onsave?.();
}}
onkeyup={() => {
readState();
readSuggestions();
}}
onclick={colourCode}
onmouseup={readState}
{onkeydown}
{onpaste}
@@ -521,6 +553,9 @@
}
.page {
/* Et token eller en URL er ét ord, der er bredere end skærmen. Uden det her
får siden en vandret rullebjælke, og teksten forsvinder ud til højre. */
overflow-wrap: anywhere;
flex: 1;
min-height: 50vh;
padding: var(--s3) 0;
@@ -572,10 +607,84 @@
color: var(--ink-muted);
}
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
uden at man skal læse et ord. Farverne er de samme i lys og mørk tilstand:
en terminal er sort, også midt på dagen. */
.page :global(pre) {
margin: 0 0 var(--s2);
margin: 0 0 var(--s3);
padding: var(--s3);
border-radius: var(--radius);
background: #14181b;
color: #d7dde3;
font-family: var(--mono, ui-monospace, monospace);
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
position: relative;
}
/* Sproget i hjørnet, når blokken siger hvad den er. */
.page :global(pre[data-lang]:not([data-lang=''])::after) {
content: attr(data-lang);
position: absolute;
top: 4px;
right: 8px;
font-size: 0.625rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5a656e;
}
.page :global(pre code) {
font: inherit;
color: inherit;
}
.page :global(.tok-comment) {
color: #6b7a85;
font-style: italic;
}
.page :global(.tok-string) {
color: #9ecb8a;
}
.page :global(.tok-number) {
color: #d9a25f;
}
.page :global(.tok-keyword) {
color: #7fb3e8;
}
.page :global(.tok-key) {
color: #7fb3e8;
}
.page :global(.tok-bool) {
color: #c48fd6;
}
.page :global(.tok-variable) {
color: #d9a25f;
}
.page :global(.tok-flag) {
color: #c48fd6;
}
.page :global(.tok-punct) {
color: #8a949c;
}
/* Prompten i en terminaludskrift: det første øjet finder, når man leder efter
hvor en kommando begynder. */
.page :global(.tok-prompt) {
color: #6fbf8f;
font-weight: 600;
}
.page :global(code) {
+15 -4
View File
@@ -1,7 +1,7 @@
<script>
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
@@ -335,16 +335,27 @@
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
function onTaskDragOver(event, project) {
if (!carries(event, TASK) || !canReceive(project)) return;
if (!(carries(event, TASK) || carries(event, NOTE)) || !canReceive(project)) return;
accept(event);
overProject = project.id;
}
async function onTaskDrop(event, project) {
event.preventDefault();
const id = dragged(event, TASK);
overProject = null;
if (!id || !canReceive(project)) return;
if (!canReceive(project)) return;
// The same target takes both. A note dropped on a project is filed there,
// which is also how it gets shared — one gesture, one meaning, and the
// sidebar already reads as "put this here".
const noteID = dragged(event, NOTE);
if (noteID) {
await app.moveNoteToProject(noteID, project.id);
return;
}
const id = dragged(event, TASK);
if (!id) return;
await app.moveToProject(id, project.id);
}
+1
View File
@@ -17,6 +17,7 @@
export const TASK = 'application/x-verdande-task';
export const PROJECT = 'application/x-verdande-project';
export const GROUP = 'application/x-verdande-group';
export const NOTE = 'application/x-verdande-note';
/** Starts a drag carrying one id of the given kind. */
export function startDrag(event, type, id) {
+118
View File
@@ -0,0 +1,118 @@
/**
* Farver til en kodeblok.
*
* Ikke et bibliotek: et fuldt syntaksbibliotek er hundredvis af kilobytes for at
* gøre nogle ord blå i en note. Det her er et lille sæt regler pr. sprog, der
* rammer det, øjet leder efter — strenge, tal, kommentarer, nøgleord — og som er
* ligeglad med resten. En farve for lidt er ingenting; en forkert er heller ikke
* meget, når teksten stadig står der, som den blev skrevet.
*
* Reglerne prøves i rækkefølge, og den første, der rammer, vinder. Strenge og
* kommentarer står derfor først: et nøgleord inde i en streng er ikke et nøgleord.
*/
const COMMON = {
string: /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/,
number: /^\b\d+(\.\d+)?\b/
};
const LANGUAGES = {
yaml: [
['comment', /^#[^\n]*/],
['string', COMMON.string],
// Nøglen, som er det, man scanner efter i en YAML-fil.
['key', /^[ \t]*[-\w.]+(?=\s*:)/],
['punct', /^[:\-|>]/],
['number', COMMON.number],
['bool', /^\b(true|false|null|yes|no|on|off)\b/i]
],
json: [
['string', COMMON.string],
['punct', /^[{}[\],:]/],
['number', COMMON.number],
['bool', /^\b(true|false|null)\b/]
],
bash: [
['comment', /^#[^\n]*/],
['string', COMMON.string],
// Prompten er ikke kode, men den er det første øjet finder i en udskrift.
['prompt', /^\S+@\S+:[^$#]*[$#]/],
['variable', /^\$\{?\w+\}?/],
['keyword', /^\b(if|then|else|fi|for|in|do|done|while|case|esac|function|return|export|local|sudo)\b/],
['flag', /^\s-{1,2}[\w-]+/],
['number', COMMON.number]
],
go: [
['comment', /^\/\/[^\n]*/],
['string', COMMON.string],
['keyword', /^\b(func|package|import|var|const|type|struct|interface|if|else|for|range|return|defer|go|chan|select|switch|case|default|map|nil|true|false)\b/],
['number', COMMON.number]
],
js: [
['comment', /^\/\/[^\n]*/],
['string', COMMON.string],
['keyword', /^\b(const|let|var|function|return|if|else|for|while|import|export|from|class|new|await|async|try|catch|throw|typeof|null|undefined|true|false)\b/],
['number', COMMON.number]
],
sql: [
['comment', /^--[^\n]*/],
['string', COMMON.string],
['keyword', /^\b(select|from|where|insert|into|values|update|set|delete|create|table|index|join|left|inner|on|and|or|not|null|primary|key|references|order|by|group|limit)\b/i],
['number', COMMON.number]
]
};
// Det, folk skriver, mod det, filen hedder.
const ALIASES = {
sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash', terminal: 'bash',
yml: 'yaml', javascript: 'js', ts: 'js', typescript: 'js', golang: 'go'
};
const escape = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* Gætter sproget, når blokken ikke siger det.
*
* Med vilje forsigtigt: at gætte forkert farver de forkerte ord, og en
* terminaludskrift uden farver er stadig læselig. Kun mønstre, der er svære at
* forveksle, tæller med.
*/
export function guessLanguage(code) {
if (/^\s*[{[]/.test(code) && /["}\]]\s*$/.test(code.trim())) return 'json';
if (/^\s*\S+@\S+:.*[$#]/m.test(code)) return 'bash';
if (/^\s*(package|func)\s+\w/m.test(code)) return 'go';
if (/^\s*[-\w.]+:\s*(\S|$)/m.test(code) && !/[;{}]/.test(code)) return 'yaml';
if (/^\s*(SELECT|INSERT|UPDATE|CREATE TABLE)\b/im.test(code)) return 'sql';
return '';
}
/** Kode til HTML med spans. Ukendt sprog giver teksten uændret, blot undsluppet. */
export function highlight(code, language) {
const rules = LANGUAGES[ALIASES[language] ?? language];
if (!rules) return escape(code);
let out = '';
let rest = code;
let guard = 0;
while (rest && guard++ < 20000) {
let matched = false;
for (const [kind, re] of rules) {
const m = re.exec(rest);
if (!m || !m[0]) continue;
out += `<span class="tok-${kind}">${escape(m[0])}</span>`;
rest = rest.slice(m[0].length);
matched = true;
break;
}
if (!matched) {
// Ét tegn frem. Langsommere end at springe til næste interessante sted og
// umuligt at få til at gå i stå, hvilket er den rigtige byttehandel i noget,
// der kører på hvert tastetryk i en note, nogen sidder og skriver i.
out += escape(rest[0]);
rest = rest.slice(1);
}
}
return out + escape(rest);
}
+4
View File
@@ -38,6 +38,7 @@ export const da = {
'notes.unfavourite': 'Fjern som favorit',
'notes.syntaxProject': 'projekt',
'notes.syntaxNote': 'en anden note',
'notes.yesterday': 'i går',
'notes.title': 'Noter',
'notes.new': 'Ny note',
'notes.search': 'Søg i noter',
@@ -51,6 +52,9 @@ export const da = {
'notes.saved': 'Gemt',
'notes.delete': 'Slet',
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
'palette.project': 'Projekt',
'palette.task': 'Opgave',
'palette.note': 'Note',
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
+4
View File
@@ -34,6 +34,7 @@ export const en = {
'notes.unfavourite': 'Remove from favourites',
'notes.syntaxProject': 'project',
'notes.syntaxNote': 'another note',
'notes.yesterday': 'yesterday',
'notes.title': 'Notes',
'notes.new': 'New note',
'notes.search': 'Search notes',
@@ -47,6 +48,9 @@ export const en = {
'notes.saved': 'Saved',
'notes.delete': 'Delete',
'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
'palette.project': 'Project',
'palette.task': 'Task',
'palette.note': 'Note',
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
+34
View File
@@ -59,6 +59,7 @@ export function markdownToHtml(markdown) {
const lines = (markdown ?? '').split('\n');
const out = [];
let list = null;
let fence = null; // sproget, mens vi er inde i en ```-blok
const closeList = () => {
if (list) {
@@ -68,6 +69,25 @@ export function markdownToHtml(markdown) {
};
for (const line of lines) {
// Hegnede blokke først: alt indeni er tekst, ikke Markdown, og en linje der
// begynder med - inde i et shell-script er ikke et punkt i en liste.
const fenced = /^```(\w*)\s*$/.exec(line);
if (fence !== null) {
if (fenced) {
out.push('</code></pre>');
fence = null;
} else {
out.push(escapeHtml(line) + '\n');
}
continue;
}
if (fenced) {
closeList();
fence = fenced[1] ?? '';
out.push(`<pre data-lang="${escapeHtml(fence)}"><code>`);
continue;
}
const bullet = /^[-*+]\s+(.*)$/.exec(line);
const numbered = /^\d+\.\s+(.*)$/.exec(line);
@@ -96,6 +116,7 @@ export function markdownToHtml(markdown) {
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
}
closeList();
if (fence !== null) out.push('</code></pre>');
return out.join('');
}
@@ -176,6 +197,19 @@ export function htmlToMarkdown(root) {
lines.push(inlineToMarkdown(child));
}
break;
case 'PRE': {
// Back out as a fence, with the language it came in with. The old
// four-space form is still read on the way in; it is not written
// out any more, because a fence survives being pasted somewhere
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
lines.push('```' + lang);
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
lines.push(l);
}
lines.push('```');
break;
}
default: {
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
const text = inlineToMarkdown(child);
+13
View File
@@ -49,6 +49,8 @@ class AppState {
* small request against modelling a count in two places.
*/
labelsChanged = $state(0);
/** Bumped when a note moves, so a list showing them knows to look again. */
notesChanged = $state(0);
#socket = null;
#reconnectDelay = 1000;
@@ -421,6 +423,17 @@ class AppState {
}
}
/** Files a note under a project, which is what sharing it means. */
async moveNoteToProject(noteID, projectID) {
try {
await api.updateNote(noteID, { project_id: projectID });
this.notesChanged++;
this.toast(t('notes.filed'));
} catch (e) {
this.toast(humanMessage(e));
}
}
async refreshProjects() {
const { projects } = await api.listProjects();
this.projects = projects;
+120 -7
View File
@@ -14,7 +14,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { t } from '$lib/i18n.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import NoteEditor from '$lib/components/NoteEditor.svelte';
let notes = $state([]);
@@ -82,6 +83,39 @@
.slice(0, 90);
}
/**
* Hvornår, skrevet som man ville sige det.
*
* "i går" og et klokkeslæt i dag; en dato, når det er længere siden. En note
* fra i formiddags og en fra i fjor skal ikke se ens ud i en liste, man skanner.
*/
function when(iso) {
if (!iso) return '';
const at = new Date(iso);
const now = new Date();
const sameDay = at.toDateString() === now.toDateString();
if (sameDay) return at.toLocaleTimeString(tag(), { hour: '2-digit', minute: '2-digit' });
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (at.toDateString() === yesterday.toDateString()) return t('notes.yesterday');
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function projectName(id) {
return app.projects.find((p) => p.id === id)?.name ?? '';
}
// Trukket ind på et projekt i sidebjælken. Nyttelasten er notens id; sidebjælken
// kender allerede formen fra opgaver.
let dragging = $state(null);
function onDragStart(event, note) {
dragging = note.id;
startDrag(event, NOTE, note.id);
}
async function create() {
try {
const note = await api.createNote({ body: '' });
@@ -219,9 +253,25 @@
{#each notes as note (note.id)}
<li>
<div class="rowline">
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
<button
class="row"
class:on={selected?.id === note.id}
onclick={() => open(note)}
draggable="true"
ondragstart={(e) => onDragStart(e, note)}
ondragend={() => (dragging = null)}
>
<strong>{note.title || t('notes.untitled')}</strong>
<span class="preview">{plain(note.body)}</span>
<!-- Dato og begyndelse på samme linje, som Apple Noter gør det: to
linjer pr. note frem for tre, og datoen er dét, man skanner
efter, når man leder efter noget, man skrev i tirsdags. -->
<span class="under">
<span class="when">{when(note.updated_at)}</span>
<span class="preview">{plain(note.body)}</span>
</span>
{#if note.project_id}
<span class="filed">{projectName(note.project_id)}</span>
{/if}
</button>
<button
class="star"
@@ -289,6 +339,12 @@
<style>
.notes {
/* En grid-kolonne er som udgangspunkt mindst så bred som sit bredeste
indhold, og et API-token uden et mellemrum i er ét meget bredt ord. Uden
min-width: 0 skubber sådan en note hele listen bredere, og editoren
klemmes sammen — på en note man ikke engang har åben. */
min-width: 0;
/* Air on all sides. The heading, the search box and the list all sat flush
against the sidebar's rule, which reads as a rendering fault rather than
as a layout. */
@@ -305,6 +361,7 @@
flex-direction: column;
gap: var(--s2);
min-height: 0;
min-width: 0;
border-right: 1px solid var(--line);
padding-right: var(--s3);
}
@@ -330,8 +387,27 @@
color: var(--ink);
}
/* Tegnet af os, ikke af browseren.
Safari giver type="search" sit eget udseende — pilleform og forstørrelsesglas
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
udgave, og den ser ens ud hele tiden. */
.search {
width: 100%;
appearance: none;
-webkit-appearance: none;
padding-left: 30px;
border-radius: 999px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b918d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-3.5-3.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 9px center;
background-size: 15px 15px;
}
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
.search::-webkit-search-decoration,
.search::-webkit-search-cancel-button {
-webkit-appearance: none;
}
ul {
@@ -371,9 +447,14 @@
flex: 1;
min-width: 0;
text-align: left;
padding: var(--s2);
padding: var(--s2) var(--s2) var(--s3);
border-radius: var(--radius);
color: var(--ink-muted);
border-bottom: 1px solid var(--line);
}
li:last-child .row {
border-bottom: 0;
}
.row:hover {
@@ -385,28 +466,60 @@
color: var(--ink);
}
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
slags — noget, der står på en liste og skal læses på et blik — og at give dem
hver sin størrelse får den ene til at se mindre vigtig ud end den anden. */
.row strong {
display: block;
font-size: var(--text-sm);
font-weight: 560;
font-weight: 600;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview {
display: block;
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
.under {
display: flex;
gap: var(--s2);
min-width: 0;
font-size: var(--text-xs);
color: var(--ink-faint);
}
.when {
flex: none;
color: var(--ink-muted);
}
.preview {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Hvor den ligger, med en mappe foran — Apple Noters egen måde at sige det på,
og den eneste linje her, der ikke er notens eget indhold. */
.filed {
display: block;
margin-top: 2px;
font-size: var(--text-xs);
color: var(--ink-faint);
}
.filed::before {
content: '▸ ';
opacity: 0.7;
}
.editor {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
/* Everything below exists to keep two layers in lockstep. The shared rules are