2 Commits

Author SHA1 Message Date
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
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,
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
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,
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
thing I open in a free period".
**Notizen** is one screen: the day, ` ` to move between days, and the editor.
**Notizen** is one screen in two panes: your notes on the left, the open one on
the right. Side by side where there is room; on a phone the list comes first and
a note pushes over it, with ` Notizen` to come back.
The list is every note, newest first — the day, the lessons it covers, and the
first thing written in it:
```
Freitag, 18.09.2026
Deutsch · LF07
Dreischritt: These, Argument mit Beleg, Fazit.
```
**** opens today, whether or not it has a note yet — the one thing a list of
notes cannot show you, because an empty day is not a note. ` ` and the date
field move between days from there.
- Opening a day with no note yet **fills in that day's lessons from WebUntis**
numbered, with times, teacher and room, cancellations left out and
@@ -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
unchanged.
### Suche — the notes themselves
### Searching, in the list
The **Suche** tab searches your own notes and nothing else. Every word has to
appear, in any order, ignoring case and accents; there is no stemming, so
*Argument* does not find *Argumente*.
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 beside it — because a day note holds five or six lessons and "Freitag" says
nothing about which one matched. Tapping a day note opens it in the editor;
anything else, such as a note from the Apple Notes import, opens read-only,
since the editor is day-shaped and those notes have no day.
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

View File

