From 66f47524096039584434a7a56548bbc102f34284 Mon Sep 17 00:00:00 2001 From: Ada Werefox Date: Mon, 1 Jun 2026 22:52:18 -0700 Subject: [PATCH] Fix and clean up this ugly ass AI code. --- .gitignore | 5 +- README_UI.md | 116 ++++++++++++ build.bat | 13 ++ requirements.txt | 4 +- restore_backup.py | 23 ++- restore_backup.spec | 38 ++++ restore_ui.py | 451 ++++++++++++++++++++++++++++++++++++++++++++ restore_ui.spec | 45 +++++ 8 files changed, 684 insertions(+), 11 deletions(-) create mode 100644 README_UI.md create mode 100644 build.bat mode change 100755 => 100644 restore_backup.py create mode 100644 restore_backup.spec create mode 100644 restore_ui.py create mode 100644 restore_ui.spec diff --git a/.gitignore b/.gitignore index a350ce7..c14aa06 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ FINAL FANTASY XIV - A Realm Reborn/* pluginConfigs/* temp/* *.pyc -.env \ No newline at end of file +.env +build/* +dist/* +.venv/* \ No newline at end of file diff --git a/README_UI.md b/README_UI.md new file mode 100644 index 0000000..040fe05 --- /dev/null +++ b/README_UI.md @@ -0,0 +1,116 @@ +# FFXIV Backup Restore UI + +A Windows-native PyQt6 GUI for the FFXIV backup restore script. + +## Features + +- **Settings Tab**: Configure Nextcloud credentials and local paths +- **Restore Tab**: Select backup and monitor restore progress in real-time +- **Single Executable**: Packaged with PyInstaller for easy distribution +- **Threading**: Non-blocking UI during restore operations +- **Status Output**: Real-time console output display + +## Requirements + +- Python 3.8+ +- Windows (tested on Windows 11) +- 7z command-line tool (for extraction) + +## Installation + +### 1. Install Python Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Verify 7z is installed + +The script requires 7z for extraction. Install it or ensure it's in your PATH. + +## Usage + +### Development (Running from source) + +```bash +python restore_ui.py +``` + +### Building Executable + +Run the build script: + +```bash +build.bat +``` + +Or manually with PyInstaller: + +```bash +pyinstaller restore_ui.spec +``` + +The executable will be created in the `dist\` directory. + +## Configuration + +Configuration is loaded from `.env` file in the same directory as the script. The file contains: + +- **NC_URL**: Nextcloud server URL +- **NC_USER**: Nextcloud username +- **NC_PASS**: Nextcloud password +- **NC_TEMP_DOWNLOAD**: Path to store downloaded .7z file +- **TEMP_EXTRACT_PATH**: Path to extract backup contents +- **TEMP_BACKUP_PATH**: Directory to store local backups +- **PLUGIN_CONFIGS_PATH**: XIVLauncher plugin config directory +- **FFXIV_CONFIGS_PATH**: FFXIV game config directory + +## Usage Steps + +1. **Configure Settings** (if needed): + - Open "Settings" tab + - Verify Nextcloud credentials and paths + - Click outside tab to save (settings are read from .env on startup) + +2. **Restore Backup**: + - Open "Restore" tab + - Enter remote archive path (e.g., `Backups/FFXIV/Desktop/backup_file.7z`) + - Click "Start Restore" + - Monitor progress in output window + +3. **View Results**: + - Success/error message appears upon completion + - Output can be cleared with "Clear Output" button + +## Troubleshooting + +### Missing Dependencies +If you get import errors, run: `pip install -r requirements.txt` + +### 7z not found +Ensure 7z is installed and in your system PATH + +### Nextcloud Connection Failed +- Verify NC_URL is correct (including https://) +- Check username and password are correct +- Ensure network connection to Nextcloud server + +### PyInstaller Build Issues +If the .spec file fails: +- Delete `build/` and `dist/` directories +- Rebuild: `pyinstaller restore_ui.spec` + +## Distributing the Executable + +Once built, the executable (`dist\FFXIV-Backup-Restore.exe`) can be: +- Copied to any Windows system with .NET Framework (included in Windows) +- Distributed as a single file +- No Python installation required on target system +- Requires `.env` file in same directory as executable + +## Notes + +- The UI runs the original `restore_backup.py` script in a background thread +- All output is captured and displayed in the UI +- Settings tab is for informational purposes (values are read from `.env` at startup) +- To update settings, edit `.env` file directly and restart the application diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..6bb23f4 --- /dev/null +++ b/build.bat @@ -0,0 +1,13 @@ +@echo off +REM Install dependencies +echo Installing dependencies... +pip install -r requirements.txt + +REM Build executable using PyInstaller +echo. +echo Building executable... +pyinstaller restore_ui.spec + +echo. +echo Build complete! Executable located at: dist\FFXIV-Backup-Restore.exe +pause diff --git a/requirements.txt b/requirements.txt index d6d2b93..3d75256 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ +PyQt6==6.7.1 nextcloud-api-wrapper==0.2.3 -python-dotenv==1.2.2 +python-dotenv==1.0.1 +pyinstaller==6.9.0 diff --git a/restore_backup.py b/restore_backup.py old mode 100755 new mode 100644 index c0e9e43..80023f5 --- a/restore_backup.py +++ b/restore_backup.py @@ -6,16 +6,17 @@ import argparse import subprocess from datetime import datetime from dotenv import load_dotenv -from nextcloud import NextCloud +from nextcloud import NextCloud # type: ignore # Load environment variables from .env file -load_dotenv() +load_dotenv(os.curdir + "\\.env") + # --- CONFIGURATION FROM ENVIRONMENT --- NC_URL = os.getenv("NC_URL") NC_USER = os.getenv("NC_USER") NC_PASS = os.getenv("NC_PASS") -TEMP_DOWNLOAD_NAME = os.getenv("NC_TEMP_DOWNLOAD", "/tmp/temp_download.7z") +TEMP_DOWNLOAD_NAME = os.getenv("NC_TEMP_DOWNLOAD", "\tmp\temp_download.7z") TEMP_EXTRACT_PATH = os.getenv("TEMP_EXTRACT_PATH") TEMP_BACKUP_PATH = os.getenv("TEMP_BACKUP_PATH") PLUGIN_CONFIG_PATH = os.getenv("PLUGIN_CONFIGS_PATH") @@ -51,7 +52,7 @@ Download a .7z archive from Nextcloud, backup local files, and replace them.""") "-r", "--remote", required=True, - help="Path to the .7z archive file inside Nextcloud (e.g., 'backups/data.7z')", + help="Path to the .7z archive file inside Nextcloud (e.g., 'backups\\data.7z')", ) return parser.parse_args() @@ -106,12 +107,16 @@ def backup_and_restore( # Create backup of FFXIV config directory if it exists and has content if os.path.exists(restore_config_path) and os.listdir(restore_config_path): print( - "Creating safety backup of " + "ffxiv" - if is_ffxiv_config - else "plugins" + f" configs at:\n -> {backup_config_path}/game" + "Creating safety backup of " + + f"{'ffxiv' if is_ffxiv_config else 'plugins'} configs at:" + + f"-> {backup_config_path}\\{'game' if is_ffxiv_config else 'plugins'}" ) try: - shutil.copytree(restore_config_path, f"{backup_config_path}/game") + shutil.copytree( + restore_config_path, + f"{backup_config_path}\\{'game' if is_ffxiv_config else 'plugins'}", + dirs_exist_ok=True, + ) shutil.rmtree(restore_config_path) except Exception as e: print(f"Failed during ffxiv config backup creation phase: {e}") @@ -121,7 +126,7 @@ def backup_and_restore( try: print("Attempting to restore ffxiv config backups") shutil.copytree( - f"{TEMP_EXTRACT_PATH}/{"game" if is_ffxiv_config else "plugins"}/", + f"{TEMP_EXTRACT_PATH}\\{"game" if is_ffxiv_config else "plugins"}\\", restore_config_path, dirs_exist_ok=True, ) diff --git a/restore_backup.spec b/restore_backup.spec new file mode 100644 index 0000000..46ab576 --- /dev/null +++ b/restore_backup.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['restore_backup.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='restore_backup', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/restore_ui.py b/restore_ui.py new file mode 100644 index 0000000..dc2706e --- /dev/null +++ b/restore_ui.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 + +import sys +import os +import subprocess +from dotenv import load_dotenv +from datetime import datetime +from nextcloud import NextCloud +from PyQt6.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QTabWidget, + QLabel, + QLineEdit, + QPushButton, + QTextEdit, + QFileDialog, + QMessageBox, + QProgressBar, + QGroupBox, + QFormLayout, + QDialog, + QListWidget, + QListWidgetItem, +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont +from pprint import pprint + + +class WebDAVBrowser(QDialog): + def __init__(self, parent, nc_url, nc_user, nc_pass): + super().__init__(parent) + self.setWindowTitle("Browse Nextcloud") + self.setGeometry(100, 100, 600, 400) + self.nc_url = nc_url + self.nc_user = nc_user + self.nc_pass = nc_pass + self.selected_path = None + self.current_path = "/" + + try: + self.nc = NextCloud(nc_url, user=nc_user, password=nc_pass) + self.nc.login() + except Exception as e: + QMessageBox.critical( + self, "Connection Error", f"Failed to connect to Nextcloud: {str(e)}" + ) + self.reject() + return + + self.init_ui() + self.load_directory(self.current_path) + + def init_ui(self): + layout = QVBoxLayout() + + # Path display and navigation + nav_layout = QHBoxLayout() + self.path_label = QLabel(self.current_path) + nav_layout.addWidget(QLabel("Current Path:"), 0) + nav_layout.addWidget(self.path_label, 1) + + up_btn = QPushButton("Up") + up_btn.clicked.connect(self.go_up) + nav_layout.addWidget(up_btn) + + layout.addLayout(nav_layout) + + # File list + self.file_list = QListWidget() + self.file_list.itemDoubleClicked.connect(self.item_double_clicked) + layout.addWidget(self.file_list) + + # Buttons + btn_layout = QHBoxLayout() + select_btn = QPushButton("Select") + select_btn.clicked.connect(self.select_file) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + + btn_layout.addStretch() + btn_layout.addWidget(select_btn) + btn_layout.addWidget(cancel_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + def load_directory(self, path): + try: + self.file_list.clear() + self.current_path = path + self.path_label.setText(path) + + response = self.nc.list_folders(path=path) # type: ignore + pprint(response.json_data) + + # Try to extract data from webDavResponse + file_list = [] + if hasattr(response, "__iter__") and not isinstance(response, (str, bytes)): + try: + file_list = list(response) + except TypeError: + # If direct iteration fails, try accessing as dict keys + if isinstance(response, dict): + file_list = list(response.keys()) + elif hasattr(response, "data"): + file_list = ( + response.data + if isinstance(response.data, list) + else list(response.data) + ) + + for item in sorted(file_list): + name = str(item).rstrip("/") + if name: + is_dir = str(item).endswith("/") + display_name = f"[DIR] {name}" if is_dir else name + list_item = QListWidgetItem(display_name) + list_item.setData(Qt.ItemDataRole.UserRole, (name, is_dir)) + self.file_list.addItem(list_item) + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load directory: {str(e)}") + + def item_double_clicked(self, item): + name, is_dir = item.data(Qt.ItemDataRole.UserRole) + if is_dir: + new_path = self.current_path.rstrip("/") + "/" + name + self.load_directory(new_path) + + def go_up(self): + if self.current_path != "/": + parent = "/".join(self.current_path.rstrip("/").split("/")[:-1]) or "/" + self.load_directory(parent) + + def select_file(self): + current_item = self.file_list.currentItem() + if current_item: + name, is_dir = current_item.data(Qt.ItemDataRole.UserRole) + if not is_dir: + self.selected_path = self.current_path.rstrip("/") + "/" + name + self.accept() + else: + QMessageBox.warning( + self, "Invalid Selection", "Please select a file, not a directory" + ) + else: + QMessageBox.warning(self, "No Selection", "Please select a file") + + +class RestoreWorker(QThread): + output_signal = pyqtSignal(str) + finished_signal = pyqtSignal(bool, str) + + def __init__(self, remote_path): + super().__init__() + self.remote_path = remote_path + + def run(self): + try: + # Run the restore script with the remote path + cmd = [sys.executable, "restore_backup.py", "-r", self.remote_path] + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=os.path.dirname(os.path.abspath(__file__)), + ) + + for line in process.stdout: + self.output_signal.emit(line.rstrip()) + + process.wait() + + if process.returncode == 0: + self.finished_signal.emit(True, "Restore completed successfully!") + else: + self.finished_signal.emit( + False, f"Restore failed with return code {process.returncode}" + ) + + except Exception as e: + self.finished_signal.emit(False, f"Error: {str(e)}") + + +class RestoreUI(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("FFXIV Backup Restore") + self.setGeometry(100, 100, 900, 700) + + load_dotenv() + self.restore_worker = None + + self.init_ui() + + def init_ui(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout() + + tabs = QTabWidget() + tabs.addTab(self.create_settings_tab(), "Settings") + tabs.addTab(self.create_restore_tab(), "Restore") + + layout.addWidget(tabs) + central_widget.setLayout(layout) + + def create_settings_tab(self): + widget = QWidget() + layout = QVBoxLayout() + + # Nextcloud Section + nc_group = QGroupBox("Nextcloud Configuration") + nc_layout = QFormLayout() + + self.nc_url_input = QLineEdit(os.getenv("NC_URL", "")) + self.nc_user_input = QLineEdit(os.getenv("NC_USER", "")) + self.nc_pass_input = QLineEdit(os.getenv("NC_PASS", "")) + self.nc_pass_input.setEchoMode(QLineEdit.EchoMode.Password) + + nc_layout.addRow("Nextcloud URL:", self.nc_url_input) + nc_layout.addRow("Username:", self.nc_user_input) + nc_layout.addRow("Password:", self.nc_pass_input) + nc_group.setLayout(nc_layout) + + # Paths Section + paths_group = QGroupBox("Local Paths") + paths_layout = QVBoxLayout() + + # Temp directory (with browse) + self.temp_dir_input = QLineEdit( + os.path.dirname(os.getenv("NC_TEMP_DOWNLOAD", "")) + ) + paths_layout.addLayout( + self._create_path_row( + "Temp Directory:", self.temp_dir_input, self.browse_temp_directory + ) + ) + + # Temp filenames + self.temp_download_name_input = QLineEdit( + os.path.basename(os.getenv("NC_TEMP_DOWNLOAD", "temp_download.7z")) + ) + temp_download_layout = QHBoxLayout() + temp_download_layout.addWidget(QLabel("Temp Download Filename:"), 1) + temp_download_layout.addWidget(self.temp_download_name_input, 3) + paths_layout.addLayout(temp_download_layout) + + self.temp_extract_name_input = QLineEdit( + os.path.basename(os.getenv("TEMP_EXTRACT_PATH", "temp_extract")) + ) + temp_extract_layout = QHBoxLayout() + temp_extract_layout.addWidget(QLabel("Temp Extract Subdirectory:"), 1) + temp_extract_layout.addWidget(self.temp_extract_name_input, 3) + paths_layout.addLayout(temp_extract_layout) + + # Other paths (with browse) + self.temp_backup_input = QLineEdit(os.getenv("TEMP_BACKUP_PATH", "")) + self.plugin_configs_input = QLineEdit(os.getenv("PLUGIN_CONFIGS_PATH", "")) + self.ffxiv_configs_input = QLineEdit(os.getenv("FFXIV_CONFIGS_PATH", "")) + + paths_layout.addLayout( + self._create_path_row( + "Temp Backup:", self.temp_backup_input, self.browse_temp_backup + ) + ) + paths_layout.addLayout( + self._create_path_row( + "Plugin Configs:", self.plugin_configs_input, self.browse_plugin_configs + ) + ) + paths_layout.addLayout( + self._create_path_row( + "FFXIV Configs:", self.ffxiv_configs_input, self.browse_ffxiv_configs + ) + ) + + paths_group.setLayout(paths_layout) + + layout.addWidget(nc_group) + layout.addWidget(paths_group) + layout.addStretch() + + widget.setLayout(layout) + return widget + + def _create_path_row(self, label, input_field, browse_callback): + row_layout = QHBoxLayout() + row_layout.addWidget(QLabel(label), 1) + row_layout.addWidget(input_field, 3) + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(browse_callback) + row_layout.addWidget(browse_btn) + return row_layout + + def browse_temp_directory(self): + path = QFileDialog.getExistingDirectory(self, "Select Temp Directory") + if path: + self.temp_dir_input.setText(path) + + def browse_temp_backup(self): + path = QFileDialog.getExistingDirectory(self, "Select Temp Backup Directory") + if path: + self.temp_backup_input.setText(path) + + def browse_plugin_configs(self): + path = QFileDialog.getExistingDirectory(self, "Select Plugin Configs Directory") + if path: + self.plugin_configs_input.setText(path) + + def browse_ffxiv_configs(self): + path = QFileDialog.getExistingDirectory(self, "Select FFXIV Configs Directory") + if path: + self.ffxiv_configs_input.setText(path) + + def create_restore_tab(self): + widget = QWidget() + layout = QVBoxLayout() + + # Remote path input + remote_group = QGroupBox("Select Backup to Restore") + remote_layout = QHBoxLayout() + + QLabel("Remote Archive Path:").setFont(QFont("Arial", 10)) + self.remote_path_input = QLineEdit() + self.remote_path_input.setPlaceholderText( + "e.g., Backups/FFXIV/Desktop/backup_file.7z" + ) + + remote_layout.addWidget(QLabel("Remote Archive Path:")) + remote_layout.addWidget(self.remote_path_input) + + browse_webdav_btn = QPushButton("Browse WebDAV") + browse_webdav_btn.clicked.connect(self.browse_webdav) + remote_layout.addWidget(browse_webdav_btn) + + remote_group.setLayout(remote_layout) + layout.addWidget(remote_group) + + # Output display + output_group = QGroupBox("Operation Progress") + output_layout = QVBoxLayout() + + self.output_text = QTextEdit() + self.output_text.setReadOnly(True) + self.output_text.setFont(QFont("Courier New", 9)) + + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + + output_layout.addWidget(self.output_text) + output_layout.addWidget(self.progress_bar) + + output_group.setLayout(output_layout) + layout.addWidget(output_group) + + # Control buttons + button_layout = QHBoxLayout() + + self.restore_button = QPushButton("Start Restore") + self.restore_button.clicked.connect(self.start_restore) + + self.clear_button = QPushButton("Clear Output") + self.clear_button.clicked.connect(self.output_text.clear) + + button_layout.addWidget(self.restore_button) + button_layout.addWidget(self.clear_button) + button_layout.addStretch() + + layout.addLayout(button_layout) + + widget.setLayout(layout) + return widget + + def browse_webdav(self): + nc_url = self.nc_url_input.text().strip() + nc_user = self.nc_user_input.text().strip() + nc_pass = self.nc_pass_input.text().strip() + + if not all([nc_url, nc_user, nc_pass]): + QMessageBox.warning( + self, + "Missing Credentials", + "Please configure Nextcloud credentials in Settings tab first", + ) + return + + browser = WebDAVBrowser(self, nc_url, nc_user, nc_pass) + if browser.exec() == QDialog.DialogCode.Accepted and browser.selected_path: + self.remote_path_input.setText(browser.selected_path) + + def start_restore(self): + remote_path = self.remote_path_input.text().strip() + + if not remote_path: + QMessageBox.warning( + self, "Input Required", "Please enter the remote archive path" + ) + return + + self.output_text.clear() + self.progress_bar.setVisible(True) + self.progress_bar.setMaximum(0) + self.restore_button.setEnabled(False) + + self.output_text.append( + f"[{datetime.now().strftime('%H:%M:%S')}] Starting restore process..." + ) + self.output_text.append(f"Remote path: {remote_path}\n") + + self.restore_worker = RestoreWorker(remote_path) + self.restore_worker.output_signal.connect(self.append_output) + self.restore_worker.finished_signal.connect(self.restore_finished) + self.restore_worker.start() + + def append_output(self, text): + self.output_text.append(text) + self.output_text.verticalScrollBar().setValue( + self.output_text.verticalScrollBar().maximum() + ) + + def restore_finished(self, success, message): + self.progress_bar.setVisible(False) + self.restore_button.setEnabled(True) + + timestamp = datetime.now().strftime("%H:%M:%S") + self.output_text.append(f"\n[{timestamp}] {message}") + + if success: + QMessageBox.information(self, "Success", message) + else: + QMessageBox.critical(self, "Error", message) + + +def main(): + app = QApplication(sys.argv) + window = RestoreUI() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/restore_ui.spec b/restore_ui.spec new file mode 100644 index 0000000..175412d --- /dev/null +++ b/restore_ui.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis( + ['restore_ui.py'], + pathex=[], + binaries=[], + datas=[('.env', '.')], # Include .env file + hiddenimports=['PyQt6.QtCore', 'PyQt6.QtGui', 'PyQt6.QtWidgets'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludedimports=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='FFXIV-Backup-Restore', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='icon.ico' if os.path.exists('icon.ico') else None, +) + +import os