diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..c8b7477 --- /dev/null +++ b/gui.py @@ -0,0 +1,961 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import subprocess +import tempfile +import urllib.request +import urllib.error +import time +import mimetypes +import urllib.parse + +def normalize_file_path(path_str: str) -> str: + if not path_str: + return "" + path_str = path_str.strip().strip("'\"") + if path_str.startswith("file://"): + url = QUrl(path_str) + if url.isLocalFile(): + path_str = url.toLocalFile() + else: + path_str = urllib.parse.unquote(path_str[7:]) + return os.path.abspath(path_str) +from PySide6.QtCore import Qt, QThread, QObject, Signal, QUrl, QFile, QIODevice +from PySide6.QtGui import QIcon, QAction, QPixmap, QPainter, QColor, QFont, QPen, QImage +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QHttpMultiPart, QHttpPart, QNetworkReply, QLocalServer, QLocalSocket +from PySide6.QtWidgets import ( + QApplication, QSystemTrayIcon, QMenu, QDialog, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QGroupBox, + QFormLayout, QMessageBox, QFileDialog +) + +# Configuration paths +CONFIG_DIR = os.path.expanduser("~/.config/f0ckm-uploader") +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") + +DEFAULT_CONFIG = { + "api_url": "", + "api_key": "", + "default_rating": "", + "default_tags": "", + "default_is_oc": False, + "enable_notifications": True, + "show_progress_dialog": True, + "autostart": False, + "icon_theme": "dark" +} + +def load_config(): + if not os.path.exists(CONFIG_PATH): + return DEFAULT_CONFIG.copy() + try: + with open(CONFIG_PATH, 'r') as f: + data = json.load(f) + config = DEFAULT_CONFIG.copy() + config.update(data) + return config + except Exception: + return DEFAULT_CONFIG.copy() + +def get_env_config(): + config = load_config() + script_dir = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.join(script_dir, ".env"), + "/home/kibi/Projects/f0ckm-uploader/.env", + os.path.expanduser("~/.config/f0ckm-uploader/.env"), + os.path.join(os.getcwd(), ".env") + ] + dotenv_path = None + for path in candidates: + if os.path.exists(path): + dotenv_path = path + break + + if dotenv_path: + try: + with open(dotenv_path, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + key = k.strip() + val = v.strip().strip("'\"") + if key == "F0CKM_URL" and val: + url = val if val.endswith("/api/v2/upload") else f"{val.rstrip('/')}/api/v2/upload" + config["api_url"] = url + elif key == "API_KEY" and val: + config["api_key"] = val + elif key == "RATING": + config["default_rating"] = val + elif key == "TAGS": + config["default_tags"] = val + elif key == "VISIBILITY": + config["default_visibility"] = val + elif key == "ICON_THEME": + config["icon_theme"] = val.lower() + except Exception as e: + print(f"Error reading .env from {dotenv_path}: {e}") + return config + +def save_config(config): + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(CONFIG_PATH, 'w') as f: + json.dump(config, f, indent=4) + return True + except Exception as e: + print(f"Failed to save config: {e}") + return False + +def set_autostart(enabled): + autostart_dir = os.path.expanduser("~/.config/autostart") + autostart_path = os.path.join(autostart_dir, "kde-uploader-gui.desktop") + + if enabled: + try: + os.makedirs(autostart_dir, exist_ok=True) + # Use the installed path ~/.local/bin/kde-uploader-gui if possible + exec_path = os.path.expanduser("~/.local/bin/kde-uploader-gui") + if not os.path.exists(exec_path): + exec_path = os.path.abspath(sys.argv[0]) + + content = f"""[Desktop Entry] +Type=Application +Name=f0ckm Uploader GUI +Comment=System tray GUI and settings for f0ckm Uploader +Exec={exec_path} +Icon=cloud-upload-symbolic +Terminal=false +Categories=Utility;Network; +X-GNOME-Autostart-enabled=true +""" + with open(autostart_path, "w") as f: + f.write(content) + os.chmod(autostart_path, 0o755) + except Exception as e: + print(f"Failed to create autostart entry: {e}") + else: + if os.path.exists(autostart_path): + try: + os.remove(autostart_path) + except Exception as e: + print(f"Failed to remove autostart entry: {e}") + +# ========================================== +# Connection Tester Worker +# ========================================== +class ConnectionTester(QObject): + finished = Signal(bool, str) # Success, Message + + def __init__(self, url, api_key): + super().__init__() + self.url = url + self.api_key = api_key + + def run(self): + try: + # Send an empty POST request to test authorization and connection + req = urllib.request.Request( + self.url, + data=b"", + headers={ + "X-Api-Key": self.api_key, + "User-Agent": "f0ckm-Uploader-GUI/1.0" + }, + method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=5) as response: + self.finished.emit(True, "Connection successful! Server responded with HTTP 200.") + except urllib.error.HTTPError as e: + body = e.read().decode('utf-8', errors='ignore') + if e.code == 401: + self.finished.emit(False, "Unauthorized: Invalid API Key.") + elif e.code in (400, 422): + # Key is valid but payload is invalid (empty file parameter), which confirms authentication works + self.finished.emit(True, "Connection successful! (API key is valid)") + else: + self.finished.emit(False, f"HTTP Error {e.code}: {body or e.reason}") + except urllib.error.URLError as e: + self.finished.emit(False, f"Connection failed: {e.reason}") + except Exception as e: + self.finished.emit(False, f"Error: {str(e)}") + +# ========================================== +# Premium Style Sheet +# ========================================== +STYLE_SHEET = """ +QDialog { + background-color: #121216; + color: #fffffe; + font-family: "Segoe UI", "Inter", "Roboto", "Noto Sans", sans-serif; + font-size: 13px; +} +QLabel { + color: #e2e2e9; +} +QLabel#title { + font-size: 18px; + font-weight: bold; + color: #7f5af0; + margin-bottom: 5px; +} +QGroupBox { + background-color: #1a1a24; + border: 1px solid #2f2f3d; + border-radius: 8px; + margin-top: 15px; + padding-top: 15px; + font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 15px; + padding: 0 5px; + background-color: #121216; + color: #7f5af0; +} +QLineEdit { + background-color: #242432; + border: 1px solid #2f2f3d; + border-radius: 6px; + padding: 6px 10px; + color: #fffffe; + selection-background-color: #7f5af0; +} +QLineEdit:focus { + border: 1px solid #7f5af0; +} +QComboBox { + background-color: #242432; + border: 1px solid #2f2f3d; + border-radius: 6px; + padding: 6px 10px; + color: #fffffe; +} +QComboBox:focus { + border: 1px solid #7f5af0; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 25px; + border-left-width: 0px; +} +QComboBox QAbstractItemView { + background-color: #242432; + border: 1px solid #2f2f3d; + selection-background-color: #7f5af0; + selection-color: #fffffe; + color: #fffffe; +} +QCheckBox { + color: #e2e2e9; +} +QCheckBox:hover { + color: #ffffff; +} +QPushButton { + background-color: #242432; + border: 1px solid #2f2f3d; + border-radius: 6px; + padding: 8px 16px; + color: #fffffe; + font-weight: bold; +} +QPushButton:hover { + background-color: #2f2f42; + border: 1px solid #42425a; +} +QPushButton:pressed { + background-color: #1a1a24; +} +QPushButton#primary { + background-color: #7f5af0; + border: 1px solid #7f5af0; +} +QPushButton#primary:hover { + background-color: #9272f2; + border: 1px solid #9272f2; +} +QPushButton#primary:pressed { + background-color: #694ad4; +} +QPushButton#test { + background-color: #2cb67d; + border: 1px solid #2cb67d; + color: #fffffe; +} +QPushButton#test:hover { + background-color: #34cf8f; + border: 1px solid #34cf8f; +} +QPushButton#test:pressed { + background-color: #249a68; +} +""" + +# ========================================== +# Settings Dialog Window +# ========================================== +class SettingsDialog(QDialog): + def __init__(self, parent=None, tray_app=None): + super().__init__(parent) + self.tray_app = tray_app + self.setWindowTitle("f0ckm Uploader Settings") + self.resize(520, 560) + self.setStyleSheet(STYLE_SHEET) + + self.tester_thread = None + self.tester = None + + self.init_ui() + + def init_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(15) + + # Header + header_layout = QHBoxLayout() + header_icon = QLabel() + icon = QIcon.fromTheme("preferences-system", QIcon.fromTheme("network-server")) + header_icon.setPixmap(icon.pixmap(32, 32)) + + header_text_layout = QVBoxLayout() + header_title = QLabel("f0ckm Uploader") + header_title.setObjectName("title") + header_subtitle = QLabel("Configure connection settings and default options") + header_subtitle.setStyleSheet("color: #94a1b2; font-size: 11px;") + header_text_layout.addWidget(header_title) + header_text_layout.addWidget(header_subtitle) + + header_layout.addWidget(header_icon) + header_layout.addLayout(header_text_layout) + header_layout.addStretch() + main_layout.addLayout(header_layout) + + # Section 1: API Connection Settings + grp_api = QGroupBox("API Connection") + api_layout = QFormLayout(grp_api) + api_layout.setContentsMargins(15, 20, 15, 15) + api_layout.setSpacing(10) + + self.txt_url = QLineEdit() + self.txt_url.setPlaceholderText("https://example.com/api/v2/upload") + api_layout.addRow("API URL:", self.txt_url) + + key_layout = QHBoxLayout() + self.txt_key = QLineEdit() + self.txt_key.setEchoMode(QLineEdit.Password) + self.txt_key.setPlaceholderText("your_api_key_here") + key_layout.addWidget(self.txt_key) + + self.btn_toggle_key = QPushButton("Show") + self.btn_toggle_key.setFixedWidth(60) + self.btn_toggle_key.clicked.connect(self.toggle_key_visibility) + key_layout.addWidget(self.btn_toggle_key) + api_layout.addRow("API Key:", key_layout) + + test_layout = QHBoxLayout() + test_layout.addStretch() + self.btn_test = QPushButton("Test Connection") + self.btn_test.setObjectName("test") + self.btn_test.clicked.connect(self.on_test_connection) + test_layout.addWidget(self.btn_test) + api_layout.addRow("", test_layout) + + main_layout.addWidget(grp_api) + + # Section 2: Default Parameters + grp_defaults = QGroupBox("Default Parameters") + defaults_layout = QFormLayout(grp_defaults) + defaults_layout.setContentsMargins(15, 20, 15, 15) + defaults_layout.setSpacing(10) + + self.cb_rating = QComboBox() + self.cb_rating.addItems(["(None / Default)", "safe", "questionable", "explicit"]) + defaults_layout.addRow("Default Rating:", self.cb_rating) + + self.txt_tags = QLineEdit() + self.txt_tags.setPlaceholderText("comma-separated tags (e.g., meme, anime)") + defaults_layout.addRow("Default Tags:", self.txt_tags) + + self.chk_oc = QCheckBox("Mark upload as Original Content (is_oc=1)") + defaults_layout.addRow("", self.chk_oc) + + main_layout.addWidget(grp_defaults) + + # Section 3: App Behavior + grp_behavior = QGroupBox("App Behavior") + behavior_layout = QVBoxLayout(grp_behavior) + behavior_layout.setContentsMargins(15, 20, 15, 15) + behavior_layout.setSpacing(10) + + self.chk_notifications = QCheckBox("Enable Desktop Notifications") + self.chk_progress = QCheckBox("Show Live Progress Dialog") + self.chk_autostart = QCheckBox("Start automatically on system login") + + behavior_layout.addWidget(self.chk_notifications) + behavior_layout.addWidget(self.chk_progress) + behavior_layout.addWidget(self.chk_autostart) + + # Icon Theme Selection + theme_layout = QHBoxLayout() + theme_label = QLabel("Tray Icon Theme:") + self.cb_icon_theme = QComboBox() + self.cb_icon_theme.addItems(["Dark (Black Sun)", "Light (White Sun)"]) + theme_layout.addWidget(theme_label) + theme_layout.addWidget(self.cb_icon_theme) + theme_layout.addStretch() + behavior_layout.addLayout(theme_layout) + + main_layout.addWidget(grp_behavior) + + # Save / Cancel Buttons + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + btn_cancel = QPushButton("Cancel") + btn_cancel.clicked.connect(self.reject) + + self.btn_save = QPushButton("Save Settings") + self.btn_save.setObjectName("primary") + self.btn_save.clicked.connect(self.save_settings) + + btn_layout.addWidget(btn_cancel) + btn_layout.addWidget(self.btn_save) + main_layout.addLayout(btn_layout) + + self.load_current_settings() + + def toggle_key_visibility(self): + if self.txt_key.echoMode() == QLineEdit.Password: + self.txt_key.setEchoMode(QLineEdit.Normal) + self.btn_toggle_key.setText("Hide") + else: + self.txt_key.setEchoMode(QLineEdit.Password) + self.btn_toggle_key.setText("Show") + + def load_current_settings(self): + config = load_config() + self.txt_url.setText(config.get("api_url", "")) + self.txt_key.setText(config.get("api_key", "")) + + rating = config.get("default_rating", "") + rating_map = {"": 0, "safe": 1, "questionable": 2, "explicit": 3} + self.cb_rating.setCurrentIndex(rating_map.get(rating, 0)) + + self.txt_tags.setText(config.get("default_tags", "")) + self.chk_oc.setChecked(config.get("default_is_oc", False)) + self.chk_notifications.setChecked(config.get("enable_notifications", True)) + self.chk_progress.setChecked(config.get("show_progress_dialog", True)) + self.chk_autostart.setChecked(config.get("autostart", False)) + + icon_theme = config.get("icon_theme", "dark").lower() + self.cb_icon_theme.setCurrentIndex(1 if icon_theme == "light" else 0) + + def on_test_connection(self): + self.btn_test.setEnabled(False) + self.btn_test.setText("Testing...") + + url = self.txt_url.text().strip() + key = self.txt_key.text().strip() + + self.tester_thread = QThread() + self.tester = ConnectionTester(url, key) + self.tester.moveToThread(self.tester_thread) + + self.tester_thread.started.connect(self.tester.run) + self.tester.finished.connect(self.on_test_finished) + self.tester.finished.connect(self.tester_thread.quit) + self.tester.finished.connect(self.tester.deleteLater) + self.tester_thread.finished.connect(self.tester_thread.deleteLater) + + self.tester_thread.start() + + def on_test_finished(self, success, message): + self.btn_test.setEnabled(True) + self.btn_test.setText("Test Connection") + + if success: + QMessageBox.information(self, "Connection Test Success", message) + else: + QMessageBox.warning(self, "Connection Test Failed", message) + + def save_settings(self): + config = { + "api_url": self.txt_url.text().strip(), + "api_key": self.txt_key.text().strip(), + "default_rating": ["", "safe", "questionable", "explicit"][self.cb_rating.currentIndex()], + "default_tags": self.txt_tags.text().strip(), + "default_is_oc": self.chk_oc.isChecked(), + "enable_notifications": self.chk_notifications.isChecked(), + "show_progress_dialog": self.chk_progress.isChecked(), + "autostart": self.chk_autostart.isChecked(), + "icon_theme": "light" if self.cb_icon_theme.currentIndex() == 1 else "dark" + } + + if save_config(config): + set_autostart(config["autostart"]) + if self.tray_app and hasattr(self.tray_app, "load_tray_icon"): + self.tray_app.load_tray_icon() + self.accept() + else: + QMessageBox.critical(self, "Error", "Failed to save configuration file.") + +# ========================================== +# Dynamic Tray Digit Icon Painter +# ========================================== +def create_digit_icon(percent: int) -> QIcon: + size = 64 + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) + + # Sharp-edged solid black badge + painter.setBrush(QColor(0, 0, 0, 255)) + painter.setPen(QPen(QColor(0, 0, 0), 2)) + painter.drawRect(2, 2, size - 4, size - 4) + + # Crisp white digits (0 - 99) + val_str = str(min(99, max(0, percent))) + font_size = 32 if len(val_str) == 1 else 26 + font = QFont("Sans-Serif", font_size, QFont.Weight.Bold) + painter.setFont(font) + painter.setPen(QColor(255, 255, 255)) + + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, val_str) + painter.end() + + return QIcon(pixmap) + +# ========================================== +# Thumbnail Generator Helper +# ========================================== +def generate_thumbnail(file_path: str) -> str: + if not file_path or not os.path.exists(file_path): + return "" + + thumb_path = os.path.join(tempfile.gettempdir(), f"f0ckm_thumb_{int(time.time())}.png") + ext = os.path.splitext(file_path)[1].lower() + + # 1. Video files -> Extract thumbnail frame using ffmpeg + video_exts = {".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv", ".m4v", ".wmv"} + if ext in video_exts: + try: + res = subprocess.run( + ["ffmpeg", "-y", "-ss", "00:00:01", "-i", file_path, "-vframes", "1", + "-vf", "scale=160:80:force_original_aspect_ratio=increase,crop=160:80", thumb_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5 + ) + if res.returncode == 0 and os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 0: + return thumb_path + except Exception: + pass + + # 2. Image files -> Native fast PySide6 scaling + image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".avif"} + if ext in image_exts: + try: + img = QImage(file_path) + if not img.isNull(): + scaled = img.scaled(160, 80, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) + x = max(0, (scaled.width() - 160) // 2) + y = max(0, (scaled.height() - 80) // 2) + cropped = scaled.copy(x, y, 160, 80) + if cropped.save(thumb_path, "PNG"): + return thumb_path + except Exception: + pass + + # 3. Non-image / Non-video -> Create sleek placeholder badge + try: + pixmap = QPixmap(160, 80) + pixmap.fill(QColor(24, 24, 30)) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + painter.setPen(QPen(QColor(60, 60, 75), 2)) + painter.drawRect(1, 1, 158, 78) + + display_ext = ext.replace(".", "").upper()[:5] or "FILE" + painter.setFont(QFont("Sans-Serif", 16, QFont.Weight.Bold)) + painter.setPen(QColor(220, 220, 240)) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, display_ext) + painter.end() + + if pixmap.save(thumb_path, "PNG"): + return thumb_path + except Exception: + pass + + return "" + +# ========================================== +# System Tray Application Manager +# ========================================== +class SystemTrayApp(QObject): + def __init__(self): + super().__init__() + self.tray = QSystemTrayIcon() + + self.load_tray_icon() + self.tray.setToolTip("f0ckm Uploader (Click to capture & upload)") + + self.network_manager = QNetworkAccessManager(self) + self.current_reply = None + + self.menu = QMenu() + + # Header entry + title_action = QAction("f0ckm Uploader", self.menu) + title_action.setEnabled(False) + self.menu.addAction(title_action) + self.menu.addSeparator() + + # Actions + capture_action = QAction("Capture Region & Upload", self.menu) + capture_action.triggered.connect(self.on_capture_and_upload) + self.menu.addAction(capture_action) + + upload_action = QAction("Upload File...", self.menu) + upload_action.triggered.connect(self.on_upload_file) + self.menu.addAction(upload_action) + + clipboard_action = QAction("Upload from Clipboard", self.menu) + clipboard_action.triggered.connect(self.on_upload_clipboard) + self.menu.addAction(clipboard_action) + + settings_action = QAction("Settings...", self.menu) + settings_action.triggered.connect(self.on_open_settings) + self.menu.addAction(settings_action) + + self.menu.addSeparator() + + quit_action = QAction("Quit", self.menu) + quit_action.triggered.connect(QApplication.quit) + self.menu.addAction(quit_action) + + self.tray.setContextMenu(self.menu) + self.tray.activated.connect(self.on_tray_activated) + + self.settings_dialog = None + + def show(self): + self.tray.show() + + def on_tray_activated(self, reason): + if reason == QSystemTrayIcon.Trigger: + # Single click on tray icon starts Spectacle capture & upload + self.on_capture_and_upload() + elif reason == QSystemTrayIcon.DoubleClick: + self.on_open_settings() + + def load_tray_icon(self): + config = get_env_config() + theme = config.get("icon_theme", "dark").lower() + filename = "icon_light.svg" if theme == "light" else "icon.svg" + + script_dir = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.join(script_dir, filename), + f"/home/kibi/Projects/f0ckm-uploader/{filename}", + os.path.expanduser(f"~/.local/bin/{filename}") + ] + icon_path = None + for c in candidates: + if os.path.exists(c): + icon_path = c + break + + if icon_path: + self.default_icon = QIcon(icon_path) + else: + self.default_icon = QIcon.fromTheme("cloud-upload-symbolic", QIcon.fromTheme("network-server")) + + self.tray.setIcon(self.default_icon) + + def on_open_settings(self): + if not self.settings_dialog: + self.settings_dialog = SettingsDialog(tray_app=self) + self.settings_dialog.show() + self.settings_dialog.raise_() + self.settings_dialog.activateWindow() + + def on_capture_and_upload(self): + screenshot_dir = os.path.expanduser("~/Pictures/Screenshots") + os.makedirs(screenshot_dir, exist_ok=True) + save_path = os.path.join(screenshot_dir, f"screenshot_{time.strftime('%Y%m%d_%H%M%S')}.png") + + try: + res = subprocess.run(["spectacle", "-r", "-b", "-n", "-o", save_path]) + if os.path.exists(save_path) and os.path.getsize(save_path) > 0: + self.upload_file_direct(save_path) + except Exception as e: + self.show_message("Upload Error", f"Failed to capture screenshot: {e}", QSystemTrayIcon.Critical) + + def upload_file_direct(self, file_path): + file_path = normalize_file_path(file_path) + if not file_path or not os.path.exists(file_path): + self.show_message("Upload Error", f"File not found: {file_path}", QSystemTrayIcon.Critical) + return + + config = get_env_config() + api_url = config.get("api_url", "") + api_key = config.get("api_key", "") + rating = config.get("default_rating", "") + tags = config.get("default_tags", "") + visibility = str(config.get("default_visibility", "0")) + is_oc = "1" if config.get("default_is_oc", False) else "0" + + self.tray.setIcon(create_digit_icon(0)) + self.tray.setToolTip("Uploading: 0%") + + request = QNetworkRequest(QUrl(api_url)) + if api_key: + request.setRawHeader(b"X-Api-Key", api_key.encode("utf-8")) + + multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType) + + file_part = QHttpPart() + filename = os.path.basename(file_path) + file_part.setHeader(QNetworkRequest.ContentDispositionHeader, f'form-data; name="file"; filename="{filename}"') + + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "image/png" if file_path.lower().endswith(".png") else "application/octet-stream" + file_part.setHeader(QNetworkRequest.ContentTypeHeader, mime_type) + + qfile = QFile(file_path) + if not qfile.open(QIODevice.ReadOnly): + self.tray.setIcon(self.default_icon) + self.show_message("Upload Error", f"Cannot open file: {file_path}", QSystemTrayIcon.Critical) + return + + file_part.setBodyDevice(qfile) + qfile.setParent(multi_part) + multi_part.append(file_part) + + for name, val in [("rating", rating), ("tags", tags), ("visibility", visibility), ("is_oc", is_oc)]: + if val != "": + p = QHttpPart() + p.setHeader(QNetworkRequest.ContentDispositionHeader, f'form-data; name="{name}"') + p.setBody(str(val).encode("utf-8")) + multi_part.append(p) + + reply = self.network_manager.post(request, multi_part) + multi_part.setParent(reply) + reply.qfile = qfile + reply.multi_part = multi_part + self.current_reply = reply + + reply.uploadProgress.connect(self.on_upload_progress) + reply.finished.connect(lambda: self.on_upload_finished(reply, file_path)) + + def on_upload_progress(self, bytes_sent, bytes_total): + if bytes_total > 0: + pct = min(100, max(0, int((bytes_sent / bytes_total) * 100))) + self.tray.setIcon(create_digit_icon(pct)) + self.tray.setToolTip(f"Uploading: {pct}%") + + def on_upload_finished(self, reply, file_path): + self.tray.setIcon(self.default_icon) + self.tray.setToolTip("f0ckm Uploader (Click to capture & upload)") + + try: + err_body = reply.readAll().data().decode("utf-8", errors="ignore") + + if reply.error() == QNetworkReply.NoError: + try: + res = json.loads(err_body) + if res.get("success") and res.get("url"): + item_url = res.get("url") + QApplication.clipboard().setText(item_url) + self.show_success_notification(file_path, item_url) + else: + msg = res.get("msg", "Upload failed") + self.show_message("Upload Failed", msg, QSystemTrayIcon.Critical) + except Exception as e: + self.show_message("Upload Error", f"Error parsing response: {e}", QSystemTrayIcon.Critical) + else: + server_msg = err_body if err_body else reply.errorString() + self.show_message("Upload Failed", f"Server Response: {server_msg}", QSystemTrayIcon.Critical) + except Exception as e: + self.show_message("Upload Error", f"Unexpected error: {e}", QSystemTrayIcon.Critical) + finally: + reply.deleteLater() + self.current_reply = None + + def show_success_notification(self, file_path, item_url): + config = get_env_config() + if not config.get("enable_notifications", True): + return + + thumb_path = generate_thumbnail(file_path) + if thumb_path and os.path.exists(thumb_path): + body_text = f'


