11 Commits

Author SHA1 Message Date
MechaCat02
27add08659 Merge: the panes scroll, the page does not
A hundred notes in the list made the page scroll and the editor grow to
the height of every note put together. `body` had a minimum height
where it needed a definite one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:25:39 +02:00
MechaCat02
bafad9b72d Scroll the note list, not the page
With a hundred notes in it 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: 12419px next to
a 700px window.

The cause was one word. `body` had `min-height: 100dvh`, which 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. `min-height:
0` on the items lets them shrink but nothing was telling them what to
shrink *to*. The page now has a definite height and no scrolling of its
own, so the two panes do the scrolling, which is what they were built
for. The settings screen and the login screen scroll themselves, having
no pane.

While there: the editor's floor drops from 12rem to 6rem so a landscape
phone with its keyboard up keeps the toolbar and the save button on
screen rather than overflowing, and the login card centres with `margin:
auto` rather than `justify-content`, because a centred flex item in a
scrolling container cannot be scrolled back to once it overflows the
top.

Reproduced with 123 seeded day notes and measured in Firefox at 1100px
and 390px, before and after: page 12684px → 700px, list scrolls itself,
editor 12419px → 435px, toolbar and save button still on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:25:34 +02:00
MechaCat02
52f420fc11 Merge: the notes screen as a notes app
A list of notes beside the note being written, with the search box at
the top of the list and its results standing in for the list. On a phone
the list comes first and a note pushes over it.

With it, one rule for showing a note rather than editing it —
`plainText` — which the row preview and the search snippet had each
half-copied, badly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:10:49 +02:00
MechaCat02
48c6cf39d5 Lay the notes screen out like a notes app
The list of notes and the note being written are one screen now, two
panes: the notes on the left, the open one on the right, side by side
where there is room and one at a time on a phone, where the back button
returns to the list.

The search box moved into the top of that list, and its results *are*
the list — searching is a way of finding a note, not a separate place to
be, and a tab for it was a tab too many. Emptying the box brings the
whole list back. Opening a hit opens that day at the lesson that
matched, rather than at the top of a day with six of them.

A row has to say what the note holds, so the listing carries it: the
subjects a day covers, how many lessons, and the first line actually
written in it. One request for the whole list rather than one per note.

`plainText` is now one rule in one place for wherever a note is shown
rather than edited — the search snippet and the list row both went
through their own half-copy of it, and the row's copy rendered a table
as `| | |` and left `_Fazit_` wearing its markers. It strips one leading
marker, not each in turn, because `## 1. Deutsch` keeps its lesson
number and the list rule was eating it.

Driven in Firefox at both widths: the list, the search, opening a hit,
the jump to the lesson, and the phone's list-then-note. 390 unit tests,
116/117 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:22:47 +02:00
MechaCat02
5db098b67f Merge: a pasted note kept whole, and search over the notes
The paste from Apple Notes was losing most of a note and bolding the
rest, which the Markdown view then showed as missing text. An element
holding blocks is now a block whatever its tag, and a container's style
is not emphasis.

With it, the Suche tab: full text over the note files rather than the
index, so a lesson written this morning is findable this morning, and a
result names the lesson rather than the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:09:53 +02:00
MechaCat02
e129fd4b0a Keep a pasted note whole, and search the notes from the app
Two things found by using this on real notes.

**The paste.** Copying out of Apple Notes put most of the note on the
floor. WebKit wraps a copied selection in a single span carrying the
computed style of everything in it — `font-weight: 700` included — with
the real blocks nested inside. The serializer read that span as inline,
so every line collapsed into one paragraph and every word came out bold;
switching to the Markdown view then showed what little had survived,
which is what "most of the text was gone" was. And because the boldness
came from a foreign span's style rather than a tag, the bold button
could not remove it.

The rule now is that an element holding blocks is a block whatever its
tag, and that a container's style is not emphasis — only a span wrapping
a single run of text is. A paste this editor cannot read at all (some
engines withhold the clipboard from the event) is tidied afterwards
instead, but only if something actually arrived, so an empty paste still
costs nothing.

