Compare commits

...
5 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 4b5f3f0864 chore: agenternes arbejdstræer hører ikke i repoet
Release / Build and publish image (push) Failing after 9m56s
Et `git add -A` fangede .claude/worktrees/ som et indlejret repo. Det er en
agents arbejdskopi og skal ingen steder.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 06:39:45 +02:00
Kristian WindandClaude Opus 5 052f8a43fb fix(notes): en note kunne smugle et script ind gennem et billedes alt-tekst
Indført i aften, sammen med billedvisningen, og fundet ved at læse den igen.

`escapeHtml` tager &, < og > — ikke anførselstegn, og det er rigtigt for tekst
mellem to tags, hvor et anførselstegn ikke betyder noget. `alt` er ikke tekst
mellem to tags. Det står inde i en attribut, og der lukker det første
anførselstegn den, hvorefter resten står som markup:

    !["  onerror="alert(1)](/api/v1/attachments/aaaa)

blev til et <img> med en onerror på. Adressen var i forvejen bundet til vores
eget bilagsmønster, så den var ikke vejen ind — teksten ved siden af var.

Det er ikke en teoretisk vej. En note deles ved at ligge i et projekt, så den
læses af alle, der kan læse projektet, og scriptet ville køre på vores eget
domæne med sessionscookien, hos den der åbnede noten.

En værdi i en attribut har brug for sin egen undslipning, og har nu en. Prøven
skriver noten, åbner den og ser efter, at hændelsen står som tekst i alt og ikke
som en attribut på elementet — og at billedets 404, som er selve vejen en
onerror ville blive kaldt ad, ikke kalder noget.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 06:39:19 +02:00
Kristian WindandClaude Opus 5 dee2d5e30b fix(faerdige): en opgave lukket efter midnat lå under "I går"
Release / Build and publish image (push) Failing after 9m56s
Fundet af prøverne, som for første gang kørte forbi midnat.

Dagen blev læst som `completed_at.slice(0, 10)` — de ti første tegn af et
tidsstempel, der er i UTC. Overskriften sammenlignede med *lokal* dato. I dansk
sommertid er de to ikke den samme dag i de første to timer efter midnat, så en
opgave lukket 00.30 blev sat under "I går", indtil klokken blev to. Tyve timer i
døgnet er de ens, hvilket er derfor prøven har været grøn hver eneste gang før.
Dagen udregnes nu lokalt, som den overskrift den skal svare til.

Og prøven for invitationslinket kunne kun bestå seks dage om ugen. Den ledte
efter "males inden fredag" som én tekst, men hurtig tilføjelse læser "fredag" som
forfaldsdatoen og tager ordet ud af titlen — tilbage står "males inden" plus et
mærke, hvis ord skifter med ugedagen. Kørt på en torsdag hedder mærket "I
morgen", og så fandt den ingenting. Prøven handler om invitationer, ikke om
datoer, så den ser nu på titlen alene.

Uddraget i notelisten viste `![](/api/v1/attachments/01a0…` for hver note med et
billede i — den eneste tekst i listen, ingen havde skrevet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 00:17:50 +02:00
Kristian WindandClaude Opus 5 2bd8e48aba fix(notes): en note med tre prikker i titlen blev smidt væk ved importen
Release / Build and publish image (push) Failing after 9m54s
`skipFile` afviste ethvert navn, der indeholdt "..", for at stoppe en zip i at
skrive uden for sin egen mappe. Det er den rigtige idé rettet mod det forkerte:
det beskriver ikke en sti, der klatrer ud af arkivet, men ethvert filnavn med to
prikker ved siden af hinanden. En ellipse i en overskrift er almindelig, og noten
"Så blev det endelig jul! Stay tuned... ☕️" blev derfor aldrig oprettet.

Lydløst, oven i købet: filen blev sprunget over, før den blev talt, så svaret
sagde det samme, som hvis den var kommet med. Én note ud af tolv hundrede, og den
blev kun fundet ved at tælle efter, hvor mange der kom ud i den anden ende — 1140
hvor der skulle stå 1141.

Nu læses ".." som et stiled, ikke som to tegn, og en Markdown-fil, filteret
afviser, tælles som sprunget over. En import, der taber noget, skal sige det.

