Compare commits
11 Commits
feat/notes
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27add08659 | ||
|
|
bafad9b72d | ||
|
|
52f420fc11 | ||
|
|
48c6cf39d5 | ||
|
|
5db098b67f | ||
|
|
e129fd4b0a | ||
|
|
f545b7cf54 | ||
|
|
6173b519db | ||
|
|
c259d97f04 | ||
|
|
534b1b0f58 | ||
|
|
5c0b658855 |
30
CLAUDE.md
30
CLAUDE.md
@@ -67,6 +67,11 @@ are stale — they were last taken before the notes and class-register work, and
|
||||
could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live
|
||||
session has lapsed — check the container's keepalive log before suspecting code.
|
||||
|
||||
The editor's Markdown round trip is unit-tested; the **browser** side of
|
||||
`editor.js` is not, because nothing here runs one. It was checked by hand in
|
||||
Firefox against a page that drives the toolbar — WebKit, which is the engine on
|
||||
the phone this is written on, has still never run it.
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
|
||||
purpose — the generation/diff semantics are entirely SQL, so a mock would test
|
||||
@@ -120,7 +125,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
hit would read "my note, Monday" and "what did we do in Deutsch" would match
|
||||
a note whose other five lessons were something else. The files are the truth and the
|
||||
index is a view of them, so `list_notes`/`get_note` read disk and answer
|
||||
before the first crawl and while Postgres is down. **The one thing anything
|
||||
before the first crawl and while Postgres is down. `searchNotes` is full
|
||||
text over those same files, behind `/api/notes/search` and the app's Suche
|
||||
tab: notes reach the index only on a **full** crawl, so anything written
|
||||
this week would be missing from it, and the app is exactly where "I wrote
|
||||
that this morning" is the common case. **The one thing anything
|
||||
here writes** — see Invariants. `docs/NOTES.md` is the guide.
|
||||
- **`untis-history.ts`** — the class register read backwards, which is what
|
||||
puts "what did we actually cover" into the search index. Its whole reason
|
||||
@@ -145,7 +154,24 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
**files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs`
|
||||
and read relative to `import.meta.dirname` — real HTML, CSS and JS that an
|
||||
editor and a linter understand, which is also what the CSP requires, since it
|
||||
forbids inline script.
|
||||
forbids inline script. `app.js` is an ES **module**; a new asset must be added
|
||||
to `ASSETS` *and* to the route's regex in `app-page.ts`, or it 404s.
|
||||
- **`http/app/markdown.js` + `editor.js`** — the note is edited as formatted
|
||||
text and stored as Markdown, and these two are that translation.
|
||||
`markdown.js` is the pair `markdownToHtml` / `markdownFromDom`; `editor.js`
|
||||
drives a `contenteditable` element with `execCommand` (no library: the CSP
|
||||
allows no outside script and the app has no bundler). **The round trip must
|
||||
settle**: one pass may tidy a note, a second must change nothing, and
|
||||
`editor.js` checks exactly that before opening a note formatted — a note that
|
||||
fails opens in the Markdown view instead. `test/app-markdown.test.ts` covers
|
||||
it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a
|
||||
lesson's notes to a lossy serializer is not a bug anyone can recover from.
|
||||
Pasted HTML goes through Markdown before it reaches the document, which is
|
||||
where sanitising and formatting are the same operation. **An element holding
|
||||
blocks is a block, whatever its tag, and a container's style is not
|
||||
emphasis** — WebKit wraps a copied selection in one span carrying the computed
|
||||
style of everything in it, so reading that span as inline made a whole pasted
|
||||
Apple note bold and flattened it into one paragraph.
|
||||
- **`http/web-auth.ts`** — the app's login, which is a different kind of
|
||||
credential from everything else here: a password a person types, not a token a
|
||||
program was configured with. scrypt at startup, a signed `HttpOnly` /
|
||||
|
||||
11
README.md
11
README.md
@@ -86,6 +86,17 @@ teachers, rooms, cancellations dropped and substitutions marked. Each heading is
|
||||
indexed as its own lesson, so a search answers "my own note, Deutsch,
|
||||
18.09.2026" rather than "Friday".
|
||||
|
||||
It reads like a notes app: the notes on the left, the open one on the right, and
|
||||
a search box above the list. Search goes straight to the files — no crawl in
|
||||
between, so a lesson written this morning is findable this morning — and a
|
||||
result names the lesson it matched in rather than the day, opening that day at
|
||||
that lesson.
|
||||
|
||||
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
|
||||
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
|
||||
underneath when you want it. The file on disk stays Markdown either way — that
|
||||
is what the index reads and what outlives the app.
|
||||
|
||||
It saves as you type, keeps a local copy of every keystroke for when the signal
|
||||
goes, and refuses a save that would overwrite a version it never saw. On a phone
|
||||
it adds to the home screen and opens standalone.
|
||||
|
||||
@@ -12,7 +12,7 @@ should not make twice**, because changing it later means moving files by hand.
|
||||
| | |
|
||||
|---|---|
|
||||
| **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. |
|
||||
| **The app at `/app`** | A login, a day-at-a-time notes editor, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
|
||||
| **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
|
||||
| **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. |
|
||||
|
||||
Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes
|
||||
@@ -156,11 +156,17 @@ If you have notes in Apple Notes, migrate them now — see
|
||||
[NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac:
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
# --folder takes that folder and everything under it; leave it off for all notes
|
||||
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > notes.ndjson
|
||||
wc -l notes.ndjson # one line per note
|
||||
schulcloud note import notes.ndjson --dry-run # look first
|
||||
schulcloud note import notes.ndjson
|
||||
```
|
||||
|
||||
The export runs on the Mac; the import does not have to. The CLI talks to the
|
||||
server over HTTP, so copying the `.ndjson` to whatever machine already has the
|
||||
CLI configured is the shorter path.
|
||||
|
||||
Import **once**. Re-running creates second copies, because the importer cannot
|
||||
tell an edited note from a new one with the same title.
|
||||
|
||||
@@ -241,6 +247,8 @@ write them.
|
||||
| `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container |
|
||||
| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app |
|
||||
| `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate |
|
||||
| The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` |
|
||||
| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note |
|
||||
| Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` |
|
||||
| `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads |
|
||||
| `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes |
|
||||
|
||||
101
docs/NOTES.md
101
docs/NOTES.md
@@ -68,7 +68,22 @@ On a phone it is worth adding to the home screen — it has a manifest and opens
|
||||
standalone, which is the difference between "a page I have to find" and "the
|
||||
thing I open in a free period".
|
||||
|
||||
**Notizen** is one screen: the day, `‹ ›` to move between days, and the editor.
|
||||
**Notizen** is one screen in two panes: your notes on the left, the open one on
|
||||
the right. Side by side where there is room; on a phone the list comes first and
|
||||
a note pushes over it, with `‹ Notizen` to come back.
|
||||
|
||||
The list is every note, newest first — the day, the lessons it covers, and the
|
||||
first thing written in it:
|
||||
|
||||
```
|
||||
Freitag, 18.09.2026
|
||||
Deutsch · LF07
|
||||
Dreischritt: These, Argument mit Beleg, Fazit.
|
||||
```
|
||||
|
||||
**+** opens today, whether or not it has a note yet — the one thing a list of
|
||||
notes cannot show you, because an empty day is not a note. `‹ ›` and the date
|
||||
field move between days from there.
|
||||
|
||||
- Opening a day with no note yet **fills in that day's lessons from WebUntis** —
|
||||
numbered, with times, teacher and room, cancellations left out and
|
||||
@@ -87,6 +102,75 @@ thing I open in a free period".
|
||||
`add_note` — the save is refused and you are asked which version wins. It
|
||||
never silently overwrites.
|
||||
|
||||
### Writing, without typing Markdown
|
||||
|
||||
The editor shows the note **formatted** — headings as headings, bold as bold,
|
||||
tables as tables — and the toolbar above it writes the Markdown. Nobody types
|
||||
`##` or `**` during a lesson.
|
||||
|
||||
| Button | What it writes |
|
||||
| --- | --- |
|
||||
| `H2` | a lesson heading — the one that makes the lesson separately searchable |
|
||||
| `H3` | a subheading inside a lesson |
|
||||
| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) |
|
||||
| `<>` | inline code (`Strg`/`Cmd` + E) |
|
||||
| `• —` `1. —` | bullet and numbered lists; nest them with Tab |
|
||||
| `☐` | a box to tick off |
|
||||
| `❝` | a quote — the teacher's exact wording |
|
||||
| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
|
||||
| `MD` | the Markdown itself |
|
||||
|
||||
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. In a
|
||||
table, `Tab` walks the cells and a `Tab` out of the last one adds a row;
|
||||
`Ctrl`/`Cmd`+`Enter` opens a paragraph after whatever block you are in.
|
||||
|
||||
A paste from a web page, a PDF or Apple Notes keeps its structure and loses its
|
||||
fonts, colours and anything else that is not in the list above — pasted HTML is
|
||||
converted to Markdown before it reaches the page, which is what keeps a copied
|
||||
page from bringing its script along. **A copy from Apple Notes arrives wrapped
|
||||
in one span carrying the computed style of everything in it**, `font-weight:
|
||||
700` included; that wrapper is a container, not emphasis, and the blocks inside
|
||||
it are blocks. Reading it the other way made a whole pasted note bold and
|
||||
flattened every line into one paragraph, which is the failure
|
||||
`test/app-markdown.test.ts` now pins down.
|
||||
|
||||
**The file is still Markdown.** `MD` shows it and lets you edit it directly,
|
||||
which is the way to write something the toolbar has no button for. There is no
|
||||
underline, because Markdown cannot store one — `F` or `K` instead.
|
||||
|
||||
Opening a note may tidy it once: `*so*` becomes `_so_`, a table typed unevenly
|
||||
lines up. Nothing is rewritten until you actually change something, and a note
|
||||
whose formatting the view cannot hold unchanged **opens as Markdown** and says
|
||||
so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
|
||||
that promise up: every construct in this document goes in and comes back out
|
||||
unchanged.
|
||||
|
||||
### Searching, in the list
|
||||
|
||||
The box above the list searches your own notes and nothing else, and the results
|
||||
*are* the list — searching is a way of finding a note, not a separate place to
|
||||
be. Emptying the box brings the whole list back.
|
||||
|
||||
Every word has to appear, in any order, ignoring case and accents; there is no
|
||||
stemming, so *Argument* does not find *Argumente*.
|
||||
|
||||
A result names the **lesson**, not the day — `1. LF10 — 08:00–08:45` with the
|
||||
date under it and the matched words marked — because a day note holds five or
|
||||
six lessons and "Freitag" says nothing about which one matched. Opening a hit
|
||||
opens that day **at that lesson**. A note that is not a school day, such as one
|
||||
from the Apple Notes import, opens read-only: the editor is day-shaped and those
|
||||
notes have no day.
|
||||
|
||||
It reads the **files**, not the index. That is the point: notes reach the
|
||||
Postgres index only on a full crawl, so a lesson written this morning would not
|
||||
be there, and "what did I write this week" is most of what anyone searches their
|
||||
own notes for. A few hundred small files answer instantly, and it keeps working
|
||||
while Postgres is down — the same reason `list_notes` reads disk.
|
||||
|
||||
The `search` tool in Claude is the other half: it spans the Schulcloud material
|
||||
and the WebUntis class register as well, at the cost of being only as fresh as
|
||||
the last crawl.
|
||||
|
||||
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
|
||||
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
|
||||
token set` and the older `/token` page do). It also shows the index's state and
|
||||
@@ -154,11 +238,22 @@ not the clumsy route to your notes — it is the only one.
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
|
||||
```
|
||||
|
||||
The first run raises a macOS permission dialog ("Terminal wants access to
|
||||
Notes"); without it every note comes back empty. `--folder Deutsch` exports one
|
||||
Notes folder.
|
||||
Notes"); without it every note comes back empty.
|
||||
|
||||
**`--folder Schule` exports one folder and everything under it.** Folder names
|
||||
are matched whole, not as a fragment, and the path is recorded relative to the
|
||||
one you named — so `Schule/Deutsch` arrives as the subject `Deutsch`, and a note
|
||||
sitting loose in `Schule` arrives with no subject rather than one called
|
||||
"Schule". Reading a note's body is the expensive part, so filtering here rather
|
||||
than afterwards is also what makes a large library finish.
|
||||
|
||||
The records go to standard output and the progress line to standard error, so
|
||||
`> notes.ndjson` gets exactly the notes. Check it took: `wc -l notes.ndjson`
|
||||
should be the number the script reported.
|
||||
|
||||
Then look at what it would do, and do it:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Run this **on the Mac that has the notes**:
|
||||
*
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Deutsch > deutsch.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
|
||||
*
|
||||
* then hand the file to `schulcloud note import notes.ndjson`.
|
||||
*
|
||||
@@ -18,9 +18,15 @@
|
||||
* JXA and not AppleScript because it can serialise JSON, and because reading
|
||||
* properties one note at a time is what keeps a locked note from aborting the
|
||||
* run rather than a language preference.
|
||||
*
|
||||
* **Nothing here uses `console.log`.** In JXA it writes to standard *error*,
|
||||
* not standard output — so `> notes.ndjson` produced an empty file while every
|
||||
* note scrolled past on the terminal. Both streams are written through
|
||||
* NSFileHandle below, which is the only way to be sure which one you are on.
|
||||
*/
|
||||
|
||||
ObjC.import('stdlib');
|
||||
ObjC.import('Foundation');
|
||||
|
||||
function run(argv) {
|
||||
const options = parseArguments(argv);
|
||||
@@ -41,23 +47,62 @@ function run(argv) {
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const note = items[i];
|
||||
const record = readNote(note);
|
||||
// The folder first. The body is the one expensive property — it is
|
||||
// decompressed per note and is what makes a big library take minutes —
|
||||
// and a note in another folder is not worth reading one for.
|
||||
const folder = folderUnder(safe(function () { return folderPath(note.container()); }, ''), options.folder);
|
||||
if (folder === null) continue;
|
||||
|
||||
const record = readNote(note, folder);
|
||||
if (!record) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (options.folder && (record.folder || '').toLowerCase().indexOf(options.folder.toLowerCase()) === -1) continue;
|
||||
// One object per line, so a huge export streams and a bad note costs one line.
|
||||
console.log(JSON.stringify(record));
|
||||
emit(JSON.stringify(record));
|
||||
written++;
|
||||
}
|
||||
|
||||
// stderr, so it never lands in the file being redirected.
|
||||
log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.');
|
||||
log(
|
||||
'Exported ' + written + ' note(s)' +
|
||||
(options.folder ? ' from "' + options.folder + '"' : '') +
|
||||
(skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') +
|
||||
'.',
|
||||
);
|
||||
if (written === 0) {
|
||||
log(
|
||||
options.folder
|
||||
? 'No note is in a folder called "' + options.folder + '". Folder names are matched whole, not as a fragment.'
|
||||
: 'No notes were readable. Grant the terminal access to Notes and try again.',
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readNote(note) {
|
||||
/**
|
||||
* A note's folder as seen from the one asked for, or null if it is elsewhere.
|
||||
*
|
||||
* Matched by path segment, not substring: `--folder Schule` takes `Schule` and
|
||||
* everything under it, and leaves `Musikschule` alone.
|
||||
*
|
||||
* The path is recorded *relative* to that folder, because the import reads the
|
||||
* last segment as the subject: `Schule/Deutsch` has to arrive as `Deutsch`, and
|
||||
* a note sitting loose in `Schule` has to arrive with no folder at all — a
|
||||
* subject of "Schule" is not a subject. Without `--folder` the full path is
|
||||
* kept, which is the same thing measured from the top.
|
||||
*/
|
||||
function folderUnder(path, wanted) {
|
||||
if (!wanted) return path;
|
||||
const parts = String(path).split('/');
|
||||
const target = wanted.toLowerCase();
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i].trim().toLowerCase() === target) return parts.slice(i + 1).join('/');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNote(note, folder) {
|
||||
try {
|
||||
// Read the body first: it is the property a locked note refuses, and
|
||||
// there is no point building a record we cannot fill.
|
||||
@@ -66,7 +111,7 @@ function readNote(note) {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, ''),
|
||||
body: body || '',
|
||||
folder: safe(function () { return folderPath(note.container()); }, ''),
|
||||
folder: folder,
|
||||
created: safe(function () { return iso(note.creationDate()); }, ''),
|
||||
modified: safe(function () { return iso(note.modificationDate()); }, ''),
|
||||
};
|
||||
@@ -77,6 +122,7 @@ function readNote(note) {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, '(unreadable)'),
|
||||
body: '',
|
||||
folder: folder,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
@@ -128,10 +174,18 @@ function parseArguments(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** One line of the export, on standard output — the file being redirected. */
|
||||
function emit(line) {
|
||||
write($.NSFileHandle.fileHandleWithStandardOutput, line);
|
||||
}
|
||||
|
||||
/** Progress and problems, on standard error, so they never land in the file. */
|
||||
function log(message) {
|
||||
$.NSFileHandle.fileHandleWithStandardError.writeData(
|
||||
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding),
|
||||
);
|
||||
write($.NSFileHandle.fileHandleWithStandardError, message);
|
||||
}
|
||||
|
||||
function write(handle, text) {
|
||||
handle.writeData($.NSString.alloc.initWithUTF8String(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
|
||||
@@ -726,10 +726,26 @@ console.log('\n== web app ==');
|
||||
);
|
||||
check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN));
|
||||
|
||||
const assets = await Promise.all(
|
||||
['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)),
|
||||
);
|
||||
// Every file the shell asks for, including the two modules the editor is
|
||||
// made of: a missing one leaves a page that loads and cannot type.
|
||||
const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest'];
|
||||
const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`)));
|
||||
check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' '));
|
||||
check(
|
||||
'the editor\'s modules are served as JavaScript',
|
||||
assets
|
||||
.filter((_, index) => assetNames[index].endsWith('.js'))
|
||||
.every((response) => /javascript/.test(response.headers.get('content-type') ?? '')),
|
||||
assets.map((r) => r.headers.get('content-type')).join(' | '),
|
||||
);
|
||||
check(
|
||||
'the shell loads the app as a module, so its imports resolve',
|
||||
/<script type="module" src="app\.js">/.test(shellText) &&
|
||||
shellText.includes('data-command="bold"') &&
|
||||
shellText.includes('id="search-form"') &&
|
||||
// The note list and the editor are one screen now, not two tabs.
|
||||
shellText.includes('id="rail-list"'),
|
||||
);
|
||||
|
||||
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
|
||||
check('session says "not logged in" rather than failing', anonymousSession.authenticated === false);
|
||||
@@ -817,6 +833,56 @@ console.log('\n== web app ==');
|
||||
`${bySubject.count} note(s)`,
|
||||
);
|
||||
|
||||
// What a row in the app's note list shows, which is why the listing carries
|
||||
// it: one request for the whole list rather than one per note.
|
||||
const listing = await (await fetch(`${root}/api/notes?limit=5`, { headers: withSession })).json();
|
||||
const dayRow = listing.notes?.find((note) => note.path === '2026/2026-09-18.md');
|
||||
check(
|
||||
'the listing says what a note holds, without its body',
|
||||
dayRow !== undefined && dayRow.text === undefined && dayRow.lessons === 1 && dayRow.subjects?.includes('Geschichte'),
|
||||
`${dayRow?.lessons} lesson(s), subjects ${dayRow?.subjects?.join('/')}`,
|
||||
);
|
||||
check(
|
||||
'and a preview that reads as prose',
|
||||
/Weimarer Republik/.test(dayRow?.preview ?? '') && !/[#*|]/.test(dayRow?.preview ?? ''),
|
||||
dayRow?.preview,
|
||||
);
|
||||
|
||||
// Full text over the files themselves — the app's search box. It reads disk,
|
||||
// so a note written seconds ago is findable without a crawl, which is the
|
||||
// whole reason it does not go through the index.
|
||||
const found = await (
|
||||
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Scheiterns')}`, { headers: withSession })
|
||||
).json();
|
||||
check(
|
||||
'note search finds a lesson by its text, with no crawl in between',
|
||||
found.count === 1 && found.hits[0]?.path === '2026/2026-09-18.md',
|
||||
`${found.count} hit(s)`,
|
||||
);
|
||||
check(
|
||||
'and answers with the lesson rather than the day',
|
||||
found.hits[0]?.subject === 'Geschichte' && /Geschichte/.test(found.hits[0]?.heading ?? ''),
|
||||
`${found.hits[0]?.subject} — ${found.hits[0]?.heading}`,
|
||||
);
|
||||
check(
|
||||
'the snippet reads as prose, not as Markdown',
|
||||
/Weimarer Republik/.test(found.hits[0]?.snippet ?? '') && !/[#*|]/.test(found.hits[0]?.snippet ?? ''),
|
||||
found.hits[0]?.snippet,
|
||||
);
|
||||
|
||||
const accents = await (
|
||||
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('weimarer')}`, { headers: withSession })
|
||||
).json();
|
||||
check('search ignores case and accents', accents.count === 1, `${accents.count} hit(s)`);
|
||||
|
||||
const bothWords = await (
|
||||
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Subnetting')}`, { headers: withSession })
|
||||
).json();
|
||||
check('every word has to match', bothWords.count === 0, `${bothWords.count} hit(s)`);
|
||||
|
||||
const tooShort = await fetch(`${root}/api/notes/search?q=a`, { headers: withSession });
|
||||
check('a one-letter search is refused rather than reading every note', tooShort.status === 400, `got ${tooShort.status}`);
|
||||
|
||||
const badDate = await fetch(`${root}/api/notes/day?date=2026-02-30`, { headers: withSession });
|
||||
check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`);
|
||||
|
||||
|
||||
@@ -550,6 +550,139 @@ export function filterNotes(
|
||||
});
|
||||
}
|
||||
|
||||
/** One place a query matched: a lesson, or a whole note that has no lessons. */
|
||||
export interface NoteHit {
|
||||
/** The note's path, as `get_note` takes it. */
|
||||
path: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
/** The lesson's subject, or the note's own. */
|
||||
subject?: string;
|
||||
/** The `##` heading the match sits under, when the note has lessons. */
|
||||
heading?: string;
|
||||
/** A line or two around the first match, for a result list. */
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search over the note files themselves.
|
||||
*
|
||||
* Deliberately *not* the Postgres index the `search` tool uses. Notes are only
|
||||
* read by a full crawl, so anything written this week would be missing from it
|
||||
* — and the one place a person searches their own notes from is the app, where
|
||||
* "I wrote that this morning" is the common case. A few hundred small files
|
||||
* read from disk answer in well under the time an index would take to catch up,
|
||||
* and this keeps working when Postgres is down, which is the same reason
|
||||
* `list_notes` reads disk.
|
||||
*
|
||||
* Matching is by word: every word must appear somewhere in the lesson, in any
|
||||
* order, ignoring case and accents, so "erorterung aufbau" finds a lesson about
|
||||
* the Erörterung whose Aufbau was discussed.
|
||||
*/
|
||||
export function searchNotes(notes: NoteDoc[], query: string, limit = 50): NoteHit[] {
|
||||
const words = fold(query)
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 0);
|
||||
if (words.length === 0) return [];
|
||||
|
||||
const hits: NoteHit[] = [];
|
||||
for (const note of notes) {
|
||||
// A day note answers per lesson, for the same reason the index does: a
|
||||
// hit that says "my note, Monday" names neither the subject nor what it
|
||||
// was about.
|
||||
const sections = noteSections(note);
|
||||
const pieces =
|
||||
sections.length > 0
|
||||
? sections.map((section) => ({
|
||||
heading: section.heading,
|
||||
subject: section.subject ?? note.subject,
|
||||
text: `${section.heading}\n${section.text}`,
|
||||
}))
|
||||
: [{ heading: undefined, subject: note.subject, text: `${note.title}\n${note.text}` }];
|
||||
|
||||
for (const piece of pieces) {
|
||||
const haystack = fold(`${piece.text}\n${note.tags.join(' ')}`);
|
||||
if (!words.every((word) => haystack.includes(word))) continue;
|
||||
hits.push({
|
||||
path: note.path,
|
||||
title: note.title,
|
||||
...(note.date ? { date: note.date } : {}),
|
||||
...(piece.subject ? { subject: piece.subject } : {}),
|
||||
...(piece.heading ? { heading: piece.heading } : {}),
|
||||
snippet: snippetAround(piece.text, words[0]!),
|
||||
});
|
||||
if (hits.length >= limit) return hits;
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case and accents removed, so "Erörterung" and "erorterung" are one word.
|
||||
*
|
||||
* The Postgres index does this with a German configuration; here it is plain
|
||||
* Unicode folding, which is enough for "find the lesson I am thinking of" and
|
||||
* has no stemming — a search for "Argumente" will not find "Argument".
|
||||
*/
|
||||
function fold(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
/** The line the first word matched, with the next one, as readable prose. */
|
||||
function snippetAround(text: string, word: string): string {
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0);
|
||||
const at = lines.findIndex((line) => fold(line).includes(word));
|
||||
const preview = lines
|
||||
.slice(Math.max(0, at === -1 ? 0 : at), (at === -1 ? 0 : at) + 2)
|
||||
.map(plainText)
|
||||
.join(' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
return preview.length > 240 ? `${preview.slice(0, 237)}…` : preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of Markdown as the words it holds.
|
||||
*
|
||||
* Wherever a note is *shown* rather than edited — a search snippet, a row in
|
||||
* the app's note list — this is what it goes through. A line is read there,
|
||||
* not parsed: `| 1NF | atomare Werte |` says more as "1NF · atomare Werte",
|
||||
* and `_Fazit_` says exactly as much as "Fazit" while looking like a mistake.
|
||||
*/
|
||||
export function plainText(line: string): string {
|
||||
let value = line.trim();
|
||||
if (/^\|.*\|$/.test(value)) {
|
||||
// A table row, including the `|---|---|` rule, which says nothing at all.
|
||||
if (/^\|[\s:|-]*\|$/.test(value)) return '';
|
||||
value = value.slice(1, -1).split('|').map((cell) => cell.trim()).filter(Boolean).join(' · ');
|
||||
}
|
||||
// One leading marker, not all of them in turn: a heading reading
|
||||
// `## 1. Deutsch` keeps its lesson number, which the list rule would
|
||||
// otherwise take for a bullet and eat.
|
||||
for (const marker of [/^#{1,6}\s+/, /^>\s?/, /^[-*+]\s+(\[[ xX]\]\s+)?/, /^\d{1,9}[.)]\s+/]) {
|
||||
if (marker.test(value)) {
|
||||
value = value.replace(marker, '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
value
|
||||
.replace(/(\*\*|__|~~)/g, '')
|
||||
// Single markers only where they are emphasis, so snake_case survives.
|
||||
.replace(/(?<![\w*])\*([^*]+)\*(?![\w*])/g, '$1')
|
||||
.replace(/(?<![\w_])_([^_]+)_(?![\w_])/g, '$1')
|
||||
.replace(/`+/g, '')
|
||||
// A link reads as its label; the target is not for a preview.
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\\([\\`*_[\]#>~|+.()-])/g, '$1')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
function isNoteFile(name: string): boolean {
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
NoteNotFound,
|
||||
filterNotes,
|
||||
readNoteAt,
|
||||
plainText,
|
||||
readNotes,
|
||||
noteSubjects,
|
||||
noteSections,
|
||||
searchNotes,
|
||||
replaceNote,
|
||||
writeNote,
|
||||
} from '../core/notes.ts';
|
||||
@@ -40,6 +44,24 @@ import type { Services } from '../services.ts';
|
||||
* index and mirror, `/token` only to the server's own token, and every upstream
|
||||
* call either triggers is a GET.
|
||||
*/
|
||||
/**
|
||||
* The first words of a note, for a list row.
|
||||
*
|
||||
* Headings and list markers are dropped: a row that reads "## 1. Deutsch —
|
||||
* 08:00" repeats what the row already says, and the point of the line is the
|
||||
* first thing that was actually written down.
|
||||
*/
|
||||
function previewOf(text: string): string {
|
||||
for (const line of text.split('\n')) {
|
||||
// Headings are skipped rather than stripped: the row above already says
|
||||
// which lessons the day holds, and repeating one is not a preview.
|
||||
if (/^#{1,6}\s/.test(line.trim())) continue;
|
||||
const plain = plainText(line);
|
||||
if (plain) return plain.length > 120 ? `${plain.slice(0, 117)}…` : plain;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const NO_NOTES_DIR =
|
||||
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
|
||||
|
||||
@@ -279,8 +301,20 @@ export function createApiRouter(services: Services): Router {
|
||||
writable: services.config.notesWritable,
|
||||
count: notes.length,
|
||||
// The body is dropped from a listing: a term of notes is megabytes,
|
||||
// and the CLI asks for the ones it wants by path.
|
||||
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
|
||||
// and the CLI asks for the ones it wants by path. What replaces it
|
||||
// is what a list *shows* — the lessons a day covers and a line of
|
||||
// its text — so the app's note list needs one request, not one per
|
||||
// note.
|
||||
notes: notes.slice(0, limit).map(({ text, ...rest }) => {
|
||||
const note = { ...rest, text };
|
||||
const sections = noteSections(note);
|
||||
return {
|
||||
...rest,
|
||||
subjects: noteSubjects(note),
|
||||
lessons: sections.length,
|
||||
preview: previewOf(note.text),
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
|
||||
@@ -288,6 +322,36 @@ export function createApiRouter(services: Services): Router {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Full text over the note files, for the app's search box.
|
||||
*
|
||||
* Reads disk rather than the index on purpose: notes reach the index only on
|
||||
* a full crawl, so a lesson written this morning would not be findable, and
|
||||
* "what did I write this week" is most of what anyone searches their own
|
||||
* notes for. `search` in MCP is the other one — it spans Schulcloud and the
|
||||
* class register too, at the cost of being as fresh as the last crawl.
|
||||
*/
|
||||
router.get('/notes/search', async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
const query = stringParam(req.query.q) ?? '';
|
||||
if (query.trim().length < 2) {
|
||||
return res.status(400).json({ error: 'invalid', message: 'Mindestens zwei Zeichen suchen.' });
|
||||
}
|
||||
try {
|
||||
const notes = filterNotes(await readNotes(root), {
|
||||
...pickParam('subject', req.query.subject),
|
||||
...pickParam('since', req.query.since),
|
||||
...pickParam('until', req.query.until),
|
||||
});
|
||||
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 50, 200);
|
||||
const hits = searchNotes(notes, query, limit);
|
||||
return res.json({ query, count: hits.length, hits });
|
||||
} catch (error) {
|
||||
return fail(res, error, 'search notes');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
|
||||
@@ -21,6 +21,10 @@ import { createWebAuth, isSecureRequest, sessionAuth, type WebAuth } from './web
|
||||
* security policy forbids inline script anyway — so the only thing gained by
|
||||
* embedding them would be a build step that no longer copies them, and the
|
||||
* only thing lost would be every tool that reads them.
|
||||
*
|
||||
* `app.js` is an ES module and imports the other two, which is also what lets
|
||||
* `markdown.js` — the Markdown the editor reads and writes — be tested under
|
||||
* `node --test` rather than only in a browser.
|
||||
*/
|
||||
|
||||
/** No outside resources at all, and no inline script. Nothing here needs either. */
|
||||
@@ -42,6 +46,8 @@ const ASSETS: Record<string, { file: string; type: string }> = {
|
||||
'/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
|
||||
'/app.css': { file: 'app.css', type: 'text/css; charset=utf-8' },
|
||||
'/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
|
||||
'/editor.js': { file: 'editor.js', type: 'text/javascript; charset=utf-8' },
|
||||
'/markdown.js': { file: 'markdown.js', type: 'text/javascript; charset=utf-8' },
|
||||
'/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' },
|
||||
'/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' },
|
||||
};
|
||||
@@ -85,10 +91,13 @@ export function createAppRouter(config: Config): AppSurface | undefined {
|
||||
|
||||
// The shell is public: it is the login screen, and it holds nothing. Every
|
||||
// byte of data it goes on to show comes from /api, behind the session.
|
||||
router.get(/^\/(index\.html|app\.css|app\.js|icon\.svg|manifest\.webmanifest)?$/, (req: Request, res: Response) => {
|
||||
const entry = ASSETS[req.path] ?? ASSETS['/']!;
|
||||
res.type(entry.type).send(asset(entry.file));
|
||||
});
|
||||
router.get(
|
||||
/^\/(index\.html|app\.css|app\.js|editor\.js|markdown\.js|icon\.svg|manifest\.webmanifest)?$/,
|
||||
(req: Request, res: Response) => {
|
||||
const entry = ASSETS[req.path] ?? ASSETS['/']!;
|
||||
res.type(entry.type).send(asset(entry.file));
|
||||
},
|
||||
);
|
||||
|
||||
router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => {
|
||||
const password = (req.body as { password?: unknown } | undefined)?.password;
|
||||
|
||||
@@ -36,18 +36,34 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/*
|
||||
* A *definite* height, not a minimum.
|
||||
*
|
||||
* `min-height` leaves the page free to grow with its content, and a column of
|
||||
* flex boxes with no definite height at the top cannot cap anything below it:
|
||||
* with a hundred notes in the list, the list grew to its full height, the page
|
||||
* scrolled instead of the list, and the editor — a flex sibling stretching to
|
||||
* the row — became as tall as every note put together. Scrolling belongs to the
|
||||
* two panes, so the page itself must not have any.
|
||||
*/
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Fills the viewport on a phone, where 100vh lies about the toolbar. */
|
||||
min-height: 100dvh;
|
||||
height: 100vh;
|
||||
/* dvh where it exists: 100vh lies about the height while a phone's toolbar
|
||||
is on screen. */
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
|
||||
|
||||
/* The page does not scroll, so anything that is not itself a pane must. */
|
||||
#login, #view-settings { overflow-y: auto; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* --- chrome ------------------------------------------------------------ */
|
||||
@@ -72,6 +88,109 @@ header { border-bottom: 1px solid var(--line); }
|
||||
|
||||
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; }
|
||||
|
||||
/* --- the notes screen: a list, and the note that is open ---------------- */
|
||||
|
||||
/*
|
||||
* Two panes where there is room, one at a time where there is not. The
|
||||
* breakpoint is about where a phone in landscape stops being a phone: below it
|
||||
* `data-pane` on the container decides which of the two is on screen, and the
|
||||
* back button is the way out of the note.
|
||||
*/
|
||||
.notes { flex-direction: row; gap: 0; padding: 0; }
|
||||
|
||||
.rail {
|
||||
flex: 0 0 18rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.rail-head { display: flex; gap: 0.4rem; padding: 0.6rem 0.6rem 0.4rem; }
|
||||
|
||||
.rail-head form { flex: 1; min-width: 0; }
|
||||
|
||||
.rail-head input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#today { flex: 0 0 auto; width: 2.5rem; padding: 0; font-size: 1.1rem; line-height: 1; }
|
||||
|
||||
#rail-status { padding: 0 0.7rem 0.3rem; }
|
||||
|
||||
.rail-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 0.4rem 0.6rem; }
|
||||
|
||||
/* A whole row is the target: on a phone the thing being tapped is the note,
|
||||
not a link inside it. */
|
||||
.row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover { background: var(--bg); }
|
||||
|
||||
.row[aria-current="true"] {
|
||||
background: var(--bg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.row-title { font-weight: 600; font-size: 0.95rem; }
|
||||
.row-line { margin: 0.1rem 0 0; color: var(--muted); font-size: 0.85rem; line-height: 1.35; }
|
||||
/* Two lines of preview and no more: a row is a glance, not a read. */
|
||||
.row-line.clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row mark { background: color-mix(in srgb, var(--accent) 28%, transparent); color: inherit; border-radius: 0.15rem; }
|
||||
|
||||
.detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
/* The editor inside it scrolls; the pane itself never does, so the toolbar
|
||||
and the save button cannot be pushed off a short screen. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Only a phone needs a way back to the list; on a wide screen it is never gone. */
|
||||
.back { display: none; align-self: flex-start; padding: 0.4rem 0.7rem; font-size: 0.9rem; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.rail { flex: 1; border-right: 0; }
|
||||
.notes[data-pane="list"] .detail { display: none; }
|
||||
.notes[data-pane="note"] .rail { display: none; }
|
||||
.notes[data-pane="note"] .back { display: block; }
|
||||
}
|
||||
|
||||
.note-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.note-preview h2 { margin: 0; font-size: 1.05rem; }
|
||||
.note-preview p { margin: 0; }
|
||||
|
||||
/* --- the day bar ------------------------------------------------------- */
|
||||
|
||||
.daybar { display: flex; align-items: center; gap: 0.5rem; }
|
||||
@@ -93,19 +212,142 @@ header { border-bottom: 1px solid var(--line); }
|
||||
.daybar-centre strong { font-size: 1.05rem; }
|
||||
.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; }
|
||||
|
||||
/* --- the toolbar ------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* One row that scrolls sideways rather than wrapping into two: a second row
|
||||
* would take a line of editor away from every note to hold buttons that are
|
||||
* used once a lesson, and a thumb swipes a row far more easily than it hunts
|
||||
* through a grid.
|
||||
*/
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.toolbar button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--card));
|
||||
}
|
||||
|
||||
.toolbar button code { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||||
.sep { flex: 0 0 auto; width: 1px; height: 1.5rem; background: var(--line); margin: 0 0.15rem; }
|
||||
|
||||
/* --- the editor -------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* The formatted document. It is the note as it will read, not as it is stored
|
||||
* — the file underneath is still Markdown, and `MD` in the toolbar shows it.
|
||||
*/
|
||||
.editor {
|
||||
flex: 1;
|
||||
/* Small enough that a landscape phone with its keyboard up still shows the
|
||||
toolbar and the actions rather than overflowing the pane. */
|
||||
min-height: 6rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
/* A long URL or a wide table must not push the page sideways. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.editor:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.editor.empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor > :first-child { margin-top: 0; }
|
||||
.editor > :last-child { margin-bottom: 0; }
|
||||
.editor p { margin: 0 0 0.75rem; }
|
||||
|
||||
/* A lesson heading is the note's structure — each one is indexed as its own
|
||||
lesson — so it is given a rule to sit on rather than just a larger size. */
|
||||
.editor h2 {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.editor h1 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; }
|
||||
.editor h3, .editor h4, .editor h5, .editor h6 { margin: 1rem 0 0.35rem; font-size: 1rem; }
|
||||
|
||||
.editor ul, .editor ol { margin: 0 0 0.75rem; padding-left: 1.4rem; }
|
||||
.editor li { margin: 0.15rem 0; }
|
||||
.editor li.task { list-style: none; margin-left: -1.2rem; }
|
||||
.editor li.task input { margin-right: 0.4rem; }
|
||||
|
||||
.editor blockquote {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor code {
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--card);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.editor pre {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--card);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.editor pre code { padding: 0; background: none; }
|
||||
.editor hr { border: 0; border-top: 1px solid var(--line); margin: 1rem 0; }
|
||||
.editor a { color: var(--accent); }
|
||||
|
||||
/* A table wider than the phone scrolls inside the note rather than stretching
|
||||
it: the caret has to stay reachable. */
|
||||
.editor table { display: block; overflow-x: auto; border-collapse: collapse; margin: 0 0 0.75rem; font-size: 0.9rem; }
|
||||
.editor th, .editor td { border: 1px solid var(--line); padding: 0.3rem 0.5rem; text-align: left; min-width: 3rem; }
|
||||
.editor th { background: var(--card); }
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
min-height: 6rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Monospace: the notes are Markdown, and headings and list markers have to
|
||||
line up to be read back as structure. */
|
||||
/* Monospace: this is the Markdown view, and headings and list markers have
|
||||
to line up to be read back as structure. */
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
@@ -174,8 +416,11 @@ input[type="password"], input[type="text"] {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#login { justify-content: center; }
|
||||
#login .card { width: min(24rem, 100%); align-self: center; }
|
||||
/* `margin: auto` rather than `justify-content: center`: a centred flex item in
|
||||
a scrolling container cannot be scrolled back to once it overflows the top,
|
||||
which on a short screen with the keyboard up would put the password field out
|
||||
of reach. */
|
||||
#login .card { width: min(24rem, calc(100% - 1.5rem)); margin: auto; }
|
||||
#login button { width: 100%; margin-top: 1rem; }
|
||||
|
||||
.steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use strict';
|
||||
import { createEditor } from './editor.js';
|
||||
import { markdownToHtml } from './markdown.js';
|
||||
|
||||
/*
|
||||
* The notes app.
|
||||
@@ -20,6 +21,10 @@
|
||||
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
|
||||
* "Offline — lokal gesichert". A silent editor over a flaky connection is
|
||||
* indistinguishable from one that is losing your work.
|
||||
*
|
||||
* What the person sees is formatted text with a toolbar; what is written to
|
||||
* disk is Markdown. `editor.js` is the whole of that translation — everything
|
||||
* here deals in Markdown strings and never touches the document.
|
||||
*/
|
||||
|
||||
const AUTOSAVE_MS = 2500;
|
||||
@@ -35,13 +40,28 @@ const ui = {
|
||||
tabSettings: document.getElementById('tab-settings'),
|
||||
viewNotes: document.getElementById('view-notes'),
|
||||
viewSettings: document.getElementById('view-settings'),
|
||||
searchForm: document.getElementById('search-form'),
|
||||
searchInput: document.getElementById('search-input'),
|
||||
railStatus: document.getElementById('rail-status'),
|
||||
railList: document.getElementById('rail-list'),
|
||||
today: document.getElementById('today'),
|
||||
back: document.getElementById('back'),
|
||||
daybar: document.querySelector('.daybar'),
|
||||
actions: document.querySelector('.actions'),
|
||||
notePreview: document.getElementById('note-preview'),
|
||||
notePreviewTitle: document.getElementById('note-preview-title'),
|
||||
notePreviewPath: document.getElementById('note-preview-path'),
|
||||
notePreviewBody: document.getElementById('note-preview-body'),
|
||||
prev: document.getElementById('prev'),
|
||||
next: document.getElementById('next'),
|
||||
dayTitle: document.getElementById('day-title'),
|
||||
dayDate: document.getElementById('day-date'),
|
||||
dayStatus: document.getElementById('day-status'),
|
||||
conflict: document.getElementById('day-conflict'),
|
||||
toolbar: document.getElementById('toolbar'),
|
||||
editor: document.getElementById('editor'),
|
||||
source: document.getElementById('source'),
|
||||
editorHint: document.getElementById('editor-hint'),
|
||||
save: document.getElementById('save'),
|
||||
fill: document.getElementById('fill'),
|
||||
lessonsHint: document.getElementById('lessons-hint'),
|
||||
@@ -68,6 +88,28 @@ const day = {
|
||||
timer: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* The formatted editor over the two elements that hold a note.
|
||||
*
|
||||
* It owns the document and the toolbar; this file only ever asks it for
|
||||
* Markdown and hands it Markdown back.
|
||||
*/
|
||||
const editor = createEditor({
|
||||
rich: ui.editor,
|
||||
source: ui.source,
|
||||
toolbar: ui.toolbar,
|
||||
onInput: markDirty,
|
||||
onModeChange: (mode) => {
|
||||
// Switching by hand is not a warning, so the automatic one goes away.
|
||||
hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '');
|
||||
},
|
||||
});
|
||||
|
||||
function hint(message) {
|
||||
ui.editorHint.textContent = message;
|
||||
ui.editorHint.hidden = !message;
|
||||
}
|
||||
|
||||
// --- plumbing ------------------------------------------------------------
|
||||
|
||||
async function api(path, options) {
|
||||
@@ -129,9 +171,10 @@ function draftKey(date) {
|
||||
return DRAFT_PREFIX + date;
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
function saveDraft(text) {
|
||||
try {
|
||||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.value, at: Date.now() }));
|
||||
const value = text === undefined ? editor.getMarkdown() : text;
|
||||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: value, at: Date.now() }));
|
||||
} catch (error) {
|
||||
// A full or disabled localStorage must not break typing; the server copy
|
||||
// is still the real one.
|
||||
@@ -162,6 +205,18 @@ function setStatus(message, kind) {
|
||||
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
/** A school day, in the editor, with the list showing which one. */
|
||||
async function openDay(date) {
|
||||
ui.notePreview.hidden = true;
|
||||
showEditor(true);
|
||||
await loadDay(date);
|
||||
}
|
||||
|
||||
/** Where a day's note lives, which is also its id in the list. */
|
||||
function dayPathFor(date) {
|
||||
return date.slice(0, 4) + '/' + date + '.md';
|
||||
}
|
||||
|
||||
async function loadDay(date) {
|
||||
// Anything unsaved goes to the draft before the view moves, or switching
|
||||
// days would be a way to lose a lesson.
|
||||
@@ -172,7 +227,8 @@ async function loadDay(date) {
|
||||
day.conflicted = false;
|
||||
ui.conflict.hidden = true;
|
||||
ui.dayDate.value = date;
|
||||
ui.editor.disabled = true;
|
||||
editor.setEnabled(false);
|
||||
hint('');
|
||||
setStatus('Wird geladen …');
|
||||
|
||||
let info;
|
||||
@@ -185,8 +241,8 @@ async function loadDay(date) {
|
||||
// no notes at all — and reporting that as "offline" would send someone
|
||||
// looking at their signal instead of at NOTES_DIR.
|
||||
if (error.status) {
|
||||
ui.editor.value = '';
|
||||
ui.editor.disabled = true;
|
||||
editor.setMarkdown('');
|
||||
editor.setEnabled(false);
|
||||
setStatus(error.message, 'error');
|
||||
ui.lessonsHint.textContent = '';
|
||||
return;
|
||||
@@ -194,8 +250,8 @@ async function loadDay(date) {
|
||||
// No status: the request never arrived. Fall back to whatever this device
|
||||
// has, rather than an empty editor that looks like a day with no notes.
|
||||
const draft = readDraft(date);
|
||||
ui.editor.disabled = false;
|
||||
ui.editor.value = draft ? draft.text : '';
|
||||
editor.setEnabled(true);
|
||||
editor.setMarkdown(draft ? draft.text : '');
|
||||
day.saved = '';
|
||||
day.modifiedAt = null;
|
||||
day.dirty = Boolean(draft);
|
||||
@@ -217,10 +273,25 @@ async function loadDay(date) {
|
||||
// is just the last save echoed back and offering it would be noise.
|
||||
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
|
||||
|
||||
ui.editor.value = useDraft ? draft.text : server;
|
||||
ui.editor.disabled = false;
|
||||
day.saved = info.exists ? info.text : '';
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
editor.setEnabled(true);
|
||||
// The server's text first, and what the editor makes of it is the baseline.
|
||||
// Opening a note the editor would tidy — a table typed unevenly, `*` for
|
||||
// italics — must not count as an edit, or simply looking at a day would
|
||||
// rewrite the file.
|
||||
const loaded = editor.setMarkdown(server);
|
||||
day.saved = info.exists ? editor.getMarkdown() : '';
|
||||
if (useDraft) editor.setMarkdown(draft.text);
|
||||
day.dirty = editor.getMarkdown() !== day.saved;
|
||||
|
||||
// One note in a hundred: something the formatted view cannot hold without
|
||||
// changing it. It opens as Markdown rather than being quietly reduced.
|
||||
hint(
|
||||
!loaded.faithful
|
||||
? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.'
|
||||
: editor.mode === 'source'
|
||||
? 'Markdown-Ansicht. „MD" führt zurück.'
|
||||
: '',
|
||||
);
|
||||
|
||||
if (useDraft) {
|
||||
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
|
||||
@@ -234,6 +305,7 @@ async function loadDay(date) {
|
||||
|
||||
describeLessons(info);
|
||||
ui.fill.hidden = !day.missing;
|
||||
markOpenRow();
|
||||
}
|
||||
|
||||
function describeLessons(info) {
|
||||
@@ -250,8 +322,9 @@ function describeLessons(info) {
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
saveDraft();
|
||||
const text = editor.getMarkdown();
|
||||
day.dirty = text !== day.saved;
|
||||
saveDraft(text);
|
||||
if (day.conflicted) return;
|
||||
if (day.dirty) setStatus('Nicht gespeichert …');
|
||||
window.clearTimeout(day.timer);
|
||||
@@ -261,7 +334,7 @@ function markDirty() {
|
||||
async function saveDay(automatic) {
|
||||
window.clearTimeout(day.timer);
|
||||
if (!day.dirty && automatic) return;
|
||||
const text = ui.editor.value;
|
||||
const text = editor.getMarkdown();
|
||||
setStatus('Wird gespeichert …');
|
||||
|
||||
try {
|
||||
@@ -272,9 +345,12 @@ async function saveDay(automatic) {
|
||||
// with, and sending null would look like "I saw no version".
|
||||
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
|
||||
});
|
||||
const isNew = !day.modifiedAt;
|
||||
day.saved = text;
|
||||
day.modifiedAt = result.modifiedAt;
|
||||
day.dirty = false;
|
||||
// A day that had no note until now is not in the list yet.
|
||||
if (isNew && !ui.searchInput.value.trim()) void loadRail();
|
||||
day.conflicted = false;
|
||||
ui.conflict.hidden = true;
|
||||
clearDraft(day.date);
|
||||
@@ -371,6 +447,250 @@ function addFact(term, value) {
|
||||
ui.serverState.append(dt, dd);
|
||||
}
|
||||
|
||||
// --- the note list -------------------------------------------------------
|
||||
|
||||
/*
|
||||
* The rail: every note, newest first, with the open one marked — and the
|
||||
* search box at the top of it, because searching your notes is a way of
|
||||
* finding one, not a separate place to be.
|
||||
*
|
||||
* Searching reads the **files** on the server rather than the Postgres index.
|
||||
* Notes reach that index only on a full crawl, so a lesson written this
|
||||
* morning would not be findable this morning, which is most of what anyone
|
||||
* searches their own notes for. The `search` tool in Claude is the other half:
|
||||
* it spans Schulcloud and the class register too, at the cost of being only as
|
||||
* fresh as the last crawl.
|
||||
*/
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 350;
|
||||
/** Day notes live at `2026/2026-09-04.md`; anything else opens read-only. */
|
||||
const DAY_NOTE = /^\d{4}\/(\d{4}-\d{2}-\d{2})\.md$/;
|
||||
|
||||
let searchTimer = 0;
|
||||
let searchTerms = [];
|
||||
|
||||
async function loadRail() {
|
||||
ui.railStatus.textContent = 'Wird geladen …';
|
||||
try {
|
||||
const listing = await api('/api/notes?limit=400');
|
||||
searchTerms = [];
|
||||
showRows(listing.notes.map(noteRow));
|
||||
ui.railStatus.textContent =
|
||||
listing.count === 0
|
||||
? 'Noch keine Notizen. + öffnet den heutigen Tag.'
|
||||
: listing.count + ' Notiz(en)' + (listing.notes.length < listing.count ? ', neueste 400' : '');
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.railList.replaceChildren();
|
||||
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.';
|
||||
}
|
||||
}
|
||||
|
||||
/** One note as a row: what it is, and the first thing written in it. */
|
||||
function noteRow(note) {
|
||||
const lessons = note.lessons > 0 ? note.lessons + ' Stunde' + (note.lessons === 1 ? '' : 'n') : '';
|
||||
const subjects = (note.subjects || []).join(' · ');
|
||||
return {
|
||||
path: note.path,
|
||||
date: note.date,
|
||||
title: note.title,
|
||||
// Subjects say more than the date repeated, and the preview says more
|
||||
// than either when a note is a single page of prose.
|
||||
line: subjects || lessons || note.preview || '',
|
||||
second: subjects && note.preview ? note.preview : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** One search hit as a row: the lesson it matched in, and why. */
|
||||
function hitRow(hit) {
|
||||
return {
|
||||
path: hit.path,
|
||||
date: hit.date,
|
||||
heading: hit.heading,
|
||||
title: hit.heading || hit.subject || hit.title,
|
||||
line: hit.date ? germanDate(hit.date) : hit.path,
|
||||
second: hit.snippet,
|
||||
mark: true,
|
||||
};
|
||||
}
|
||||
|
||||
function showRows(rows) {
|
||||
const list = document.createDocumentFragment();
|
||||
for (const row of rows) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'row';
|
||||
item.dataset.path = row.path;
|
||||
if (row.heading) item.dataset.heading = row.heading;
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'row-title';
|
||||
if (row.mark) highlight(title, row.title);
|
||||
else title.textContent = row.title;
|
||||
item.append(title);
|
||||
|
||||
for (const [text, clamp] of [
|
||||
[row.line, false],
|
||||
[row.second, true],
|
||||
]) {
|
||||
if (!text) continue;
|
||||
const line = document.createElement('p');
|
||||
line.className = 'row-line' + (clamp ? ' clamp' : '');
|
||||
if (row.mark) highlight(line, text);
|
||||
else line.textContent = text;
|
||||
item.append(line);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => void openRow(row));
|
||||
list.append(item);
|
||||
}
|
||||
ui.railList.replaceChildren(list);
|
||||
markOpenRow();
|
||||
}
|
||||
|
||||
/**
|
||||
* The matched words marked, without building HTML from them.
|
||||
*
|
||||
* A snippet is the user's own text, and text that has been through a URL and a
|
||||
* JSON response is exactly what should not be handed to `innerHTML`.
|
||||
*/
|
||||
function highlight(target, text) {
|
||||
const terms = searchTerms.map(fold).filter((term) => term.length > 1);
|
||||
if (terms.length === 0) {
|
||||
target.textContent = text;
|
||||
return;
|
||||
}
|
||||
const folded = fold(text);
|
||||
const marks = [];
|
||||
for (const term of terms) {
|
||||
for (let at = folded.indexOf(term); at !== -1; at = folded.indexOf(term, at + term.length)) {
|
||||
marks.push([at, at + term.length]);
|
||||
}
|
||||
}
|
||||
marks.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
let cursor = 0;
|
||||
for (const [from, to] of marks) {
|
||||
if (from < cursor) continue;
|
||||
target.append(text.slice(cursor, from));
|
||||
const mark = document.createElement('mark');
|
||||
mark.textContent = text.slice(from, to);
|
||||
target.append(mark);
|
||||
cursor = to;
|
||||
}
|
||||
target.append(text.slice(cursor));
|
||||
}
|
||||
|
||||
/** Lowercase without accents — the same folding the server searches with. */
|
||||
function fold(value) {
|
||||
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
function germanDate(date) {
|
||||
const parts = date.split('-');
|
||||
return parts[2] + '.' + parts[1] + '.' + parts[0];
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
window.clearTimeout(searchTimer);
|
||||
const value = query.trim();
|
||||
if (value.length === 0) return loadRail();
|
||||
if (value.length < 2) {
|
||||
ui.railStatus.textContent = 'Mindestens zwei Zeichen.';
|
||||
return;
|
||||
}
|
||||
ui.railStatus.textContent = 'Wird gesucht …';
|
||||
try {
|
||||
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=100');
|
||||
searchTerms = value.split(/\s+/).filter(Boolean);
|
||||
showRows(result.hits.map(hitRow));
|
||||
ui.railStatus.textContent =
|
||||
result.count === 0
|
||||
? 'Nichts gefunden — jedes Wort muss vorkommen.'
|
||||
: result.count + ' Treffer' + (result.count >= 100 ? ' (mehr vorhanden)' : '');
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.railList.replaceChildren();
|
||||
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A row, opened.
|
||||
*
|
||||
* A school day opens in the editor, because that is where it is written.
|
||||
* Anything else — an imported note, a page of revision — has no day to open, so
|
||||
* it is shown read-only rather than forced into a day-shaped screen.
|
||||
*/
|
||||
async function openRow(row) {
|
||||
const day = DAY_NOTE.exec(row.path);
|
||||
showPane('note');
|
||||
if (day) {
|
||||
ui.notePreview.hidden = true;
|
||||
showEditor(true);
|
||||
await loadDay(day[1]);
|
||||
// A search hit names a lesson, so put that lesson on screen rather than
|
||||
// the top of a day with six of them.
|
||||
if (row.heading) scrollToHeading(row.heading);
|
||||
return;
|
||||
}
|
||||
await showNoteReadOnly(row.path);
|
||||
}
|
||||
|
||||
function scrollToHeading(heading) {
|
||||
const wanted = fold(heading).trim();
|
||||
for (const element of ui.editor.querySelectorAll('h2')) {
|
||||
if (fold(element.textContent).trim() === wanted) {
|
||||
element.scrollIntoView({ block: 'start' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showNoteReadOnly(path) {
|
||||
showEditor(false);
|
||||
ui.notePreview.hidden = false;
|
||||
ui.notePreviewTitle.textContent = '…';
|
||||
try {
|
||||
const note = await api('/api/notes?path=' + encodeURIComponent(path));
|
||||
ui.notePreviewTitle.textContent = note.title;
|
||||
ui.notePreviewPath.textContent = note.path + ' — schreibgeschützt, weil diese Notiz kein Schultag ist.';
|
||||
// Markdown from our own store, through the parser the editor trusts with
|
||||
// the same input: it escapes everything it did not produce itself.
|
||||
ui.notePreviewBody.innerHTML = markdownToHtml(note.text ?? '');
|
||||
day.path = note.path;
|
||||
markOpenRow();
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.notePreviewTitle.textContent = 'Konnte die Notiz nicht öffnen';
|
||||
ui.notePreviewPath.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
/** The day editor and everything that belongs to it, on or off. */
|
||||
function showEditor(on) {
|
||||
for (const element of [ui.daybar, ui.dayStatus, ui.toolbar, ui.actions]) element.hidden = !on;
|
||||
ui.editor.hidden = !on || editor.mode !== 'rich';
|
||||
ui.source.hidden = !on || editor.mode !== 'source';
|
||||
// The hint belongs to whatever is loaded next; leaving it visible and empty
|
||||
// would cost a line of editor for nothing.
|
||||
if (!on) {
|
||||
ui.conflict.hidden = true;
|
||||
ui.editorHint.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function showPane(pane) {
|
||||
ui.viewNotes.dataset.pane = pane;
|
||||
}
|
||||
|
||||
/** Marks the row whose note is open, whichever list is showing. */
|
||||
function markOpenRow() {
|
||||
for (const row of ui.railList.querySelectorAll('.row')) {
|
||||
row.setAttribute('aria-current', String(row.dataset.path === day.path));
|
||||
}
|
||||
}
|
||||
|
||||
// --- views ---------------------------------------------------------------
|
||||
|
||||
function showLogin() {
|
||||
@@ -403,6 +723,8 @@ ui.loginForm.addEventListener('submit', async (event) => {
|
||||
ui.password.value = '';
|
||||
showApp();
|
||||
await loadDay(day.date);
|
||||
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
|
||||
await loadRail();
|
||||
} catch (error) {
|
||||
ui.loginError.textContent =
|
||||
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
|
||||
@@ -418,23 +740,41 @@ ui.logout.addEventListener('click', async () => {
|
||||
ui.tabNotes.addEventListener('click', () => showTab('notes'));
|
||||
ui.tabSettings.addEventListener('click', () => showTab('settings'));
|
||||
|
||||
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
|
||||
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
|
||||
ui.dayDate.addEventListener('change', () => {
|
||||
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
|
||||
ui.searchForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
void runSearch(ui.searchInput.value);
|
||||
});
|
||||
|
||||
ui.searchInput.addEventListener('input', () => {
|
||||
// As you type, but not on every keystroke: each search re-reads the notes
|
||||
// directory on the server.
|
||||
window.clearTimeout(searchTimer);
|
||||
const value = ui.searchInput.value;
|
||||
searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
ui.today.addEventListener('click', () => {
|
||||
// The day you are in, whether or not it has a note yet — the one thing the
|
||||
// list cannot show, because an empty day is not a note.
|
||||
void openRow({ path: dayPathFor(today()) });
|
||||
});
|
||||
|
||||
ui.back.addEventListener('click', () => showPane('list'));
|
||||
|
||||
ui.prev.addEventListener('click', () => void openDay(shiftDate(day.date, -1)));
|
||||
ui.next.addEventListener('click', () => void openDay(shiftDate(day.date, 1)));
|
||||
ui.dayDate.addEventListener('change', () => {
|
||||
if (ui.dayDate.value) void openDay(ui.dayDate.value);
|
||||
});
|
||||
|
||||
ui.editor.addEventListener('input', markDirty);
|
||||
ui.save.addEventListener('click', () => void saveDay(false));
|
||||
|
||||
ui.fill.addEventListener('click', () => {
|
||||
// Appended, never merged into place: the person's own text is not something
|
||||
// to reorder, and a heading in the wrong order is trivial to move.
|
||||
const separator = ui.editor.value.trim() ? '\n\n' : '';
|
||||
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
|
||||
editor.append(day.missing);
|
||||
day.missing = '';
|
||||
ui.fill.hidden = true;
|
||||
markDirty();
|
||||
});
|
||||
|
||||
// A phone locking, the app going to the background, or the tab closing: all of
|
||||
@@ -487,4 +827,8 @@ void (async () => {
|
||||
}
|
||||
showApp();
|
||||
await loadDay(day.date);
|
||||
// Wide enough for both panes: the day is already open beside the list.
|
||||
// Narrow: the list comes first, the way a notes app opens.
|
||||
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
|
||||
await loadRail();
|
||||
})();
|
||||
|
||||
549
src/http/app/editor.js
Normal file
549
src/http/app/editor.js
Normal file
@@ -0,0 +1,549 @@
|
||||
import { markdownFromDom, markdownToHtml } from './markdown.js';
|
||||
|
||||
/*
|
||||
* The formatted editor.
|
||||
*
|
||||
* Notes are written in a lesson, with a thumb, on a phone. Typing `##` and
|
||||
* `**` while a teacher talks is not note-taking, so what this shows is the
|
||||
* formatted text and a toolbar — and what it writes to disk is still Markdown,
|
||||
* because that is what the indexer reads and what outlives this app.
|
||||
*
|
||||
* `contenteditable` plus `document.execCommand` rather than a framework or an
|
||||
* editor library: the content security policy allows no outside script, and
|
||||
* this app has no build step to bundle one in. execCommand is deprecated on
|
||||
* paper and universally implemented in practice — including on iOS Safari,
|
||||
* which is the browser that actually matters here — and it brings selection
|
||||
* handling, native undo and the software keyboard's own behaviour with it.
|
||||
* A hand-written selection engine would be a much larger thing to get wrong.
|
||||
*
|
||||
* Whatever the browser leaves behind in the document is the serializer's
|
||||
* problem, not this file's: `markdownFromDom` is deliberately tolerant, and
|
||||
* everything typed, pasted or produced by a command is reduced to the
|
||||
* supported subset on the way to the file.
|
||||
*/
|
||||
|
||||
export function createEditor(options) {
|
||||
const rich = options.rich;
|
||||
const source = options.source;
|
||||
const toolbar = options.toolbar;
|
||||
const onInput = options.onInput ?? (() => {});
|
||||
const onModeChange = options.onModeChange ?? (() => {});
|
||||
|
||||
let mode = 'rich';
|
||||
// Which view the person last chose. Moving to another day should not undo
|
||||
// that choice, so only a note the formatted view cannot hold overrides it.
|
||||
let preferred = 'rich';
|
||||
let enabled = true;
|
||||
|
||||
// execCommand's default is to write inline styles (`<span style="font-weight:
|
||||
// bold">`). Tags survive the trip to Markdown far more reliably, and the
|
||||
// serializer would only have to undo the styles anyway.
|
||||
try {
|
||||
document.execCommand('styleWithCSS', false, false);
|
||||
} catch {
|
||||
// Not every engine has it, and none of them needs it to work.
|
||||
}
|
||||
|
||||
// --- reading and writing --------------------------------------------
|
||||
|
||||
function getMarkdown() {
|
||||
return mode === 'source' ? source.value.trim() : markdownFromDom(rich);
|
||||
}
|
||||
|
||||
function setMarkdown(text) {
|
||||
const value = String(text ?? '');
|
||||
// A note whose formatting this editor cannot hold opens as Markdown
|
||||
// rather than being quietly rewritten into something smaller.
|
||||
const faithful = isStable(value);
|
||||
setMode(faithful ? preferred : 'source', { silent: true });
|
||||
source.value = value;
|
||||
rich.innerHTML = markdownToHtml(value);
|
||||
ensureTrailingParagraph();
|
||||
updatePlaceholder();
|
||||
return { faithful };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the round trip settles.
|
||||
*
|
||||
* One pass may tidy the note — `*a*` becomes `_a_`, a ragged table lines up
|
||||
* — and that is fine, because a note is only ever rewritten once it has been
|
||||
* edited. A second pass that changes something again is not fine: it means
|
||||
* this editor does not understand the note, and every save would erode it a
|
||||
* little further. That is the case where the Markdown view is the honest
|
||||
* answer.
|
||||
*/
|
||||
function isStable(text) {
|
||||
const once = markdownFromDom(parse(text));
|
||||
return markdownFromDom(parse(once)) === once;
|
||||
}
|
||||
|
||||
function parse(text) {
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = markdownToHtml(text);
|
||||
return holder;
|
||||
}
|
||||
|
||||
// --- the two modes ---------------------------------------------------
|
||||
|
||||
function setMode(next, config) {
|
||||
if (next === mode) return;
|
||||
// Carry the text across, so a toggle never costs a word.
|
||||
if (next === 'source') source.value = markdownFromDom(rich);
|
||||
else {
|
||||
rich.innerHTML = markdownToHtml(source.value);
|
||||
ensureTrailingParagraph();
|
||||
}
|
||||
|
||||
mode = next;
|
||||
rich.hidden = mode !== 'rich';
|
||||
source.hidden = mode !== 'source';
|
||||
toolbar.querySelectorAll('[data-command]').forEach((button) => {
|
||||
if (button.dataset.command !== 'mode') button.disabled = mode === 'source';
|
||||
});
|
||||
const toggle = toolbar.querySelector('[data-command="mode"]');
|
||||
if (toggle) toggle.setAttribute('aria-pressed', String(mode === 'source'));
|
||||
updatePlaceholder();
|
||||
if (!config || !config.silent) onModeChange(mode);
|
||||
}
|
||||
|
||||
function setEnabled(value) {
|
||||
enabled = value;
|
||||
rich.contentEditable = value ? 'true' : 'false';
|
||||
source.disabled = !value;
|
||||
toolbar.querySelectorAll('button').forEach((button) => {
|
||||
button.disabled = !value || (mode === 'source' && button.dataset.command !== 'mode');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A line after the last block, so there is somewhere to go.
|
||||
*
|
||||
* A table, a code block or a rule at the very end of a `contenteditable`
|
||||
* element is a dead end: there is no node after it to put the caret in, and
|
||||
* no key that makes one — the note simply cannot be continued. Every engine
|
||||
* behaves this way, and every editor works around it the same way. The
|
||||
* paragraph is empty, so it serializes to nothing and never reaches the file.
|
||||
*/
|
||||
const TRAILING_TRAP = /^(TABLE|PRE|HR|BLOCKQUOTE|UL|OL)$/;
|
||||
|
||||
function ensureTrailingParagraph() {
|
||||
const last = rich.lastElementChild;
|
||||
if (!last || !TRAILING_TRAP.test(last.nodeName)) return;
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.appendChild(document.createElement('br'));
|
||||
rich.appendChild(paragraph);
|
||||
}
|
||||
|
||||
function updatePlaceholder() {
|
||||
rich.classList.toggle('empty', rich.textContent.trim() === '' && rich.children.length <= 1);
|
||||
}
|
||||
|
||||
// --- commands --------------------------------------------------------
|
||||
|
||||
const commands = {
|
||||
bold: () => document.execCommand('bold'),
|
||||
italic: () => document.execCommand('italic'),
|
||||
strike: () => document.execCommand('strikeThrough'),
|
||||
h2: () => toggleBlock('H2'),
|
||||
h3: () => toggleBlock('H3'),
|
||||
quote: () => toggleBlock('BLOCKQUOTE'),
|
||||
ul: () => document.execCommand('insertUnorderedList'),
|
||||
ol: () => document.execCommand('insertOrderedList'),
|
||||
task: insertTask,
|
||||
code: insertCode,
|
||||
link: insertLink,
|
||||
table: insertTable,
|
||||
mode: () => {
|
||||
preferred = mode === 'rich' ? 'source' : 'rich';
|
||||
setMode(preferred);
|
||||
},
|
||||
};
|
||||
|
||||
/** A second press on the same button goes back to ordinary text. */
|
||||
function toggleBlock(tag) {
|
||||
const current = blockAt();
|
||||
document.execCommand('formatBlock', false, current === tag ? 'P' : tag);
|
||||
}
|
||||
|
||||
function blockAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && /^(P|DIV|H[1-6]|BLOCKQUOTE|LI|PRE|TD|TH)$/.test(node.nodeName)) return node.nodeName;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function selectionNode() {
|
||||
const selection = document.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return undefined;
|
||||
const node = selection.getRangeAt(0).startContainer;
|
||||
return rich.contains(node) ? node : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A checkbox item.
|
||||
*
|
||||
* Built as a list first, so the browser handles the splitting and merging
|
||||
* of the item the cursor is in, and then given its box.
|
||||
*/
|
||||
function insertTask() {
|
||||
const item = itemAt();
|
||||
if (item && firstCheckbox(item)) {
|
||||
// Already a task: take the box away rather than adding a second.
|
||||
firstCheckbox(item).remove();
|
||||
return;
|
||||
}
|
||||
if (!item) document.execCommand('insertUnorderedList');
|
||||
const target = itemAt();
|
||||
if (!target || firstCheckbox(target)) return;
|
||||
target.classList.add('task');
|
||||
target.insertBefore(checkbox(false), target.firstChild);
|
||||
}
|
||||
|
||||
function itemAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && node.nodeName === 'LI') return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function checkbox(checked) {
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.contentEditable = 'false';
|
||||
// The attribute, not just the property: the serializer reads the
|
||||
// document, and a property set by a click leaves no trace in it.
|
||||
if (checked) box.setAttribute('checked', '');
|
||||
return box;
|
||||
}
|
||||
|
||||
function firstCheckbox(item) {
|
||||
const first = item.firstElementChild;
|
||||
return first && first.nodeName === 'INPUT' && first.type === 'checkbox' ? first : null;
|
||||
}
|
||||
|
||||
function insertCode() {
|
||||
const selection = document.getSelection();
|
||||
const text = selection ? selection.toString() : '';
|
||||
// `insertHTML` and not a wrapping node, so the caret lands inside the
|
||||
// new element and the browser records one undo step.
|
||||
document.execCommand('insertHTML', false, '<code>' + escapeHtml(text || 'Code') + '</code> ');
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
const selection = document.getSelection();
|
||||
const label = selection ? selection.toString() : '';
|
||||
const href = window.prompt('Adresse des Links', 'https://');
|
||||
if (!href || href === 'https://') return;
|
||||
if (!/^(https?:|mailto:|tel:)/i.test(href)) {
|
||||
window.alert('Nur http, https, mailto und tel.');
|
||||
return;
|
||||
}
|
||||
if (label) document.execCommand('createLink', false, href);
|
||||
else document.execCommand('insertHTML', false, '<a href="' + escapeHtml(href) + '">' + escapeHtml(href) + '</a> ');
|
||||
}
|
||||
|
||||
/**
|
||||
* A table, or one more row of the table already under the cursor.
|
||||
*
|
||||
* Two jobs on one button because a phone has no Tab key, and adding a row is
|
||||
* what anyone wants far more often than a second table inside the first.
|
||||
* `reflect` renames the button so it says which one it will do.
|
||||
*/
|
||||
function insertTable() {
|
||||
const table = tableAt();
|
||||
if (table) {
|
||||
addRow(table);
|
||||
return;
|
||||
}
|
||||
const head = '<tr><th><br></th><th><br></th></tr>';
|
||||
const row = '<tr><td><br></td><td><br></td></tr>';
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
'<table><thead>' + head + '</thead><tbody>' + row + row + '</tbody></table><p><br></p>',
|
||||
);
|
||||
}
|
||||
|
||||
function cellAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && (node.nodeName === 'TD' || node.nodeName === 'TH')) return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tableAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && node.nodeName === 'TABLE') return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One more row, as wide as the table, with the caret in its first cell. */
|
||||
function addRow(table) {
|
||||
const rows = table.querySelectorAll('tr');
|
||||
const width = Math.max(1, ...Array.from(rows, (row) => row.children.length));
|
||||
const body = table.querySelector('tbody') ?? table;
|
||||
const row = document.createElement('tr');
|
||||
for (let i = 0; i < width; i++) {
|
||||
const cell = document.createElement('td');
|
||||
// An empty cell with nothing in it cannot be clicked into in Gecko;
|
||||
// the break gives the caret somewhere to stand.
|
||||
cell.appendChild(document.createElement('br'));
|
||||
row.appendChild(cell);
|
||||
}
|
||||
body.appendChild(row);
|
||||
placeCaret(row.firstElementChild);
|
||||
}
|
||||
|
||||
function placeCaret(node) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
range.collapse(true);
|
||||
const selection = document.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
// --- input -----------------------------------------------------------
|
||||
|
||||
function notify() {
|
||||
ensureTrailingParagraph();
|
||||
updatePlaceholder();
|
||||
onInput();
|
||||
}
|
||||
|
||||
rich.addEventListener('input', notify);
|
||||
source.addEventListener('input', notify);
|
||||
|
||||
// A checkbox is the one control inside the document: its state has to reach
|
||||
// the markup, or the save would not see it.
|
||||
rich.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
if (!target || target.nodeName !== 'INPUT' || target.type !== 'checkbox') return;
|
||||
if (target.checked) target.setAttribute('checked', '');
|
||||
else target.removeAttribute('checked');
|
||||
notify();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pasted content goes through Markdown before it reaches the document.
|
||||
*
|
||||
* A paste from a web page or a Word document carries fonts, colours,
|
||||
* classes and occasionally script. Converting it to Markdown and parsing it
|
||||
* back reduces it to exactly what this editor supports — the same subset the
|
||||
* file will hold — and is the one place where sanitising and formatting are
|
||||
* the same operation.
|
||||
*/
|
||||
rich.addEventListener('paste', (event) => {
|
||||
if (!enabled || mode !== 'rich') return;
|
||||
const data = event.clipboardData;
|
||||
const html = data ? data.getData('text/html') : '';
|
||||
const text = data ? data.getData('text/plain') : '';
|
||||
if (!html && !text) {
|
||||
// Nothing readable in the event — either the paste really is empty, or
|
||||
// this engine withholds the clipboard. Let it happen and tidy after,
|
||||
// because raw pasted markup sitting in the document is what the
|
||||
// toolbar cannot format and the serializer should never have to meet.
|
||||
// Only if something actually arrived: an empty paste must not move the
|
||||
// caret or mark the note changed.
|
||||
tidyIfChanged();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
let markdown;
|
||||
if (html) {
|
||||
const holder = document.createElement('div');
|
||||
// Never assigned to a live document: this element is detached, and
|
||||
// what comes out of it is Markdown, not markup.
|
||||
holder.innerHTML = html;
|
||||
markdown = markdownFromDom(holder);
|
||||
} else {
|
||||
markdown = text;
|
||||
}
|
||||
document.execCommand('insertHTML', false, markdownToHtml(markdown));
|
||||
notify();
|
||||
});
|
||||
|
||||
/** Tidies the document after a paste this editor could not read. */
|
||||
function tidyIfChanged() {
|
||||
const before = rich.innerHTML;
|
||||
window.setTimeout(() => {
|
||||
if (rich.innerHTML !== before) normalise();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The document, reduced to what this editor models.
|
||||
*
|
||||
* Everything the round trip does not understand is dropped here rather than
|
||||
* being carried around until a save, and the caret is placed at the end
|
||||
* because there is no way to keep it across a rebuild.
|
||||
*/
|
||||
function normalise() {
|
||||
rich.innerHTML = markdownToHtml(markdownFromDom(rich));
|
||||
ensureTrailingParagraph();
|
||||
if (rich.lastElementChild) placeCaret(rich.lastElementChild);
|
||||
notify();
|
||||
}
|
||||
|
||||
rich.addEventListener('keydown', (event) => {
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
if (modifier && !event.altKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
const shortcut = { b: 'bold', i: 'italic', k: 'link', e: 'code' }[key];
|
||||
if (shortcut) {
|
||||
event.preventDefault();
|
||||
run(shortcut);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Tab walks the cells, and a Tab out of the last one adds a row. This is
|
||||
// what every table anywhere does, and without it the only way to add a
|
||||
// row on a keyboard would be the toolbar.
|
||||
if (event.key === 'Tab' && !modifier) {
|
||||
const cell = cellAt();
|
||||
if (cell) {
|
||||
event.preventDefault();
|
||||
const cells = Array.from(cell.closest('table').querySelectorAll('th, td'));
|
||||
const next = cells[cells.indexOf(cell) + (event.shiftKey ? -1 : 1)];
|
||||
if (next) placeCaret(next);
|
||||
else if (!event.shiftKey) addRow(cell.closest('table'));
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The way out of anything: a new paragraph after the block the cursor is
|
||||
// in, however deep in a table or a quote it sits.
|
||||
if (event.key === 'Enter' && modifier) {
|
||||
event.preventDefault();
|
||||
let block = selectionNode();
|
||||
while (block && block.parentNode !== rich) block = block.parentNode;
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.appendChild(document.createElement('br'));
|
||||
if (block) block.after(paragraph);
|
||||
else rich.appendChild(paragraph);
|
||||
placeCaret(paragraph);
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter at the end of a task item continues the list as tasks; the
|
||||
// browser would give the new item no box.
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
const item = itemAt();
|
||||
if (item && firstCheckbox(item)) {
|
||||
window.setTimeout(() => {
|
||||
const next = itemAt();
|
||||
if (next && next !== item && !firstCheckbox(next) && next.textContent.trim() === '') {
|
||||
next.classList.add('task');
|
||||
next.insertBefore(checkbox(false), next.firstChild);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- the toolbar -----------------------------------------------------
|
||||
|
||||
function run(name) {
|
||||
const command = commands[name];
|
||||
if (!command) return;
|
||||
if (name !== 'mode') {
|
||||
if (!enabled || mode !== 'rich') return;
|
||||
rich.focus();
|
||||
}
|
||||
command();
|
||||
notify();
|
||||
reflect();
|
||||
}
|
||||
|
||||
toolbar.addEventListener('mousedown', (event) => {
|
||||
// The selection must survive the press, or every command would apply to
|
||||
// nothing. Touch devices fire this too, ahead of the click.
|
||||
if (event.target.closest('[data-command]')) event.preventDefault();
|
||||
});
|
||||
|
||||
toolbar.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-command]');
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
run(button.dataset.command);
|
||||
});
|
||||
|
||||
/** Which buttons are "on" for the cursor's position. */
|
||||
function reflect() {
|
||||
if (mode !== 'rich') return;
|
||||
const block = blockAt();
|
||||
const states = {
|
||||
bold: query('bold'),
|
||||
italic: query('italic'),
|
||||
strike: query('strikeThrough'),
|
||||
h2: block === 'H2',
|
||||
h3: block === 'H3',
|
||||
quote: block === 'BLOCKQUOTE',
|
||||
ul: query('insertUnorderedList'),
|
||||
ol: query('insertOrderedList'),
|
||||
};
|
||||
for (const [name, active] of Object.entries(states)) {
|
||||
const button = toolbar.querySelector('[data-command="' + name + '"]');
|
||||
if (button) button.setAttribute('aria-pressed', String(Boolean(active)));
|
||||
}
|
||||
|
||||
const table = toolbar.querySelector('[data-command="table"]');
|
||||
if (table) {
|
||||
const inside = Boolean(tableAt());
|
||||
const label = inside ? 'Zeile anfügen' : 'Tabelle';
|
||||
table.setAttribute('aria-label', label);
|
||||
table.title = inside ? label + ' (oder Tab in der letzten Zelle)' : label;
|
||||
}
|
||||
}
|
||||
|
||||
function query(command) {
|
||||
try {
|
||||
return document.queryCommandState(command);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', () => {
|
||||
if (selectionNode()) reflect();
|
||||
});
|
||||
|
||||
// --- what the app calls ----------------------------------------------
|
||||
|
||||
return {
|
||||
getMarkdown,
|
||||
setMarkdown,
|
||||
setEnabled,
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
focus() {
|
||||
(mode === 'rich' ? rich : source).focus();
|
||||
},
|
||||
/** Adds Markdown at the end — how the day's missing lessons arrive. */
|
||||
append(markdown) {
|
||||
const current = getMarkdown();
|
||||
const next = (current ? current.replace(/\s*$/, '') + '\n\n' : '') + markdown;
|
||||
if (mode === 'source') source.value = next;
|
||||
else rich.innerHTML = markdownToHtml(next);
|
||||
notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -32,7 +32,24 @@
|
||||
</header>
|
||||
|
||||
<!-- Notes: one school day per note, one heading per lesson. -->
|
||||
<main id="view-notes" class="view">
|
||||
<!-- Two panes: the notes on the left, the open one on the right. Side by
|
||||
side where there is room; one at a time on a phone, where `data-pane`
|
||||
says which, and the back button returns to the list. -->
|
||||
<main id="view-notes" class="view notes" data-pane="list">
|
||||
<aside class="rail">
|
||||
<div class="rail-head">
|
||||
<form id="search-form" class="searchbar" role="search">
|
||||
<input id="search-input" type="search" inputmode="search" autocomplete="off"
|
||||
placeholder="Durchsuchen" aria-label="Notizen durchsuchen">
|
||||
</form>
|
||||
<button type="button" id="today" aria-label="Heutiger Tag" title="Heutiger Tag">+</button>
|
||||
</div>
|
||||
<p id="rail-status" class="status" role="status" aria-live="polite"></p>
|
||||
<div id="rail-list" class="rail-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="detail">
|
||||
<button type="button" id="back" class="back">‹ Notizen</button>
|
||||
<div class="daybar">
|
||||
<button type="button" id="prev" aria-label="Vorheriger Tag">‹</button>
|
||||
<div class="daybar-centre">
|
||||
@@ -45,14 +62,53 @@
|
||||
<p id="day-status" class="status" role="status" aria-live="polite"></p>
|
||||
<p id="day-conflict" class="conflict" role="alert" hidden></p>
|
||||
|
||||
<textarea id="editor" spellcheck="true" autocapitalize="sentences"
|
||||
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea>
|
||||
<!-- The toolbar writes the Markdown so nobody has to type it. Every button
|
||||
carries a word as well as a glyph, because the glyph is the thing a
|
||||
screen reader cannot read and a stranger cannot guess. -->
|
||||
<div id="toolbar" class="toolbar" role="toolbar" aria-label="Formatierung">
|
||||
<button type="button" data-command="h2" aria-pressed="false" aria-label="Stunde (Überschrift)" title="Stunde (Überschrift)"><b>H2</b></button>
|
||||
<button type="button" data-command="h3" aria-pressed="false" aria-label="Zwischenüberschrift" title="Zwischenüberschrift"><b>H3</b></button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="bold" aria-pressed="false" aria-label="Fett" title="Fett (Strg+B)"><b>F</b></button>
|
||||
<button type="button" data-command="italic" aria-pressed="false" aria-label="Kursiv" title="Kursiv (Strg+I)"><i>K</i></button>
|
||||
<button type="button" data-command="strike" aria-pressed="false" aria-label="Durchgestrichen" title="Durchgestrichen"><s>S</s></button>
|
||||
<button type="button" data-command="code" aria-label="Code" title="Code (Strg+E)"><code><></code></button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="ul" aria-pressed="false" aria-label="Aufzählung" title="Aufzählung">• —</button>
|
||||
<button type="button" data-command="ol" aria-pressed="false" aria-label="Nummerierte Liste" title="Nummerierte Liste">1. —</button>
|
||||
<button type="button" data-command="task" aria-label="Kästchen zum Abhaken" title="Kästchen zum Abhaken">☐</button>
|
||||
<button type="button" data-command="quote" aria-pressed="false" aria-label="Zitat" title="Zitat">❝</button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="link" aria-label="Link" title="Link (Strg+K)">🔗</button>
|
||||
<button type="button" data-command="table" aria-label="Tabelle" title="Tabelle">▦</button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="mode" aria-pressed="false" aria-label="Markdown bearbeiten" title="Markdown bearbeiten">MD</button>
|
||||
</div>
|
||||
|
||||
<p id="editor-hint" class="hint" role="status" hidden></p>
|
||||
|
||||
<!-- The formatted document, and the same note as Markdown. Exactly one of
|
||||
the two is visible; both hold the whole note. -->
|
||||
<div id="editor" class="editor" contenteditable="true" spellcheck="true" autocapitalize="sentences"
|
||||
role="textbox" aria-multiline="true" aria-label="Notizen des Tages"
|
||||
data-placeholder="Noch nichts für diesen Tag."></div>
|
||||
<textarea id="source" class="source" hidden spellcheck="false" autocapitalize="off"
|
||||
aria-label="Notizen des Tages als Markdown"></textarea>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="save">Speichern</button>
|
||||
<button type="button" id="fill" hidden>Stunden ergänzen</button>
|
||||
<span id="lessons-hint" class="hint"></span>
|
||||
</div>
|
||||
|
||||
<!-- A note that is not a school day — one from the import, a page of
|
||||
revision — has no day to open, so it is shown rather than edited. -->
|
||||
<article id="note-preview" class="note-preview" hidden>
|
||||
<h2 id="note-preview-title"></h2>
|
||||
<p id="note-preview-path" class="hint"></p>
|
||||
<div id="note-preview-body" class="editor" aria-readonly="true"></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Settings: the Schulcloud token, and what the server is doing. -->
|
||||
@@ -82,6 +138,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
687
src/http/app/markdown.js
Normal file
687
src/http/app/markdown.js
Normal file
@@ -0,0 +1,687 @@
|
||||
/*
|
||||
* Markdown in, formatted text out, and back again.
|
||||
*
|
||||
* The notes are Markdown files — that is what the indexer reads, what
|
||||
* `subjectFromHeading` takes a lesson apart with, and what survives this
|
||||
* project. The editor shows them as formatted text anyway, so this module is
|
||||
* the hinge: `markdownToHtml` on the way into the editor, `markdownFromDom` on
|
||||
* the way back out to the file.
|
||||
*
|
||||
* Three properties matter more than completeness, because what passes through
|
||||
* here is the only record of what was said in a lesson:
|
||||
*
|
||||
* - **Round-trip stability.** `fromDom(toHtml(x))` may tidy `x` once — `*a*`
|
||||
* becomes `_a_`, a ragged table lines up — but doing it again must change
|
||||
* nothing. `editor.js` checks exactly that before it opens a note in
|
||||
* formatted mode, and falls back to the Markdown view when it does not hold.
|
||||
* - **Nothing is dropped.** An element this module does not model keeps its
|
||||
* words and loses its tag. A note is better off plain than short.
|
||||
* - **No HTML is trusted.** `markdownToHtml` escapes everything that is not a
|
||||
* construct it produced itself, so a note containing `<script>` is text, not
|
||||
* script. Pasted HTML never reaches the document either: it is converted to
|
||||
* Markdown first and parsed back, which reduces it to the subset below.
|
||||
*
|
||||
* The subset is what these notes are made of: headings, paragraphs, bold,
|
||||
* italic, strikethrough, code (inline and fenced), links, bullet / numbered /
|
||||
* task lists with nesting, blockquotes, tables and rules. Underline is
|
||||
* deliberately absent — Markdown has no way to write it, so the toolbar does
|
||||
* not offer what the file cannot keep.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where a code span sat while the emphasis rules ran over the line.
|
||||
*
|
||||
* A control character, because it is the one thing a note cannot contain: the
|
||||
* store strips NUL out of extracted text, and nothing types one.
|
||||
*/
|
||||
const PLACEHOLDER = '\u0000';
|
||||
const PLACEHOLDERS = /\u0000(\d+)\u0000/g;
|
||||
|
||||
/** Ordered and bullet items, with their indentation and marker. */
|
||||
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
|
||||
/** How far a continuation line must be indented to belong to the item above. */
|
||||
const CONTINUATION = 2;
|
||||
|
||||
// --- Markdown → HTML -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* A note's body as HTML for the editor.
|
||||
*
|
||||
* The output is the only HTML the editor ever starts from, which is what makes
|
||||
* the serializer's job finite.
|
||||
*/
|
||||
export function markdownToHtml(markdown) {
|
||||
const lines = String(markdown ?? '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.map(expandLeadingTabs);
|
||||
return parseBlocks(lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs only in the indentation, and only there.
|
||||
*
|
||||
* Indentation is measured in columns to decide what nests inside what, so a
|
||||
* tab has to become a known number of spaces first. Tabs inside the text are
|
||||
* left alone — in a code block they are content.
|
||||
*/
|
||||
function expandLeadingTabs(line) {
|
||||
const match = /^[ \t]+/.exec(line);
|
||||
if (!match) return line;
|
||||
return match[0].replace(/\t/g, ' ') + line.slice(match[0].length);
|
||||
}
|
||||
|
||||
function parseBlocks(lines) {
|
||||
const out = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fence = /^ {0,3}(```+|~~~+)\s*([A-Za-z0-9_+#-]*)\s*$/.exec(line);
|
||||
if (fence) {
|
||||
const closing = new RegExp('^ {0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
|
||||
const body = [];
|
||||
i++;
|
||||
while (i < lines.length && !closing.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
// An unclosed fence still ends the block; the note is what it is.
|
||||
i++;
|
||||
const language = fence[2] ? ' class="language-' + escapeHtml(fence[2]) + '"' : '';
|
||||
out.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^ {0,3}(#{1,6})\s+(.*?)\s*#*$/.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push('<h' + level + '>' + inlineToHtml(heading[2]) + '</h' + level + '>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRule(line)) {
|
||||
out.push('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ {0,3}>/.test(line)) {
|
||||
const body = [];
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
if (/^ {0,3}>/.test(lines[i])) body.push(lines[i].replace(/^ {0,3}> ?/, ''));
|
||||
// A wrapped line with no `>` still belongs to the quote it follows.
|
||||
else body.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
out.push('<blockquote>' + parseBlocks(body) + '</blockquote>');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startsTable(lines, i)) {
|
||||
const header = splitRow(lines[i]);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
||||
rows.push(splitRow(lines[i]));
|
||||
i++;
|
||||
}
|
||||
out.push(tableToHtml(header, rows));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ITEM.test(line)) {
|
||||
const list = parseList(lines, i);
|
||||
out.push(list.html);
|
||||
i = list.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A paragraph, whose single newlines are line breaks rather than
|
||||
// paragraph breaks. That is how a note reads in a plain editor and how
|
||||
// Notes.app behaved, and it round-trips exactly — unlike the two
|
||||
// trailing spaces CommonMark wants, which no one can see.
|
||||
const paragraph = [];
|
||||
while (i < lines.length && lines[i].trim() && !startsBlock(lines, i)) {
|
||||
paragraph.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
out.push('<p>' + paragraph.map(inlineToHtml).join('<br>') + '</p>');
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** Everything that interrupts a paragraph. */
|
||||
function startsBlock(lines, index) {
|
||||
const line = lines[index];
|
||||
return (
|
||||
/^ {0,3}(```+|~~~+)/.test(line) ||
|
||||
/^ {0,3}#{1,6}\s/.test(line) ||
|
||||
/^ {0,3}>/.test(line) ||
|
||||
isRule(line) ||
|
||||
ITEM.test(line) ||
|
||||
startsTable(lines, index)
|
||||
);
|
||||
}
|
||||
|
||||
function isRule(line) {
|
||||
return /^ {0,3}([-*_])\s*(?:\1\s*){2,}$/.test(line);
|
||||
}
|
||||
|
||||
function startsTable(lines, index) {
|
||||
if (!lines[index].includes('|')) return false;
|
||||
const next = lines[index + 1];
|
||||
return Boolean(next) && /^\s*\|?(\s*:?-{1,}:?\s*\|)+\s*:?-*:?\s*\|?\s*$/.test(next) && next.includes('-');
|
||||
}
|
||||
|
||||
function splitRow(line) {
|
||||
let value = line.trim();
|
||||
if (value.startsWith('|')) value = value.slice(1);
|
||||
if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
|
||||
// Split on pipes that are not escaped, then give the cells their pipes back.
|
||||
return value.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
||||
}
|
||||
|
||||
function tableToHtml(header, rows) {
|
||||
const width = Math.max(header.length, ...rows.map((row) => row.length), 1);
|
||||
const cells = (row, tag) => {
|
||||
let out = '';
|
||||
for (let i = 0; i < width; i++) {
|
||||
// A break in an empty cell: a `<td></td>` with nothing in it cannot be
|
||||
// clicked into, so a blank cell would be uneditable. It serializes
|
||||
// back to an empty cell.
|
||||
out += '<' + tag + '>' + (inlineToHtml(row[i] ?? '') || '<br>') + '</' + tag + '>';
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const body = rows.map((row) => '<tr>' + cells(row, 'td') + '</tr>').join('');
|
||||
return '<table><thead><tr>' + cells(header, 'th') + '</tr></thead><tbody>' + body + '</tbody></table>';
|
||||
}
|
||||
|
||||
/**
|
||||
* One list, and everything nested inside it.
|
||||
*
|
||||
* Continuation is by indentation: a line indented at least two columns past
|
||||
* the item's own marker belongs to that item, which is what makes nesting and
|
||||
* multi-paragraph items work without tracking marker widths through the
|
||||
* recursion. Indentation inside an item is relative, so the nested list parses
|
||||
* as a list of its own.
|
||||
*/
|
||||
function parseList(lines, start) {
|
||||
const first = ITEM.exec(lines[start]);
|
||||
const base = first[1].length;
|
||||
const ordered = /^\d/.test(first[2]);
|
||||
const startNumber = ordered ? Number.parseInt(first[2], 10) : 1;
|
||||
const items = [];
|
||||
let i = start;
|
||||
|
||||
while (i < lines.length) {
|
||||
const match = ITEM.exec(lines[i]);
|
||||
if (!match) break;
|
||||
// A shallower item ends this list; a deeper one is swallowed below as
|
||||
// part of the item above it, so reaching one here means the list is over.
|
||||
if (match[1].length !== base) break;
|
||||
if (/^\d/.test(match[2]) !== ordered) break;
|
||||
|
||||
const body = [match[3]];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) {
|
||||
// A blank line keeps the item open only if something indented
|
||||
// follows it; otherwise the list ends here.
|
||||
const after = lines[i + 1];
|
||||
if (after && after.trim() && indentOf(after) >= base + CONTINUATION) {
|
||||
body.push('');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (indentOf(line) >= base + CONTINUATION) {
|
||||
body.push(line.slice(base + CONTINUATION));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ITEM.test(line) || startsBlock(lines, i)) break;
|
||||
// A wrapped line, typed without indentation.
|
||||
body.push(line.trim());
|
||||
i++;
|
||||
}
|
||||
items.push(body);
|
||||
}
|
||||
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
const open = ordered && startNumber !== 1 ? '<ol start="' + startNumber + '">' : '<' + tag + '>';
|
||||
return { html: open + items.map(itemToHtml).join('') + '</' + tag + '>', next: i };
|
||||
}
|
||||
|
||||
function itemToHtml(body) {
|
||||
const task = /^\[([ xX])\]\s+([\s\S]*)$/.exec(body[0] ?? '');
|
||||
if (task) body = [task[2], ...body.slice(1)];
|
||||
|
||||
let inner = parseBlocks(body);
|
||||
// A tight item: its first paragraph is the item's own text, not a paragraph
|
||||
// inside it. Unwrapping only the first keeps multi-paragraph items intact.
|
||||
inner = inner.replace(/^<p>([\s\S]*?)<\/p>/, '$1');
|
||||
|
||||
if (!task) return '<li>' + inner + '</li>';
|
||||
const checked = task[1] !== ' ';
|
||||
return (
|
||||
'<li class="task"><input type="checkbox" contenteditable="false"' +
|
||||
(checked ? ' checked' : '') +
|
||||
'>' +
|
||||
inner +
|
||||
'</li>'
|
||||
);
|
||||
}
|
||||
|
||||
function indentOf(line) {
|
||||
return /^[ ]*/.exec(line)[0].length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Markdown as HTML.
|
||||
*
|
||||
* Code spans are taken out first and put back last, so the stars and
|
||||
* underscores inside `**bold**` written as code stay literal.
|
||||
*/
|
||||
function inlineToHtml(text) {
|
||||
const literals = [];
|
||||
const park = (html) => {
|
||||
literals.push(html);
|
||||
return PLACEHOLDER + (literals.length - 1) + PLACEHOLDER;
|
||||
};
|
||||
|
||||
// Backslash escapes first, or `\*` would still be read as emphasis and a
|
||||
// backslashed backtick would still open a code span. Parked as literal
|
||||
// text, they take no further part in anything.
|
||||
let value = String(text).replace(/\\([\\`*_[\]#>~|+.()-])/g, (all, character) => park(escapeHtml(character)));
|
||||
|
||||
value = value.replace(/(`+)([\s\S]*?)\1/g, (all, fence, body) =>
|
||||
park('<code>' + escapeHtml(body.replace(/^ (.*) $/, '$1')) + '</code>'),
|
||||
);
|
||||
|
||||
value = escapeHtml(value);
|
||||
|
||||
// Links before emphasis: a label may contain either, and a URL may contain
|
||||
// underscores that are not emphasis. One level of balanced parentheses is
|
||||
// allowed in the target, because real links have them —
|
||||
// de.wikipedia.org/wiki/Erörterung_(Textsorte).
|
||||
value = value.replace(/\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))*)\)/g, (all, label, href) => {
|
||||
const safe = safeUrl(href);
|
||||
if (!safe) return label;
|
||||
return '<a href="' + safe + '">' + (label || safe) + '</a>';
|
||||
});
|
||||
|
||||
value = value.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>');
|
||||
value = value.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
||||
// A single marker, not part of a double one, and not mid-word for `_` —
|
||||
// otherwise snake_case_names turn into emphasis.
|
||||
value = value.replace(/(?<!\*)\*(?!\*)(?=\S)([\s\S]*?\S)\*(?!\*)/g, '<em>$1</em>');
|
||||
value = value.replace(/(?<![\w_])_(?!_)(?=\S)([\s\S]*?\S)_(?![\w_])/g, '<em>$1</em>');
|
||||
|
||||
return value.replace(PLACEHOLDERS, (all, index) => literals[Number(index)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link target, or nothing.
|
||||
*
|
||||
* The editor's content comes from the user's own notes, but a note can be
|
||||
* written by anything — an import, a paste from a web page — so a `javascript:`
|
||||
* url is refused rather than rendered into a document a finger will tap.
|
||||
*/
|
||||
function safeUrl(href) {
|
||||
const value = href.trim();
|
||||
if (!value) return '';
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^(https?|mailto|tel):/i.test(value)) return '';
|
||||
return escapeHtml(value).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// --- HTML → Markdown -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* What the editor holds, as the Markdown that will be written to the file.
|
||||
*
|
||||
* Deliberately tolerant: browsers put their own tags into a contenteditable
|
||||
* element (`<div>` for a line, `<span style="font-weight: bold">` after a
|
||||
* paste, `<font>` on older engines), and none of that may cost a word. An
|
||||
* element with no meaning here serializes its children.
|
||||
*
|
||||
* `root` needs only the read-only parts of the DOM — `nodeType`, `nodeName`,
|
||||
* `childNodes`, `textContent` and `getAttribute` — so the same function runs
|
||||
* against a plain tree in the tests.
|
||||
*/
|
||||
export function markdownFromDom(root) {
|
||||
return serializeBlocks(root).replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
||||
'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE', 'SECTION', 'ARTICLE', 'FIGURE',
|
||||
]);
|
||||
|
||||
function serializeBlocks(node) {
|
||||
const out = [];
|
||||
let inline = [];
|
||||
|
||||
const flush = () => {
|
||||
if (inline.length === 0) return;
|
||||
const text = paragraph(inlineFrom(inline));
|
||||
if (text) out.push(text);
|
||||
inline = [];
|
||||
};
|
||||
|
||||
for (const child of children(node)) {
|
||||
// A block *inside* an inline element is still a block. WebKit wraps a
|
||||
// copied selection in one span carrying the computed style of everything
|
||||
// in it, so a paste from Apple Notes arrives as
|
||||
// `<span style="font-weight: 700"><div>…</div><div>…</div></span>` —
|
||||
// and reading that span as inline flattened a whole note into one
|
||||
// paragraph and made every word of it bold.
|
||||
if (child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || holdsBlock(child))) {
|
||||
flush();
|
||||
const block = serializeBlock(child);
|
||||
if (block) out.push(block);
|
||||
} else {
|
||||
inline.push(child);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
return out.join('\n\n');
|
||||
}
|
||||
|
||||
function serializeBlock(element) {
|
||||
switch (element.nodeName) {
|
||||
case 'H1':
|
||||
case 'H2':
|
||||
case 'H3':
|
||||
case 'H4':
|
||||
case 'H5':
|
||||
case 'H6': {
|
||||
const text = inlineFrom(children(element)).replace(/\n+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
return '#'.repeat(Number(element.nodeName[1])) + ' ' + text;
|
||||
}
|
||||
case 'HR':
|
||||
return '---';
|
||||
case 'PRE': {
|
||||
const body = element.textContent.replace(/\n$/, '');
|
||||
const language = languageOf(element);
|
||||
// A fence longer than any run of backticks inside, or a note about
|
||||
// Markdown closes its own code block.
|
||||
const longest = Math.max(2, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
|
||||
const fence = '`'.repeat(longest + 1);
|
||||
return fence + language + '\n' + body + '\n' + fence;
|
||||
}
|
||||
case 'BLOCKQUOTE': {
|
||||
const inner = serializeBlocks(element).trim();
|
||||
if (!inner) return '';
|
||||
return inner.split('\n').map((line) => (line ? '> ' + line : '>')).join('\n');
|
||||
}
|
||||
case 'UL':
|
||||
case 'OL':
|
||||
return serializeList(element);
|
||||
case 'TABLE':
|
||||
return serializeTable(element);
|
||||
case 'DIV':
|
||||
case 'SECTION':
|
||||
case 'ARTICLE':
|
||||
case 'FIGURE':
|
||||
// A browser's line wrapper, or a real container. Both are handled by
|
||||
// asking what is inside.
|
||||
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
||||
default:
|
||||
// Anything else that reached this function is here because it holds
|
||||
// blocks — a paste wrapper, most often. Its own tag means nothing;
|
||||
// what it contains means everything.
|
||||
return holdsBlock(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
||||
}
|
||||
}
|
||||
|
||||
function serializeList(list, depth = 0) {
|
||||
const ordered = list.nodeName === 'OL';
|
||||
const start = Number.parseInt(list.getAttribute('start') ?? '', 10);
|
||||
let number = Number.isFinite(start) && start > 0 ? start : 1;
|
||||
const out = [];
|
||||
|
||||
// Two columns per level, matching what the parser takes back apart.
|
||||
const indent = ' '.repeat(CONTINUATION);
|
||||
const shift = (block) => block.split('\n').map((line) => (line ? indent + line : '')).join('\n');
|
||||
|
||||
for (const item of children(list)) {
|
||||
if (item.nodeType !== 1) continue;
|
||||
if (item.nodeName === 'UL' || item.nodeName === 'OL') {
|
||||
// A list as a *sibling* of the items rather than inside one. Several
|
||||
// engines produce this when Tab indents a bullet, and skipping it
|
||||
// would silently drop everything the person nested.
|
||||
const nested = shift(serializeList(item, depth + 1));
|
||||
if (out.length > 0) out[out.length - 1] += '\n' + nested;
|
||||
else out.push(nested);
|
||||
continue;
|
||||
}
|
||||
if (item.nodeName !== 'LI') continue;
|
||||
|
||||
const checkbox = firstCheckbox(item);
|
||||
const marker = ordered ? number++ + '.' : '-';
|
||||
const box = checkbox ? (checkbox.getAttribute('checked') === null ? '[ ] ' : '[x] ') : '';
|
||||
|
||||
// The item's own text, then whatever blocks hang under it.
|
||||
const leading = [];
|
||||
const blocks = [];
|
||||
for (const child of children(item)) {
|
||||
if (child === checkbox) continue;
|
||||
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) blocks.push(child);
|
||||
else if (blocks.length === 0) leading.push(child);
|
||||
// Inline content after a nested list is rare and reads as part of it.
|
||||
else blocks.push(child);
|
||||
}
|
||||
|
||||
// An item whose text the browser wrapped in a div or a p: that is the
|
||||
// item's own line, not a block underneath it.
|
||||
if (leading.length === 0 && blocks.length > 0 && (blocks[0].nodeName === 'DIV' || blocks[0].nodeName === 'P')) {
|
||||
if (!hasBlockChild(blocks[0])) leading.push(...children(blocks.shift()));
|
||||
}
|
||||
|
||||
const head = paragraph(inlineFrom(leading));
|
||||
const rest = blocks
|
||||
.map((child) =>
|
||||
child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')
|
||||
? serializeList(child, depth + 1)
|
||||
: serializeBlock(child),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const first = marker + ' ' + box + head.split('\n').join('\n' + indent);
|
||||
out.push([first, ...rest.map(shift)].join('\n'));
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function serializeTable(table) {
|
||||
const rows = [];
|
||||
const walk = (node) => {
|
||||
for (const child of children(node)) {
|
||||
if (child.nodeType !== 1) continue;
|
||||
if (child.nodeName === 'TR') rows.push(child);
|
||||
else walk(child);
|
||||
}
|
||||
};
|
||||
walk(table);
|
||||
if (rows.length === 0) return '';
|
||||
|
||||
const cells = rows.map((row) =>
|
||||
children(row)
|
||||
.filter((cell) => cell.nodeType === 1 && (cell.nodeName === 'TD' || cell.nodeName === 'TH'))
|
||||
.map((cell) => inlineFrom(children(cell)).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim()),
|
||||
);
|
||||
const width = Math.max(...cells.map((row) => row.length));
|
||||
const line = (row) => '| ' + Array.from({ length: width }, (_, i) => row[i] ?? '').join(' | ') + ' |';
|
||||
|
||||
// A header row is required by the syntax: a table whose first row is data
|
||||
// would otherwise lose that row entirely.
|
||||
return [line(cells[0]), '|' + ' --- |'.repeat(width), ...cells.slice(1).map(line)].join('\n');
|
||||
}
|
||||
|
||||
function inlineFrom(nodes) {
|
||||
return nodes.map(inlineNode).join('');
|
||||
}
|
||||
|
||||
function inlineNode(node) {
|
||||
if (node.nodeType === 3) return escapeText(node.textContent);
|
||||
if (node.nodeType !== 1) return '';
|
||||
|
||||
switch (node.nodeName) {
|
||||
case 'BR':
|
||||
return '\n';
|
||||
case 'IMG':
|
||||
// No note here has an image; one arriving by paste says so rather
|
||||
// than vanishing.
|
||||
return node.getAttribute('alt') ? '[' + escapeText(node.getAttribute('alt')) + ']' : '';
|
||||
case 'INPUT':
|
||||
// Only ever a task checkbox, and `serializeList` has already read it.
|
||||
return '';
|
||||
case 'CODE': {
|
||||
const body = node.textContent;
|
||||
if (!body) return '';
|
||||
const longest = Math.max(0, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
|
||||
const fence = '`'.repeat(longest + 1);
|
||||
const pad = body.startsWith('`') || body.endsWith('`') ? ' ' : '';
|
||||
return fence + pad + body + pad + fence;
|
||||
}
|
||||
case 'A': {
|
||||
const label = inlineFrom(children(node));
|
||||
const href = (node.getAttribute('href') ?? '').trim();
|
||||
if (!href) return label;
|
||||
if (!label.trim()) return href;
|
||||
return '[' + label + '](' + href + ')';
|
||||
}
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return emphasise(inlineFrom(children(node)), '**');
|
||||
case 'EM':
|
||||
case 'I':
|
||||
return emphasise(inlineFrom(children(node)), '_');
|
||||
case 'DEL':
|
||||
case 'S':
|
||||
case 'STRIKE':
|
||||
return emphasise(inlineFrom(children(node)), '~~');
|
||||
case 'SPAN':
|
||||
case 'FONT': {
|
||||
// What a paste leaves behind. The tag says nothing; the style might
|
||||
// — but only for a span wrapping one run of text. A span with
|
||||
// elements inside it is a container carrying inherited style, not
|
||||
// emphasis: WebKit hangs the whole computed style of a copied
|
||||
// selection on such a wrapper, and honouring its `font-weight: 700`
|
||||
// is what made an entire pasted note bold.
|
||||
const inner = inlineFrom(children(node));
|
||||
if (!isTextOnly(node)) return inner;
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
if (/font-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
|
||||
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
|
||||
return inner;
|
||||
}
|
||||
default:
|
||||
return inlineFrom(children(node));
|
||||
}
|
||||
}
|
||||
|
||||
/** Markers hug their text: `** bold **` is four literal stars, not emphasis. */
|
||||
function emphasise(inner, marker) {
|
||||
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner);
|
||||
if (!parts[2]) return inner;
|
||||
// Already carrying the same marker (nested `<b><b>`, or a paste): once is enough.
|
||||
if (parts[2].startsWith(marker) && parts[2].endsWith(marker)) return inner;
|
||||
return parts[1] + marker + parts[2] + marker + parts[3];
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of inline content as one paragraph.
|
||||
*
|
||||
* Line starts are escaped here rather than in `escapeText`, because whether a
|
||||
* `-` opens a list depends on where in the line it sits.
|
||||
*/
|
||||
function paragraph(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/^(\s*)([#>]|[-*+](?=\s))/, '$1\\$2')
|
||||
// The backslash goes before the dot, never before the digit: a
|
||||
// backslash in front of anything but punctuation is a literal
|
||||
// backslash, and `\1.` would be written into the file as it looks.
|
||||
.replace(/^(\s*\d{1,9})([.)](?=\s))/, '$1\\$2'),
|
||||
)
|
||||
.join('\n')
|
||||
.replace(/^\n+|\n+$/g, '');
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/([`*[\]])/g, '\\$1')
|
||||
// Only where it could be read as emphasis: `snake_case` stays readable.
|
||||
.replace(/(^|[^\w_])_/g, '$1\\_')
|
||||
.replace(/_($|[^\w_])/g, '\\_$1')
|
||||
.replace(/~~/g, '\\~\\~')
|
||||
// A lone `<` only matters when it could open a tag.
|
||||
.replace(/<(?=[a-zA-Z/!])/g, '\\<');
|
||||
}
|
||||
|
||||
function languageOf(pre) {
|
||||
for (const child of children(pre)) {
|
||||
if (child.nodeType === 1 && child.nodeName === 'CODE') {
|
||||
const match = /language-([A-Za-z0-9_+#-]+)/.exec(child.getAttribute('class') ?? '');
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstCheckbox(item) {
|
||||
for (const child of children(item)) {
|
||||
if (child.nodeType === 1 && child.nodeName === 'INPUT' && child.getAttribute('type') === 'checkbox') return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasBlockChild(element) {
|
||||
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a block hides anywhere under this element.
|
||||
*
|
||||
* Pastes nest wrappers several deep — `<span><span><div>` — so the answer has
|
||||
* to be looked for rather than checked one level down. Bounded, because the
|
||||
* tree comes from a clipboard and nothing here should be able to hang on one.
|
||||
*/
|
||||
function holdsBlock(element, depth = 0) {
|
||||
if (depth > 6) return false;
|
||||
return children(element).some(
|
||||
(child) =>
|
||||
child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || child.nodeName === 'LI' || holdsBlock(child, depth + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/** A span with nothing but text in it — the only shape whose style is emphasis. */
|
||||
function isTextOnly(element) {
|
||||
return children(element).every((child) => child.nodeType === 3 || child.nodeName === 'BR');
|
||||
}
|
||||
|
||||
function children(node) {
|
||||
return Array.prototype.slice.call(node.childNodes ?? []);
|
||||
}
|
||||
285
test/app-markdown.test.ts
Normal file
285
test/app-markdown.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { markdownFromDom, markdownToHtml } from '../src/http/app/markdown.js';
|
||||
import { parseHtml } from './mini-dom.ts';
|
||||
|
||||
/**
|
||||
* The notes editor's Markdown bridge.
|
||||
*
|
||||
* The editor shows a note as formatted text and writes it back as Markdown, so
|
||||
* every save runs the note through `markdownToHtml` and `markdownFromDom`. If
|
||||
* that pair loses anything, it loses a lesson — these notes are the only record
|
||||
* of what was actually said in the room, and there is no second copy to restore
|
||||
* from. Hence the shape of almost every test here: put Markdown in, get the
|
||||
* same Markdown back.
|
||||
*/
|
||||
|
||||
/** Markdown → HTML → Markdown, the trip a note takes on every edit. */
|
||||
function back(markdown: string): string {
|
||||
return markdownFromDom(parseHtml(markdownToHtml(markdown)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the trip is idempotent, and returns what it settles on.
|
||||
*
|
||||
* One pass may tidy — `*a*` becomes `_a_`, a ragged table lines up — and that
|
||||
* is allowed, because the editor only rewrites a file the person has edited.
|
||||
* A second pass changing anything is not: it would mean every save mangles the
|
||||
* note a little further, which is how a term's notes rot into nothing.
|
||||
*/
|
||||
function settles(markdown: string): string {
|
||||
const once = back(markdown);
|
||||
assert.equal(back(once), once, 'the round trip is not stable');
|
||||
return once;
|
||||
}
|
||||
|
||||
/** Markdown that must survive the trip exactly as written. */
|
||||
function unchanged(markdown: string): void {
|
||||
assert.equal(settles(markdown), markdown);
|
||||
}
|
||||
|
||||
test('headings keep their level', () => {
|
||||
unchanged('## 1. Deutsch — 08:00–08:45 · MEI · R 204');
|
||||
unchanged('# Eins\n\n## Zwei\n\n### Drei\n\n#### Vier');
|
||||
assert.match(markdownToHtml('## Deutsch'), /<h2>Deutsch<\/h2>/);
|
||||
});
|
||||
|
||||
test('a paragraph keeps its line breaks without turning them into paragraphs', () => {
|
||||
// How a note actually gets typed: shift-enter within a thought, enter
|
||||
// between them.
|
||||
unchanged('Erste Zeile\nzweite Zeile\n\nNeuer Absatz');
|
||||
assert.equal(markdownToHtml('a\nb'), '<p>a<br>b</p>');
|
||||
});
|
||||
|
||||
test('emphasis round-trips, and normalises to one spelling', () => {
|
||||
unchanged('**fett** und _kursiv_ und ~~gestrichen~~');
|
||||
assert.equal(settles('*kursiv*'), '_kursiv_');
|
||||
assert.equal(settles('__fett__'), '**fett**');
|
||||
assert.equal(markdownToHtml('**fett**'), '<p><strong>fett</strong></p>');
|
||||
});
|
||||
|
||||
test('emphasis markers hug their text', () => {
|
||||
// `** fett **` is four literal stars in every renderer there is.
|
||||
const html = '<p>Merke:<strong> fett </strong>rest</p>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), 'Merke: **fett** rest');
|
||||
});
|
||||
|
||||
test('inline code keeps what is inside it literal', () => {
|
||||
unchanged('Der Platzhalter `**nicht fett**` bleibt stehen.');
|
||||
unchanged('`a | b`');
|
||||
assert.equal(settles('``ein ` backtick``'), '``ein ` backtick``');
|
||||
});
|
||||
|
||||
test('links keep their target, and a dangerous scheme is dropped', () => {
|
||||
unchanged('[Arbeitsblatt](https://example.org/ab.pdf)');
|
||||
// The label survives; only the target goes. A note is never worth less than
|
||||
// its words, and nothing here should render a tappable `javascript:`.
|
||||
assert.equal(back('[hier](javascript:alert)'), 'hier');
|
||||
// A target with parentheses in it is a link, not a broken one.
|
||||
unchanged('[Erörterung](https://de.wikipedia.org/wiki/Erörterung_(Textsorte))');
|
||||
});
|
||||
|
||||
test('bullet lists nest', () => {
|
||||
unchanged('- eins\n- zwei\n - zwei a\n - zwei b\n- drei');
|
||||
});
|
||||
|
||||
test('numbered lists keep their numbering', () => {
|
||||
unchanged('1. eins\n2. zwei\n3. drei');
|
||||
// A list that starts elsewhere keeps its first number and renumbers the rest.
|
||||
assert.equal(settles('3. drei\n4. vier'), '3. drei\n4. vier');
|
||||
});
|
||||
|
||||
test('task lists keep their boxes', () => {
|
||||
unchanged('- [ ] offen\n- [x] erledigt');
|
||||
assert.match(markdownToHtml('- [x] fertig'), /<input type="checkbox" contenteditable="false" checked>/);
|
||||
});
|
||||
|
||||
test('tables round-trip and line up', () => {
|
||||
unchanged('| Präfix | Adressen |\n| --- | --- |\n| /24 | 254 |\n| /25 | 126 |');
|
||||
// A ragged table is tidied once, then left alone.
|
||||
assert.equal(settles('|a|b|\n|-|-|\n|1|2|'), '| a | b |\n| --- | --- |\n| 1 | 2 |');
|
||||
});
|
||||
|
||||
test('a pipe inside a cell stays inside the cell', () => {
|
||||
const md = '| Zeichen | Bedeutung |\n| --- | --- |\n| \\| | oder |';
|
||||
assert.equal(settles(md), md);
|
||||
});
|
||||
|
||||
test('blockquotes and rules survive', () => {
|
||||
unchanged('> Merksatz des Lehrers\n> über zwei Zeilen');
|
||||
unchanged('---');
|
||||
});
|
||||
|
||||
test('fenced code keeps its language and its contents verbatim', () => {
|
||||
unchanged('```bash\nip route add 10.0.0.0/8 via 10.1.1.1\n```');
|
||||
// Indentation inside a fence is content, not structure.
|
||||
unchanged('```\nif x:\n y = 1\n```');
|
||||
});
|
||||
|
||||
test('underscores inside words are not emphasis', () => {
|
||||
unchanged('snake_case_name bleibt ein Wort');
|
||||
// `__` is bold in Markdown, though, and is normalised to the one spelling.
|
||||
assert.equal(settles('__wirklich fett__'), '**wirklich fett**');
|
||||
});
|
||||
|
||||
test('a note cannot smuggle HTML into the editor', () => {
|
||||
const html = markdownToHtml('<script>alert(1)</script> & <b>nicht fett</b>');
|
||||
assert.equal(html.includes('<script'), false);
|
||||
assert.equal(html.includes('<b>'), false);
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
|
||||
test('markup the editor does not model keeps its words', () => {
|
||||
// What a paste from a web page leaves behind: the tags mean nothing here,
|
||||
// the text means everything.
|
||||
const html = '<p><span style="font-weight: 700">fett</span> <u>unterstrichen</u> <font color="red">rot</font></p>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '**fett** unterstrichen rot');
|
||||
});
|
||||
|
||||
test('a browser\'s own line divs become paragraphs', () => {
|
||||
// contenteditable produces these on every Enter, in every engine.
|
||||
assert.equal(markdownFromDom(parseHtml('<div>eins</div><div>zwei</div>')), 'eins\n\nzwei');
|
||||
assert.equal(markdownFromDom(parseHtml('<div><br></div>')), '');
|
||||
});
|
||||
|
||||
test('text that looks like Markdown is escaped, and comes back as text', () => {
|
||||
unchanged('2 \\* 3 \\* 4');
|
||||
unchanged('\\- kein Listenpunkt');
|
||||
unchanged('\\# keine Überschrift');
|
||||
assert.equal(back('Gewicht \\_in kg\\_'), 'Gewicht \\_in kg\\_');
|
||||
});
|
||||
|
||||
test('a whole day note survives unchanged', () => {
|
||||
// The shape the app writes and the indexer reads back: one `##` per lesson,
|
||||
// prose, a list, a subheading and a table underneath.
|
||||
const note = [
|
||||
'## 1. Deutsch — 08:00–08:45 · MEI · R 204',
|
||||
'',
|
||||
'Dreischritt: These, Argument mit Beleg, Fazit.',
|
||||
'',
|
||||
'### Aufbau',
|
||||
'',
|
||||
'- Gegenargument nicht vergessen',
|
||||
' - kam letztes Jahr in der Arbeit dran',
|
||||
'- **Fazit** knapp halten',
|
||||
'',
|
||||
'## 2. LF07 — 08:50–09:35 · Sb · R 108',
|
||||
'',
|
||||
'| Präfix | Nutzbare Adressen |',
|
||||
'| --- | --- |',
|
||||
'| /24 | 254 |',
|
||||
'| /25 | 126 |',
|
||||
'',
|
||||
'> Kommt so in der Arbeit dran.',
|
||||
].join('\n');
|
||||
unchanged(note);
|
||||
});
|
||||
|
||||
test('the lesson headings the indexer keys on come back verbatim', () => {
|
||||
// `lessonHeading` writes these and `subjectFromHeading` reads the subject
|
||||
// back out of them. An editor that rewrote the dash or the separator would
|
||||
// file a day's notes under nothing.
|
||||
for (const heading of [
|
||||
'## 1. Deutsch — 08:00–08:45 · MEI · R 204',
|
||||
'## 3. LF07 — 10:35–11:20 · Sb · R 108 (Vertretung)',
|
||||
'## 5. Englisch — 12:15–13:00',
|
||||
]) {
|
||||
unchanged(heading);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty note is empty, not a paragraph', () => {
|
||||
assert.equal(markdownToHtml(''), '');
|
||||
assert.equal(back(''), '');
|
||||
assert.equal(back('\n\n \n'), '');
|
||||
});
|
||||
|
||||
test('a list the browser nested as a sibling keeps its items', () => {
|
||||
// What several engines produce when Tab indents a bullet: the nested list
|
||||
// beside the items rather than inside one. Skipping it would drop
|
||||
// everything under it without a trace.
|
||||
const html = '<ul><li>eins</li><ul><li>eins a</li></ul><li>zwei</li></ul>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '- eins\n - eins a\n- zwei');
|
||||
});
|
||||
|
||||
test('an item whose text the browser wrapped in a div is still one line', () => {
|
||||
assert.equal(markdownFromDom(parseHtml('<ul><li><div>eins</div></li></ul>')), '- eins');
|
||||
assert.equal(markdownFromDom(parseHtml('<ol><li><p>eins</p></li></ol>')), '1. eins');
|
||||
});
|
||||
|
||||
test('what execCommand produces round-trips', () => {
|
||||
// styleWithCSS is turned off, so bold and italic arrive as tags — but
|
||||
// `<b>`/`<i>`, not `<strong>`/`<em>`.
|
||||
assert.equal(markdownFromDom(parseHtml('<p><b>fett</b> und <i>kursiv</i></p>')), '**fett** und _kursiv_');
|
||||
// A heading made by formatBlock, and the empty paragraph left behind.
|
||||
assert.equal(markdownFromDom(parseHtml('<h2>Deutsch</h2><p><br></p>')), '## Deutsch');
|
||||
});
|
||||
|
||||
test('a task list keeps its state through the DOM the editor builds', () => {
|
||||
const html = '<ul><li class="task"><input type="checkbox" contenteditable="false">offen</li>' +
|
||||
'<li class="task"><input type="checkbox" contenteditable="false" checked>fertig</li></ul>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig');
|
||||
});
|
||||
|
||||
test('the editor\'s trailing escape line never reaches the file', () => {
|
||||
// A table at the end of a contenteditable element is a dead end, so the
|
||||
// editor keeps an empty paragraph after it. It must serialize to nothing,
|
||||
// or every note with a table would grow a blank line on each save.
|
||||
const html = markdownToHtml('| a | b |\n| --- | --- |\n| 1 | 2 |') + '<p><br></p>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |');
|
||||
});
|
||||
|
||||
test('an empty cell stays an empty cell', () => {
|
||||
// The editor puts a <br> in blank cells so the caret can reach them.
|
||||
unchanged('| a | b |\n| --- | --- |\n| | 2 |');
|
||||
assert.match(markdownToHtml('| a |\n| --- |\n| |'), /<td><br><\/td>/);
|
||||
});
|
||||
|
||||
test('a row the editor appended round-trips', () => {
|
||||
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead>' +
|
||||
'<tbody><tr><td>1</td><td>2</td></tr><tr><td><br></td><td><br></td></tr></tbody></table>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
|
||||
});
|
||||
|
||||
test('a paste from Apple Notes keeps its structure and is not all bold', () => {
|
||||
// What WebKit actually puts on the clipboard: one wrapper span carrying the
|
||||
// *computed* style of everything copied — including `font-weight: 700` —
|
||||
// with the real blocks nested inside it. Read naively that makes the whole
|
||||
// note bold and flattens every line into one paragraph.
|
||||
const html =
|
||||
'<meta charset="UTF-8"><span style="color: rgb(0, 0, 0); font-family: Helvetica; ' +
|
||||
'font-size: 16px; font-weight: 700; text-align: start; -webkit-text-stroke-width: 0px; ' +
|
||||
'display: inline !important; float: none;">' +
|
||||
'<div><b>Erörterung</b></div><div><br></div><div>These, Argument, Fazit</div>' +
|
||||
'<ul><li>Gegenargument nicht vergessen</li><li>Fazit knapp halten</li></ul></span>';
|
||||
assert.equal(
|
||||
markdownFromDom(parseHtml(html)),
|
||||
'**Erörterung**\n\nThese, Argument, Fazit\n\n- Gegenargument nicht vergessen\n- Fazit knapp halten',
|
||||
);
|
||||
});
|
||||
|
||||
test('a container\'s font never swallows the blocks inside it', () => {
|
||||
// The general rule behind the case above: an element holding blocks is a
|
||||
// container whatever its tag, and a container's style is not emphasis.
|
||||
const html = '<span style="font-weight: bold"><h2>Deutsch</h2><p>Text</p></span>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '## Deutsch\n\nText');
|
||||
});
|
||||
|
||||
test('a styled span around a single run is still emphasis', () => {
|
||||
// The case the style check exists for, which must keep working.
|
||||
assert.equal(markdownFromDom(parseHtml('<p><span style="font-weight: 700">fett</span> rest</p>')), '**fett** rest');
|
||||
assert.equal(markdownFromDom(parseHtml('<p><span style="font-style: italic">kursiv</span></p>')), '_kursiv_');
|
||||
});
|
||||
|
||||
test('no word is ever lost, whatever the markup', () => {
|
||||
// The property that matters more than any particular shape: a note is
|
||||
// allowed to lose its formatting, never its words.
|
||||
const html =
|
||||
'<div><span style="font-weight:700"><div>Erste Zeile</div>' +
|
||||
'<blockquote><span><p>Zitat</p></span></blockquote>' +
|
||||
'<table><tr><td><div>Zelle</div></td></tr></table></span></div>';
|
||||
const markdown = markdownFromDom(parseHtml(html));
|
||||
for (const word of ['Erste', 'Zeile', 'Zitat', 'Zelle']) {
|
||||
assert.ok(markdown.includes(word), `lost "${word}" in: ${markdown}`);
|
||||
}
|
||||
});
|
||||
@@ -78,6 +78,14 @@ describe('convertAppleNote', () => {
|
||||
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07');
|
||||
});
|
||||
|
||||
it('gives no subject to a note the export could not place', () => {
|
||||
// What `--folder Schule` produces for a note sitting loose in Schule:
|
||||
// the path relative to the folder asked for, which is nothing. A subject
|
||||
// of "Schule" would file every such note under a subject that is not one.
|
||||
assert.equal(convertAppleNote({ ...note, folder: '' }).subject, undefined);
|
||||
assert.equal(convertAppleNote({ ...note, folder: ' ' }).subject, undefined);
|
||||
});
|
||||
|
||||
it('does not repeat the title as the first line of the body', () => {
|
||||
const converted = convertAppleNote(note);
|
||||
assert.equal(converted.title, 'Erörterung');
|
||||
|
||||
112
test/mini-dom.ts
Normal file
112
test/mini-dom.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Just enough DOM to run the notes editor's serializer under `node --test`.
|
||||
*
|
||||
* `markdownFromDom` walks a tree with `nodeType`, `nodeName`, `childNodes`,
|
||||
* `textContent` and `getAttribute` — nothing else, and nothing that writes —
|
||||
* which is what lets the round-trip be tested here rather than only in a
|
||||
* browser. The round-trip is the part of the editor that can quietly destroy a
|
||||
* lesson's notes, so testing it is not optional; adding a headless browser or a
|
||||
* DOM library to do it would be a much larger dependency than these 60 lines.
|
||||
*
|
||||
* It parses only the HTML `markdownToHtml` emits: known tags, quoted
|
||||
* attributes, no comments, no CDATA, no implied end tags.
|
||||
*/
|
||||
|
||||
export interface MiniNode {
|
||||
nodeType: 1 | 3;
|
||||
nodeName: string;
|
||||
childNodes: MiniNode[];
|
||||
textContent: string;
|
||||
getAttribute(name: string): string | null;
|
||||
}
|
||||
|
||||
const VOID = new Set(['BR', 'HR', 'INPUT', 'IMG', 'META', 'LINK']);
|
||||
const TAG = /<(\/)?([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/)?>/g;
|
||||
|
||||
class Element implements MiniNode {
|
||||
readonly nodeType = 1 as const;
|
||||
readonly nodeName: string;
|
||||
readonly childNodes: MiniNode[] = [];
|
||||
private readonly attributes: Map<string, string>;
|
||||
|
||||
constructor(name: string, attributes: Map<string, string>) {
|
||||
this.nodeName = name.toUpperCase();
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.childNodes.map((child) => child.textContent).join('');
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return this.attributes.get(name.toLowerCase()) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class Text implements MiniNode {
|
||||
readonly nodeType = 3 as const;
|
||||
readonly nodeName = '#text';
|
||||
readonly childNodes: MiniNode[] = [];
|
||||
textContent: string;
|
||||
|
||||
constructor(value: string) {
|
||||
this.textContent = value;
|
||||
}
|
||||
|
||||
getAttribute(): null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A fragment whose `childNodes` are the parsed top-level nodes. */
|
||||
export function parseHtml(html: string): MiniNode {
|
||||
const root = new Element('body', new Map());
|
||||
const stack: Element[] = [root];
|
||||
let index = 0;
|
||||
|
||||
TAG.lastIndex = 0;
|
||||
for (let match = TAG.exec(html); match; match = TAG.exec(html)) {
|
||||
if (match.index > index) addText(stack.at(-1)!, html.slice(index, match.index));
|
||||
index = TAG.lastIndex;
|
||||
|
||||
const name = match[2]!.toUpperCase();
|
||||
if (match[1]) {
|
||||
// A close tag: unwind to it, ignoring one that was never opened.
|
||||
const at = stack.findLastIndex((element) => element.nodeName === name);
|
||||
if (at > 0) stack.length = at;
|
||||
continue;
|
||||
}
|
||||
|
||||
const element = new Element(name, attributesOf(match[3] ?? ''));
|
||||
stack.at(-1)!.childNodes.push(element);
|
||||
if (!VOID.has(name) && !match[4]) stack.push(element);
|
||||
}
|
||||
if (index < html.length) addText(stack.at(-1)!, html.slice(index));
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function addText(parent: Element, value: string): void {
|
||||
if (!value) return;
|
||||
parent.childNodes.push(new Text(decode(value)));
|
||||
}
|
||||
|
||||
function attributesOf(source: string): Map<string, string> {
|
||||
const attributes = new Map<string, string>();
|
||||
const pattern = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
||||
for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
|
||||
// A bare attribute (`checked`) is present with an empty value, which is
|
||||
// what the DOM reports too.
|
||||
attributes.set(match[1]!.toLowerCase(), decode(match[2] ?? match[3] ?? match[4] ?? ''));
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function decode(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
subjectFromHeading,
|
||||
notePathFor,
|
||||
parseNote,
|
||||
plainText,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
renderNote,
|
||||
searchNotes,
|
||||
splitFrontmatter,
|
||||
writeNote,
|
||||
} from '../src/core/notes.ts';
|
||||
@@ -361,3 +363,130 @@ describe('filterNotes', () => {
|
||||
assert.deepEqual(titles, ['B', 'lose']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNotes', () => {
|
||||
const day = parseNote(
|
||||
'2026/2026-09-04.md',
|
||||
[
|
||||
'---',
|
||||
'title: Freitag, 04.09.2026',
|
||||
'date: 2026-09-04',
|
||||
'---',
|
||||
'',
|
||||
'## 1. LF10 — 08:00–08:45',
|
||||
'',
|
||||
'Normalisierung: erste, zweite und dritte Normalform.',
|
||||
'',
|
||||
'## 2. Deutsch — 08:50–09:35',
|
||||
'',
|
||||
'Erörterung: These, Argument, Fazit.',
|
||||
].join('\n'),
|
||||
new Date(),
|
||||
0,
|
||||
);
|
||||
const loose = parseNote(
|
||||
'Deutsch/2026-09-15 Aufbau.md',
|
||||
['---', 'title: Aufbau', 'subject: Deutsch', '---', '', 'Gegenargument nicht vergessen.'].join('\n'),
|
||||
new Date(),
|
||||
0,
|
||||
);
|
||||
|
||||
it('answers with the lesson, not the day', () => {
|
||||
const [hit] = searchNotes([day], 'Normalform');
|
||||
assert.equal(hit?.heading, '1. LF10 — 08:00–08:45');
|
||||
assert.equal(hit?.subject, 'LF10');
|
||||
assert.equal(hit?.date, '2026-09-04');
|
||||
});
|
||||
|
||||
it('does not report a day because another of its lessons matched', () => {
|
||||
// The whole reason a day note is searched per section: "Erörterung" is
|
||||
// Deutsch, and reporting it as LF10 would be worse than not finding it.
|
||||
const hits = searchNotes([day], 'Erörterung');
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0]?.subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('ignores case and accents', () => {
|
||||
assert.equal(searchNotes([day], 'erorterung').length, 1);
|
||||
assert.equal(searchNotes([day], 'ERÖRTERUNG').length, 1);
|
||||
});
|
||||
|
||||
it('needs every word, in any order', () => {
|
||||
assert.equal(searchNotes([day], 'normalform erste').length, 1);
|
||||
assert.equal(searchNotes([day], 'normalform erörterung').length, 0);
|
||||
});
|
||||
|
||||
it('searches the heading itself, so a subject finds its lessons', () => {
|
||||
assert.equal(searchNotes([day], 'LF10').length, 1);
|
||||
});
|
||||
|
||||
it('treats a note without lessons as one piece', () => {
|
||||
const [hit] = searchNotes([loose], 'Gegenargument');
|
||||
assert.equal(hit?.path, 'Deutsch/2026-09-15 Aufbau.md');
|
||||
assert.equal(hit?.heading, undefined);
|
||||
assert.equal(hit?.subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('carries a snippet worth reading', () => {
|
||||
const [hit] = searchNotes([day], 'Normalform');
|
||||
assert.match(hit!.snippet, /erste, zweite und dritte Normalform/);
|
||||
});
|
||||
|
||||
it('shows the snippet as prose, not as Markdown', () => {
|
||||
const table = parseNote(
|
||||
'2026/2026-09-05.md',
|
||||
[
|
||||
'---',
|
||||
'title: Samstag',
|
||||
'---',
|
||||
'',
|
||||
'## LF10',
|
||||
'',
|
||||
'| Normalform | Bedingung |',
|
||||
'| --- | --- |',
|
||||
'| 1NF | atomare Werte |',
|
||||
].join('\n'),
|
||||
new Date(),
|
||||
0,
|
||||
);
|
||||
const [hit] = searchNotes([table], '1NF');
|
||||
// The pipes and the `|---|` rule say nothing to someone reading a result.
|
||||
assert.equal(hit?.snippet, '1NF · atomare Werte');
|
||||
});
|
||||
|
||||
it('strips the markers from a bullet or a heading in the snippet', () => {
|
||||
const [hit] = searchNotes([day], 'Argument');
|
||||
assert.equal(hit?.snippet.includes('**'), false);
|
||||
});
|
||||
|
||||
it('finds nothing for an empty query rather than everything', () => {
|
||||
assert.deepEqual(searchNotes([day, loose], ' '), []);
|
||||
});
|
||||
|
||||
it('stops at the limit', () => {
|
||||
assert.equal(searchNotes([day], 'e', 1).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainText', () => {
|
||||
it('reads a table row as its cells', () => {
|
||||
assert.equal(plainText('| 1NF | atomare Werte |'), '1NF · atomare Werte');
|
||||
assert.equal(plainText('| --- | --- |'), '');
|
||||
});
|
||||
|
||||
it('drops the markers but keeps the words', () => {
|
||||
assert.equal(plainText('- **These**, Argument, _Fazit_'), 'These, Argument, Fazit');
|
||||
assert.equal(plainText('## 1. Deutsch'), '1. Deutsch');
|
||||
assert.equal(plainText('> Merksatz'), 'Merksatz');
|
||||
assert.equal(plainText('- [x] erledigt'), 'erledigt');
|
||||
});
|
||||
|
||||
it('leaves a word with an underscore in it alone', () => {
|
||||
assert.equal(plainText('snake_case_name bleibt'), 'snake_case_name bleibt');
|
||||
});
|
||||
|
||||
it('reads a link as its label and an escape as its character', () => {
|
||||
assert.equal(plainText('[Arbeitsblatt](https://example.org/ab.pdf)'), 'Arbeitsblatt');
|
||||
assert.equal(plainText('2 \\* 3'), '2 * 3');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user