**The search.** A Suche tab over the user's own notes, reading the files
rather than the index: notes reach the 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. A result names the
lesson it matched in, not the day, for the same reason the index indexes
day notes per section. Tapping one opens that day in the editor.

Driven in Firefox against the real app with a proxied session: the
paste, six switches between the two views, bold and unbold on pasted
text, the search, and opening a result. 386 unit tests, 114/115 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:09:47 +02:00
MechaCat02
f545b7cf54 Export Apple Notes to stdout, and one folder at a time
`console.log` in JXA writes to standard error, not standard output, so
`> notes.ndjson` produced an empty file while every note scrolled past
on the terminal. Both streams now go through NSFileHandle, which is the
only way to be sure which one you are on; the comment says so, because
the next person will reach for console.log too.

`--folder` was a substring match applied after each note's body had
already been read. It now matches a whole path segment — `Schule` takes
Schule and everything under it and leaves Musikschule alone — and is
checked before the body, which is the one expensive property and is what
makes a large library take minutes.

The path is recorded relative to the folder asked for, because the
import reads its last segment as the subject: `Schule/Deutsch` has to
arrive as `Deutsch`, and a note loose in `Schule` has to arrive with no
subject at all rather than one called "Schule".

Not run: this needs a Mac with Notes, and there is none here. The JXA
stream behaviour is the documented one and the folder logic is plain
string work, but the script itself is still unexercised.
2026-09-20 19:07:20 +02:00
MechaCat02
6173b519db Merge: write the notes as formatted text, not Markdown syntax
The notes app showed a textarea of raw Markdown, which is the wrong
thing to hand someone taking notes during a lesson. It now shows the
note formatted with a toolbar above it, while the file on disk stays
Markdown — that is what the indexer reads and what outlives this app.

The property the whole thing rests on is that the round trip settles:
one pass may tidy a note, a second must change nothing. A note the
editor cannot hold unchanged opens in the Markdown view and says so
rather than being quietly reduced.

Includes the table dead end found on the first real use: a table at the
end of a contenteditable element cannot be typed past, in any engine.
2026-09-20 18:43:08 +02:00
MechaCat02
c259d97f04 Do not let a table be the end of a note
A table, code block, quote or list as the last element 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, so the note simply cannot be
continued below it. Every engine behaves this way. The editor now keeps
an empty paragraph after such a block — it serializes to nothing, so it
never reaches the file, which a test pins down.

Tables got the rest of what they were missing while the cause was in
view: Tab walks the cells and a Tab out of the last one adds a row, the
toolbar button adds a row when the cursor is already in a table (a phone
has no Tab key, and it renames itself so it says which it will do), and
Ctrl/Cmd+Enter opens a paragraph after whatever block the cursor is in.
Empty cells are given a break, because a `<td></td>` with nothing in it
cannot be clicked into in Gecko — a blank cell was uneditable.

Driven in Firefox against a page that reproduces the dead end first.
2026-09-20 13:06:25 +02:00
MechaCat02
534b1b0f58 Write notes as formatted text, store them as Markdown
The editor was a textarea holding raw Markdown, which is the wrong thing
to hand someone taking notes during a lesson: nobody types `##` and `**`
while a teacher is talking. It now shows the note formatted and puts a
toolbar above it — headings, bold, lists, tick boxes, quotes, links,
tables — while the file on disk stays exactly what it was, because that
is what the indexer reads and what outlives this app.

`markdown.js` is the whole translation: `markdownToHtml` on the way in,
`markdownFromDom` on the way out. The property that matters is that the
round trip settles — one pass may tidy a note, a second must change
nothing — because these notes are the only record of what was said in
the room and there is nothing to restore a lossy save from. `editor.js`
checks exactly that before opening a note formatted, and a note it
cannot hold unchanged opens in the Markdown view and says so instead of
being quietly reduced.

No editor library: the content security policy allows no outside script
and the app has no bundler, so this is `contenteditable` and
`execCommand` with a tolerant serializer behind it — an element it does
not model keeps its words and loses its tag. Pasted HTML is converted to
Markdown before it reaches the document, which is the one place where
sanitising and formatting are the same operation.

