aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games/basic_features
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games/basic_features
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 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games/basic_features')
-rw-r--r--libs/basic_games/basic_features/__init__.py10
-rw-r--r--libs/basic_games/basic_features/basic_local_savegames.py27
-rw-r--r--libs/basic_games/basic_features/basic_mod_data_checker.py226
-rw-r--r--libs/basic_games/basic_features/basic_save_game_info.py226
-rw-r--r--libs/basic_games/basic_features/utils.py7
5 files changed, 496 insertions, 0 deletions
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()