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