Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dee2d5e30b | ||
|
|
2bd8e48aba | ||
|
|
9b4e7686f9 | ||
|
|
2ffbd9533b | ||
|
|
f6c6bdccbb | ||
|
|
9e687185fb | ||
|
|
8229800767 | ||
|
|
a4b12f3601 | ||
|
|
43ad60a84a | ||
|
|
08fc355bf9 |
@@ -394,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
||||
// HTML file displayed inline would run its own script on this origin, with the
|
||||
// session cookie attached — attachments are the one place a user supplies
|
||||
// content that another user opens.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
||||
// Almost everything is served as a download rather than rendered. An uploaded
|
||||
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||
// with the session cookie attached — attachments are the one place a user
|
||||
// supplies content that another user opens.
|
||||
//
|
||||
// The exception is a short list of raster images, and it is not a softening of
|
||||
// that rule but what makes a note work: a note carries its pictures in the text
|
||||
// as ``, and served as octet-stream every one of them is a broken image.
|
||||
// The whole Apple Notes import exists to bring those pictures along.
|
||||
//
|
||||
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||
// type that can run script.
|
||||
disposition := "attachment"
|
||||
contentType := "application/octet-stream"
|
||||
if inlineImage[a.MimeType] {
|
||||
disposition = "inline"
|
||||
contentType = a.MimeType
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||
@@ -408,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// inlineImage is what may be shown rather than downloaded.
|
||||
//
|
||||
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||
// can run script, and a list that grows by accident is how that happens.
|
||||
var inlineImage = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/avif": true,
|
||||
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -352,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
|
||||
}
|
||||
|
||||
@@ -368,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.
|
||||
@@ -397,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
|
||||
@@ -421,10 +466,20 @@ func (s *Server) attachToNote(
|
||||
continue
|
||||
}
|
||||
base := path.Base(f.Name)
|
||||
// The two ways a Markdown file names a neighbouring image: by the path it
|
||||
// was written with, and by its bare name.
|
||||
// The three ways a Markdown file names a neighbouring image: by its path
|
||||
// inside the archive, by the path relative to the note, and by its bare name.
|
||||
//
|
||||
// The archive path was missing, and it is the one an Apple Notes export
|
||||
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||
// root beside the folder. Only the bare name matched, so only the bare name
|
||||
// was replaced, and every picture in every imported note ended up pointing at
|
||||
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||
// stored and attached correctly; it was the link that was wrong.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
|
||||
refs := []string{f.Name, relative, base}
|
||||
if !strings.Contains(body, f.Name) &&
|
||||
!strings.Contains(body, relative) &&
|
||||
!strings.Contains(body, base) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -441,14 +496,81 @@ func (s *Server) attachToNote(
|
||||
continue
|
||||
}
|
||||
|
||||
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||
// stående foran adressen, og linket peger på ingenting.
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
body = strings.ReplaceAll(body, relative, url)
|
||||
body = strings.ReplaceAll(body, base, url)
|
||||
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||
for _, ref := range refs {
|
||||
if ref != "" {
|
||||
body = strings.ReplaceAll(body, ref, url)
|
||||
}
|
||||
}
|
||||
attached++
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Executable
+491
@@ -0,0 +1,491 @@
|
||||
#!/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": "#### "}
|
||||
# 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('/\\:*?"<>|')
|
||||
|
||||
|
||||
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):
|
||||
# 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()
|
||||
|
||||
# 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:
|
||||
# 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 = []
|
||||
|
||||
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:
|
||||
# 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()
|
||||
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._open("**", "**")
|
||||
elif tag in ("i", "em"):
|
||||
self._open("*", "*")
|
||||
elif tag == "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:
|
||||
# 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:
|
||||
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()
|
||||
self.block = None
|
||||
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
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")
|
||||
|
||||
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.
|
||||
#
|
||||
# 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.
|
||||
# Titlen kan være delt over flere overskrifter.
|
||||
#
|
||||
# Noter kalder noten det, der står på de første linjer, sat sammen med
|
||||
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
|
||||
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
|
||||
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
|
||||
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
|
||||
#
|
||||
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
|
||||
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
|
||||
lines = text.split("\n")
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
|
||||
taken, joined = 0, ""
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
taken += 1
|
||||
continue
|
||||
if not line.lstrip().startswith("#"):
|
||||
break
|
||||
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
|
||||
if not title.strip().startswith(candidate):
|
||||
break
|
||||
joined, taken = candidate, taken + 1
|
||||
if joined == title.strip():
|
||||
break
|
||||
|
||||
if joined == title.strip():
|
||||
lines = lines[taken:]
|
||||
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"
|
||||
|
||||
# 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"{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.
|
||||
# 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, row["files"] + 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})\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())
|
||||
@@ -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\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")\""
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
|
||||
|
||||
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
|
||||
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
|
||||
gangen, og tunnelen foran den tager mindre — så det skal deles.
|
||||
|
||||
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
|
||||
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
|
||||
får hver deres. En note og dens billeder ligger altid i samme portion, for
|
||||
linkene i teksten er relative, og en note, hvis billeder kom med i næste
|
||||
portion, ville pege på ingenting.
|
||||
|
||||
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
|
||||
# portion mere end en, der ryger på målstregen.
|
||||
DEFAULT_LIMIT_MB = 40
|
||||
|
||||
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
|
||||
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
|
||||
out_dir = src.rstrip("/") + "-portioner"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
|
||||
if not notes:
|
||||
print(f"Ingen .md-filer i {src}")
|
||||
return 1
|
||||
|
||||
# Hvad hver note vejer, og hvad den trækker med sig.
|
||||
weighed = []
|
||||
for name in notes:
|
||||
path = os.path.join(src, name)
|
||||
text = open(path, encoding="utf-8", errors="replace").read()
|
||||
files = []
|
||||
size = os.path.getsize(path)
|
||||
for rel in LINK.findall(text):
|
||||
f = os.path.join(src, rel)
|
||||
if os.path.exists(f):
|
||||
files.append(rel)
|
||||
size += os.path.getsize(f)
|
||||
weighed.append((name, files, size))
|
||||
|
||||
# En note, der alene er større end grænsen, får sin egen portion frem for at
|
||||
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
|
||||
# kan sendes.
|
||||
batches, current, current_size = [], [], 0
|
||||
for item in weighed:
|
||||
if current and current_size + item[2] > limit:
|
||||
batches.append(current)
|
||||
current, current_size = [], 0
|
||||
current.append(item)
|
||||
current_size += item[2]
|
||||
if current:
|
||||
batches.append(current)
|
||||
|
||||
print(f"{len(notes)} noter → {len(batches)} portioner\n")
|
||||
for i, batch in enumerate(batches, start=1):
|
||||
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
|
||||
seen = set()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, files, _ in batch:
|
||||
z.write(os.path.join(src, name), name)
|
||||
for rel in files:
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
z.write(os.path.join(src, rel), rel)
|
||||
mb = os.path.getsize(zip_path) / 1024 / 1024
|
||||
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
|
||||
|
||||
print(f"\nLigger i:\n {out_dir}")
|
||||
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+139
-1
@@ -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' });
|
||||
@@ -2258,3 +2266,133 @@ 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([]);
|
||||
});
|
||||
|
||||
test('lister overlever en gemning — også i et citat, og også med numre', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Formerne, der hver især gik tabt: en punktliste, en nummereret, en der
|
||||
// begynder et andet sted end ved ét, en liste i en liste, og en liste inde i et
|
||||
// citat. Den sidste er den, en note fra Apple Noter er fuld af.
|
||||
const markdown = [
|
||||
'# Former',
|
||||
'',
|
||||
'- Punkt',
|
||||
'- Punkt 2',
|
||||
'',
|
||||
'1. Nummer',
|
||||
'2. Nummer 2',
|
||||
'',
|
||||
'10. Ti',
|
||||
'11. Elleve',
|
||||
'',
|
||||
'- Ydre',
|
||||
' - Indre',
|
||||
'',
|
||||
'> Et citat',
|
||||
'> - med en liste i'
|
||||
].join('\n');
|
||||
|
||||
const id = await page.evaluate(async (body) => {
|
||||
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 })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
}, markdown);
|
||||
|
||||
await page.goto('/noter?note=' + id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed.locator('ul > li').first()).toBeVisible();
|
||||
|
||||
// Vist som lister, ikke som linjer med bindestreger i.
|
||||
await expect(ed.locator('ol')).toHaveCount(2);
|
||||
await expect(ed.locator('blockquote ul li')).toHaveText('med en liste i');
|
||||
// Den indlejrede ligger inde i sit punkt, ikke ved siden af.
|
||||
await expect(ed.locator('ul li ul li')).toHaveText('Indre');
|
||||
// Og den, der begyndte ved ti, gør det stadig.
|
||||
await expect(ed.locator('ol[start="10"] li').first()).toHaveText('Ti');
|
||||
|
||||
// Rør noten, sådan som en tastning gør, og lad den gemme.
|
||||
await ed.click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' ');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Det, der står i filen bagefter, er det, der stod i den før.
|
||||
//
|
||||
// Det var her, det gik galt: alt fra det første punkt blev til én linje —
|
||||
// "PunktPunkt 2NummerNummer 2" — fordi en liste inde i en blok blev læst som
|
||||
// tekst. Ingen havde rørt de 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('- Punkt\n- Punkt 2');
|
||||
expect(body).toContain('1. Nummer\n2. Nummer 2');
|
||||
expect(body).toContain('10. Ti\n11. Elleve');
|
||||
expect(body).toContain('- Ydre\n - Indre');
|
||||
expect(body).toContain('> Et citat\n> - med en liste i');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
+4
-1
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -21,6 +22,8 @@
|
||||
let { note, onchange, onsave } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
let list;
|
||||
let stylesOpen = $state(false);
|
||||
// Which marks are on where the caret is, so the toolbar shows state rather than
|
||||
// only offering actions.
|
||||
@@ -40,6 +43,11 @@
|
||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||
loadedId = note.id;
|
||||
active = {};
|
||||
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
||||
// er det den rige flade, man skal møde — ellers står den næste note som kode,
|
||||
// uden at nogen har bedt om det.
|
||||
asSource = false;
|
||||
colourCode();
|
||||
});
|
||||
|
||||
const SYNTAX = [
|
||||
@@ -102,16 +110,90 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
readSuggestions();
|
||||
}
|
||||
|
||||
// --- kilden ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Noten som den Markdown, den er.
|
||||
*
|
||||
* Fladen er rig tekst, fordi det er den, folk kan. Men filen under er Markdown,
|
||||
* og der er to ting, den rige flade ikke kan: tage imod Markdown, nogen har
|
||||
* skrevet et andet sted — indsat i den rige flade er `## Overskrift` fem tegn og
|
||||
* ikke en overskrift — og vise, hvad der faktisk bliver gemt. Begge dele er det
|
||||
* samme svar: lad folk se kilden.
|
||||
*
|
||||
* Ikke en tredje tilstand at holde styr på: teksten går gennem de samme to
|
||||
* funktioner som altid, kun den anden vej. Det, man ser i kildefeltet, er ord for
|
||||
* ord det, der ligger i databasen.
|
||||
*/
|
||||
let asSource = $state(false);
|
||||
let source = $state('');
|
||||
|
||||
function toggleSource() {
|
||||
if (!asSource) {
|
||||
source = htmlToMarkdown(editor);
|
||||
asSource = true;
|
||||
return;
|
||||
}
|
||||
asSource = false;
|
||||
onchange?.(source);
|
||||
// Fyldt fra kilden, ikke fra `note.body`.
|
||||
//
|
||||
// Noten i lageret er den sidst gemte, og gemningen venter på sin pause — så
|
||||
// den vej ville kaste det, der lige er skrevet i kildefeltet, væk. `loadedId`
|
||||
// røres ikke: $effect ser stadig en note, den har indlæst, og skriver ikke
|
||||
// hen over det her.
|
||||
queueMicrotask(() => {
|
||||
if (!editor) return;
|
||||
editor.innerHTML = source.trim() ? markdownToHtml(source) : '<h1><br></h1>';
|
||||
colourCode();
|
||||
});
|
||||
}
|
||||
|
||||
/** Kilden skrives direkte igennem: det er filen, man redigerer. */
|
||||
function sourceChanged(event) {
|
||||
source = event.currentTarget.value;
|
||||
onchange?.(source);
|
||||
}
|
||||
|
||||
// --- suggesting a project ------------------------------------------------------
|
||||
|
||||
let suggestions = $state([]);
|
||||
let chosen = $state(0);
|
||||
// Hvor forslagslisten skal stå, og wrappen den måles imod.
|
||||
let menuAt = $state(null);
|
||||
let wrap;
|
||||
|
||||
/**
|
||||
* The word being typed after a #, if that is what is happening.
|
||||
@@ -131,6 +213,33 @@
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvor `#` står, målt i wrappens koordinater.
|
||||
*
|
||||
* Målt på selve tegnet og ikke på markøren: et sammenfaldet område har ingen
|
||||
* kasse på en tom linje, mens `#term` altid har en, fordi der står noget i den.
|
||||
*
|
||||
* Klemt inden for ruden, så en note skrevet helt ude i højre side ikke sender
|
||||
* menuen ud over kanten, og vendt op over linjen, når der ikke er plads under.
|
||||
*/
|
||||
function tagPosition(partial) {
|
||||
if (!wrap) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, Math.max(0, partial.start - 1));
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const at = range.getBoundingClientRect();
|
||||
const box = wrap.getBoundingClientRect();
|
||||
if (!at.width && !at.height) return null;
|
||||
|
||||
const MENU = { w: 210, h: 240 };
|
||||
const below = at.bottom - box.top + 6;
|
||||
const flip = at.bottom + MENU.h > box.bottom && at.top - box.top > MENU.h;
|
||||
return {
|
||||
top: flip ? at.top - box.top - MENU.h - 6 : below,
|
||||
left: Math.max(0, Math.min(at.left - box.left, box.width - MENU.w))
|
||||
};
|
||||
}
|
||||
|
||||
function readSuggestions() {
|
||||
const partial = partialTag();
|
||||
if (!partial) {
|
||||
@@ -138,9 +247,35 @@
|
||||
return;
|
||||
}
|
||||
const term = partial.term.toLowerCase();
|
||||
suggestions = app.projects
|
||||
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||
.slice(0, 6);
|
||||
|
||||
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
|
||||
// man skriver — den stod før øverst i ruden, hvilket i en lang note er et
|
||||
// helt andet sted end det, øjet er — men den må heller ikke rykke sig et par
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
||||
// noget — kun at ens eget projekt ikke var der.
|
||||
//
|
||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
const matches = app.projects.filter(
|
||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
||||
);
|
||||
matches.sort((a, b) => {
|
||||
const an = a.name.toLowerCase();
|
||||
const bn = b.name.toLowerCase();
|
||||
const ap = an.startsWith(term);
|
||||
const bp = bn.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return an.localeCompare(bn, 'da');
|
||||
});
|
||||
suggestions = matches;
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
@@ -199,6 +334,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruller det valgte forslag frem, når listen er længere end sin egen kasse.
|
||||
*
|
||||
* Efter opdateringen, ikke før: `chosen` er netop sat, og rækken bærer klassen
|
||||
* først når Svelte har skrevet den ud. Uden ventetiden ruller den til den
|
||||
* forrige.
|
||||
*/
|
||||
function keepChosenInView() {
|
||||
requestAnimationFrame(() => {
|
||||
list?.querySelector('button.on')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
// The suggestion list owns the arrows and return while it is open, the way
|
||||
// every other completion does. Escape closes it without choosing.
|
||||
@@ -206,11 +354,13 @@
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen + 1) % suggestions.length;
|
||||
keepChosenInView();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
|
||||
keepChosenInView();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
@@ -257,7 +407,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="wrap" bind:this={wrap}>
|
||||
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||
collapses the selection first, so the format lands on nothing — the button
|
||||
appears to do nothing at all, which is how it read the first time. -->
|
||||
@@ -308,11 +458,37 @@
|
||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||
<s>S</s>
|
||||
</button>
|
||||
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
|
||||
anden måde at se det hele på. -->
|
||||
<button
|
||||
class="src"
|
||||
class:on={asSource}
|
||||
onclick={toggleSource}
|
||||
aria-pressed={asSource}
|
||||
aria-label={t('notes.showSource')}
|
||||
title={t('notes.showSource')}></></button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if asSource}
|
||||
<!-- Et almindeligt tekstfelt, fordi det er almindelig tekst. Ingen
|
||||
contenteditable, ingen execCommand: her er der intet at formatere. -->
|
||||
<textarea
|
||||
class="page source"
|
||||
aria-label={t('notes.source')}
|
||||
spellcheck="false"
|
||||
value={source}
|
||||
oninput={sourceChanged}
|
||||
onblur={() => onsave?.()}
|
||||
></textarea>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="page"
|
||||
class:hidden={asSource}
|
||||
bind:this={editor}
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
@@ -320,21 +496,30 @@
|
||||
aria-label={t('notes.body')}
|
||||
spellcheck="true"
|
||||
oninput={changed}
|
||||
onblur={onsave}
|
||||
onblur={() => {
|
||||
colourCode();
|
||||
onsave?.();
|
||||
}}
|
||||
onkeyup={() => {
|
||||
readState();
|
||||
readSuggestions();
|
||||
}}
|
||||
onclick={colourCode}
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
></div>
|
||||
|
||||
{#if suggestions.length}
|
||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
||||
one that sits still. -->
|
||||
<ul class="suggestions" role="listbox">
|
||||
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
|
||||
står stille, mens listen snævres ind. -->
|
||||
<ul
|
||||
class="suggestions"
|
||||
role="listbox"
|
||||
bind:this={list}
|
||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||
>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
<li>
|
||||
<button
|
||||
@@ -483,6 +668,10 @@
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
/* Nu hvor alle der passer står på listen, skal den kunne rulle. Otte rækker
|
||||
er nok til at det ses, at der er flere, uden at menuen dækker noten. */
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
@@ -521,12 +710,50 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
outline: none;
|
||||
line-height: 1.55;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Linjelængden har en grænse, som skærmen ikke har.
|
||||
*
|
||||
* En note i fuld bredde på en stor skærm er linjer på hundrede og fyrre tegn,
|
||||
* og øjet mister hvilken linje det var på, når det springer tilbage. Alle,
|
||||
* der laver noget, man læser i, sætter det her — Apple Noter, Bear, Notion —
|
||||
* og de sætter det omkring det samme sted.
|
||||
*
|
||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||
* blive i den. */
|
||||
max-width: 46rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Kildeteksten. Monospace, fordi det er en fil — og fordi indrykningen i en
|
||||
liste er indhold her og ikke pynt: den skal kunne tælles. */
|
||||
.source {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.src {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||
@@ -555,16 +782,39 @@
|
||||
margin: 0 0 var(--s2);
|
||||
}
|
||||
|
||||
/* Renden skal kunne rumme det bredeste mærke.
|
||||
*
|
||||
* Mærket sættes uden for tekstkanten, inde i listens egen indrykning, og en fast
|
||||
* indrykning på 16px rakte til et punkttegn og ikke til "10." — resten hang ud
|
||||
* over kanten, hvor `.page`, som ruller lodret og derfor beskærer vandret,
|
||||
* klippede det af. Man så et punktum uden sit tal.
|
||||
*
|
||||
* Samme rende til begge slags, frem for en bredere til den nummererede: mærket
|
||||
* lægger sig op ad teksten uanset hvor bred renden er, så deler de den, flugter
|
||||
* punkter og tal — og gør de ikke, står to lister under hinanden med hver sin
|
||||
* venstrekant.
|
||||
*
|
||||
* Målt i em, så den følger skriftstørrelsen, og bred nok til tre cifre. */
|
||||
.page :global(ul),
|
||||
.page :global(ol) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s4);
|
||||
padding-left: 2.2em;
|
||||
}
|
||||
|
||||
.page :global(li) {
|
||||
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);
|
||||
@@ -572,10 +822,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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -131,7 +131,21 @@
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
// its id. It is in this list only so it can be dragged with the others.
|
||||
{ key: 'inbox', label: 'nav.inbox' }
|
||||
{ key: 'inbox', label: 'nav.inbox' },
|
||||
// Noter hører til her, og bærer et ark frem for en prik.
|
||||
//
|
||||
// Den stod før for sig selv over en streg, hvilket sagde det rigtige og
|
||||
// kostede halvfjerds pixels: en streg, luft over og luft under, og
|
||||
// projekterne skubbet så langt ned, at de to første var alt, man så uden
|
||||
// at rulle. Grunden til at skille den ud var, at den ellers læste som
|
||||
// endnu et filter — men det, der gør den til et filter for øjet, er
|
||||
// prikken, ikke pladsen. Et andet mærke siger "en anden slags ting" og
|
||||
// koster ingenting.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -335,16 +349,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);
|
||||
}
|
||||
|
||||
@@ -452,7 +477,20 @@
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||
>
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{#if item.key === 'notes'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M4 1.5h5.2L12.5 5v9.5H4z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
{t(item.label)}
|
||||
</a>
|
||||
</div>
|
||||
@@ -460,17 +498,6 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Notes stand apart, with a rule above them.
|
||||
They are not a fourth way of looking at the same tasks, which is what
|
||||
everything above is; they are the other half of the program. Sitting in that
|
||||
run made them read as one more filter. -->
|
||||
<div class="apart">
|
||||
<a href="/noter" class:active={current === '/noter'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('notes.title')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- One row for every project the sidebar shows — your own, the Inbox and the
|
||||
shared ones — because all three are somewhere a task can be dropped, and
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
@@ -481,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)}
|
||||
@@ -977,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) {
|
||||
@@ -1014,15 +1045,20 @@
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||
half of the program, not a fifth way of looking at the same tasks. */
|
||||
.apart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: var(--s3) 0;
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
/* Arket. Samme plads som prikken, så teksten står på linje med resten af runden
|
||||
— mærket skifter, kolonnen gør ikke. */
|
||||
.mark {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
/* Prikken er 6px bred, og et 13px mærke ville skubbe etiketten tre pixels ud
|
||||
af flugt med de andre. Negativ margen på hver side lægger det tilbage. */
|
||||
margin: 0 -3.5px;
|
||||
color: var(--ink-faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
a.active .mark {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -25,7 +25,13 @@ 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',
|
||||
'notes.styleTitle': 'Titel',
|
||||
'notes.styleHeading': 'Overskrift',
|
||||
'notes.styleSubheading': 'Underrubrik',
|
||||
@@ -38,6 +44,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 +58,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',
|
||||
|
||||
@@ -21,7 +21,13 @@ 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',
|
||||
'notes.styleTitle': 'Title',
|
||||
'notes.styleHeading': 'Heading',
|
||||
'notes.styleSubheading': 'Subheading',
|
||||
@@ -34,6 +40,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 +54,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',
|
||||
|
||||
+202
-32
@@ -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>')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,31 +72,108 @@ function inlineToHtml(text) {
|
||||
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) {
|
||||
out.push(`</${list}>`);
|
||||
list = null;
|
||||
// Listeniveauerne, der står åbne lige nu — ét mærke pr. dybde.
|
||||
//
|
||||
// En stak frem for én `list`-variabel, fordi en punktliste kan ligge inde i en
|
||||
// anden. Den flade udgave kunne kun beskrive ét niveau, så et indrykket punkt
|
||||
// blev lagt ved siden af sit ophav og kom tilbage som søskende: indrykningen
|
||||
// var væk efter én gemning.
|
||||
//
|
||||
// Underlisten lægges *inde i* sit `<li>`, ikke ved siden af. Browsere viser
|
||||
// begge dele ens, men kun den ene er en liste i en liste, når den læses tilbage
|
||||
// — og det er læsningen tilbage, der skriver filen.
|
||||
const stack = [];
|
||||
let liOpen = false;
|
||||
|
||||
const closeItem = () => {
|
||||
if (liOpen) {
|
||||
out.push('</li>');
|
||||
liOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
||||
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
||||
const closeList = (toDepth = 0) => {
|
||||
while (stack.length > toDepth) {
|
||||
closeItem();
|
||||
out.push(`</${stack.pop()}>`);
|
||||
// Efter et niveau lukkes, står vi igen inde i ophavets `<li>`.
|
||||
liOpen = stack.length > 0;
|
||||
}
|
||||
if (stack.length === 0) liOpen = false;
|
||||
};
|
||||
|
||||
if (bullet || numbered) {
|
||||
const want = bullet ? 'ul' : 'ol';
|
||||
if (list !== want) {
|
||||
closeList();
|
||||
out.push(`<${want}>`);
|
||||
list = want;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// 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');
|
||||
}
|
||||
out.push(`<li>${inlineToHtml((bullet ?? numbered)[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
if (fenced) {
|
||||
closeList();
|
||||
fence = fenced[1] ?? '';
|
||||
out.push(`<pre data-lang="${escapeHtml(fence)}"><code>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indrykning bærer niveauet: to mellemrum, eller en tabulator, er ét trin.
|
||||
const item = /^([ \t]*)(?:([-*+])|(\d+)\.)\s+(.*)$/.exec(line);
|
||||
|
||||
if (item) {
|
||||
const indent = item[1].replace(/\t/g, ' ').length;
|
||||
const depth = Math.floor(indent / 2);
|
||||
const want = item[2] ? 'ul' : 'ol';
|
||||
|
||||
// Dybere end vi står: luk ned til niveauet. Én ad gangen, så hvert
|
||||
// `</ul>` lander inde i det `<li>`, det hører til.
|
||||
closeList(depth + 1);
|
||||
|
||||
// Samme dybde, men den anden slags liste — en punktliste efterfulgt af en
|
||||
// nummereret er to lister, ikke én med blandede mærker.
|
||||
if (stack.length === depth + 1 && stack[depth] !== want) {
|
||||
closeList(depth);
|
||||
}
|
||||
|
||||
while (stack.length < depth + 1) {
|
||||
// Nummeret tælles ikke om. Skrev nogen 10., 11., er det dét, der står i
|
||||
// filen, og en liste, der begynder ved ti, skal begynde ved ti — ellers
|
||||
// gør en gemning uden ændringer noten om.
|
||||
const start = item[3] && item[3] !== '1' && stack.length === depth ? ` start="${Number(item[3])}"` : '';
|
||||
out.push(`<${want}${want === 'ol' ? start : ''}>`);
|
||||
stack.push(want);
|
||||
liOpen = false;
|
||||
}
|
||||
|
||||
closeItem();
|
||||
out.push(`<li>${inlineToHtml(item[4])}`);
|
||||
liOpen = true;
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
|
||||
// Et blokcitat, der rummer mere end én linje, læses som ét citat med
|
||||
// indholdet indeni — samme vej tilbage som htmlToMarkdown skrev det. Uden
|
||||
// det blev en liste i et citat til seks citater med et bindestreg i hver.
|
||||
if (/^>(?: |$)/.test(line)) {
|
||||
const inner = [];
|
||||
while (i < lines.length && /^>(?: |$)/.test(lines[i])) {
|
||||
inner.push(lines[i].replace(/^> ?/, ''));
|
||||
i++;
|
||||
}
|
||||
i--;
|
||||
out.push(`<blockquote>${markdownToHtml(inner.join('\n'))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = BLOCKS.find((b) =>
|
||||
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
|
||||
);
|
||||
@@ -96,6 +187,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('');
|
||||
}
|
||||
|
||||
@@ -106,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}**` : '';
|
||||
@@ -146,13 +243,25 @@ function inlineToMarkdown(node) {
|
||||
* browser will nest, split and re-wrap as somebody types, and a regular expression
|
||||
* over that is a guess.
|
||||
*/
|
||||
/** The tags that are a block of their own, and must never be read as inline text. */
|
||||
const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
|
||||
|
||||
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
||||
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
||||
|
||||
export function htmlToMarkdown(root) {
|
||||
const lines = [];
|
||||
|
||||
const walk = (node) => {
|
||||
/**
|
||||
* @param node hvad der læses
|
||||
* @param emit hvor linjerne lægges. Et blokcitat giver sin egen, som sætter
|
||||
* `> ` foran alt, hvad der kommer indefra — sådan kan et citat
|
||||
* indeholde afsnit og lister uden at de bliver til én linje.
|
||||
*/
|
||||
const walk = (node, emit) => {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (child.textContent.trim()) lines.push(child.textContent);
|
||||
if (child.textContent.trim()) emit(child.textContent);
|
||||
continue;
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
@@ -160,32 +269,93 @@ export function htmlToMarkdown(root) {
|
||||
switch (child.tagName) {
|
||||
case 'UL':
|
||||
case 'OL': {
|
||||
let n = 1;
|
||||
for (const li of child.children) {
|
||||
const text = inlineToMarkdown(li).trim();
|
||||
lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`);
|
||||
list(child, 0, emit);
|
||||
break;
|
||||
}
|
||||
|
||||
// Et citat kan rumme blokke. Læst som én streng blev "En", "To" til
|
||||
// "EnTo", og en liste inde i et citat mistede hvert eneste punktmærke
|
||||
// — det var sådan en note kom tilbage som én lang linje efter en
|
||||
// gemning, uden at nogen havde rørt teksten.
|
||||
case 'BLOCKQUOTE': {
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, (l) => emit(l.trim() === '' ? '>' : `> ${l}`));
|
||||
} else {
|
||||
emit('> ' + inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DIV':
|
||||
// The browser's own container when nothing else applies. Read through
|
||||
// it rather than treating it as a block, or every paste adds a level.
|
||||
if ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) {
|
||||
walk(child);
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
} else {
|
||||
lines.push(inlineToMarkdown(child));
|
||||
emit(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') ?? '';
|
||||
emit('```' + lang);
|
||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||
emit(l);
|
||||
}
|
||||
emit('```');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
||||
// En overskrift med en liste i sig er stadig en liste. Samme fælde som
|
||||
// citatet: læst som én linje forsvandt hvert punktmærke.
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
break;
|
||||
}
|
||||
const text = inlineToMarkdown(child);
|
||||
lines.push(block ? block.prefix + text : text);
|
||||
emit(block ? block.prefix + text : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
/**
|
||||
* Én liste, og de lister der ligger i den.
|
||||
*
|
||||
* `start` læses med, så en liste, der begynder ved ti, stadig gør det. Talt om
|
||||
* fra ét gjorde en gemning uden ændringer noten om — den slags er værre end en
|
||||
* fejl, fordi den sker uden at nogen har rørt noget.
|
||||
*/
|
||||
const list = (el, depth, emit) => {
|
||||
let n = Number(el.getAttribute('start') || 1);
|
||||
if (!Number.isFinite(n) || n < 1) n = 1;
|
||||
const pad = ' '.repeat(depth);
|
||||
|
||||
for (const li of el.children) {
|
||||
if (li.tagName !== 'LI') continue;
|
||||
|
||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||
const nested = [...li.children].filter((c) => c.tagName === 'UL' || c.tagName === 'OL');
|
||||
let text;
|
||||
if (nested.length) {
|
||||
const clone = li.cloneNode(true);
|
||||
for (const sub of [...clone.children]) {
|
||||
if (sub.tagName === 'UL' || sub.tagName === 'OL') sub.remove();
|
||||
}
|
||||
text = inlineToMarkdown(clone).trim();
|
||||
} else {
|
||||
text = inlineToMarkdown(li).trim();
|
||||
}
|
||||
|
||||
emit(el.tagName === 'UL' ? `${pad}- ${text}` : `${pad}${n++}. ${text}`);
|
||||
for (const sub of nested) list(sub, depth + 1, emit);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, (l) => lines.push(l));
|
||||
// Trailing blank lines are what an editor accumulates from people pressing
|
||||
// return at the end; they are not content and they grow every time.
|
||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,55 @@
|
||||
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([]);
|
||||
|
||||
/**
|
||||
* 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');
|
||||
@@ -68,6 +113,10 @@
|
||||
*/
|
||||
function plain(body) {
|
||||
return (body ?? '')
|
||||
// Et billede har ingen ord i sig. Uden det her stod der
|
||||
// "]*\)/g, '')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*>\s?/gm, '')
|
||||
@@ -82,6 +131,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: '' });
|
||||
@@ -199,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
|
||||
@@ -216,12 +313,28 @@
|
||||
<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 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,10 +402,18 @@
|
||||
|
||||
<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. */
|
||||
padding: var(--s3) 0 0 var(--s4);
|
||||
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
||||
ingen kant havde og ingenting kunne støde imod. */
|
||||
padding: var(--s3) var(--s4) var(--s3) var(--s4);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: var(--s4);
|
||||
@@ -305,6 +426,7 @@
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: var(--s3);
|
||||
}
|
||||
@@ -313,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 {
|
||||
@@ -330,8 +469,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 +529,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 +548,79 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
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;
|
||||
}
|
||||
|
||||
/* Noten er et ark, ikke en flade i appen.
|
||||
*
|
||||
* Apple Notes skriver på hvidt, og det er ikke pynt: et ark, der holder op et
|
||||
* sted, fortæller hvor teksten hører til, og skiller det, man skriver, fra det
|
||||
* program, man skriver det i. Uden det flød noten ud i siden og lignede endnu
|
||||
* en rude.
|
||||
*
|
||||
* `--surface` frem for `#fff`, fordi der er fem temaer. Rollen er "arket løftet
|
||||
* fra grunden" — hvid i lyst, cremet i paper, en anelse lysere end grunden i de
|
||||
* mørke. Et hårdkodet hvidt ville være et blændende ark i mørkt tema. */
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
/* Luft nok til at teksten ikke rører kanten.
|
||||
*
|
||||
* Vandret vokser den med ruden: på en bred skærm skal et ark have marginer,
|
||||
* ikke bare en kant — clamp holder den mellem en rimelig mindste og en, der
|
||||
* ikke bliver til et vindue med tekst i midten. */
|
||||
padding: var(--s4) clamp(var(--s4), 3.5%, var(--s6));
|
||||
}
|
||||
|
||||
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
||||
|
||||
Reference in New Issue
Block a user