Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dee2d5e30b | ||
|
|
2bd8e48aba | ||
|
|
9b4e7686f9 | ||
|
|
2ffbd9533b | ||
|
|
f6c6bdccbb |
@@ -394,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
// Almost everything is served as a download rather than rendered. An uploaded
|
||||||
// HTML file displayed inline would run its own script on this origin, with the
|
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||||
// session cookie attached — attachments are the one place a user supplies
|
// with the session cookie attached — attachments are the one place a user
|
||||||
// content that another user opens.
|
// supplies content that another user opens.
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
//
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
// 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"))
|
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
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)
|
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) {
|
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
attachmentID := chi.URLParam(r, "attachmentID")
|
attachmentID := chi.URLParam(r, "attachmentID")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -352,7 +353,16 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
name := f.Name
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,13 +378,24 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
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 {
|
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||||
s.log.Warn("import note", "err", err, "file", name)
|
s.log.Warn("import note", "err", err, "file", name)
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
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 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
|
// 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.
|
// 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
|
// 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.
|
// would double a whole library with each copy full of binary rubbish.
|
||||||
func skipFile(name string) bool {
|
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.HasPrefix(path.Base(name), ".") ||
|
strings.HasPrefix(path.Base(name), ".") {
|
||||||
strings.Contains(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
|
// attachToNote records the files a note's text points at and rewrites the text to
|
||||||
@@ -421,10 +466,20 @@ func (s *Server) attachToNote(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
base := path.Base(f.Name)
|
base := path.Base(f.Name)
|
||||||
// The two ways a Markdown file names a neighbouring image: by the path it
|
// The three ways a Markdown file names a neighbouring image: by its path
|
||||||
// was written with, and by its bare name.
|
// 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), "./")
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,14 +496,81 @@ func (s *Server) attachToNote(
|
|||||||
continue
|
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
|
url := "/api/v1/attachments/" + a.ID
|
||||||
body = strings.ReplaceAll(body, relative, url)
|
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||||
body = strings.ReplaceAll(body, base, url)
|
for _, ref := range refs {
|
||||||
|
if ref != "" {
|
||||||
|
body = strings.ReplaceAll(body, ref, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
attached++
|
attached++
|
||||||
}
|
}
|
||||||
return body, 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
|
// mimeOf guesses from the extension. The archive does not carry a type, and
|
||||||
// sniffing the bytes would mean reading every file twice.
|
// sniffing the bytes would mean reading every file twice.
|
||||||
func mimeOf(name string) string {
|
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)
|
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.
|
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import sys
|
|||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
|
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('/\\:*?"<>|')
|
FORBIDDEN = set('/\\:*?"<>|')
|
||||||
|
|
||||||
|
|
||||||
@@ -69,8 +72,26 @@ class NoteParser(HTMLParser):
|
|||||||
# --- linjer ---------------------------------------------------------------
|
# --- linjer ---------------------------------------------------------------
|
||||||
|
|
||||||
def _flush_line(self):
|
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()
|
text = "".join(self.line).rstrip()
|
||||||
self.line = []
|
|
||||||
|
# En linje, der kun er mærker, er en tom linje. Det sker for den sidste
|
||||||
|
# stump af et fedt afsnit, hvor der ikke står andet end linjeskiftet.
|
||||||
|
if MARKS_ONLY.fullmatch(text):
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
self.line = [OPENER[c] for c in reopen]
|
||||||
mono, self.mono = self.mono, False
|
mono, self.mono = self.mono, False
|
||||||
|
|
||||||
if mono:
|
if mono:
|
||||||
@@ -121,11 +142,42 @@ class NoteParser(HTMLParser):
|
|||||||
self.lines.append("")
|
self.lines.append("")
|
||||||
self.mono_lines = []
|
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 -----------------------------------------------------------------
|
# --- tags -----------------------------------------------------------------
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
def handle_starttag(self, tag, attrs):
|
||||||
if tag in BLOCK_PREFIX:
|
if tag in BLOCK_PREFIX:
|
||||||
self._flush_line()
|
# En overskrift, der fortsætter den forrige, er den samme linje.
|
||||||
|
#
|
||||||
|
# Noter deler en linje op i så mange stumper, den har brug for — en
|
||||||
|
# overskrift skrevet i ét hug står som
|
||||||
|
# <h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>, fordi der er en
|
||||||
|
# attributkørsel bag hver stump. Læste man et linjeskift ved hver <h1>,
|
||||||
|
# blev "Aalborg Kommune" til fire overskrifter oven på hinanden, og
|
||||||
|
# noten så ødelagt ud på den mest synlige måde, der findes: i sin egen
|
||||||
|
# titel.
|
||||||
|
#
|
||||||
|
# Der brydes derfor kun, når blokken skifter til en anden slags. Selve
|
||||||
|
# linjeskiftene kommer fra <div> og <br>, som er dem, der betyder det.
|
||||||
|
if self.block != tag:
|
||||||
|
self._flush_line()
|
||||||
self.block = tag
|
self.block = tag
|
||||||
elif tag in ("ul", "ol"):
|
elif tag in ("ul", "ol"):
|
||||||
self._flush_line()
|
self._flush_line()
|
||||||
@@ -138,23 +190,24 @@ class NoteParser(HTMLParser):
|
|||||||
# Mærket sættes på linjen og aflæses, når den skrives ud.
|
# Mærket sættes på linjen og aflæses, når den skrives ud.
|
||||||
self.mono = True
|
self.mono = True
|
||||||
elif tag in ("b", "strong"):
|
elif tag in ("b", "strong"):
|
||||||
self.marks.append("**")
|
self._open("**", "**")
|
||||||
self.line.append("**")
|
|
||||||
elif tag in ("i", "em"):
|
elif tag in ("i", "em"):
|
||||||
self.marks.append("*")
|
self._open("*", "*")
|
||||||
self.line.append("*")
|
|
||||||
elif tag == "u":
|
elif tag == "u":
|
||||||
self.marks.append("</u>")
|
self._open("<u>", "</u>")
|
||||||
self.line.append("<u>")
|
|
||||||
elif tag == "br":
|
elif tag == "br":
|
||||||
self._flush_line()
|
self._flush_line()
|
||||||
|
self.block = None
|
||||||
elif tag == "div":
|
elif tag == "div":
|
||||||
self._flush_line()
|
self._flush_line()
|
||||||
|
self.block = None
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
def handle_endtag(self, tag):
|
||||||
if tag in BLOCK_PREFIX:
|
if tag in BLOCK_PREFIX:
|
||||||
self._flush_line()
|
# Ikke her. Lukkes overskriften ved sit eget sluttag, kan den næste
|
||||||
self.block = None
|
# 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"):
|
elif tag in ("ul", "ol"):
|
||||||
self._flush_line()
|
self._flush_line()
|
||||||
if self.list_kind:
|
if self.list_kind:
|
||||||
@@ -172,6 +225,7 @@ class NoteParser(HTMLParser):
|
|||||||
self.line.append(self.marks.pop())
|
self.line.append(self.marks.pop())
|
||||||
elif tag == "div":
|
elif tag == "div":
|
||||||
self._flush_line()
|
self._flush_line()
|
||||||
|
self.block = None
|
||||||
|
|
||||||
def handle_data(self, data):
|
def handle_data(self, data):
|
||||||
self.line.append(data)
|
self.line.append(data)
|
||||||
@@ -208,6 +262,95 @@ def safe_name(title: str, fallback: str) -> str:
|
|||||||
return name[:80] or fallback
|
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:
|
def main() -> int:
|
||||||
out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/Desktop/apple-noter")
|
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
|
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||||
@@ -238,16 +381,15 @@ def main() -> int:
|
|||||||
used: dict[str, int] = {}
|
used: dict[str, int] = {}
|
||||||
attachments_dir = os.path.join(out_dir, "vedhaeftninger")
|
attachments_dir = os.path.join(out_dir, "vedhaeftninger")
|
||||||
|
|
||||||
for i, note_id in enumerate(ids, start=1):
|
CHUNK = 40
|
||||||
# Navn og krop i ét kald, adskilt af noget, der ikke står i en note.
|
fetched: list[dict] = []
|
||||||
# Hentet hver for sig kunne de nå at komme fra hver sin note.
|
for start in range(0, len(ids), CHUNK):
|
||||||
both = osascript(
|
fetched.extend(fetch(ids[start:start + CHUNK]))
|
||||||
f'tell application "Notes" to tell note id "{note_id}" '
|
print(f" hentet {min(start + CHUNK, len(ids))} / {total}")
|
||||||
f'to return name & "\u241E" & body'
|
|
||||||
)
|
for i, (note_id, row) in enumerate(zip(ids, fetched), start=1):
|
||||||
title, _, body = both.partition("\u241E")
|
title = row["name"].strip() or f"uden titel {i}"
|
||||||
title = title.strip() or f"uden titel {i}"
|
text = to_markdown(row["body"])
|
||||||
text = to_markdown(body)
|
|
||||||
|
|
||||||
# Kroppen begynder med notens egen titel. Skrives der en overskrift
|
# Kroppen begynder med notens egen titel. Skrives der en overskrift
|
||||||
# ovenover, står titlen to gange i hver eneste note.
|
# ovenover, står titlen to gange i hver eneste note.
|
||||||
@@ -255,11 +397,36 @@ def main() -> int:
|
|||||||
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
# 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
|
# starttag tømmer linjen før den — så første linje er tom, og
|
||||||
# sammenligningen så på ingenting.
|
# 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")
|
lines = text.split("\n")
|
||||||
while lines and not lines[0].strip():
|
while lines and not lines[0].strip():
|
||||||
lines.pop(0)
|
lines.pop(0)
|
||||||
if lines and lines[0].lstrip("# ").strip() == title.strip():
|
|
||||||
lines = lines[1:]
|
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():
|
while lines and not lines[0].strip():
|
||||||
lines.pop(0)
|
lines.pop(0)
|
||||||
text = "\n".join(lines)
|
text = "\n".join(lines)
|
||||||
@@ -268,18 +435,34 @@ def main() -> int:
|
|||||||
used[name] = used.get(name, 0) + 1
|
used[name] = used.get(name, 0) + 1
|
||||||
path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
|
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:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write(f"# {title}\n\n{text}\n")
|
f.write(f"{head}# {title}\n\n{text}\n")
|
||||||
|
|
||||||
# Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en
|
# 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.
|
# note, der kommer over som ord alene, er ikke den note.
|
||||||
count = osascript(
|
# Antallet kom med i klumpen. Det var ét kald pr. note, og da kun hver
|
||||||
f'tell application "Notes" to return count of attachments of note id "{note_id}"'
|
# tiende note har et bilag, var de ni af dem en proces startet for at få
|
||||||
)
|
# svaret nul.
|
||||||
if count.isdigit() and int(count) > 0:
|
if row["files"] > 0:
|
||||||
os.makedirs(attachments_dir, exist_ok=True)
|
os.makedirs(attachments_dir, exist_ok=True)
|
||||||
with open(path, "a", encoding="utf-8") as f:
|
with open(path, "a", encoding="utf-8") as f:
|
||||||
for j in range(1, int(count) + 1):
|
for j in range(1, row["files"] + 1):
|
||||||
a_name = osascript(
|
a_name = osascript(
|
||||||
f'tell application "Notes" to return name of attachment {j} '
|
f'tell application "Notes" to return name of attachment {j} '
|
||||||
f'of note id "{note_id}"'
|
f'of note id "{note_id}"'
|
||||||
|
|||||||
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())
|
||||||
@@ -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').fill('males inden fredag #Fælleshuset');
|
||||||
await page.getByLabel('Ny opgave').press('Enter');
|
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' });
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
||||||
|
|||||||
@@ -191,6 +191,9 @@
|
|||||||
|
|
||||||
let suggestions = $state([]);
|
let suggestions = $state([]);
|
||||||
let chosen = $state(0);
|
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.
|
* The word being typed after a #, if that is what is happening.
|
||||||
@@ -210,6 +213,33 @@
|
|||||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
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() {
|
function readSuggestions() {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) {
|
if (!partial) {
|
||||||
@@ -218,6 +248,12 @@
|
|||||||
}
|
}
|
||||||
const term = partial.term.toLowerCase();
|
const term = partial.term.toLowerCase();
|
||||||
|
|
||||||
|
// 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.
|
// Alle der passer, ikke de seks første.
|
||||||
//
|
//
|
||||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||||
@@ -371,7 +407,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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
|
<!-- 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
|
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. -->
|
appears to do nothing at all, which is how it read the first time. -->
|
||||||
@@ -475,10 +511,15 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
{#if suggestions.length}
|
{#if suggestions.length}
|
||||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
|
||||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
står stille, mens listen snævres ind. -->
|
||||||
one that sits still. -->
|
<ul
|
||||||
<ul class="suggestions" role="listbox" bind:this={list}>
|
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)}
|
{#each suggestions as project, i (project.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -764,6 +805,16 @@
|
|||||||
margin-bottom: var(--s1);
|
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) {
|
.page :global(blockquote) {
|
||||||
margin: 0 0 var(--s2);
|
margin: 0 0 var(--s2);
|
||||||
padding-left: var(--s3);
|
padding-left: var(--s3);
|
||||||
|
|||||||
@@ -508,14 +508,22 @@
|
|||||||
sidebar is already a drop target for tasks — a task dragged onto I dag
|
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
|
stopped landing, because the row had claimed the gesture. A grip claims
|
||||||
nothing until it is grabbed. -->
|
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)}
|
{#snippet grip(key)}
|
||||||
<span
|
<span
|
||||||
class="grip"
|
class="grip"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => onNavDragStart(e, key)}
|
ondragstart={(e) => onNavDragStart(e, key)}
|
||||||
ondragend={clearNavDrag}
|
ondragend={clearNavDrag}
|
||||||
aria-hidden="true">⠿</span
|
aria-hidden="true"
|
||||||
>
|
></span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet projectRow(project, sortable)}
|
{#snippet projectRow(project, sortable)}
|
||||||
@@ -1004,19 +1012,15 @@
|
|||||||
so nothing moves when it appears. */
|
so nothing moves when it appears. */
|
||||||
.grip {
|
.grip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -2px;
|
left: 0;
|
||||||
width: 12px;
|
top: 0;
|
||||||
text-align: center;
|
bottom: 0;
|
||||||
font-size: 10px;
|
width: 14px;
|
||||||
line-height: 1;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--fast) var(--ease);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navrow:hover .grip {
|
.grip:active {
|
||||||
opacity: 1;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navrow > :global(a) {
|
.navrow > :global(a) {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const da = {
|
|||||||
'notes.bold': 'Fed',
|
'notes.bold': 'Fed',
|
||||||
'notes.italic': 'Kursiv',
|
'notes.italic': 'Kursiv',
|
||||||
'notes.underline': 'Understreget',
|
'notes.underline': 'Understreget',
|
||||||
|
'notes.sortBy': 'Rækkefølge',
|
||||||
|
'notes.sortUpdated': 'Senest rørt',
|
||||||
|
'notes.sortCreated': 'Oprettet',
|
||||||
|
'notes.sortTitle': 'Navn',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
'notes.showSource': 'Vis som kode',
|
'notes.showSource': 'Vis som kode',
|
||||||
'notes.source': 'Notens kildetekst',
|
'notes.source': 'Notens kildetekst',
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const en = {
|
|||||||
'notes.bold': 'Bold',
|
'notes.bold': 'Bold',
|
||||||
'notes.italic': 'Italic',
|
'notes.italic': 'Italic',
|
||||||
'notes.underline': 'Underline',
|
'notes.underline': 'Underline',
|
||||||
|
'notes.sortBy': 'Order',
|
||||||
|
'notes.sortUpdated': 'Last touched',
|
||||||
|
'notes.sortCreated': 'Created',
|
||||||
|
'notes.sortTitle': 'Name',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
'notes.showSource': 'Show as code',
|
'notes.showSource': 'Show as code',
|
||||||
'notes.source': 'Note source',
|
'notes.source': 'Note source',
|
||||||
|
|||||||
+25
-6
@@ -39,12 +39,26 @@ const escapeHtml = (s) =>
|
|||||||
|
|
||||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||||
function inlineToHtml(text) {
|
function inlineToHtml(text) {
|
||||||
return escapeHtml(text)
|
return (
|
||||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
escapeHtml(text)
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
// Billedet først, og før alt andet.
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
//
|
||||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
// Der var ingen regel for det overhovedet, så en note fra Apple Noter
|
||||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
// viste `` som ord. Billedet var hentet, gemt og
|
||||||
|
// hæftet på noten — det eneste, der manglede, var at nogen tegnede det.
|
||||||
|
//
|
||||||
|
// Kun vores egen adresse. Et `` fra en indsat tekst ville
|
||||||
|
// ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at
|
||||||
|
// den er blevet læst — og en note er det sidste sted, man vil have en
|
||||||
|
// sporingspixel. Alt andet står som den tekst, det er.
|
||||||
|
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
|
||||||
|
(_, alt, src) => `<img src="${src}" alt="${alt}">`)
|
||||||
|
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||||
|
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,6 +198,11 @@ function inlineToMarkdown(node) {
|
|||||||
|
|
||||||
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||||
switch (node.tagName) {
|
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 'STRONG':
|
||||||
case 'B':
|
case 'B':
|
||||||
return inner ? `**${inner}**` : '';
|
return inner ? `**${inner}**` : '';
|
||||||
|
|||||||
@@ -35,10 +35,29 @@
|
|||||||
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
|
* 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.
|
* 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(() => {
|
let days = $derived.by(() => {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const task of done) {
|
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, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
groups.get(key).push(task);
|
groups.get(key).push(task);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,50 @@
|
|||||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||||
|
|
||||||
let notes = $state([]);
|
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 selected = $state(null);
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let status = $state('loading');
|
let status = $state('loading');
|
||||||
@@ -69,6 +113,10 @@
|
|||||||
*/
|
*/
|
||||||
function plain(body) {
|
function plain(body) {
|
||||||
return (body ?? '')
|
return (body ?? '')
|
||||||
|
// Et billede har ingen ord i sig. Uden det her stod der
|
||||||
|
// "]*\)/g, '')
|
||||||
.replace(/^#{1,6}\s+/gm, '')
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
.replace(/^\s*[-*+]\s+/gm, '')
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
.replace(/^\s*>\s?/gm, '')
|
.replace(/^\s*>\s?/gm, '')
|
||||||
@@ -233,7 +281,22 @@
|
|||||||
<aside>
|
<aside>
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<h1>{t('notes.title')}</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -250,7 +313,7 @@
|
|||||||
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each notes as note (note.id)}
|
{#each ordered as note (note.id)}
|
||||||
<li>
|
<li>
|
||||||
<div class="rowline">
|
<div class="rowline">
|
||||||
<button
|
<button
|
||||||
@@ -372,6 +435,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
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 {
|
h1 {
|
||||||
|
|||||||
Reference in New Issue
Block a user