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>
This commit is contained in:
MechaCat02
2026-09-21 18:22:47 +02:00
parent 5db098b67f
commit 48c6cf39d5
9 changed files with 478 additions and 190 deletions

View File

@@ -86,9 +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, 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".
A **Suche** tab searches your own notes straight from the files — no crawl in 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 between, so a lesson written this morning is findable this morning — and a
result names the lesson it matched in rather than the day. 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, Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
quotes, links and tables come from a toolbar, and `MD` shows the Markdown quotes, links and tables come from a toolbar, and `MD` shows the Markdown

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
@@ -130,17 +145,21 @@ 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 that promise up: every construct in this document goes in and comes back out
unchanged. unchanged.
### Suche — the notes themselves ### Searching, in the list
The **Suche** tab searches your own notes and nothing else. Every word has to The box above the list searches your own notes and nothing else, and the results
appear, in any order, ignoring case and accents; there is no stemming, so *are* the list — searching is a way of finding a note, not a separate place to
*Argument* does not find *Argumente*. 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 A result names the **lesson**, not the day — `1. LF10 — 08:0008:45` with the
date beside it — because a day note holds five or six lessons and "Freitag" says date under it and the matched words marked — because a day note holds five or
nothing about which one matched. Tapping a day note opens it in the editor; six lessons and "Freitag" says nothing about which one matched. Opening a hit
anything else, such as a note from the Apple Notes import, opens read-only, opens that day **at that lesson**. A note that is not a school day, such as one
since the editor is day-shaped and those notes have no day. 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 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 Postgres index only on a full crawl, so a lesson written this morning would not

View File

@@ -742,7 +742,9 @@ console.log('\n== web app ==');
'the shell loads the app as a module, so its imports resolve', 'the shell loads the app as a module, so its imports resolve',
/<script type="module" src="app\.js">/.test(shellText) && /<script type="module" src="app\.js">/.test(shellText) &&
shellText.includes('data-command="bold"') && shellText.includes('data-command="bold"') &&
shellText.includes('id="search-form"'), 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();
@@ -831,6 +833,21 @@ 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, // 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 // so a note written seconds ago is findable without a crawl, which is the
// whole reason it does not go through the index. // whole reason it does not go through the index.

View File

