import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { convertAppleNote, parseExport } from './apple-notes.ts'; import type { ApiClient, NoteSummary } from './client.ts'; import { writeNote } from '../core/notes.ts'; /** * `schulcloud note` — the user's own lesson notes from the command line. * * The notes live on the server beside the index, so these go through /api like * everything else here. The exception is `import --out`, which writes files * directly: a migration of several hundred notes is worth doing offline, and * the result can be looked at before it goes anywhere. */ export interface NoteWriter { (line: string): void; } export async function noteList( api: ApiClient, filter: { subject?: string; since?: string; until?: string }, long: boolean, out: NoteWriter, ): Promise { const listing = await api.notes(filter); if (listing.count === 0) { out(`No notes yet. The server keeps them in ${listing.root}.`); return 0; } for (const note of listing.notes) out(formatLine(note, long)); if (listing.notes.length < listing.count) { out(`… ${listing.count - listing.notes.length} more.`); } return 0; } export async function noteShow(api: ApiClient, path: string, out: NoteWriter): Promise { const note = await api.note(path); out(`# ${note.title}`); const facts = [note.date, note.subject, note.tags.length > 0 ? note.tags.join(', ') : undefined].filter(Boolean); if (facts.length > 0) out(facts.join(' · ')); out(''); out(note.text ?? ''); return 0; } export async function noteAdd( api: ApiClient, input: { title: string; text: string; subject?: string; date?: string; tags?: string[]; append?: boolean }, out: NoteWriter, ): Promise { const note = await api.addNote({ ...input, source: 'cli' }); out(`${note.appended ? 'Appended to' : 'Saved'} ${note.path}`); return 0; } export interface ImportOptions { /** Write files here instead of sending them to the server. */ outDir?: string; /** Force every note into one subject, rather than using its Notes folder. */ subject?: string; dryRun?: boolean; } /** * Migrates an Apple Notes export. * * Notes with no text are skipped rather than imported empty: an export always * has some — locked notes, and notes that are one attachment — and a store * seeded with blank entries makes every later listing worse. */ export async function noteImport( api: ApiClient | undefined, file: string, options: ImportOptions, out: NoteWriter, ): Promise { const contents = await readFile(resolve(file), 'utf8'); const exported = parseExport(contents); if (exported.length === 0) { out(`${file} holds no notes. Re-run scripts/export-apple-notes.js on the Mac.`); return 1; } let imported = 0; let empty = 0; let failed = 0; const unreadable: string[] = []; for (const note of exported) { if (note.error) { unreadable.push(note.name || note.id); continue; } const converted = convertAppleNote(note, options.subject ? { subject: options.subject } : {}); if (!converted.text.trim()) { empty++; continue; } if (options.dryRun) { out(`would import: ${converted.date ?? '????-??-??'} · ${converted.subject ?? '—'} · ${converted.title}`); imported++; continue; } try { if (options.outDir) { const { note: written } = await writeNote(options.outDir, converted); out(written.path); } else { if (!api) throw new Error('No server configured and no --out directory given.'); const written = await api.addNote(converted); out(written.path); } imported++; } catch (error) { failed++; out(`FAILED ${converted.title}: ${error instanceof Error ? error.message : String(error)}`); } } out( `${options.dryRun ? 'Would import' : 'Imported'} ${imported} of ${exported.length} note(s)` + (empty > 0 ? `, skipped ${empty} with no text` : '') + (unreadable.length > 0 ? `, ${unreadable.length} unreadable in Notes` : '') + (failed > 0 ? `, FAILED ${failed}` : '') + '.', ); if (unreadable.length > 0) { // Almost always locked notes: they are the ones worth naming, because the // fix is to unlock them in Notes and export again. out(`Unreadable (locked, or not downloaded from iCloud): ${unreadable.slice(0, 10).join('; ')}`); } return failed > 0 ? 1 : 0; } function formatLine(note: NoteSummary, long: boolean): string { const date = note.date ?? ' '; const subject = note.subject ? `[${note.subject}] ` : ''; if (!long) return `${date} ${subject}${note.title}`; const tags = note.tags.length > 0 ? ` #${note.tags.join(' #')}` : ''; return `${date} ${String(note.bytes).padStart(7)} ${subject}${note.title}${tags}\n ${note.path}`; }