feat: add clipboard file handling for Mattermost and implement batch debounce functionality

This commit is contained in:
2026-05-15 07:54:01 -06:00
parent 7fc4320f46
commit 456a4c3381
5 changed files with 322 additions and 23 deletions

View File

@@ -9,6 +9,7 @@ import json
import os
import shlex
import subprocess
import threading
from dataclasses import dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -18,6 +19,9 @@ from urllib.parse import parse_qs, urlparse
WORKSPACE_DIR = Path(__file__).resolve().parents[2]
SCRIPT_DIR = Path(__file__).resolve().parent
COPY_FILES_HELPER = SCRIPT_DIR / "copy_files_to_clipboard.swift"
COPY_FILES_HELPER_BIN = SCRIPT_DIR / "copy_files_to_clipboard"
DEFAULT_OPENCODE_DIR = WORKSPACE_DIR / "ai" / "inbox" / "photos"
DEFAULT_MATTERMOST_DIR = Path.home() / "Pictures" / "iPhone Inbox"
@@ -26,6 +30,7 @@ CLIPBOARD_IMAGE = "image"
CLIPBOARD_PATH = "path"
CLIPBOARD_TERMINAL_PATH = "terminal-path"
CLIPBOARD_FILE = "file"
CLIPBOARD_FILES = "files"
@dataclass(frozen=True)
@@ -55,7 +60,7 @@ def profile_defaults() -> dict[str, Profile]:
general_dir = env_path("IPHONE_PHOTO_GENERAL_DIR", DEFAULT_MATTERMOST_DIR)
return {
"opencode": Profile("opencode", opencode_dir, CLIPBOARD_TERMINAL_PATH),
"mattermost": Profile("mattermost", mattermost_dir, CLIPBOARD_IMAGE),
"mattermost": Profile("mattermost", mattermost_dir, CLIPBOARD_FILES),
"general": Profile("general", general_dir, CLIPBOARD_NONE),
}
@@ -93,6 +98,11 @@ def run_macos_action(command: list[str]) -> bool:
print(f"macOS action failed: {error}", flush=True)
return False
if env_flag("IPHONE_PHOTO_DEBUG"):
output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
if output:
print(f"macOS action output: {output}", flush=True)
return True
@@ -111,6 +121,10 @@ def copy_image_to_clipboard(path: Path) -> bool:
def copy_file_to_clipboard(path: Path) -> bool:
return copy_files_to_clipboard([path])
def copy_file_to_clipboard_with_applescript(path: Path) -> bool:
script = f"""
set theFile to POSIX file {json.dumps(str(path))} as alias
tell application "Finder"
@@ -120,6 +134,47 @@ end tell
return run_macos_action(["osascript", "-e", script])
def copy_files_to_clipboard(paths: list[Path]) -> bool:
if not paths:
return True
helper = COPY_FILES_HELPER
if COPY_FILES_HELPER_BIN.exists():
source_mtime = COPY_FILES_HELPER.stat().st_mtime if COPY_FILES_HELPER.exists() else 0
helper_mtime = COPY_FILES_HELPER_BIN.stat().st_mtime
if helper_mtime >= source_mtime:
helper = COPY_FILES_HELPER_BIN
else:
print("compiled pasteboard helper is older than source; using Swift script", flush=True)
if helper.exists():
if run_macos_action([str(helper), *[str(path) for path in paths]]):
return True
print("native pasteboard helper failed; falling back to AppleScript", flush=True)
return copy_files_to_clipboard_with_applescript(paths)
def copy_files_to_clipboard_with_applescript(paths: list[Path]) -> bool:
if not paths:
return True
alias_lines = [
f"set end of theFiles to POSIX file {json.dumps(str(path))} as alias"
for path in paths
]
script = "\n".join(
[
"set theFiles to {}",
*alias_lines,
'tell application "Finder"',
" set the clipboard to theFiles",
"end tell",
]
)
return run_macos_action(["osascript", "-e", script])
def copy_text_to_clipboard(text: str) -> bool:
script = f"set the clipboard to {json.dumps(text)}"
return run_macos_action(["osascript", "-e", script])
@@ -129,21 +184,95 @@ def terminal_path_reference(path: Path) -> str:
return shlex.quote(str(path))
def apply_clipboard_mode(mode: str, path: Path) -> bool:
def paths_reference(paths: list[Path]) -> str:
return "\n".join(str(path) for path in paths)
def terminal_paths_reference(paths: list[Path]) -> str:
return "\n".join(terminal_path_reference(path) for path in paths)
def apply_clipboard_mode(mode: str, paths: list[Path]) -> bool:
if mode == CLIPBOARD_NONE:
return True
if not paths:
return True
latest = paths[-1]
if mode == CLIPBOARD_IMAGE:
return copy_image_to_clipboard(path)
return copy_image_to_clipboard(latest)
if mode == CLIPBOARD_PATH:
return copy_text_to_clipboard(str(path))
return copy_text_to_clipboard(paths_reference(paths))
if mode == CLIPBOARD_TERMINAL_PATH:
return copy_text_to_clipboard(terminal_path_reference(path))
return copy_text_to_clipboard(terminal_paths_reference(paths))
if mode == CLIPBOARD_FILE:
return copy_file_to_clipboard(path)
return copy_file_to_clipboard(latest)
if mode == CLIPBOARD_FILES:
return copy_files_to_clipboard(paths)
print(f"unsupported clipboard mode: {mode}", flush=True)
return False
@dataclass
class Batch:
profile: Profile
output_dir: Path
paths: list[Path]
started_at: dt.datetime
updated_at: dt.datetime
timer: threading.Timer | None = None
@property
def count(self) -> int:
return len(self.paths)
class BatchManager:
def __init__(self, debounce_seconds: float) -> None:
self.debounce_seconds = debounce_seconds
self.lock = threading.Lock()
self.batches: dict[str, Batch] = {}
def add(self, profile: Profile, output_dir: Path, path: Path) -> tuple[int, list[Path]]:
with self.lock:
now = dt.datetime.now()
batch = self.batches.get(profile.name)
if batch is None:
batch = Batch(profile, output_dir, [], now, now)
self.batches[profile.name] = batch
batch.profile = profile
batch.output_dir = output_dir
batch.paths.append(path)
batch.updated_at = now
if batch.timer is not None:
batch.timer.cancel()
batch.timer = threading.Timer(self.debounce_seconds, self.finish, args=(profile.name,))
batch.timer.daemon = True
batch.timer.start()
return batch.count, list(batch.paths)
def finish(self, profile_name: str) -> None:
with self.lock:
batch = self.batches.pop(profile_name, None)
if batch is None:
return
if batch.profile.notify:
plural = "photo" if batch.count == 1 else "photos"
message = f"{batch.profile.name}: {batch.count} {plural} ready; clipboard={batch.profile.clipboard}"
if notify("iPhone Photo Inbox", message):
print(f"batch notification sent profile={batch.profile.name} count={batch.count}", flush=True)
print(
f"batch finalized profile={batch.profile.name} count={batch.count} "
f"dir={batch.output_dir}",
flush=True,
)
class UploadHandler(BaseHTTPRequestHandler):
server_version = "iPhonePhotoInbox/2.0"
@@ -201,17 +330,20 @@ class UploadHandler(BaseHTTPRequestHandler):
temp_path = Path(tmp.name)
temp_path.replace(target)
if profile.notify:
if notify("iPhone Photo Inbox", f"{profile.name}: {target.name}"):
print("notification sent", flush=True)
if profile.reveal:
if reveal_in_finder(target):
print("revealed in Finder", flush=True)
if apply_clipboard_mode(profile.clipboard, target):
print(f"clipboard mode applied: {profile.clipboard}", flush=True)
batch_count, batch_paths = self.server.batch_manager.add(profile, output_dir, target)
if apply_clipboard_mode(profile.clipboard, batch_paths):
print(
f"clipboard mode applied: {profile.clipboard} "
f"profile={profile.name} count={batch_count}",
flush=True,
)
self.send_text(HTTPStatus.CREATED, f"{target}\n")
print(f"saved {target} profile={profile.name}", flush=True)
print(f"saved {target} profile={profile.name} batch_count={batch_count}", flush=True)
def log_message(self, format: str, *args: object) -> None:
print(f"{self.address_string()} - {format % args}", flush=True)
@@ -235,6 +367,7 @@ class UploadServer(ThreadingHTTPServer):
output_dir_override: Path | None,
upload_token: str,
max_bytes: int,
debounce_seconds: float,
) -> None:
super().__init__(server_address, handler_class)
self.profiles = profiles
@@ -242,6 +375,7 @@ class UploadServer(ThreadingHTTPServer):
self.output_dir_override = output_dir_override
self.upload_token = upload_token
self.max_bytes = max_bytes
self.batch_manager = BatchManager(debounce_seconds)
def apply_legacy_clipboard_overrides(profile: Profile) -> Profile:
@@ -254,6 +388,8 @@ def apply_legacy_clipboard_overrides(profile: Profile) -> Profile:
clipboard = CLIPBOARD_PATH
if env_flag("IPHONE_PHOTO_COPY_TERMINAL_PATH"):
clipboard = CLIPBOARD_TERMINAL_PATH
if env_flag("IPHONE_PHOTO_COPY_FILES"):
clipboard = CLIPBOARD_FILES
notify_enabled = env_flag("IPHONE_PHOTO_NOTIFY", profile.notify)
reveal_enabled = env_flag("IPHONE_PHOTO_REVEAL", profile.reveal)
@@ -268,9 +404,10 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--output-dir", type=Path, default=os.getenv("IPHONE_PHOTO_OUTPUT_DIR"))
parser.add_argument("--token", default=os.getenv("IPHONE_PHOTO_TOKEN", ""))
parser.add_argument("--max-mb", type=int, default=int(os.getenv("IPHONE_PHOTO_MAX_MB", "30")))
parser.add_argument("--debounce-seconds", type=float, default=float(os.getenv("IPHONE_PHOTO_DEBOUNCE_SECONDS", "5")))
parser.add_argument(
"--clipboard",
choices=[CLIPBOARD_NONE, CLIPBOARD_IMAGE, CLIPBOARD_PATH, CLIPBOARD_TERMINAL_PATH, CLIPBOARD_FILE],
choices=[CLIPBOARD_NONE, CLIPBOARD_IMAGE, CLIPBOARD_PATH, CLIPBOARD_TERMINAL_PATH, CLIPBOARD_FILE, CLIPBOARD_FILES],
default=os.getenv("IPHONE_PHOTO_CLIPBOARD"),
)
parser.add_argument("--no-notify", action="store_true", default=env_flag("IPHONE_PHOTO_NO_NOTIFY"))
@@ -319,9 +456,11 @@ def main() -> None:
output_dir_override,
args.token,
args.max_mb * 1024 * 1024,
args.debounce_seconds,
)
print(f"listening on http://{args.host}:{args.port}/upload", flush=True)
print(f"default profile: {default_profile}", flush=True)
print(f"debounce seconds: {args.debounce_seconds:g}", flush=True)
for profile in profiles.values():
output_dir = output_dir_override or profile.output_dir
print(