385 lines
12 KiB
Dart
385 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
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<String> versions; // ModMetaData.supportedVersions
|
|
final String description; // ModMetaData.description
|
|
final List<String> dependencies; // ModMetaData.modDependencies
|
|
final List<String> loadAfter; // ModMetaData.loadAfter
|
|
final List<String> loadBefore; // ModMetaData.loadBefore
|
|
final List<String> 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<String> 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<String> dependencies = [];
|
|
try {
|
|
dependencies =
|
|
metadata
|
|
.findElements('modDependenciesByVersion')
|
|
.first
|
|
.children
|
|
.whereType<XmlElement>()
|
|
.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<String> 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<String> 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<String> 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<String> 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<Directory>()
|
|
.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<int> vA =
|
|
a.path
|
|
.split(Platform.pathSeparator)
|
|
.last
|
|
.split('.')
|
|
.map(int.parse)
|
|
.toList();
|
|
final List<int> 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<String>? versions,
|
|
String? description,
|
|
List<String>? dependencies,
|
|
List<String>? loadAfter,
|
|
List<String>? loadBefore,
|
|
List<String>? 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,
|
|
);
|
|
}
|
|
}
|