714 lines
24 KiB
Python
714 lines
24 KiB
Python
#!/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="", help="剪贴板写回模板,默认 ")
|
||
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())
|