From 2345627eb8d002f9030f3e112d590586a2149aa9 Mon Sep 17 00:00:00 2001 From: aeroreyna Date: Mon, 13 Jul 2026 16:18:40 -0400 Subject: [PATCH] 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 --- README.md | 59 +++++- scripts/deco_ha_bridge.py | 383 ++++++++++++++++++++++++++++++++++ src/integrations/calendar.js | 6 +- src/integrations/contacts.js | 6 +- src/integrations/deco.js | 6 +- src/integrations/notes.js | 6 +- src/integrations/reminders.js | 102 ++++++++- 7 files changed, 544 insertions(+), 24 deletions(-) create mode 100644 scripts/deco_ha_bridge.py diff --git a/README.md b/README.md index e3f2b55..6043ebc 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,25 @@ and stays on the local machine. | `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_create_event` | Create an event in the focused `Home` calendar | -| `reminders_list_lists` | List reminder lists | -| `reminders_list` | List reminders | +| `reminders_list_lists` | List reminder lists with account context and assignment metadata availability | +| `reminders_list` | List reminders, including assignment details or assignment hints for shared-list reminders | | `reminders_create` | Create a reminder | | `contacts_search` | Search contact names and organizations without disclosing contact methods | | `contacts_read` | Read phone and email details for one selected contact | | `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_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_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. @@ -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. 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 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 URL: http://:7331/mcp -Authorization: Bearer +Authorization: Bearer ``` Restart after changing `.env`: @@ -96,8 +114,28 @@ npm run service:restart ``` For TP-Link Deco tools, install Python dependencies with `npm run -python:install`, then set `DECO_HOST`, `DECO_USERNAME`, `DECO_PASSWORD`, and -optionally `DECO_VERIFY_SSL=false` in `.env`. +python:install`, then set `DECO_HOST`, `DECO_USERNAME=admin`, `DECO_PASSWORD`, +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: @@ -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 LAN address, it refuses to start without `MACMINI_MCP_TOKEN`; clients must send -`Authorization: Bearer `. 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 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 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. diff --git a/scripts/deco_ha_bridge.py b/scripts/deco_ha_bridge.py new file mode 100644 index 0000000..263eddb --- /dev/null +++ b/scripts/deco_ha_bridge.py @@ -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) diff --git a/src/integrations/calendar.js b/src/integrations/calendar.js index 5e99d4e..b8bd3d5 100644 --- a/src/integrations/calendar.js +++ b/src/integrations/calendar.js @@ -7,7 +7,7 @@ export const FOCUS_CALENDAR = Object.freeze({ const CALENDAR_LIST_SCRIPT = String.raw` function run() { - const app = Application("Calendar"); + const app = Application("/System/Applications/Calendar.app"); return JSON.stringify(app.calendars().map(function (calendar, index) { return { index: index, @@ -20,7 +20,7 @@ function run() { const EVENTS_LIST_SCRIPT = String.raw` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Calendar"); + const app = Application("/System/Applications/Calendar.app"); const start = new Date(input.start); const end = new Date(input.end); const calendars = app.calendars(); @@ -61,7 +61,7 @@ function run(argv) { const EVENT_CREATE_SCRIPT = String.raw` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Calendar"); + const app = Application("/System/Applications/Calendar.app"); const calendars = app.calendars(); let destination = null; let destinationIndex = null; diff --git a/src/integrations/contacts.js b/src/integrations/contacts.js index 3c09806..95bedf9 100644 --- a/src/integrations/contacts.js +++ b/src/integrations/contacts.js @@ -7,7 +7,7 @@ function text(value) { function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Contacts"); + const app = Application("/System/Applications/Contacts.app"); const query = (input.query || "").toLowerCase(); const people = app.people(); const found = []; @@ -41,7 +41,7 @@ function items(values) { function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Contacts"); + const app = Application("/System/Applications/Contacts.app"); const people = app.people(); for (let i = 0; i < people.length; i++) { const person = people[i]; @@ -64,7 +64,7 @@ function run(argv) { const CONTACTS_CREATE_SCRIPT = String.raw` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Contacts"); + const app = Application("/System/Applications/Contacts.app"); const person = app.Person({ firstName: input.firstName || "", lastName: input.lastName || "", diff --git a/src/integrations/deco.js b/src/integrations/deco.js index 4871908..313f81b 100644 --- a/src/integrations/deco.js +++ b/src/integrations/deco.js @@ -14,11 +14,13 @@ const execFileAsync = promisify(execFile); const PYTHON = new URL("../../.venv/bin/python", 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) { try { - const { stdout } = await execFileAsync(PYTHON, [BRIDGE, action], { - timeout: 45_000, + const args = action === "clients" ? [HA_CLIENTS_BRIDGE] : [BRIDGE, action]; + const { stdout } = await execFileAsync(PYTHON, args, { + timeout: 90_000, maxBuffer: 4 * 1024 * 1024, env: process.env, }); diff --git a/src/integrations/notes.js b/src/integrations/notes.js index 97c4b8d..8b48a7f 100644 --- a/src/integrations/notes.js +++ b/src/integrations/notes.js @@ -3,7 +3,7 @@ import { plainTextToNoteHtml, runJxa } from "../apple-events.js"; const NOTES_LIST_SCRIPT = String.raw` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Notes"); + const app = Application("/System/Applications/Notes.app"); const query = (input.query || "").toLowerCase(); const folderName = input.folder || ""; const limit = input.limit; @@ -41,7 +41,7 @@ function run(argv) { const NOTES_READ_SCRIPT = String.raw` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Notes"); + const app = Application("/System/Applications/Notes.app"); const accounts = app.accounts(); for (let a = 0; a < accounts.length; a++) { const folders = accounts[a].folders(); @@ -69,7 +69,7 @@ function run(argv) { const NOTES_CREATE_SCRIPT = String.raw` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Notes"); + const app = Application("/System/Applications/Notes.app"); const accounts = app.accounts(); let destination = null; diff --git a/src/integrations/reminders.js b/src/integrations/reminders.js index 8391220..09e28e1 100644 --- a/src/integrations/reminders.js +++ b/src/integrations/reminders.js @@ -2,18 +2,105 @@ import { dateFromInput, runJxa } from "../apple-events.js"; const LISTS_SCRIPT = String.raw` 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 { 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` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Reminders"); + const app = Application("/System/Applications/Reminders.app"); const lists = app.lists(); 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++) { const list = lists[l]; const name = String(list.name()); @@ -28,13 +115,16 @@ function run(argv) { const value = reminder.dueDate(); due = value ? value.toISOString() : null; } catch (_) {} + const title = String(reminder.name()); + const notes = String(reminder.body() || ""); found.push({ id: String(reminder.id()), list: name, - title: String(reminder.name()), + title: title, completed: completed, 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` function run(argv) { const input = JSON.parse(argv[0]); - const app = Application("Reminders"); + const app = Application("/System/Applications/Reminders.app"); const lists = app.lists(); let destination = null; for (let l = 0; l < lists.length; l++) {