diff --git a/CLAUDE.md b/CLAUDE.md index 050035e..0fcbed6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,11 @@ are stale — they were last taken before the notes and class-register work, and could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live session has lapsed — check the container's keepalive log before suspecting code. +The editor's Markdown round trip is unit-tested; the **browser** side of +`editor.js` is not, because nothing here runs one. It was checked by hand in +Firefox against a page that drives the toolbar — WebKit, which is the engine on +the phone this is written on, has still never run it. + Store tests need a database and skip without one: `TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on purpose — the generation/diff semantics are entirely SQL, so a mock would test @@ -145,7 +150,20 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync **files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs` and read relative to `import.meta.dirname` — real HTML, CSS and JS that an editor and a linter understand, which is also what the CSP requires, since it - forbids inline script. + forbids inline script. `app.js` is an ES **module**; a new asset must be added + to `ASSETS` *and* to the route's regex in `app-page.ts`, or it 404s. +- **`http/app/markdown.js` + `editor.js`** — the note is edited as formatted + text and stored as Markdown, and these two are that translation. + `markdown.js` is the pair `markdownToHtml` / `markdownFromDom`; `editor.js` + drives a `contenteditable` element with `execCommand` (no library: the CSP + allows no outside script and the app has no bundler). **The round trip must + settle**: one pass may tidy a note, a second must change nothing, and + `editor.js` checks exactly that before opening a note formatted — a note that + fails opens in the Markdown view instead. `test/app-markdown.test.ts` covers + it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a + lesson's notes to a lossy serializer is not a bug anyone can recover from. + Pasted HTML goes through Markdown before it reaches the document, which is + where sanitising and formatting are the same operation. - **`http/web-auth.ts`** — the app's login, which is a different kind of credential from everything else here: a password a person types, not a token a program was configured with. scrypt at startup, a signed `HttpOnly` / diff --git a/README.md b/README.md index 9cd9661..b81de63 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,11 @@ teachers, rooms, cancellations dropped and substitutions marked. Each heading is indexed as its own lesson, so a search answers "my own note, Deutsch, 18.09.2026" rather than "Friday". +Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes, +quotes, links and tables come from a toolbar, and `MD` shows the Markdown +underneath when you want it. The file on disk stays Markdown either way — that +is what the index reads and what outlives the app. + It saves as you type, keeps a local copy of every keystroke for when the signal goes, and refuses a save that would overwrite a version it never saw. On a phone it adds to the home screen and opens standalone. diff --git a/docs/DEPLOY-NOTES.md b/docs/DEPLOY-NOTES.md index 1a08a42..24a793d 100644 --- a/docs/DEPLOY-NOTES.md +++ b/docs/DEPLOY-NOTES.md @@ -12,7 +12,7 @@ should not make twice**, because changing it later means moving files by hand. | | | |---|---| | **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. | -| **The app at `/app`** | A login, a day-at-a-time notes editor, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. | +| **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. | | **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. | Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes @@ -241,6 +241,8 @@ write them. | `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container | | Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app | | `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate | +| The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` | +| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note | | Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` | | `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads | | `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes | diff --git a/docs/NOTES.md b/docs/NOTES.md index 358844a..551712e 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -87,6 +87,41 @@ thing I open in a free period". `add_note` — the save is refused and you are asked which version wins. It never silently overwrites. +### Writing, without typing Markdown + +The editor shows the note **formatted** — headings as headings, bold as bold, +tables as tables — and the toolbar above it writes the Markdown. Nobody types +`##` or `**` during a lesson. + +| Button | What it writes | +| --- | --- | +| `H2` | a lesson heading — the one that makes the lesson separately searchable | +| `H3` | a subheading inside a lesson | +| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) | +| `<>` | inline code (`Strg`/`Cmd` + E) | +| `• —` `1. —` | bullet and numbered lists; nest them with Tab | +| `☐` | a box to tick off | +| `❝` | a quote — the teacher's exact wording | +| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table | +| `MD` | the Markdown itself | + +`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A +paste from a web page or a PDF 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. + +**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. + **Einstellungen** holds the Schulcloud token: how long it has left, and the box to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud token set` and the older `/token` page do). It also shows the index's state and diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index af5186f..cbdc422 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -726,10 +726,22 @@ console.log('\n== web app =='); ); check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN)); - const assets = await Promise.all( - ['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)), - ); + // Every file the shell asks for, including the two modules the editor is + // made of: a missing one leaves a page that loads and cannot type. + const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest']; + const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`))); check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' ')); + check( + 'the editor\'s modules are served as JavaScript', + assets + .filter((_, index) => assetNames[index].endsWith('.js')) + .every((response) => /javascript/.test(response.headers.get('content-type') ?? '')), + assets.map((r) => r.headers.get('content-type')).join(' | '), + ); + check( + 'the shell loads the app as a module, so its imports resolve', + / + diff --git a/src/http/app/markdown.js b/src/http/app/markdown.js new file mode 100644 index 0000000..a4c5b6a --- /dev/null +++ b/src/http/app/markdown.js @@ -0,0 +1,647 @@ +/* + * 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 ` & nicht fett'); + assert.equal(html.includes(''), false); + assert.match(html, /<script>/); +}); + +test('markup the editor does not model keeps its words', () => { + // What a paste from a web page leaves behind: the tags mean nothing here, + // the text means everything. + const html = '