Tested against `test/mini-dom.ts`, sixty lines of read-only DOM, rather
than a headless browser or a DOM dependency; the toolbar itself was
driven by hand in Firefox. WebKit has still never run it.
2026-09-19 21:24:42 +02:00
MechaCat02
5c0b658855 Merge: the user's own lesson notes, the class register, and an app to write them in
Three sources instead of two. Schulcloud has the material, WebUntis has
the schedule and now the class register, and the notes have what the
teacher actually stressed — which was the half nothing here could reach.

- core/notes.ts, the notes as Markdown files; a note with ## headings is
  a school day and is indexed per lesson, not whole.
- core/untis-history.ts, the class register read backwards — one call per
  lesson series covers a term, because getLessonTopic2017 answers per
  series rather than per period.
- core/day-note.ts and http/app*, the app at /app: a login, a day editor
  whose headings come from WebUntis, and a settings page for the
  Schulcloud token.
- scripts/export-apple-notes.js and `schulcloud note import`, the way out
  of Notes.app, which has no export of its own.

docs/NOTES.md is the guide, docs/DEPLOY-NOTES.md the rollout runbook.
2026-09-19 17:32:38 +02:00
18 changed files with 2942 additions and 61 deletions

View File

@@ -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 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. 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: Store tests need a database and skip without one:
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on `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 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 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 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 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. here writes** — see Invariants. `docs/NOTES.md` is the guide.
- **`untis-history.ts`** — the class register read backwards, which is what - **`untis-history.ts`** — the class register read backwards, which is what
puts "what did we actually cover" into the search index. Its whole reason 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` **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 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 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 - **`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 credential from everything else here: a password a person types, not a token a
program was configured with. scrypt at startup, a signed `HttpOnly` / program was configured with. scrypt at startup, a signed `HttpOnly` /

View File

@@ -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, indexed as its own lesson, so a search answers "my own note, Deutsch,
18.09.2026" rather than "Friday". 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 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 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. it adds to the home screen and opens standalone.

View File

@@ -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`. | | **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. | | **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 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: [NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac:
```bash ```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 --dry-run # look first
schulcloud note import notes.ndjson 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 Import **once**. Re-running creates second copies, because the importer cannot
tell an edited note from a new one with the same title. 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 | | `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 | | 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 | | `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` | | 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 | | `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 | | `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 |

View File

@@ -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 standalone, which is the difference between "a page I have to find" and "the
thing I open in a free period". 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** - 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 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 `add_note` — the save is refused and you are asked which version wins. It
never silently overwrites. 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:0008: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 **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 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 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 ```bash
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson 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 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"); without it every note comes back empty.
Notes folder.
**`--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: Then look at what it would do, and do it:

View File

@@ -5,7 +5,7 @@
* Run this **on the Mac that has the notes**: * 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 > 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`. * 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 * 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 * properties one note at a time is what keeps a locked note from aborting the
* run rather than a language preference. * 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('stdlib');
ObjC.import('Foundation');
function run(argv) { function run(argv) {
const options = parseArguments(argv); const options = parseArguments(argv);
@@ -41,23 +47,62 @@ function run(argv) {
let skipped = 0; let skipped = 0;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const note = items[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) { if (!record) {
skipped++; skipped++;
continue; 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. // 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++; written++;
} }
// stderr, so it never lands in the file being redirected. // 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 ''; 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 { try {
// Read the body first: it is the property a locked note refuses, and // Read the body first: it is the property a locked note refuses, and
// there is no point building a record we cannot fill. // there is no point building a record we cannot fill.
@@ -66,7 +111,7 @@ function readNote(note) {
id: safe(function () { return note.id(); }, ''), id: safe(function () { return note.id(); }, ''),
name: safe(function () { return note.name(); }, ''), name: safe(function () { return note.name(); }, ''),
body: body || '', body: body || '',
folder: safe(function () { return folderPath(note.container()); }, ''), folder: folder,
created: safe(function () { return iso(note.creationDate()); }, ''), created: safe(function () { return iso(note.creationDate()); }, ''),
modified: safe(function () { return iso(note.modificationDate()); }, ''), modified: safe(function () { return iso(note.modificationDate()); }, ''),
}; };
@@ -77,6 +122,7 @@ function readNote(note) {
id: safe(function () { return note.id(); }, ''), id: safe(function () { return note.id(); }, ''),
name: safe(function () { return note.name(); }, '(unreadable)'), name: safe(function () { return note.name(); }, '(unreadable)'),
body: '', body: '',
folder: folder,
error: String(error), error: String(error),
}; };
} }
@@ -128,10 +174,18 @@ function parseArguments(argv) {
return options; 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) { function log(message) {
$.NSFileHandle.fileHandleWithStandardError.writeData( write($.NSFileHandle.fileHandleWithStandardError, message);
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding), }
);
function write(handle, text) {
handle.writeData($.NSString.alloc.initWithUTF8String(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
} }
function fail(message) { function fail(message) {

View File

@@ -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)); check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN));
const assets = await Promise.all( // Every file the shell asks for, including the two modules the editor is
['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)), // 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 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(); const anonymousSession = await (await fetch(`${root}/app/session`)).json();
check('session says "not logged in" rather than failing', anonymousSession.authenticated === false); 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)`, `${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 }); 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}`); check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`);

