Files
Schulcloud-MCP/test/apple-notes.test.ts
MechaCat02 f545b7cf54 Export Apple Notes to stdout, and one folder at a time
`console.log` in JXA writes to standard error, not standard output, so
`> notes.ndjson` produced an empty file while every note scrolled past
on the terminal. Both streams now go through NSFileHandle, which is the
only way to be sure which one you are on; the comment says so, because
the next person will reach for console.log too.

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

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

Not run: this needs a Mac with Notes, and there is none here. The JXA
stream behaviour is the documented one and the folder logic is plain
string work, but the script itself is still unexercised.
2026-09-20 19:07:20 +02:00

109 lines
4.2 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&ouml;rterung &amp; 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('gives no subject to a note the export could not place', () => {
// What `--folder Schule` produces for a note sitting loose in Schule:
// the path relative to the folder asked for, which is nothing. A subject
// of "Schule" would file every such note under a subject that is not one.
assert.equal(convertAppleNote({ ...note, folder: '' }).subject, undefined);
assert.equal(convertAppleNote({ ...note, folder: ' ' }).subject, undefined);
});
it('does not repeat the title as the first line of the body', () => {
const converted = convertAppleNote(note);
assert.equal(converted.title, 'Erörterung');
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/);
});
});