Og move-ikonet i sidebjælken er væk. ⠿ sad i den to pixels brede rende til
venstre for teksten og lå oven i rækkens egen kant — idéen var rigtig, pladsen
var det ikke. Elementet bliver: det er dét, der ejer trækket, og gjorde man i
stedet hele rækken draggable, ville alt indeni også blive det, og en opgave
trukket hen på I dag holdt op med at lande. Nu er håndtaget usynligt og lidt
bredere, og hånden ved hover er hele forklaringen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 00:04:08 +02:00
Kristian WindandClaude Opus 5 9b4e7686f9 feat(notes): datoerne følger med, billederne bliver tegnet, og listen kan sorteres
Release / Build and publish image (push) Failing after 9m54s
Fire ting, som en rigtig import af tolv hundrede noter fandt på en time.

**Billederne blev vist som ord.** `markdownToHtml` havde ingen regel for
`![](…)` overhovedet, så en note fra Apple Noter viste sin egen billedadresse som
tekst. Filen var hentet, gemt og hæftet på noten; det eneste, der manglede, var
at nogen tegnede den. Kun vores egen adresse bliver til et billede — et
`![](http://…)` fra en indsat tekst ville ellers hente fra en fremmed vært, når
noten åbnes, og en note er det sidste sted, man vil have en sporingspixel. Og
vejen tilbage skulle med: uden `IMG` i inlineToMarkdown ville den første gemning
have taget hvert billede ud af noten igen.

**Titlen blev revet i stykker.** Noter deler en linje op i så mange stumper, den
har brug for — "Aalborg Kommune" står som
`<h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>`, fordi der er en
attributkørsel bag hver stump — og eksporten læste et linjeskift ved hver af dem.
Noten kom ud som fire overskrifter oven på hinanden, altså ødelagt på den mest
synlige måde der findes: i sin egen titel. Samme rod gav `**M****øde**` for fed
og `*M**øde*` for kursiv, hvor de to stjerner i midten læses som fed. Nu brydes
der kun, når blokken skifter, og et mærke, der lukkes og straks åbnes igen, er
ingenting.

**Datoerne fandtes ikke.** Eksporten læste dem aldrig, og Markdown havde ingen
plads til dem — så en flytning af tolv hundrede noter blev en bunke, der alle var
skrevet samme aften. Rækkefølgen er halvdelen af, hvad et arkiv er. Nu står de i
et YAML-hoved, som Obsidian og Bear allerede skriver, og importen læser det.
`SetNoteTimes` frem for gennem SaveNote: `updated_at` betyder "hvornår skrev
*dette* program noten sidst", og lod man enhver kalder sætte den, ville kolonnen
betyde det, den sidste skriver havde lyst til.

**Og listen kan sorteres** — senest rørt, oprettet, navn. Favoritter står øverst
uanset hvad; det er hele meningen med at gøre en note til favorit.

