feat(notes): datoerne følger med, billederne bliver tegnet, og listen kan sorteres
Release / Build and publish image (push) Failing after 9m54s
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 `` 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:
co-authored by
Claude Opus 5
parent
2ffbd9533b
commit
9b4e7686f9
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+25
-6
@@ -39,12 +39,26 @@ const escapeHtml = (s) =>
|
||||
|
||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||
function inlineToHtml(text) {
|
||||
return escapeHtml(text)
|
||||
.replace(/<u>(.+?)<\/u>/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 `` 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 `` 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.
|
||||
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
|
||||
(_, alt, src) => `<img src="${src}" alt="${alt}">`)
|
||||
.replace(/<u>(.+?)<\/u>/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 +198,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 ` ?? ''})`;
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return inner ? `**${inner}**` : '';
|
||||
|
||||
@@ -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');
|
||||
@@ -233,7 +277,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 +309,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 +431,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: 11ch;
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
||||
Reference in New Issue
Block a user