using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Web.Script.Serialization; using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.ModBuildConfig.Framework { /// Manages the files that are part of a mod package. internal class ModFileManager { /********* ** Properties *********/ /// The name of the manifest file. private readonly string ManifestFileName = "manifest.json"; /// The files that are part of the package. private readonly IDictionary Files; /********* ** Public methods *********/ /// Construct an instance. /// The folder containing the project files. /// The folder containing the build output. /// Custom regex patterns matching files to ignore when deploying or zipping the mod. /// The mod package isn't valid. public ModFileManager(string projectDir, string targetDir, Regex[] ignoreFilePatterns) { this.Files = new Dictionary(StringComparer.InvariantCultureIgnoreCase); // validate paths if (!Directory.Exists(projectDir)) throw new UserErrorException("Could not create mod package because the project folder wasn't found."); if (!Directory.Exists(targetDir)) throw new UserErrorException("Could not create mod package because no build output was found."); // project manifest bool hasProjectManifest = false; { FileInfo manifest = new FileInfo(Path.Combine(projectDir, "manifest.json")); if (manifest.Exists) { this.Files[this.ManifestFileName] = manifest; hasProjectManifest = true; } } // project i18n files bool hasProjectTranslations = false; DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n")); if (translationsFolder.Exists) { foreach (FileInfo file in translationsFolder.EnumerateFiles()) this.Files[Path.Combine("i18n", file.Name)] = file; hasProjectTranslations = true; } // build output DirectoryInfo buildFolder = new DirectoryInfo(targetDir); foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) { // get relative paths string relativePath = file.FullName.Replace(buildFolder.FullName, ""); string relativeDirPath = file.Directory.FullName.Replace(buildFolder.FullName, ""); // prefer project manifest/i18n files if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName)) continue; if (hasProjectTranslations && this.EqualsInvariant(relativeDirPath, "i18n")) continue; // handle ignored files if (this.ShouldIgnore(file, relativePath, ignoreFilePatterns)) continue; // add file this.Files[relativePath] = file; } // check for missing manifest if (!this.Files.ContainsKey(this.ManifestFileName)) throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output."); // check for missing DLL // ReSharper disable once SimplifyLinqExpression if (!this.Files.Any(p => !p.Key.EndsWith(".dll"))) throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output."); } /// Get the files in the mod package. public IDictionary GetFiles() { return new Dictionary(this.Files, StringComparer.InvariantCultureIgnoreCase); } /// Get a semantic version from the mod manifest. /// The manifest is missing or invalid. public string GetManifestVersion() { // get manifest file if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile)) throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor // read content string json = File.ReadAllText(manifestFile.FullName); if (string.IsNullOrWhiteSpace(json)) throw new UserErrorException("The mod's manifest must not be empty."); // parse JSON IDictionary data; try { data = this.Parse(json); } catch (Exception ex) { throw new UserErrorException($"The mod's manifest couldn't be parsed. It doesn't seem to be valid JSON.\n{ex}"); } // get version field object versionObj = data.ContainsKey("Version") ? data["Version"] : null; if (versionObj == null) throw new UserErrorException("The mod's manifest must have a version field."); // get version string if (versionObj is IDictionary versionFields) // SMAPI 1.x { int major = versionFields.ContainsKey("MajorVersion") ? (int)versionFields["MajorVersion"] : 0; int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0; int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0; string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null; return new SemanticVersion(major, minor, patch, tag).ToString(); } return new SemanticVersion(versionObj.ToString()).ToString(); // SMAPI 2.0+ } /********* ** Private methods *********/ /// Get whether a build output file should be ignored. /// The file to check. /// The file's relative path in the package. /// Custom regex patterns matching files to ignore when deploying or zipping the mod. private bool ShouldIgnore(FileInfo file, string relativePath, Regex[] ignoreFilePatterns) { return // release zips this.EqualsInvariant(file.Extension, ".zip") // Json.NET (bundled into SMAPI) || this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll") || this.EqualsInvariant(file.Name, "Newtonsoft.Json.xml") // code analysis files || file.Name.EndsWith(".CodeAnalysisLog.xml", StringComparison.InvariantCultureIgnoreCase) || file.Name.EndsWith(".lastcodeanalysissucceeded", StringComparison.InvariantCultureIgnoreCase) // OS metadata files || this.EqualsInvariant(file.Name, ".DS_Store") || this.EqualsInvariant(file.Name, "Thumbs.db") // custom ignore patterns || ignoreFilePatterns.Any(p => p.IsMatch(relativePath)); } /// Get a case-insensitive dictionary matching the given JSON. /// The JSON to parse. private IDictionary Parse(string json) { IDictionary MakeCaseInsensitive(IDictionary dict) { foreach (var field in dict.ToArray()) { if (field.Value is IDictionary value) dict[field.Key] = MakeCaseInsensitive(value); } return new Dictionary(dict, StringComparer.InvariantCultureIgnoreCase); } IDictionary data = (IDictionary)new JavaScriptSerializer().DeserializeObject(json); return MakeCaseInsensitive(data); } /// Get whether a string is equal to another case-insensitively. /// The string value. /// The string to compare with. private bool EqualsInvariant(string str, string other) { return str.Equals(other, StringComparison.InvariantCultureIgnoreCase); } } }