Eksporten henter nu i klumper. Ét kald pr. note tog halvanden time for tolv
hundrede, ikke fordi Noter er langsom, men fordi hvert kald er en proces, der
skal startes — og det gjorde det umuligt at rette en fejl i oversættelsen og se
resultatet, for hver runde var en frokostpause. Antallet af bilag kom med i
klumpen af samme grund: ni ud af ti kald var en proces startet for at få svaret
nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 22:58:19 +02:00
13 changed files with 592 additions and 53 deletions
+3
View File
@@ -66,3 +66,6 @@ node_modules.nosync/
# Kept on disk because it is how a session hands over to the next one.
HANDOFF.md
NOTES-PLAN.md
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
.claude/worktrees/
+110 -5
View File
@@ -353,7 +353,16 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
for _, f := range zr.File {
name := f.Name
if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") {
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
continue
}
// Counted, not passed over in silence. A Markdown file the filter refuses is
// a note that did not arrive, and the caller is told the same number either
// way unless it is said here — which is how one note went missing without
// anything looking wrong.
if skipFile(name) {
s.log.Warn("import skipped a note file", "file", name)
skipped++
continue
}
@@ -369,13 +378,24 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
continue
}
n := &store.Note{CreatedBy: user.ID, Body: string(body)}
text, wrote, changed := readFrontMatter(string(body))
n := &store.Note{CreatedBy: user.ID, Body: text}
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.log.Warn("import note", "err", err, "file", name)
skipped++
continue
}
// After the save, not as part of it: SaveNote owns `updated_at` and is right
// to, so the dates a note had before this program existed are written over
// the top rather than passed through it.
if !wrote.IsZero() || !changed.IsZero() {
if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil {
s.log.Warn("import note dates", "err", err, "file", name)
}
}
// The pictures the note referred to, now that it has an id to hang them on.
// The text is rewritten to the addresses they were given, so an image that
// sat in the middle of a paragraph is still in the middle of it.
@@ -398,10 +418,34 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
// would double a whole library with each copy full of binary rubbish.
func skipFile(name string) bool {
return strings.HasPrefix(name, "__MACOSX/") ||
if strings.HasPrefix(name, "__MACOSX/") ||
strings.HasPrefix(path.Base(name), "._") ||
strings.HasPrefix(path.Base(name), ".") ||
strings.Contains(name, "..")
strings.HasPrefix(path.Base(name), ".") {
return true
}
// Traversal, read as path segments rather than as a substring.
//
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
// wrong thing: it does not describe a path that climbs out of the archive, it
// describes any name with two dots next to each other anywhere in it. A note
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
// silently, and without even counting as skipped, so the number the import
// reported still looked right. One note in twelve hundred, found only by
// counting what came out.
//
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
// starts at the root, is not — and those are what a zip uses to write outside
// the folder it was unpacked into.
if strings.HasPrefix(name, "/") {
return true
}
for _, part := range strings.Split(name, "/") {
if part == ".." {
return true
}
}
return false
}
// attachToNote records the files a note's text points at and rewrites the text to
@@ -466,6 +510,67 @@ func (s *Server) attachToNote(
return body, attached
}
// readFrontMatter takes the YAML block off the top of a Markdown file and reads
// the two dates out of it, returning the rest of the text.
//
// Not a YAML parser, and deliberately not: the block this reads is the one this
// program writes, plus whatever Obsidian and Bear write, and all of them use the
// same handful of `key: value` lines. Pulling in a parser to read two dates would
// be a dependency that can fail in ways nobody here would recognise.
//
// A file with no front matter comes back untouched — that is the normal case, and
// it must never lose its first line to a rule about a block that is not there.
func readFrontMatter(body string) (text string, created, modified time.Time) {
if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") {
return body, time.Time{}, time.Time{}
}
rest := body[strings.Index(body, "\n")+1:]
end := strings.Index(rest, "\n---")
if end < 0 {
// An opening fence with no close is not front matter; it is a horizontal
// rule somebody wrote at the top of their note.
return body, time.Time{}, time.Time{}
}
block := rest[:end]
after := rest[end+len("\n---"):]
after = strings.TrimPrefix(after, "\r")
after = strings.TrimPrefix(after, "\n")
for _, line := range strings.Split(block, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
switch strings.ToLower(strings.TrimSpace(key)) {
case "created", "created_at", "date":
created = parseNoteTime(value)
case "modified", "updated", "updated_at":
modified = parseNoteTime(value)
}
}
return strings.TrimLeft(after, "\n"), created, modified
}
// parseNoteTime accepts the shapes these files actually carry. A date without a
// time is that day at midnight, local — the note was written on that day, and
// pretending to know the hour would be worse than not having one.
func parseNoteTime(v string) time.Time {
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
} {
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
return t
}
}
return time.Time{}
}
// mimeOf guesses from the extension. The archive does not carry a type, and
// sniffing the bytes would mean reading every file twice.
func mimeOf(name string) string {
@@ -0,0 +1,38 @@
package httpapi
import "testing"
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
//
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
// tælle efter, hvor mange der kom ud.
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
keep := []string{
"Så blev det endelig jul! Stay tuned... ☕️.md",
"Møde 12.8..md",
"vedhaeftninger/1-2-Pasted Graphic.png",
"noter/Referat....md",
}
for _, name := range keep {
if skipFile(name) {
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
}
}
drop := []string{
"../uden for.md",
"noter/../../etc/passwd",
"/etc/passwd",
"__MACOSX/._noget",
".skjult.md",
"noter/._AppleDouble.md",
}
for _, name := range drop {
if !skipFile(name) {
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
}
}
}
+31
View File
@@ -247,6 +247,37 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
LIMIT ?`, expr, limit)
}
// SetNoteTimes writes the dates a note had somewhere else.
//
// Only the import calls this, and it exists because SaveNote is right to refuse:
// `updated_at` is when this program last wrote the note, and letting any caller
// set it would make the column mean whatever the last writer felt like. A note
// brought in from Apple Notes is the one case where the dates are facts from
// before this database existed — and without them a move of twelve hundred notes
// is a pile that was all written the same evening, which throws away the order
// they were written in. That order is half of what an archive is.
func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time.Time) error {
if created.IsZero() && updated.IsZero() {
return nil
}
// COALESCE, so a note carrying only one of the two keeps what it already had
// for the other rather than being stamped with the zero time — which would sort
// it to the year one.
var c, u any
if !created.IsZero() {
c = created.Unix()
}
if !updated.IsZero() {
u = updated.Unix()
}
_, err := db.ExecContext(ctx, `
UPDATE notes
SET created_at = COALESCE(?, created_at),
updated_at = COALESCE(?, updated_at)
WHERE id = ?`, c, u, id)
return err
}
// DeleteNote puts it in the trash. Nothing here removes a row.
func (db *DB) DeleteNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
+184 -26
View File
@@ -31,6 +31,9 @@ import sys
from html.parser import HTMLParser
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
# Fra lukkemærke tilbage til åbnemærke, når et mærke skal deles over to linjer.
OPENER = {"**": "**", "*": "*", "</u>": "<u>"}
MARKS_ONLY = re.compile(r"(?:\*\*|\*|~~|<u>|</u>|\s)*")
FORBIDDEN = set('/\\:*?"<>|')
@@ -69,8 +72,26 @@ class NoteParser(HTMLParser):
# --- linjer ---------------------------------------------------------------
def _flush_line(self):
# Et mærke, der står åbent, når linjen slutter, lukkes her og åbnes igen på
# den næste.
#
# Noter skriver <b>Møde forberedelse<br></b>: linjeskiftet ligger *inde i*
# det fede. Skrev man linjen ud uden videre, blev de to stjerner, der lukker,
# til den næste linjes første tegn — "**Møde forberedelse" og så en linje med
# "**" på. Markdown er linjebaseret; et mærke, der skal over et linjeskift,
# findes ikke, og skal derfor deles i to.
reopen = list(self.marks)
for closer in reversed(self.marks):
self.line.append(closer)
text = "".join(self.line).rstrip()
self.line = []
# En linje, der kun er mærker, er en tom linje. Det sker for den sidste
# stump af et fedt afsnit, hvor der ikke står andet end linjeskiftet.
if MARKS_ONLY.fullmatch(text):
text = ""
self.line = [OPENER[c] for c in reopen]
mono, self.mono = self.mono, False
if mono:
@@ -121,11 +142,42 @@ class NoteParser(HTMLParser):
self.lines.append("")
self.mono_lines = []
def _open(self, opener: str, closer: str):
"""Åbner et mærke — eller fortryder det, der lige lukkede.
Noter deler også fremhævet tekst op i kørsler: "Møde" står som
<b>M</b><b>øde</b>, fordi der er en attributkørsel bag hver stump. Skrevet
ligeud bliver det **M****øde**, som er ét ord i fed sagt på den grimmest
mulige måde — og for kursiv bliver det *M**øde*, hvor de to stjerner i
midten læses som fed af enhver Markdown-læser. Ordet kom ud forkert.
Lukkes et mærke og åbnes det samme igen med det samme, er der ikke sket
noget imellem. Så tages lukningen tilbage, og de to kørsler er ét mærke.
"""
if self.line and self.line[-1] == closer:
self.line.pop()
else:
self.line.append(opener)
self.marks.append(closer)
# --- tags -----------------------------------------------------------------
def handle_starttag(self, tag, attrs):
if tag in BLOCK_PREFIX:
self._flush_line()
# En overskrift, der fortsætter den forrige, er den samme linje.
#
# Noter deler en linje op i så mange stumper, den har brug for — en
# overskrift skrevet i ét hug står som
# <h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>, fordi der er en
# attributkørsel bag hver stump. Læste man et linjeskift ved hver <h1>,
# blev "Aalborg Kommune" til fire overskrifter oven på hinanden, og
# noten så ødelagt ud på den mest synlige måde, der findes: i sin egen
# titel.
#
# Der brydes derfor kun, når blokken skifter til en anden slags. Selve
# linjeskiftene kommer fra <div> og <br>, som er dem, der betyder det.
if self.block != tag:
self._flush_line()
self.block = tag
elif tag in ("ul", "ol"):
self._flush_line()
@@ -138,23 +190,24 @@ class NoteParser(HTMLParser):
# 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("**")
self._open("**", "**")
elif tag in ("i", "em"):
self.marks.append("*")
self.line.append("*")
self._open("*", "*")
elif tag == "u":
self.marks.append("</u>")
self.line.append("<u>")
self._open("<u>", "</u>")
elif tag == "br":
self._flush_line()
self.block = None
elif tag == "div":
self._flush_line()
self.block = None
def handle_endtag(self, tag):
if tag in BLOCK_PREFIX:
self._flush_line()
self.block = None
# Ikke her. Lukkes overskriften ved sit eget sluttag, kan den næste
# stump af samme linje ikke se, at den hører til — det er <div> og <br>,
# der afgør, hvor linjen holder op.
pass
elif tag in ("ul", "ol"):
self._flush_line()
if self.list_kind:
@@ -172,6 +225,7 @@ class NoteParser(HTMLParser):
self.line.append(self.marks.pop())
elif tag == "div":
self._flush_line()
self.block = None
def handle_data(self, data):
self.line.append(data)
@@ -208,6 +262,95 @@ def safe_name(title: str, fallback: str) -> str:
return name[:80] or fallback
# Skilletegn, der ikke står i en note: enhedsadskillere fra ASCII, som ingen
# skriver med et tastatur.
FIELD, RECORD = "\u241f", "\u241e"
def osascript_file(script: str) -> str:
"""Som osascript(), men til et manuskript, der er for stort til -e."""
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".applescript", delete=False,
encoding="utf-8") as f:
f.write(script)
path = f.name
try:
out = subprocess.run(["osascript", path], capture_output=True, text=True,
timeout=600, check=False)
return out.stdout
except subprocess.SubprocessError:
return ""
finally:
os.unlink(path)
def fetch(ids: list[str]) -> list[dict]:
"""Navn, krop og datoer for en håndfuld noter i ét kald.
Ét kald pr. note var det oplagte, og det tog halvanden time for tolv hundrede
— ikke fordi Noter er langsom, men fordi hvert kald er en proces, der skal
startes. Det gjorde det umuligt at rette en fejl i oversættelsen og se
resultatet: hver runde var en frokostpause. Nu hentes de i klumper, og hele
eksporten tager minutter.
Klumper frem for alle på én gang, fordi en enkelt uleselig note ellers ville
tage hele kørslen med sig — og fordi svaret skal kunne være i hukommelsen.
Datoerne læses som tal frem for som tekst. `creation date` giver en dato
formateret efter maskinens sprog, og den ville skulle gættes tilbage; year,
month og day er de samme tal på enhver maskine.
"""
quoted = ", ".join('"' + i.replace('"', '\\"') + '"' for i in ids)
script = f'''
tell application "Notes"
set out to ""
repeat with theId in {{{quoted}}}
try
set n to note id theId
set c to creation date of n
set m to modification date of n
set out to out & (name of n) & "{FIELD}" & ¬
((year of c) & "-" & (month of c as integer) & "-" & (day of c) & "T" & ¬
(hours of c) & ":" & (minutes of c) & ":" & (seconds of c)) & "{FIELD}" & ¬
((year of m) & "-" & (month of m as integer) & "-" & (day of m) & "T" & ¬
(hours of m) & ":" & (minutes of m) & ":" & (seconds of m)) & "{FIELD}" & ¬
(count of attachments of n) & "{FIELD}" & ¬
(body of n) & "{RECORD}"
on error
set out to out & "{FIELD}{FIELD}{FIELD}{FIELD}" & "{RECORD}"
end try
end repeat
return out
end tell
'''
raw = osascript_file(script)
rows = []
for chunk in raw.split(RECORD):
if not chunk.strip("\n "):
continue
parts = chunk.split(FIELD)
if len(parts) < 5:
rows.append({"name": "", "created": "", "modified": "", "files": 0, "body": ""})
continue
rows.append({
"name": parts[0].lstrip("\n"), "created": parts[1], "modified": parts[2],
"files": int(parts[3]) if parts[3].strip().isdigit() else 0,
"body": parts[4],
})
return rows
def iso(stamp: str) -> str:
""""2026-8-17T12:55:14" → "2026-08-17T12:55:14". Nul foran, så det sorterer."""
try:
date, _, time = stamp.partition("T")
y, mo, d = (int(x) for x in date.split("-"))
h, mi, se = (int(x) for x in time.split(":"))
return f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{se:02d}"
except (ValueError, AttributeError):
return ""
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
@@ -238,16 +381,15 @@ def main() -> int:
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)
CHUNK = 40
fetched: list[dict] = []
for start in range(0, len(ids), CHUNK):
fetched.extend(fetch(ids[start:start + CHUNK]))
print(f" hentet {min(start + CHUNK, len(ids))} / {total}")
for i, (note_id, row) in enumerate(zip(ids, fetched), start=1):
title = row["name"].strip() or f"uden titel {i}"
text = to_markdown(row["body"])
# Kroppen begynder med notens egen titel. Skrives der en overskrift
# ovenover, står titlen to gange i hver eneste note.
@@ -293,18 +435,34 @@ def main() -> int:
used[name] = used.get(name, 0) + 1
path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
# Datoerne i et hoved, som enhver anden Markdown-læser kender.
#
# Uden dem er en flytning af tolv hundrede noter en bunke, der alle er
# skrevet samme aften: rækkefølgen, som er halvdelen af hvad et arkiv er,
# findes ikke længere. Obsidian, Bear og alt andet læser den her blok, og
# verdandes egen import gør nu også.
created, modified = iso(row["created"]), iso(row["modified"])
head = ""
if created or modified:
head = "---\n"
if created:
head += f"created: {created}\n"
if modified:
head += f"modified: {modified}\n"
head += "---\n\n"
with open(path, "w", encoding="utf-8") as f:
f.write(f"# {title}\n\n{text}\n")
f.write(f"{head}# {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:
# Antallet kom med i klumpen. Det var ét kald pr. note, og da kun hver
# tiende note har et bilag, var de ni af dem en proces startet for at få
# svaret nul.
if row["files"] > 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):
for j in range(1, row["files"] + 1):
a_name = osascript(
f'tell application "Notes" to return name of attachment {j} '
f'of note id "{note_id}"'
+47 -1
View File
@@ -655,7 +655,15 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('males inden fredag', { exact: true }).click();
// Titlen alene, ikke rækken med datomærket i.
//
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
// datoer; den skal ikke vide hvilken dag det er.
await page.getByText('males inden', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
@@ -2388,3 +2396,41 @@ test('lister overlever en gemning — også i et citat, og også med numre', asy
expect(trouble).toEqual([]);
});
test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Undslap `alt` ikke sine anførselstegn, lukkede den første af dem attributten,
// og resten stod som markup: et img med en onerror på. En note deles gennem et
// projekt, så det ville køre på vores eget domæne, hos den der åbnede noten.
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: '# Ondsindet\n\n![" onerror="window.__pwned = true](/api/v1/attachments/aaaabbbbccccdddd)\n'
})
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('img')).toHaveCount(1);
// Billedet peger ingen steder, så det fejler at indlæse — hvilket er præcis
// den vej, en onerror ville blive kaldt ad.
await page.waitForTimeout(800);
expect(await page.evaluate(() => window.__pwned)).toBeUndefined();
// Og hændelsen står som tekst i alt-attributten, hvor den hører hjemme.
const alt = await ed.locator('img').getAttribute('alt');
expect(alt).toContain('onerror');
expect(await ed.locator('img').evaluate((el) => el.hasAttribute('onerror'))).toBe(false);
// Billedets egen 404 hører til prøven: adressen peger med vilje på et bilag,
// der ikke findes, fordi det er den vej en onerror ville blive kaldt ad. Alt
// andet skal stadig være stille.
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
});
+10
View File
@@ -805,6 +805,16 @@
margin-bottom: var(--s1);
}
/* Et billede fylder sin egen bredde og ikke mere. Et foto fra en telefon er
fire tusind pixels bredt, og uden det her stikker det ud af arket. */
.page :global(img) {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
display: block;
margin: var(--s2) 0;
}
.page :global(blockquote) {
margin: 0 0 var(--s2);
padding-left: var(--s3);
+16 -12
View File
@@ -508,14 +508,22 @@
sidebar is already a drop target for tasks — a task dragged onto I dag
stopped landing, because the row had claimed the gesture. A grip claims
nothing until it is grabbed. -->
<!-- Håndtaget har ingen prik i sig længere.
⠿ sad i den to pixels brede rende til venstre for teksten og lå oven i
rækkens egen kant — idéen var rigtig, pladsen var det ikke. Men elementet
bliver: det er dét, der ejer trækket, og gjorde man i stedet hele rækken
draggable, ville alt indeni også blive det — og en opgave trukket hen på
I dag holdt op med at lande, fordi rækken havde taget gesten. Nu er
håndtaget usynligt og lidt bredere, og hånden, der kommer ved hover,
er hele forklaringen. -->
{#snippet grip(key)}
<span
class="grip"
draggable="true"
ondragstart={(e) => onNavDragStart(e, key)}
ondragend={clearNavDrag}
aria-hidden="true">⠿</span
>
aria-hidden="true"
></span>
{/snippet}
{#snippet projectRow(project, sortable)}
@@ -1004,19 +1012,15 @@
so nothing moves when it appears. */
.grip {
position: absolute;
left: -2px;
width: 12px;
text-align: center;
font-size: 10px;
line-height: 1;
color: var(--ink-faint);
left: 0;
top: 0;
bottom: 0;
width: 14px;
cursor: grab;
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.navrow:hover .grip {
opacity: 1;
.grip:active {
cursor: grabbing;
}
.navrow > :global(a) {
+4
View File
@@ -25,6 +25,10 @@ export const da = {
'notes.bold': 'Fed',
'notes.italic': 'Kursiv',
'notes.underline': 'Understreget',
'notes.sortBy': 'Rækkefølge',
'notes.sortUpdated': 'Senest rørt',
'notes.sortCreated': 'Oprettet',
'notes.sortTitle': 'Navn',
'notes.strike': 'Gennemstreget',
'notes.showSource': 'Vis som kode',
'notes.source': 'Notens kildetekst',
+4
View File
@@ -21,6 +21,10 @@ export const en = {
'notes.bold': 'Bold',
'notes.italic': 'Italic',
'notes.underline': 'Underline',
'notes.sortBy': 'Order',
'notes.sortUpdated': 'Last touched',
'notes.sortCreated': 'Created',
'notes.sortTitle': 'Name',
'notes.strike': 'Strikethrough',
'notes.showSource': 'Show as code',
'notes.source': 'Note source',
+43 -6
View File
@@ -37,14 +37,46 @@ const BLOCKS = [
const escapeHtml = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* En værdi, der skal stå inde i en attribut.
*
* escapeHtml er skrevet til tekst mellem to tags, hvor et anførselstegn ikke
* betyder noget, og lader dem derfor stå. Inde i en attribut lukker de den — og
* så står resten af strengen som markup. Det er den ene forskel, og den er hele
* forskellen mellem tekst og et onerror.
*/
const attr = (s) => escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
return escapeHtml(text)
.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
return (
escapeHtml(text)
// Billedet først, og før alt andet.
//
// Der var ingen regel for det overhovedet, så en note fra Apple Noter
// viste `![](/api/v1/attachments/…)` som ord. Billedet var hentet, gemt og
// hæftet på noten — det eneste, der manglede, var at nogen tegnede det.
//
// Kun vores egen adresse. Et `![](http://…)` fra en indsat tekst ville
// ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at
// den er blevet læst — og en note er det sidste sted, man vil have en
// sporingspixel. Alt andet står som den tekst, det er.
//
// `alt` skal citat-undslippes her, og det er ikke en formalitet.
// escapeHtml tager &, < og > — ikke anførselstegn, fordi den er skrevet
// til tekst mellem to tags, hvor et anførselstegn ikke betyder noget. Her
// står værdien inde i en attribut, og der lukker et anførselstegn den:
// `![" onerror="alert(1)](/api/v1/attachments/aaaa)` blev til et img med
// en onerror på. En note er delt gennem et projekt, så det ville køre på
// vores eget domæne med sessionscookien, hos den, der åbnede noten.
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
(_, alt, src) => `<img src="${src}" alt="${attr(alt)}">`)
.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
);
}
/**
@@ -184,6 +216,11 @@ function inlineToMarkdown(node) {
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
switch (node.tagName) {
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
// — og den første gemning ville tage hvert billede ud af noten uden at
// nogen havde rørt det.
case 'IMG':
return `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`;
case 'STRONG':
case 'B':
return inner ? `**${inner}**` : '';
+20 -1
View File
@@ -35,10 +35,29 @@
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
* how anybody looking for something they just closed would describe it.
*/
/**
* Dagen en opgave blev lukket, målt hvor personen står.
*
* Ikke `completed_at.slice(0, 10)`. Tidsstemplet er i UTC, og de ti første tegn
* er derfor UTC's dato — mens overskriften nedenfor sammenligner med *lokal*
* dato. I to timer efter midnat i dansk sommertid er de to ikke den samme dag,
* så en opgave lukket 00.30 lå under "I går", indtil klokken blev to.
*
* Fundet af prøven, som kørte forbi midnat for første gang. Den havde været
* grøn hver eneste gang før, fordi de to datoer er ens tyve timer i døgnet.
*/
const localDay = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
let days = $derived.by(() => {
const groups = new Map();
for (const task of done) {
const key = (task.completed_at ?? '').slice(0, 10);
const key = localDay(task.completed_at);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(task);
}
+82 -2
View File
@@ -19,6 +19,50 @@
import NoteEditor from '$lib/components/NoteEditor.svelte';
let notes = $state([]);
/**
* Rækkefølgen, og hvorfor den kan vælges.
*
* Serveren svarer i én rækkefølge — senest rørt øverst — og det er den rigtige,
* når man arbejder: den note, man var i gang med, er den, man skal tilbage til.
* Den er også den eneste, der ikke kan svare på "hvad skrev jeg dengang".
* Tolv hundrede noter flyttet ind fra et andet program er et arkiv, og et arkiv
* læses efter, hvornår noget blev skrevet, og efter navn.
*
* Sorteret her frem for i en forespørgsel: hele listen er allerede hentet, den
* fylder ingenting, og et valg, der ikke koster et kald, kan skiftes så hurtigt
* som man kan ombestemme sig.
*/
const ORDERS = [
{ key: 'updated', label: 'notes.sortUpdated' },
{ key: 'created', label: 'notes.sortCreated' },
{ key: 'title', label: 'notes.sortTitle' }
];
let order = $state('updated');
$effect(() => {
const saved = localStorage.getItem('notes.order');
if (saved && ORDERS.some((o) => o.key === saved)) order = saved;
});
function setOrder(next) {
order = next;
localStorage.setItem('notes.order', next);
}
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
let ordered = $derived(
[...notes].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (order === 'title') {
return (a.title || '').localeCompare(b.title || '', 'da', { sensitivity: 'base' });
}
const field = order === 'created' ? 'created_at' : 'updated_at';
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
})
);
let selected = $state(null);
let query = $state('');
let status = $state('loading');
@@ -69,6 +113,10 @@
*/
function plain(body) {
return (body ?? '')
// Et billede har ingen ord i sig. Uden det her stod der
// "![](/api/v1/attachments/01a0…" i uddraget af hver note med et foto i,
// hvilket er den eneste tekst i listen, ingen har skrevet.
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*>\s?/gm, '')
@@ -233,7 +281,22 @@
<aside>
<div class="head">
<h1>{t('notes.title')}</h1>
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
<div class="tools">
<!-- En select frem for tre knapper: rækkefølgen vælges sjældent, og tre
knapper ville tage plads fra listen hver eneste dag for at spare et
klik en gang imellem. -->
<select
class="order"
value={order}
onchange={(e) => setOrder(e.currentTarget.value)}
aria-label={t('notes.sortBy')}
>
{#each ORDERS as o}
<option value={o.key}>{t(o.label)}</option>
{/each}
</select>
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
</div>
</div>
<input
@@ -250,7 +313,7 @@
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
{:else}
<ul>
{#each notes as note (note.id)}
{#each ordered as note (note.id)}
<li>
<div class="rowline">
<button
@@ -372,6 +435,23 @@
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s2);
}
.tools {
display: flex;
align-items: center;
gap: var(--s1);
min-width: 0;
}
.order {
font-size: var(--text-xs);
color: var(--ink-muted);
border: 0;
background: none;
padding: 2px var(--s1);
max-width: 13ch;
}
h1 {