Files
manjaro_kde_upload_tools/superbed_qt_uploader.py

714 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from urllib.parse import unquote, urlparse
import requests
from PySide6.QtCore import (
QByteArray,
QEasingCurve,
QBuffer,
QIODevice,
QMessageLogContext,
QMimeDatabase,
QObject,
QPoint,
QPropertyAnimation,
QParallelAnimationGroup,
QPauseAnimation,
QSequentialAnimationGroup,
QThread,
QTimer,
Qt,
QtMsgType,
Signal,
Slot,
qInstallMessageHandler,
)
from PySide6.QtGui import (
QClipboard,
QColor,
QCursor,
QGuiApplication,
QImage,
QImageWriter,
)
from PySide6.QtWidgets import (
QApplication,
QFrame,
QGraphicsDropShadowEffect,
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QVBoxLayout,
QWidget,
)
API_URL = "https://api.superbed.cn/upload"
SUPPORTED_IMAGE_MIME = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
]
MIME_TO_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
}
MIME_TO_QT_FORMAT = {
"image/png": "PNG",
"image/jpeg": "JPEG",
"image/webp": "WEBP",
"image/gif": "GIF",
"image/bmp": "BMP",
"image/tiff": "TIFF",
}
class UploadError(RuntimeError):
pass
def install_qt_message_filter() -> None:
def handler(mode: QtMsgType, context: QMessageLogContext, message: str) -> None:
if "Failed to compute left/right minimum bearings for \"Droid Sans\"" in message:
return
stream = sys.stderr
try:
stream.write(f"{message}\n")
stream.flush()
except Exception:
pass
qInstallMessageHandler(handler)
@dataclass(slots=True)
class UploadOptions:
token: str
categories: str | None
filename: str | None
timeout: int
markdown_template: str
watermark: bool | None
compress: bool | None
webp: bool | None
test_toast: str | None = None
@dataclass(slots=True)
class ClipboardImage:
data: bytes
mime: str
filename: str
def strip_optional_quotes(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
return value
def read_env_value(paths: list[Path], key: str) -> str | None:
for path in paths:
if not path.exists():
continue
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
env_key, env_value = line.split("=", 1)
if env_key.strip() == key:
return strip_optional_quotes(env_value)
return None
def supported_qt_formats() -> set[str]:
return {bytes(fmt).decode("ascii").upper() for fmt in QImageWriter.supportedImageFormats()}
def choose_mime_from_formats(formats: list[str]) -> str:
lower_formats = {fmt.lower() for fmt in formats}
for mime in SUPPORTED_IMAGE_MIME:
if mime in lower_formats:
return mime
return "image/png"
def image_to_bytes(image: QImage, mime: str) -> tuple[bytes, str]:
if image.isNull():
raise UploadError("剪贴板里的图片无效。")
qt_formats = supported_qt_formats()
requested = MIME_TO_QT_FORMAT.get(mime, "PNG")
fmt = requested if requested in qt_formats else "PNG"
actual_mime = mime if fmt == requested else "image/png"
ext = MIME_TO_EXT.get(actual_mime, ".png")
payload = QByteArray()
buffer = QBuffer(payload)
if not buffer.open(QIODevice.OpenModeFlag.WriteOnly):
raise UploadError("无法为图片编码创建内存缓冲区。")
ok = image.save(buffer, fmt)
buffer.close()
if not ok:
raise UploadError(f"无法把剪贴板图片编码为 {fmt}")
return bytes(payload.data()), f"clipboard{ext}"
def parse_local_file_url(url_text: str) -> Path | None:
parsed = urlparse(url_text)
if parsed.scheme != "file":
return None
path = unquote(parsed.path)
if not path:
return None
candidate = Path(path)
return candidate if candidate.exists() else None
def run_command(cmd: list[str], input_bytes: bytes | None = None) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
cmd,
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def mime_to_ext(mime: str) -> str:
if mime == "image/jpg":
return ".jpg"
if mime == "image/tif":
return ".tiff"
return MIME_TO_EXT.get(mime, ".png")
def available_clipboard_types() -> list[str]:
if not shutil.which("wl-paste"):
return []
result = run_command(["wl-paste", "--list-types"])
if result.returncode != 0:
return []
return [line.strip() for line in result.stdout.decode("utf-8", "ignore").splitlines() if line.strip()]
def read_clipboard_image_from_tools() -> ClipboardImage | None:
types = available_clipboard_types()
if types:
for mime in SUPPORTED_IMAGE_MIME:
if mime not in types:
continue
result = run_command(["wl-paste", "--type", mime])
if result.returncode == 0 and result.stdout:
return ClipboardImage(
data=result.stdout,
mime=mime,
filename=f"clipboard{mime_to_ext(mime)}",
)
if "text/uri-list" in types:
result = run_command(["wl-paste", "--type", "text/uri-list"])
if result.returncode == 0 and result.stdout:
mime_db = QMimeDatabase()
for raw in result.stdout.decode("utf-8", "ignore").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
path = parse_local_file_url(line)
if path is None or not path.is_file():
continue
mime = mime_db.mimeTypeForFile(str(path)).name()
if mime.startswith("image/"):
return ClipboardImage(data=path.read_bytes(), mime=mime, filename=path.name)
if shutil.which("xclip"):
for mime in SUPPORTED_IMAGE_MIME:
result = run_command(["xclip", "-selection", "clipboard", "-t", mime, "-o"])
if result.returncode == 0 and result.stdout:
return ClipboardImage(
data=result.stdout,
mime=mime,
filename=f"clipboard{mime_to_ext(mime)}",
)
return None
def read_clipboard_image(clipboard: QClipboard) -> ClipboardImage:
clipboard_image = read_clipboard_image_from_tools()
if clipboard_image is not None:
return clipboard_image
mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard)
if mime_data is None:
raise UploadError("当前会话无法读取系统剪贴板。")
if mime_data.hasImage():
mime = choose_mime_from_formats(mime_data.formats())
image = clipboard.image(QClipboard.Mode.Clipboard)
if image.isNull():
image_data = mime_data.imageData()
if isinstance(image_data, QImage):
image = image_data
data, filename = image_to_bytes(image, mime)
return ClipboardImage(data=data, mime=mime, filename=filename)
if mime_data.hasUrls():
mime_db = QMimeDatabase()
for url in mime_data.urls():
if not url.isLocalFile():
continue
path = Path(url.toLocalFile())
if not path.is_file():
continue
mime = mime_db.mimeTypeForFile(str(path)).name()
if not mime.startswith("image/"):
continue
return ClipboardImage(data=path.read_bytes(), mime=mime, filename=path.name)
text = clipboard.text(QClipboard.Mode.Clipboard).strip()
if text.startswith("file://"):
path = parse_local_file_url(text)
if path is not None:
mime = QMimeDatabase().mimeTypeForFile(str(path)).name()
if mime.startswith("image/"):
return ClipboardImage(data=path.read_bytes(), mime=mime, filename=path.name)
raise UploadError("剪贴板里没有图片数据。请先复制截图或图片。")
def write_text_to_clipboard(text: str, clipboard: QClipboard) -> None:
encoded = text.encode("utf-8")
if shutil.which("wl-copy"):
process = subprocess.Popen(
["wl-copy", "--type", "text/plain;charset=utf-8"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
assert process.stdin is not None
process.stdin.write(encoded)
process.stdin.close()
if process.poll() in {None, 0}:
return
if shutil.which("xclip"):
process = subprocess.Popen(
["xclip", "-selection", "clipboard"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
assert process.stdin is not None
process.stdin.write(encoded)
process.stdin.close()
if process.poll() in {None, 0}:
return
if shutil.which("xsel"):
process = subprocess.Popen(
["xsel", "--clipboard", "--input"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
assert process.stdin is not None
process.stdin.write(encoded)
process.stdin.close()
if process.poll() in {None, 0}:
return
clipboard.setText(text, QClipboard.Mode.Clipboard)
if clipboard.supportsSelection():
clipboard.setText(text, QClipboard.Mode.Selection)
def upload_to_superbed(options: UploadOptions, image: ClipboardImage) -> str:
form: dict[str, str] = {"token": options.token}
if options.categories:
form["categories"] = options.categories
if options.filename:
form["filename"] = options.filename
if options.watermark is not None:
form["watermark"] = "true" if options.watermark else "false"
if options.compress is not None:
form["compress"] = "true" if options.compress else "false"
if options.webp is not None:
form["webp"] = "true" if options.webp else "false"
upload_name = options.filename or image.filename
files = {
"file": (
upload_name,
image.data,
image.mime,
)
}
try:
response = requests.post(API_URL, data=form, files=files, timeout=options.timeout)
response.raise_for_status()
except requests.RequestException as exc:
raise UploadError(f"网络请求失败: {exc}") from exc
try:
payload = response.json()
except ValueError as exc:
raise UploadError(f"接口返回的不是合法 JSON: {response.text[:200]}") from exc
if payload.get("err") != 0:
raise UploadError(str(payload.get("msg") or payload))
url = payload.get("url")
if not url:
raise UploadError(f"接口未返回 url: {payload}")
return str(url)
class UploadWorker(QObject):
success = Signal(str)
failure = Signal(str)
finished = Signal()
def __init__(self, options: UploadOptions, image: ClipboardImage) -> None:
super().__init__()
self.options = options
self.image = image
@Slot()
def run(self) -> None:
try:
url = upload_to_superbed(self.options, self.image)
except Exception as exc: # noqa: BLE001
self.failure.emit(str(exc))
else:
self.success.emit(url)
finally:
self.finished.emit()
class ToastWindow(QWidget):
done = Signal()
def __init__(self, message: str, success: bool, duration_ms: int = 2200) -> None:
super().__init__(None)
self.duration_ms = duration_ms
self.success = success
self._setup_window()
self._build_ui(message, success)
self._animations: list[QObject] = []
def _setup_window(self) -> None:
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.setWindowFlags(
Qt.WindowType.Tool
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint
| Qt.WindowType.WindowDoesNotAcceptFocus
)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
def _build_ui(self, message: str, success: bool) -> None:
palette = {
True: {
"background": "rgba(22, 101, 52, 214)",
"border": "#4ade80",
"icon": "",
},
False: {
"background": "rgba(153, 27, 27, 214)",
"border": "#f87171",
"icon": "",
},
}[success]
root = QVBoxLayout(self)
root.setContentsMargins(14, 10, 14, 16)
self.container = QFrame(self)
self.container.setObjectName("container")
self.container.setStyleSheet("background: transparent; border: 0;")
container_layout = QVBoxLayout(self.container)
container_layout.setContentsMargins(0, 0, 0, 0)
self.panel = QFrame(self.container)
self.panel.setObjectName("panel")
self.panel.setStyleSheet(
f"""
QFrame#panel {{
background-color: {palette["background"]};
border: 1px solid {palette["border"]};
border-radius: 16px;
}}
QLabel {{
color: white;
background: transparent;
}}
"""
)
shadow = QGraphicsDropShadowEffect(self.container)
shadow.setBlurRadius(32)
shadow.setOffset(0, 10)
shadow.setColor(QColor(0, 0, 0, 95))
self.container.setGraphicsEffect(shadow)
panel_layout = QHBoxLayout(self.panel)
panel_layout.setContentsMargins(16, 14, 16, 14)
panel_layout.setSpacing(10)
icon_label = QLabel(palette["icon"], self.panel)
icon_label.setStyleSheet("font-size: 17px;")
icon_label.setAlignment(Qt.AlignmentFlag.AlignTop)
panel_layout.addWidget(icon_label, 0, Qt.AlignmentFlag.AlignTop)
self.message_label = QLabel(message, self.panel)
self.message_label.setWordWrap(True)
self.message_label.setMinimumWidth(220)
self.message_label.setMaximumWidth(640)
self.message_label.setStyleSheet("font-size: 14px; font-weight: 700;")
panel_layout.addWidget(self.message_label)
container_layout.addWidget(self.panel)
root.addWidget(self.container)
self.opacity_effect = QGraphicsOpacityEffect(self.panel)
self.opacity_effect.setOpacity(0.0)
self.panel.setGraphicsEffect(self.opacity_effect)
self.adjustSize()
def showAnimated(self) -> None:
self.adjustSize()
screen = QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
geometry = screen.availableGeometry() if screen else QGuiApplication.primaryScreen().availableGeometry()
self.resize(self.sizeHint())
end_pos = QPoint(
geometry.x() + (geometry.width() - self.width()) // 2,
geometry.bottom() - self.height() - max(18, int(geometry.height() * 0.03)),
)
start_pos = end_pos + QPoint(0, 18)
self.move(start_pos)
self.show()
self.raise_()
fade_in = QPropertyAnimation(self.opacity_effect, b"opacity", self)
fade_in.setDuration(180)
fade_in.setStartValue(0.0)
fade_in.setEndValue(1.0)
fade_in.setEasingCurve(QEasingCurve.Type.OutCubic)
move_in = QPropertyAnimation(self, b"pos", self)
move_in.setDuration(220)
move_in.setStartValue(start_pos)
move_in.setEndValue(end_pos)
move_in.setEasingCurve(QEasingCurve.Type.OutCubic)
enter = QParallelAnimationGroup(self)
enter.addAnimation(fade_in)
enter.addAnimation(move_in)
fade_out = QPropertyAnimation(self.opacity_effect, b"opacity", self)
fade_out.setDuration(260)
fade_out.setStartValue(1.0)
fade_out.setEndValue(0.0)
fade_out.setEasingCurve(QEasingCurve.Type.InCubic)
move_out = QPropertyAnimation(self, b"pos", self)
move_out.setDuration(260)
move_out.setStartValue(end_pos)
move_out.setEndValue(end_pos - QPoint(0, 8))
move_out.setEasingCurve(QEasingCurve.Type.InCubic)
leave = QParallelAnimationGroup(self)
leave.addAnimation(fade_out)
leave.addAnimation(move_out)
sequence = QSequentialAnimationGroup(self)
sequence.addAnimation(enter)
sequence.addAnimation(QPauseAnimation(max(300, self.duration_ms - 440), self))
sequence.addAnimation(leave)
sequence.finished.connect(self._finish)
self._animations = [sequence, enter, leave, fade_in, fade_out, move_in, move_out]
sequence.start()
@Slot()
def _finish(self) -> None:
self.close()
self.done.emit()
class UploadController(QObject):
def __init__(self, app: QApplication, options: UploadOptions) -> None:
super().__init__()
self.app = app
self.options = options
self.clipboard = QApplication.clipboard()
self.thread: QThread | None = None
self.worker: UploadWorker | None = None
self.toast: ToastWindow | None = None
self.current_filename = "clipboard"
@Slot()
def start(self) -> None:
if self.options.test_toast == "success":
self.show_toast("图片上传成功Markdown 已复制到剪贴板", True)
return
if self.options.test_toast == "fail":
self.show_toast("图片上传失败,原因:这是一个失败 toast 测试", False)
return
try:
clipboard_image = read_clipboard_image(self.clipboard)
except UploadError as exc:
print(str(exc), file=sys.stderr, flush=True)
self.show_toast(f"图片上传失败,原因:{exc}", False)
return
self.current_filename = clipboard_image.filename
self.thread = QThread(self)
self.worker = UploadWorker(self.options, clipboard_image)
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)
self.worker.success.connect(self.on_success)
self.worker.failure.connect(self.on_failure)
self.worker.finished.connect(self.thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.thread.start()
@Slot(str)
def on_success(self, url: str) -> None:
markdown = self.options.markdown_template.format(url=url, filename=self.options.filename or self.current_filename)
write_text_to_clipboard(markdown, self.clipboard)
print(markdown, flush=True)
self.show_toast("图片上传成功Markdown 已复制到剪贴板", True)
@Slot(str)
def on_failure(self, error: str) -> None:
print(error, file=sys.stderr, flush=True)
self.show_toast(f"图片上传失败,原因:{error}", False)
def show_toast(self, message: str, success: bool) -> None:
if self.toast is not None:
self.toast.close()
self.toast.deleteLater()
self.toast = ToastWindow(message, success)
self.toast.done.connect(self.app.quit)
self.toast.showAnimated()
def parse_args(argv: Iterable[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="上传剪贴板图片到聚合图床,并把 Markdown 链接写回剪贴板。")
parser.add_argument("--token", help="图床 token不传则按优先级读取环境变量和配置文件")
parser.add_argument("--categories", help="相册,多个用英文逗号分隔")
parser.add_argument("--filename", help="指定上传文件名")
parser.add_argument("--timeout", type=int, default=30, help="上传超时秒数,默认 30")
parser.add_argument("--markdown-template", default="![]({url})", help="剪贴板写回模板,默认 ![]({url})")
parser.add_argument("--watermark", dest="watermark", action="store_true", help="强制开启水印")
parser.add_argument("--no-watermark", dest="watermark", action="store_false", help="强制关闭水印")
parser.set_defaults(watermark=None)
parser.add_argument("--compress", dest="compress", action="store_true", help="强制开启压缩")
parser.add_argument("--no-compress", dest="compress", action="store_false", help="强制关闭压缩")
parser.set_defaults(compress=None)
parser.add_argument("--webp", dest="webp", action="store_true", help="强制转为 webp")
parser.add_argument("--no-webp", dest="webp", action="store_false", help="强制关闭 webp")
parser.set_defaults(webp=None)
parser.add_argument("--test-toast-success", action="store_true", help="只显示成功 toast不上传")
parser.add_argument("--test-toast-fail", action="store_true", help="只显示失败 toast不上传")
return parser.parse_args(list(argv))
def make_options(args: argparse.Namespace) -> UploadOptions:
home_config = Path.home() / ".config" / "superbed-upload.env"
token = (
args.token
or os.environ.get("SUPERBED_TOKEN")
or read_env_value([home_config], "SUPERBED_TOKEN")
or ""
).strip()
if not token and not (args.test_toast_success or args.test_toast_fail):
raise UploadError(f"未提供 token。请设置 {home_config}、环境变量 SUPERBED_TOKEN或用 --token 传入。")
test_toast = None
if args.test_toast_success:
test_toast = "success"
elif args.test_toast_fail:
test_toast = "fail"
return UploadOptions(
token=token,
categories=args.categories,
filename=args.filename,
timeout=args.timeout,
markdown_template=args.markdown_template,
watermark=args.watermark,
compress=args.compress,
webp=args.webp,
test_toast=test_toast,
)
def main(argv: Iterable[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else list(argv)
args = parse_args(argv)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
install_qt_message_filter()
app = QApplication([sys.argv[0], *argv])
app.setQuitOnLastWindowClosed(False)
try:
options = make_options(args)
except UploadError as exc:
print(str(exc), file=sys.stderr, flush=True)
holder: dict[str, ToastWindow] = {}
def show_error_toast() -> None:
toast = ToastWindow(f"图片上传失败,原因:{exc}", False)
holder["toast"] = toast
toast.done.connect(app.quit)
toast.showAnimated()
QTimer.singleShot(0, show_error_toast)
return app.exec()
controller = UploadController(app, options)
QTimer.singleShot(60, controller.start)
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())