{item_url}' + else: + body_text = f'Upload Successful: {item_url}' + + try: + subprocess.run(["notify-send", "-a", "f0ckm", "Upload Successful", body_text], check=False) + except Exception as e: + print(f"Notification error: {e}") + + def on_upload_file(self): + file_path, _ = QFileDialog.getOpenFileName( + None, "Select File to Upload", "", "All Files (*)" + ) + if file_path: + self.upload_file_direct(file_path) + + def on_upload_clipboard(self): + clipboard = QApplication.clipboard() + mime_data = clipboard.mimeData() + + if mime_data.hasImage(): + image = clipboard.image() + temp_dir = tempfile.gettempdir() + temp_path = os.path.join(temp_dir, f"f0ckm_clipboard_{int(time.time())}.png") + if image.save(temp_path, "PNG"): + self.upload_file_direct(temp_path) + else: + self.show_message("Upload Error", "Failed to save clipboard image to temporary file.", QSystemTrayIcon.Critical) + elif mime_data.hasUrls(): + urls = mime_data.urls() + uploaded_any = False + for url in urls: + if url.isLocalFile(): + path = url.toLocalFile() + if os.path.exists(path): + self.upload_file_direct(path) + uploaded_any = True + if not uploaded_any: + self.show_message("Upload Error", "No valid local files in clipboard.", QSystemTrayIcon.Warning) + elif mime_data.hasText(): + text = mime_data.text().strip() + lines = [l.strip() for l in text.split('\n') if l.strip()] + uploaded_any = False + for line in lines: + if line.startswith("file://"): + line = line[7:] + if os.path.exists(line): + self.upload_file_direct(line) + uploaded_any = True + if not uploaded_any: + self.show_message("Upload Error", "Clipboard text is not a valid local file path.", QSystemTrayIcon.Warning) + else: + self.show_message("Upload Error", "Clipboard does not contain an image or file paths.", QSystemTrayIcon.Warning) + + def show_message(self, title, message, icon=QSystemTrayIcon.Information): + self.tray.showMessage(title, message, icon, 5000) + +# ========================================== +# Application Entry Point & IPC +# ========================================== +class SingleInstanceApp: + def __init__(self, name): + self.name = name + self.server = QLocalServer() + + def start(self, callback_on_message): + if not self.server.listen(self.name): + socket = QLocalSocket() + socket.connectToServer(self.name) + if socket.waitForConnected(500): + msg = "show" + if len(sys.argv) > 1: + arg = sys.argv[1] + if arg in ("--spectacle", "-s"): + msg = "spectacle" + elif arg.startswith("--upload="): + msg = f"upload:{arg[9:]}" + elif arg.startswith("--upload"): + if len(sys.argv) > 2: + msg = f"upload:{sys.argv[2]}" + else: + norm = normalize_file_path(arg) + if os.path.exists(norm): + msg = f"upload:{norm}" + socket.write(msg.encode("utf-8")) + socket.waitForBytesWritten(500) + socket.disconnectFromServer() + return False + else: + QLocalServer.removeServer(self.name) + if not self.server.listen(self.name): + return False + + self.server.newConnection.connect(lambda: self._handle_connection(callback_on_message)) + return True + + def _handle_connection(self, callback): + socket = self.server.nextPendingConnection() + if socket: + socket.readyRead.connect(lambda: self._read_message(socket, callback)) + + def _read_message(self, socket, callback): + data = socket.readAll().data().decode("utf-8").strip() + callback(data) + socket.disconnectFromServer() + +def main(): + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + instance_name = "f0ckm-uploader-gui-lock" + single_instance = SingleInstanceApp(instance_name) + + tray_app = None + + def on_activate(msg_str): + nonlocal tray_app + if tray_app: + if msg_str == "spectacle": + tray_app.on_capture_and_upload() + elif msg_str.startswith("upload:"): + file_path = normalize_file_path(msg_str[7:]) + if os.path.exists(file_path): + tray_app.upload_file_direct(file_path) + else: + tray_app.show_message("Upload Error", f"File not found: {file_path}", QSystemTrayIcon.Critical) + else: + tray_app.on_open_settings() + + if not single_instance.start(on_activate): + sys.exit(0) + + if not os.path.exists(CONFIG_PATH): + save_config(DEFAULT_CONFIG) + + tray_app = SystemTrayApp() + tray_app.show() + + # Check if started with action argument + if len(sys.argv) > 1: + arg = sys.argv[1] + if arg in ("--spectacle", "-s"): + tray_app.on_capture_and_upload() + elif arg.startswith("--upload="): + file_path = normalize_file_path(arg[9:]) + tray_app.upload_file_direct(file_path) + elif arg.startswith("--upload"): + if len(sys.argv) > 2: + file_path = normalize_file_path(sys.argv[2]) + tray_app.upload_file_direct(file_path) + else: + norm = normalize_file_path(arg) + if os.path.exists(norm): + tray_app.upload_file_direct(norm) + + sys.exit(app.exec()) + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 791f805..0beb0a9 --- a/install.sh +++ b/install.sh @@ -9,22 +9,110 @@ LEGACY_SERVICE_MENU_DIR="$HOME/.local/share/kservices5/ServiceMenus" # Create necessary directories mkdir -p "$INSTALL_DIR" "$APP_DIR" "$SERVICE_MENU_DIR" "$LEGACY_SERVICE_MENU_DIR" -# 1. Install the script +# 1. Install the scripts cp uploader.sh "$INSTALL_DIR/kde-uploader" chmod +x "$INSTALL_DIR/kde-uploader" -# 2. Install standard application entry (for Spectacle) -sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader|g" kde-uploader.desktop > "$APP_DIR/kde-uploader.desktop" -chmod +x "$APP_DIR/kde-uploader.desktop" -update-desktop-database "$APP_DIR" &> /dev/null +cp gui.py "$INSTALL_DIR/kde-uploader-gui" +chmod +x "$INSTALL_DIR/kde-uploader-gui" -# 3. Install KDE Service Menu entry (for Dolphin) -sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader|g" kde-uploader-servicemenu.desktop > "$SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" +mkdir -p "$HOME/.local/share/icons/hicolor/scalable/apps" +mkdir -p "$HOME/.local/share/pixmaps" + +# Generate multi-resolution PNG icons (32x32, 48x48, 64x64, 128x128, 256x256) for KRunner search compatibility +python3 -c ' +import os +from PySide6.QtGui import QImage, QPainter +from PySide6.QtSvg import QSvgRenderer +try: + renderer = QSvgRenderer("icon_app.svg") + sizes = [32, 48, 64, 128, 256] + for sz in sizes: + dir_path = f"'$HOME'/.local/share/icons/hicolor/{sz}x{sz}/apps" + os.makedirs(dir_path, exist_ok=True) + img = QImage(sz, sz, QImage.Format_ARGB32) + img.fill(0) + p = QPainter(img) + renderer.render(p) + p.end() + img.save(os.path.join(dir_path, "f0ckm-uploader.png")) + + img_48 = QImage(48, 48, QImage.Format_ARGB32) + img_48.fill(0) + p = QPainter(img_48) + renderer.render(p) + p.end() + img_48.save("'$HOME'/.local/share/pixmaps/f0ckm-uploader.png") +except Exception as e: + pass +' &> /dev/null + +if [ -f icon_app.svg ]; then + cp icon_app.svg "$INSTALL_DIR/icon_app.svg" + cp icon_app.svg "$HOME/.local/share/icons/hicolor/scalable/apps/f0ckm-uploader.svg" +elif [ -f icon.svg ]; then + cp icon.svg "$HOME/.local/share/icons/hicolor/scalable/apps/f0ckm-uploader.svg" +fi + +if [ -f icon.svg ]; then + cp icon.svg "$INSTALL_DIR/icon.svg" +fi + +if [ -f icon_light.svg ]; then + cp icon_light.svg "$INSTALL_DIR/icon_light.svg" +fi + +if [ -f .env ]; then + cp .env "$INSTALL_DIR/.env" +fi + +# Clean up any stale auto-generated desktop entries +rm -f "$APP_DIR/net.local.spectacle_uploader.sh.desktop" + +ICON_PATH="f0ckm-uploader" + +# 2. Install standard application entry (for Spectacle - hidden from main menu) +sed -e "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui --spectacle|g" -e "s|__ICON_PATH__|$ICON_PATH|g" kde-uploader.desktop > "$APP_DIR/kde-uploader.desktop" +chmod +x "$APP_DIR/kde-uploader.desktop" + +# 3. Install the GUI application entry (visible in main menu) +sed -e "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui|g" -e "s|__ICON_PATH__|$ICON_PATH|g" kde-uploader-gui.desktop > "$APP_DIR/kde-uploader-gui.desktop" +chmod +x "$APP_DIR/kde-uploader-gui.desktop" + +update-desktop-database "$APP_DIR" &> /dev/null +gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" &> /dev/null || true +kbuildsycoca6 --noincremental &> /dev/null || kbuildsycoca5 --noincremental &> /dev/null || true + +# 4. Install KDE Service Menu entry (for Dolphin) +sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui --upload|g" kde-uploader-servicemenu.desktop > "$SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" chmod +x "$SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" -sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader|g" kde-uploader-servicemenu.desktop > "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" +sed "s|__EXEC_PATH__|$INSTALL_DIR/kde-uploader-gui --upload|g" kde-uploader-servicemenu.desktop > "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" chmod +x "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" +# 5. Migrate config to config.json if not present +CONFIG_DIR="$HOME/.config/f0ckm-uploader" +CONFIG_FILE="$CONFIG_DIR/config.json" +if [ ! -f "$CONFIG_FILE" ]; then + mkdir -p "$CONFIG_DIR" + # Fallback to the default key in script if no config file is found + cat < "$CONFIG_FILE" +{ + "api_url": "", + "api_key": "", + "default_rating": "", + "default_tags": "", + "default_is_oc": false, + "enable_notifications": true, + "show_progress_dialog": true, + "autostart": false +} +EOF + echo "Initial configuration created at $CONFIG_FILE" +fi + echo "Installation complete!" echo "The script has been installed to $INSTALL_DIR/kde-uploader" -echo "You can now right click files in Dolphin and find 'Upload to Custom API' in the Actions menu." -echo "In Spectacle, you should find 'Upload to Custom API' in the Export or Share menu." +echo "The GUI has been installed to $INSTALL_DIR/kde-uploader-gui" +echo "You can launch the GUI uploader from your applications menu (f0ckm Uploader GUI)." +echo "You can now right click files in Dolphin and find 'Upload to f0ckm' in the Actions menu." +echo "In Spectacle, you should find 'Upload to f0ckm' in the Export or Share menu." diff --git a/kde-uploader-gui.desktop b/kde-uploader-gui.desktop new file mode 100755 index 0000000..b4c897f --- /dev/null +++ b/kde-uploader-gui.desktop @@ -0,0 +1,17 @@ +[Desktop Entry] +Type=Application +Name=f0ckm Uploader +GenericName=Image & Media Uploader +Comment=System tray GUI uploader for f0ckm +Exec=__EXEC_PATH__ +Icon=__ICON_PATH__ +Terminal=false +Categories=Utility;Network; +Keywords=upload;screenshot;f0ckm;tray; +StartupNotify=false +Actions=capture; + +[Desktop Action capture] +Name=Capture Region & Upload +Exec=__EXEC_PATH__ --spectacle +Icon=__ICON_PATH__ diff --git a/kde-uploader-servicemenu.desktop b/kde-uploader-servicemenu.desktop index c6b935b..3a42cdc 100644 --- a/kde-uploader-servicemenu.desktop +++ b/kde-uploader-servicemenu.desktop @@ -9,4 +9,4 @@ X-KDE-Submenu=Upload [Desktop Action upload_custom_api] Name=Upload to f0ckm Icon=cloud-upload-symbolic -Exec=__EXEC_PATH__ "%u" +Exec=__EXEC_PATH__ "%f" diff --git a/kde-uploader.desktop b/kde-uploader.desktop index e92f5d7..187624e 100644 --- a/kde-uploader.desktop +++ b/kde-uploader.desktop @@ -1,16 +1,16 @@ [Desktop Entry] Type=Application -Name=f0ckm Uploader -Comment=Upload to f0ckm -Exec=__EXEC_PATH__ %F -Icon=network-server +Name=f0ckm Uploader (Internal) +Comment=Upload to f0ckm (Backend registration) +Exec=__EXEC_PATH__ +Icon=f0ckm-uploader Terminal=false Categories=Utility;Network; MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/avif;image/heic;video/mp4;video/webm;audio/mpeg;audio/ogg; -NoDisplay=false +NoDisplay=true InitialPreference=0 [Desktop Action upload] Name=Upload to Custom API -Exec=__EXEC_PATH__ %F -Icon=network-server +Exec=__EXEC_PATH__ +Icon=f0ckm-uploader diff --git a/spectacle_uploader.sh b/spectacle_uploader.sh index 5eb3f25..427711b 100755 --- a/spectacle_uploader.sh +++ b/spectacle_uploader.sh @@ -1,93 +1,3 @@ #!/usr/bin/env bash - -# ── 0. Load Environment Variables from .env ───────────────────────────── -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOTENV_PATH="${SCRIPT_DIR}/.env" - -if [ -f "$DOTENV_PATH" ]; then - set -o allexport - source "$DOTENV_PATH" - set +o allexport -fi - -# Environment variables from .env -F0CKM_URL="${F0CKM_URL}" -API_KEY="${API_KEY}" -RATING="${RATING}" # sfw, nsfw, or nsfl -TAGS="${TAGS}" # Comma-separated tags -VISIBILITY="${VISIBILITY}" # 0 = public, 1 = unlisted, 2 = private - -# Notification app header title & preview dimensions -APP_NAME="${APP_NAME:-f0ckm}" -PREVIEW_HEIGHT="${PREVIEW_HEIGHT:-80}" # Center crop height in pixels (e.g. 80) - -# ── 1. Clipboard Helper Function (Wayland / X11) ─────────────────────────── -copy_to_clipboard() { - local content="$1" - if command -v wl-copy &>/dev/null; then - echo -n "$content" | wl-copy - elif command -v xclip &>/dev/null; then - echo -n "$content" | xclip -selection clipboard - elif command -v xsel &>/dev/null; then - echo -n "$content" | xsel -b - else - echo "Warning: No clipboard utility found (wl-clipboard or xclip)." - fi -} - -# ── 1. Create Output Directory & Timestamped File Path ─────────────────── -SCREENSHOT_DIR="${HOME}/Pictures/Screenshots" -mkdir -p "$SCREENSHOT_DIR" - -SAVE_PATH="${SCREENSHOT_DIR}/screenshot_$(date +%Y%m%d_%H%M%S).png" - -# ── 2. Capture Region Screenshot with Spectacle ────────────────────────── -spectacle -r -b -n -o "$SAVE_PATH" - -# ── 3. Upload File & Process Response ──────────────────────────────────── -if [ -f "$SAVE_PATH" ]; then - echo "Uploading '$SAVE_PATH' to ${F0CKM_URL}/api/v2/upload..." - - RESPONSE=$(curl -s -X POST "${F0CKM_URL}/api/v2/upload" \ - -H "X-Api-Key: ${API_KEY}" \ - -F "file=@${SAVE_PATH}" \ - -F "rating=${RATING}" \ - -F "tags=${TAGS}" \ - -F "visibility=${VISIBILITY}") - - # Parse response JSON - SUCCESS=$(echo "$RESPONSE" | jq -r '.success // false') - ITEM_URL=$(echo "$RESPONSE" | jq -r '.url // empty') - MSG=$(echo "$RESPONSE" | jq -r '.msg // "Upload failed"') - - if [ "$SUCCESS" = "true" ] && [ -n "$ITEM_URL" ]; then - # Copy URL to clipboard - copy_to_clipboard "$ITEM_URL" - - # Create center-cropped preview banner (exact 80px height) - PREVIEW_THUMB="/tmp/f0ckm_preview.png" - if command -v magick &>/dev/null; then - magick "$SAVE_PATH" -gravity center -crop "x${PREVIEW_HEIGHT}+0+0" +repage "$PREVIEW_THUMB" - else - PREVIEW_THUMB="$SAVE_PATH" - fi - - # HTML body with fixed height preview banner (no scrolling), and link on a new line - BODY_TEXT="


