The list of notes and the note being written are one screen now, two panes: the notes on the left, the open one on the right, side by side where there is room and one at a time on a phone, where the back button returns to the list. The search box moved into the top of that list, and its results *are* the list — searching is a way of finding a note, not a separate place to be, and a tab for it was a tab too many. Emptying the box brings the whole list back. Opening a hit opens that day at the lesson that matched, rather than at the top of a day with six of them. A row has to say what the note holds, so the listing carries it: the subjects a day covers, how many lessons, and the first line actually written in it. One request for the whole list rather than one per note. `plainText` is now one rule in one place for wherever a note is shown rather than edited — the search snippet and the list row both went through their own half-copy of it, and the row's copy rendered a table as `| | |` and left `_Fazit_` wearing its markers. It strips one leading marker, not each in turn, because `## 1. Deutsch` keeps its lesson number and the list rule was eating it. Driven in Firefox at both widths: the list, the search, opening a hit, the jump to the lesson, and the phone's list-then-note. 390 unit tests, 116/117 smoke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
835 lines
27 KiB
JavaScript
835 lines
27 KiB
JavaScript
import { createEditor } from './editor.js';
|
||
import { markdownToHtml } from './markdown.js';
|
||
|
||
/*
|
||
* The notes app.
|
||
*
|
||
* One school day is one note, one lesson is one `##` heading, and the server
|
||
* builds the headings from WebUntis — so opening the app during a free period
|
||
* gives you the day already laid out rather than an empty box. That shape is
|
||
* also what makes each lesson separately searchable afterwards, which is the
|
||
* whole reason the notes are worth writing here rather than in Notes.app.
|
||
*
|
||
* Three rules this file exists to honour:
|
||
*
|
||
* - **Never lose what was typed.** Every keystroke goes to localStorage, and a
|
||
* draft that is newer than the server's copy survives a dead connection, a
|
||
* locked phone and a closed tab. A note taken in a lesson cannot be retaken.
|
||
* - **Never silently overwrite.** Saves carry the modification time the editor
|
||
* loaded; the server refuses one that would clobber a version this editor
|
||
* never saw, and the banner then makes it the person's decision.
|
||
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
|
||
* "Offline — lokal gesichert". A silent editor over a flaky connection is
|
||
* indistinguishable from one that is losing your work.
|
||
*
|
||
* What the person sees is formatted text with a toolbar; what is written to
|
||
* disk is Markdown. `editor.js` is the whole of that translation — everything
|
||
* here deals in Markdown strings and never touches the document.
|
||
*/
|
||
|
||
const AUTOSAVE_MS = 2500;
|
||
const DRAFT_PREFIX = 'schulcloud-mcp/draft/';
|
||
|
||
const ui = {
|
||
login: document.getElementById('login'),
|
||
loginForm: document.getElementById('login-form'),
|
||
password: document.getElementById('password'),
|
||
loginError: document.getElementById('login-error'),
|
||
app: document.getElementById('app'),
|
||
tabNotes: document.getElementById('tab-notes'),
|
||
tabSettings: document.getElementById('tab-settings'),
|
||
viewNotes: document.getElementById('view-notes'),
|
||
viewSettings: document.getElementById('view-settings'),
|
||
searchForm: document.getElementById('search-form'),
|
||
searchInput: document.getElementById('search-input'),
|
||
railStatus: document.getElementById('rail-status'),
|
||
railList: document.getElementById('rail-list'),
|
||
today: document.getElementById('today'),
|
||
back: document.getElementById('back'),
|
||
daybar: document.querySelector('.daybar'),
|
||
actions: document.querySelector('.actions'),
|
||
notePreview: document.getElementById('note-preview'),
|
||
notePreviewTitle: document.getElementById('note-preview-title'),
|
||
notePreviewPath: document.getElementById('note-preview-path'),
|
||
notePreviewBody: document.getElementById('note-preview-body'),
|
||
prev: document.getElementById('prev'),
|
||
next: document.getElementById('next'),
|
||
dayTitle: document.getElementById('day-title'),
|
||
dayDate: document.getElementById('day-date'),
|
||
dayStatus: document.getElementById('day-status'),
|
||
conflict: document.getElementById('day-conflict'),
|
||
toolbar: document.getElementById('toolbar'),
|
||
editor: document.getElementById('editor'),
|
||
source: document.getElementById('source'),
|
||
editorHint: document.getElementById('editor-hint'),
|
||
save: document.getElementById('save'),
|
||
fill: document.getElementById('fill'),
|
||
lessonsHint: document.getElementById('lessons-hint'),
|
||
tokenState: document.getElementById('token-state'),
|
||
tokenForm: document.getElementById('token-form'),
|
||
jwt: document.getElementById('jwt'),
|
||
tokenResult: document.getElementById('token-result'),
|
||
serverState: document.getElementById('server-state'),
|
||
logout: document.getElementById('logout'),
|
||
};
|
||
|
||
/** Everything about the day currently open. */
|
||
const day = {
|
||
date: today(),
|
||
path: '',
|
||
/** The server's modification time for the loaded note, or null if there is none. */
|
||
modifiedAt: null,
|
||
/** The text as the server last confirmed it, to tell "dirty" from "saved". */
|
||
saved: '',
|
||
/** Headings the timetable has and the note does not. */
|
||
missing: '',
|
||
dirty: false,
|
||
conflicted: false,
|
||
timer: 0,
|
||
};
|
||
|
||
/**
|
||
* The formatted editor over the two elements that hold a note.
|
||
*
|
||
* It owns the document and the toolbar; this file only ever asks it for
|
||
* Markdown and hands it Markdown back.
|
||
*/
|
||
const editor = createEditor({
|
||
rich: ui.editor,
|
||
source: ui.source,
|
||
toolbar: ui.toolbar,
|
||
onInput: markDirty,
|
||
onModeChange: (mode) => {
|
||
// Switching by hand is not a warning, so the automatic one goes away.
|
||
hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '');
|
||
},
|
||
});
|
||
|
||
function hint(message) {
|
||
ui.editorHint.textContent = message;
|
||
ui.editorHint.hidden = !message;
|
||
}
|
||
|
||
// --- plumbing ------------------------------------------------------------
|
||
|
||
async function api(path, options) {
|
||
const response = await fetch(path, {
|
||
credentials: 'same-origin',
|
||
...options,
|
||
headers: { accept: 'application/json', ...(options && options.headers) },
|
||
});
|
||
if (response.status === 401) {
|
||
showLogin();
|
||
throw new Error('unauthorized');
|
||
}
|
||
let body = null;
|
||
try {
|
||
body = await response.json();
|
||
} catch (error) {
|
||
body = null;
|
||
}
|
||
if (!response.ok) {
|
||
const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status);
|
||
failure.status = response.status;
|
||
failure.body = body;
|
||
throw failure;
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function json(method, path, payload) {
|
||
return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
|
||
}
|
||
|
||
function today() {
|
||
// The device's own date. The server keeps school dates in Europe/Berlin, but
|
||
// the phone in the lesson is in that timezone by definition.
|
||
const now = new Date();
|
||
return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-');
|
||
}
|
||
|
||
function pad(value) {
|
||
return String(value).padStart(2, '0');
|
||
}
|
||
|
||
function shiftDate(date, days) {
|
||
// Noon, so a daylight-saving change cannot push the result onto the
|
||
// neighbouring day.
|
||
const at = new Date(date + 'T12:00:00');
|
||
at.setDate(at.getDate() + days);
|
||
return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-');
|
||
}
|
||
|
||
function clock() {
|
||
const now = new Date();
|
||
return pad(now.getHours()) + ':' + pad(now.getMinutes());
|
||
}
|
||
|
||
// --- drafts: the safety net ---------------------------------------------
|
||
|
||
function draftKey(date) {
|
||
return DRAFT_PREFIX + date;
|
||
}
|
||
|
||
function saveDraft(text) {
|
||
try {
|
||
const value = text === undefined ? editor.getMarkdown() : text;
|
||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: value, at: Date.now() }));
|
||
} catch (error) {
|
||
// A full or disabled localStorage must not break typing; the server copy
|
||
// is still the real one.
|
||
}
|
||
}
|
||
|
||
function readDraft(date) {
|
||
try {
|
||
const raw = localStorage.getItem(draftKey(date));
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function clearDraft(date) {
|
||
try {
|
||
localStorage.removeItem(draftKey(date));
|
||
} catch (error) {
|
||
// Nothing to do: a stale draft is only ever offered, never forced.
|
||
}
|
||
}
|
||
|
||
// --- the day -------------------------------------------------------------
|
||
|
||
function setStatus(message, kind) {
|
||
ui.dayStatus.textContent = message;
|
||
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
|
||
}
|
||
|
||
/** A school day, in the editor, with the list showing which one. */
|
||
async function openDay(date) {
|
||
ui.notePreview.hidden = true;
|
||
showEditor(true);
|
||
await loadDay(date);
|
||
}
|
||
|
||
/** Where a day's note lives, which is also its id in the list. */
|
||
function dayPathFor(date) {
|
||
return date.slice(0, 4) + '/' + date + '.md';
|
||
}
|
||
|
||
async function loadDay(date) {
|
||
// Anything unsaved goes to the draft before the view moves, or switching
|
||
// days would be a way to lose a lesson.
|
||
if (day.dirty) saveDraft();
|
||
window.clearTimeout(day.timer);
|
||
|
||
day.date = date;
|
||
day.conflicted = false;
|
||
ui.conflict.hidden = true;
|
||
ui.dayDate.value = date;
|
||
editor.setEnabled(false);
|
||
hint('');
|
||
setStatus('Wird geladen …');
|
||
|
||
let info;
|
||
try {
|
||
info = await api('/api/notes/day?date=' + encodeURIComponent(date));
|
||
} catch (error) {
|
||
if (error.message === 'unauthorized') return;
|
||
ui.dayTitle.textContent = date;
|
||
// A reply with a status is the server saying no — most often that it keeps
|
||
// no notes at all — and reporting that as "offline" would send someone
|
||
// looking at their signal instead of at NOTES_DIR.
|
||
if (error.status) {
|
||
editor.setMarkdown('');
|
||
editor.setEnabled(false);
|
||
setStatus(error.message, 'error');
|
||
ui.lessonsHint.textContent = '';
|
||
return;
|
||
}
|
||
// No status: the request never arrived. Fall back to whatever this device
|
||
// has, rather than an empty editor that looks like a day with no notes.
|
||
const draft = readDraft(date);
|
||
editor.setEnabled(true);
|
||
editor.setMarkdown(draft ? draft.text : '');
|
||
day.saved = '';
|
||
day.modifiedAt = null;
|
||
day.dirty = Boolean(draft);
|
||
setStatus(
|
||
draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.',
|
||
'warn',
|
||
);
|
||
return;
|
||
}
|
||
|
||
day.path = info.path;
|
||
day.modifiedAt = info.modifiedAt;
|
||
day.missing = info.missing || '';
|
||
ui.dayTitle.textContent = info.title;
|
||
|
||
const server = info.exists ? info.text : info.skeleton;
|
||
const draft = readDraft(date);
|
||
// A draft only wins when it differs from what the server holds; otherwise it
|
||
// is just the last save echoed back and offering it would be noise.
|
||
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
|
||
|
||
editor.setEnabled(true);
|
||
// The server's text first, and what the editor makes of it is the baseline.
|
||
// Opening a note the editor would tidy — a table typed unevenly, `*` for
|
||
// italics — must not count as an edit, or simply looking at a day would
|
||
// rewrite the file.
|
||
const loaded = editor.setMarkdown(server);
|
||
day.saved = info.exists ? editor.getMarkdown() : '';
|
||
if (useDraft) editor.setMarkdown(draft.text);
|
||
day.dirty = editor.getMarkdown() !== day.saved;
|
||
|
||
// One note in a hundred: something the formatted view cannot hold without
|
||
// changing it. It opens as Markdown rather than being quietly reduced.
|
||
hint(
|
||
!loaded.faithful
|
||
? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.'
|
||
: editor.mode === 'source'
|
||
? 'Markdown-Ansicht. „MD" führt zurück.'
|
||
: '',
|
||
);
|
||
|
||
if (useDraft) {
|
||
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
|
||
} else if (info.exists) {
|
||
setStatus('Gespeichert.');
|
||
} else if (info.skeleton) {
|
||
setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.');
|
||
} else {
|
||
setStatus('Neuer Tag.');
|
||
}
|
||
|
||
describeLessons(info);
|
||
ui.fill.hidden = !day.missing;
|
||
markOpenRow();
|
||
}
|
||
|
||
function describeLessons(info) {
|
||
if (info.timetable === 'off') {
|
||
ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.';
|
||
return;
|
||
}
|
||
if (info.timetable === 'unavailable') {
|
||
ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.';
|
||
return;
|
||
}
|
||
const count = (info.lessons || []).length;
|
||
ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.';
|
||
}
|
||
|
||
function markDirty() {
|
||
const text = editor.getMarkdown();
|
||
day.dirty = text !== day.saved;
|
||
saveDraft(text);
|
||
if (day.conflicted) return;
|
||
if (day.dirty) setStatus('Nicht gespeichert …');
|
||
window.clearTimeout(day.timer);
|
||
day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS);
|
||
}
|
||
|
||
async function saveDay(automatic) {
|
||
window.clearTimeout(day.timer);
|
||
if (!day.dirty && automatic) return;
|
||
const text = editor.getMarkdown();
|
||
setStatus('Wird gespeichert …');
|
||
|
||
try {
|
||
const result = await json('PUT', '/api/notes/day', {
|
||
date: day.date,
|
||
text,
|
||
// Absent for a note that does not exist yet: there is nothing to clash
|
||
// with, and sending null would look like "I saw no version".
|
||
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
|
||
});
|
||
const isNew = !day.modifiedAt;
|
||
day.saved = text;
|
||
day.modifiedAt = result.modifiedAt;
|
||
day.dirty = false;
|
||
// A day that had no note until now is not in the list yet.
|
||
if (isNew && !ui.searchInput.value.trim()) void loadRail();
|
||
day.conflicted = false;
|
||
ui.conflict.hidden = true;
|
||
clearDraft(day.date);
|
||
setStatus('Gespeichert ' + clock() + '.', 'ok');
|
||
} catch (error) {
|
||
if (error.message === 'unauthorized') return;
|
||
if (error.status === 409) {
|
||
// Stop autosaving: every further attempt would fail the same way, and
|
||
// the choice of which version wins is not ours to make.
|
||
day.conflicted = true;
|
||
ui.conflict.hidden = false;
|
||
ui.conflict.textContent =
|
||
'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' +
|
||
'„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' +
|
||
'Deine Fassung ist lokal gesichert.';
|
||
ensureConflictButtons();
|
||
setStatus('Nicht gespeichert — Konflikt.', 'error');
|
||
return;
|
||
}
|
||
if (error.status === 403) {
|
||
setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error');
|
||
return;
|
||
}
|
||
setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error');
|
||
}
|
||
}
|
||
|
||
/** The two ways out of a conflict, added once and only when one happens. */
|
||
function ensureConflictButtons() {
|
||
if (document.getElementById('conflict-reload')) return;
|
||
const reload = document.createElement('button');
|
||
reload.id = 'conflict-reload';
|
||
reload.type = 'button';
|
||
reload.textContent = 'Neu laden';
|
||
reload.addEventListener('click', () => {
|
||
clearDraft(day.date);
|
||
void loadDay(day.date);
|
||
});
|
||
|
||
const force = document.createElement('button');
|
||
force.id = 'conflict-force';
|
||
force.type = 'button';
|
||
force.textContent = 'Trotzdem speichern';
|
||
force.addEventListener('click', () => {
|
||
day.modifiedAt = null;
|
||
day.conflicted = false;
|
||
ui.conflict.hidden = true;
|
||
void saveDay(false);
|
||
});
|
||
|
||
ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force);
|
||
}
|
||
|
||
// --- settings ------------------------------------------------------------
|
||
|
||
async function loadSettings() {
|
||
ui.tokenState.textContent = 'Wird geladen …';
|
||
try {
|
||
const info = await api('/api/token');
|
||
const budget = info.keepalive && info.keepalive.budgetSeconds;
|
||
ui.tokenState.textContent =
|
||
'Noch ' + info.daysLeft + ' Tag(e) gültig' +
|
||
(budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') +
|
||
' (' + info.source + ').';
|
||
ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : '');
|
||
} catch (error) {
|
||
if (error.message === 'unauthorized') return;
|
||
ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message;
|
||
ui.tokenState.className = 'status error';
|
||
}
|
||
|
||
ui.serverState.replaceChildren();
|
||
try {
|
||
const status = await api('/api/status');
|
||
addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer');
|
||
addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)');
|
||
if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft');
|
||
} catch (error) {
|
||
addFact('Index', 'nicht verfügbar');
|
||
}
|
||
try {
|
||
const notes = await api('/api/notes?limit=1');
|
||
addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)'));
|
||
} catch (error) {
|
||
addFact('Notizen', 'nicht verfügbar');
|
||
}
|
||
}
|
||
|
||
function addFact(term, value) {
|
||
const dt = document.createElement('dt');
|
||
dt.textContent = term;
|
||
const dd = document.createElement('dd');
|
||
dd.textContent = value;
|
||
ui.serverState.append(dt, dd);
|
||
}
|
||
|
||
// --- the note list -------------------------------------------------------
|
||
|
||
/*
|
||
* The rail: every note, newest first, with the open one marked — and the
|
||
* search box at the top of it, because searching your notes is a way of
|
||
* finding one, not a separate place to be.
|
||
*
|
||
* Searching reads the **files** on the server rather than the Postgres index.
|
||
* Notes reach that index only on a full crawl, so a lesson written this
|
||
* morning would not be findable this morning, which is most of what anyone
|
||
* searches their own notes for. The `search` tool in Claude is the other half:
|
||
* it spans Schulcloud and the class register too, at the cost of being only as
|
||
* fresh as the last crawl.
|
||
*/
|
||
|
||
const SEARCH_DEBOUNCE_MS = 350;
|
||
/** Day notes live at `2026/2026-09-04.md`; anything else opens read-only. */
|
||
const DAY_NOTE = /^\d{4}\/(\d{4}-\d{2}-\d{2})\.md$/;
|
||
|
||
let searchTimer = 0;
|
||
let searchTerms = [];
|
||
|
||
async function loadRail() {
|
||
ui.railStatus.textContent = 'Wird geladen …';
|
||
try {
|
||
const listing = await api('/api/notes?limit=400');
|
||
searchTerms = [];
|
||
showRows(listing.notes.map(noteRow));
|
||
ui.railStatus.textContent =
|
||
listing.count === 0
|
||
? 'Noch keine Notizen. + öffnet den heutigen Tag.'
|
||
: listing.count + ' Notiz(en)' + (listing.notes.length < listing.count ? ', neueste 400' : '');
|
||
} catch (error) {
|
||
if (error.message === 'unauthorized') return;
|
||
ui.railList.replaceChildren();
|
||
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.';
|
||
}
|
||
}
|
||
|
||
/** One note as a row: what it is, and the first thing written in it. */
|
||
function noteRow(note) {
|
||
const lessons = note.lessons > 0 ? note.lessons + ' Stunde' + (note.lessons === 1 ? '' : 'n') : '';
|
||
const subjects = (note.subjects || []).join(' · ');
|
||
return {
|
||
path: note.path,
|
||
date: note.date,
|
||
title: note.title,
|
||
// Subjects say more than the date repeated, and the preview says more
|
||
// than either when a note is a single page of prose.
|
||
line: subjects || lessons || note.preview || '',
|
||
second: subjects && note.preview ? note.preview : '',
|
||
};
|
||
}
|
||
|
||
/** One search hit as a row: the lesson it matched in, and why. */
|
||
function hitRow(hit) {
|
||
return {
|
||
path: hit.path,
|
||
date: hit.date,
|
||
heading: hit.heading,
|
||
title: hit.heading || hit.subject || hit.title,
|
||
line: hit.date ? germanDate(hit.date) : hit.path,
|
||
second: hit.snippet,
|
||
mark: true,
|
||
};
|
||
}
|
||
|
||
function showRows(rows) {
|
||
const list = document.createDocumentFragment();
|
||
for (const row of rows) {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'row';
|
||
item.dataset.path = row.path;
|
||
if (row.heading) item.dataset.heading = row.heading;
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'row-title';
|
||
if (row.mark) highlight(title, row.title);
|
||
else title.textContent = row.title;
|
||
item.append(title);
|
||
|
||
for (const [text, clamp] of [
|
||
[row.line, false],
|
||
[row.second, true],
|
||
]) {
|
||
if (!text) continue;
|
||
const line = document.createElement('p');
|
||
line.className = 'row-line' + (clamp ? ' clamp' : '');
|
||
if (row.mark) highlight(line, text);
|
||
else line.textContent = text;
|
||
item.append(line);
|
||
}
|
||
|
||
item.addEventListener('click', () => void openRow(row));
|
||
list.append(item);
|
||
}
|
||
ui.railList.replaceChildren(list);
|
||
markOpenRow();
|
||
}
|
||
|
||
/**
|
||
* The matched words marked, without building HTML from them.
|
||
*
|
||
* A snippet is the user's own text, and text that has been through a URL and a
|
||
* JSON response is exactly what should not be handed to `innerHTML`.
|
||
*/
|
||
function highlight(target, text) {
|
||
const terms = searchTerms.map(fold).filter((term) => term.length > 1);
|
||
if (terms.length === 0) {
|
||
target.textContent = text;
|
||
return;
|
||
}
|
||
const folded = fold(text);
|
||
const marks = [];
|
||
for (const term of terms) {
|
||
for (let at = folded.indexOf(term); at !== -1; at = folded.indexOf(term, at + term.length)) {
|
||
marks.push([at, at + term.length]);
|
||
}
|
||
}
|
||
marks.sort((a, b) => a[0] - b[0]);
|
||
|
||
let cursor = 0;
|
||
for (const [from, to] of marks) {
|
||
if (from < cursor) continue;
|
||
target.append(text.slice(cursor, from));
|
||
const mark = document.createElement('mark');
|
||
mark.textContent = text.slice(from, to);
|
||
target.append(mark);
|
||
cursor = to;
|
||
}
|
||
target.append(text.slice(cursor));
|
||
}
|
||
|
||
/** Lowercase without accents — the same folding the server searches with. */
|
||
function fold(value) {
|
||
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||
}
|
||
|
||
function germanDate(date) {
|
||
const parts = date.split('-');
|
||
return parts[2] + '.' + parts[1] + '.' + parts[0];
|
||
}
|
||
|
||
async function runSearch(query) {
|
||
window.clearTimeout(searchTimer);
|
||
const value = query.trim();
|
||
if (value.length === 0) return loadRail();
|
||
if (value.length < 2) {
|
||
ui.railStatus.textContent = 'Mindestens zwei Zeichen.';
|
||
return;
|
||
}
|
||
ui.railStatus.textContent = 'Wird gesucht …';
|
||
try {
|
||
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=100');
|
||
searchTerms = value.split(/\s+/).filter(Boolean);
|
||
showRows(result.hits.map(hitRow));
|
||
ui.railStatus.textContent =
|
||
result.count === 0
|
||
? 'Nichts gefunden — jedes Wort muss vorkommen.'
|
||
: result.count + ' Treffer' + (result.count >= 100 ? ' (mehr vorhanden)' : '');
|
||
} catch (error) {
|
||
if (error.message === 'unauthorized') return;
|
||
ui.railList.replaceChildren();
|
||
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A row, opened.
|
||
*
|
||
* A school day opens in the editor, because that is where it is written.
|
||
* Anything else — an imported note, a page of revision — has no day to open, so
|
||
* it is shown read-only rather than forced into a day-shaped screen.
|
||
*/
|
||
async function openRow(row) {
|
||
const day = DAY_NOTE.exec(row.path);
|
||
showPane('note');
|
||
if (day) {
|
||
ui.notePreview.hidden = true;
|
||
showEditor(true);
|
||
await loadDay(day[1]);
|
||
// A search hit names a lesson, so put that lesson on screen rather than
|
||
// the top of a day with six of them.
|
||
if (row.heading) scrollToHeading(row.heading);
|
||
return;
|
||
}
|
||
await showNoteReadOnly(row.path);
|
||
}
|
||
|
||
function scrollToHeading(heading) {
|
||
const wanted = fold(heading).trim();
|
||
for (const element of ui.editor.querySelectorAll('h2')) {
|
||
if (fold(element.textContent).trim() === wanted) {
|
||
element.scrollIntoView({ block: 'start' });
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function showNoteReadOnly(path) {
|
||
showEditor(false);
|
||
ui.notePreview.hidden = false;
|
||
ui.notePreviewTitle.textContent = '…';
|
||
try {
|
||
const note = await api('/api/notes?path=' + encodeURIComponent(path));
|
||
ui.notePreviewTitle.textContent = note.title;
|
||
ui.notePreviewPath.textContent = note.path + ' — schreibgeschützt, weil diese Notiz kein Schultag ist.';
|
||
// Markdown from our own store, through the parser the editor trusts with
|
||
// the same input: it escapes everything it did not produce itself.
|
||
ui.notePreviewBody.innerHTML = markdownToHtml(note.text ?? '');
|
||
day.path = note.path;
|
||
markOpenRow();
|
||
} catch (error) {
|
||
if (error.message === 'unauthorized') return;
|
||
ui.notePreviewTitle.textContent = 'Konnte die Notiz nicht öffnen';
|
||
ui.notePreviewPath.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
/** The day editor and everything that belongs to it, on or off. */
|
||
function showEditor(on) {
|
||
for (const element of [ui.daybar, ui.dayStatus, ui.toolbar, ui.actions]) element.hidden = !on;
|
||
ui.editor.hidden = !on || editor.mode !== 'rich';
|
||
ui.source.hidden = !on || editor.mode !== 'source';
|
||
// The hint belongs to whatever is loaded next; leaving it visible and empty
|
||
// would cost a line of editor for nothing.
|
||
if (!on) {
|
||
ui.conflict.hidden = true;
|
||
ui.editorHint.hidden = true;
|
||
}
|
||
}
|
||
|
||
function showPane(pane) {
|
||
ui.viewNotes.dataset.pane = pane;
|
||
}
|
||
|
||
/** Marks the row whose note is open, whichever list is showing. */
|
||
function markOpenRow() {
|
||
for (const row of ui.railList.querySelectorAll('.row')) {
|
||
row.setAttribute('aria-current', String(row.dataset.path === day.path));
|
||
}
|
||
}
|
||
|
||
// --- views ---------------------------------------------------------------
|
||
|
||
function showLogin() {
|
||
ui.app.hidden = true;
|
||
ui.login.hidden = false;
|
||
ui.password.focus();
|
||
}
|
||
|
||
function showApp() {
|
||
ui.login.hidden = true;
|
||
ui.app.hidden = false;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// --- wiring --------------------------------------------------------------
|
||
|
||
ui.loginForm.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
ui.loginError.textContent = '';
|
||
try {
|
||
await json('POST', '/app/login', { password: ui.password.value });
|
||
ui.password.value = '';
|
||
showApp();
|
||
await loadDay(day.date);
|
||
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
|
||
await loadRail();
|
||
} catch (error) {
|
||
ui.loginError.textContent =
|
||
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
|
||
}
|
||
});
|
||
|
||
ui.logout.addEventListener('click', async () => {
|
||
// The draft stays: logging out is not the same as discarding a lesson.
|
||
await json('POST', '/app/logout', {}).catch(() => {});
|
||
showLogin();
|
||
});
|
||
|
||
ui.tabNotes.addEventListener('click', () => showTab('notes'));
|
||
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.today.addEventListener('click', () => {
|
||
// The day you are in, whether or not it has a note yet — the one thing the
|
||
// list cannot show, because an empty day is not a note.
|
||
void openRow({ path: dayPathFor(today()) });
|
||
});
|
||
|
||
ui.back.addEventListener('click', () => showPane('list'));
|
||
|
||
ui.prev.addEventListener('click', () => void openDay(shiftDate(day.date, -1)));
|
||
ui.next.addEventListener('click', () => void openDay(shiftDate(day.date, 1)));
|
||
ui.dayDate.addEventListener('change', () => {
|
||
if (ui.dayDate.value) void openDay(ui.dayDate.value);
|
||
});
|
||
|
||
ui.save.addEventListener('click', () => void saveDay(false));
|
||
|
||
ui.fill.addEventListener('click', () => {
|
||
// Appended, never merged into place: the person's own text is not something
|
||
// to reorder, and a heading in the wrong order is trivial to move.
|
||
editor.append(day.missing);
|
||
day.missing = '';
|
||
ui.fill.hidden = true;
|
||
});
|
||
|
||
// A phone locking, the app going to the background, or the tab closing: all of
|
||
// them end the session without a "save" ever being pressed.
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'hidden' && day.dirty) {
|
||
saveDraft();
|
||
if (!day.conflicted) void saveDay(true);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('beforeunload', (event) => {
|
||
if (!day.dirty) return;
|
||
saveDraft();
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
});
|
||
|
||
ui.tokenForm.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
ui.tokenResult.textContent = 'Wird geprüft …';
|
||
ui.tokenResult.className = 'status';
|
||
try {
|
||
const result = await json('PUT', '/api/token', { jwt: ui.jwt.value });
|
||
ui.jwt.value = '';
|
||
ui.tokenResult.textContent = result.changed
|
||
? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)')
|
||
: 'Das ist der Token, der bereits benutzt wird.';
|
||
ui.tokenResult.className = 'status ok';
|
||
void loadSettings();
|
||
} catch (error) {
|
||
if (error.message === 'unauthorized') return;
|
||
ui.tokenResult.textContent = error.message;
|
||
ui.tokenResult.className = 'status error';
|
||
}
|
||
});
|
||
|
||
// --- start ---------------------------------------------------------------
|
||
|
||
void (async () => {
|
||
try {
|
||
const session = await api('/app/session');
|
||
if (!session.authenticated) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
showApp();
|
||
await loadDay(day.date);
|
||
// Wide enough for both panes: the day is already open beside the list.
|
||
// Narrow: the list comes first, the way a notes app opens.
|
||
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
|
||
await loadRail();
|
||
})();
|