import 'dart:io'; import 'package:rimworld_modman/logger.dart'; import 'package:xml/xml.dart'; XmlElement findCaseInsensitive(XmlElement element, String name) { return element.childElements.firstWhere( (e) => e.name.local.toLowerCase() == name, ); } XmlElement findCaseInsensitiveDoc(XmlDocument document, String name) { name = name.toLowerCase(); return document.childElements.firstWhere( (e) => e.name.local.toLowerCase() == name, ); } class Mod { final String name; // ModMetaData.name final String id; // ModMetaData.packageId final String path; // figure it out final List versions; // ModMetaData.supportedVersions final String description; // ModMetaData.description final List dependencies; // ModMetaData.modDependencies final List loadAfter; // ModMetaData.loadAfter final List loadBefore; // ModMetaData.loadBefore final List incompatibilities; // ModMetaData.incompatibleWith bool enabled; final int size; // Count of files in the mod directory final bool isBaseGame; // Is this the base RimWorld game final bool isExpansion; // Is this a RimWorld expansion Mod({ required this.name, required this.id, required this.path, required this.versions, required this.description, required this.dependencies, required this.loadAfter, required this.loadBefore, required this.incompatibilities, required this.size, this.isBaseGame = false, this.isExpansion = false, this.enabled = false, }); int get tier { if (isBaseGame) return 0; if (isExpansion) return 1; return 2; } @override String toString() { return 'Mod{name: $name, id: $id, path: $path, dependencies: $dependencies, loadAfter: $loadAfter, loadBefore: $loadBefore, incompatibilities: $incompatibilities, size: $size, isBaseGame: $isBaseGame, isExpansion: $isExpansion}'; } static Mod fromDirectory(String path, {bool skipFileCount = false}) { // final logger = Logger.instance; // final stopwatch = Stopwatch()..start(); // logger.info('Attempting to load mod from directory: $path'); final aboutFile = File('$path/About/About.xml'); if (!aboutFile.existsSync()) { // logger.error('About.xml file does not exist in $aboutFile'); throw Exception('About.xml file does not exist in $aboutFile'); } // logger.info('Parsing About.xml file...'); final aboutXml = XmlDocument.parse(aboutFile.readAsStringSync()); // final xmlTime = stopwatch.elapsedMilliseconds; late final XmlElement metadata; try { metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData'); // logger.info('Successfully found ModMetaData in About.xml'); } catch (e) { // logger.error( // 'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', // ); throw Exception( 'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', ); } late final String name; try { name = metadata.findElements('name').first.innerText; // logger.info('Mod name found: $name'); } catch (e) { // logger.error( // 'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', // ); throw Exception( 'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', ); } late final String id; try { id = metadata.findElements('packageId').first.innerText.toLowerCase(); // logger.info('Mod ID found: $id'); } catch (e) { // logger.error( // 'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', // ); throw Exception( 'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', ); } late final List versions; try { versions = metadata .findElements('supportedVersions') .first .findElements('li') .map((e) => e.innerText) .toList(); // logger.info('Supported versions found: ${versions.join(", ")}'); } catch (e) { // logger.error( // 'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', // ); throw Exception( 'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', ); } String description = ''; try { description = metadata.findElements('description').first.innerText; // logger.info('Mod description found: $description'); } catch (e) { // logger.warning( // 'Description element is missing in ModMetaData ($aboutFile).', // ); } List dependencies = []; try { dependencies = metadata .findElements('modDependenciesByVersion') .first .children .whereType() .last .findElements('li') .map( (e) => e.findElements("packageId").first.innerText.toLowerCase(), ) .toList(); // logger.info('Dependencies found: ${dependencies.join(", ")}'); } catch (e) { // logger.warning( // 'Dependencies element is missing in ModMetaData ($aboutFile).', // ); } try { dependencies.addAll( metadata .findElements('modDependencies') .first .findElements('li') .map( (e) => e.findElements("packageId").first.innerText.toLowerCase(), ) .toList(), ); // logger.info('Additional dependencies found: ${dependencies.join(", ")}'); } catch (e) { // logger.warning( // 'modDependencies element is missing in ModMetaData ($aboutFile). Original error: $e', // ); } List loadAfter = []; try { loadAfter = metadata .findElements('loadAfter') .first .findElements('li') .map((e) => e.innerText.toLowerCase()) .toList(); // logger.info( // 'Load after dependencies found: ${loadAfter.isNotEmpty ? loadAfter.join(", ") : "none"}', // ); } catch (e) { // logger.warning( // 'Load after element is missing or empty in ModMetaData ($aboutFile). Original error: $e', // ); } List loadAfterForce = []; try { loadAfterForce = metadata .findElements('forceLoadAfter') .first .findElements('li') .map((e) => e.innerText.toLowerCase()) .toList(); // logger.info( // 'Force load after dependencies found: ${loadAfterForce.isNotEmpty ? loadAfterForce.join(", ") : "none"}', // ); } catch (e) { // logger.warning( // 'Force load after element is missing or empty in ModMetaData ($aboutFile). Original error: $e', // ); } dependencies.addAll(loadAfterForce); List loadBefore = []; try { loadBefore = metadata .findElements('loadBefore') .first .findElements('li') .map((e) => e.innerText.toLowerCase()) .toList(); // logger.info( // 'Load before dependencies found: ${loadBefore.isNotEmpty ? loadBefore.join(", ") : "none"}', // ); } catch (e) { // logger.warning( // 'Load before element is missing or empty in ModMetaData ($aboutFile). Original error: $e ', // ); } List incompatibilities = []; try { incompatibilities = metadata .findElements('incompatibleWith') .first .findElements('li') .map((e) => e.innerText.toLowerCase()) .toList(); // logger.info('Incompatibilities found: ${incompatibilities.join(", ")}'); } catch (e) { // logger.warning( // 'Incompatibilities element is missing in ModMetaData ($aboutFile).', // ); } // final metadataTime = stopwatch.elapsedMilliseconds - xmlTime; int size = 0; if (!skipFileCount) { // Find all directories matching version pattern (like "1.0", "1.4", etc.) final versionDirs = Directory(path) .listSync(recursive: false) .whereType() .where( (dir) => RegExp( r'^\d+\.\d+$', ).hasMatch(dir.path.split(Platform.pathSeparator).last), ) .toList(); // Find the latest version directory (if any) Directory? latestVersionDir; if (versionDirs.isNotEmpty) { // Sort by version number versionDirs.sort((a, b) { final List vA = a.path .split(Platform.pathSeparator) .last .split('.') .map(int.parse) .toList(); final List vB = b.path .split(Platform.pathSeparator) .last .split('.') .map(int.parse) .toList(); return vA[0] != vB[0] ? vA[0] - vB[0] : vA[1] - vB[1]; // Compare major, then minor version }); latestVersionDir = versionDirs.last; // logger.info( // 'Latest version directory found: ${latestVersionDir.path.split(Platform.pathSeparator).last}', // ); } // Count all files, excluding older version directories size = Directory(path).listSync(recursive: true).where((entity) { if (entity is! File || entity.path .split(Platform.pathSeparator) .any((part) => part.startsWith('.'))) { return false; } // Skip files in version directories except for the latest for (final verDir in versionDirs) { if (verDir != latestVersionDir && entity.path.startsWith(verDir.path)) { return false; } } return true; }).length; // logger.info( // 'File count in mod directory (with only latest version): $size', // ); } // final fileCountTime = // stopwatch.elapsedMilliseconds - metadataTime - xmlTime; // final totalTime = stopwatch.elapsedMilliseconds; // Log detailed timing information // logger.info( // 'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms', // ); dependencies = dependencies.toSet().toList(); loadAfter = loadAfter.toSet().toList(); loadBefore = loadBefore.toSet().toList(); incompatibilities = incompatibilities.toSet().toList(); return Mod( name: name, id: id, path: path, versions: versions, description: description, dependencies: dependencies, loadAfter: loadAfter, loadBefore: loadBefore, incompatibilities: incompatibilities, size: size, // No mods loaded from workshop are ever base or expansion games isBaseGame: false, isExpansion: false, ); } Mod copyWith({ String? name, String? id, String? path, List? versions, String? description, List? dependencies, List? loadAfter, List? loadBefore, List? incompatibilities, int? size, bool? isBaseGame, bool? isExpansion, bool? enabled, }) { return Mod( name: name ?? this.name, id: id ?? this.id, path: path ?? this.path, versions: versions ?? this.versions, description: description ?? this.description, dependencies: dependencies ?? this.dependencies, loadAfter: loadAfter ?? this.loadAfter, loadBefore: loadBefore ?? this.loadBefore, incompatibilities: incompatibilities ?? this.incompatibilities, size: size ?? this.size, isBaseGame: isBaseGame ?? this.isBaseGame, isExpansion: isExpansion ?? this.isExpansion, enabled: enabled ?? this.enabled, ); } }