"""Direct Apple Notes automation for the mutable Reyna CLI. The scripts are static JXA programs. User data is passed only as one JSON argument to ``osascript`` so note content is never interpolated into source. This deliberately stays outside the stable Swift host: Python-only CLI changes do not require rebuilding or re-signing Reyna CLI.app. """ from __future__ import annotations import json import subprocess from typing import Any, Callable, Optional, Sequence class NotesAutomationError(RuntimeError): """Apple Notes could not complete the requested automation operation.""" Runner = Callable[..., Any] _NOTES_LIST_SCRIPT = r'''function run(argv) { const input = JSON.parse(argv[0]); const app = Application("/System/Applications/Notes.app"); const query = (input.query || "").toLowerCase(); const folderName = input.folder || ""; const found = []; const accounts = app.accounts(); for (let a = 0; a < accounts.length && found.length < input.limit; a++) { const folders = accounts[a].folders(); for (let f = 0; f < folders.length && found.length < input.limit; f++) { const folder = folders[f]; const currentFolder = String(folder.name()); if (folderName && currentFolder !== folderName) continue; const notes = folder.notes(); for (let n = 0; n < notes.length && found.length < input.limit; n++) { const note = notes[n]; let title = ""; let text = ""; try { title = String(note.name()); } catch (_) {} try { text = String(note.plaintext()); } catch (_) {} if (query && (title + "\n" + text).toLowerCase().indexOf(query) === -1) continue; found.push({ id: String(note.id()), title: title, folder: currentFolder, modifiedAt: note.modificationDate().toISOString(), preview: input.includePreview ? text.slice(0, 180) : undefined }); } } } return JSON.stringify(found); }''' _NOTES_READ_SCRIPT = r'''function run(argv) { const input = JSON.parse(argv[0]); const app = Application("/System/Applications/Notes.app"); const accounts = app.accounts(); for (let a = 0; a < accounts.length; a++) { const folders = accounts[a].folders(); for (let f = 0; f < folders.length; f++) { const notes = folders[f].notes(); for (let n = 0; n < notes.length; n++) { const note = notes[n]; if (String(note.id()) === input.id) { return JSON.stringify({ id: String(note.id()), title: String(note.name()), folder: String(folders[f].name()), bodyHtml: String(note.body()), plaintext: String(note.plaintext()), createdAt: note.creationDate().toISOString(), modifiedAt: note.modificationDate().toISOString() }); } } } } throw new Error("Note not found"); }''' _NOTES_CREATE_SCRIPT = r'''function run(argv) { const input = JSON.parse(argv[0]); const app = Application("/System/Applications/Notes.app"); const escapeHtml = value => String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); const body = escapeHtml(input.body).replace(/\n/g, "
"); const html = "

" + escapeHtml(input.title) + "

" + body + "
"; let destination = null; const accounts = app.accounts(); for (let a = 0; a < accounts.length && !destination; a++) { const folders = accounts[a].folders(); for (let f = 0; f < folders.length; f++) { if (!input.folder || String(folders[f].name()) === input.folder) { destination = folders[f]; break; } } } if (!destination) throw new Error("Notes destination folder not found"); const note = app.Note({body: html}); destination.notes.push(note); return JSON.stringify({id: String(note.id()), title: String(note.name()), folder: String(destination.name())}); }''' def _run_jxa(script: str, payload: dict[str, Any], *, runner: Runner = subprocess.run) -> Any: arguments: Sequence[str] = [ "/usr/bin/osascript", "-l", "JavaScript", "-e", script, "--", json.dumps(payload, separators=(",", ":")), ] try: result = runner(arguments, capture_output=True, text=True, timeout=30, check=False) except (OSError, subprocess.TimeoutExpired) as exc: raise NotesAutomationError(f"Apple Notes automation could not start: {exc}") from exc if result.returncode: detail = (result.stderr or result.stdout or "Apple Notes automation failed").strip() raise NotesAutomationError(detail[:1000]) try: return json.loads((result.stdout or "").strip()) except json.JSONDecodeError as exc: raise NotesAutomationError("Apple Notes automation returned invalid JSON") from exc def _bounded_text(value: str, field: str, maximum: int) -> str: value = value.strip() if not value: raise ValueError(f"{field} is required") if len(value) > maximum: raise ValueError(f"{field} exceeds maximum length {maximum}") return value def list_notes( *, query: Optional[str] = None, folder: Optional[str] = None, include_preview: bool = False, limit: int = 20, runner: Runner = subprocess.run, ) -> list[dict[str, Any]]: if not 1 <= limit <= 100: raise ValueError("limit must be between 1 and 100") result = _run_jxa( _NOTES_LIST_SCRIPT, {"query": query or "", "folder": folder or "", "includePreview": include_preview, "limit": limit}, runner=runner, ) if not isinstance(result, list): raise NotesAutomationError("Apple Notes list response was not a list") return result def read_note(note_id: str, *, runner: Runner = subprocess.run) -> dict[str, Any]: result = _run_jxa(_NOTES_READ_SCRIPT, {"id": _bounded_text(note_id, "note id", 1024)}, runner=runner) if not isinstance(result, dict): raise NotesAutomationError("Apple Notes read response was not an object") return result def create_note( title: str, body: str = "", *, folder: Optional[str] = None, runner: Runner = subprocess.run, ) -> dict[str, Any]: title = _bounded_text(title, "title", 500) if len(body) > 100_000: raise ValueError("body exceeds maximum length 100000") result = _run_jxa(_NOTES_CREATE_SCRIPT, {"title": title, "body": body, "folder": folder or ""}, runner=runner) if not isinstance(result, dict): raise NotesAutomationError("Apple Notes create response was not an object") return result