@@ -742,7 +742,9 @@ console.log('\n== web app ==');
'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"'),
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();
@@ -831,6 +833,21 @@ console.log('\n== web app ==');
`${bySubject.count} note(s)`,
);
// What a row in the app's note list shows, which is why the listing carries
// it: one request for the whole list rather than one per note.
const listing = await (await fetch(`${root}/api/notes?limit=5`, { headers: withSession })).json();
const dayRow = listing.notes?.find((note) => note.path === '2026/2026-09-18.md');
check(
'the listing says what a note holds, without its body',
dayRow !== undefined && dayRow.text === undefined && dayRow.lessons === 1 && dayRow.subjects?.includes('Geschichte'),
`${dayRow?.lessons} lesson(s), subjects ${dayRow?.subjects?.join('/')}`,
);
check(
'and a preview that reads as prose',
/Weimarer Republik/.test(dayRow?.preview ?? '') && !/[#*|]/.test(dayRow?.preview ?? ''),
dayRow?.preview,
);
// Full text over the files themselves — the app's search box. It reads disk,
// so a note written seconds ago is findable without a crawl, which is the
// whole reason it does not go through the index.

View File

@@ -637,7 +637,7 @@ function snippetAround(text: string, word: string): string {
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(plainLine)
.map(plainText)
.join(' ')
.replace(/\s{2,}/g, ' ')
.trim();
@@ -647,24 +647,40 @@ function snippetAround(text: string, word: string): string {
/**
* One line of Markdown as the words it holds.
*
* A snippet is read, not parsed: `| 1NF | atomare Werte |` says more as
* "1NF · atomare Werte", and a row of `**` in a preview is noise.
* 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.
*/
function plainLine(line: string): string {
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(' · ');
}
return value
.replace(/^#{1,6}\s+/, '')
.replace(/^>\s?/, '')
.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, '')
.replace(/^\d{1,9}[.)]\s+/, '')
.replace(/(\*\*|__|~~)/g, '')
.replace(/`+/g, '')
.trim();
// 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 -------------------------------------------------------------

View File

@@ -20,7 +20,10 @@ import {
NoteNotFound,
filterNotes,
readNoteAt,
plainText,
readNotes,
noteSubjects,
noteSections,
searchNotes,
replaceNote,
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
* call either triggers is a GET.
*/
/**
* The first words of a note, for a list row.
*
* Headings and list markers are dropped: a row that reads "## 1. Deutsch —
* 08:00" repeats what the row already says, and the point of the line is the
* first thing that was actually written down.
*/
function previewOf(text: string): string {
for (const line of text.split('\n')) {
// Headings are skipped rather than stripped: the row above already says
// which lessons the day holds, and repeating one is not a preview.
if (/^#{1,6}\s/.test(line.trim())) continue;
const plain = plainText(line);
if (plain) return plain.length > 120 ? `${plain.slice(0, 117)}` : plain;
}
return '';
}
const NO_NOTES_DIR =
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
@@ -280,8 +301,20 @@ export function createApiRouter(services: Services): Router {
writable: services.config.notesWritable,
count: notes.length,
// The body is dropped from a listing: a term of notes is megabytes,
// and the CLI asks for the ones it wants by path.
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
// and the CLI asks for the ones it wants by path. What replaces it
// is what a list *shows* — the lessons a day covers and a line of
// its text — so the app's note list needs one request, not one per
// note.
notes: notes.slice(0, limit).map(({ text, ...rest }) => {
const note = { ...rest, text };
const sections = noteSections(note);
return {
...rest,
subjects: noteSubjects(note),
lessons: sections.length,
preview: previewOf(note.text),
};
}),
});
} catch (error) {
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });

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; }
/* --- 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 ------------------------------------------------------- */
.daybar { display: flex; align-items: center; gap: 0.5rem; }
@@ -271,51 +362,6 @@ button:disabled { opacity: 0.5; cursor: default; }
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) ------------------------------------------- */
.card {

View File

@@ -37,19 +37,21 @@ const ui = {
loginError: document.getElementById('login-error'),
app: document.getElementById('app'),
tabNotes: document.getElementById('tab-notes'),
tabSearch: document.getElementById('tab-search'),
tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'),
viewSearch: document.getElementById('view-search'),
viewSettings: document.getElementById('view-settings'),
searchForm: document.getElementById('search-form'),
searchInput: document.getElementById('search-input'),
searchStatus: document.getElementById('search-status'),
searchResults: document.getElementById('search-results'),
searchNote: document.getElementById('search-note'),
searchNoteTitle: document.getElementById('search-note-title'),
searchNoteBody: document.getElementById('search-note-body'),
searchBack: document.getElementById('search-back'),
railStatus: document.getElementById('rail-status'),
railList: document.getElementById('rail-list'),
today: document.getElementById('today'),
back: document.getElementById('back'),
daybar: document.querySelector('.daybar'),
actions: document.querySelector('.actions'),
notePreview: document.getElementById('note-preview'),
notePreviewTitle: document.getElementById('note-preview-title'),
notePreviewPath: document.getElementById('note-preview-path'),
notePreviewBody: document.getElementById('note-preview-body'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'),
@@ -203,6 +205,18 @@ function setStatus(message, kind) {
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
}
/** A school day, in the editor, with the list showing which one. */
async function openDay(date) {
ui.notePreview.hidden = true;
showEditor(true);
await loadDay(date);
}
/** Where a day's note lives, which is also its id in the list. */
function dayPathFor(date) {
return date.slice(0, 4) + '/' + date + '.md';
}
async function loadDay(date) {
// Anything unsaved goes to the draft before the view moves, or switching
// days would be a way to lose a lesson.
@@ -291,6 +305,7 @@ async function loadDay(date) {
describeLessons(info);
ui.fill.hidden = !day.missing;
markOpenRow();
}
function describeLessons(info) {
@@ -330,9 +345,12 @@ async function saveDay(automatic) {
// with, and sending null would look like "I saw no version".
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
});
const isNew = !day.modifiedAt;
day.saved = text;
day.modifiedAt = result.modifiedAt;
day.dirty = false;
// A day that had no note until now is not in the list yet.
if (isNew && !ui.searchInput.value.trim()) void loadRail();
day.conflicted = false;
ui.conflict.hidden = true;
clearDraft(day.date);
@@ -429,82 +447,112 @@ function addFact(term, value) {
ui.serverState.append(dt, dd);
}
// --- search --------------------------------------------------------------
// --- the note list -------------------------------------------------------
/*
* The notes themselves, read from disk by the server rather than from the
* index. A lesson written this morning is findable this morning, which is most
* of what anyone searches their own notes for — the MCP `search` tool is the
* other half, spanning Schulcloud and the class register at the cost of being
* only as fresh as the last crawl.
* 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 runSearch(query) {
window.clearTimeout(searchTimer);
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 …';
async function loadRail() {
ui.railStatus.textContent = 'Wird geladen …';
try {
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=60');
searchTerms = value.split(/\s+/).filter(Boolean);
showHits(result.hits);
ui.searchStatus.textContent =
result.count === 0
? 'Nichts gefunden. Die Suche braucht jedes Wort — und kennt keine Wortformen.'
: result.count + ' Treffer' + (result.count >= 60 ? ' (mehr vorhanden)' : '') + '.';
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.searchResults.replaceChildren();
ui.searchStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
ui.railList.replaceChildren();
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();
for (const hit of hits) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'hit';
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 head = document.createElement('div');
head.className = 'hit-head';
const subject = document.createElement('span');
subject.className = 'hit-subject';
// The lesson if there is one, else the note — never just "Freitag".
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 title = document.createElement('div');
title.className = 'row-title';
if (row.mark) highlight(title, row.title);
else title.textContent = row.title;
item.append(title);
const snippet = document.createElement('p');
snippet.className = 'hit-snippet';
highlight(snippet, hit.snippet);
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);
}
card.append(head, snippet);
card.addEventListener('click', () => openHit(hit));
list.append(card);
item.addEventListener('click', () => void openRow(row));
list.append(item);
}
ui.searchResults.replaceChildren(list);
ui.railList.replaceChildren(list);
markOpenRow();
}
/**
* 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
* JSON response, and `innerHTML` on anything that has been round-tripped is
* how an editor ends up rendering what it should be showing.
* 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);
@@ -522,18 +570,18 @@ function highlight(target, text) {
marks.sort((a, b) => a[0] - b[0]);
let cursor = 0;
for (const [start, end] of marks) {
if (start < cursor) continue;
target.append(text.slice(cursor, start));
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(start, end);
mark.textContent = text.slice(from, to);
target.append(mark);
cursor = end;
cursor = to;
}
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) {
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
@@ -543,33 +591,103 @@ function germanDate(date) {
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
/**
* A result, opened.
*
* A day note 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 openHit(hit) {
const day = DAY_NOTE.exec(hit.path);
if (day) {
showTab('notes');
await loadDay(day[1]);
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.searchStatus.textContent = 'Wird geöffnet …';
ui.railStatus.textContent = 'Wird gesucht …';
try {
const note = await api('/api/notes?path=' + encodeURIComponent(hit.path));
ui.searchNoteTitle.textContent = note.title;
// The note is Markdown from our own store, and markdownToHtml escapes
// everything it did not produce itself — the same parser the editor
// trusts with the same input.
ui.searchNoteBody.innerHTML = markdownToHtml(note.text ?? '');
ui.searchNote.hidden = false;
ui.searchStatus.textContent = note.path;
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.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) {
for (const [tab, view, id] of [
[ui.tabNotes, ui.viewNotes, 'notes'],
[ui.tabSearch, ui.viewSearch, 'search'],
[ui.tabSettings, ui.viewSettings, 'settings'],
]) {
view.hidden = name !== id;
tab.setAttribute('aria-current', name === id ? 'page' : 'false');
}
if (name === 'settings') void loadSettings();
if (name === 'search') ui.searchInput.focus();
const notes = name !== 'settings';
ui.viewNotes.hidden = !notes;
ui.viewSettings.hidden = notes;
ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
if (!notes) void loadSettings();
}
// --- wiring --------------------------------------------------------------
@@ -609,6 +723,8 @@ ui.loginForm.addEventListener('submit', async (event) => {
ui.password.value = '';
showApp();
await loadDay(day.date);
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
await loadRail();
} catch (error) {
ui.loginError.textContent =
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
@@ -622,7 +738,6 @@ ui.logout.addEventListener('click', async () => {
});
ui.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSearch.addEventListener('click', () => showTab('search'));
ui.tabSettings.addEventListener('click', () => showTab('settings'));
ui.searchForm.addEventListener('submit', (event) => {
@@ -638,14 +753,18 @@ ui.searchInput.addEventListener('input', () => {
searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS);
});
ui.searchBack.addEventListener('click', () => {
ui.searchNote.hidden = true;
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.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
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 loadDay(ui.dayDate.value);
if (ui.dayDate.value) void openDay(ui.dayDate.value);
});
ui.save.addEventListener('click', () => void saveDay(false));
@@ -708,4 +827,8 @@ void (async () => {
}
showApp();
await loadDay(day.date);
// Wide enough for both panes: the day is already open beside the list.
// Narrow: the list comes first, the way a notes app opens.
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
await loadRail();
})();

View File

@@ -27,13 +27,29 @@
<header>
<nav class="tabs">
<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>
</nav>
</header>
<!-- Notes: one school day per note, one heading per lesson. -->
<main id="view-notes" class="view">
<!-- Two panes: the notes on the left, the open one on the right. Side by
side where there is room; one at a time on a phone, where `data-pane`
says which, and the back button returns to the list. -->
<main id="view-notes" class="view notes" data-pane="list">
<aside class="rail">
<div class="rail-head">
<form id="search-form" class="searchbar" role="search">
<input id="search-input" type="search" inputmode="search" autocomplete="off"
placeholder="Durchsuchen" aria-label="Notizen durchsuchen">
</form>
<button type="button" id="today" aria-label="Heutiger Tag" title="Heutiger Tag"></button>
</div>
<p id="rail-status" class="status" role="status" aria-live="polite"></p>
<div id="rail-list" class="rail-list"></div>
</aside>
<section class="detail">
<button type="button" id="back" class="back"> Notizen</button>
<div class="daybar">
<button type="button" id="prev" aria-label="Vorheriger Tag"></button>
<div class="daybar-centre">
@@ -84,23 +100,15 @@
<button type="button" id="fill" hidden>Stunden ergänzen</button>
<span id="lessons-hint" class="hint"></span>
</div>
</main>
<!-- Search: the notes themselves, read from disk rather than the index, so a
lesson written this morning is findable this morning. -->
<main id="view-search" class="view" hidden>
<form id="search-form" class="searchbar">
<input id="search-input" type="search" inputmode="search" autocomplete="off"
placeholder="In den eigenen Notizen suchen" aria-label="Suchbegriff">
<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>
<!-- A note that is not a school day — one from the import, a page of
revision — has no day to open, so it is shown rather than edited. -->
<article id="note-preview" class="note-preview" hidden>
<h2 id="note-preview-title"></h2>
<p id="note-preview-path" class="hint"></p>
<div id="note-preview-body" class="editor" aria-readonly="true"></div>
</article>
</section>
</main>
<!-- Settings: the Schulcloud token, and what the server is doing. -->

View File

@@ -14,6 +14,7 @@ import {
subjectFromHeading,
notePathFor,
parseNote,
plainText,
readNoteAt,
readNotes,
renderNote,
@@ -466,3 +467,26 @@ describe('searchNotes', () => {
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');
});
});