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>
This commit is contained in:
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
|
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
|
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
|
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.
|
here writes** — see Invariants. `docs/NOTES.md` is the guide.
|
||||||
- **`untis-history.ts`** — the class register read backwards, which is what
|
- **`untis-history.ts`** — the class register read backwards, which is what
|
||||||
puts "what did we actually cover" into the search index. Its whole reason
|
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
|
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.
|
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
|
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
|
- **`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
|
credential from everything else here: a password a person types, not a token a
|
||||||
program was configured with. scrypt at startup, a signed `HttpOnly` /
|
program was configured with. scrypt at startup, a signed `HttpOnly` /
|
||||||
|
|||||||
@@ -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,
|
indexed as its own lesson, so a search answers "my own note, Deutsch,
|
||||||
18.09.2026" rather than "Friday".
|
18.09.2026" rather than "Friday".
|
||||||
|
|
||||||
|
A **Suche** tab searches your own notes straight from the files — no crawl in
|
||||||
|
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,
|
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
|
||||||
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
|
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
|
||||||
underneath when you want it. The file on disk stays Markdown either way — that
|
underneath when you want it. The file on disk stays Markdown either way — that
|
||||||
|
|||||||
@@ -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 |
|
| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
|
||||||
| `MD` | the Markdown itself |
|
| `MD` | the Markdown itself |
|
||||||
|
|
||||||
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A
|
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. In a
|
||||||
paste from a web page or a PDF keeps its structure and loses its fonts, colours
|
table, `Tab` walks the cells and a `Tab` out of the last one adds a row;
|
||||||
and anything else that is not in the list above — pasted HTML is converted to
|
`Ctrl`/`Cmd`+`Enter` opens a paragraph after whatever block you are in.
|
||||||
Markdown before it reaches the page, which is what keeps a copied page from
|
|
||||||
bringing its script along.
|
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,
|
**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
|
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
|
that promise up: every construct in this document goes in and comes back out
|
||||||
unchanged.
|
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:00–08: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
|
**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
|
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
|
token set` and the older `/token` page do). It also shows the index's state and
|
||||||
|
|||||||
@@ -740,7 +740,9 @@ console.log('\n== web app ==');
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'the shell loads the app as a module, so its imports resolve',
|
'the shell loads the app as a module, so its imports resolve',
|
||||||
/<script type="module" src="app\.js">/.test(shellText) && 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();
|
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
|
||||||
@@ -829,6 +831,41 @@ console.log('\n== web app ==');
|
|||||||
`${bySubject.count} note(s)`,
|
`${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 });
|
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}`);
|
check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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 -------------------------------------------------------------
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
function isNoteFile(name: string): boolean {
|
function isNoteFile(name: string): boolean {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
filterNotes,
|
filterNotes,
|
||||||
readNoteAt,
|
readNoteAt,
|
||||||
readNotes,
|
readNotes,
|
||||||
|
searchNotes,
|
||||||
replaceNote,
|
replaceNote,
|
||||||
writeNote,
|
writeNote,
|
||||||
} from '../core/notes.ts';
|
} 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) => {
|
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
|
||||||
const root = services.config.notesDir;
|
const root = services.config.notesDir;
|
||||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||||
|
|||||||
@@ -271,6 +271,51 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- search ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.searchbar { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.searchbar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar button { background: var(--accent); border-color: var(--accent); color: #ffffff; font-weight: 600; }
|
||||||
|
|
||||||
|
.results { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
/* A whole result is the target: on a phone the thing being tapped is a card,
|
||||||
|
not a link inside one. */
|
||||||
|
.hit {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hit-head { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
|
||||||
|
.hit-subject { font-weight: 600; }
|
||||||
|
.hit-date { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
.hit-snippet { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.9rem; line-height: 1.4; }
|
||||||
|
.hit mark { background: color-mix(in srgb, var(--accent) 25%, transparent); color: inherit; border-radius: 0.15rem; }
|
||||||
|
|
||||||
|
.note-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.note-preview h2 { margin: 0; font-size: 1.05rem; }
|
||||||
|
.note-preview .editor { cursor: default; }
|
||||||
|
#search-back { align-self: flex-start; }
|
||||||
|
|
||||||
/* --- cards (login, settings) ------------------------------------------- */
|
/* --- cards (login, settings) ------------------------------------------- */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createEditor } from './editor.js';
|
import { createEditor } from './editor.js';
|
||||||
|
import { markdownToHtml } from './markdown.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The notes app.
|
* The notes app.
|
||||||
@@ -36,9 +37,19 @@ const ui = {
|
|||||||
loginError: document.getElementById('login-error'),
|
loginError: document.getElementById('login-error'),
|
||||||
app: document.getElementById('app'),
|
app: document.getElementById('app'),
|
||||||
tabNotes: document.getElementById('tab-notes'),
|
tabNotes: document.getElementById('tab-notes'),
|
||||||
|
tabSearch: document.getElementById('tab-search'),
|
||||||
tabSettings: document.getElementById('tab-settings'),
|
tabSettings: document.getElementById('tab-settings'),
|
||||||
viewNotes: document.getElementById('view-notes'),
|
viewNotes: document.getElementById('view-notes'),
|
||||||
|
viewSearch: document.getElementById('view-search'),
|
||||||
viewSettings: document.getElementById('view-settings'),
|
viewSettings: document.getElementById('view-settings'),
|
||||||
|
searchForm: document.getElementById('search-form'),
|
||||||
|
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'),
|
prev: document.getElementById('prev'),
|
||||||
next: document.getElementById('next'),
|
next: document.getElementById('next'),
|
||||||
dayTitle: document.getElementById('day-title'),
|
dayTitle: document.getElementById('day-title'),
|
||||||
@@ -418,6 +429,150 @@ function addFact(term, value) {
|
|||||||
ui.serverState.append(dt, dd);
|
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 ---------------------------------------------------------------
|
// --- views ---------------------------------------------------------------
|
||||||
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
@@ -432,12 +587,16 @@ function showApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showTab(name) {
|
function showTab(name) {
|
||||||
const notes = name !== 'settings';
|
for (const [tab, view, id] of [
|
||||||
ui.viewNotes.hidden = !notes;
|
[ui.tabNotes, ui.viewNotes, 'notes'],
|
||||||
ui.viewSettings.hidden = notes;
|
[ui.tabSearch, ui.viewSearch, 'search'],
|
||||||
ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
|
[ui.tabSettings, ui.viewSettings, 'settings'],
|
||||||
ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
|
]) {
|
||||||
if (!notes) void loadSettings();
|
view.hidden = name !== id;
|
||||||
|
tab.setAttribute('aria-current', name === id ? 'page' : 'false');
|
||||||
|
}
|
||||||
|
if (name === 'settings') void loadSettings();
|
||||||
|
if (name === 'search') ui.searchInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- wiring --------------------------------------------------------------
|
// --- wiring --------------------------------------------------------------
|
||||||
@@ -463,8 +622,26 @@ ui.logout.addEventListener('click', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ui.tabNotes.addEventListener('click', () => showTab('notes'));
|
ui.tabNotes.addEventListener('click', () => showTab('notes'));
|
||||||
|
ui.tabSearch.addEventListener('click', () => showTab('search'));
|
||||||
ui.tabSettings.addEventListener('click', () => showTab('settings'));
|
ui.tabSettings.addEventListener('click', () => showTab('settings'));
|
||||||
|
|
||||||
|
ui.searchForm.addEventListener('submit', (event) => {
|
||||||
|
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.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
|
||||||
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
|
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
|
||||||
ui.dayDate.addEventListener('change', () => {
|
ui.dayDate.addEventListener('change', () => {
|
||||||
|
|||||||
@@ -346,10 +346,18 @@ export function createEditor(options) {
|
|||||||
rich.addEventListener('paste', (event) => {
|
rich.addEventListener('paste', (event) => {
|
||||||
if (!enabled || mode !== 'rich') return;
|
if (!enabled || mode !== 'rich') return;
|
||||||
const data = event.clipboardData;
|
const data = event.clipboardData;
|
||||||
if (!data) return;
|
const html = data ? data.getData('text/html') : '';
|
||||||
const html = data.getData('text/html');
|
const text = data ? data.getData('text/plain') : '';
|
||||||
const text = data.getData('text/plain');
|
if (!html && !text) {
|
||||||
if (!html && !text) return;
|
// 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();
|
event.preventDefault();
|
||||||
|
|
||||||
let markdown;
|
let markdown;
|
||||||
@@ -366,6 +374,28 @@ export function createEditor(options) {
|
|||||||
notify();
|
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) => {
|
rich.addEventListener('keydown', (event) => {
|
||||||
const modifier = event.metaKey || event.ctrlKey;
|
const modifier = event.metaKey || event.ctrlKey;
|
||||||
if (modifier && !event.altKey) {
|
if (modifier && !event.altKey) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
|
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
|
||||||
|
<button type="button" id="tab-search" class="tab">Suche</button>
|
||||||
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
|
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -85,6 +86,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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. -->
|
<!-- Settings: the Schulcloud token, and what the server is doing. -->
|
||||||
<main id="view-settings" class="view" hidden>
|
<main id="view-settings" class="view" hidden>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
@@ -384,7 +384,13 @@ function serializeBlocks(node) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const child of children(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();
|
flush();
|
||||||
const block = serializeBlock(child);
|
const block = serializeBlock(child);
|
||||||
if (block) out.push(block);
|
if (block) out.push(block);
|
||||||
@@ -438,7 +444,10 @@ function serializeBlock(element) {
|
|||||||
// asking what is inside.
|
// asking what is inside.
|
||||||
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
||||||
default:
|
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)), '~~');
|
return emphasise(inlineFrom(children(node)), '~~');
|
||||||
case 'SPAN':
|
case 'SPAN':
|
||||||
case 'FONT': {
|
case 'FONT': {
|
||||||
// What a paste leaves behind. The tag says nothing; the style might.
|
// What a paste leaves behind. The tag says nothing; the style might
|
||||||
const style = node.getAttribute('style') ?? '';
|
// — 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));
|
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-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
|
||||||
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
|
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
|
||||||
return inner;
|
return inner;
|
||||||
@@ -647,6 +662,26 @@ function hasBlockChild(element) {
|
|||||||
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
|
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) {
|
function children(node) {
|
||||||
return Array.prototype.slice.call(node.childNodes ?? []);
|
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>';
|
'<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| | |');
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
readNoteAt,
|
readNoteAt,
|
||||||
readNotes,
|
readNotes,
|
||||||
renderNote,
|
renderNote,
|
||||||
|
searchNotes,
|
||||||
splitFrontmatter,
|
splitFrontmatter,
|
||||||
writeNote,
|
writeNote,
|
||||||
} from '../src/core/notes.ts';
|
} from '../src/core/notes.ts';
|
||||||
@@ -361,3 +362,107 @@ describe('filterNotes', () => {
|
|||||||
assert.deepEqual(titles, ['B', 'lose']);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user