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:
@@ -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://<mac-lan-ip>:7331/mcp
|
||||
Authorization: Bearer <MACMINI_MCP_TOKEN>
|
||||
Authorization: Bearer <MACMI...KEN>
|
||||
```
|
||||
|
||||
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 <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
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user