Fix and clean up this ugly ass AI code.

This commit is contained in:
Ada Werefox 2026-06-01 22:52:18 -07:00
parent 133238dfca
commit 66f4752409
8 changed files with 684 additions and 11 deletions

5
.gitignore vendored
View file

@ -3,4 +3,7 @@ FINAL FANTASY XIV - A Realm Reborn/*
pluginConfigs/*
temp/*
*.pyc
.env
.env
build/*
dist/*
.venv/*

116
README_UI.md Normal file
View file

@ -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

13
build.bat Normal file
View file

@ -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

View file

@ -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

23
restore_backup.py Executable file → Normal file
View file

@ -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,
)

38
restore_backup.spec Normal file
View file

@ -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,
)

451
restore_ui.py Normal file
View file

@ -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()

45
restore_ui.spec Normal file
View file

@ -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