diff options
Diffstat (limited to 'src/SMAPI.Toolkit/Framework/ModScanning')
4 files changed, 195 insertions, 39 deletions
diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs index bb467b36..d0df09a1 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Toolkit.Framework.ModScanning @@ -18,14 +18,17 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>The folder containing the mod's manifest.json.</summary> public DirectoryInfo Directory { get; } + /// <summary>The mod type.</summary> + public ModType Type { get; } + /// <summary>The mod manifest.</summary> public Manifest Manifest { get; } /// <summary>The error which occurred parsing the manifest, if any.</summary> - public string ManifestParseError { get; } + public ModParseError ManifestParseError { get; set; } - /// <summary>Whether the mod should be loaded by default. This is <c>false</c> if it was found within a folder whose name starts with a dot.</summary> - public bool ShouldBeLoaded { get; } + /// <summary>A human-readable message for the <see cref="ManifestParseError"/>, if any.</summary> + public string ManifestParseErrorText { get; set; } /********* @@ -34,16 +37,26 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>Construct an instance.</summary> /// <param name="root">The root folder containing mods.</param> /// <param name="directory">The folder containing the mod's manifest.json.</param> + /// <param name="type">The mod type.</param> + /// <param name="manifest">The mod manifest.</param> + public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest manifest) + : this(root, directory, type, manifest, ModParseError.None, null) { } + + /// <summary>Construct an instance.</summary> + /// <param name="root">The root folder containing mods.</param> + /// <param name="directory">The folder containing the mod's manifest.json.</param> + /// <param name="type">The mod type.</param> /// <param name="manifest">The mod manifest.</param> /// <param name="manifestParseError">The error which occurred parsing the manifest, if any.</param> - /// <param name="shouldBeLoaded">Whether the mod should be loaded by default. This should be <c>false</c> if it was found within a folder whose name starts with a dot.</param> - public ModFolder(DirectoryInfo root, DirectoryInfo directory, Manifest manifest, string manifestParseError = null, bool shouldBeLoaded = true) + /// <param name="manifestParseErrorText">A human-readable message for the <paramref name="manifestParseError"/>, if any.</param> + public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest manifest, ModParseError manifestParseError, string manifestParseErrorText) { // save info this.Directory = directory; + this.Type = type; this.Manifest = manifest; this.ManifestParseError = manifestParseError; - this.ShouldBeLoaded = shouldBeLoaded; + this.ManifestParseErrorText = manifestParseErrorText; // set display name this.DisplayName = manifest?.Name; diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs new file mode 100644 index 00000000..b10510ff --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Toolkit.Framework.ModScanning +{ + /// <summary>Indicates why a mod could not be parsed.</summary> + public enum ModParseError + { + /// <summary>No parse error.</summary> + None, + + /// <summary>The folder is empty or contains only ignored files.</summary> + EmptyFolder, + + /// <summary>The folder is ignored by convention.</summary> + IgnoredFolder, + + /// <summary>The mod's <c>manifest.json</c> could not be parsed.</summary> + ManifestInvalid, + + /// <summary>The folder contains non-ignored and non-XNB files, but none of them are <c>manifest.json</c>.</summary> + ManifestMissing, + + /// <summary>The folder is an XNB mod, which can't be loaded through SMAPI.</summary> + XnbMod + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs index 0ab73d56..f11cc1a7 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -2,8 +2,9 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { @@ -17,20 +18,32 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning private readonly JsonHelper JsonHelper; /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary> - private readonly HashSet<string> IgnoreFilesystemEntries = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + private readonly HashSet<Regex> IgnoreFilesystemEntries = new HashSet<Regex> { - ".DS_Store", - "mcs", - "Thumbs.db" + // OS metadata files + new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager + new Regex(@"^(?:__MACOSX|\._\.DS_Store|\.DS_Store|mcs)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS + new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows + new Regex(@"\.(?:url|lnk)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows shortcut files + + // other + new Regex(@"\.(?:bmp|gif|jpeg|jpg|png|psd|tif)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // image files + new Regex(@"\.(?:md|rtf|txt)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // text files + new Regex(@"\.(?:backup|bak|old)$", RegexOptions.Compiled | RegexOptions.IgnoreCase) // backup file }; - /// <summary>The extensions for files which an XNB mod may contain. If a mod contains *only* these file extensions, it should be considered an XNB mod.</summary> + /// <summary>The extensions for files which an XNB mod may contain. If a mod doesn't have a <c>manifest.json</c> and contains *only* these file extensions, it should be considered an XNB mod.</summary> private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { - ".md", - ".png", - ".txt", - ".xnb" + // XNB files + ".xgs", + ".xnb", + ".xsb", + ".xwb", + + // unpacking artifacts + ".json", + ".yaml" }; @@ -52,6 +65,15 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return this.GetModFolders(root, root); } + /// <summary>Extract information about all mods in the given folder.</summary> + /// <param name="rootPath">The root folder containing mods. Only the <paramref name="modPath"/> will be searched, but this field allows it to be treated as a potential mod folder of its own.</param> + /// <param name="modPath">The mod path to search.</param> + // /// <param name="tryConsolidateMod">If the folder contains multiple XNB mods, treat them as subfolders of a single mod. This is useful when reading a single mod archive, as opposed to a mods folder.</param> + public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath) + { + return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath)); + } + /// <summary>Extract information from a mod folder.</summary> /// <param name="root">The root folder containing mods.</param> /// <param name="searchFolder">The folder to search for a mod.</param> @@ -63,34 +85,40 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning // set appropriate invalid-mod error if (manifestFile == null) { - FileInfo[] files = searchFolder.GetFiles("*", SearchOption.AllDirectories).Where(this.IsRelevant).ToArray(); + FileInfo[] files = this.RecursivelyGetRelevantFiles(searchFolder).ToArray(); if (!files.Any()) - return new ModFolder(root, searchFolder, null, "it's an empty folder."); - if (files.All(file => this.PotentialXnbModExtensions.Contains(file.Extension))) - return new ModFolder(root, searchFolder, null, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); - return new ModFolder(root, searchFolder, null, "it contains files, but none of them are manifest.json."); + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder."); + if (files.All(this.IsPotentialXnbFile)) + return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "it contains files, but none of them are manifest.json."); } // read mod info Manifest manifest = null; - string manifestError = null; + ModParseError error = ModParseError.None; + string errorText = null; { try { if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null) - manifestError = "its manifest is invalid."; + { + error = ModParseError.ManifestInvalid; + errorText = "its manifest is invalid."; + } } catch (SParseException ex) { - manifestError = $"parsing its manifest failed: {ex.Message}"; + error = ModParseError.ManifestInvalid; + errorText = $"parsing its manifest failed: {ex.Message}"; } catch (Exception ex) { - manifestError = $"parsing its manifest failed:\n{ex}"; + error = ModParseError.ManifestInvalid; + errorText = $"parsing its manifest failed:\n{ex}"; } } - // normalise display fields + // normalize display fields if (manifest != null) { manifest.Name = this.StripNewlines(manifest.Name); @@ -98,7 +126,17 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning manifest.Author = this.StripNewlines(manifest.Author); } - return new ModFolder(root, manifestFile.Directory, manifest, manifestError); + // get mod type + ModType type = ModType.Invalid; + if (manifest != null) + { + type = !string.IsNullOrWhiteSpace(manifest.ContentPackFor?.UniqueID) + ? ModType.ContentPack + : ModType.Smapi; + } + + // build result + return new ModFolder(root, manifestFile.Directory, type, manifest, error, errorText); } @@ -108,20 +146,30 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>Recursively extract information about all mods in the given folder.</summary> /// <param name="root">The root mod folder.</param> /// <param name="folder">The folder to search for mods.</param> - public IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder) + private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder) { - // skip - if (folder.FullName != root.FullName && folder.Name.StartsWith(".")) - yield return new ModFolder(root, folder, null, "ignored folder because its name starts with a dot.", shouldBeLoaded: false); + bool isRoot = folder.FullName == root.FullName; - // recurse into subfolders - else if (this.IsModSearchFolder(root, folder)) + // skip + if (!isRoot) { - foreach (DirectoryInfo subfolder in folder.EnumerateDirectories()) + if (folder.Name.StartsWith(".")) { - foreach (ModFolder match in this.GetModFolders(root, subfolder)) - yield return match; + yield return new ModFolder(root, folder, ModType.Ignored, null, ModParseError.IgnoredFolder, "ignored folder because its name starts with a dot."); + yield break; } + if (!this.IsRelevant(folder)) + yield break; + } + + // find mods in subfolders + if (this.IsModSearchFolder(root, folder)) + { + IEnumerable<ModFolder> subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub)); + if (!isRoot) + subfolders = this.TryConsolidate(root, folder, subfolders.ToArray()); + foreach (ModFolder subfolder in subfolders) + yield return subfolder; } // treat as mod folder @@ -129,6 +177,26 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning yield return this.ReadFolder(root, folder); } + /// <summary>Consolidate adjacent folders into one mod folder, if possible.</summary> + /// <param name="root">The folder containing both parent and subfolders.</param> + /// <param name="parentFolder">The parent folder to consolidate, if possible.</param> + /// <param name="subfolders">The subfolders to consolidate, if possible.</param> + private IEnumerable<ModFolder> TryConsolidate(DirectoryInfo root, DirectoryInfo parentFolder, ModFolder[] subfolders) + { + if (subfolders.Length > 1) + { + // a collection of empty folders + if (subfolders.All(p => p.ManifestParseError == ModParseError.EmptyFolder)) + return new[] { new ModFolder(root, parentFolder, ModType.Invalid, null, ModParseError.EmptyFolder, subfolders[0].ManifestParseErrorText) }; + + // an XNB mod + if (subfolders.All(p => p.Type == ModType.Xnb || p.ManifestParseError == ModParseError.EmptyFolder)) + return new[] { new ModFolder(root, parentFolder, ModType.Xnb, null, ModParseError.XnbMod, subfolders[0].ManifestParseErrorText) }; + } + + return subfolders; + } + /// <summary>Find the manifest for a mod folder.</summary> /// <param name="folder">The folder to search.</param> private FileInfo FindManifest(DirectoryInfo folder) @@ -166,11 +234,41 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return subfolders.Any() && !files.Any(); } + /// <summary>Recursively get all relevant files in a folder based on the result of <see cref="IsRelevant"/>.</summary> + /// <param name="folder">The root folder to search.</param> + private IEnumerable<FileInfo> RecursivelyGetRelevantFiles(DirectoryInfo folder) + { + foreach (FileSystemInfo entry in folder.GetFileSystemInfos()) + { + if (!this.IsRelevant(entry)) + continue; + + if (entry is FileInfo file) + yield return file; + + if (entry is DirectoryInfo subfolder) + { + foreach (FileInfo subfolderFile in this.RecursivelyGetRelevantFiles(subfolder)) + yield return subfolderFile; + } + } + } + /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary> /// <param name="entry">The file or folder.</param> private bool IsRelevant(FileSystemInfo entry) { - return !this.IgnoreFilesystemEntries.Contains(entry.Name); + return !this.IgnoreFilesystemEntries.Any(p => p.IsMatch(entry.Name)); + } + + /// <summary>Get whether a file is potentially part of an XNB mod.</summary> + /// <param name="entry">The file.</param> + private bool IsPotentialXnbFile(FileInfo entry) + { + if (!this.IsRelevant(entry)) + return true; + + return this.PotentialXnbModExtensions.Contains(entry.Extension); // use EndsWith to handle cases like image..png } /// <summary>Strip newlines from a string.</summary> diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs new file mode 100644 index 00000000..bc86edb6 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs @@ -0,0 +1,21 @@ +namespace StardewModdingAPI.Toolkit.Framework.ModScanning +{ + /// <summary>A general mod type.</summary> + public enum ModType + { + /// <summary>The mod is invalid and its type could not be determined.</summary> + Invalid, + + /// <summary>The folder is ignored by convention.</summary> + Ignored, + + /// <summary>A mod which uses SMAPI directly.</summary> + Smapi, + + /// <summary>A mod which contains files loaded by a SMAPI mod.</summary> + ContentPack, + + /// <summary>A legacy mod which replaces game files directly.</summary> + Xnb + } +} |