fett unterstrichen rot

'; + 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('
eins
zwei
')), 'eins\n\nzwei'); + assert.equal(markdownFromDom(parseHtml('

')), ''); +}); + +test('text that looks like Markdown is escaped, and comes back as text', () => { + unchanged('2 \\* 3 \\* 4'); + unchanged('\\- kein Listenpunkt'); + unchanged('\\# keine Überschrift'); + assert.equal(back('Gewicht \\_in kg\\_'), 'Gewicht \\_in kg\\_'); +}); + +test('a whole day note survives unchanged', () => { + // The shape the app writes and the indexer reads back: one `##` per lesson, + // prose, a list, a subheading and a table underneath. + const note = [ + '## 1. Deutsch — 08:00–08:45 · MEI · R 204', + '', + 'Dreischritt: These, Argument mit Beleg, Fazit.', + '', + '### Aufbau', + '', + '- Gegenargument nicht vergessen', + ' - kam letztes Jahr in der Arbeit dran', + '- **Fazit** knapp halten', + '', + '## 2. LF07 — 08:50–09:35 · Sb · R 108', + '', + '| Präfix | Nutzbare Adressen |', + '| --- | --- |', + '| /24 | 254 |', + '| /25 | 126 |', + '', + '> Kommt so in der Arbeit dran.', + ].join('\n'); + unchanged(note); +}); + +test('the lesson headings the indexer keys on come back verbatim', () => { + // `lessonHeading` writes these and `subjectFromHeading` reads the subject + // back out of them. An editor that rewrote the dash or the separator would + // file a day's notes under nothing. + for (const heading of [ + '## 1. Deutsch — 08:00–08:45 · MEI · R 204', + '## 3. LF07 — 10:35–11:20 · Sb · R 108 (Vertretung)', + '## 5. Englisch — 12:15–13:00', + ]) { + unchanged(heading); + } +}); + +test('an empty note is empty, not a paragraph', () => { + assert.equal(markdownToHtml(''), ''); + assert.equal(back(''), ''); + assert.equal(back('\n\n \n'), ''); +}); + +test('a list the browser nested as a sibling keeps its items', () => { + // What several engines produce when Tab indents a bullet: the nested list + // beside the items rather than inside one. Skipping it would drop + // everything under it without a trace. + const html = ''; + 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('')), '- eins'); + assert.equal(markdownFromDom(parseHtml('
  1. eins

')), '1. eins'); +}); + +test('what execCommand produces round-trips', () => { + // styleWithCSS is turned off, so bold and italic arrive as tags — but + // ``/``, not ``/``. + assert.equal(markdownFromDom(parseHtml('

fett und kursiv

')), '**fett** und _kursiv_'); + // A heading made by formatBlock, and the empty paragraph left behind. + assert.equal(markdownFromDom(parseHtml('

Deutsch


')), '## Deutsch'); +}); + +test('a task list keeps its state through the DOM the editor builds', () => { + const html = '
  • offen
  • ' + + '
  • fertig
'; + assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig'); +}); diff --git a/test/mini-dom.ts b/test/mini-dom.ts new file mode 100644 index 0000000..c5ffeec --- /dev/null +++ b/test/mini-dom.ts @@ -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; + + constructor(name: string, attributes: Map) { + 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 { + const attributes = new Map(); + const pattern = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; + for (let match = pattern.exec(source); match; match = pattern.exec(source)) { + // A bare attribute (`checked`) is present with an empty value, which is + // what the DOM reports too. + attributes.set(match[1]!.toLowerCase(), decode(match[2] ?? match[3] ?? match[4] ?? '')); + } + return attributes; +} + +function decode(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +}