Sync from Mac mini main: preserve full-app-path fix (/System/Applications/), reminders assignment hints, deco_ha_bridge for clients, plus image tools, plus system speech API check

Mac mini is on macOS 26.5.2 Mac16,10 M4 - SpeechAnalyzer available
- Calendar/Contacts/Notes/Reminders use full /System/Applications/*.app path (more reliable)
- Reminders now returns assignment {assignee, source, available} with fallback to (Name) in title
- deco.js uses deco_ha_bridge.py for clients action (more reliable than old bridge)
- Added deco_ha_bridge.py (13KB RSA/AES Deco HA client)
- Added system_get_info + system_speech_api_status (verifies SpeechAnalyzer on macOS 26+)
- Preserved codex-image, gemini-image, gemini-chrome-prompt tools
- Updated README with all 23 tools + macOS 26 note + benchmark ref
This commit is contained in:
aeroreyna
2026-07-13 16:18:40 -04:00
parent 4bed6145b9
commit 2345627eb8
7 changed files with 544 additions and 24 deletions
+52 -7
View File
@@ -14,17 +14,25 @@ and stays on the local machine.
| `calendar_list_calendars` | List calendar indexes, names, and write capability | | `calendar_list_calendars` | List calendar indexes, names, and write capability |
| `calendar_list_events` | List events in an ISO-8601 time window from the focused `Home` calendar | | `calendar_list_events` | List events in an ISO-8601 time window from the focused `Home` calendar |
| `calendar_create_event` | Create an event in the focused `Home` calendar | | `calendar_create_event` | Create an event in the focused `Home` calendar |
| `reminders_list_lists` | List reminder lists | | `reminders_list_lists` | List reminder lists with account context and assignment metadata availability |
| `reminders_list` | List reminders | | `reminders_list` | List reminders, including assignment details or assignment hints for shared-list reminders |
| `reminders_create` | Create a reminder | | `reminders_create` | Create a reminder |
| `contacts_search` | Search contact names and organizations without disclosing contact methods | | `contacts_search` | Search contact names and organizations without disclosing contact methods |
| `contacts_read` | Read phone and email details for one selected contact | | `contacts_read` | Read phone and email details for one selected contact |
| `contacts_create` | Create a contact with optional email and phone details | | `contacts_create` | Create a contact with optional email and phone details |
| `deco_get_config_status` | Show Deco connection config without revealing the password | | `deco_get_config_status` | Show Deco connection config without revealing the password |
| `deco_get_overview` | Read TP-Link Deco overview stats and firmware | | `deco_get_overview` | Read TP-Link Deco overview stats and firmware |
| `deco_list_clients` | List online Deco clients and current traffic speeds | | `deco_list_clients` | List online Deco clients, current traffic speeds, and linked mesh node when available |
| `deco_get_ipv4_status` | Read WAN/LAN IPv4 status | | `deco_get_ipv4_status` | Read WAN/LAN IPv4 status |
| `deco_get_firmware` | Read Deco model and firmware version | | `deco_get_firmware` | Read Deco model and firmware version |
| `system_get_info` | Get macOS version (sw_vers), hardware model, and SpeechAnalyzer availability |
| `system_speech_api_status` | Full check for Apple SpeechAnalyzer/SpeechTranscriber (macOS 26+) |
| `codex_image_get_config_status` | Show local Codex CLI image generation config |
| `codex_image_generate` | Generate an image with this Mac's Codex CLI and save it locally |
| `gemini_image_get_config_status` | Show Gemini image generation config without revealing the API key |
| `gemini_image_generate` | Generate an image with the Gemini API and save it locally |
| `gemini_chrome_prompt_get_config_status` | Show config for the Codex Chrome-skill Gemini image prompt builder |
| `gemini_chrome_prompt_build` | Build a ready-to-run Codex prompt for Gemini web-app image generation |
There are no destructive tools in the initial server. There are no destructive tools in the initial server.
@@ -33,6 +41,16 @@ event-rich `Home` calendar discovered during setup (`calendarIndex: 2`) and
verifies the selected index is still named `Home` before operating on it. verifies the selected index is still named `Home` before operating on it.
Calendar selector parameters remain available as advanced overrides. Calendar selector parameters remain available as advanced overrides.
Reminder assignment data is exposed on `reminders_list` as an `assignment`
object. Apple Reminders automation does not currently publish shared-list
participant metadata directly, so the tool first checks for any native assignee
field macOS exposes and then falls back to assignment hints embedded in the
reminder title or notes, such as `(Alicia)` or `Captured 2026-05-18, Alicia;`.
**macOS 26 note:** This Mac is on macOS 26.5.2 (Mac16,10 M4) — so `system_speech_api_status`
confirms SpeechAnalyzer/SpeechTranscriber is available. Apple's new engine beats Whisper
Small 2.12% vs 3.74% WER per Inscribe benchmark (2026-07-13).
## Setup ## Setup
Requires macOS and Node.js 20 or newer. Requires macOS and Node.js 20 or newer.
@@ -86,7 +104,7 @@ For a trusted local-network harness such as a Raspberry Pi, set
```text ```text
URL: http://<mac-lan-ip>:7331/mcp URL: http://<mac-lan-ip>:7331/mcp
Authorization: Bearer <MACMINI_MCP_TOKEN> Authorization: Bearer <MACMI...KEN>
``` ```
Restart after changing `.env`: Restart after changing `.env`:
@@ -96,8 +114,28 @@ npm run service:restart
``` ```
For TP-Link Deco tools, install Python dependencies with `npm run For TP-Link Deco tools, install Python dependencies with `npm run
python:install`, then set `DECO_HOST`, `DECO_USERNAME`, `DECO_PASSWORD`, and python:install`, then set `DECO_HOST`, `DECO_USERNAME=admin`, `DECO_PASSWORD`,
optionally `DECO_VERIFY_SSL=false` in `.env`. and optionally `DECO_VERIFY_SSL=false` in `.env`.
For Codex image generation, make sure the Mac is logged in with `codex login`.
Generated files are saved to `generated-images/` by default; override this with
`CODEX_IMAGE_OUTPUT_DIR`. Set `CODEX_IMAGE_MODEL` or `CODEX_IMAGE_TIMEOUT_MS` or
`CODEX_CLI_PATH` to the absolute `codex` path.
For Gemini image generation, set `GEMINI_API_KEY` in `.env`. Generated files are
saved to `generated-images/` by default; override this with
`GEMINI_IMAGE_OUTPUT_DIR`. The Gemini image tool calls the Gemini API directly
and does not expose browser navigation, page inspection, or screenshot tools.
For Gemini image generation through the signed-in Chrome web app, use
`gemini_chrome_prompt_build` to create a ready-to-run Codex prompt. The MCP
service does not control Chrome directly; the Chrome skill is only available
inside an active Codex session. The generated prompt verifies the Chrome profile
name is `ReynaFamilyBot`, opens only `https://gemini.google.com/app`, submits the
image prompt, downloads the generated image, and copies it to
`generated-images/`. Override the expected Chrome profile with
`GEMINI_CHROME_PROFILE_NAME` and the output directory with
`GEMINI_CHROME_IMAGE_OUTPUT_DIR`.
For a harness that launches stdio servers, use: For a harness that launches stdio servers, use:
@@ -120,7 +158,7 @@ to control under **System Settings > Privacy & Security > Automation**.
The HTTP service binds to `127.0.0.1` by default. When configured to bind to a The HTTP service binds to `127.0.0.1` by default. When configured to bind to a
LAN address, it refuses to start without `MACMINI_MCP_TOKEN`; clients must send LAN address, it refuses to start without `MACMINI_MCP_TOKEN`; clients must send
`Authorization: Bearer <token>`. This is HTTP bearer authentication on your `Authorization: Bearer *** This is HTTP bearer authentication on your
local network, not encrypted transport. Use it only on a trusted LAN or put it local network, not encrypted transport. Use it only on a trusted LAN or put it
behind a private encrypted network such as a VPN. behind a private encrypted network such as a VPN.
@@ -133,3 +171,10 @@ npm run dev
The MCP transport follows the official TypeScript SDK Streamable HTTP server The MCP transport follows the official TypeScript SDK Streamable HTTP server
approach: [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk). approach: [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk).
## Image generation
This server exposes Codex-backed and Gemini-backed image generation tools.
Gemini image generation uses the Gemini API directly, not browser automation.
Image Playground was tried and removed because the macOS app does not expose a
scriptable prompt-to-file action through AppleScript or Shortcuts/App Intents.
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env python3
import base64
import hashlib
import json
import math
import os
import re
import secrets
import ssl
import subprocess
import sys
from urllib.parse import quote_plus
import requests
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad, unpad
from requests import RequestException
AES_KEY_BYTES = 16
MIN_AES_KEY = 10 ** (AES_KEY_BYTES - 1)
MAX_AES_KEY = (10**AES_KEY_BYTES) - 1
PKCS1_V1_5_HEADER_BYTES = 11
def load_env_file():
path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env")
try:
with open(path, "r", encoding="utf-8") as env_file:
for line in env_file:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key, value.strip().strip("\"'"))
except FileNotFoundError:
pass
def default_gateway():
try:
result = subprocess.run(
["/sbin/route", "-n", "get", "default"],
check=True,
capture_output=True,
text=True,
timeout=5,
)
except Exception:
return None
for line in result.stdout.splitlines():
key, _, value = line.partition(":")
if key.strip() == "gateway":
return value.strip()
return None
def config():
load_env_file()
host = os.environ.get("DECO_HOST") or default_gateway()
password = os.environ.get("DECO_PASSWORD")
if not password:
raise RuntimeError("DECO_PASSWORD must be set in .env.")
if not host:
raise RuntimeError("DECO_HOST must be set in .env; default gateway detection failed.")
if not host.startswith(("http://", "https://")):
host = f"http://{host}"
verify_ssl = os.environ.get("DECO_VERIFY_SSL", "true").lower() not in {
"0",
"false",
"no",
}
return {
"host": host.rstrip("/"),
"username": os.environ.get("DECO_USERNAME", "admin"),
"password": password,
"verify_ssl": verify_ssl,
"timeout": int(os.environ.get("DECO_TIMEOUT", "10")),
}
def byte_len(n):
return (int(math.log2(n)) + 8) >> 3
def rsa_encrypt(n, e, plaintext):
public_key = RSA.construct((n, e)).publickey()
encryptor = PKCS1_v1_5.new(public_key)
block_size = byte_len(n)
bytes_per_block = block_size - PKCS1_V1_5_HEADER_BYTES
encrypted_text = ""
for index in range(0, len(plaintext), bytes_per_block):
encrypted_text += encryptor.encrypt(plaintext[index:index + bytes_per_block]).hex()
return encrypted_text
def decode_name(value):
if not value:
return value
try:
return base64.b64decode(value).decode()
except Exception:
return value
def title_from_snake(value):
if not value:
return value
return " ".join(part.title() for part in value.split("_"))
def deco_name(device):
return (
decode_name(device.get("custom_nickname"))
or title_from_snake(device.get("nickname"))
or device.get("device_model")
or device.get("mac")
)
class DecoApi:
def __init__(self, host, username, password, verify_ssl, timeout):
self.host = host
self.username = username
self.password = password
self.verify_ssl = verify_ssl
self.timeout = timeout
self.session = requests.Session()
self.aes_key = None
self.aes_iv = None
self.password_rsa_n = None
self.password_rsa_e = None
self.sign_rsa_n = None
self.sign_rsa_e = None
self.seq = None
self.stok = None
self.cookie = None
def generate_aes_key_and_iv(self):
self.aes_key = str(secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY).encode()
self.aes_iv = str(secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY).encode()
def post(self, context, path, params, data):
headers = {"Content-Type": "application/json"}
cookies = {}
if self.cookie:
name, _, value = self.cookie.partition("=")
if name and value:
cookies[name] = value
try:
response = self.session.post(
f"{self.host}{path}",
params=params,
data=data,
headers=headers,
cookies=cookies,
verify=self.verify_ssl,
timeout=self.timeout,
)
except RequestException:
response = self.curl_post(path, params, data, cookies)
if response.status_code == 403:
self.clear_auth()
raise RuntimeError(f"{context}: forbidden")
response.raise_for_status()
for cookie in response.headers.get("Set-Cookie", "").split(","):
match = re.search(r"(sysauth=[A-Za-z0-9]+)", cookie)
if match:
self.cookie = match.group(1)
break
result = response.json()
error_code = result.get("error_code")
if error_code not in (None, "", 0):
raise RuntimeError(f"{context}: response error_code={error_code}")
return result
def curl_post(self, path, params, data, cookies):
url = f"{self.host}{path}"
if params:
query = "&".join(f"{key}={quote_plus(str(value))}" for key, value in params.items())
url = f"{url}?{query}"
command = [
"curl",
"-s",
"-i",
"--connect-timeout",
str(self.timeout),
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"--data-raw",
data,
]
if not self.verify_ssl:
command.insert(2, "-k")
if cookies:
command.extend(["-H", "Cookie: " + "; ".join(f"{k}={v}" for k, v in cookies.items())])
result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=self.timeout + 5)
head, separator, body = result.stdout.rpartition("\r\n\r\n")
if not separator:
head, _, body = result.stdout.rpartition("\n\n")
status_match = re.search(r"HTTP/\S+\s+(\d+)", head)
status = int(status_match.group(1)) if status_match else 200
response = requests.Response()
response.status_code = status
response._content = body.encode()
response.url = url
for line in head.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
response.headers[key.strip()] = value.strip()
return response
def fetch_keys(self):
response = self.post(
"Fetch keys",
"/cgi-bin/luci/;stok=/login",
{"form": "keys"},
json.dumps({"operation": "read"}),
)
keys = response["result"]["password"]
self.password_rsa_n = int(keys[0], 16)
self.password_rsa_e = int(keys[1], 16)
def fetch_auth(self):
response = self.post(
"Fetch auth",
"/cgi-bin/luci/;stok=/login",
{"form": "auth"},
json.dumps({"operation": "read"}),
)
auth = response["result"]
self.sign_rsa_n = int(auth["key"][0], 16)
self.sign_rsa_e = int(auth["key"][1], 16)
self.seq = auth["seq"]
def encode_payload(self, payload):
payload_json = json.dumps(payload, separators=(",", ":")).encode()
encrypted = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv).encrypt(
pad(payload_json, AES.block_size)
)
data = base64.b64encode(encrypted).decode()
sign = self.encode_sign(len(data))
return f"sign={sign}&data={quote_plus(data)}"
def encode_sign(self, data_len):
auth_hash = hashlib.md5(f"{self.username}{self.password}".encode()).hexdigest()
sign_text = (
f"k={self.aes_key.decode()}&i={self.aes_iv.decode()}&h={auth_hash}&s={self.seq + data_len}"
)
return rsa_encrypt(self.sign_rsa_n, self.sign_rsa_e, sign_text.encode())
def decrypt_data(self, context, data):
if not data:
self.clear_auth()
raise RuntimeError(f"{context}: empty data")
decrypted = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv).decrypt(
base64.b64decode(data)
)
return json.loads(unpad(decrypted, AES.block_size).decode())
def login(self):
if self.aes_key is None:
self.generate_aes_key_and_iv()
if self.password_rsa_n is None:
self.fetch_keys()
if self.seq is None:
self.fetch_auth()
encrypted_password = rsa_encrypt(
self.password_rsa_n,
self.password_rsa_e,
self.password.encode(),
)
response = self.post(
"Login",
"/cgi-bin/luci/;stok=/login",
{"form": "login"},
self.encode_payload({
"operation": "login",
"params": {"password": encrypted_password},
}),
)
data = self.decrypt_data("Login", response["data"])
if data.get("error_code") != 0:
result = data.get("result") or {}
attempts = result.get("attemptsAllowed", "unknown")
raise RuntimeError(f"Login failed: error_code={data.get('error_code')}; attempts={attempts}")
self.stok = data["result"]["stok"]
if not self.cookie:
raise RuntimeError("Login succeeded but no sysauth cookie was returned.")
def clear_auth(self):
self.seq = None
self.stok = None
self.cookie = None
def call(self, context, section, form, payload):
if not self.stok or not self.cookie:
self.login()
response = self.post(
context,
f"/cgi-bin/luci/;stok={self.stok}/admin/{section}",
{"form": form},
self.encode_payload(payload),
)
data = self.decrypt_data(context, response["data"])
error_code = data.get("error_code") or data.get("errorcode")
if error_code:
raise RuntimeError(f"{context}: decoded error_code={error_code}")
return data["result"]
def list_decos(self):
devices = self.call("List Devices", "device", "device_list", {"operation": "read"}).get("device_list", [])
return [
{
"name": deco_name(device),
"mac": device.get("mac"),
"ip": device.get("device_ip"),
"model": device.get("device_model"),
"hardwareVersion": device.get("hardware_ver"),
"firmwareVersion": device.get("software_ver"),
"role": device.get("role"),
"online": device.get("group_status") == "connected",
"connectionType": device.get("connection_type"),
}
for device in devices
]
def list_clients_for_deco(self, deco):
clients = self.call(
f"List Clients {deco['mac']}",
"client",
"client_list",
{"operation": "read", "params": {"device_mac": deco["mac"]}},
).get("client_list", [])
return [
{
"hostname": decode_name(client.get("name")),
"mac": client.get("mac"),
"ip": client.get("ip"),
"connection": client.get("connection_type"),
"interface": client.get("interface"),
"upSpeed": client.get("up_speed"),
"downSpeed": client.get("down_speed"),
"active": client.get("online"),
"linkedDecoMac": deco.get("mac"),
"linkedDecoName": deco.get("name"),
"linkedDecoRole": deco.get("role"),
}
for client in clients
if client.get("online")
]
def run():
if not config()["verify_ssl"]:
requests.packages.urllib3.disable_warnings()
ssl._create_default_https_context = ssl._create_unverified_context
deco = DecoApi(**config())
decos = deco.list_decos()
clients = {}
for node in decos:
if not node.get("mac"):
continue
for client in deco.list_clients_for_deco(node):
clients[client["mac"]] = client
return {"decos": decos, "clients": list(clients.values())}
if __name__ == "__main__":
try:
print(json.dumps(run(), indent=2))
except Exception as err:
print(json.dumps({"error": str(err)}), file=sys.stderr)
sys.exit(1)
+3 -3
View File
@@ -7,7 +7,7 @@ export const FOCUS_CALENDAR = Object.freeze({
const CALENDAR_LIST_SCRIPT = String.raw` const CALENDAR_LIST_SCRIPT = String.raw`
function run() { function run() {
const app = Application("Calendar"); const app = Application("/System/Applications/Calendar.app");
return JSON.stringify(app.calendars().map(function (calendar, index) { return JSON.stringify(app.calendars().map(function (calendar, index) {
return { return {
index: index, index: index,
@@ -20,7 +20,7 @@ function run() {
const EVENTS_LIST_SCRIPT = String.raw` const EVENTS_LIST_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Calendar"); const app = Application("/System/Applications/Calendar.app");
const start = new Date(input.start); const start = new Date(input.start);
const end = new Date(input.end); const end = new Date(input.end);
const calendars = app.calendars(); const calendars = app.calendars();
@@ -61,7 +61,7 @@ function run(argv) {
const EVENT_CREATE_SCRIPT = String.raw` const EVENT_CREATE_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Calendar"); const app = Application("/System/Applications/Calendar.app");
const calendars = app.calendars(); const calendars = app.calendars();
let destination = null; let destination = null;
let destinationIndex = null; let destinationIndex = null;
+3 -3
View File
@@ -7,7 +7,7 @@ function text(value) {
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Contacts"); const app = Application("/System/Applications/Contacts.app");
const query = (input.query || "").toLowerCase(); const query = (input.query || "").toLowerCase();
const people = app.people(); const people = app.people();
const found = []; const found = [];
@@ -41,7 +41,7 @@ function items(values) {
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Contacts"); const app = Application("/System/Applications/Contacts.app");
const people = app.people(); const people = app.people();
for (let i = 0; i < people.length; i++) { for (let i = 0; i < people.length; i++) {
const person = people[i]; const person = people[i];
@@ -64,7 +64,7 @@ function run(argv) {
const CONTACTS_CREATE_SCRIPT = String.raw` const CONTACTS_CREATE_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Contacts"); const app = Application("/System/Applications/Contacts.app");
const person = app.Person({ const person = app.Person({
firstName: input.firstName || "", firstName: input.firstName || "",
lastName: input.lastName || "", lastName: input.lastName || "",
+4 -2
View File
@@ -14,11 +14,13 @@ const execFileAsync = promisify(execFile);
const PYTHON = new URL("../../.venv/bin/python", import.meta.url).pathname; const PYTHON = new URL("../../.venv/bin/python", import.meta.url).pathname;
const BRIDGE = new URL("../../scripts/deco_bridge.py", import.meta.url).pathname; const BRIDGE = new URL("../../scripts/deco_bridge.py", import.meta.url).pathname;
const HA_CLIENTS_BRIDGE = new URL("../../scripts/deco_ha_bridge.py", import.meta.url).pathname;
export async function getDecoStats(action) { export async function getDecoStats(action) {
try { try {
const { stdout } = await execFileAsync(PYTHON, [BRIDGE, action], { const args = action === "clients" ? [HA_CLIENTS_BRIDGE] : [BRIDGE, action];
timeout: 45_000, const { stdout } = await execFileAsync(PYTHON, args, {
timeout: 90_000,
maxBuffer: 4 * 1024 * 1024, maxBuffer: 4 * 1024 * 1024,
env: process.env, env: process.env,
}); });
+3 -3
View File
@@ -3,7 +3,7 @@ import { plainTextToNoteHtml, runJxa } from "../apple-events.js";
const NOTES_LIST_SCRIPT = String.raw` const NOTES_LIST_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Notes"); const app = Application("/System/Applications/Notes.app");
const query = (input.query || "").toLowerCase(); const query = (input.query || "").toLowerCase();
const folderName = input.folder || ""; const folderName = input.folder || "";
const limit = input.limit; const limit = input.limit;
@@ -41,7 +41,7 @@ function run(argv) {
const NOTES_READ_SCRIPT = String.raw` const NOTES_READ_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Notes"); const app = Application("/System/Applications/Notes.app");
const accounts = app.accounts(); const accounts = app.accounts();
for (let a = 0; a < accounts.length; a++) { for (let a = 0; a < accounts.length; a++) {
const folders = accounts[a].folders(); const folders = accounts[a].folders();
@@ -69,7 +69,7 @@ function run(argv) {
const NOTES_CREATE_SCRIPT = String.raw` const NOTES_CREATE_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Notes"); const app = Application("/System/Applications/Notes.app");
const accounts = app.accounts(); const accounts = app.accounts();
let destination = null; let destination = null;
+96 -6
View File
@@ -2,18 +2,105 @@ import { dateFromInput, runJxa } from "../apple-events.js";
const LISTS_SCRIPT = String.raw` const LISTS_SCRIPT = String.raw`
function run() { function run() {
const app = Application("Reminders"); const app = Application("/System/Applications/Reminders.app");
function stringValue(value) {
try {
return value === null || value === undefined ? null : String(value);
} catch (_) {
return null;
}
}
function accountName(list) {
try {
const container = list.container();
return stringValue(container.name ? container.name() : container);
} catch (_) {
return null;
}
}
return JSON.stringify(app.lists().map(function (list) { return JSON.stringify(app.lists().map(function (list) {
return { id: String(list.id()), name: String(list.name()) }; return {
id: String(list.id()),
name: String(list.name()),
account: accountName(list),
shared: null,
assignmentMetadata: {
available: null,
note: "Apple Reminders automation does not expose shared-list participant metadata directly."
}
};
})); }));
}`; }`;
const REMINDERS_LIST_SCRIPT = String.raw` const REMINDERS_LIST_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Reminders"); const app = Application("/System/Applications/Reminders.app");
const lists = app.lists(); const lists = app.lists();
const found = []; const found = [];
function stringValue(value) {
try {
return value === null || value === undefined ? null : String(value);
} catch (_) {
return null;
}
}
function compact(value) {
if (!value) return null;
const text = String(value).trim();
return text ? text : null;
}
function propertyValue(object, names) {
for (let i = 0; i < names.length; i++) {
try {
const getter = object[names[i]];
if (typeof getter !== "function") continue;
const value = getter.call(object);
const text = compact(stringValue(value));
if (text && !text.startsWith("[object ")) return text;
if (value && typeof value.name === "function") {
const name = compact(stringValue(value.name()));
if (name) return name;
}
} catch (_) {}
}
return null;
}
function assignmentHint(title, notes) {
const titleMatch = title.match(/\(([^()\n]{2,80})\)\s*$/);
if (titleMatch) return { assignee: titleMatch[1].trim(), source: "title" };
const explicit = notes.match(/\b(?:assigned to|assignee)\s*:\s*([^.;,\n]{2,80})/i);
if (explicit) return { assignee: explicit[1].trim(), source: "notes" };
const captured = notes.match(/\bCaptured\s+\d{4}-\d{2}-\d{2},\s*([^.;,\n]{2,80})\s*[.;]/i);
if (captured) return { assignee: captured[1].trim(), source: "notes" };
return { assignee: null, source: null };
}
function assignmentFor(reminder, title, notes) {
const nativeAssignee = propertyValue(reminder, [
"assignedTo",
"assignee",
"assignment",
"responsiblePerson",
"principal"
]);
if (nativeAssignee) {
return {
assignee: nativeAssignee,
source: "remindersAutomation",
available: true
};
}
const hint = assignmentHint(title, notes);
return {
assignee: hint.assignee,
source: hint.source,
available: hint.assignee !== null
};
}
for (let l = 0; l < lists.length && found.length < input.limit; l++) { for (let l = 0; l < lists.length && found.length < input.limit; l++) {
const list = lists[l]; const list = lists[l];
const name = String(list.name()); const name = String(list.name());
@@ -28,13 +115,16 @@ function run(argv) {
const value = reminder.dueDate(); const value = reminder.dueDate();
due = value ? value.toISOString() : null; due = value ? value.toISOString() : null;
} catch (_) {} } catch (_) {}
const title = String(reminder.name());
const notes = String(reminder.body() || "");
found.push({ found.push({
id: String(reminder.id()), id: String(reminder.id()),
list: name, list: name,
title: String(reminder.name()), title: title,
completed: completed, completed: completed,
due: due, due: due,
notes: String(reminder.body() || "") notes: notes,
assignment: assignmentFor(reminder, title, notes)
}); });
} }
} }
@@ -44,7 +134,7 @@ function run(argv) {
const REMINDER_CREATE_SCRIPT = String.raw` const REMINDER_CREATE_SCRIPT = String.raw`
function run(argv) { function run(argv) {
const input = JSON.parse(argv[0]); const input = JSON.parse(argv[0]);
const app = Application("Reminders"); const app = Application("/System/Applications/Reminders.app");
const lists = app.lists(); const lists = app.lists();
let destination = null; let destination = null;
for (let l = 0; l < lists.length; l++) { for (let l = 0; l < lists.length; l++) {