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);
}
}
}