mirror of
https://github.com/neoromantique/dotfiles.git
synced 2026-03-13 21:53:20 +03:00
286 lines
8.2 KiB
Python
286 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Quick memo - text input or clipboard capture to jax-bot.
|
|
|
|
Hotkeys:
|
|
F12 - Text input mode (wofi dialog)
|
|
SHIFT+F12 - Clipboard mode (text/URL/image)
|
|
|
|
Usage:
|
|
quick-memo --input # Text input via wofi
|
|
quick-memo --clipboard # Capture clipboard content
|
|
"""
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
|
|
# httpx is optional - fall back to urllib if not available
|
|
try:
|
|
import httpx
|
|
HAS_HTTPX = True
|
|
except ImportError:
|
|
import urllib.request
|
|
import urllib.error
|
|
import json as json_module
|
|
HAS_HTTPX = False
|
|
|
|
# Configuration - chezmoi template with default fallback
|
|
{{- $jaxUrl := "http://127.0.0.1:8080" -}}
|
|
{{- if hasKey . "jaxBotUrl" -}}
|
|
{{- $jaxUrl = .jaxBotUrl -}}
|
|
{{- end }}
|
|
JAX_URL = "{{ $jaxUrl }}"
|
|
NOTIFY_ID = "91192"
|
|
|
|
|
|
def notify(title: str, msg: str, critical: bool = False):
|
|
"""Send desktop notification."""
|
|
cmd = ["notify-send", title, msg, "-r", NOTIFY_ID, "-t", "3000"]
|
|
if critical:
|
|
cmd.extend(["-u", "critical"])
|
|
subprocess.run(cmd, capture_output=True)
|
|
|
|
|
|
def check_jax() -> bool:
|
|
"""Check if jax-bot is running."""
|
|
try:
|
|
if HAS_HTTPX:
|
|
httpx.get(f"{JAX_URL}/", timeout=2)
|
|
else:
|
|
urllib.request.urlopen(f"{JAX_URL}/", timeout=2)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_text_input() -> str | None:
|
|
"""Open yad text area dialog."""
|
|
result = subprocess.run(
|
|
[
|
|
"yad", "--form",
|
|
"--field", ":TXT",
|
|
"--title", "Quick Memo",
|
|
"--width", "500",
|
|
"--height", "300",
|
|
"--center",
|
|
"--on-top",
|
|
"--skip-taskbar",
|
|
"--undecorated",
|
|
"--borders", "20",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return None # User cancelled
|
|
# yad returns field value followed by |
|
|
text = result.stdout.strip()
|
|
if text.endswith("|"):
|
|
text = text[:-1]
|
|
return text.strip()
|
|
|
|
|
|
def get_clipboard_type() -> str:
|
|
"""Detect clipboard content type."""
|
|
result = subprocess.run(
|
|
["wl-paste", "--list-types"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
types = result.stdout.lower()
|
|
if "image/png" in types:
|
|
return "image/png"
|
|
elif "image/jpeg" in types:
|
|
return "image/jpeg"
|
|
elif "text/plain" in types:
|
|
return "text"
|
|
return "unknown"
|
|
|
|
|
|
def get_clipboard_text() -> str:
|
|
"""Get text from clipboard."""
|
|
result = subprocess.run(["wl-paste"], capture_output=True, text=True)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def get_clipboard_image(mime: str) -> bytes:
|
|
"""Get image bytes from clipboard."""
|
|
result = subprocess.run(
|
|
["wl-paste", "--type", mime],
|
|
capture_output=True,
|
|
)
|
|
return result.stdout
|
|
|
|
|
|
def send_memo_httpx(
|
|
content: str | None = None,
|
|
image_data: bytes | None = None,
|
|
image_type: str | None = None,
|
|
) -> dict:
|
|
"""Send memo via httpx."""
|
|
try:
|
|
if image_data:
|
|
ext = "png" if "png" in (image_type or "") else "jpg"
|
|
response = httpx.post(
|
|
f"{JAX_URL}/api/memo",
|
|
files={"file": (f"clipboard.{ext}", image_data, image_type)},
|
|
data={"auto_tag": "true"},
|
|
timeout=30,
|
|
)
|
|
else:
|
|
is_url = (content or "").startswith(("http://", "https://"))
|
|
response = httpx.post(
|
|
f"{JAX_URL}/api/memo",
|
|
data={
|
|
"content": content,
|
|
"auto_tag": "true",
|
|
"summarize_url": "true" if is_url else "false",
|
|
},
|
|
timeout=30,
|
|
)
|
|
return response.json()
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def send_memo_urllib(
|
|
content: str | None = None,
|
|
image_data: bytes | None = None,
|
|
image_type: str | None = None,
|
|
) -> dict:
|
|
"""Send memo via urllib (fallback when httpx unavailable)."""
|
|
import io
|
|
import uuid
|
|
|
|
try:
|
|
boundary = uuid.uuid4().hex
|
|
|
|
if image_data:
|
|
ext = "png" if "png" in (image_type or "") else "jpg"
|
|
filename = f"clipboard.{ext}"
|
|
|
|
body = io.BytesIO()
|
|
body.write(f"--{boundary}\r\n".encode())
|
|
body.write(f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode())
|
|
body.write(f"Content-Type: {image_type}\r\n\r\n".encode())
|
|
body.write(image_data)
|
|
body.write(f"\r\n--{boundary}\r\n".encode())
|
|
body.write(b'Content-Disposition: form-data; name="auto_tag"\r\n\r\ntrue')
|
|
body.write(f"\r\n--{boundary}--\r\n".encode())
|
|
|
|
req = urllib.request.Request(
|
|
f"{JAX_URL}/api/memo",
|
|
data=body.getvalue(),
|
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
)
|
|
else:
|
|
is_url = (content or "").startswith(("http://", "https://"))
|
|
data = urllib.parse.urlencode({
|
|
"content": content or "",
|
|
"auto_tag": "true",
|
|
"summarize_url": "true" if is_url else "false",
|
|
}).encode()
|
|
req = urllib.request.Request(f"{JAX_URL}/api/memo", data=data)
|
|
|
|
with urllib.request.urlopen(req, timeout=30) as response:
|
|
return json_module.loads(response.read().decode())
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def send_memo(
|
|
content: str | None = None,
|
|
image_data: bytes | None = None,
|
|
image_type: str | None = None,
|
|
) -> dict:
|
|
"""Send memo to jax-bot API."""
|
|
if HAS_HTTPX:
|
|
return send_memo_httpx(content, image_data, image_type)
|
|
return send_memo_urllib(content, image_data, image_type)
|
|
|
|
|
|
def mode_input():
|
|
"""F12: Text input mode via wofi dialog."""
|
|
text = get_text_input()
|
|
if not text:
|
|
return # User cancelled, no notification
|
|
|
|
notify("Quick Memo", "Processing...")
|
|
result = send_memo(content=text)
|
|
|
|
if result.get("success"):
|
|
tags = " ".join(f"#{t}" for t in result.get("tags", []))
|
|
preview = text[:60] + ("..." if len(text) > 60 else "")
|
|
notify("Quick Memo", f"Saved!\n{preview}\n{tags}")
|
|
else:
|
|
notify("Quick Memo", f"Error: {result.get('error')}", critical=True)
|
|
|
|
|
|
def mode_clipboard():
|
|
"""SHIFT+F12: Clipboard capture mode."""
|
|
clip_type = get_clipboard_type()
|
|
|
|
if clip_type == "unknown":
|
|
notify("Quick Memo", "Clipboard empty", critical=True)
|
|
return
|
|
|
|
notify("Quick Memo", "Processing...")
|
|
|
|
if clip_type.startswith("image/"):
|
|
image_data = get_clipboard_image(clip_type)
|
|
if not image_data:
|
|
notify("Quick Memo", "Failed to read image", critical=True)
|
|
return
|
|
result = send_memo(image_data=image_data, image_type=clip_type)
|
|
else:
|
|
text = get_clipboard_text()
|
|
if not text:
|
|
notify("Quick Memo", "Clipboard empty", critical=True)
|
|
return
|
|
result = send_memo(content=text)
|
|
|
|
if result.get("success"):
|
|
tags = " ".join(f"#{t}" for t in result.get("tags", []))
|
|
preview = result.get("content", "")[:60]
|
|
if len(result.get("content", "")) > 60:
|
|
preview += "..."
|
|
notify("Quick Memo", f"Saved!\n{preview}\n{tags}")
|
|
else:
|
|
notify("Quick Memo", f"Error: {result.get('error')}", critical=True)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Quick memo to jax-bot",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
parser.add_argument(
|
|
"--input", "-i",
|
|
action="store_true",
|
|
help="Text input mode (wofi dialog)",
|
|
)
|
|
parser.add_argument(
|
|
"--clipboard", "-c",
|
|
action="store_true",
|
|
help="Clipboard capture mode",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if not args.input and not args.clipboard:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
if not check_jax():
|
|
notify("Quick Memo", "jax-bot not running", critical=True)
|
|
sys.exit(1)
|
|
|
|
if args.input:
|
|
mode_input()
|
|
elif args.clipboard:
|
|
mode_clipboard()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|