4 Commits

Author SHA1 Message Date
MechaCat02
5db098b67f Merge: a pasted note kept whole, and search over the notes
The paste from Apple Notes was losing most of a note and bolding the
rest, which the Markdown view then showed as missing text. An element
holding blocks is now a block whatever its tag, and a container's style
is not emphasis.

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

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

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

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

**The search.** A Suche tab over the user's own notes, reading the files
rather than the index: notes reach the index only on a full crawl, so a
lesson written this morning would not be findable this morning, which is
most of what anyone searches their own notes for. A result names the
lesson it matched in, not the day, for the same reason the index indexes
day notes per section. Tapping one opens that day in the editor.

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

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

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

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

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

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

Includes the table dead end found on the first real use: a table at the
end of a contenteditable element cannot be typed past, in any engine.
2026-09-20 18:43:08 +02:00
16 changed files with 794 additions and 35 deletions

View File

@@ -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` /

View File

@@ -86,6 +86,10 @@ 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
between, so a lesson written this morning is findable this morning — and a
result names the lesson it matched in rather than the day.
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

View File

@@ -156,11 +156,17 @@ If you have notes in Apple Notes, migrate them now — see
[NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac:
```bash
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
# --folder takes that folder and everything under it; leave it off for all notes
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > notes.ndjson
wc -l notes.ndjson # one line per note
schulcloud note import notes.ndjson --dry-run # look first
schulcloud note import notes.ndjson
```
The export runs on the Mac; the import does not have to. The CLI talks to the
server over HTTP, so copying the `.ndjson` to whatever machine already has the
CLI configured is the shorter path.
Import **once**. Re-running creates second copies, because the importer cannot
tell an edited note from a new one with the same title.

View File

@@ -105,11 +105,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 +130,28 @@ 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
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*.
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.
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
@@ -189,11 +219,22 @@ not the clumsy route to your notes — it is the only one.
```bash
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
```
The first run raises a macOS permission dialog ("Terminal wants access to
Notes"); without it every note comes back empty. `--folder Deutsch` exports one
Notes folder.
Notes"); without it every note comes back empty.
**`--folder Schule` exports one folder and everything under it.** Folder names
are matched whole, not as a fragment, and the path is recorded relative to the
one you named — so `Schule/Deutsch` arrives as the subject `Deutsch`, and a note
sitting loose in `Schule` arrives with no subject rather than one called
"Schule". Reading a note's body is the expensive part, so filtering here rather
than afterwards is also what makes a large library finish.
The records go to standard output and the progress line to standard error, so
`> notes.ndjson` gets exactly the notes. Check it took: `wc -l notes.ndjson`
should be the number the script reported.
Then look at what it would do, and do it:

View File

@@ -5,7 +5,7 @@
* Run this **on the Mac that has the notes**:
*
* osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
* osascript -l JavaScript scripts/export-apple-notes.js --folder Deutsch > deutsch.ndjson
* osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
*
* then hand the file to `schulcloud note import notes.ndjson`.
*
@@ -18,9 +18,15 @@
* JXA and not AppleScript because it can serialise JSON, and because reading
* properties one note at a time is what keeps a locked note from aborting the
* run rather than a language preference.
*
* **Nothing here uses `console.log`.** In JXA it writes to standard *error*,
* not standard output — so `> notes.ndjson` produced an empty file while every
* note scrolled past on the terminal. Both streams are written through
* NSFileHandle below, which is the only way to be sure which one you are on.
*/
ObjC.import('stdlib');
ObjC.import('Foundation');
function run(argv) {
const options = parseArguments(argv);
@@ -41,23 +47,62 @@ function run(argv) {
let skipped = 0;
for (let i = 0; i < items.length; i++) {
const note = items[i];
const record = readNote(note);
// The folder first. The body is the one expensive property — it is
// decompressed per note and is what makes a big library take minutes —
// and a note in another folder is not worth reading one for.
const folder = folderUnder(safe(function () { return folderPath(note.container()); }, ''), options.folder);
if (folder === null) continue;
const record = readNote(note, folder);
if (!record) {
skipped++;
continue;
}
if (options.folder && (record.folder || '').toLowerCase().indexOf(options.folder.toLowerCase()) === -1) continue;
// One object per line, so a huge export streams and a bad note costs one line.
console.log(JSON.stringify(record));
emit(JSON.stringify(record));
written++;
}
// stderr, so it never lands in the file being redirected.
log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.');
log(
'Exported ' + written + ' note(s)' +
(options.folder ? ' from "' + options.folder + '"' : '') +
(skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') +
'.',
);
if (written === 0) {
log(
options.folder
? 'No note is in a folder called "' + options.folder + '". Folder names are matched whole, not as a fragment.'
: 'No notes were readable. Grant the terminal access to Notes and try again.',
);
}
return '';
}
function readNote(note) {
/**
* A note's folder as seen from the one asked for, or null if it is elsewhere.
*
* Matched by path segment, not substring: `--folder Schule` takes `Schule` and
* everything under it, and leaves `Musikschule` alone.
*
* The path is recorded *relative* to that folder, because the import reads the
* last segment as the subject: `Schule/Deutsch` has to arrive as `Deutsch`, and
* a note sitting loose in `Schule` has to arrive with no folder at all — a
* subject of "Schule" is not a subject. Without `--folder` the full path is
* kept, which is the same thing measured from the top.
*/
function folderUnder(path, wanted) {
if (!wanted) return path;
const parts = String(path).split('/');
const target = wanted.toLowerCase();
for (let i = 0; i < parts.length; i++) {
if (parts[i].trim().toLowerCase() === target) return parts.slice(i + 1).join('/');
}
return null;
}
function readNote(note, folder) {
try {
// Read the body first: it is the property a locked note refuses, and
// there is no point building a record we cannot fill.
@@ -66,7 +111,7 @@ function readNote(note) {
id: safe(function () { return note.id(); }, ''),
name: safe(function () { return note.name(); }, ''),
body: body || '',
folder: safe(function () { return folderPath(note.container()); }, ''),
folder: folder,
created: safe(function () { return iso(note.creationDate()); }, ''),
modified: safe(function () { return iso(note.modificationDate()); }, ''),
};
@@ -77,6 +122,7 @@ function readNote(note) {
id: safe(function () { return note.id(); }, ''),
name: safe(function () { return note.name(); }, '(unreadable)'),
body: '',
folder: folder,
error: String(error),
};
}
@@ -128,10 +174,18 @@ function parseArguments(argv) {
return options;
}
/** One line of the export, on standard output — the file being redirected. */
function emit(line) {
write($.NSFileHandle.fileHandleWithStandardOutput, line);
}
/** Progress and problems, on standard error, so they never land in the file. */
function log(message) {
$.NSFileHandle.fileHandleWithStandardError.writeData(
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding),
);
write($.NSFileHandle.fileHandleWithStandardError, message);
}
function write(handle, text) {
handle.writeData($.NSString.alloc.initWithUTF8String(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
}
function fail(message) {

View File

@@ -740,7 +740,9 @@ 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"'),
);
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
@@ -829,6 +831,41 @@ console.log('\n== web app ==');
`${bySubject.count} note(s)`,
);
// 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}`);

