summaryrefslogtreecommitdiff
path: root/src/SMAPI.Toolkit/Framework/ModScanning
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Toolkit/Framework/ModScanning')
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs27
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs24
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs162
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs21
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
+ }
+}