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 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-19 22:58:19 +02:00
co-authored by Claude Opus 5
parent 2ffbd9533b
commit 9b4e7686f9
8 changed files with 409 additions and 35 deletions
+73 -1
View File
@@ -369,13 +369,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.
@@ -466,6 +477,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 {