Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
117
src/core/day-note.ts
Normal file
117
src/core/day-note.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { germanDay, germanWeekday } from './dates.ts';
|
||||
import type { UntisLesson, UntisTimetable } from './untis.ts';
|
||||
|
||||
/**
|
||||
* A school day as a note: one file, one heading per lesson.
|
||||
*
|
||||
* This is where the two systems meet for the third one. WebUntis is the only
|
||||
* place that knows which lessons a day actually holds — including that the
|
||||
* third period was cancelled and the fourth is a substitution — so a page that
|
||||
* asks someone to write up their day can hand them the day already laid out
|
||||
* instead of an empty box. The headings it writes are the ones
|
||||
* `subjectFromHeading` reads back, which is what makes each lesson separately
|
||||
* searchable afterwards.
|
||||
*/
|
||||
|
||||
export interface DayLesson {
|
||||
/** The heading text, without its `##`. */
|
||||
heading: string;
|
||||
subject?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
periodId: number;
|
||||
/** A substitution: worth knowing while writing, since the teacher differs. */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** `Montag, 15.09.2026` — what a day note is called. */
|
||||
export function dayNoteTitle(date: string): string {
|
||||
return `${germanWeekday(date)}, ${germanDay(date)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One lesson's heading: `1. DE — 08:00–08:45 · Meier · R 204`.
|
||||
*
|
||||
* The subject leads, because that is the part a person scans for and the part
|
||||
* the parser reads back. Everything after the first `·` is context and may be
|
||||
* edited away without breaking anything.
|
||||
*/
|
||||
export function lessonHeading(lesson: UntisLesson, index: number): string {
|
||||
const subject = lesson.subjects[0];
|
||||
const name = subject?.longName || subject?.name || 'Stunde';
|
||||
const teachers = lesson.teachers.map((teacher) => teacher.name).join(', ');
|
||||
const rooms = lesson.rooms.map((room) => room.name).join(', ');
|
||||
return [
|
||||
`${index + 1}. ${name} — ${lesson.start}–${lesson.end}`,
|
||||
teachers || undefined,
|
||||
rooms ? `R ${rooms}` : undefined,
|
||||
lesson.changed ? 'Vertretung' : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's lessons, in order, as headings.
|
||||
*
|
||||
* Cancelled periods are left out: nothing was taught in them, and a heading
|
||||
* with nothing under it is worse than no heading. A substitution is kept and
|
||||
* marked, because it did happen and its teacher is not the usual one.
|
||||
*/
|
||||
export function dayLessons(timetable: UntisTimetable, date: string): DayLesson[] {
|
||||
const day = timetable.days.find((entry) => entry.date === date);
|
||||
const held = (day?.lessons ?? []).filter((lesson) => !lesson.cancelled);
|
||||
return held.map((lesson, index) => {
|
||||
const subject = lesson.subjects[0];
|
||||
return {
|
||||
heading: lessonHeading(lesson, index),
|
||||
...(subject?.longName || subject?.name ? { subject: subject!.longName || subject!.name } : {}),
|
||||
start: lesson.start,
|
||||
end: lesson.end,
|
||||
periodId: lesson.periodId,
|
||||
changed: lesson.changed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The starting text for a day's note: a heading per lesson, blank beneath.
|
||||
*
|
||||
* Blank rather than prompted — a placeholder line would have to be deleted in
|
||||
* every lesson of every day, and half of them would survive into the note.
|
||||
*/
|
||||
export function dayNoteSkeleton(lessons: DayLesson[]): string {
|
||||
if (lessons.length === 0) return '';
|
||||
return `${lessons.map((lesson) => `## ${lesson.heading}\n`).join('\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headings a note is missing, for a day whose timetable is known.
|
||||
*
|
||||
* Someone who starts a note before the day ends, or whose timetable changed
|
||||
* after they started, should be able to top it up without losing what they
|
||||
* wrote — so the page adds what is absent rather than rebuilding the note.
|
||||
*/
|
||||
export function missingHeadings(text: string, lessons: DayLesson[]): DayLesson[] {
|
||||
const present = new Set(
|
||||
text
|
||||
.split('\n')
|
||||
.map((line) => /^##\s+(.*\S)\s*$/.exec(line)?.[1])
|
||||
.filter((heading): heading is string => Boolean(heading))
|
||||
.map(headingKey),
|
||||
);
|
||||
return lessons.filter((lesson) => !present.has(headingKey(lesson.heading)));
|
||||
}
|
||||
|
||||
/**
|
||||
* What makes two headings "the same lesson".
|
||||
*
|
||||
* The period number and the subject, ignoring everything a person may have
|
||||
* rewritten — a heading edited from `2. DE — 08:50–09:35 · Meier` down to
|
||||
* `2. Deutsch` is still the second period, and adding it again would give the
|
||||
* day two.
|
||||
*/
|
||||
function headingKey(heading: string): string {
|
||||
const match = /^\s*(\d{1,2})\s*[.)]/.exec(heading);
|
||||
return match ? `#${match[1]}` : heading.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
||||
import { h5pSearchText } from './h5p.ts';
|
||||
import { noteSearchText } from './notes.ts';
|
||||
import { noteSearchText, noteSections } from './notes.ts';
|
||||
import { matchesAll, snippet, tokenize } from './text.ts';
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,26 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
|
||||
// follows a note back to a course, and the subject is what makes the hit
|
||||
// readable — "Deutsch — my note" rather than a bare path.
|
||||
for (const note of snapshot.notes) {
|
||||
// Per lesson where the note has lessons, exactly as the index does it —
|
||||
// otherwise fresh=true would report "Monday" where the index reports
|
||||
// "Deutsch, Monday", and the two paths would disagree about the same file.
|
||||
const sections = noteSections(note);
|
||||
if (sections.length > 0) {
|
||||
for (const section of sections) {
|
||||
const haystack = [section.heading, section.text].filter(Boolean).join('\n');
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
courseId: note.courseId ?? '',
|
||||
courseTitle: section.subject ?? note.subject ?? 'Notizen',
|
||||
where: `my own note, ${section.heading}${note.date ? `, ${note.date}` : ''}`,
|
||||
targetId: note.path,
|
||||
targetKind: 'note',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const haystack = noteSearchText(note);
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
|
||||
@@ -195,12 +195,20 @@ export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: s
|
||||
|
||||
const front: NoteFrontmatter = {};
|
||||
const extra: Record<string, string> = {};
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
const lines = block.split(/\r?\n/);
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const key = match[1]!.toLowerCase();
|
||||
const value = unquote(match[2]!.trim());
|
||||
if (!value) continue;
|
||||
let value = unquote(match[2]!.trim());
|
||||
// `tags:` followed by indented `- item` lines is how Obsidian and most
|
||||
// YAML front ends write a list, and reading only the inline `[a, b]`
|
||||
// form dropped every tag such an editor had written.
|
||||
if (!value) {
|
||||
const items = blockList(lines, index);
|
||||
if (items.length === 0) continue;
|
||||
value = `[${items.join(', ')}]`;
|
||||
}
|
||||
switch (key) {
|
||||
case 'title':
|
||||
front.title = value;
|
||||
@@ -305,6 +313,69 @@ export async function writeNote(root: string, input: NoteInput): Promise<{ note:
|
||||
return { note: await readNoteAt(root, target), appended: false };
|
||||
}
|
||||
|
||||
/** Raised when a note changed under an editor that was holding it open. */
|
||||
export class NoteConflict extends Error {
|
||||
readonly path: string;
|
||||
readonly modifiedAt: string;
|
||||
|
||||
constructor(path: string, modifiedAt: string) {
|
||||
super(`The note "${path}" was changed by something else since it was loaded.`);
|
||||
this.name = 'NoteConflict';
|
||||
this.path = path;
|
||||
this.modifiedAt = modifiedAt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a note's contents — what an editor does when it saves.
|
||||
*
|
||||
* Separate from `writeNote`, which never overwrites: that rule protects
|
||||
* `add_note` from clobbering a note it did not mean to touch, and it is exactly
|
||||
* wrong for a page whose whole job is editing the day in front of you.
|
||||
*
|
||||
* `expectedModifiedAt` is how the two stay compatible. The notes are a folder
|
||||
* that may be synced and is certainly open in more than one place — a phone in
|
||||
* the lesson, a laptop after it — so a save that would overwrite a version the
|
||||
* editor never saw is refused rather than silently winning.
|
||||
*/
|
||||
export async function replaceNote(
|
||||
root: string,
|
||||
relative: string,
|
||||
input: { title: string; text: string; date?: string; subject?: string; courseId?: string; tags?: string[]; source?: string },
|
||||
options: { expectedModifiedAt?: string } = {},
|
||||
): Promise<NoteDoc> {
|
||||
const path = normalizeRelative(relative);
|
||||
const absolute = resolveWithin(root, path);
|
||||
|
||||
const info = await stat(absolute).catch(() => undefined);
|
||||
if (info && options.expectedModifiedAt) {
|
||||
// Second resolution: some filesystems and most sync tools do not preserve
|
||||
// milliseconds, so comparing the full ISO string would report a conflict
|
||||
// for a file nobody touched.
|
||||
const seen = Math.floor(new Date(options.expectedModifiedAt).getTime() / 1000);
|
||||
const actual = Math.floor(info.mtime.getTime() / 1000);
|
||||
if (Number.isFinite(seen) && actual > seen) throw new NoteConflict(path, info.mtime.toISOString());
|
||||
}
|
||||
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
await writeFile(
|
||||
absolute,
|
||||
renderNote(
|
||||
{
|
||||
title: input.title,
|
||||
...(input.date ? { date: input.date } : {}),
|
||||
...pick('subject', input.subject),
|
||||
...pick('courseId', input.courseId),
|
||||
...pick('source', input.source),
|
||||
...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
|
||||
},
|
||||
input.text,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
return readNoteAt(root, path);
|
||||
}
|
||||
|
||||
/** A note as it is stored: frontmatter, then the body. */
|
||||
export function renderNote(front: NoteFrontmatter, body: string): string {
|
||||
const lines = [
|
||||
@@ -362,6 +433,86 @@ async function freePath(root: string, relative: string): Promise<string> {
|
||||
throw new Error(`Too many notes named like ${relative}.`);
|
||||
}
|
||||
|
||||
// --- sections: one note per school day, one heading per lesson -------------
|
||||
|
||||
/**
|
||||
* A `##` section of a note, which for a day note is one lesson.
|
||||
*
|
||||
* The shape the notes page writes — one note per school day, titled with the
|
||||
* date, a heading per timetable lesson, prose and lists and tables beneath —
|
||||
* is the shape people actually take notes in, and it is the reason this exists.
|
||||
* Indexing such a note whole would make every hit read "my note, Monday" and
|
||||
* lose the one thing that makes it findable: which lesson it was.
|
||||
*/
|
||||
export interface NoteSection {
|
||||
/** The heading text, without its `##`. */
|
||||
heading: string;
|
||||
/** The subject read out of the heading, when there is one. */
|
||||
subject?: string;
|
||||
/** The body under the heading, subheadings included. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `##` sections of a note, in order. Empty when it has none — a subject
|
||||
* note from the import is one piece of prose, and has to stay that way.
|
||||
*
|
||||
* Fenced code blocks are skipped, or a `## comment` inside one would split the
|
||||
* note where nobody wrote a heading.
|
||||
*/
|
||||
export function noteSections(note: NoteDoc): NoteSection[] {
|
||||
const sections: NoteSection[] = [];
|
||||
let current: { heading: string; lines: string[] } | undefined;
|
||||
let fence: string | undefined;
|
||||
|
||||
for (const line of note.text.split('\n')) {
|
||||
const fenceMark = /^\s{0,3}(```+|~~~+)/.exec(line);
|
||||
if (fenceMark) {
|
||||
if (!fence) fence = fenceMark[1]![0];
|
||||
else if (fenceMark[1]!.startsWith(fence)) fence = undefined;
|
||||
}
|
||||
const heading = fence ? null : /^##\s+(.*\S)\s*$/.exec(line);
|
||||
if (heading) {
|
||||
if (current) sections.push(toSection(current));
|
||||
current = { heading: heading[1]!, lines: [] };
|
||||
continue;
|
||||
}
|
||||
if (current) current.lines.push(line);
|
||||
}
|
||||
if (current) sections.push(toSection(current));
|
||||
return sections;
|
||||
}
|
||||
|
||||
function toSection(raw: { heading: string; lines: string[] }): NoteSection {
|
||||
const subject = subjectFromHeading(raw.heading);
|
||||
return { heading: raw.heading, ...(subject ? { subject } : {}), text: raw.lines.join('\n').trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject a lesson heading names.
|
||||
*
|
||||
* Lenient on purpose: the page writes `1. Deutsch — 08:00–08:45 · Meier`, but
|
||||
* a heading typed by hand is just `Deutsch`, and both have to work. Leading
|
||||
* period numbers and clock times are stripped, then the subject is whatever
|
||||
* comes before the first separator.
|
||||
*/
|
||||
export function subjectFromHeading(heading: string): string | undefined {
|
||||
let value = heading.trim();
|
||||
value = value.replace(/^\d{1,2}\s*[.)]\s*/, '');
|
||||
value = value.replace(/^(\d{1,2}:\d{2}\s*[–—-]\s*\d{1,2}:\d{2}|\d{1,2}:\d{2})\s*[–—·|-]?\s*/, '');
|
||||
value = value.split(/\s[–—·|]\s|\s{2,}|\(/)[0]!.trim();
|
||||
value = value.replace(/[:,;]+$/, '').trim();
|
||||
// A heading that is only a time, a number or punctuation names no subject,
|
||||
// and guessing one would file the lesson under nonsense.
|
||||
if (!value || !/[\p{L}]/u.test(value) || value.length > 60) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Where a school day's note lives: `2026/2026-09-15.md`. */
|
||||
export function dayNotePath(date: string): string {
|
||||
return `${date.slice(0, 4)}/${date}.md`;
|
||||
}
|
||||
|
||||
// --- matching ------------------------------------------------------------
|
||||
|
||||
/** Everything about a note that search should look at, as one string. */
|
||||
@@ -369,6 +520,19 @@ export function noteSearchText(note: NoteDoc): string {
|
||||
return [note.title, note.subject, note.tags.join(' '), note.text].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every subject a note covers: its own, plus one per lesson heading.
|
||||
*
|
||||
* A day note has no subject of its own and covers five or six, so asking only
|
||||
* the frontmatter would make "my Deutsch notes" return nothing at all for
|
||||
* anyone who writes a note per day.
|
||||
*/
|
||||
export function noteSubjects(note: NoteDoc): string[] {
|
||||
const subjects = note.subject ? [note.subject] : [];
|
||||
for (const section of noteSections(note)) if (section.subject) subjects.push(section.subject);
|
||||
return [...new Set(subjects)];
|
||||
}
|
||||
|
||||
/** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */
|
||||
export function filterNotes(
|
||||
notes: NoteDoc[],
|
||||
@@ -376,7 +540,7 @@ export function filterNotes(
|
||||
): NoteDoc[] {
|
||||
const subject = filter.subject?.trim().toLowerCase();
|
||||
return notes.filter((note) => {
|
||||
if (subject && !(note.subject ?? '').toLowerCase().includes(subject)) return false;
|
||||
if (subject && !noteSubjects(note).some((name) => name.toLowerCase().includes(subject))) return false;
|
||||
if (filter.courseId && note.courseId !== filter.courseId) return false;
|
||||
// A note with no date cannot be excluded by a date window without
|
||||
// silently hiding it; undated notes always pass.
|
||||
@@ -432,10 +596,17 @@ function dateFromFileName(fileName: string): string | undefined {
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}` : undefined;
|
||||
}
|
||||
|
||||
/** The first folder is the subject, by the layout `notePathFor` writes. */
|
||||
/**
|
||||
* The first folder is the subject, by the layout `notePathFor` writes.
|
||||
*
|
||||
* Except a year: notes filed under `2026/` are filed by date, and calling the
|
||||
* year a subject would put every lesson of a school year under one.
|
||||
*/
|
||||
function subjectFromPath(relative: string): string | undefined {
|
||||
const parts = relative.split('/');
|
||||
return parts.length > 1 ? parts[0] : undefined;
|
||||
if (parts.length < 2) return undefined;
|
||||
const first = parts[0]!;
|
||||
return /^\d{4}$/.test(first) ? undefined : first;
|
||||
}
|
||||
|
||||
/** `15.09.2026` and `2026-09-15T08:00:00Z` both mean the same school day. */
|
||||
@@ -446,6 +617,23 @@ function normalizeDate(value: string): string | undefined {
|
||||
return iso ? iso[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `- item` lines directly under a `key:` with no inline value.
|
||||
*
|
||||
* Stops at the first line that is not one, so a list never swallows the key
|
||||
* after it.
|
||||
*/
|
||||
function blockList(lines: string[], from: number): string[] {
|
||||
const items: string[] = [];
|
||||
for (let i = from + 1; i < lines.length; i++) {
|
||||
const item = /^[ \t]+-\s+(.*)$/.exec(lines[i]!);
|
||||
if (!item) break;
|
||||
const value = unquote(item[1]!.trim());
|
||||
if (value) items.push(value);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseList(value: string): string[] {
|
||||
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
|
||||
return inner
|
||||
|
||||
Reference in New Issue
Block a user