View File

@@ -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 ------------------------------------------------------------- // --- helpers -------------------------------------------------------------
function isNoteFile(name: string): boolean { function isNoteFile(name: string): boolean {

View File

@@ -20,7 +20,11 @@ import {
NoteNotFound, NoteNotFound,
filterNotes, filterNotes,
readNoteAt, readNoteAt,
plainText,
readNotes, readNotes,
noteSubjects,
noteSections,
searchNotes,
replaceNote, replaceNote,
writeNote, writeNote,
} from '../core/notes.ts'; } 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 * index and mirror, `/token` only to the server's own token, and every upstream
* call either triggers is a GET. * 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 = const NO_NOTES_DIR =
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.'; '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, writable: services.config.notesWritable,
count: notes.length, count: notes.length,
// The body is dropped from a listing: a term of notes is megabytes, // The body is dropped from a listing: a term of notes is megabytes,
// and the CLI asks for the ones it wants by path. // and the CLI asks for the ones it wants by path. What replaces it
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest), // 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) { } catch (error) {
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message }); 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) => { router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
const root = services.config.notesDir; const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR }); if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });

View File

@@ -21,6 +21,10 @@ import { createWebAuth, isSecureRequest, sessionAuth, type WebAuth } from './web
* security policy forbids inline script anyway — so the only thing gained by * 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 * embedding them would be a build step that no longer copies them, and the
* only thing lost would be every tool that reads them. * 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. */ /** 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' }, '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
'/app.css': { file: 'app.css', type: 'text/css; 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' }, '/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' }, '/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' },
'/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' }, '/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 // 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. // 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) => { router.get(
const entry = ASSETS[req.path] ?? ASSETS['/']!; /^\/(index\.html|app\.css|app\.js|editor\.js|markdown\.js|icon\.svg|manifest\.webmanifest)?$/,
res.type(entry.type).send(asset(entry.file)); (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) => { router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => {
const password = (req.body as { password?: unknown } | undefined)?.password; const password = (req.body as { password?: unknown } | undefined)?.password;

View File

@@ -36,18 +36,34 @@
* { box-sizing: border-box; } * { 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 { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
/* Fills the viewport on a phone, where 100vh lies about the toolbar. */ height: 100vh;
min-height: 100dvh; /* dvh where it exists: 100vh lies about the height while a phone's toolbar
is on screen. */
height: 100dvh;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); 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; } [hidden] { display: none !important; }
/* --- chrome ------------------------------------------------------------ */ /* --- 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; } .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 ------------------------------------------------------- */ /* --- the day bar ------------------------------------------------------- */
.daybar { display: flex; align-items: center; gap: 0.5rem; } .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 strong { font-size: 1.05rem; }
.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; } .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 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 { textarea {
flex: 1; flex: 1;
min-height: 12rem; min-height: 6rem;
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.5rem; border-radius: 0.5rem;
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
/* Monospace: the notes are Markdown, and headings and list markers have to /* Monospace: this is the Markdown view, and headings and list markers have
line up to be read back as structure. */ to line up to be read back as structure. */
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.5; line-height: 1.5;
@@ -174,8 +416,11 @@ input[type="password"], input[type="text"] {
font: inherit; font: inherit;
} }
#login { justify-content: center; } /* `margin: auto` rather than `justify-content: center`: a centred flex item in
#login .card { width: min(24rem, 100%); align-self: center; } 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; } #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; } .steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }

View File

@@ -1,4 +1,5 @@
'use strict'; import { createEditor } from './editor.js';
import { markdownToHtml } from './markdown.js';
/* /*
* The notes app. * The notes app.
@@ -20,6 +21,10 @@
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert", * - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
* "Offline — lokal gesichert". A silent editor over a flaky connection is * "Offline — lokal gesichert". A silent editor over a flaky connection is
* indistinguishable from one that is losing your work. * 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; const AUTOSAVE_MS = 2500;
@@ -35,13 +40,28 @@ const ui = {
tabSettings: document.getElementById('tab-settings'), tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'), viewNotes: document.getElementById('view-notes'),
viewSettings: document.getElementById('view-settings'), 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'), prev: document.getElementById('prev'),
next: document.getElementById('next'), next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'), dayTitle: document.getElementById('day-title'),
dayDate: document.getElementById('day-date'), dayDate: document.getElementById('day-date'),
dayStatus: document.getElementById('day-status'), dayStatus: document.getElementById('day-status'),
conflict: document.getElementById('day-conflict'), conflict: document.getElementById('day-conflict'),
toolbar: document.getElementById('toolbar'),
editor: document.getElementById('editor'), editor: document.getElementById('editor'),
source: document.getElementById('source'),
editorHint: document.getElementById('editor-hint'),
save: document.getElementById('save'), save: document.getElementById('save'),
fill: document.getElementById('fill'), fill: document.getElementById('fill'),
lessonsHint: document.getElementById('lessons-hint'), lessonsHint: document.getElementById('lessons-hint'),
@@ -68,6 +88,28 @@ const day = {
timer: 0, 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 ------------------------------------------------------------ // --- plumbing ------------------------------------------------------------
async function api(path, options) { async function api(path, options) {
@@ -129,9 +171,10 @@ function draftKey(date) {
return DRAFT_PREFIX + date; return DRAFT_PREFIX + date;
} }
function saveDraft() { function saveDraft(text) {
try { 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) { } catch (error) {
// A full or disabled localStorage must not break typing; the server copy // A full or disabled localStorage must not break typing; the server copy
// is still the real one. // is still the real one.
@@ -162,6 +205,18 @@ function setStatus(message, kind) {
ui.dayStatus.className = 'status' + (kind ? ' ' + 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) { async function loadDay(date) {
// Anything unsaved goes to the draft before the view moves, or switching // Anything unsaved goes to the draft before the view moves, or switching
// days would be a way to lose a lesson. // days would be a way to lose a lesson.
@@ -172,7 +227,8 @@ async function loadDay(date) {
day.conflicted = false; day.conflicted = false;
ui.conflict.hidden = true; ui.conflict.hidden = true;
ui.dayDate.value = date; ui.dayDate.value = date;
ui.editor.disabled = true; editor.setEnabled(false);
hint('');
setStatus('Wird geladen …'); setStatus('Wird geladen …');
let info; let info;
@@ -185,8 +241,8 @@ async function loadDay(date) {
// no notes at all — and reporting that as "offline" would send someone // no notes at all — and reporting that as "offline" would send someone
// looking at their signal instead of at NOTES_DIR. // looking at their signal instead of at NOTES_DIR.
if (error.status) { if (error.status) {
ui.editor.value = ''; editor.setMarkdown('');
ui.editor.disabled = true; editor.setEnabled(false);
setStatus(error.message, 'error'); setStatus(error.message, 'error');
ui.lessonsHint.textContent = ''; ui.lessonsHint.textContent = '';
return; return;
@@ -194,8 +250,8 @@ async function loadDay(date) {
// No status: the request never arrived. Fall back to whatever this device // 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. // has, rather than an empty editor that looks like a day with no notes.
const draft = readDraft(date); const draft = readDraft(date);
ui.editor.disabled = false; editor.setEnabled(true);
ui.editor.value = draft ? draft.text : ''; editor.setMarkdown(draft ? draft.text : '');
day.saved = ''; day.saved = '';
day.modifiedAt = null; day.modifiedAt = null;
day.dirty = Boolean(draft); 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. // is just the last save echoed back and offering it would be noise.
const useDraft = draft && draft.text !== server && draft.text.trim() !== ''; const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
ui.editor.value = useDraft ? draft.text : server; editor.setEnabled(true);
ui.editor.disabled = false; // The server's text first, and what the editor makes of it is the baseline.
day.saved = info.exists ? info.text : ''; // Opening a note the editor would tidy — a table typed unevenly, `*` for
day.dirty = ui.editor.value !== day.saved; // 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) { if (useDraft) {
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn'); setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
@@ -234,6 +305,7 @@ async function loadDay(date) {
describeLessons(info); describeLessons(info);
ui.fill.hidden = !day.missing; ui.fill.hidden = !day.missing;
markOpenRow();
} }
function describeLessons(info) { function describeLessons(info) {
@@ -250,8 +322,9 @@ function describeLessons(info) {
} }
function markDirty() { function markDirty() {
day.dirty = ui.editor.value !== day.saved; const text = editor.getMarkdown();
saveDraft(); day.dirty = text !== day.saved;
saveDraft(text);
if (day.conflicted) return; if (day.conflicted) return;
if (day.dirty) setStatus('Nicht gespeichert …'); if (day.dirty) setStatus('Nicht gespeichert …');
window.clearTimeout(day.timer); window.clearTimeout(day.timer);
@@ -261,7 +334,7 @@ function markDirty() {
async function saveDay(automatic) { async function saveDay(automatic) {
window.clearTimeout(day.timer); window.clearTimeout(day.timer);
if (!day.dirty && automatic) return; if (!day.dirty && automatic) return;
const text = ui.editor.value; const text = editor.getMarkdown();
setStatus('Wird gespeichert …'); setStatus('Wird gespeichert …');
try { try {
@@ -272,9 +345,12 @@ async function saveDay(automatic) {
// with, and sending null would look like "I saw no version". // with, and sending null would look like "I saw no version".
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}), ...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
}); });
const isNew = !day.modifiedAt;
day.saved = text; day.saved = text;
day.modifiedAt = result.modifiedAt; day.modifiedAt = result.modifiedAt;
day.dirty = false; 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; day.conflicted = false;
ui.conflict.hidden = true; ui.conflict.hidden = true;
clearDraft(day.date); clearDraft(day.date);
@@ -371,6 +447,250 @@ function addFact(term, value) {
ui.serverState.append(dt, dd); 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 --------------------------------------------------------------- // --- views ---------------------------------------------------------------
function showLogin() { function showLogin() {
@@ -403,6 +723,8 @@ ui.loginForm.addEventListener('submit', async (event) => {
ui.password.value = ''; ui.password.value = '';
showApp(); showApp();
await loadDay(day.date); await loadDay(day.date);
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
await loadRail();
} catch (error) { } catch (error) {
ui.loginError.textContent = ui.loginError.textContent =
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.'; 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.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSettings.addEventListener('click', () => showTab('settings')); ui.tabSettings.addEventListener('click', () => showTab('settings'));
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1))); ui.searchForm.addEventListener('submit', (event) => {
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1))); event.preventDefault();
ui.dayDate.addEventListener('change', () => { void runSearch(ui.searchInput.value);
if (ui.dayDate.value) void loadDay(ui.dayDate.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.save.addEventListener('click', () => void saveDay(false));
ui.fill.addEventListener('click', () => { ui.fill.addEventListener('click', () => {
// Appended, never merged into place: the person's own text is not something // 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. // to reorder, and a heading in the wrong order is trivial to move.
const separator = ui.editor.value.trim() ? '\n\n' : ''; editor.append(day.missing);
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
day.missing = ''; day.missing = '';
ui.fill.hidden = true; ui.fill.hidden = true;
markDirty();
}); });
// A phone locking, the app going to the background, or the tab closing: all of // A phone locking, the app going to the background, or the tab closing: all of
@@ -487,4 +827,8 @@ void (async () => {
} }
showApp(); showApp();
await loadDay(day.date); 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
View 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>&nbsp;');
}
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>&nbsp;');
}
/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -32,7 +32,24 @@
</header> </header>
<!-- Notes: one school day per note, one heading per lesson. --> <!-- 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"> <div class="daybar">
<button type="button" id="prev" aria-label="Vorheriger Tag"></button> <button type="button" id="prev" aria-label="Vorheriger Tag"></button>
<div class="daybar-centre"> <div class="daybar-centre">
@@ -45,14 +62,53 @@
<p id="day-status" class="status" role="status" aria-live="polite"></p> <p id="day-status" class="status" role="status" aria-live="polite"></p>
<p id="day-conflict" class="conflict" role="alert" hidden></p> <p id="day-conflict" class="conflict" role="alert" hidden></p>
<textarea id="editor" spellcheck="true" autocapitalize="sentences" <!-- The toolbar writes the Markdown so nobody has to type it. Every button
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea> 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>&lt;&gt;</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">&nbsp;</button>
<button type="button" data-command="ol" aria-pressed="false" aria-label="Nummerierte Liste" title="Nummerierte Liste">1.&nbsp;</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"> <div class="actions">
<button type="button" id="save">Speichern</button> <button type="button" id="save">Speichern</button>
<button type="button" id="fill" hidden>Stunden ergänzen</button> <button type="button" id="fill" hidden>Stunden ergänzen</button>
<span id="lessons-hint" class="hint"></span> <span id="lessons-hint" class="hint"></span>
</div> </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> </main>
<!-- Settings: the Schulcloud token, and what the server is doing. --> <!-- Settings: the Schulcloud token, and what the server is doing. -->
@@ -82,6 +138,6 @@
</main> </main>
</div> </div>
<script src="app.js"></script> <script type="module" src="app.js"></script>
</body> </body>
</html> </html>

687
src/http/app/markdown.js Normal file
View 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, '&quot;');
}
function escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// --- 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
View 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:0008: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, /&lt;script&gt;/);
});
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:0008: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:5009: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:0008:45 · MEI · R 204',
'## 3. LF07 — 10:3511:20 · Sb · R 108 (Vertretung)',
'## 5. Englisch — 12:1513: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}`);
}
});

View File

@@ -78,6 +78,14 @@ describe('convertAppleNote', () => {
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07'); 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', () => { it('does not repeat the title as the first line of the body', () => {
const converted = convertAppleNote(note); const converted = convertAppleNote(note);
assert.equal(converted.title, 'Erörterung'); assert.equal(converted.title, 'Erörterung');

112
test/mini-dom.ts Normal file
View 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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
}

View File

@@ -14,9 +14,11 @@ import {
subjectFromHeading, subjectFromHeading,
notePathFor, notePathFor,
parseNote, parseNote,
plainText,
readNoteAt, readNoteAt,
readNotes, readNotes,
renderNote, renderNote,
searchNotes,
splitFrontmatter, splitFrontmatter,
writeNote, writeNote,
} from '../src/core/notes.ts'; } from '../src/core/notes.ts';
@@ -361,3 +363,130 @@ describe('filterNotes', () => {
assert.deepEqual(titles, ['B', 'lose']); 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:0008:45',
'',
'Normalisierung: erste, zweite und dritte Normalform.',
'',
'## 2. Deutsch — 08:5009: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:0008: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');
});
});