feat: add PySide6 clipboard uploader with Nuitka release tooling
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
SUPERBED_TOKEN=your_token_here
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.qml
|
||||
superbed-upload.local.env
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
*.bin
|
||||
*.build
|
||||
*.dist
|
||||
.cache/
|
||||
nuitka-crash-report.xml
|
||||
111
README.md
Normal file
111
README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# manjaro_kde_upload_tools
|
||||
|
||||
Manjaro Linux + KDE 6 下的剪贴板图片上传工具。
|
||||
|
||||
## 功能
|
||||
|
||||
- 读取当前剪贴板中的最新图片
|
||||
- 上传到聚合图床 `https://api.superbed.cn/upload`
|
||||
- 成功后把返回链接改写成 Markdown:``
|
||||
- 自动写回剪贴板
|
||||
- 用 PySide6 显示底部居中的圆角、半透明、淡入淡出 toast
|
||||
- 可用 Nuitka 打包成单文件二进制
|
||||
- 二进制可直接运行
|
||||
- 启动脚本会优先运行 `dist/superbed-uploader.bin`,没有二进制时回退到 Python
|
||||
|
||||
## 依赖
|
||||
|
||||
- 运行时:
|
||||
- `wl-paste` / `wl-copy`(Wayland,推荐)
|
||||
- 或 `xclip` / `xsel`(X11)
|
||||
- 开发/构建时:
|
||||
- Python 3
|
||||
- `uv`
|
||||
- `PySide6`
|
||||
- `requests`
|
||||
- `Nuitka`
|
||||
|
||||
说明:这套方案面向**桌面 Linux**。没有图形界面或没有剪贴板工具的服务器环境不适用。
|
||||
|
||||
## Token 配置
|
||||
|
||||
程序读取:
|
||||
|
||||
```bash
|
||||
/home/droid/.config/superbed-upload.env
|
||||
```
|
||||
|
||||
内容:
|
||||
|
||||
```bash
|
||||
SUPERBED_TOKEN=你的token
|
||||
```
|
||||
|
||||
也可以参考仓库里的:
|
||||
|
||||
```bash
|
||||
.env.example
|
||||
```
|
||||
|
||||
## 初始化开发环境
|
||||
|
||||
```bash
|
||||
cd /home/droid/project/manjaro_kde_upload_tools
|
||||
./setup-venv.sh
|
||||
```
|
||||
|
||||
## 直接运行 Python 版
|
||||
|
||||
```bash
|
||||
cd /home/droid/project/manjaro_kde_upload_tools
|
||||
./.venv/bin/python superbed_qt_uploader.py
|
||||
```
|
||||
|
||||
## 测试 toast
|
||||
|
||||
成功 toast:
|
||||
|
||||
```bash
|
||||
./superbed-upload.sh --test-toast-success
|
||||
```
|
||||
|
||||
失败 toast:
|
||||
|
||||
```bash
|
||||
./superbed-upload.sh --test-toast-fail
|
||||
```
|
||||
|
||||
## 手工测试
|
||||
|
||||
```bash
|
||||
~/project/manjaro_kde_upload_tools/superbed-upload.sh
|
||||
```
|
||||
|
||||
或者直接运行二进制:
|
||||
|
||||
```bash
|
||||
~/project/manjaro_kde_upload_tools/dist/superbed-uploader.bin
|
||||
```
|
||||
|
||||
## KDE 6 快捷键
|
||||
|
||||
在 KDE 系统设置中把下面命令绑定到 `Alt+U`:
|
||||
|
||||
```bash
|
||||
/home/droid/project/manjaro_kde_upload_tools/superbed-upload.sh
|
||||
```
|
||||
|
||||
## 构建单文件二进制
|
||||
|
||||
```bash
|
||||
cd /home/droid/project/manjaro_kde_upload_tools
|
||||
./build-nuitka.sh
|
||||
```
|
||||
|
||||
成功后得到:
|
||||
|
||||
```bash
|
||||
/home/droid/project/manjaro_kde_upload_tools/dist/superbed-uploader.bin
|
||||
```
|
||||
|
||||
此后 `superbed-upload.sh` 会优先调用这个二进制。
|
||||
23
build-nuitka.sh
Executable file
23
build-nuitka.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${SCRIPT_DIR}/.cache}"
|
||||
|
||||
if [[ ! -x .venv/bin/python ]]; then
|
||||
"${SCRIPT_DIR}/setup-venv.sh"
|
||||
fi
|
||||
|
||||
. .venv/bin/activate
|
||||
|
||||
python -m nuitka \
|
||||
--onefile \
|
||||
--plugin-enable=pyside6 \
|
||||
--output-dir="${SCRIPT_DIR}/dist" \
|
||||
--output-filename="superbed-uploader.bin" \
|
||||
--assume-yes-for-downloads \
|
||||
--include-package=requests \
|
||||
"${SCRIPT_DIR}/superbed_qt_uploader.py"
|
||||
|
||||
echo "构建完成:${SCRIPT_DIR}/dist/superbed-uploader.bin"
|
||||
2
requirements-build.txt
Normal file
2
requirements-build.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
nuitka>=4.0
|
||||
zstandard>=0.23
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
PySide6>=6.11,<7
|
||||
requests>=2.32,<3
|
||||
19
setup-venv.sh
Executable file
19
setup-venv.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
export UV_CACHE_DIR="${UV_CACHE_DIR:-/tmp/uv-cache}"
|
||||
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${SCRIPT_DIR}/.cache}"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "未找到 uv,请先安装 uv。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uv venv .venv
|
||||
. .venv/bin/activate
|
||||
uv pip install -r requirements.txt -r requirements-build.txt
|
||||
|
||||
echo "虚拟环境已就绪:${SCRIPT_DIR}/.venv"
|
||||
22
superbed-upload.sh
Executable file
22
superbed-upload.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BIN_PATH="${SCRIPT_DIR}/dist/superbed-uploader.bin"
|
||||
VENV_PYTHON="${SCRIPT_DIR}/.venv/bin/python"
|
||||
PY_APP="${SCRIPT_DIR}/superbed_qt_uploader.py"
|
||||
|
||||
if [[ -x "${BIN_PATH}" ]]; then
|
||||
exec "${BIN_PATH}" "$@"
|
||||
fi
|
||||
|
||||
if [[ -x "${VENV_PYTHON}" ]]; then
|
||||
exec "${VENV_PYTHON}" "${PY_APP}" "$@"
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
exec python3 "${PY_APP}" "$@"
|
||||
fi
|
||||
|
||||
echo "未找到可用运行环境。请先执行 ${SCRIPT_DIR}/setup-venv.sh 或构建 dist/superbed-uploader.bin" >&2
|
||||
exit 1
|
||||
5
superbed-upload.sh.example
Executable file
5
superbed-upload.sh.example
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "${SCRIPT_DIR}/superbed-upload.sh" "$@"
|
||||
351
superbed_clipboard_upload.py
Normal file
351
superbed_clipboard_upload.py
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
API_URL = "https://api.superbed.cn/upload"
|
||||
IMAGE_MIME_PREFERENCE = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
]
|
||||
MIME_EXT = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/tiff": ".tiff",
|
||||
}
|
||||
|
||||
|
||||
class UploadError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_command(cmd: list[str], *, input_bytes: bytes | None = None) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
input=input_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def available_clipboard_types() -> list[str]:
|
||||
if shutil.which("wl-paste"):
|
||||
proc = run_command(["wl-paste", "--list-types"])
|
||||
if proc.returncode != 0:
|
||||
raise UploadError(f"读取剪贴板类型失败: {proc.stderr.decode('utf-8', 'ignore').strip()}")
|
||||
return [line.strip() for line in proc.stdout.decode("utf-8", "ignore").splitlines() if line.strip()]
|
||||
|
||||
if shutil.which("xclip"):
|
||||
# X11 fallback: xclip 很难完整列出所有目标类型,这里只做常见兜底
|
||||
return IMAGE_MIME_PREFERENCE + ["text/uri-list"]
|
||||
|
||||
raise UploadError("未找到 wl-paste 或 xclip,无法读取剪贴板。请先安装 wl-clipboard。")
|
||||
|
||||
|
||||
def clipboard_image_from_wayland() -> tuple[str, bytes] | None:
|
||||
types = available_clipboard_types()
|
||||
for mime in IMAGE_MIME_PREFERENCE:
|
||||
if mime not in types:
|
||||
continue
|
||||
proc = run_command(["wl-paste", "--type", mime])
|
||||
if proc.returncode == 0 and proc.stdout:
|
||||
return mime, proc.stdout
|
||||
return None
|
||||
|
||||
|
||||
def clipboard_image_from_x11() -> tuple[str, bytes] | None:
|
||||
if not shutil.which("xclip"):
|
||||
return None
|
||||
proc = run_command(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"])
|
||||
if proc.returncode == 0 and proc.stdout:
|
||||
return "image/png", proc.stdout
|
||||
return None
|
||||
|
||||
|
||||
def parse_file_uri(uri: str) -> Path | None:
|
||||
parsed = urlparse(uri.strip())
|
||||
if parsed.scheme != "file":
|
||||
return None
|
||||
path = unquote(parsed.path)
|
||||
if not path:
|
||||
return None
|
||||
p = Path(path)
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
def clipboard_file_from_wayland() -> tuple[str, bytes, str] | None:
|
||||
types = available_clipboard_types()
|
||||
if "text/uri-list" not in types or not shutil.which("wl-paste"):
|
||||
return None
|
||||
|
||||
proc = run_command(["wl-paste", "--type", "text/uri-list"])
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
return None
|
||||
|
||||
for raw in proc.stdout.decode("utf-8", "ignore").splitlines():
|
||||
raw = raw.strip()
|
||||
if not raw or raw.startswith("#"):
|
||||
continue
|
||||
file_path = parse_file_uri(raw)
|
||||
if not file_path:
|
||||
continue
|
||||
ext = file_path.suffix.lower()
|
||||
mime = next((k for k, v in MIME_EXT.items() if v == ext), None)
|
||||
if mime is None and ext == ".jpeg":
|
||||
mime = "image/jpeg"
|
||||
if mime is None:
|
||||
continue
|
||||
data = file_path.read_bytes()
|
||||
return mime, data, file_path.name
|
||||
return None
|
||||
|
||||
|
||||
def get_clipboard_image() -> tuple[str, bytes, str]:
|
||||
img = clipboard_image_from_wayland()
|
||||
if img is not None:
|
||||
mime, data = img
|
||||
return mime, data, f"clipboard{MIME_EXT.get(mime, '.png')}"
|
||||
|
||||
img = clipboard_image_from_x11()
|
||||
if img is not None:
|
||||
mime, data = img
|
||||
return mime, data, f"clipboard{MIME_EXT.get(mime, '.png')}"
|
||||
|
||||
file_clip = clipboard_file_from_wayland()
|
||||
if file_clip is not None:
|
||||
return file_clip
|
||||
|
||||
raise UploadError("剪贴板里没有图片数据。请先复制截图或图片。")
|
||||
|
||||
|
||||
def build_form(args: argparse.Namespace) -> dict[str, str]:
|
||||
token = args.token or os.environ.get("SUPERBED_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise UploadError("未提供 token。请设置环境变量 SUPERBED_TOKEN,或用 --token 传入。")
|
||||
|
||||
form: dict[str, str] = {"token": token}
|
||||
if args.categories:
|
||||
form["categories"] = args.categories
|
||||
if args.filename:
|
||||
form["filename"] = args.filename
|
||||
if args.watermark is not None:
|
||||
form["watermark"] = "true" if args.watermark else "false"
|
||||
if args.compress is not None:
|
||||
form["compress"] = "true" if args.compress else "false"
|
||||
if args.webp is not None:
|
||||
form["webp"] = "true" if args.webp else "false"
|
||||
return form
|
||||
|
||||
|
||||
def upload_image(args: argparse.Namespace, mime: str, data: bytes, filename: str) -> str:
|
||||
form = build_form(args)
|
||||
upload_name = args.filename or filename
|
||||
files = {"file": (upload_name, data, mime)}
|
||||
try:
|
||||
resp = requests.post(API_URL, data=form, files=files, timeout=args.timeout)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise UploadError(f"网络请求失败: {exc}") from exc
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise UploadError(f"接口返回的不是合法 JSON: {resp.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)
|
||||
|
||||
|
||||
def write_clipboard_text(text: str) -> None:
|
||||
encoded = text.encode("utf-8")
|
||||
if shutil.which("wl-copy"):
|
||||
proc = run_command(["wl-copy", "--type", "text/plain;charset=utf-8"], input_bytes=encoded)
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
if shutil.which("xclip"):
|
||||
proc = run_command(["xclip", "-selection", "clipboard"], input_bytes=encoded)
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
raise UploadError("上传成功,但写回剪贴板失败:未找到 wl-copy 或 xclip。")
|
||||
|
||||
|
||||
def qml_toast(message: str, success: bool) -> bool:
|
||||
qmlscene = shutil.which("qmlscene6") or shutil.which("qmlscene")
|
||||
if not qmlscene:
|
||||
return False
|
||||
|
||||
bg = "#166534" if success else "#991b1b"
|
||||
border = "#4ade80" if success else "#f87171"
|
||||
text = json.dumps(message, ensure_ascii=False)
|
||||
qml = f"""import QtQuick 2.15
|
||||
import QtQuick.Window 2.15
|
||||
|
||||
Window {{
|
||||
id: root
|
||||
visible: true
|
||||
color: "transparent"
|
||||
opacity: 0.0
|
||||
flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus
|
||||
property string toastText: {text}
|
||||
width: Math.min(Screen.width * 0.88, Math.max(300, label.implicitWidth + 48))
|
||||
height: Math.max(52, label.paintedHeight + 26)
|
||||
|
||||
Rectangle {{
|
||||
id: bubble
|
||||
anchors.fill: parent
|
||||
radius: 16
|
||||
color: "{bg}"
|
||||
border.width: 1
|
||||
border.color: "{border}"
|
||||
opacity: 0.84
|
||||
antialiasing: true
|
||||
}}
|
||||
|
||||
Text {{
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
width: root.width - 28
|
||||
text: root.toastText
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "white"
|
||||
font.pixelSize: 14
|
||||
font.bold: true
|
||||
}}
|
||||
|
||||
Component.onCompleted: {{
|
||||
x = Math.round((Screen.width - width) / 2)
|
||||
y = Math.round(Screen.height - height - Math.max(48, Screen.height * 0.08))
|
||||
fadeAnim.start()
|
||||
}}
|
||||
|
||||
SequentialAnimation {{
|
||||
id: fadeAnim
|
||||
running: false
|
||||
NumberAnimation {{
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 0.0
|
||||
to: 1.0
|
||||
duration: 180
|
||||
easing.type: Easing.OutCubic
|
||||
}}
|
||||
PauseAnimation {{ duration: 1800 }}
|
||||
NumberAnimation {{
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 1.0
|
||||
to: 0.0
|
||||
duration: 260
|
||||
easing.type: Easing.InCubic
|
||||
}}
|
||||
onStopped: Qt.quit()
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
tmp = None
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile("w", suffix=".qml", delete=False, encoding="utf-8")
|
||||
tmp.write(qml)
|
||||
tmp.flush()
|
||||
tmp.close()
|
||||
subprocess.Popen(
|
||||
[qmlscene, tmp.name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def notify_send(message: str, success: bool) -> bool:
|
||||
if not shutil.which("notify-send"):
|
||||
return False
|
||||
title = "✅ 图片上传成功" if success else "❌ 图片上传失败"
|
||||
icon = "dialog-information" if success else "dialog-error"
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["notify-send", "-a", "Superbed Upload", "-i", icon, title, message],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def show_toast(message: str, success: bool) -> None:
|
||||
prefix = "✅" if success else "❌"
|
||||
decorated_message = f"{prefix} {message}"
|
||||
if qml_toast(decorated_message, success):
|
||||
return
|
||||
notify_send(decorated_message, success)
|
||||
|
||||
|
||||
def parse_args(argv: Iterable[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="上传剪贴板图片到聚合图床,并把 Markdown 链接写回剪贴板。")
|
||||
parser.add_argument("--token", help="图床 token;不传则读取环境变量 SUPERBED_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)
|
||||
return parser.parse_args(list(argv))
|
||||
|
||||
|
||||
def main(argv: Iterable[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
try:
|
||||
mime, data, filename = get_clipboard_image()
|
||||
url = upload_image(args, mime, data, filename)
|
||||
markdown = args.markdown_template.format(url=url, filename=filename)
|
||||
write_clipboard_text(markdown)
|
||||
print(markdown)
|
||||
show_toast("图片上传成功,Markdown 已复制到剪贴板", True)
|
||||
return 0
|
||||
except UploadError as exc:
|
||||
msg = f"图片上传失败,原因:{exc}"
|
||||
print(msg, file=sys.stderr)
|
||||
show_toast(msg, False)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
713
superbed_qt_uploader.py
Normal file
713
superbed_qt_uploader.py
Normal file
@@ -0,0 +1,713 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user