@@ -637,7 +637,7 @@ function snippetAround(text: string, word: string): string {
const at = lines.findIndex((line) => fold(line).includes(word)); const at = lines.findIndex((line) => fold(line).includes(word));
const preview = lines const preview = lines
.slice(Math.max(0, at === -1 ? 0 : at), (at === -1 ? 0 : at) + 2) .slice(Math.max(0, at === -1 ? 0 : at), (at === -1 ? 0 : at) + 2)
.map(plainLine) .map(plainText)
.join(' ') .join(' ')
.replace(/\s{2,}/g, ' ') .replace(/\s{2,}/g, ' ')
.trim(); .trim();
@@ -647,24 +647,40 @@ function snippetAround(text: string, word: string): string {
/** /**
* One line of Markdown as the words it holds. * One line of Markdown as the words it holds.
* *
* A snippet is read, not parsed: `| 1NF | atomare Werte |` says more as * Wherever a note is *shown* rather than edited — a search snippet, a row in
* "1NF · atomare Werte", and a row of `**` in a preview is noise. * 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.
*/ */
function plainLine(line: string): string { export function plainText(line: string): string {
let value = line.trim(); let value = line.trim();
if (/^\|.*\|$/.test(value)) { if (/^\|.*\|$/.test(value)) {
// A table row, including the `|---|---|` rule, which says nothing at all. // A table row, including the `|---|---|` rule, which says nothing at all.
if (/^\|[\s:|-]*\|$/.test(value)) return ''; if (/^\|[\s:|-]*\|$/.test(value)) return '';
value = value.slice(1, -1).split('|').map((cell) => cell.trim()).filter(Boolean).join(' · '); value = value.slice(1, -1).split('|').map((cell) => cell.trim()).filter(Boolean).join(' · ');
} }
return value // One leading marker, not all of them in turn: a heading reading
.replace(/^#{1,6}\s+/, '') // `## 1. Deutsch` keeps its lesson number, which the list rule would
.replace(/^>\s?/, '') // otherwise take for a bullet and eat.
.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, '') for (const marker of [/^#{1,6}\s+/, /^>\s?/, /^[-*+]\s+(\[[ xX]\]\s+)?/, /^\d{1,9}[.)]\s+/]) {
.replace(/^\d{1,9}[.)]\s+/, '') if (marker.test(value)) {
.replace(/(\*\*|__|~~)/g, '') value = value.replace(marker, '');
.replace(/`+/g, '') break;
.trim(); }
}
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 -------------------------------------------------------------

View File

@@ -20,7 +20,10 @@ import {
NoteNotFound, NoteNotFound,
filterNotes, filterNotes,
readNoteAt, readNoteAt,
plainText,
readNotes, readNotes,
noteSubjects,
noteSections,
searchNotes, searchNotes,
replaceNote, replaceNote,
writeNote, writeNote,
@@ -41,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.';
@@ -280,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 });

View File

@@ -72,6 +72,97 @@ 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;
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; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem; min-height: 0; }
/* 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; }
@@ -271,51 +362,6 @@ button:disabled { opacity: 0.5; cursor: default; }
font-size: 0.9rem; font-size: 0.9rem;
} }
/* --- search ------------------------------------------------------------ */
.searchbar { display: flex; gap: 0.5rem; }
.searchbar input {
flex: 1;
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
font: inherit;
}
.searchbar button { background: var(--accent); border-color: var(--accent); color: #ffffff; font-weight: 600; }
.results { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; }
/* A whole result is the target: on a phone the thing being tapped is a card,
not a link inside one. */
.hit {
width: 100%;
text-align: left;
padding: 0.6rem 0.75rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--card);
color: var(--fg);
font: inherit;
cursor: pointer;
display: block;
}
.hit-head { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
.hit-subject { font-weight: 600; }
.hit-date { color: var(--muted); font-size: 0.85rem; }
.hit-snippet { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.9rem; line-height: 1.4; }
.hit mark { background: color-mix(in srgb, var(--accent) 25%, transparent); color: inherit; border-radius: 0.15rem; }
.note-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.note-preview h2 { margin: 0; font-size: 1.05rem; }
.note-preview .editor { cursor: default; }
#search-back { align-self: flex-start; }
/* --- cards (login, settings) ------------------------------------------- */ /* --- cards (login, settings) ------------------------------------------- */
.card { .card {

View File

@@ -37,19 +37,21 @@ const ui = {
loginError: document.getElementById('login-error'), loginError: document.getElementById('login-error'),
app: document.getElementById('app'), app: document.getElementById('app'),
tabNotes: document.getElementById('tab-notes'), tabNotes: document.getElementById('tab-notes'),
tabSearch: document.getElementById('tab-search'),
tabSettings: document.getElementById('tab-settings'), tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'), viewNotes: document.getElementById('view-notes'),
viewSearch: document.getElementById('view-search'),
viewSettings: document.getElementById('view-settings'), viewSettings: document.getElementById('view-settings'),
searchForm: document.getElementById('search-form'), searchForm: document.getElementById('search-form'),
searchInput: document.getElementById('search-input'), searchInput: document.getElementById('search-input'),
searchStatus: document.getElementById('search-status'), railStatus: document.getElementById('rail-status'),
searchResults: document.getElementById('search-results'), railList: document.getElementById('rail-list'),
searchNote: document.getElementById('search-note'), today: document.getElementById('today'),
searchNoteTitle: document.getElementById('search-note-title'), back: document.getElementById('back'),
searchNoteBody: document.getElementById('search-note-body'), daybar: document.querySelector('.daybar'),
searchBack: document.getElementById('search-back'), 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'),
@@ -203,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.
@@ -291,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) {
@@ -330,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);
@@ -429,82 +447,112 @@ function addFact(term, value) {
ui.serverState.append(dt, dd); ui.serverState.append(dt, dd);
} }
// --- search -------------------------------------------------------------- // --- the note list -------------------------------------------------------
/* /*
* The notes themselves, read from disk by the server rather than from the * The rail: every note, newest first, with the open one marked — and the
* index. A lesson written this morning is findable this morning, which is most * search box at the top of it, because searching your notes is a way of
* of what anyone searches their own notes for — the MCP `search` tool is the * finding one, not a separate place to be.
* other half, spanning Schulcloud and the class register at the cost of being *
* only as fresh as the last crawl. * 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; const SEARCH_DEBOUNCE_MS = 350;
/** Day notes live at `2026/2026-09-04.md`; anything else opens read-only. */ /** 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$/; const DAY_NOTE = /^\d{4}\/(\d{4}-\d{2}-\d{2})\.md$/;
let searchTimer = 0; let searchTimer = 0;
let searchTerms = []; let searchTerms = [];
async function runSearch(query) { async function loadRail() {
window.clearTimeout(searchTimer); ui.railStatus.textContent = 'Wird geladen …';
const value = query.trim();
ui.searchNote.hidden = true;
if (value.length < 2) {
ui.searchResults.replaceChildren();
ui.searchStatus.textContent = value ? 'Mindestens zwei Zeichen.' : '';
return;
}
ui.searchStatus.textContent = 'Wird gesucht …';
try { try {
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=60'); const listing = await api('/api/notes?limit=400');
searchTerms = value.split(/\s+/).filter(Boolean); searchTerms = [];
showHits(result.hits); showRows(listing.notes.map(noteRow));
ui.searchStatus.textContent = ui.railStatus.textContent =
result.count === 0 listing.count === 0
? 'Nichts gefunden. Die Suche braucht jedes Wort — und kennt keine Wortformen.' ? 'Noch keine Notizen. öffnet den heutigen Tag.'
: result.count + ' Treffer' + (result.count >= 60 ? ' (mehr vorhanden)' : '') + '.'; : listing.count + ' Notiz(en)' + (listing.notes.length < listing.count ? ', neueste 400' : '');
} catch (error) { } catch (error) {
if (error.message === 'unauthorized') return; if (error.message === 'unauthorized') return;
ui.searchResults.replaceChildren(); ui.railList.replaceChildren();
ui.searchStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.'; ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.';
} }
} }
function showHits(hits) { /** 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(); const list = document.createDocumentFragment();
for (const hit of hits) { for (const row of rows) {
const card = document.createElement('button'); const item = document.createElement('button');
card.type = 'button'; item.type = 'button';
card.className = 'hit'; item.className = 'row';
item.dataset.path = row.path;
if (row.heading) item.dataset.heading = row.heading;
const head = document.createElement('div'); const title = document.createElement('div');
head.className = 'hit-head'; title.className = 'row-title';
const subject = document.createElement('span'); if (row.mark) highlight(title, row.title);
subject.className = 'hit-subject'; else title.textContent = row.title;
// The lesson if there is one, else the note — never just "Freitag". item.append(title);
subject.textContent = hit.heading || hit.subject || hit.title;
const when = document.createElement('span');
when.className = 'hit-date';
when.textContent = hit.date ? germanDate(hit.date) : hit.path;
head.append(subject, when);
const snippet = document.createElement('p'); for (const [text, clamp] of [
snippet.className = 'hit-snippet'; [row.line, false],
highlight(snippet, hit.snippet); [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);
}
card.append(head, snippet); item.addEventListener('click', () => void openRow(row));
card.addEventListener('click', () => openHit(hit)); list.append(item);
list.append(card);
} }
ui.searchResults.replaceChildren(list); ui.railList.replaceChildren(list);
markOpenRow();
} }
/** /**
* The matched words marked, without building HTML from them. * The matched words marked, without building HTML from them.
* *
* A snippet is the user's own text, but it reaches here through a URL and a * A snippet is the user's own text, and text that has been through a URL and a
* JSON response, and `innerHTML` on anything that has been round-tripped is * JSON response is exactly what should not be handed to `innerHTML`.
* how an editor ends up rendering what it should be showing.
*/ */
function highlight(target, text) { function highlight(target, text) {
const terms = searchTerms.map(fold).filter((term) => term.length > 1); const terms = searchTerms.map(fold).filter((term) => term.length > 1);
@@ -522,18 +570,18 @@ function highlight(target, text) {
marks.sort((a, b) => a[0] - b[0]); marks.sort((a, b) => a[0] - b[0]);
let cursor = 0; let cursor = 0;
for (const [start, end] of marks) { for (const [from, to] of marks) {
if (start < cursor) continue; if (from < cursor) continue;
target.append(text.slice(cursor, start)); target.append(text.slice(cursor, from));
const mark = document.createElement('mark'); const mark = document.createElement('mark');
mark.textContent = text.slice(start, end); mark.textContent = text.slice(from, to);
target.append(mark); target.append(mark);
cursor = end; cursor = to;
} }
target.append(text.slice(cursor)); target.append(text.slice(cursor));
} }
/** Lowercase without accents, the same folding the server searches with. */ /** Lowercase without accents the same folding the server searches with. */
function fold(value) { function fold(value) {
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
} }
@@ -543,33 +591,103 @@ function germanDate(date) {
return parts[2] + '.' + parts[1] + '.' + parts[0]; return parts[2] + '.' + parts[1] + '.' + parts[0];
} }
/** async function runSearch(query) {
* A result, opened. window.clearTimeout(searchTimer);
* const value = query.trim();
* A day note opens in the editor, because that is where it is written. Anything if (value.length === 0) return loadRail();
* else — an imported note, a page of revision — has no day to open, so it is if (value.length < 2) {
* shown read-only rather than forced into a day-shaped screen. ui.railStatus.textContent = 'Mindestens zwei Zeichen.';
*/
async function openHit(hit) {
const day = DAY_NOTE.exec(hit.path);
if (day) {
showTab('notes');
await loadDay(day[1]);
return; return;
} }
ui.searchStatus.textContent = 'Wird geöffnet …'; ui.railStatus.textContent = 'Wird gesucht …';
try { try {
const note = await api('/api/notes?path=' + encodeURIComponent(hit.path)); const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=100');
ui.searchNoteTitle.textContent = note.title; searchTerms = value.split(/\s+/).filter(Boolean);
// The note is Markdown from our own store, and markdownToHtml escapes showRows(result.hits.map(hitRow));
// everything it did not produce itself — the same parser the editor ui.railStatus.textContent =
// trusts with the same input. result.count === 0
ui.searchNoteBody.innerHTML = markdownToHtml(note.text ?? ''); ? 'Nichts gefunden — jedes Wort muss vorkommen.'
ui.searchNote.hidden = false; : result.count + ' Treffer' + (result.count >= 100 ? ' (mehr vorhanden)' : '');
ui.searchStatus.textContent = note.path;
} catch (error) { } catch (error) {
if (error.message === 'unauthorized') return; if (error.message === 'unauthorized') return;
ui.searchStatus.textContent = 'Konnte die Notiz nicht öffnen: ' + error.message; 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));
} }
} }
@@ -587,16 +705,12 @@ function showApp() {
} }
function showTab(name) { function showTab(name) {
for (const [tab, view, id] of [ const notes = name !== 'settings';
[ui.tabNotes, ui.viewNotes, 'notes'], ui.viewNotes.hidden = !notes;
[ui.tabSearch, ui.viewSearch, 'search'], ui.viewSettings.hidden = notes;
[ui.tabSettings, ui.viewSettings, 'settings'], ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
]) { ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
view.hidden = name !== id; if (!notes) void loadSettings();
tab.setAttribute('aria-current', name === id ? 'page' : 'false');
}
if (name === 'settings') void loadSettings();
if (name === 'search') ui.searchInput.focus();
} }
// --- wiring -------------------------------------------------------------- // --- wiring --------------------------------------------------------------
@@ -609,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.';
@@ -622,7 +738,6 @@ ui.logout.addEventListener('click', async () => {
}); });
ui.tabNotes.addEventListener('click', () => showTab('notes')); ui.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSearch.addEventListener('click', () => showTab('search'));
ui.tabSettings.addEventListener('click', () => showTab('settings')); ui.tabSettings.addEventListener('click', () => showTab('settings'));
ui.searchForm.addEventListener('submit', (event) => { ui.searchForm.addEventListener('submit', (event) => {
@@ -638,14 +753,18 @@ ui.searchInput.addEventListener('input', () => {
searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS); searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS);
}); });
ui.searchBack.addEventListener('click', () => { ui.today.addEventListener('click', () => {
ui.searchNote.hidden = true; // 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.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1))); ui.back.addEventListener('click', () => showPane('list'));
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
ui.prev.addEventListener('click', () => void openDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void openDay(shiftDate(day.date, 1)));
ui.dayDate.addEventListener('change', () => { ui.dayDate.addEventListener('change', () => {
if (ui.dayDate.value) void loadDay(ui.dayDate.value); if (ui.dayDate.value) void openDay(ui.dayDate.value);
}); });
ui.save.addEventListener('click', () => void saveDay(false)); ui.save.addEventListener('click', () => void saveDay(false));
@@ -708,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();
})(); })();

View File

@@ -27,13 +27,29 @@
<header> <header>
<nav class="tabs"> <nav class="tabs">
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button> <button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
<button type="button" id="tab-search" class="tab">Suche</button>
<button type="button" id="tab-settings" class="tab">Einstellungen</button> <button type="button" id="tab-settings" class="tab">Einstellungen</button>
</nav> </nav>
</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">
@@ -84,23 +100,15 @@
<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>
</main>
<!-- Search: the notes themselves, read from disk rather than the index, so a <!-- A note that is not a school day — one from the import, a page of
lesson written this morning is findable this morning. --> revision — has no day to open, so it is shown rather than edited. -->
<main id="view-search" class="view" hidden> <article id="note-preview" class="note-preview" hidden>
<form id="search-form" class="searchbar"> <h2 id="note-preview-title"></h2>
<input id="search-input" type="search" inputmode="search" autocomplete="off" <p id="note-preview-path" class="hint"></p>
placeholder="In den eigenen Notizen suchen" aria-label="Suchbegriff"> <div id="note-preview-body" class="editor" aria-readonly="true"></div>
<button type="submit">Suchen</button>
</form>
<p id="search-status" class="status" role="status" aria-live="polite"></p>
<div id="search-results" class="results"></div>
<article id="search-note" class="note-preview" hidden>
<button type="button" id="search-back"> Zurück zu den Treffern</button>
<h2 id="search-note-title"></h2>
<div id="search-note-body" class="editor" aria-readonly="true"></div>
</article> </article>
</section>
</main> </main>
<!-- Settings: the Schulcloud token, and what the server is doing. --> <!-- Settings: the Schulcloud token, and what the server is doing. -->

View File

@@ -14,6 +14,7 @@ import {
subjectFromHeading, subjectFromHeading,
notePathFor, notePathFor,
parseNote, parseNote,
plainText,
readNoteAt, readNoteAt,
readNotes, readNotes,
renderNote, renderNote,
@@ -466,3 +467,26 @@ describe('searchNotes', () => {
assert.equal(searchNotes([day], 'e', 1).length, 1); 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');
});
});