import { decodeEntities } from '../core/text.ts'; /** * Turning an Apple Notes export into notes this server can read. * * Notes.app stores a note's body as HTML and exposes it through AppleScript, * which is the only interface it has — there is no file on disk to copy, no * export format worth the name, and iCloud's copy is encrypted. So * `scripts/export-apple-notes.js` reads the notes through that interface and * writes one JSON object per line; this converts them. * * Kept out of `core/` because nothing on the server needs it: a migration runs * once, from the Mac that has the notes, and the server only ever sees the * Markdown that comes out. */ /** One note as `scripts/export-apple-notes.js` writes it. */ export interface AppleNote { id: string; name: string; /** The note's HTML body. */ body: string; /** The Notes folder it sits in — "Notizen", "Deutsch", "Schule/LF07". */ folder?: string; /** ISO timestamps from Notes.app. */ created?: string; modified?: string; /** Set when Notes refused the body, e.g. a locked note. */ error?: string; } export interface ConvertedNote { title: string; text: string; date?: string; subject?: string; source: string; } /** * An exported note as a note here. * * The creation date becomes the note's date because that is the day of the * lesson it was taken in — the modification date is whenever it was last * tidied, which is not a school day at all. */ export function convertAppleNote(note: AppleNote, options: { subject?: string } = {}): ConvertedNote { const body = htmlToMarkdown(note.body); const title = (note.name || firstLine(body) || 'Notiz').trim(); // Notes repeats the title as the first line of the body; keeping both would // give every migrated note a duplicated heading. const text = stripLeadingTitle(body, title); return { title, text, ...(dayOf(note.created) ? { date: dayOf(note.created)! } : {}), ...(subjectFor(note, options.subject) ? { subject: subjectFor(note, options.subject)! } : {}), source: 'apple-notes', }; } /** * The subject a note belongs to: what the caller said, else its Notes folder. * * The folder is the only structure Notes has, and someone keeping lesson notes * has almost certainly used it for exactly this. A note loose in the default * folder gets no subject rather than a wrong one. */ function subjectFor(note: AppleNote, override: string | undefined): string | undefined { if (override) return override; const folder = note.folder?.trim(); if (!folder) return undefined; // Notes' own default folders say nothing about a subject. if (/^(notes|notizen|alle .*|all .*|recently deleted|zuletzt gelöscht)$/i.test(folder)) return undefined; // A nested folder arrives as "Schule/Deutsch"; the leaf is the subject. return folder.split('/').pop()!.trim() || undefined; } /** * Apple Notes HTML as Markdown. * * Deliberately small. Notes emits a narrow set of tags — divs, breaks, lists, * headings, bold/italic/underline, links and tables — and the goal is readable * text that keeps its structure, not a faithful rendering. Anything unknown * loses its tag and keeps its words, which is the right failure for a note. */ export function htmlToMarkdown(html: string | undefined | null): string { if (!html) return ''; let value = html; // Drop what carries no text at all before anything else looks at it. value = value.replace(/<(script|style|head)[^>]*>[\s\S]*?<\/\1>/gi, ''); // Attachments (images, scans, drawings) come through as : they have // no text, and silently dropping them would hide that the note had one. value = value.replace(/]*>[\s\S]*?<\/object>/gi, '\n[Anhang aus Apple Notes — nicht übernommen]\n'); value = value.replace(/]*>/gi, '\n[Bild aus Apple Notes — nicht übernommen]\n'); value = value.replace(//gi, '\n'); // `li` is deliberately absent: the next `
  • ` already opens a line, and // closing one here too would put a blank line between every bullet, which // Markdown renders as a loose list. value = value.replace(/<\/(p|div|tr|h[1-6]|blockquote)>/gi, '\n'); value = value.replace(/]*>/gi, (_all, level: string) => `\n${'#'.repeat(Number(level))} `); // A checklist is a list in Notes and a task list in Markdown; the checked // state lives on the li, so it has to be read before the tag is stripped. value = value.replace(/]*\bchecked\b[^>]*>/gi, '\n- [x] '); value = value.replace(/]*>/gi, '\n- '); // A blank line after a list, or whatever follows is absorbed into the last // bullet as a lazy continuation. value = value.replace(/<\/(ul|ol|table)>/gi, '\n\n'); value = value.replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '**')); value = value.replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '_')); value = value.replace(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_all, href: string, inner: string) => { const label = stripTags(inner).trim(); return label ? `[${label}](${href})` : href; }); // Table cells become separators rather than vanishing, or a row of figures // runs into one number. value = value.replace(/<\/(td|th)>/gi, ' | '); value = stripTags(value); value = decodeEntities(value); return value .split('\n') .map((line) => line.replace(/[ \t ]+/g, ' ').replace(/ \| $/, '').trimEnd()) .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } /** Emphasis only around text that has some: `** **` renders as literal stars. */ function emphasise(inner: string, marker: string): string { const text = inner.replace(//gi, '\n'); const body = stripTags(text).trim(); if (!body) return ''; return `${marker}${body}${marker}`; } function stripTags(value: string): string { return value.replace(/<[^>]+>/g, ''); } function firstLine(body: string): string | undefined { return body .split('\n') .map((line) => line.replace(/^#+\s*/, '').trim()) .find((line) => line.length > 0); } /** * Removes the title if the body repeats it. * * Notes shows a note's first line as its name, so `name` and the first line of * `body` are usually the same string. */ function stripLeadingTitle(body: string, title: string): string { const lines = body.split('\n'); const firstIndex = lines.findIndex((line) => line.trim().length > 0); if (firstIndex === -1) return ''; const first = lines[firstIndex]!.replace(/^#+\s*/, '').replace(/^\*\*(.*)\*\*$/, '$1').trim(); if (first !== title.trim()) return body.trim(); return lines.slice(firstIndex + 1).join('\n').trim(); } /** An ISO timestamp as a school day, or nothing when Notes gave none. */ function dayOf(value: string | undefined): string | undefined { if (!value) return undefined; const at = new Date(value); if (Number.isNaN(at.getTime())) return undefined; // The export writes local time, which is the timezone the note was taken in. return value.slice(0, 10).match(/^\d{4}-\d{2}-\d{2}$/) ? value.slice(0, 10) : at.toISOString().slice(0, 10); } /** Parses the export file: one JSON object per line, blank lines ignored. */ export function parseExport(contents: string): AppleNote[] { const notes: AppleNote[] = []; for (const [index, line] of contents.split('\n').entries()) { const trimmed = line.trim(); if (!trimmed) continue; try { notes.push(JSON.parse(trimmed) as AppleNote); } catch { // One unparsable line must not cost the export; say which. throw new Error(`Line ${index + 1} of the export is not JSON. Re-run scripts/export-apple-notes.js.`); } } return notes; }