From 7ee008e150bc5bcf76082d726f719ee0fdfda982 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 02:37:39 -0600 Subject: Fluorine Manager: full Linux port of Mod Organizer 2 Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 --- libs/basic_games/basic_features/__init__.py | 10 + .../basic_features/basic_local_savegames.py | 27 +++ .../basic_features/basic_mod_data_checker.py | 226 +++++++++++++++++++++ .../basic_features/basic_save_game_info.py | 226 +++++++++++++++++++++ libs/basic_games/basic_features/utils.py | 7 + 5 files changed, 496 insertions(+) create mode 100644 libs/basic_games/basic_features/__init__.py create mode 100644 libs/basic_games/basic_features/basic_local_savegames.py create mode 100644 libs/basic_games/basic_features/basic_mod_data_checker.py create mode 100644 libs/basic_games/basic_features/basic_save_game_info.py create mode 100644 libs/basic_games/basic_features/utils.py (limited to 'libs/basic_games/basic_features') diff --git a/libs/basic_games/basic_features/__init__.py b/libs/basic_games/basic_features/__init__.py new file mode 100644 index 0000000..9326dd9 --- /dev/null +++ b/libs/basic_games/basic_features/__init__.py @@ -0,0 +1,10 @@ +from .basic_local_savegames import BasicLocalSavegames +from .basic_mod_data_checker import BasicModDataChecker, GlobPatterns +from .basic_save_game_info import BasicGameSaveGameInfo + +__all__ = [ + "BasicModDataChecker", + "BasicGameSaveGameInfo", + "GlobPatterns", + "BasicLocalSavegames", +] diff --git a/libs/basic_games/basic_features/basic_local_savegames.py b/libs/basic_games/basic_features/basic_local_savegames.py new file mode 100644 index 0000000..e0991f8 --- /dev/null +++ b/libs/basic_games/basic_features/basic_local_savegames.py @@ -0,0 +1,27 @@ +from PyQt6.QtCore import QDir + +import mobase + + +class BasicLocalSavegames(mobase.LocalSavegames): + _game: mobase.IPluginGame + + def __init__(self, game: mobase.IPluginGame): + super().__init__() + self._game = game + + def game_save_dir(self) -> str: + return self._game.savesDirectory().absolutePath() + + def mappings(self, profile_save_dir: QDir): + return [ + mobase.Mapping( + source=profile_save_dir.absolutePath(), + destination=self.game_save_dir(), + is_directory=True, + create_target=True, + ) + ] + + def prepareProfile(self, profile: mobase.IProfile) -> bool: + return profile.localSavesEnabled() diff --git a/libs/basic_games/basic_features/basic_mod_data_checker.py b/libs/basic_games/basic_features/basic_mod_data_checker.py new file mode 100644 index 0000000..1d061eb --- /dev/null +++ b/libs/basic_games/basic_features/basic_mod_data_checker.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import fnmatch +import re +from dataclasses import dataclass, field +from typing import Iterable, Literal + +import mobase + +from .utils import is_directory + + +class OptionalRegexPattern: + _pattern: re.Pattern[str] | None + + def __init__(self, globs: Iterable[str] | None) -> None: + if globs is None: + self._pattern = None + else: + self._pattern = OptionalRegexPattern.regex_from_glob_list(globs) + + @staticmethod + def regex_from_glob_list(glob_list: Iterable[str]) -> re.Pattern[str]: + """ + Returns a regex pattern form a list of glob patterns. + + Every pattern has a capturing group, so that `match.lastindex - 1` will + give the `glob_list` index. + """ + return re.compile( + "|".join(f"({fnmatch.translate(f)})" for f in glob_list), re.I + ) + + def match(self, value: str) -> bool: + if self._pattern is None: + return False + return bool(self._pattern.match(value)) + + +class RegexPatterns: + """ + Regex patterns for validation in `BasicModDataChecker`. + """ + + def __init__(self, globs: GlobPatterns) -> None: + self.unfold = OptionalRegexPattern(globs.unfold) + self.delete = OptionalRegexPattern(globs.delete) + self.valid = OptionalRegexPattern(globs.valid) + + self.move = { + key: re.compile(fnmatch.translate(key), re.I) for key in globs.move + } + self.ignore = OptionalRegexPattern(globs.ignore) + + def move_match(self, value: str) -> str | None: + """ + Retrieve the first move patterns that matches the given value, or None if no + move matches. + """ + for key, pattern in self.move.items(): + if pattern.match(value): + return key + return None + + +def _merge_list(l1: list[str] | None, l2: list[str] | None) -> list[str] | None: + if l1 is None and l2 is None: + return None + + return (l1 or []) + (l2 or []) + + +@dataclass(frozen=True, unsafe_hash=True) +class GlobPatterns: + """ + See: `BasicModDataChecker` + """ + + unfold: list[str] | None = None + valid: list[str] | None = None + delete: list[str] | None = None + move: dict[str, str] = field(default_factory=dict[str, str]) + ignore: list[str] | None = None + + def merge( + self, other: GlobPatterns, mode: Literal["merge", "replace"] = "replace" + ) -> GlobPatterns: + """ + Construct a new GlobPatterns by merging the current one with the given one. + + There are two different modes: + - 'merge': In this mode, unfold/valid/delete are concatenated and move + will contain the union of key from self and other, with values from other + overriding common keys. + - 'replace': The merged object will contains attributes from other, except + for None attributes taken from self. + + Args: + other: Other patterns to "merge" with this one. + mode: Merge mode. + + Returns: + A new glob pattern representing the merge of this one with other. + """ + if mode == "merge": + return GlobPatterns( + unfold=_merge_list(self.unfold, other.unfold), + valid=_merge_list(self.valid, other.valid), + delete=_merge_list(self.delete, other.delete), + move=self.move | other.move, + ignore=_merge_list(self.ignore, other.ignore), + ) + else: + return GlobPatterns( + unfold=other.unfold or self.unfold, + valid=other.valid or self.valid, + delete=other.delete or self.delete, + move=other.move or self.move, + ignore=other.ignore or self.ignore, + ) + + +class BasicModDataChecker(mobase.ModDataChecker): + """Game feature that is used to check and fix the content of a data tree + via simple file definitions. + + The file definitions support glob pattern (without subfolders) and are + checked and fixed in definition order of the `file_patterns` dict. + + Args: + file_patterns (optional): A GlobPatterns object, with the following attributes: + ignore: [ "list of files and folders to ignore." ] + # Check result: unchanged + unfold: [ "list of folders to unfold" ], + # (remove and move contents to parent), after being checked and + # fixed recursively. + # Check result: `mobase.ModDataChecker.VALID`. + + valid: [ "list of files and folders in the right path." ], + # Check result: `mobase.ModDataChecker.VALID`. + + delete: [ "list of files/folders to delete." ], + # Check result: `mobase.ModDataChecker.FIXABLE`. + + move: {"Files/folders to move": "target path"} + # If the path ends with `/` or `\\`, the entry will be inserted + # in the corresponding directory instead of replacing it. + # Check result: `mobase.ModDataChecker.FIXABLE`. + + Example: + + BasicModDataChecker( + GlobPatterns( + valid=["valid_folder", "*.ext1"] + move={"*.ext2": "path/to/target_folder/"} + ) + ) + + See Also: + `mobase.IFileTree.move` for the `"move"` target path specs. + """ + + _file_patterns: GlobPatterns + """Private `file_patterns`, updated together with `._regex` and `._move_targets`.""" + + _regex_patterns: RegexPatterns + """The regex patterns derived from the file (glob) patterns.""" + + def __init__(self, file_patterns: GlobPatterns | None = None): + super().__init__() + + self._file_patterns = file_patterns or GlobPatterns() + self._regex_patterns = RegexPatterns(self._file_patterns) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + + if rp.ignore.match(name): + continue + if rp.unfold.match(name): + if is_directory(entry): + status = self.dataLooksValid(entry) + else: + status = mobase.ModDataChecker.INVALID + break + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif rp.delete.match(name) or rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + else: + status = mobase.ModDataChecker.INVALID + break + return status + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + rp = self._regex_patterns + for entry in list(filetree): + name = entry.name() + + if rp.ignore.match(name): + continue + # unfold first - if this match, entry is a directory (checked in + # dataLooksValid) + if rp.unfold.match(name): + assert is_directory(entry) + filetree.merge(entry) + entry.detach() + + elif rp.valid.match(name): + continue + + elif rp.delete.match(name): + entry.detach() + + elif (move_key := rp.move_match(name)) is not None: + target = self._file_patterns.move[move_key] + filetree.move(entry, target) + + return filetree diff --git a/libs/basic_games/basic_features/basic_save_game_info.py b/libs/basic_games/basic_features/basic_save_game_info.py new file mode 100644 index 0000000..436e724 --- /dev/null +++ b/libs/basic_games/basic_features/basic_save_game_info.py @@ -0,0 +1,226 @@ +# -*- encoding: utf-8 -*- + +import sys +from collections.abc import Mapping +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Self, Sequence + +from PyQt6.QtCore import QDateTime, QLocale, Qt +from PyQt6.QtGui import QImage, QPixmap +from PyQt6.QtWidgets import QFormLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + +import mobase + + +def format_date(date_time: QDateTime | datetime | str, format_str: str | None = None): + """Default format for date and time in the `BasicGameSaveGameInfoWidget`. + + Args: + date_time: either a `QDateTime`/`datetime` or a string together with + a `format_str`. + format_str (optional): date/time format string (see `QDateTime.fromString`). + + Returns: + Date and time in short locale format. + """ + if isinstance(date_time, str): + date_time = QDateTime.fromString(date_time, format_str) + return QLocale.system().toString(date_time, QLocale.FormatType.ShortFormat) + + +class BasicGameSaveGame(mobase.ISaveGame): + def __init__(self, filepath: Path): + super().__init__() + self._filepath = filepath + + def getFilepath(self) -> str: + return self._filepath.as_posix() + + def getName(self) -> str: + return self._filepath.name + + def getCreationTime(self): + return QDateTime.fromSecsSinceEpoch(int(self._filepath.stat().st_mtime)) + + def getSaveGroupIdentifier(self) -> str: + return "" + + def allFiles(self) -> list[str]: + return [self.getFilepath()] + + +def get_filedate_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + """Returns saves file date as the metadata for `BasicGameSaveGameInfoWidget`.""" + return {"File Date:": format_date(save.getCreationTime())} + + +class BasicGameSaveGameInfoWidget(mobase.ISaveGameInfoWidget): + """Save game info widget to display metadata and a preview.""" + + def __init__( + self, + parent: QWidget | None, + get_preview: ( + Callable[[Path], QPixmap | QImage | Path | str | None] | None + ) = lambda p: None, + get_metadata: ( + Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] | None + ) = get_filedate_metadata, + max_width: int = 320, + ): + """ + Args: + parent: parent widget + get_preview (optional): `callback(savegame_path)` returning the + saves preview image or the path to it. + get_metadata (optional): `callback(savegame_path, ISaveGame)` returning + the saves metadata. By default the saves file date is shown. + max_width (optional): The maximum widget and (scaled) preview width. + Defaults to 320. + """ + super().__init__(parent) + + def _no_preview(p: Path) -> None: + return None + + self._get_preview = get_preview or _no_preview + self._get_metadata = get_metadata or get_filedate_metadata + self._max_width = max_width or 320 + + layout = QVBoxLayout() + + # Metadata form + self._metadata_widget = QWidget() + self._metadata_widget.setMaximumWidth(self._max_width) + self._metadata_layout = form_layout = QFormLayout(self._metadata_widget) + form_layout.setContentsMargins(0, 0, 0, 0) + form_layout.setVerticalSpacing(2) + layout.addWidget(self._metadata_widget) + self._metadata_widget.hide() # Backwards compatibility (no metadata) + + # Preview (pixmap) + self._label = QLabel() + layout.addWidget(self._label) + self.setLayout(layout) + + self.setWindowFlags( + Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget + ) + + def setSave(self, save: mobase.ISaveGame): + save_path = Path(save.getFilepath()) + + # Clear previous + self.hide() + self._label.clear() + while self._metadata_layout.count(): + layoutItem = self._metadata_layout.takeAt(0) + if layoutItem is not None and (w := layoutItem.widget()): + w.deleteLater() + + # Retrieve the pixmap and metadata: + preview = self._get_preview(save_path) + pixmap = None + + # Set the preview pixmap if the preview file exits + if preview is not None: + if isinstance(preview, str): + preview = Path(preview) + if isinstance(preview, Path): + if preview.exists(): + pixmap = QPixmap(str(preview)) + else: + print( + f"Failed to retrieve the preview, file not found: {preview}", + file=sys.stderr, + ) + elif isinstance(preview, QImage): + pixmap = QPixmap.fromImage(preview) + else: + pixmap = preview + if pixmap and not pixmap.isNull(): + # Scale the pixmap and show it: + pixmap = pixmap.scaledToWidth(self._max_width) + self._label.setPixmap(pixmap) + self._label.show() + else: + self._label.hide() + pixmap = None + + # Add metadata, file date by default. + metadata = self._get_metadata(save_path, save) + if metadata: + for key, value in metadata.items(): + self._metadata_layout.addRow(*self._new_form_row(key, str(value))) + self._metadata_widget.show() + self._metadata_widget.setLayout(self._metadata_layout) + self._metadata_widget.adjustSize() + else: + self._metadata_widget.hide() + + if metadata or pixmap: + self.adjustSize() + self.show() + + def _new_form_row(self, label: str = "", field: str = ""): + qLabel = QLabel(text=label) + qLabel.setAlignment(Qt.AlignmentFlag.AlignTop) + qLabel.setStyleSheet("font: italic") + qLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + qField = QLabel(text=field) + qField.setWordWrap(True) + qField.setAlignment(Qt.AlignmentFlag.AlignTop) + qField.setStyleSheet("font: bold") + qField.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + return qLabel, qField + + def set_maximum_width(self, width: int): + self._max_width = width + self._metadata_widget.setMaximumWidth(width) + + +class BasicGameSaveGameInfo(mobase.SaveGameInfo): + _get_widget: Callable[[QWidget | None], mobase.ISaveGameInfoWidget | None] | None + + def __init__( + self, + get_preview: ( + Callable[[Path], QPixmap | QImage | Path | str | None] | None + ) = None, + get_metadata: ( + Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] | None + ) = None, + max_width: int = 0, + ): + """Args from: `BasicGameSaveGameInfoWidget`.""" + super().__init__() + self._get_widget = lambda parent: BasicGameSaveGameInfoWidget( + parent, get_preview, get_metadata, max_width + ) + + @classmethod + def with_widget( + cls, + widget: type[mobase.ISaveGameInfoWidget] | None, + ) -> Self: + """ + + Args: + widget: a custom `ISaveGameInfoWidget` instead of the default + `BasicGameSaveGameInfoWidget`. + """ + self = cls() + self._get_widget = lambda parent: widget(parent) if widget else None + return self + + def getMissingAssets(self, save: mobase.ISaveGame) -> dict[str, Sequence[str]]: + return {} + + def getSaveGameWidget( + self, parent: QWidget | None = None + ) -> mobase.ISaveGameInfoWidget | None: + if self._get_widget: + return self._get_widget(parent) + else: + return None diff --git a/libs/basic_games/basic_features/utils.py b/libs/basic_games/basic_features/utils.py new file mode 100644 index 0000000..b703901 --- /dev/null +++ b/libs/basic_games/basic_features/utils.py @@ -0,0 +1,7 @@ +from typing import TypeGuard + +import mobase + + +def is_directory(entry: mobase.FileTreeEntry) -> TypeGuard[mobase.IFileTree]: + return entry.isDir() -- cgit v1.3.1