feat: Add modpack import/export functionality
This commit is contained in:
1 parent
f780daecad
commit
848dac4264
7 files changed
+257
-1
No files matched your search
@@ -2,3 +2,8 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
AppDir/
|
||||||
|
appimage-build/
|
||||||
|
appimage-builder-cache/
|
||||||
|
*.AppImage
|
||||||
|
*.zsync
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
AppDir:
|
||||||
|
path: ./AppDir
|
||||||
|
app_info:
|
||||||
|
id: ca.schantli.MHWildsModManager
|
||||||
|
name: MHWildsModManager
|
||||||
|
icon: ca.schantli.MHWildsModManager
|
||||||
|
version: 1.0.0
|
||||||
|
exec: usr/bin/python3
|
||||||
|
exec_args: -m mhwmm $@
|
||||||
|
apt:
|
||||||
|
arch: amd64
|
||||||
|
allow_unauthenticated: true
|
||||||
|
sources:
|
||||||
|
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ noble main restricted universe multiverse'
|
||||||
|
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ noble-updates main restricted universe multiverse'
|
||||||
|
include:
|
||||||
|
- python3
|
||||||
|
- python3-gi
|
||||||
|
- gir1.2-gtk-4.0
|
||||||
|
- gir1.2-adw-1
|
||||||
|
- libglib2.0-bin
|
||||||
|
- libgtk-4-1
|
||||||
|
- libadwaita-1-0
|
||||||
|
- adwaita-icon-theme
|
||||||
|
- python3-requests
|
||||||
|
files:
|
||||||
|
include: []
|
||||||
|
exclude:
|
||||||
|
- usr/share/man
|
||||||
|
- usr/share/doc/*/README.*
|
||||||
|
- usr/share/doc/*/changelog.*
|
||||||
|
- usr/share/doc/*/copyright
|
||||||
|
- usr/share/doc/*/TODO.*
|
||||||
|
runtime:
|
||||||
|
env:
|
||||||
|
PYTHONPATH: '${APPDIR}/usr/lib/python3.12/site-packages:${APPDIR}/usr/share/mhwmm'
|
||||||
|
|
||||||
|
AppImage:
|
||||||
|
update-information: None
|
||||||
|
sign-key: None
|
||||||
|
arch: x86_64
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Cleaning old AppDir..."
|
||||||
|
rm -rf AppDir MHWildsModManager*.AppImage
|
||||||
|
|
||||||
|
echo "Creating AppDir structure..."
|
||||||
|
mkdir -p AppDir/usr/bin
|
||||||
|
mkdir -p AppDir/usr/share/mhwmm
|
||||||
|
mkdir -p AppDir/usr/share/applications
|
||||||
|
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||||
|
|
||||||
|
echo "Copying source files..."
|
||||||
|
cp -r src/mhwmm/* AppDir/usr/share/mhwmm/
|
||||||
|
cat << 'WRAPPER' > AppDir/usr/bin/mhwmm
|
||||||
|
#!/bin/bash
|
||||||
|
export PYTHONPATH="${APPDIR}/usr/share/mhwmm:${PYTHONPATH}"
|
||||||
|
exec python3 -m mhwmm "$@"
|
||||||
|
WRAPPER
|
||||||
|
chmod +x AppDir/usr/bin/mhwmm
|
||||||
|
|
||||||
|
echo "Copying desktop and icon..."
|
||||||
|
cp data/ca.schantli.MHWildsModManager.desktop AppDir/usr/share/applications/
|
||||||
|
cp data/ca.schantli.MHWildsModManager.png AppDir/usr/share/icons/hicolor/256x256/apps/
|
||||||
|
cp data/ca.schantli.MHWildsModManager.png AppDir/ca.schantli.MHWildsModManager.png
|
||||||
|
|
||||||
|
python3 -c "
|
||||||
|
with open('AppDir/usr/share/applications/ca.schantli.MHWildsModManager.desktop', 'r') as df:
|
||||||
|
d = df.read()
|
||||||
|
d = d.replace('Exec=bin/mhwmm', 'Exec=python3')
|
||||||
|
with open('AppDir/usr/share/applications/ca.schantli.MHWildsModManager.desktop', 'w') as df:
|
||||||
|
df.write(d)
|
||||||
|
"
|
||||||
|
echo "Building AppImage using Podman (ubuntu:24.04)..."
|
||||||
|
|
||||||
|
podman run --rm -i --privileged -v $(pwd):/workspace -w /workspace ubuntu:24.04 bash -c "apt-get update && apt-get install -y wget file squashfs-tools binutils gnupg2 gtk-update-icon-cache && wget -q -O /tmp/appimage-builder https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage && chmod +x /tmp/appimage-builder && cd /tmp && ./appimage-builder --appimage-extract && cd /workspace && /tmp/squashfs-root/AppRun --recipe AppImageBuilder.yml --skip-test"
|
||||||
|
|
||||||
|
echo "Done! AppImage is ready."
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 739 KiB |
@@ -14,6 +14,7 @@ from .layout import PAK, Classification
|
|||||||
from .library import Library, Mod
|
from .library import Library, Mod
|
||||||
from .paths import Paths
|
from .paths import Paths
|
||||||
from .profiles import Profiles
|
from .profiles import Profiles
|
||||||
|
from .modpack import export_modpack, import_modpack
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -185,6 +186,17 @@ class Manager:
|
|||||||
self.profiles.save()
|
self.profiles.save()
|
||||||
return self.deploy() if self.deployer else None
|
return self.deploy() if self.deployer else None
|
||||||
|
|
||||||
|
def export_modpack(self, dest_path: Path, profile_name: str | None = None) -> None:
|
||||||
|
profile_name = profile_name or self.profiles.active
|
||||||
|
export_modpack(self, dest_path, profile_name)
|
||||||
|
|
||||||
|
def import_modpack(self, source_path: Path, profile_name: str | None = None) -> list[str]:
|
||||||
|
profile_name = profile_name or source_path.stem
|
||||||
|
res = import_modpack(self, source_path, profile_name)
|
||||||
|
if self.deployer and self.settings.auto_deploy:
|
||||||
|
self.deploy()
|
||||||
|
return res
|
||||||
|
|
||||||
def conflicts(self) -> list[Conflict]:
|
def conflicts(self) -> list[Conflict]:
|
||||||
return self.last_desired.conflicts if self.baseline else []
|
return self.last_desired.conflicts if self.baseline else []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .library import Mod, LibraryError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .manager import Manager
|
||||||
|
|
||||||
|
def export_modpack(manager: Manager, dest_path: Path, profile_name: str) -> None:
|
||||||
|
profile = manager.profiles.data.get(profile_name)
|
||||||
|
if not profile:
|
||||||
|
raise ValueError(f"Profile {profile_name} not found")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(dest_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# Export profile metadata
|
||||||
|
zf.writestr("modpack.json", json.dumps({"name": profile_name, "profile": profile}, indent=2))
|
||||||
|
|
||||||
|
# Export mods
|
||||||
|
mods_to_export = set(profile["order"])
|
||||||
|
for mod_id in mods_to_export:
|
||||||
|
mod = manager.library.get(mod_id)
|
||||||
|
if not mod:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Write mod files
|
||||||
|
mod_root = mod.root
|
||||||
|
for dirpath, dirnames, filenames in os.walk(mod_root):
|
||||||
|
for filename in filenames:
|
||||||
|
file_path = Path(dirpath) / filename
|
||||||
|
rel_path = file_path.relative_to(mod_root)
|
||||||
|
zf.write(file_path, f"mods/{mod_id}/{rel_path}")
|
||||||
|
|
||||||
|
def import_modpack(manager: Manager, source_path: Path, new_profile_name: str) -> list[str]:
|
||||||
|
with zipfile.ZipFile(source_path, "r") as zf:
|
||||||
|
try:
|
||||||
|
with zf.open("modpack.json") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
profile = data.get("profile")
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError("Not a valid modpack: missing modpack.json")
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
raise ValueError("Invalid modpack.json")
|
||||||
|
|
||||||
|
# Extract mods
|
||||||
|
extracted_mods = []
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.startswith("mods/") and len(name.split("/")) > 2:
|
||||||
|
parts = name.split("/")
|
||||||
|
mod_id = parts[1]
|
||||||
|
if mod_id not in manager.library.mods:
|
||||||
|
# Not in library, we need to extract it
|
||||||
|
dest_file = manager.paths.mods_dir / Path(*parts[1:])
|
||||||
|
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not name.endswith("/"):
|
||||||
|
with zf.open(name) as source, open(dest_file, "wb") as target:
|
||||||
|
shutil.copyfileobj(source, target)
|
||||||
|
if mod_id not in extracted_mods:
|
||||||
|
extracted_mods.append(mod_id)
|
||||||
|
|
||||||
|
# Reload library to discover the new mods
|
||||||
|
manager.reload_library()
|
||||||
|
|
||||||
|
# Ensure profile name is unique
|
||||||
|
final_name = new_profile_name
|
||||||
|
n = 2
|
||||||
|
while final_name in manager.profiles.data:
|
||||||
|
final_name = f"{new_profile_name} ({n})"
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
manager.profiles.data[final_name] = profile
|
||||||
|
manager.profiles.switch(final_name)
|
||||||
|
manager.profiles.save()
|
||||||
|
|
||||||
|
return extracted_mods
|
||||||
+77
-1
@@ -83,7 +83,8 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
menu.append_section(None, section)
|
menu.append_section(None, section)
|
||||||
prof = Gio.Menu()
|
prof = Gio.Menu()
|
||||||
for label, action in (("New Profile…", "win.new-profile"), ("Rename Profile…", "win.rename-profile"),
|
for label, action in (("New Profile…", "win.new-profile"), ("Rename Profile…", "win.rename-profile"),
|
||||||
("Delete Profile", "win.delete-profile")):
|
("Delete Profile", "win.delete-profile"), ("Export Modpack…", "win.export-modpack"),
|
||||||
|
("Import Modpack…", "win.import-modpack")):
|
||||||
prof.append(label, action)
|
prof.append(label, action)
|
||||||
menu.append_section("Profiles", prof)
|
menu.append_section("Profiles", prof)
|
||||||
tail = Gio.Menu()
|
tail = Gio.Menu()
|
||||||
@@ -151,6 +152,8 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
add("new-profile", self._new_profile)
|
add("new-profile", self._new_profile)
|
||||||
add("rename-profile", self._rename_profile)
|
add("rename-profile", self._rename_profile)
|
||||||
add("delete-profile", self._delete_profile)
|
add("delete-profile", self._delete_profile)
|
||||||
|
add("export-modpack", self._export_modpack)
|
||||||
|
add("import-modpack", self._import_modpack)
|
||||||
add("move-up", lambda: self._move_selected(-1))
|
add("move-up", lambda: self._move_selected(-1))
|
||||||
add("move-down", lambda: self._move_selected(+1))
|
add("move-down", lambda: self._move_selected(+1))
|
||||||
app = self.get_application()
|
app = self.get_application()
|
||||||
@@ -432,6 +435,79 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
return
|
return
|
||||||
confirm(self, f"Delete profile “{name}”?", "Mods themselves are kept.", "Delete", go, destructive=True)
|
confirm(self, f"Delete profile “{name}”?", "Mods themselves are kept.", "Delete", go, destructive=True)
|
||||||
|
|
||||||
|
def _export_modpack(self) -> None:
|
||||||
|
d = Gtk.FileDialog(title="Export Modpack", initial_name=f"{self.m.profiles.active}.zip")
|
||||||
|
def done(dlg, res):
|
||||||
|
try:
|
||||||
|
f = dlg.save_finish(res)
|
||||||
|
except GLib.Error:
|
||||||
|
return
|
||||||
|
path = Path(f.get_path())
|
||||||
|
if not path.name.endswith(".zip"):
|
||||||
|
path = path.with_name(path.name + ".zip")
|
||||||
|
|
||||||
|
self._busy += 1
|
||||||
|
self.toast(f"Exporting modpack {self.m.profiles.active}...", 2)
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
self.m.export_modpack(path)
|
||||||
|
return True, None
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def finish(res, err):
|
||||||
|
self._busy -= 1
|
||||||
|
success, error = res
|
||||||
|
if error:
|
||||||
|
self.toast(f"Export failed: {error}", 10)
|
||||||
|
else:
|
||||||
|
self.toast(f"Modpack exported to {path.name}", 8)
|
||||||
|
|
||||||
|
run_async(work, finish)
|
||||||
|
|
||||||
|
d.save(self, None, done)
|
||||||
|
|
||||||
|
def _import_modpack(self) -> None:
|
||||||
|
d = Gtk.FileDialog(title="Import Modpack")
|
||||||
|
flt = Gtk.FileFilter()
|
||||||
|
flt.set_name("Modpacks (.zip)")
|
||||||
|
flt.add_pattern("*.zip")
|
||||||
|
filters = Gio.ListStore.new(Gtk.FileFilter)
|
||||||
|
filters.append(flt)
|
||||||
|
d.set_filters(filters)
|
||||||
|
d.set_default_filter(flt)
|
||||||
|
|
||||||
|
def done(dlg, res):
|
||||||
|
try:
|
||||||
|
f = dlg.open_finish(res)
|
||||||
|
except GLib.Error:
|
||||||
|
return
|
||||||
|
path = Path(f.get_path())
|
||||||
|
|
||||||
|
self._busy += 1
|
||||||
|
self.toast(f"Importing modpack...", 2)
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
mods = self.m.import_modpack(path)
|
||||||
|
return mods, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
def finish(res, err):
|
||||||
|
self._busy -= 1
|
||||||
|
mods, error = res
|
||||||
|
if error:
|
||||||
|
self.toast(f"Import failed: {error}", 10)
|
||||||
|
else:
|
||||||
|
self.toast(f"Imported modpack with {len(mods)} mods.", 8)
|
||||||
|
self.refresh_all()
|
||||||
|
|
||||||
|
run_async(work, finish)
|
||||||
|
|
||||||
|
d.open(self, None, done)
|
||||||
|
|
||||||
# -- deploy ---------------------------------------------------------------------------------------------
|
# -- deploy ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user