Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
3.7 KiB
TypeScript
101 lines
3.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
import { convertAppleNote, htmlToMarkdown, parseExport } from '../src/cli/apple-notes.ts';
|
|
|
|
/**
|
|
* The migration path from Notes.app. The HTML here is the shape Notes actually
|
|
* emits — divs for lines, a repeated title, `<object>` for attachments — since
|
|
* the converter's whole job is to survive that particular markup.
|
|
*/
|
|
|
|
describe('htmlToMarkdown', () => {
|
|
it('turns divs and breaks into lines', () => {
|
|
assert.equal(htmlToMarkdown('<div>Erste Zeile</div><div>Zweite<br>Dritte</div>'), 'Erste Zeile\nZweite\nDritte');
|
|
});
|
|
|
|
it('keeps headings, lists and emphasis', () => {
|
|
const markdown = htmlToMarkdown('<h1>Thema</h1><ul><li>eins</li><li><b>zwei</b></li></ul>');
|
|
assert.match(markdown, /^# Thema$/m);
|
|
assert.match(markdown, /^- eins$/m);
|
|
assert.match(markdown, /^- \*\*zwei\*\*$/m);
|
|
});
|
|
|
|
it('keeps bullets together and separates what follows the list', () => {
|
|
// Blank lines between bullets make a loose list; no blank line after one
|
|
// makes the next paragraph a lazy continuation of the last bullet.
|
|
const markdown = htmlToMarkdown('<ul><li>eins</li><li>zwei</li></ul><div>danach</div>');
|
|
assert.equal(markdown, '- eins\n- zwei\n\ndanach');
|
|
});
|
|
|
|
it('renders a checklist as a task list', () => {
|
|
assert.match(htmlToMarkdown('<ul><li checked="checked">erledigt</li></ul>'), /- \[x\] erledigt/);
|
|
});
|
|
|
|
it('keeps a link as a link', () => {
|
|
assert.equal(htmlToMarkdown('<div><a href="https://example.org">Quelle</a></div>'), '[Quelle](https://example.org)');
|
|
});
|
|
|
|
it('decodes entities', () => {
|
|
assert.equal(htmlToMarkdown('<div>Erörterung & Analyse</div>'), 'Erörterung & Analyse');
|
|
});
|
|
|
|
it('says an attachment was there rather than dropping it silently', () => {
|
|
// A note that was one scan would otherwise import as empty, and nobody
|
|
// would know the picture had been left behind.
|
|
assert.match(htmlToMarkdown('<div>Tafelbild</div><object data="x"></object>'), /Anhang aus Apple Notes/);
|
|
});
|
|
|
|
it('emits no empty emphasis markers', () => {
|
|
assert.equal(htmlToMarkdown('<div><b> </b>Text</div>'), 'Text');
|
|
});
|
|
});
|
|
|
|
describe('convertAppleNote', () => {
|
|
const note = {
|
|
id: 'x-coredata://1',
|
|
name: 'Erörterung',
|
|
body: '<div><b>Erörterung</b></div><div>These, Argument, Fazit</div>',
|
|
folder: 'Schule/Deutsch',
|
|
created: '2026-09-15T08:30:00',
|
|
modified: '2026-09-20T19:00:00',
|
|
};
|
|
|
|
it('dates the note when it was written, not when it was last touched', () => {
|
|
// The creation date is the lesson; the modification date is whenever it
|
|
// was last tidied, which is not a school day at all.
|
|
assert.equal(convertAppleNote(note).date, '2026-09-15');
|
|
});
|
|
|
|
it('takes the leaf of the Notes folder as the subject', () => {
|
|
assert.equal(convertAppleNote(note).subject, 'Deutsch');
|
|
});
|
|
|
|
it('ignores Notes\' own default folders', () => {
|
|
assert.equal(convertAppleNote({ ...note, folder: 'Notizen' }).subject, undefined);
|
|
});
|
|
|
|
it('lets an explicit subject win', () => {
|
|
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07');
|
|
});
|
|
|
|
it('does not repeat the title as the first line of the body', () => {
|
|
const converted = convertAppleNote(note);
|
|
assert.equal(converted.title, 'Erörterung');
|
|
assert.equal(converted.text, 'These, Argument, Fazit');
|
|
});
|
|
|
|
it('marks where it came from', () => {
|
|
assert.equal(convertAppleNote(note).source, 'apple-notes');
|
|
});
|
|
});
|
|
|
|
describe('parseExport', () => {
|
|
it('reads one note per line and ignores blank lines', () => {
|
|
assert.equal(parseExport('{"id":"1","name":"A","body":"<div>a</div>"}\n\n{"id":"2","name":"B","body":""}\n').length, 2);
|
|
});
|
|
|
|
it('names the line it could not read', () => {
|
|
assert.throws(() => parseExport('{"id":"1"}\nnope\n'), /Line 2/);
|
|
});
|
|
});
|