Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b12f3601 | ||
|
|
43ad60a84a | ||
|
|
08fc355bf9 |
Executable
+308
@@ -0,0 +1,308 @@
|
|||||||
|
#!/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": "#### "}
|
||||||
|
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):
|
||||||
|
text = "".join(self.line).rstrip()
|
||||||
|
self.line = []
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
# --- tags -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag in BLOCK_PREFIX:
|
||||||
|
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.marks.append("**")
|
||||||
|
self.line.append("**")
|
||||||
|
elif tag in ("i", "em"):
|
||||||
|
self.marks.append("*")
|
||||||
|
self.line.append("*")
|
||||||
|
elif tag == "u":
|
||||||
|
self.marks.append("</u>")
|
||||||
|
self.line.append("<u>")
|
||||||
|
elif tag == "br":
|
||||||
|
self._flush_line()
|
||||||
|
elif tag == "div":
|
||||||
|
self._flush_line()
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag in BLOCK_PREFIX:
|
||||||
|
self._flush_line()
|
||||||
|
self.block = None
|
||||||
|
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()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
for i, note_id in enumerate(ids, start=1):
|
||||||
|
# Navn og krop i ét kald, adskilt af noget, der ikke står i en note.
|
||||||
|
# Hentet hver for sig kunne de nå at komme fra hver sin note.
|
||||||
|
both = osascript(
|
||||||
|
f'tell application "Notes" to tell note id "{note_id}" '
|
||||||
|
f'to return name & "\u241E" & body'
|
||||||
|
)
|
||||||
|
title, _, body = both.partition("\u241E")
|
||||||
|
title = title.strip() or f"uden titel {i}"
|
||||||
|
text = to_markdown(body)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
lines = text.split("\n")
|
||||||
|
while lines and not lines[0].strip():
|
||||||
|
lines.pop(0)
|
||||||
|
if lines and lines[0].lstrip("# ").strip() == title.strip():
|
||||||
|
lines = lines[1:]
|
||||||
|
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"
|
||||||
|
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"# {title}\n\n{text}\n")
|
||||||
|
|
||||||
|
# Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en
|
||||||
|
# note, der kommer over som ord alene, er ikke den note.
|
||||||
|
count = osascript(
|
||||||
|
f'tell application "Notes" to return count of attachments of note id "{note_id}"'
|
||||||
|
)
|
||||||
|
if count.isdigit() and int(count) > 0:
|
||||||
|
os.makedirs(attachments_dir, exist_ok=True)
|
||||||
|
with open(path, "a", encoding="utf-8") as f:
|
||||||
|
for j in range(1, int(count) + 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")\""
|
|
||||||
@@ -2258,3 +2258,57 @@ test('en opgave viser de noter, der nævner den, og linket åbner noten', async
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
+4
-1
@@ -363,7 +363,10 @@ button {
|
|||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
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 {
|
::selection {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
let query = $state('');
|
let query = $state('');
|
||||||
let tasks = $state([]);
|
let tasks = $state([]);
|
||||||
let projects = $state([]);
|
let projects = $state([]);
|
||||||
|
let notes = $state([]);
|
||||||
let selected = $state(0);
|
let selected = $state(0);
|
||||||
let input;
|
let input;
|
||||||
let controller = null;
|
let controller = null;
|
||||||
@@ -32,13 +33,21 @@
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
tasks = [];
|
tasks = [];
|
||||||
projects = [];
|
projects = [];
|
||||||
|
notes = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
timer = setTimeout(async () => {
|
timer = setTimeout(async () => {
|
||||||
try {
|
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 ?? [];
|
tasks = result.tasks ?? [];
|
||||||
projects = result.projects ?? [];
|
projects = result.projects ?? [];
|
||||||
|
notes = found.notes ?? [];
|
||||||
selected = 0;
|
selected = 0;
|
||||||
} catch {
|
} catch {
|
||||||
// A failed search shows nothing rather than an error inside a
|
// A failed search shows nothing rather than an error inside a
|
||||||
@@ -49,6 +58,7 @@
|
|||||||
|
|
||||||
let results = $derived([
|
let results = $derived([
|
||||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
...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) => ({
|
...tasks.map((t) => ({
|
||||||
kind: 'task',
|
kind: 'task',
|
||||||
id: t.id,
|
id: t.id,
|
||||||
@@ -60,6 +70,7 @@
|
|||||||
|
|
||||||
function choose(item) {
|
function choose(item) {
|
||||||
open = false;
|
open = false;
|
||||||
|
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +120,7 @@
|
|||||||
onclick={() => choose(item)}
|
onclick={() => choose(item)}
|
||||||
onmouseenter={() => (selected = i)}
|
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>
|
<span class="label">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* is the file to change.
|
* is the file to change.
|
||||||
*/
|
*/
|
||||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||||
|
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||||
loadedId = note.id;
|
loadedId = note.id;
|
||||||
active = {};
|
active = {};
|
||||||
|
colourCode();
|
||||||
});
|
});
|
||||||
|
|
||||||
const SYNTAX = [
|
const SYNTAX = [
|
||||||
@@ -102,6 +104,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
function changed() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
onchange?.(htmlToMarkdown(editor));
|
onchange?.(htmlToMarkdown(editor));
|
||||||
@@ -320,11 +348,15 @@
|
|||||||
aria-label={t('notes.body')}
|
aria-label={t('notes.body')}
|
||||||
spellcheck="true"
|
spellcheck="true"
|
||||||
oninput={changed}
|
oninput={changed}
|
||||||
onblur={onsave}
|
onblur={() => {
|
||||||
|
colourCode();
|
||||||
|
onsave?.();
|
||||||
|
}}
|
||||||
onkeyup={() => {
|
onkeyup={() => {
|
||||||
readState();
|
readState();
|
||||||
readSuggestions();
|
readSuggestions();
|
||||||
}}
|
}}
|
||||||
|
onclick={colourCode}
|
||||||
onmouseup={readState}
|
onmouseup={readState}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
{onpaste}
|
{onpaste}
|
||||||
@@ -521,6 +553,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.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;
|
flex: 1;
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
padding: var(--s3) 0;
|
padding: var(--s3) 0;
|
||||||
@@ -572,10 +607,84 @@
|
|||||||
color: var(--ink-muted);
|
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) {
|
.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-family: var(--mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
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) {
|
.page :global(code) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.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 { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
@@ -335,16 +335,27 @@
|
|||||||
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
|
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
|
||||||
|
|
||||||
function onTaskDragOver(event, project) {
|
function onTaskDragOver(event, project) {
|
||||||
if (!carries(event, TASK) || !canReceive(project)) return;
|
if (!(carries(event, TASK) || carries(event, NOTE)) || !canReceive(project)) return;
|
||||||
accept(event);
|
accept(event);
|
||||||
overProject = project.id;
|
overProject = project.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTaskDrop(event, project) {
|
async function onTaskDrop(event, project) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const id = dragged(event, TASK);
|
|
||||||
overProject = null;
|
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);
|
await app.moveToProject(id, project.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
export const TASK = 'application/x-verdande-task';
|
export const TASK = 'application/x-verdande-task';
|
||||||
export const PROJECT = 'application/x-verdande-project';
|
export const PROJECT = 'application/x-verdande-project';
|
||||||
export const GROUP = 'application/x-verdande-group';
|
export const GROUP = 'application/x-verdande-group';
|
||||||
|
export const NOTE = 'application/x-verdande-note';
|
||||||
|
|
||||||
/** Starts a drag carrying one id of the given kind. */
|
/** Starts a drag carrying one id of the given kind. */
|
||||||
export function startDrag(event, type, id) {
|
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);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ export const da = {
|
|||||||
'notes.unfavourite': 'Fjern som favorit',
|
'notes.unfavourite': 'Fjern som favorit',
|
||||||
'notes.syntaxProject': 'projekt',
|
'notes.syntaxProject': 'projekt',
|
||||||
'notes.syntaxNote': 'en anden note',
|
'notes.syntaxNote': 'en anden note',
|
||||||
|
'notes.yesterday': 'i går',
|
||||||
'notes.title': 'Noter',
|
'notes.title': 'Noter',
|
||||||
'notes.new': 'Ny note',
|
'notes.new': 'Ny note',
|
||||||
'notes.search': 'Søg i noter',
|
'notes.search': 'Søg i noter',
|
||||||
@@ -51,6 +52,9 @@ export const da = {
|
|||||||
'notes.saved': 'Gemt',
|
'notes.saved': 'Gemt',
|
||||||
'notes.delete': 'Slet',
|
'notes.delete': 'Slet',
|
||||||
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
|
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
|
||||||
|
'palette.project': 'Projekt',
|
||||||
|
'palette.task': 'Opgave',
|
||||||
|
'palette.note': 'Note',
|
||||||
'nav.today': 'I dag',
|
'nav.today': 'I dag',
|
||||||
'nav.upcoming': 'Kommende',
|
'nav.upcoming': 'Kommende',
|
||||||
'nav.delegated': 'Venter på andre',
|
'nav.delegated': 'Venter på andre',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const en = {
|
|||||||
'notes.unfavourite': 'Remove from favourites',
|
'notes.unfavourite': 'Remove from favourites',
|
||||||
'notes.syntaxProject': 'project',
|
'notes.syntaxProject': 'project',
|
||||||
'notes.syntaxNote': 'another note',
|
'notes.syntaxNote': 'another note',
|
||||||
|
'notes.yesterday': 'yesterday',
|
||||||
'notes.title': 'Notes',
|
'notes.title': 'Notes',
|
||||||
'notes.new': 'New note',
|
'notes.new': 'New note',
|
||||||
'notes.search': 'Search notes',
|
'notes.search': 'Search notes',
|
||||||
@@ -47,6 +48,9 @@ export const en = {
|
|||||||
'notes.saved': 'Saved',
|
'notes.saved': 'Saved',
|
||||||
'notes.delete': 'Delete',
|
'notes.delete': 'Delete',
|
||||||
'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
|
'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.today': 'Today',
|
||||||
'nav.upcoming': 'Upcoming',
|
'nav.upcoming': 'Upcoming',
|
||||||
'nav.delegated': 'Waiting on others',
|
'nav.delegated': 'Waiting on others',
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function markdownToHtml(markdown) {
|
|||||||
const lines = (markdown ?? '').split('\n');
|
const lines = (markdown ?? '').split('\n');
|
||||||
const out = [];
|
const out = [];
|
||||||
let list = null;
|
let list = null;
|
||||||
|
let fence = null; // sproget, mens vi er inde i en ```-blok
|
||||||
|
|
||||||
const closeList = () => {
|
const closeList = () => {
|
||||||
if (list) {
|
if (list) {
|
||||||
@@ -68,6 +69,25 @@ export function markdownToHtml(markdown) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fenced) {
|
||||||
|
closeList();
|
||||||
|
fence = fenced[1] ?? '';
|
||||||
|
out.push(`<pre data-lang="${escapeHtml(fence)}"><code>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
||||||
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
||||||
|
|
||||||
@@ -96,6 +116,7 @@ export function markdownToHtml(markdown) {
|
|||||||
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
|
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
|
||||||
}
|
}
|
||||||
closeList();
|
closeList();
|
||||||
|
if (fence !== null) out.push('</code></pre>');
|
||||||
return out.join('');
|
return out.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +197,19 @@ export function htmlToMarkdown(root) {
|
|||||||
lines.push(inlineToMarkdown(child));
|
lines.push(inlineToMarkdown(child));
|
||||||
}
|
}
|
||||||
break;
|
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') ?? '';
|
||||||
|
lines.push('```' + lang);
|
||||||
|
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||||
|
lines.push(l);
|
||||||
|
}
|
||||||
|
lines.push('```');
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
||||||
const text = inlineToMarkdown(child);
|
const text = inlineToMarkdown(child);
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class AppState {
|
|||||||
* small request against modelling a count in two places.
|
* small request against modelling a count in two places.
|
||||||
*/
|
*/
|
||||||
labelsChanged = $state(0);
|
labelsChanged = $state(0);
|
||||||
|
/** Bumped when a note moves, so a list showing them knows to look again. */
|
||||||
|
notesChanged = $state(0);
|
||||||
|
|
||||||
#socket = null;
|
#socket = null;
|
||||||
#reconnectDelay = 1000;
|
#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() {
|
async refreshProjects() {
|
||||||
const { projects } = await api.listProjects();
|
const { projects } = await api.listProjects();
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
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';
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||||
|
|
||||||
let notes = $state([]);
|
let notes = $state([]);
|
||||||
@@ -82,6 +83,39 @@
|
|||||||
.slice(0, 90);
|
.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() {
|
async function create() {
|
||||||
try {
|
try {
|
||||||
const note = await api.createNote({ body: '' });
|
const note = await api.createNote({ body: '' });
|
||||||
@@ -219,9 +253,25 @@
|
|||||||
{#each notes as note (note.id)}
|
{#each notes as note (note.id)}
|
||||||
<li>
|
<li>
|
||||||
<div class="rowline">
|
<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>
|
<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>
|
||||||
<button
|
<button
|
||||||
class="star"
|
class="star"
|
||||||
@@ -289,6 +339,12 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.notes {
|
.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
|
/* 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
|
against the sidebar's rule, which reads as a rendering fault rather than
|
||||||
as a layout. */
|
as a layout. */
|
||||||
@@ -305,6 +361,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
padding-right: var(--s3);
|
padding-right: var(--s3);
|
||||||
}
|
}
|
||||||
@@ -330,8 +387,27 @@
|
|||||||
color: var(--ink);
|
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 {
|
.search {
|
||||||
width: 100%;
|
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 {
|
ul {
|
||||||
@@ -371,9 +447,14 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--s2);
|
padding: var(--s2) var(--s2) var(--s3);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
li:last-child .row {
|
||||||
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
.row:hover {
|
||||||
@@ -385,28 +466,60 @@
|
|||||||
color: var(--ink);
|
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 {
|
.row strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 560;
|
font-weight: 600;
|
||||||
|
line-height: 1.45;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview {
|
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
||||||
display: block;
|
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);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.when {
|
||||||
|
flex: none;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
||||||
|
|||||||
Reference in New Issue
Block a user