feat: Add modpack import/export functionality

This commit is contained in:
jschantli committed 2026-09-30 19:09:26 -07:00
1 parent f780daecad
commit 848dac4264
7 files changed
+257 -1

No files matched your search

+12
View File
@@ -14,6 +14,7 @@ from .layout import PAK, Classification
from .library import Library, Mod
from .paths import Paths
from .profiles import Profiles
from .modpack import export_modpack, import_modpack
@dataclass
@@ -185,6 +186,17 @@ class Manager:
self.profiles.save()
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]:
return self.last_desired.conflicts if self.baseline else []
+82
View File
@@ -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
View File
@@ -83,7 +83,8 @@ class MainWindow(Adw.ApplicationWindow):
menu.append_section(None, section)
prof = Gio.Menu()
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)
menu.append_section("Profiles", prof)
tail = Gio.Menu()
@@ -151,6 +152,8 @@ class MainWindow(Adw.ApplicationWindow):
add("new-profile", self._new_profile)
add("rename-profile", self._rename_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-down", lambda: self._move_selected(+1))
app = self.get_application()
@@ -432,6 +435,79 @@ class MainWindow(Adw.ApplicationWindow):
return
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 ---------------------------------------------------------------------------------------------