${ITEM_URL}" - - # Send Notification - notify-send \ - -a "${APP_NAME}" \ - "" \ - "${BODY_TEXT}" - - echo "Success! $ITEM_URL" - else - # Show error notification - notify-send -a "${APP_NAME}" -u critical "Upload Failed" "$MSG" - echo "Upload failed: $RESPONSE" - exit 1 - fi -else - echo "Screenshot capture canceled." -fi +# Pass execution directly to the Python GUI engine +exec kde-uploader-gui --spectacle "$@" diff --git a/uninstall.sh b/uninstall.sh index 2ce48c8..3f2f590 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -6,17 +6,27 @@ APP_DIR="$HOME/.local/share/applications" SERVICE_MENU_DIR="$HOME/.local/share/kio/servicemenus" LEGACY_SERVICE_MENU_DIR="$HOME/.local/share/kservices5/ServiceMenus" -# 1. Remove the executable script +# 1. Remove the executable scripts if [ -f "$INSTALL_DIR/kde-uploader" ]; then rm "$INSTALL_DIR/kde-uploader" - echo "Removed executable from $INSTALL_DIR" + echo "Removed uploader executable from $INSTALL_DIR" fi -# 2. Remove the standard application entry +if [ -f "$INSTALL_DIR/kde-uploader-gui" ]; then + rm "$INSTALL_DIR/kde-uploader-gui" + echo "Removed GUI executable from $INSTALL_DIR" +fi + +# 2. Remove the application entries if [ -f "$APP_DIR/kde-uploader.desktop" ]; then rm "$APP_DIR/kde-uploader.desktop" echo "Removed application entry from $APP_DIR" fi + +if [ -f "$APP_DIR/kde-uploader-gui.desktop" ]; then + rm "$APP_DIR/kde-uploader-gui.desktop" + echo "Removed GUI application entry from $APP_DIR" +fi update-desktop-database "$APP_DIR" &> /dev/null # 3. Remove the modern KDE Service Menu entry @@ -31,4 +41,11 @@ if [ -f "$LEGACY_SERVICE_MENU_DIR/kde-uploader-servicemenu.desktop" ]; then echo "Removed legacy service menu entry from $LEGACY_SERVICE_MENU_DIR" fi -echo "Uninstallation complete! The custom upload options and script have been removed." +# 5. Remove autostart entry +AUTOSTART_FILE="$HOME/.config/autostart/kde-uploader-gui.desktop" +if [ -f "$AUTOSTART_FILE" ]; then + rm "$AUTOSTART_FILE" + echo "Removed autostart entry" +fi + +echo "Uninstallation complete! The custom upload options, GUI, and scripts have been removed." diff --git a/uploader.sh b/uploader.sh index e82a3df..7d1646e 100755 --- a/uploader.sh +++ b/uploader.sh @@ -1,193 +1,3 @@ -#!/bin/bash - -# ========================================== -# Notification Wrapper -# ========================================== -show_notification() { - local title="$1" - local message="$2" - local icon="${3:-cloud-upload-symbolic}" - if command -v kdialog &>/dev/null; then - kdialog --passivepopup "$message" 5 --title "$title" --icon "$icon" - else - echo "$title: $message" - fi -} - -# ========================================== -# Configuration -# ========================================== -API_URL="http://localhost:1337/api/v2/upload" -API_KEY="" - -# ========================================== - -FILE="$1" - -if [ -z "$FILE" ]; then - show_notification "Upload Failed" "No file provided to uploader." "dialog-error" - exit 1 -fi - -# Determine clipboard command (Wayland or X11) -if command -v wl-copy &>/dev/null; then - COPY_CMD="wl-copy" -elif command -v xclip &>/dev/null; then - COPY_CMD="xclip -selection clipboard" -else - show_notification "Upload Error" "Please install wl-clipboard (Wayland) or xclip (X11)." "dialog-error" - exit 1 -fi - -# Detect the real MIME type so curl doesn't fall back to application/octet-stream -MIME_TYPE=$(file --mime-type -b "$FILE") -BASENAME=$(basename "$FILE") - -# Get file size locally -FILE_SIZE_BYTES=$(stat -c%s "$FILE" 2>/dev/null || echo "0") - -human_readable_size() { - local size=$1 - local units=('B' 'KB' 'MB' 'GB' 'TB') - local unit=0 - while [ $size -ge 1024 ] && [ $unit -lt 4 ]; do - size=$((size / 1024)) - unit=$((unit + 1)) - done - echo "$size ${units[$unit]}" -} -TOTAL_SIZE=$(human_readable_size "$FILE_SIZE_BYTES") - -# Helper to format centered HTML label with a fixed width -format_label() { - local filename="$1" - local detail="$2" - local wrapped_filename - # Wrap the filename to 45 chars max, and join with
- wrapped_filename=$(echo "$filename" | fold -w 45 | sed ':a;N;$!ba;s/\n/
/g') - echo "
Uploading $wrapped_filename
$detail
" -} - -INIT_LABEL=$(format_label "$BASENAME" "(0 B / $TOTAL_SIZE) @ 0 B/s — --:--:-- remaining") - -# ========================================== -# Progress bar setup (kdialog + qdbus) -# ========================================== - -# Prefer qdbus6 on KDE6, fall back to qdbus on KDE5 -QDBUS_CMD="" -if command -v qdbus6 &>/dev/null; then - QDBUS_CMD="qdbus6" -elif command -v qdbus &>/dev/null; then - QDBUS_CMD="qdbus" -fi - -PROGRESS_SERVICE="" -PROGRESS_OBJECT="" - -if command -v kdialog &>/dev/null && [ -n "$QDBUS_CMD" ]; then - DBUS_REF=$(kdialog --title "f0ckm Uploader" \ - --progressbar "$INIT_LABEL" 100 2>/dev/null) - PROGRESS_SERVICE=$(echo "$DBUS_REF" | awk '{print $1}') - PROGRESS_OBJECT=$(echo "$DBUS_REF" | awk '{print $2}') -fi - -# If no progress dialog is available, show a passive "uploading" popup instead -if [ -z "$PROGRESS_SERVICE" ]; then - show_notification "Uploading..." "Uploading $BASENAME" "cloud-upload-symbolic" -fi - -# ========================================== -# Upload via curl with live progress -# ========================================== - -# FIFO routes curl's stderr (progress bar) to the reader loop -FIFO=$(mktemp -u /tmp/f0ckm_XXXXXX) -mkfifo "$FIFO" -RESPONSE_TMP=$(mktemp) - -# --progress-meter forces curl to output progress table even when redirected to a FIFO -curl --progress-meter -w "\n%{http_code}" -X POST "$API_URL" \ - -H "X-Api-Key: $API_KEY" \ - -F "file=@$FILE;type=$MIME_TYPE" \ - -F "rating=" \ - -F "tags=" \ - -F "is_oc=0" \ - >"$RESPONSE_TMP" 2>"$FIFO" & -CURL_PID=$! - -# Parse the curl progress meter output in real-time -if [ -n "$PROGRESS_SERVICE" ] && [ -n "$PROGRESS_OBJECT" ]; then - while IFS= read -r -d $'\r' line; do - # Clean line and split into fields - line=$(echo "$line" | tr -d '\n\r') - read -ra fields <<< "$line" - - # Check if valid progress line: starts with percentage digit and has >= 11 fields - if [[ ${#fields[@]} -ge 11 ]] && [[ "${fields[0]}" =~ ^[0-9]+$ ]]; then - PCT="${fields[4]}" # % Xferd - if [[ ! "$PCT" =~ ^[0-9]+$ ]]; then - PCT="${fields[0]}" - fi - - TOTAL_SIZE_CURL="${fields[1]}" - XFERD="${fields[5]}" - SPEED="${fields[11]}" - TIME_LEFT="${fields[10]}" - - # Format speed (append /s if it looks like a size unit) - if [[ "$SPEED" =~ [0-9]+[bBkKmMgGtT]?$ ]]; then - SPEED="${SPEED}/s" - fi - - # Prefer the local size calculation if curl total size is 0/empty/not computed yet - if [ -z "$TOTAL_SIZE_CURL" ] || [ "$TOTAL_SIZE_CURL" = "0" ] || [ "$TOTAL_SIZE_CURL" = "0b" ]; then - TOTAL_SIZE_CURL="$TOTAL_SIZE" - fi - - DETAIL="($XFERD / $TOTAL_SIZE_CURL) @ $SPEED — $TIME_LEFT remaining" - LABEL=$(format_label "$BASENAME" "$DETAIL") - - $QDBUS_CMD "$PROGRESS_SERVICE" "$PROGRESS_OBJECT" Set "" value "$PCT" 2>/dev/null || true - $QDBUS_CMD "$PROGRESS_SERVICE" "$PROGRESS_OBJECT" setLabelText "$LABEL" 2>/dev/null || true - fi - done < "$FIFO" -else - # No progress dialog — drain the FIFO in the background so curl isn't blocked - cat "$FIFO" >/dev/null & -fi - -wait "$CURL_PID" - -# Close the progress dialog -if [ -n "$PROGRESS_SERVICE" ] && [ -n "$PROGRESS_OBJECT" ]; then - $QDBUS_CMD "$PROGRESS_SERVICE" "$PROGRESS_OBJECT" close 2>/dev/null || true -fi - -rm -f "$FIFO" - -# ========================================== -# Parse response -# ========================================== -HTTP_CODE=$(tail -n1 "$RESPONSE_TMP") -BODY=$(sed '$d' "$RESPONSE_TMP") -rm -f "$RESPONSE_TMP" - -if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then - # Parse the direct file URL from the JSON response. - # Expects a response containing a "file_url" field. - URL=$(echo "$BODY" | grep -oP '"file_url"\s*:\s*"\K[^"]+') - - # Fallback: if no file_url field is found, copy the whole body - if [ -z "$URL" ]; then - URL="$BODY" - fi - - echo -n "$URL" | $COPY_CMD - show_notification "Upload Successful" \ - "Link copied to clipboard!

$URL" \ - "cloud-upload-symbolic" -else - show_notification "Upload Failed" "HTTP Error $HTTP_CODE\n$BODY" "dialog-error" - exit 1 -fi +#!/usr/bin/env bash +# Pass execution directly to the Python GUI engine +exec kde-uploader-gui --upload="$1"