View File

@@ -550,6 +550,123 @@ 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(plainLine)
.join(' ')
.replace(/\s{2,}/g, ' ')
.trim();
return preview.length > 240 ? `${preview.slice(0, 237)}` : preview;
}
/**
* 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.
*/
function plainLine(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();
}
// --- helpers -------------------------------------------------------------
function isNoteFile(name: string): boolean {

View File

@@ -21,6 +21,7 @@ import {
filterNotes,
readNoteAt,
readNotes,
searchNotes,
replaceNote,
writeNote,
} from '../core/notes.ts';
@@ -288,6 +289,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 });

View File

@@ -271,6 +271,51 @@ 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

@@ -1,4 +1,5 @@
import { createEditor } from './editor.js';
import { markdownToHtml } from './markdown.js';
/*
* The notes app.
@@ -36,9 +37,19 @@ 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'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'),
@@ -418,6 +429,150 @@ function addFact(term, value) {
ui.serverState.append(dt, dd);
}
// --- search --------------------------------------------------------------
/*
* 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.
*/
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 …';
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)' : '') + '.';
} catch (error) {
if (error.message === 'unauthorized') return;
ui.searchResults.replaceChildren();
ui.searchStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
}
}
function showHits(hits) {
const list = document.createDocumentFragment();
for (const hit of hits) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'hit';
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 snippet = document.createElement('p');
snippet.className = 'hit-snippet';
highlight(snippet, hit.snippet);
card.append(head, snippet);
card.addEventListener('click', () => openHit(hit));
list.append(card);
}
ui.searchResults.replaceChildren(list);
}
/**
* 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.
*/
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 [start, end] of marks) {
if (start < cursor) continue;
target.append(text.slice(cursor, start));
const mark = document.createElement('mark');
mark.textContent = text.slice(start, end);
target.append(mark);
cursor = end;
}
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];
}
/**
* 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]);
return;
}
ui.searchStatus.textContent = 'Wird geöffnet …';
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;
} catch (error) {
if (error.message === 'unauthorized') return;
ui.searchStatus.textContent = 'Konnte die Notiz nicht öffnen: ' + error.message;
}
}
// --- views ---------------------------------------------------------------
function showLogin() {
@@ -432,12 +587,16 @@ function showApp() {
}
function showTab(name) {
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();
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();
}
// --- wiring --------------------------------------------------------------
@@ -463,8 +622,26 @@ 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) => {
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.searchBack.addEventListener('click', () => {
ui.searchNote.hidden = true;
});
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
ui.dayDate.addEventListener('change', () => {

View File

@@ -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) {

View File

@@ -27,6 +27,7 @@
<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>
@@ -85,6 +86,23 @@
</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>
</article>
</main>
<!-- Settings: the Schulcloud token, and what the server is doing. -->
<main id="view-settings" class="view" hidden>
<section class="card">

View File

@@ -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 ?? []);
}

View File

@@ -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}`);
}
});

View File

@@ -78,6 +78,14 @@ describe('convertAppleNote', () => {
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07');
});
it('gives no subject to a note the export could not place', () => {
// What `--folder Schule` produces for a note sitting loose in Schule:
// the path relative to the folder asked for, which is nothing. A subject
// of "Schule" would file every such note under a subject that is not one.
assert.equal(convertAppleNote({ ...note, folder: '' }).subject, undefined);
assert.equal(convertAppleNote({ ...note, folder: ' ' }).subject, undefined);
});
it('does not repeat the title as the first line of the body', () => {
const converted = convertAppleNote(note);
assert.equal(converted.title, 'Erörterung');

View File

@@ -17,6 +17,7 @@ import {
readNoteAt,
readNotes,
renderNote,
searchNotes,
splitFrontmatter,
writeNote,
} from '../src/core/notes.ts';
@@ -361,3 +362,107 @@ 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:0008:45',
'',
'Normalisierung: erste, zweite und dritte Normalform.',
'',
'## 2. Deutsch — 08:5009:35',
'',
'Erörterung: These, Argument, Fazit.',
].join('\n'),
new Date(),
0,
);
const loose = parseNote(
'Deutsch/2026-09-15 Aufbau.md',
['---', 'title: Aufbau', 'subject: Deutsch', '---', '', 'Gegenargument nicht vergessen.'].join('\n'),
new Date(),
0,
);
it('answers with the lesson, not the day', () => {
const [hit] = searchNotes([day], 'Normalform');
assert.equal(hit?.heading, '1. LF10 — 08:0008:45');
assert.equal(hit?.subject, 'LF10');
assert.equal(hit?.date, '2026-09-04');
});
it('does not report a day because another of its lessons matched', () => {
// The whole reason a day note is searched per section: "Erörterung" is
// Deutsch, and reporting it as LF10 would be worse than not finding it.
const hits = searchNotes([day], 'Erörterung');
assert.equal(hits.length, 1);
assert.equal(hits[0]?.subject, 'Deutsch');
});
it('ignores case and accents', () => {
assert.equal(searchNotes([day], 'erorterung').length, 1);
assert.equal(searchNotes([day], 'ERÖRTERUNG').length, 1);
});
it('needs every word, in any order', () => {
assert.equal(searchNotes([day], 'normalform erste').length, 1);
assert.equal(searchNotes([day], 'normalform erörterung').length, 0);
});
it('searches the heading itself, so a subject finds its lessons', () => {
assert.equal(searchNotes([day], 'LF10').length, 1);
});
it('treats a note without lessons as one piece', () => {
const [hit] = searchNotes([loose], 'Gegenargument');
assert.equal(hit?.path, 'Deutsch/2026-09-15 Aufbau.md');
assert.equal(hit?.heading, undefined);
assert.equal(hit?.subject, 'Deutsch');
});
it('carries a snippet worth reading', () => {
const [hit] = searchNotes([day], 'Normalform');
assert.match(hit!.snippet, /erste, zweite und dritte Normalform/);
});
it('shows the snippet as prose, not as Markdown', () => {
const table = parseNote(
'2026/2026-09-05.md',
[
'---',
'title: Samstag',
'---',
'',
'## LF10',
'',
'| Normalform | Bedingung |',
'| --- | --- |',
'| 1NF | atomare Werte |',
].join('\n'),
new Date(),
0,
);
const [hit] = searchNotes([table], '1NF');
// The pipes and the `|---|` rule say nothing to someone reading a result.
assert.equal(hit?.snippet, '1NF · atomare Werte');
});
it('strips the markers from a bullet or a heading in the snippet', () => {
const [hit] = searchNotes([day], 'Argument');
assert.equal(hit?.snippet.includes('**'), false);
});
it('finds nothing for an empty query rather than everything', () => {
assert.deepEqual(searchNotes([day, loose], ' '), []);
});
it('stops at the limit', () => {
assert.equal(searchNotes([day], 'e', 1).length, 1);
});
});