feat: add clipboard file handling for Mattermost and implement batch debounce functionality
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user