Compare commits
5 Commits
f545b7cf54
...
fix/pane-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bafad9b72d | ||
|
|
52f420fc11 | ||
|
|
48c6cf39d5 | ||
|
|
5db098b67f | ||
|
|
e129fd4b0a |
12
CLAUDE.md
12
CLAUDE.md
@@ -125,7 +125,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
hit would read "my note, Monday" and "what did we do in Deutsch" would match
|
||||
a note whose other five lessons were something else. The files are the truth and the
|
||||
index is a view of them, so `list_notes`/`get_note` read disk and answer
|
||||
before the first crawl and while Postgres is down. **The one thing anything
|
||||
before the first crawl and while Postgres is down. `searchNotes` is full
|
||||
text over those same files, behind `/api/notes/search` and the app's Suche
|
||||
tab: notes reach the index only on a **full** crawl, so anything written
|
||||
this week would be missing from it, and the app is exactly where "I wrote
|
||||
that this morning" is the common case. **The one thing anything
|
||||
here writes** — see Invariants. `docs/NOTES.md` is the guide.
|
||||
- **`untis-history.ts`** — the class register read backwards, which is what
|
||||
puts "what did we actually cover" into the search index. Its whole reason
|
||||
@@ -163,7 +167,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
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.
|
||||
where sanitising and formatting are the same operation. **An element holding
|
||||
blocks is a block, whatever its tag, and a container's style is not
|
||||
emphasis** — WebKit wraps a copied selection in one span carrying the computed
|
||||
style of everything in it, so reading that span as inline made a whole pasted
|
||||
Apple note bold and flattened it into one paragraph.
|
||||
- **`http/web-auth.ts`** — the app's login, which is a different kind of
|
||||
credential from everything else here: a password a person types, not a token a
|
||||
program was configured with. scrypt at startup, a signed `HttpOnly` /
|
||||
|
||||
@@ -86,6 +86,12 @@ teachers, rooms, cancellations dropped and substitutions marked. Each heading is
|
||||
indexed as its own lesson, so a search answers "my own note, Deutsch,
|
||||
18.09.2026" rather than "Friday".
|
||||
|
||||
It reads like a notes app: the notes on the left, the open one on the right, and
|
||||
a search box above the list. Search goes straight to the files — no crawl in
|
||||
between, so a lesson written this morning is findable this morning — and a
|
||||
result names the lesson it matched in rather than the day, opening that day at
|
||||
that lesson.
|
||||
|
||||
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
|
||||
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
|
||||
underneath when you want it. The file on disk stays Markdown either way — that
|
||||
|
||||
@@ -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
|
||||
@@ -105,11 +120,19 @@ tables as tables — and the toolbar above it writes the Markdown. Nobody types
|
||||
| `🔗` `▦` | 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.
|
||||
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. In a
|
||||
table, `Tab` walks the cells and a `Tab` out of the last one adds a row;
|
||||
`Ctrl`/`Cmd`+`Enter` opens a paragraph after whatever block you are in.
|
||||
|
||||
A paste from a web page, a PDF or Apple Notes keeps its structure and loses its
|
||||
fonts, colours and anything else that is not in the list above — pasted HTML is
|
||||
converted to Markdown before it reaches the page, which is what keeps a copied
|
||||
page from bringing its script along. **A copy from Apple Notes arrives wrapped
|
||||
in one span carrying the computed style of everything in it**, `font-weight:
|
||||
700` included; that wrapper is a container, not emphasis, and the blocks inside
|
||||
it are blocks. Reading it the other way made a whole pasted note bold and
|
||||
flattened every line into one paragraph, which is the failure
|
||||
`test/app-markdown.test.ts` now pins down.
|
||||
|
||||
**The file is still Markdown.** `MD` shows it and lets you edit it directly,
|
||||
which is the way to write something the toolbar has no button for. There is no
|
||||
@@ -122,6 +145,32 @@ so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
|
||||
that promise up: every construct in this document goes in and comes back out
|
||||
unchanged.
|
||||
|
||||
### Searching, in the list
|
||||
|
||||
The box above the list searches your own notes and nothing else, and the results
|
||||
*are* the list — searching is a way of finding a note, not a separate place to
|
||||
be. Emptying the box brings the whole list back.
|
||||
|
||||
Every word has to appear, in any order, ignoring case and accents; there is no
|
||||
stemming, so *Argument* does not find *Argumente*.
|
||||
|
||||
A result names the **lesson**, not the day — `1. LF10 — 08:00–08:45` with the
|
||||
date under it and the matched words marked — because a day note holds five or
|
||||
six lessons and "Freitag" says nothing about which one matched. Opening a hit
|
||||
opens that day **at that lesson**. A note that is not a school day, such as one
|
||||
from the Apple Notes import, opens read-only: the editor is day-shaped and those
|
||||
notes have no day.
|
||||
|
||||
It reads the **files**, not the index. That is the point: notes reach the
|
||||
Postgres index only on a full crawl, so a lesson written this morning would not
|
||||
be there, and "what did I write this week" is most of what anyone searches their
|
||||
own notes for. A few hundred small files answer instantly, and it keeps working
|
||||
while Postgres is down — the same reason `list_notes` reads disk.
|
||||
|
||||
The `search` tool in Claude is the other half: it spans the Schulcloud material
|
||||
and the WebUntis class register as well, at the cost of being only as fresh as
|
||||
the last crawl.
|
||||
|
||||
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
|
||||
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
|
||||
token set` and the older `/token` page do). It also shows the index's state and
|
||||
|
||||
@@ -740,7 +740,11 @@ console.log('\n== web app ==');
|
||||
);
|
||||
check(
|
||||
'the shell loads the app as a module, so its imports resolve',
|
||||
/<script type="module" src="app\.js">/.test(shellText) && shellText.includes('data-command="bold"'),
|
||||
/<script type="module" src="app\.js">/.test(shellText) &&
|
||||
shellText.includes('data-command="bold"') &&
|
||||
shellText.includes('id="search-form"') &&
|
||||
// The note list and the editor are one screen now, not two tabs.
|
||||
shellText.includes('id="rail-list"'),
|
||||
);
|
||||
|
||||
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
|
||||
@@ -829,6 +833,56 @@ console.log('\n== web app ==');
|
||||
`${bySubject.count} note(s)`,
|
||||
);
|
||||
|
||||
// What a row in the app's note list shows, which is why the listing carries
|
||||
// it: one request for the whole list rather than one per note.
|
||||
const listing = await (await fetch(`${root}/api/notes?limit=5`, { headers: withSession })).json();
|
||||
const dayRow = listing.notes?.find((note) => note.path === '2026/2026-09-18.md');
|
||||
check(
|
||||
'the listing says what a note holds, without its body',
|
||||
dayRow !== undefined && dayRow.text === undefined && dayRow.lessons === 1 && dayRow.subjects?.includes('Geschichte'),
|
||||
`${dayRow?.lessons} lesson(s), subjects ${dayRow?.subjects?.join('/')}`,
|
||||
);
|
||||
check(
|
||||
'and a preview that reads as prose',
|
||||
/Weimarer Republik/.test(dayRow?.preview ?? '') && !/[#*|]/.test(dayRow?.preview ?? ''),
|
||||
dayRow?.preview,
|
||||
);
|
||||
|
||||
// Full text over the files themselves — the app's search box. It reads disk,
|
||||
// so a note written seconds ago is findable without a crawl, which is the
|
||||
// whole reason it does not go through the index.
|
||||
const found = await (
|
||||
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Scheiterns')}`, { headers: withSession })
|
||||
).json();
|
||||
check(
|
||||
'note search finds a lesson by its text, with no crawl in between',
|
||||
found.count === 1 && found.hits[0]?.path === '2026/2026-09-18.md',
|
||||
`${found.count} hit(s)`,
|
||||
);
|
||||
check(
|
||||
'and answers with the lesson rather than the day',
|
||||
found.hits[0]?.subject === 'Geschichte' && /Geschichte/.test(found.hits[0]?.heading ?? ''),
|
||||
`${found.hits[0]?.subject} — ${found.hits[0]?.heading}`,
|
||||
);
|
||||
check(
|
||||
'the snippet reads as prose, not as Markdown',
|
||||
/Weimarer Republik/.test(found.hits[0]?.snippet ?? '') && !/[#*|]/.test(found.hits[0]?.snippet ?? ''),
|
||||
found.hits[0]?.snippet,
|
||||
);
|
||||
|
||||
const accents = await (
|
||||
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('weimarer')}`, { headers: withSession })
|
||||
).json();
|
||||
check('search ignores case and accents', accents.count === 1, `${accents.count} hit(s)`);
|
||||
|
||||
const bothWords = await (
|
||||
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Subnetting')}`, { headers: withSession })
|
||||
).json();
|
||||
check('every word has to match', bothWords.count === 0, `${bothWords.count} hit(s)`);
|
||||
|
||||
const tooShort = await fetch(`${root}/api/notes/search?q=a`, { headers: withSession });
|
||||
check('a one-letter search is refused rather than reading every note', tooShort.status === 400, `got ${tooShort.status}`);
|
||||
|
||||
const badDate = await fetch(`${root}/api/notes/day?date=2026-02-30`, { headers: withSession });
|
||||
check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`);
|
||||
|
||||
|
||||
@@ -550,6 +550,139 @@ export function filterNotes(
|
||||
});
|
||||
}
|
||||
|
||||
/** One place a query matched: a lesson, or a whole note that has no lessons. */
|
||||
export interface NoteHit {
|
||||
/** The note's path, as `get_note` takes it. */
|
||||
path: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
/** The lesson's subject, or the note's own. */
|
||||
subject?: string;
|
||||
/** The `##` heading the match sits under, when the note has lessons. */
|
||||
heading?: string;
|
||||
/** A line or two around the first match, for a result list. */
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search over the note files themselves.
|
||||
*
|
||||
* Deliberately *not* the Postgres index the `search` tool uses. Notes are only
|
||||
* read by a full crawl, so anything written this week would be missing from it
|
||||
* — and the one place a person searches their own notes from is the app, where
|
||||
* "I wrote that this morning" is the common case. A few hundred small files
|
||||
* read from disk answer in well under the time an index would take to catch up,
|
||||
* and this keeps working when Postgres is down, which is the same reason
|
||||
* `list_notes` reads disk.
|
||||
*
|
||||
* Matching is by word: every word must appear somewhere in the lesson, in any
|
||||
* order, ignoring case and accents, so "erorterung aufbau" finds a lesson about
|
||||
* the Erörterung whose Aufbau was discussed.
|
||||
*/
|
||||
export function searchNotes(notes: NoteDoc[], query: string, limit = 50): NoteHit[] {
|
||||
const words = fold(query)
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 0);
|
||||
if (words.length === 0) return [];
|
||||
|
||||
const hits: NoteHit[] = [];
|
||||
for (const note of notes) {
|
||||
// A day note answers per lesson, for the same reason the index does: a
|
||||
// hit that says "my note, Monday" names neither the subject nor what it
|
||||
// was about.
|
||||
const sections = noteSections(note);
|
||||
const pieces =
|
||||
sections.length > 0
|
||||
? sections.map((section) => ({
|
||||
heading: section.heading,
|
||||
subject: section.subject ?? note.subject,
|
||||
text: `${section.heading}\n${section.text}`,
|
||||
}))
|
||||
: [{ heading: undefined, subject: note.subject, text: `${note.title}\n${note.text}` }];
|
||||
|
||||
for (const piece of pieces) {
|
||||
const haystack = fold(`${piece.text}\n${note.tags.join(' ')}`);
|
||||
if (!words.every((word) => haystack.includes(word))) continue;
|
||||
hits.push({
|
||||
path: note.path,
|
||||
title: note.title,
|
||||
...(note.date ? { date: note.date } : {}),
|
||||
...(piece.subject ? { subject: piece.subject } : {}),
|
||||
...(piece.heading ? { heading: piece.heading } : {}),
|
||||
snippet: snippetAround(piece.text, words[0]!),
|
||||
});
|
||||
if (hits.length >= limit) return hits;
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case and accents removed, so "Erörterung" and "erorterung" are one word.
|
||||
*
|
||||
* The Postgres index does this with a German configuration; here it is plain
|
||||
* Unicode folding, which is enough for "find the lesson I am thinking of" and
|
||||
* has no stemming — a search for "Argumente" will not find "Argument".
|
||||
*/
|
||||
function fold(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
/** The line the first word matched, with the next one, as readable prose. */
|
||||
function snippetAround(text: string, word: string): string {
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0);
|
||||
const at = lines.findIndex((line) => fold(line).includes(word));
|
||||
const preview = lines
|
||||
.slice(Math.max(0, at === -1 ? 0 : at), (at === -1 ? 0 : at) + 2)
|
||||
.map(plainText)
|
||||
.join(' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
return preview.length > 240 ? `${preview.slice(0, 237)}…` : preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of Markdown as the words it holds.
|
||||
*
|
||||
* Wherever a note is *shown* rather than edited — a search snippet, a row in
|
||||
* the app's note list — this is what it goes through. A line is read there,
|
||||
* not parsed: `| 1NF | atomare Werte |` says more as "1NF · atomare Werte",
|
||||
* and `_Fazit_` says exactly as much as "Fazit" while looking like a mistake.
|
||||
*/
|
||||
export function plainText(line: string): string {
|
||||
let value = line.trim();
|
||||
if (/^\|.*\|$/.test(value)) {
|
||||
// A table row, including the `|---|---|` rule, which says nothing at all.
|
||||
if (/^\|[\s:|-]*\|$/.test(value)) return '';
|
||||
value = value.slice(1, -1).split('|').map((cell) => cell.trim()).filter(Boolean).join(' · ');
|
||||
}
|
||||
// One leading marker, not all of them in turn: a heading reading
|
||||
// `## 1. Deutsch` keeps its lesson number, which the list rule would
|
||||
// otherwise take for a bullet and eat.
|
||||
for (const marker of [/^#{1,6}\s+/, /^>\s?/, /^[-*+]\s+(\[[ xX]\]\s+)?/, /^\d{1,9}[.)]\s+/]) {
|
||||
if (marker.test(value)) {
|
||||
value = value.replace(marker, '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
value
|
||||
.replace(/(\*\*|__|~~)/g, '')
|
||||
// Single markers only where they are emphasis, so snake_case survives.
|
||||
.replace(/(?<![\w*])\*([^*]+)\*(?![\w*])/g, '$1')
|
||||
.replace(/(?<![\w_])_([^_]+)_(?![\w_])/g, '$1')
|
||||
.replace(/`+/g, '')
|
||||
// A link reads as its label; the target is not for a preview.
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\\([\\`*_[\]#>~|+.()-])/g, '$1')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
function isNoteFile(name: string): boolean {
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
NoteNotFound,
|
||||
filterNotes,
|
||||
readNoteAt,
|
||||
plainText,
|
||||
readNotes,
|
||||
noteSubjects,
|
||||
noteSections,
|
||||
searchNotes,
|
||||
replaceNote,
|
||||
writeNote,
|
||||
} from '../core/notes.ts';
|
||||
@@ -40,6 +44,24 @@ import type { Services } from '../services.ts';
|
||||
* index and mirror, `/token` only to the server's own token, and every upstream
|
||||
* call either triggers is a GET.
|
||||
*/
|
||||
/**
|
||||
* The first words of a note, for a list row.
|
||||
*
|
||||
* Headings and list markers are dropped: a row that reads "## 1. Deutsch —
|
||||
* 08:00" repeats what the row already says, and the point of the line is the
|
||||
* first thing that was actually written down.
|
||||
*/
|
||||
function previewOf(text: string): string {
|
||||
for (const line of text.split('\n')) {
|
||||
// Headings are skipped rather than stripped: the row above already says
|
||||
// which lessons the day holds, and repeating one is not a preview.
|
||||
if (/^#{1,6}\s/.test(line.trim())) continue;
|
||||
const plain = plainText(line);
|
||||
if (plain) return plain.length > 120 ? `${plain.slice(0, 117)}…` : plain;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const NO_NOTES_DIR =
|
||||
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
|
||||
|
||||
@@ -279,8 +301,20 @@ export function createApiRouter(services: Services): Router {
|
||||
writable: services.config.notesWritable,
|
||||
count: notes.length,
|
||||
// The body is dropped from a listing: a term of notes is megabytes,
|
||||
// and the CLI asks for the ones it wants by path.
|
||||
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
|
||||
// and the CLI asks for the ones it wants by path. What replaces it
|
||||
// is what a list *shows* — the lessons a day covers and a line of
|
||||
// its text — so the app's note list needs one request, not one per
|
||||
// note.
|
||||
notes: notes.slice(0, limit).map(({ text, ...rest }) => {
|
||||
const note = { ...rest, text };
|
||||
const sections = noteSections(note);
|
||||
return {
|
||||
...rest,
|
||||
subjects: noteSubjects(note),
|
||||
lessons: sections.length,
|
||||
preview: previewOf(note.text),
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
|
||||
@@ -288,6 +322,36 @@ export function createApiRouter(services: Services): Router {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Full text over the note files, for the app's search box.
|
||||
*
|
||||
* Reads disk rather than the index on purpose: notes reach the index only on
|
||||
* a full crawl, so a lesson written this morning would not be findable, and
|
||||
* "what did I write this week" is most of what anyone searches their own
|
||||
* notes for. `search` in MCP is the other one — it spans Schulcloud and the
|
||||
* class register too, at the cost of being as fresh as the last crawl.
|
||||
*/
|
||||
router.get('/notes/search', async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
const query = stringParam(req.query.q) ?? '';
|
||||
if (query.trim().length < 2) {
|
||||
return res.status(400).json({ error: 'invalid', message: 'Mindestens zwei Zeichen suchen.' });
|
||||
}
|
||||
try {
|
||||
const notes = filterNotes(await readNotes(root), {
|
||||
...pickParam('subject', req.query.subject),
|
||||
...pickParam('since', req.query.since),
|
||||
...pickParam('until', req.query.until),
|
||||
});
|
||||
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 50, 200);
|
||||
const hits = searchNotes(notes, query, limit);
|
||||
return res.json({ query, count: hits.length, hits });
|
||||
} catch (error) {
|
||||
return fail(res, error, 'search notes');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
|
||||
@@ -36,18 +36,34 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/*
|
||||
* A *definite* height, not a minimum.
|
||||
*
|
||||
* `min-height` leaves the page free to grow with its content, and a column of
|
||||
* flex boxes with no definite height at the top cannot cap anything below it:
|
||||
* with a hundred notes in the list, the list grew to its full height, the page
|
||||
* scrolled instead of the list, and the editor — a flex sibling stretching to
|
||||
* the row — became as tall as every note put together. Scrolling belongs to the
|
||||
* two panes, so the page itself must not have any.
|
||||
*/
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Fills the viewport on a phone, where 100vh lies about the toolbar. */
|
||||
min-height: 100dvh;
|
||||
height: 100vh;
|
||||
/* dvh where it exists: 100vh lies about the height while a phone's toolbar
|
||||
is on screen. */
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
|
||||
|
||||
/* The page does not scroll, so anything that is not itself a pane must. */
|
||||
#login, #view-settings { overflow-y: auto; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* --- chrome ------------------------------------------------------------ */
|
||||
@@ -72,6 +88,109 @@ header { border-bottom: 1px solid var(--line); }
|
||||
|
||||
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; }
|
||||
|
||||
/* --- the notes screen: a list, and the note that is open ---------------- */
|
||||
|
||||
/*
|
||||
* Two panes where there is room, one at a time where there is not. The
|
||||
* breakpoint is about where a phone in landscape stops being a phone: below it
|
||||
* `data-pane` on the container decides which of the two is on screen, and the
|
||||
* back button is the way out of the note.
|
||||
*/
|
||||
.notes { flex-direction: row; gap: 0; padding: 0; }
|
||||
|
||||
.rail {
|
||||
flex: 0 0 18rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.rail-head { display: flex; gap: 0.4rem; padding: 0.6rem 0.6rem 0.4rem; }
|
||||
|
||||
.rail-head form { flex: 1; min-width: 0; }
|
||||
|
||||
.rail-head input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#today { flex: 0 0 auto; width: 2.5rem; padding: 0; font-size: 1.1rem; line-height: 1; }
|
||||
|
||||
#rail-status { padding: 0 0.7rem 0.3rem; }
|
||||
|
||||
.rail-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 0.4rem 0.6rem; }
|
||||
|
||||
/* A whole row is the target: on a phone the thing being tapped is the note,
|
||||
not a link inside it. */
|
||||
.row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover { background: var(--bg); }
|
||||
|
||||
.row[aria-current="true"] {
|
||||
background: var(--bg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.row-title { font-weight: 600; font-size: 0.95rem; }
|
||||
.row-line { margin: 0.1rem 0 0; color: var(--muted); font-size: 0.85rem; line-height: 1.35; }
|
||||
/* Two lines of preview and no more: a row is a glance, not a read. */
|
||||
.row-line.clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row mark { background: color-mix(in srgb, var(--accent) 28%, transparent); color: inherit; border-radius: 0.15rem; }
|
||||
|
||||
.detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
/* The editor inside it scrolls; the pane itself never does, so the toolbar
|
||||
and the save button cannot be pushed off a short screen. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Only a phone needs a way back to the list; on a wide screen it is never gone. */
|
||||
.back { display: none; align-self: flex-start; padding: 0.4rem 0.7rem; font-size: 0.9rem; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.rail { flex: 1; border-right: 0; }
|
||||
.notes[data-pane="list"] .detail { display: none; }
|
||||
.notes[data-pane="note"] .rail { display: none; }
|
||||
.notes[data-pane="note"] .back { display: block; }
|
||||
}
|
||||
|
||||
.note-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.note-preview h2 { margin: 0; font-size: 1.05rem; }
|
||||
.note-preview p { margin: 0; }
|
||||
|
||||
/* --- the day bar ------------------------------------------------------- */
|
||||
|
||||
.daybar { display: flex; align-items: center; gap: 0.5rem; }
|
||||
@@ -141,7 +260,9 @@ header { border-bottom: 1px solid var(--line); }
|
||||
*/
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
/* Small enough that a landscape phone with its keyboard up still shows the
|
||||
toolbar and the actions rather than overflowing the pane. */
|
||||
min-height: 6rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
@@ -218,7 +339,7 @@ header { border-bottom: 1px solid var(--line); }
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
min-height: 6rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
@@ -295,8 +416,11 @@ input[type="password"], input[type="text"] {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#login { justify-content: center; }
|
||||
#login .card { width: min(24rem, 100%); align-self: center; }
|
||||
/* `margin: auto` rather than `justify-content: center`: a centred flex item in
|
||||
a scrolling container cannot be scrolled back to once it overflows the top,
|
||||
which on a short screen with the keyboard up would put the password field out
|
||||
of reach. */
|
||||
#login .card { width: min(24rem, calc(100% - 1.5rem)); margin: auto; }
|
||||
#login button { width: 100%; margin-top: 1rem; }
|
||||
|
||||
.steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEditor } from './editor.js';
|
||||
import { markdownToHtml } from './markdown.js';
|
||||
|
||||
/*
|
||||
* The notes app.
|
||||
@@ -39,6 +40,18 @@ const ui = {
|
||||
tabSettings: document.getElementById('tab-settings'),
|
||||
viewNotes: document.getElementById('view-notes'),
|
||||
viewSettings: document.getElementById('view-settings'),
|
||||
searchForm: document.getElementById('search-form'),
|
||||
searchInput: document.getElementById('search-input'),
|
||||
railStatus: document.getElementById('rail-status'),
|
||||
railList: document.getElementById('rail-list'),
|
||||
today: document.getElementById('today'),
|
||||
back: document.getElementById('back'),
|
||||
daybar: document.querySelector('.daybar'),
|
||||
actions: document.querySelector('.actions'),
|
||||
notePreview: document.getElementById('note-preview'),
|
||||
notePreviewTitle: document.getElementById('note-preview-title'),
|
||||
notePreviewPath: document.getElementById('note-preview-path'),
|
||||
notePreviewBody: document.getElementById('note-preview-body'),
|
||||
prev: document.getElementById('prev'),
|
||||
next: document.getElementById('next'),
|
||||
dayTitle: document.getElementById('day-title'),
|
||||
@@ -192,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.
|
||||
@@ -280,6 +305,7 @@ async function loadDay(date) {
|
||||
|
||||
describeLessons(info);
|
||||
ui.fill.hidden = !day.missing;
|
||||
markOpenRow();
|
||||
}
|
||||
|
||||
function describeLessons(info) {
|
||||
@@ -319,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);
|
||||
@@ -418,6 +447,250 @@ function addFact(term, value) {
|
||||
ui.serverState.append(dt, dd);
|
||||
}
|
||||
|
||||
// --- the note list -------------------------------------------------------
|
||||
|
||||
/*
|
||||
* The rail: every note, newest first, with the open one marked — and the
|
||||
* search box at the top of it, because searching your notes is a way of
|
||||
* finding one, not a separate place to be.
|
||||
*
|
||||
* Searching reads the **files** on the server rather than the Postgres index.
|
||||
* Notes reach that index only on a full crawl, so a lesson written this
|
||||
* morning would not be findable this morning, which is most of what anyone
|
||||
* searches their own notes for. The `search` tool in Claude is the other half:
|
||||
* it spans Schulcloud and the class register too, at the cost of being only as
|
||||
* fresh as the last crawl.
|
||||
*/
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 350;
|
||||
/** Day notes live at `2026/2026-09-04.md`; anything else opens read-only. */
|
||||
const DAY_NOTE = /^\d{4}\/(\d{4}-\d{2}-\d{2})\.md$/;
|
||||
|
||||
let searchTimer = 0;
|
||||
let searchTerms = [];
|
||||
|
||||
async function loadRail() {
|
||||
ui.railStatus.textContent = 'Wird geladen …';
|
||||
try {
|
||||
const listing = await api('/api/notes?limit=400');
|
||||
searchTerms = [];
|
||||
showRows(listing.notes.map(noteRow));
|
||||
ui.railStatus.textContent =
|
||||
listing.count === 0
|
||||
? 'Noch keine Notizen. + öffnet den heutigen Tag.'
|
||||
: listing.count + ' Notiz(en)' + (listing.notes.length < listing.count ? ', neueste 400' : '');
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.railList.replaceChildren();
|
||||
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.';
|
||||
}
|
||||
}
|
||||
|
||||
/** One note as a row: what it is, and the first thing written in it. */
|
||||
function noteRow(note) {
|
||||
const lessons = note.lessons > 0 ? note.lessons + ' Stunde' + (note.lessons === 1 ? '' : 'n') : '';
|
||||
const subjects = (note.subjects || []).join(' · ');
|
||||
return {
|
||||
path: note.path,
|
||||
date: note.date,
|
||||
title: note.title,
|
||||
// Subjects say more than the date repeated, and the preview says more
|
||||
// than either when a note is a single page of prose.
|
||||
line: subjects || lessons || note.preview || '',
|
||||
second: subjects && note.preview ? note.preview : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** One search hit as a row: the lesson it matched in, and why. */
|
||||
function hitRow(hit) {
|
||||
return {
|
||||
path: hit.path,
|
||||
date: hit.date,
|
||||
heading: hit.heading,
|
||||
title: hit.heading || hit.subject || hit.title,
|
||||
line: hit.date ? germanDate(hit.date) : hit.path,
|
||||
second: hit.snippet,
|
||||
mark: true,
|
||||
};
|
||||
}
|
||||
|
||||
function showRows(rows) {
|
||||
const list = document.createDocumentFragment();
|
||||
for (const row of rows) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'row';
|
||||
item.dataset.path = row.path;
|
||||
if (row.heading) item.dataset.heading = row.heading;
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'row-title';
|
||||
if (row.mark) highlight(title, row.title);
|
||||
else title.textContent = row.title;
|
||||
item.append(title);
|
||||
|
||||
for (const [text, clamp] of [
|
||||
[row.line, false],
|
||||
[row.second, true],
|
||||
]) {
|
||||
if (!text) continue;
|
||||
const line = document.createElement('p');
|
||||
line.className = 'row-line' + (clamp ? ' clamp' : '');
|
||||
if (row.mark) highlight(line, text);
|
||||
else line.textContent = text;
|
||||
item.append(line);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => void openRow(row));
|
||||
list.append(item);
|
||||
}
|
||||
ui.railList.replaceChildren(list);
|
||||
markOpenRow();
|
||||
}
|
||||
|
||||
/**
|
||||
* The matched words marked, without building HTML from them.
|
||||
*
|
||||
* A snippet is the user's own text, and text that has been through a URL and a
|
||||
* JSON response is exactly what should not be handed to `innerHTML`.
|
||||
*/
|
||||
function highlight(target, text) {
|
||||
const terms = searchTerms.map(fold).filter((term) => term.length > 1);
|
||||
if (terms.length === 0) {
|
||||
target.textContent = text;
|
||||
return;
|
||||
}
|
||||
const folded = fold(text);
|
||||
const marks = [];
|
||||
for (const term of terms) {
|
||||
for (let at = folded.indexOf(term); at !== -1; at = folded.indexOf(term, at + term.length)) {
|
||||
marks.push([at, at + term.length]);
|
||||
}
|
||||
}
|
||||
marks.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
let cursor = 0;
|
||||
for (const [from, to] of marks) {
|
||||
if (from < cursor) continue;
|
||||
target.append(text.slice(cursor, from));
|
||||
const mark = document.createElement('mark');
|
||||
mark.textContent = text.slice(from, to);
|
||||
target.append(mark);
|
||||
cursor = to;
|
||||
}
|
||||
target.append(text.slice(cursor));
|
||||
}
|
||||
|
||||
/** Lowercase without accents — the same folding the server searches with. */
|
||||
function fold(value) {
|
||||
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
function germanDate(date) {
|
||||
const parts = date.split('-');
|
||||
return parts[2] + '.' + parts[1] + '.' + parts[0];
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
window.clearTimeout(searchTimer);
|
||||
const value = query.trim();
|
||||
if (value.length === 0) return loadRail();
|
||||
if (value.length < 2) {
|
||||
ui.railStatus.textContent = 'Mindestens zwei Zeichen.';
|
||||
return;
|
||||
}
|
||||
ui.railStatus.textContent = 'Wird gesucht …';
|
||||
try {
|
||||
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=100');
|
||||
searchTerms = value.split(/\s+/).filter(Boolean);
|
||||
showRows(result.hits.map(hitRow));
|
||||
ui.railStatus.textContent =
|
||||
result.count === 0
|
||||
? 'Nichts gefunden — jedes Wort muss vorkommen.'
|
||||
: result.count + ' Treffer' + (result.count >= 100 ? ' (mehr vorhanden)' : '');
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.railList.replaceChildren();
|
||||
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A row, opened.
|
||||
*
|
||||
* A school day opens in the editor, because that is where it is written.
|
||||
* Anything else — an imported note, a page of revision — has no day to open, so
|
||||
* it is shown read-only rather than forced into a day-shaped screen.
|
||||
*/
|
||||
async function openRow(row) {
|
||||
const day = DAY_NOTE.exec(row.path);
|
||||
showPane('note');
|
||||
if (day) {
|
||||
ui.notePreview.hidden = true;
|
||||
showEditor(true);
|
||||
await loadDay(day[1]);
|
||||
// A search hit names a lesson, so put that lesson on screen rather than
|
||||
// the top of a day with six of them.
|
||||
if (row.heading) scrollToHeading(row.heading);
|
||||
return;
|
||||
}
|
||||
await showNoteReadOnly(row.path);
|
||||
}
|
||||
|
||||
function scrollToHeading(heading) {
|
||||
const wanted = fold(heading).trim();
|
||||
for (const element of ui.editor.querySelectorAll('h2')) {
|
||||
if (fold(element.textContent).trim() === wanted) {
|
||||
element.scrollIntoView({ block: 'start' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showNoteReadOnly(path) {
|
||||
showEditor(false);
|
||||
ui.notePreview.hidden = false;
|
||||
ui.notePreviewTitle.textContent = '…';
|
||||
try {
|
||||
const note = await api('/api/notes?path=' + encodeURIComponent(path));
|
||||
ui.notePreviewTitle.textContent = note.title;
|
||||
ui.notePreviewPath.textContent = note.path + ' — schreibgeschützt, weil diese Notiz kein Schultag ist.';
|
||||
// Markdown from our own store, through the parser the editor trusts with
|
||||
// the same input: it escapes everything it did not produce itself.
|
||||
ui.notePreviewBody.innerHTML = markdownToHtml(note.text ?? '');
|
||||
day.path = note.path;
|
||||
markOpenRow();
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.notePreviewTitle.textContent = 'Konnte die Notiz nicht öffnen';
|
||||
ui.notePreviewPath.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
/** The day editor and everything that belongs to it, on or off. */
|
||||
function showEditor(on) {
|
||||
for (const element of [ui.daybar, ui.dayStatus, ui.toolbar, ui.actions]) element.hidden = !on;
|
||||
ui.editor.hidden = !on || editor.mode !== 'rich';
|
||||
ui.source.hidden = !on || editor.mode !== 'source';
|
||||
// The hint belongs to whatever is loaded next; leaving it visible and empty
|
||||
// would cost a line of editor for nothing.
|
||||
if (!on) {
|
||||
ui.conflict.hidden = true;
|
||||
ui.editorHint.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function showPane(pane) {
|
||||
ui.viewNotes.dataset.pane = pane;
|
||||
}
|
||||
|
||||
/** Marks the row whose note is open, whichever list is showing. */
|
||||
function markOpenRow() {
|
||||
for (const row of ui.railList.querySelectorAll('.row')) {
|
||||
row.setAttribute('aria-current', String(row.dataset.path === day.path));
|
||||
}
|
||||
}
|
||||
|
||||
// --- views ---------------------------------------------------------------
|
||||
|
||||
function showLogin() {
|
||||
@@ -450,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.';
|
||||
@@ -465,10 +740,31 @@ ui.logout.addEventListener('click', async () => {
|
||||
ui.tabNotes.addEventListener('click', () => showTab('notes'));
|
||||
ui.tabSettings.addEventListener('click', () => showTab('settings'));
|
||||
|
||||
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
|
||||
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
|
||||
ui.searchForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
void runSearch(ui.searchInput.value);
|
||||
});
|
||||
|
||||
ui.searchInput.addEventListener('input', () => {
|
||||
// As you type, but not on every keystroke: each search re-reads the notes
|
||||
// directory on the server.
|
||||
window.clearTimeout(searchTimer);
|
||||
const value = ui.searchInput.value;
|
||||
searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
ui.today.addEventListener('click', () => {
|
||||
// The day you are in, whether or not it has a note yet — the one thing the
|
||||
// list cannot show, because an empty day is not a note.
|
||||
void openRow({ path: dayPathFor(today()) });
|
||||
});
|
||||
|
||||
ui.back.addEventListener('click', () => showPane('list'));
|
||||
|
||||
ui.prev.addEventListener('click', () => void openDay(shiftDate(day.date, -1)));
|
||||
ui.next.addEventListener('click', () => void openDay(shiftDate(day.date, 1)));
|
||||
ui.dayDate.addEventListener('change', () => {
|
||||
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
|
||||
if (ui.dayDate.value) void openDay(ui.dayDate.value);
|
||||
});
|
||||
|
||||
ui.save.addEventListener('click', () => void saveDay(false));
|
||||
@@ -531,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();
|
||||
})();
|
||||
|
||||
@@ -346,10 +346,18 @@ export function createEditor(options) {
|
||||
rich.addEventListener('paste', (event) => {
|
||||
if (!enabled || mode !== 'rich') return;
|
||||
const data = event.clipboardData;
|
||||
if (!data) return;
|
||||
const html = data.getData('text/html');
|
||||
const text = data.getData('text/plain');
|
||||
if (!html && !text) return;
|
||||
const html = data ? data.getData('text/html') : '';
|
||||
const text = data ? data.getData('text/plain') : '';
|
||||
if (!html && !text) {
|
||||
// Nothing readable in the event — either the paste really is empty, or
|
||||
// this engine withholds the clipboard. Let it happen and tidy after,
|
||||
// because raw pasted markup sitting in the document is what the
|
||||
// toolbar cannot format and the serializer should never have to meet.
|
||||
// Only if something actually arrived: an empty paste must not move the
|
||||
// caret or mark the note changed.
|
||||
tidyIfChanged();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
let markdown;
|
||||
@@ -366,6 +374,28 @@ export function createEditor(options) {
|
||||
notify();
|
||||
});
|
||||
|
||||
/** Tidies the document after a paste this editor could not read. */
|
||||
function tidyIfChanged() {
|
||||
const before = rich.innerHTML;
|
||||
window.setTimeout(() => {
|
||||
if (rich.innerHTML !== before) normalise();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The document, reduced to what this editor models.
|
||||
*
|
||||
* Everything the round trip does not understand is dropped here rather than
|
||||
* being carried around until a save, and the caret is placed at the end
|
||||
* because there is no way to keep it across a rebuild.
|
||||
*/
|
||||
function normalise() {
|
||||
rich.innerHTML = markdownToHtml(markdownFromDom(rich));
|
||||
ensureTrailingParagraph();
|
||||
if (rich.lastElementChild) placeCaret(rich.lastElementChild);
|
||||
notify();
|
||||
}
|
||||
|
||||
rich.addEventListener('keydown', (event) => {
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
if (modifier && !event.altKey) {
|
||||
|
||||
@@ -32,7 +32,24 @@
|
||||
</header>
|
||||
|
||||
<!-- Notes: one school day per note, one heading per lesson. -->
|
||||
<main id="view-notes" class="view">
|
||||
<!-- Two panes: the notes on the left, the open one on the right. Side by
|
||||
side where there is room; one at a time on a phone, where `data-pane`
|
||||
says which, and the back button returns to the list. -->
|
||||
<main id="view-notes" class="view notes" data-pane="list">
|
||||
<aside class="rail">
|
||||
<div class="rail-head">
|
||||
<form id="search-form" class="searchbar" role="search">
|
||||
<input id="search-input" type="search" inputmode="search" autocomplete="off"
|
||||
placeholder="Durchsuchen" aria-label="Notizen durchsuchen">
|
||||
</form>
|
||||
<button type="button" id="today" aria-label="Heutiger Tag" title="Heutiger Tag">+</button>
|
||||
</div>
|
||||
<p id="rail-status" class="status" role="status" aria-live="polite"></p>
|
||||
<div id="rail-list" class="rail-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="detail">
|
||||
<button type="button" id="back" class="back">‹ Notizen</button>
|
||||
<div class="daybar">
|
||||
<button type="button" id="prev" aria-label="Vorheriger Tag">‹</button>
|
||||
<div class="daybar-centre">
|
||||
@@ -83,6 +100,15 @@
|
||||
<button type="button" id="fill" hidden>Stunden ergänzen</button>
|
||||
<span id="lessons-hint" class="hint"></span>
|
||||
</div>
|
||||
|
||||
<!-- A note that is not a school day — one from the import, a page of
|
||||
revision — has no day to open, so it is shown rather than edited. -->
|
||||
<article id="note-preview" class="note-preview" hidden>
|
||||
<h2 id="note-preview-title"></h2>
|
||||
<p id="note-preview-path" class="hint"></p>
|
||||
<div id="note-preview-body" class="editor" aria-readonly="true"></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Settings: the Schulcloud token, and what the server is doing. -->
|
||||
|
||||
@@ -384,7 +384,13 @@ function serializeBlocks(node) {
|
||||
};
|
||||
|
||||
for (const child of children(node)) {
|
||||
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) {
|
||||
// A block *inside* an inline element is still a block. WebKit wraps a
|
||||
// copied selection in one span carrying the computed style of everything
|
||||
// in it, so a paste from Apple Notes arrives as
|
||||
// `<span style="font-weight: 700"><div>…</div><div>…</div></span>` —
|
||||
// and reading that span as inline flattened a whole note into one
|
||||
// paragraph and made every word of it bold.
|
||||
if (child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || holdsBlock(child))) {
|
||||
flush();
|
||||
const block = serializeBlock(child);
|
||||
if (block) out.push(block);
|
||||
@@ -438,7 +444,10 @@ function serializeBlock(element) {
|
||||
// asking what is inside.
|
||||
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
||||
default:
|
||||
return paragraph(inlineFrom(children(element)));
|
||||
// Anything else that reached this function is here because it holds
|
||||
// blocks — a paste wrapper, most often. Its own tag means nothing;
|
||||
// what it contains means everything.
|
||||
return holdsBlock(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,9 +581,15 @@ function inlineNode(node) {
|
||||
return emphasise(inlineFrom(children(node)), '~~');
|
||||
case 'SPAN':
|
||||
case 'FONT': {
|
||||
// What a paste leaves behind. The tag says nothing; the style might.
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
// What a paste leaves behind. The tag says nothing; the style might
|
||||
// — but only for a span wrapping one run of text. A span with
|
||||
// elements inside it is a container carrying inherited style, not
|
||||
// emphasis: WebKit hangs the whole computed style of a copied
|
||||
// selection on such a wrapper, and honouring its `font-weight: 700`
|
||||
// is what made an entire pasted note bold.
|
||||
const inner = inlineFrom(children(node));
|
||||
if (!isTextOnly(node)) return inner;
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
if (/font-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
|
||||
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
|
||||
return inner;
|
||||
@@ -647,6 +662,26 @@ function hasBlockChild(element) {
|
||||
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a block hides anywhere under this element.
|
||||
*
|
||||
* Pastes nest wrappers several deep — `<span><span><div>` — so the answer has
|
||||
* to be looked for rather than checked one level down. Bounded, because the
|
||||
* tree comes from a clipboard and nothing here should be able to hang on one.
|
||||
*/
|
||||
function holdsBlock(element, depth = 0) {
|
||||
if (depth > 6) return false;
|
||||
return children(element).some(
|
||||
(child) =>
|
||||
child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || child.nodeName === 'LI' || holdsBlock(child, depth + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/** A span with nothing but text in it — the only shape whose style is emphasis. */
|
||||
function isTextOnly(element) {
|
||||
return children(element).every((child) => child.nodeType === 3 || child.nodeName === 'BR');
|
||||
}
|
||||
|
||||
function children(node) {
|
||||
return Array.prototype.slice.call(node.childNodes ?? []);
|
||||
}
|
||||
|
||||
@@ -240,3 +240,46 @@ test('a row the editor appended round-trips', () => {
|
||||
'<tbody><tr><td>1</td><td>2</td></tr><tr><td><br></td><td><br></td></tr></tbody></table>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
|
||||
});
|
||||
|
||||
test('a paste from Apple Notes keeps its structure and is not all bold', () => {
|
||||
// What WebKit actually puts on the clipboard: one wrapper span carrying the
|
||||
// *computed* style of everything copied — including `font-weight: 700` —
|
||||
// with the real blocks nested inside it. Read naively that makes the whole
|
||||
// note bold and flattens every line into one paragraph.
|
||||
const html =
|
||||
'<meta charset="UTF-8"><span style="color: rgb(0, 0, 0); font-family: Helvetica; ' +
|
||||
'font-size: 16px; font-weight: 700; text-align: start; -webkit-text-stroke-width: 0px; ' +
|
||||
'display: inline !important; float: none;">' +
|
||||
'<div><b>Erörterung</b></div><div><br></div><div>These, Argument, Fazit</div>' +
|
||||
'<ul><li>Gegenargument nicht vergessen</li><li>Fazit knapp halten</li></ul></span>';
|
||||
assert.equal(
|
||||
markdownFromDom(parseHtml(html)),
|
||||
'**Erörterung**\n\nThese, Argument, Fazit\n\n- Gegenargument nicht vergessen\n- Fazit knapp halten',
|
||||
);
|
||||
});
|
||||
|
||||
test('a container\'s font never swallows the blocks inside it', () => {
|
||||
// The general rule behind the case above: an element holding blocks is a
|
||||
// container whatever its tag, and a container's style is not emphasis.
|
||||
const html = '<span style="font-weight: bold"><h2>Deutsch</h2><p>Text</p></span>';
|
||||
assert.equal(markdownFromDom(parseHtml(html)), '## Deutsch\n\nText');
|
||||
});
|
||||
|
||||
test('a styled span around a single run is still emphasis', () => {
|
||||
// The case the style check exists for, which must keep working.
|
||||
assert.equal(markdownFromDom(parseHtml('<p><span style="font-weight: 700">fett</span> rest</p>')), '**fett** rest');
|
||||
assert.equal(markdownFromDom(parseHtml('<p><span style="font-style: italic">kursiv</span></p>')), '_kursiv_');
|
||||
});
|
||||
|
||||
test('no word is ever lost, whatever the markup', () => {
|
||||
// The property that matters more than any particular shape: a note is
|
||||
// allowed to lose its formatting, never its words.
|
||||
const html =
|
||||
'<div><span style="font-weight:700"><div>Erste Zeile</div>' +
|
||||
'<blockquote><span><p>Zitat</p></span></blockquote>' +
|
||||
'<table><tr><td><div>Zelle</div></td></tr></table></span></div>';
|
||||
const markdown = markdownFromDom(parseHtml(html));
|
||||
for (const word of ['Erste', 'Zeile', 'Zitat', 'Zelle']) {
|
||||
assert.ok(markdown.includes(word), `lost "${word}" in: ${markdown}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
subjectFromHeading,
|
||||
notePathFor,
|
||||
parseNote,
|
||||
plainText,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
renderNote,
|
||||
searchNotes,
|
||||
splitFrontmatter,
|
||||
writeNote,
|
||||
} from '../src/core/notes.ts';
|
||||
@@ -361,3 +363,130 @@ describe('filterNotes', () => {
|
||||
assert.deepEqual(titles, ['B', 'lose']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNotes', () => {
|
||||
const day = parseNote(
|
||||
'2026/2026-09-04.md',
|
||||
[
|
||||
'---',
|
||||
'title: Freitag, 04.09.2026',
|
||||
'date: 2026-09-04',
|
||||
'---',
|
||||
'',
|
||||
'## 1. LF10 — 08:00–08:45',
|
||||
'',
|
||||
'Normalisierung: erste, zweite und dritte Normalform.',
|
||||
'',
|
||||
'## 2. Deutsch — 08:50–09:35',
|
||||
'',
|
||||
'Erörterung: These, Argument, Fazit.',
|
||||
].join('\n'),
|
||||
new Date(),
|
||||
0,
|
||||
);
|
||||
const loose = parseNote(
|
||||
'Deutsch/2026-09-15 Aufbau.md',
|
||||
['---', 'title: Aufbau', 'subject: Deutsch', '---', '', 'Gegenargument nicht vergessen.'].join('\n'),
|
||||
new Date(),
|
||||
0,
|
||||
);
|
||||
|
||||
it('answers with the lesson, not the day', () => {
|
||||
const [hit] = searchNotes([day], 'Normalform');
|
||||
assert.equal(hit?.heading, '1. LF10 — 08:00–08:45');
|
||||
assert.equal(hit?.subject, 'LF10');
|
||||
assert.equal(hit?.date, '2026-09-04');
|
||||
});
|
||||
|
||||
it('does not report a day because another of its lessons matched', () => {
|
||||
// The whole reason a day note is searched per section: "Erörterung" is
|
||||
// Deutsch, and reporting it as LF10 would be worse than not finding it.
|
||||
const hits = searchNotes([day], 'Erörterung');
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0]?.subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('ignores case and accents', () => {
|
||||
assert.equal(searchNotes([day], 'erorterung').length, 1);
|
||||
assert.equal(searchNotes([day], 'ERÖRTERUNG').length, 1);
|
||||
});
|
||||
|
||||
it('needs every word, in any order', () => {
|
||||
assert.equal(searchNotes([day], 'normalform erste').length, 1);
|
||||
assert.equal(searchNotes([day], 'normalform erörterung').length, 0);
|
||||
});
|
||||
|
||||
it('searches the heading itself, so a subject finds its lessons', () => {
|
||||
assert.equal(searchNotes([day], 'LF10').length, 1);
|
||||
});
|
||||
|
||||
it('treats a note without lessons as one piece', () => {
|
||||
const [hit] = searchNotes([loose], 'Gegenargument');
|
||||
assert.equal(hit?.path, 'Deutsch/2026-09-15 Aufbau.md');
|
||||
assert.equal(hit?.heading, undefined);
|
||||
assert.equal(hit?.subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('carries a snippet worth reading', () => {
|
||||
const [hit] = searchNotes([day], 'Normalform');
|
||||
assert.match(hit!.snippet, /erste, zweite und dritte Normalform/);
|
||||
});
|
||||
|
||||
it('shows the snippet as prose, not as Markdown', () => {
|
||||
const table = parseNote(
|
||||
'2026/2026-09-05.md',
|
||||
[
|
||||
'---',
|
||||
'title: Samstag',
|
||||
'---',
|
||||
'',
|
||||
'## LF10',
|
||||
'',
|
||||
'| Normalform | Bedingung |',
|
||||
'| --- | --- |',
|
||||
'| 1NF | atomare Werte |',
|
||||
].join('\n'),
|
||||
new Date(),
|
||||
0,
|
||||
);
|
||||
const [hit] = searchNotes([table], '1NF');
|
||||
// The pipes and the `|---|` rule say nothing to someone reading a result.
|
||||
assert.equal(hit?.snippet, '1NF · atomare Werte');
|
||||
});
|
||||
|
||||
it('strips the markers from a bullet or a heading in the snippet', () => {
|
||||
const [hit] = searchNotes([day], 'Argument');
|
||||
assert.equal(hit?.snippet.includes('**'), false);
|
||||
});
|
||||
|
||||
it('finds nothing for an empty query rather than everything', () => {
|
||||
assert.deepEqual(searchNotes([day, loose], ' '), []);
|
||||
});
|
||||
|
||||
it('stops at the limit', () => {
|
||||
assert.equal(searchNotes([day], 'e', 1).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainText', () => {
|
||||
it('reads a table row as its cells', () => {
|
||||
assert.equal(plainText('| 1NF | atomare Werte |'), '1NF · atomare Werte');
|
||||
assert.equal(plainText('| --- | --- |'), '');
|
||||
});
|
||||
|
||||
it('drops the markers but keeps the words', () => {
|
||||
assert.equal(plainText('- **These**, Argument, _Fazit_'), 'These, Argument, Fazit');
|
||||
assert.equal(plainText('## 1. Deutsch'), '1. Deutsch');
|
||||
assert.equal(plainText('> Merksatz'), 'Merksatz');
|
||||
assert.equal(plainText('- [x] erledigt'), 'erledigt');
|
||||
});
|
||||
|
||||
it('leaves a word with an underscore in it alone', () => {
|
||||
assert.equal(plainText('snake_case_name bleibt'), 'snake_case_name bleibt');
|
||||
});
|
||||
|
||||
it('reads a link as its label and an escape as its character', () => {
|
||||
assert.equal(plainText('[Arbeitsblatt](https://example.org/ab.pdf)'), 'Arbeitsblatt');
|
||||
assert.equal(plainText('2 \\* 3'), '2 * 3');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user