feat: add clipboard file handling for Mattermost and implement batch debounce functionality
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,6 +14,9 @@ ai/inbox/mattermost-status.json
|
|||||||
ai/inbox/photos/*
|
ai/inbox/photos/*
|
||||||
!ai/inbox/photos/.gitkeep
|
!ai/inbox/photos/.gitkeep
|
||||||
|
|
||||||
|
# Local build artifact for the iPhone photo inbox pasteboard helper
|
||||||
|
scripts/iphone-photo-inbox/copy_files_to_clipboard
|
||||||
|
|
||||||
# Workspace-local Mattermost runtime artifacts
|
# Workspace-local Mattermost runtime artifacts
|
||||||
scripts/mattermost/.env
|
scripts/mattermost/.env
|
||||||
scripts/mattermost/.venv/
|
scripts/mattermost/.venv/
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ clipboard behavior.
|
|||||||
`opencode`
|
`opencode`
|
||||||
|
|
||||||
- Saves to `ai/inbox/photos/`
|
- Saves to `ai/inbox/photos/`
|
||||||
- Copies a terminal-safe path to the clipboard
|
- Copies terminal-safe paths to the clipboard
|
||||||
- Best for pasting into OpenCode running in a terminal
|
- Best for pasting into OpenCode running in a terminal
|
||||||
|
|
||||||
`mattermost`
|
`mattermost`
|
||||||
|
|
||||||
- Saves to `~/Pictures/iPhone Inbox`
|
- Saves to `~/Pictures/iPhone Inbox`
|
||||||
- Copies the image data to the clipboard
|
- Copies native macOS file URLs to the clipboard
|
||||||
- Best for pasting directly into Mattermost
|
- Best effort for pasting one or more files directly into Mattermost
|
||||||
|
|
||||||
`general`
|
`general`
|
||||||
|
|
||||||
@@ -26,6 +26,28 @@ clipboard behavior.
|
|||||||
|
|
||||||
All profiles show a macOS notification by default.
|
All profiles show a macOS notification by default.
|
||||||
|
|
||||||
|
## Batch debounce
|
||||||
|
|
||||||
|
Uploads are grouped by profile. Every new photo extends the active profile batch
|
||||||
|
by 10 seconds and immediately refreshes the clipboard with the full batch.
|
||||||
|
|
||||||
|
Example for `opencode`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
photo 1 arrives -> clipboard has photo 1 path
|
||||||
|
photo 2 arrives within 10s -> clipboard has photo 1 + photo 2 paths
|
||||||
|
photo 3 arrives within 10s -> clipboard has photo 1 + photo 2 + photo 3 paths
|
||||||
|
10s pass with no new photo -> notification says the batch is ready
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the Shortcut simple while still making the clipboard usable before
|
||||||
|
the final notification appears.
|
||||||
|
|
||||||
|
For `mattermost`, each upload rewrites the clipboard with the full batch using a
|
||||||
|
small Swift helper and `NSPasteboard.writeObjects`. This is closer to Finder's
|
||||||
|
file-copy behavior than the older AppleScript alias approach. If the native
|
||||||
|
helper fails, the receiver falls back to AppleScript and logs the fallback.
|
||||||
|
|
||||||
## Start the receiver
|
## Start the receiver
|
||||||
|
|
||||||
Recommended:
|
Recommended:
|
||||||
@@ -150,17 +172,25 @@ Clipboard override for all profiles:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
IPHONE_PHOTO_CLIPBOARD=image
|
IPHONE_PHOTO_CLIPBOARD=image
|
||||||
|
IPHONE_PHOTO_CLIPBOARD=files
|
||||||
IPHONE_PHOTO_CLIPBOARD=terminal-path
|
IPHONE_PHOTO_CLIPBOARD=terminal-path
|
||||||
IPHONE_PHOTO_CLIPBOARD=path
|
IPHONE_PHOTO_CLIPBOARD=path
|
||||||
IPHONE_PHOTO_CLIPBOARD=file
|
IPHONE_PHOTO_CLIPBOARD=file
|
||||||
IPHONE_PHOTO_CLIPBOARD=none
|
IPHONE_PHOTO_CLIPBOARD=none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Debounce override:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IPHONE_PHOTO_DEBOUNCE_SECONDS=5
|
||||||
|
```
|
||||||
|
|
||||||
Other useful options:
|
Other useful options:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/iphone-photo-inbox/receiver.py --no-notify
|
python3 scripts/iphone-photo-inbox/receiver.py --no-notify
|
||||||
python3 scripts/iphone-photo-inbox/receiver.py --reveal
|
python3 scripts/iphone-photo-inbox/receiver.py --reveal
|
||||||
|
IPHONE_PHOTO_DEBUG=1 python3 scripts/iphone-photo-inbox/receiver.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -169,26 +199,43 @@ Startup should print each active profile:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
profile opencode: dir=... clipboard=terminal-path notify=True reveal=False
|
profile opencode: dir=... clipboard=terminal-path notify=True reveal=False
|
||||||
profile mattermost: dir=... clipboard=image notify=True reveal=False
|
profile mattermost: dir=... clipboard=files notify=True reveal=False
|
||||||
```
|
```
|
||||||
|
|
||||||
After upload, expect:
|
After each upload, expect:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
notification sent
|
clipboard mode applied: terminal-path profile=opencode count=2
|
||||||
clipboard mode applied: terminal-path
|
saved ... profile=opencode batch_count=2
|
||||||
saved ... profile=opencode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For Mattermost, expect:
|
After the debounce window closes, expect:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clipboard mode applied: image
|
batch notification sent profile=opencode count=2
|
||||||
|
batch finalized profile=opencode count=2 dir=...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The native file clipboard helper lives at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
For faster multi-file clipboard updates during debounce, compile it once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swiftc scripts/iphone-photo-inbox/copy_files_to_clipboard.swift \
|
||||||
|
-o scripts/iphone-photo-inbox/copy_files_to_clipboard
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiled binary is ignored by git. If it is not present, the receiver can
|
||||||
|
run the Swift script directly, but that is slower on first use.
|
||||||
|
|
||||||
If files arrive but clipboard/notifications do not behave as expected, check:
|
If files arrive but clipboard/notifications do not behave as expected, check:
|
||||||
|
|
||||||
- The Shortcut URL includes the intended `profile=`.
|
- The Shortcut URL includes the intended `profile=`.
|
||||||
- The receiver log shows the expected profile.
|
- The receiver log shows the expected profile.
|
||||||
|
- With `IPHONE_PHOTO_DEBUG=1`, the Mattermost profile should report `pasteboard files=2 items=2` after the second photo in a two-photo batch.
|
||||||
- macOS Focus/Do Not Disturb is not hiding notifications.
|
- macOS Focus/Do Not Disturb is not hiding notifications.
|
||||||
- Terminal/Codex has permission for AppleScript automation if macOS prompts.
|
- Terminal/Codex has permission for AppleScript automation if macOS prompts.
|
||||||
|
|||||||
40
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
Executable file
40
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
let paths = CommandLine.arguments.dropFirst()
|
||||||
|
|
||||||
|
guard !paths.isEmpty else {
|
||||||
|
fputs("usage: copy_files_to_clipboard.swift <path> [path...]\n", stderr)
|
||||||
|
exit(64)
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls = paths.map { URL(fileURLWithPath: $0).standardizedFileURL }
|
||||||
|
let missing = urls.filter { !FileManager.default.fileExists(atPath: $0.path) }
|
||||||
|
|
||||||
|
guard missing.isEmpty else {
|
||||||
|
for url in missing {
|
||||||
|
fputs("missing file: \(url.path)\n", stderr)
|
||||||
|
}
|
||||||
|
exit(66)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
|
||||||
|
let wroteObjects = pasteboard.writeObjects(urls as [NSURL])
|
||||||
|
|
||||||
|
let filenamesType = NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||||
|
let pathsList = urls.map(\.path)
|
||||||
|
let wroteFilenames = pasteboard.setPropertyList(pathsList, forType: filenamesType)
|
||||||
|
let wrotePlainText = pasteboard.setString(pathsList.joined(separator: "\n"), forType: .string)
|
||||||
|
|
||||||
|
if wroteObjects || wroteFilenames || wrotePlainText {
|
||||||
|
let itemCount = pasteboard.pasteboardItems?.count ?? 0
|
||||||
|
fputs("pasteboard files=\(urls.count) items=\(itemCount) objects=\(wroteObjects) filenames=\(wroteFilenames) text=\(wrotePlainText)\n", stderr)
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fputs("failed to write file URLs to pasteboard\n", stderr)
|
||||||
|
exit(1)
|
||||||
@@ -9,6 +9,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@@ -18,6 +19,9 @@ from urllib.parse import parse_qs, urlparse
|
|||||||
|
|
||||||
|
|
||||||
WORKSPACE_DIR = Path(__file__).resolve().parents[2]
|
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_OPENCODE_DIR = WORKSPACE_DIR / "ai" / "inbox" / "photos"
|
||||||
DEFAULT_MATTERMOST_DIR = Path.home() / "Pictures" / "iPhone Inbox"
|
DEFAULT_MATTERMOST_DIR = Path.home() / "Pictures" / "iPhone Inbox"
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ CLIPBOARD_IMAGE = "image"
|
|||||||
CLIPBOARD_PATH = "path"
|
CLIPBOARD_PATH = "path"
|
||||||
CLIPBOARD_TERMINAL_PATH = "terminal-path"
|
CLIPBOARD_TERMINAL_PATH = "terminal-path"
|
||||||
CLIPBOARD_FILE = "file"
|
CLIPBOARD_FILE = "file"
|
||||||
|
CLIPBOARD_FILES = "files"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -55,7 +60,7 @@ def profile_defaults() -> dict[str, Profile]:
|
|||||||
general_dir = env_path("IPHONE_PHOTO_GENERAL_DIR", DEFAULT_MATTERMOST_DIR)
|
general_dir = env_path("IPHONE_PHOTO_GENERAL_DIR", DEFAULT_MATTERMOST_DIR)
|
||||||
return {
|
return {
|
||||||
"opencode": Profile("opencode", opencode_dir, CLIPBOARD_TERMINAL_PATH),
|
"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),
|
"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)
|
print(f"macOS action failed: {error}", flush=True)
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +121,10 @@ def copy_image_to_clipboard(path: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def copy_file_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"""
|
script = f"""
|
||||||
set theFile to POSIX file {json.dumps(str(path))} as alias
|
set theFile to POSIX file {json.dumps(str(path))} as alias
|
||||||
tell application "Finder"
|
tell application "Finder"
|
||||||
@@ -120,6 +134,47 @@ end tell
|
|||||||
return run_macos_action(["osascript", "-e", script])
|
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:
|
def copy_text_to_clipboard(text: str) -> bool:
|
||||||
script = f"set the clipboard to {json.dumps(text)}"
|
script = f"set the clipboard to {json.dumps(text)}"
|
||||||
return run_macos_action(["osascript", "-e", script])
|
return run_macos_action(["osascript", "-e", script])
|
||||||
@@ -129,21 +184,95 @@ def terminal_path_reference(path: Path) -> str:
|
|||||||
return shlex.quote(str(path))
|
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:
|
if mode == CLIPBOARD_NONE:
|
||||||
return True
|
return True
|
||||||
|
if not paths:
|
||||||
|
return True
|
||||||
|
|
||||||
|
latest = paths[-1]
|
||||||
if mode == CLIPBOARD_IMAGE:
|
if mode == CLIPBOARD_IMAGE:
|
||||||
return copy_image_to_clipboard(path)
|
return copy_image_to_clipboard(latest)
|
||||||
if mode == CLIPBOARD_PATH:
|
if mode == CLIPBOARD_PATH:
|
||||||
return copy_text_to_clipboard(str(path))
|
return copy_text_to_clipboard(paths_reference(paths))
|
||||||
if mode == CLIPBOARD_TERMINAL_PATH:
|
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:
|
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)
|
print(f"unsupported clipboard mode: {mode}", flush=True)
|
||||||
return False
|
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):
|
class UploadHandler(BaseHTTPRequestHandler):
|
||||||
server_version = "iPhonePhotoInbox/2.0"
|
server_version = "iPhonePhotoInbox/2.0"
|
||||||
|
|
||||||
@@ -201,17 +330,20 @@ class UploadHandler(BaseHTTPRequestHandler):
|
|||||||
temp_path = Path(tmp.name)
|
temp_path = Path(tmp.name)
|
||||||
temp_path.replace(target)
|
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 profile.reveal:
|
||||||
if reveal_in_finder(target):
|
if reveal_in_finder(target):
|
||||||
print("revealed in Finder", flush=True)
|
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")
|
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:
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
print(f"{self.address_string()} - {format % args}", flush=True)
|
print(f"{self.address_string()} - {format % args}", flush=True)
|
||||||
@@ -235,6 +367,7 @@ class UploadServer(ThreadingHTTPServer):
|
|||||||
output_dir_override: Path | None,
|
output_dir_override: Path | None,
|
||||||
upload_token: str,
|
upload_token: str,
|
||||||
max_bytes: int,
|
max_bytes: int,
|
||||||
|
debounce_seconds: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(server_address, handler_class)
|
super().__init__(server_address, handler_class)
|
||||||
self.profiles = profiles
|
self.profiles = profiles
|
||||||
@@ -242,6 +375,7 @@ class UploadServer(ThreadingHTTPServer):
|
|||||||
self.output_dir_override = output_dir_override
|
self.output_dir_override = output_dir_override
|
||||||
self.upload_token = upload_token
|
self.upload_token = upload_token
|
||||||
self.max_bytes = max_bytes
|
self.max_bytes = max_bytes
|
||||||
|
self.batch_manager = BatchManager(debounce_seconds)
|
||||||
|
|
||||||
|
|
||||||
def apply_legacy_clipboard_overrides(profile: Profile) -> Profile:
|
def apply_legacy_clipboard_overrides(profile: Profile) -> Profile:
|
||||||
@@ -254,6 +388,8 @@ def apply_legacy_clipboard_overrides(profile: Profile) -> Profile:
|
|||||||
clipboard = CLIPBOARD_PATH
|
clipboard = CLIPBOARD_PATH
|
||||||
if env_flag("IPHONE_PHOTO_COPY_TERMINAL_PATH"):
|
if env_flag("IPHONE_PHOTO_COPY_TERMINAL_PATH"):
|
||||||
clipboard = CLIPBOARD_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)
|
notify_enabled = env_flag("IPHONE_PHOTO_NOTIFY", profile.notify)
|
||||||
reveal_enabled = env_flag("IPHONE_PHOTO_REVEAL", profile.reveal)
|
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("--output-dir", type=Path, default=os.getenv("IPHONE_PHOTO_OUTPUT_DIR"))
|
||||||
parser.add_argument("--token", default=os.getenv("IPHONE_PHOTO_TOKEN", ""))
|
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("--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(
|
parser.add_argument(
|
||||||
"--clipboard",
|
"--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"),
|
default=os.getenv("IPHONE_PHOTO_CLIPBOARD"),
|
||||||
)
|
)
|
||||||
parser.add_argument("--no-notify", action="store_true", default=env_flag("IPHONE_PHOTO_NO_NOTIFY"))
|
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,
|
output_dir_override,
|
||||||
args.token,
|
args.token,
|
||||||
args.max_mb * 1024 * 1024,
|
args.max_mb * 1024 * 1024,
|
||||||
|
args.debounce_seconds,
|
||||||
)
|
)
|
||||||
print(f"listening on http://{args.host}:{args.port}/upload", flush=True)
|
print(f"listening on http://{args.host}:{args.port}/upload", flush=True)
|
||||||
print(f"default profile: {default_profile}", 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():
|
for profile in profiles.values():
|
||||||
output_dir = output_dir_override or profile.output_dir
|
output_dir = output_dir_override or profile.output_dir
|
||||||
print(
|
print(
|
||||||
|
|||||||
70
scripts/iphone-photo-inbox/test_receiver.py
Normal file
70
scripts/iphone-photo-inbox/test_receiver.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
RECEIVER_PATH = Path(__file__).with_name("receiver.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("iphone_photo_receiver", RECEIVER_PATH)
|
||||||
|
receiver = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
sys.modules[SPEC.name] = receiver
|
||||||
|
SPEC.loader.exec_module(receiver)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiverTests(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
for batch in getattr(self, "batches", []):
|
||||||
|
if batch.timer is not None:
|
||||||
|
batch.timer.cancel()
|
||||||
|
|
||||||
|
def test_mattermost_profile_defaults_to_files_clipboard(self) -> None:
|
||||||
|
profile = receiver.profile_defaults()["mattermost"]
|
||||||
|
self.assertEqual(profile.clipboard, receiver.CLIPBOARD_FILES)
|
||||||
|
|
||||||
|
def test_batch_add_accumulates_paths_by_profile(self) -> None:
|
||||||
|
manager = receiver.BatchManager(debounce_seconds=60)
|
||||||
|
profile = receiver.Profile("opencode", Path("/tmp"), receiver.CLIPBOARD_TERMINAL_PATH, notify=False)
|
||||||
|
|
||||||
|
count1, paths1 = manager.add(profile, Path("/tmp"), Path("/tmp/one.jpg"))
|
||||||
|
count2, paths2 = manager.add(profile, Path("/tmp"), Path("/tmp/two.jpg"))
|
||||||
|
|
||||||
|
self.batches = list(manager.batches.values())
|
||||||
|
self.assertEqual(count1, 1)
|
||||||
|
self.assertEqual(paths1, [Path("/tmp/one.jpg")])
|
||||||
|
self.assertEqual(count2, 2)
|
||||||
|
self.assertEqual(paths2, [Path("/tmp/one.jpg"), Path("/tmp/two.jpg")])
|
||||||
|
|
||||||
|
def test_files_clipboard_passes_all_paths_to_native_helper(self) -> None:
|
||||||
|
paths = [Path("/tmp/one.jpg"), Path("/tmp/two.jpg")]
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as helper:
|
||||||
|
helper_path = Path(helper.name)
|
||||||
|
with patch.object(receiver, "COPY_FILES_HELPER_BIN", Path("/tmp/missing-helper-bin")), \
|
||||||
|
patch.object(receiver, "COPY_FILES_HELPER", helper_path), \
|
||||||
|
patch.object(receiver, "run_macos_action", return_value=True) as run_action:
|
||||||
|
self.assertTrue(receiver.copy_files_to_clipboard(paths))
|
||||||
|
|
||||||
|
run_action.assert_called_once_with([
|
||||||
|
str(helper_path),
|
||||||
|
"/tmp/one.jpg",
|
||||||
|
"/tmp/two.jpg",
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_terminal_paths_clipboard_contains_all_paths(self) -> None:
|
||||||
|
paths = [Path("/tmp/one.jpg"), Path("/tmp/two with space.jpg")]
|
||||||
|
|
||||||
|
with patch.object(receiver, "copy_text_to_clipboard", return_value=True) as copy_text:
|
||||||
|
self.assertTrue(receiver.apply_clipboard_mode(receiver.CLIPBOARD_TERMINAL_PATH, paths))
|
||||||
|
|
||||||
|
copy_text.assert_called_once_with("/tmp/one.jpg\n'/tmp/two with space.jpg'")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user