181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
"""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, ">").replace(/"/g, """);
|
|
const body = escapeHtml(input.body).replace(/\n/g, "<br>");
|
|
const html = "<h1>" + escapeHtml(input.title) + "</h1><div>" + body + "</div>";
|
|
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
|