|
1 | | -Creating |
2 | | -clean |
3 | | -widget.py |
| 1 | +import os |
| 2 | +from functools import cmp_to_key |
| 3 | +from pathlib import Path |
| 4 | +from typing import cast |
| 5 | + |
| 6 | +from PyQt6.QtCore import QDir, QFileInfo |
| 7 | +from PyQt6.QtWidgets import QGridLayout, QWidget |
| 8 | + |
| 9 | +import mobase |
| 10 | + |
| 11 | +from ....basic_features.utils import is_directory |
| 12 | +from .model import S2HoCPaksModel |
| 13 | +from .view import S2HoCPaksView |
| 14 | + |
| 15 | + |
| 16 | +def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: |
| 17 | + """Sort function for PAK files""" |
| 18 | + if a[0] < b[0]: |
| 19 | + return -1 |
| 20 | + elif a[0] > b[0]: |
| 21 | + return 1 |
| 22 | + else: |
| 23 | + return 0 |
| 24 | + |
| 25 | + |
| 26 | +class S2HoCPaksTabWidget(QWidget): |
| 27 | + """ |
| 28 | + Widget for managing PAK files in Stalker 2: Heart of Chornobyl. |
| 29 | + """ |
| 30 | + |
| 31 | + def __init__(self, parent: QWidget, organizer: mobase.IOrganizer): |
| 32 | + super().__init__(parent) |
| 33 | + self._organizer = organizer |
| 34 | + self._view = S2HoCPaksView(self) |
| 35 | + self._layout = QGridLayout(self) |
| 36 | + self._layout.addWidget(self._view) |
| 37 | + self._model = S2HoCPaksModel(self._view, organizer) |
| 38 | + self._view.setModel(self._model) |
| 39 | + self._model.dataChanged.connect(self.write_paks_list) |
| 40 | + self._view.data_dropped.connect(self.write_paks_list) |
| 41 | + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files()) |
| 42 | + organizer.modList().onModInstalled(lambda mod: self._parse_pak_files()) |
| 43 | + organizer.modList().onModRemoved(lambda mod: self._parse_pak_files()) |
| 44 | + organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) |
| 45 | + self._parse_pak_files() |
| 46 | + |
| 47 | + def load_paks_list(self) -> list[str]: |
| 48 | + profile = QDir(self._organizer.profilePath()) |
| 49 | + paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_paks.txt")) |
| 50 | + paks_list: list[str] = [] |
| 51 | + if paks_txt.exists(): |
| 52 | + try: |
| 53 | + with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8") as paks_file: |
| 54 | + for line in paks_file: |
| 55 | + stripped_line = line.strip() |
| 56 | + if stripped_line: |
| 57 | + paks_list.append(stripped_line) |
| 58 | + except (IOError, OSError): |
| 59 | + pass |
| 60 | + return paks_list |
| 61 | + |
| 62 | + def write_paks_list(self): |
| 63 | + """Write the PAK list to file and then move the files""" |
| 64 | + profile = QDir(self._organizer.profilePath()) |
| 65 | + paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_paks.txt")) |
| 66 | + try: |
| 67 | + with open(paks_txt.absoluteFilePath(), "w", encoding="utf-8") as paks_file: |
| 68 | + for _, pak in sorted(self._model.paks.items()): |
| 69 | + name, _, _, _ = pak |
| 70 | + paks_file.write(f"{name}\n") |
| 71 | + self.write_pak_files() |
| 72 | + except (IOError, OSError) as e: |
| 73 | + print(f"Error writing PAK list: {e}") |
| 74 | + |
| 75 | + def write_pak_files(self): |
| 76 | + """Move PAK files to their target numbered directories""" |
| 77 | + for index, pak in sorted(self._model.paks.items()): |
| 78 | + _, _, current_path, target_path = pak |
| 79 | + if current_path and current_path != target_path: |
| 80 | + path_dir = Path(current_path) |
| 81 | + target_dir = Path(target_path) |
| 82 | + if not target_dir.exists(): |
| 83 | + target_dir.mkdir(parents=True, exist_ok=True) |
| 84 | + if path_dir.exists(): |
| 85 | + for pak_file in path_dir.glob("*.pak"): |
| 86 | + ucas_file = pak_file.with_suffix(".ucas") |
| 87 | + utoc_file = pak_file.with_suffix(".utoc") |
| 88 | + for file in (pak_file, ucas_file, utoc_file): |
| 89 | + if not file.exists(): |
| 90 | + continue |
| 91 | + try: |
| 92 | + file.rename(target_dir.joinpath(file.name)) |
| 93 | + except FileExistsError: |
| 94 | + pass |
| 95 | + data = self._model.paks[index] |
| 96 | + self._model.paks[index] = ( |
| 97 | + data[0], |
| 98 | + data[1], |
| 99 | + data[3], |
| 100 | + data[3], |
| 101 | + ) |
| 102 | + break |
| 103 | + if not list(path_dir.iterdir()): |
| 104 | + path_dir.rmdir() |
| 105 | + |
| 106 | + def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: |
| 107 | + """Preserve order from paks.txt if it exists, otherwise use alphabetical""" |
| 108 | + shaken_paks: list[str] = [] |
| 109 | + shaken_paks_p: list[str] = [] |
| 110 | + paks_list = self.load_paks_list() |
| 111 | + for pak in paks_list: |
| 112 | + if pak in sorted_paks.keys(): |
| 113 | + if pak.casefold().endswith("_p"): |
| 114 | + shaken_paks_p.append(pak) |
| 115 | + else: |
| 116 | + shaken_paks.append(pak) |
| 117 | + sorted_paks.pop(pak) |
| 118 | + for pak in sorted_paks.keys(): |
| 119 | + if pak.casefold().endswith("_p"): |
| 120 | + shaken_paks_p.append(pak) |
| 121 | + else: |
| 122 | + shaken_paks.append(pak) |
| 123 | + return shaken_paks + shaken_paks_p |
| 124 | + |
| 125 | + def _parse_pak_files(self): |
| 126 | + """Parse PAK files from mods, following numbered folder assignment pattern""" |
| 127 | + from ...game_stalker2heartofchornobyl import S2HoCGame |
| 128 | + |
| 129 | + mods = self._organizer.modList().allMods() |
| 130 | + paks: dict[str, str] = {} |
| 131 | + pak_paths: dict[str, tuple[str, str]] = {} |
| 132 | + pak_source: dict[str, str] = {} |
| 133 | + existing_folders: set[int] = set() |
| 134 | + |
| 135 | + game = self._organizer.managedGame() |
| 136 | + if isinstance(game, S2HoCGame): |
| 137 | + pak_mods_dir = QFileInfo(game.paksModsDirectory().absolutePath()) |
| 138 | + if pak_mods_dir.exists() and pak_mods_dir.isDir(): |
| 139 | + for entry in QDir(pak_mods_dir.absoluteFilePath()).entryInfoList( |
| 140 | + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot |
| 141 | + ): |
| 142 | + try: |
| 143 | + folder_num = int(entry.completeBaseName()) |
| 144 | + existing_folders.add(folder_num) |
| 145 | + except ValueError: |
| 146 | + pass |
| 147 | + |
| 148 | + for mod in mods: |
| 149 | + mod_item = self._organizer.modList().getMod(mod) |
| 150 | + if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: |
| 151 | + continue |
| 152 | + filetree = mod_item.fileTree() |
| 153 | + |
| 154 | + has_logicmods = ( |
| 155 | + filetree.find("Content/Paks/LogicMods") |
| 156 | + or filetree.find("Paks/LogicMods") |
| 157 | + ) |
| 158 | + if isinstance(has_logicmods, mobase.IFileTree): |
| 159 | + continue |
| 160 | + |
| 161 | + pak_mods = filetree.find("Paks/~mods") |
| 162 | + if not pak_mods: |
| 163 | + pak_mods = filetree.find("Content/Paks/~mods") |
| 164 | + if isinstance(pak_mods, mobase.IFileTree) and pak_mods.name() == "~mods": |
| 165 | + for entry in pak_mods: |
| 166 | + if is_directory(entry): |
| 167 | + for sub_entry in entry: |
| 168 | + if ( |
| 169 | + sub_entry.isFile() |
| 170 | + and sub_entry.suffix().casefold() == "pak" |
| 171 | + ): |
| 172 | + pak_name = sub_entry.name()[ |
| 173 | + : -1 - len(sub_entry.suffix()) |
| 174 | + ] |
| 175 | + paks[pak_name] = entry.name() |
| 176 | + pak_paths[pak_name] = ( |
| 177 | + mod_item.absolutePath() |
| 178 | + + "/" |
| 179 | + + cast( |
| 180 | + mobase.IFileTree, sub_entry.parent() |
| 181 | + ).path("/"), |
| 182 | + mod_item.absolutePath() + "/" + pak_mods.path("/"), |
| 183 | + ) |
| 184 | + pak_source[pak_name] = mod_item.name() |
| 185 | + else: |
| 186 | + if entry.suffix().casefold() == "pak": |
| 187 | + pak_name = entry.name()[: -1 - len(entry.suffix())] |
| 188 | + paks[pak_name] = "" |
| 189 | + pak_paths[pak_name] = ( |
| 190 | + mod_item.absolutePath() |
| 191 | + + "/" |
| 192 | + + cast(mobase.IFileTree, entry.parent()).path("/"), |
| 193 | + mod_item.absolutePath() + "/" + pak_mods.path("/"), |
| 194 | + ) |
| 195 | + pak_source[pak_name] = mod_item.name() |
| 196 | + |
| 197 | + sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) |
| 198 | + shaken_paks: list[str] = self._shake_paks(sorted_paks) |
| 199 | + |
| 200 | + final_paks: dict[str, tuple[str, str, str]] = {} |
| 201 | + pak_index = 8999 |
| 202 | + |
| 203 | + for pak in shaken_paks: |
| 204 | + while pak_index in existing_folders: |
| 205 | + pak_index -= 1 |
| 206 | + |
| 207 | + current_folder = paks[pak] |
| 208 | + if current_folder.isdigit(): |
| 209 | + target_dir = pak_paths[pak][1] + "/" + current_folder |
| 210 | + existing_folders.add(int(current_folder)) |
| 211 | + else: |
| 212 | + target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) |
| 213 | + existing_folders.add(pak_index) |
| 214 | + pak_index -= 1 |
| 215 | + |
| 216 | + final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) |
| 217 | + |
| 218 | + new_data_paks: dict[int, tuple[str, str, str, str]] = {} |
| 219 | + i = 0 |
| 220 | + for pak, data in final_paks.items(): |
| 221 | + source, current_path, target_path = data |
| 222 | + new_data_paks[i] = (pak, source, current_path, target_path) |
| 223 | + i += 1 |
| 224 | + |
| 225 | + self._model.set_paks(new_data_paks) |
0 commit comments