using System; using System.Collections.Generic; using System.IO; using System.Linq; using StardewModdingAPI.Toolkit.Serialisation; using StardewModdingAPI.Toolkit.Serialisation.Models; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { /// Scans folders for mod data. public class ModScanner { /********* ** Fields *********/ /// The JSON helper with which to read manifests. private readonly JsonHelper JsonHelper; /// A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod. private readonly HashSet IgnoreFilesystemEntries = new HashSet(StringComparer.InvariantCultureIgnoreCase) { ".DS_Store", "mcs", "Thumbs.db" }; /// 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. private readonly HashSet PotentialXnbModExtensions = new HashSet(StringComparer.InvariantCultureIgnoreCase) { ".md", ".png", ".txt", ".xnb" }; /********* ** Public methods *********/ /// Construct an instance. /// The JSON helper with which to read manifests. public ModScanner(JsonHelper jsonHelper) { this.JsonHelper = jsonHelper; } /// Extract information about all mods in the given folder. /// The root folder containing mods. public IEnumerable GetModFolders(string rootPath) { DirectoryInfo root = new DirectoryInfo(rootPath); return this.GetModFolders(root, root); } /// Extract information from a mod folder. /// The root folder containing mods. /// The folder to search for a mod. public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder) { // find manifest.json FileInfo manifestFile = this.FindManifest(searchFolder); // set appropriate invalid-mod error if (manifestFile == null) { FileInfo[] files = searchFolder.GetFiles("*", SearchOption.AllDirectories).Where(this.IsRelevant).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."); } // read mod info Manifest manifest = null; string manifestError = null; { try { if (!this.JsonHelper.ReadJsonFileIfExists(manifestFile.FullName, out manifest) || manifest == null) manifestError = "its manifest is invalid."; } catch (SParseException ex) { manifestError = $"parsing its manifest failed: {ex.Message}"; } catch (Exception ex) { manifestError = $"parsing its manifest failed:\n{ex}"; } } // normalise display fields if (manifest != null) { manifest.Name = this.StripNewlines(manifest.Name); manifest.Description = this.StripNewlines(manifest.Description); manifest.Author = this.StripNewlines(manifest.Author); } return new ModFolder(root, manifestFile.Directory, manifest, manifestError); } /********* ** Private methods *********/ /// Recursively extract information about all mods in the given folder. /// The root mod folder. /// The folder to search for mods. public IEnumerable 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); // recurse into subfolders else if (this.IsModSearchFolder(root, folder)) { foreach (DirectoryInfo subfolder in folder.EnumerateDirectories()) { foreach (ModFolder match in this.GetModFolders(root, subfolder)) yield return match; } } // treat as mod folder else yield return this.ReadFolder(root, folder); } /// Find the manifest for a mod folder. /// The folder to search. private FileInfo FindManifest(DirectoryInfo folder) { while (true) { // check for manifest in current folder FileInfo file = new FileInfo(Path.Combine(folder.FullName, "manifest.json")); if (file.Exists) return file; // check for single subfolder FileSystemInfo[] entries = folder.EnumerateFileSystemInfos().Take(2).ToArray(); if (entries.Length == 1 && entries[0] is DirectoryInfo subfolder) { folder = subfolder; continue; } // not found return null; } } /// Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods). /// The root mod folder. /// The folder to search for mods. private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder) { if (root.FullName == folder.FullName) return true; DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray(); FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray(); return subfolders.Any() && !files.Any(); } /// Get whether a file or folder is relevant when deciding how to process a mod folder. /// The file or folder. private bool IsRelevant(FileSystemInfo entry) { return !this.IgnoreFilesystemEntries.Contains(entry.Name); } /// Strip newlines from a string. /// The input to strip. private string StripNewlines(string input) { return input?.Replace("\r", "").Replace("\n", ""); } } }