From 125bcbee56bf40cf82abc7fdb502f8cbc18546cf Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 13 Sep 2019 17:22:45 -0400 Subject: migrate to new project file format --- .../Converters/ManifestContentPackForConverter.cs | 50 ++++++++ .../Converters/ManifestDependencyArrayConverter.cs | 60 +++++++++ .../Converters/SemanticVersionConverter.cs | 98 +++++++++++++++ .../Converters/SimpleReadOnlyConverter.cs | 76 ++++++++++++ .../Serialisation/InternalExtensions.cs | 21 ++++ src/SMAPI.Toolkit/Serialisation/JsonHelper.cs | 136 +++++++++++++++++++++ src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs | 74 +++++++++++ .../Serialisation/Models/ManifestContentPackFor.cs | 15 +++ .../Serialisation/Models/ManifestDependency.cs | 35 ++++++ src/SMAPI.Toolkit/Serialisation/SParseException.cs | 17 +++ 10 files changed, 582 insertions(+) create mode 100644 src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/JsonHelper.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs create mode 100644 src/SMAPI.Toolkit/Serialisation/SParseException.cs (limited to 'src/SMAPI.Toolkit/Serialisation') diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs new file mode 100644 index 00000000..232c22a7 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs @@ -0,0 +1,50 @@ +using System; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation.Models; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// Handles deserialisation of arrays. + public class ManifestContentPackForConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ManifestContentPackFor[]); + } + + + /********* + ** Protected methods + *********/ + /// Read the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return serializer.Deserialize(reader); + } + + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs new file mode 100644 index 00000000..0a304ee3 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Toolkit.Serialisation.Models; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// Handles deserialisation of arrays. + internal class ManifestDependencyArrayConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ManifestDependency[]); + } + + + /********* + ** Protected methods + *********/ + /// Read the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + List result = new List(); + foreach (JObject obj in JArray.Load(reader).Children()) + { + string uniqueID = obj.ValueIgnoreCase(nameof(ManifestDependency.UniqueID)); + string minVersion = obj.ValueIgnoreCase(nameof(ManifestDependency.MinimumVersion)); + bool required = obj.ValueIgnoreCase(nameof(ManifestDependency.IsRequired)) ?? true; + result.Add(new ManifestDependency(uniqueID, minVersion, required)); + } + return result.ToArray(); + } + + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs new file mode 100644 index 00000000..aca06849 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs @@ -0,0 +1,98 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// Handles deserialisation of . + internal class SemanticVersionConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// Get whether this converter can read JSON. + public override bool CanRead => true; + + /// Get whether this converter can write JSON. + public override bool CanWrite => true; + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return typeof(ISemanticVersion).IsAssignableFrom(objectType); + } + + /// Reads the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + string path = reader.Path; + switch (reader.TokenType) + { + case JsonToken.StartObject: + return this.ReadObject(JObject.Load(reader)); + case JsonToken.String: + return this.ReadString(JToken.Load(reader).Value(), path); + default: + throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path})."); + } + } + + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } + + + /********* + ** Private methods + *********/ + /// Read a JSON object. + /// The JSON object to read. + private ISemanticVersion ReadObject(JObject obj) + { + int major = obj.ValueIgnoreCase(nameof(ISemanticVersion.MajorVersion)); + int minor = obj.ValueIgnoreCase(nameof(ISemanticVersion.MinorVersion)); + int patch = obj.ValueIgnoreCase(nameof(ISemanticVersion.PatchVersion)); + string prereleaseTag = obj.ValueIgnoreCase(nameof(ISemanticVersion.PrereleaseTag)); +#if !SMAPI_3_0_STRICT + if (string.IsNullOrWhiteSpace(prereleaseTag)) + { + prereleaseTag = obj.ValueIgnoreCase("Build"); + if (prereleaseTag == "0") + prereleaseTag = null; // '0' from incorrect examples in old SMAPI documentation + } +#endif + + return new SemanticVersion(major, minor, patch, prereleaseTag +#if !SMAPI_3_0_STRICT + , isLegacyFormat: true +#endif + ); + } + + /// Read a JSON string. + /// The JSON string value. + /// The path to the current JSON node. + private ISemanticVersion ReadString(string str, string path) + { + if (string.IsNullOrWhiteSpace(str)) + return null; + if (!SemanticVersion.TryParse(str, out ISemanticVersion version)) + throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path})."); + return version; + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs new file mode 100644 index 00000000..5e0b0f4a --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs @@ -0,0 +1,76 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// The base implementation for simplified converters which deserialise without overriding serialisation. + /// The type to deserialise. + internal abstract class SimpleReadOnlyConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(T); + } + + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + + /// Reads the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + string path = reader.Path; + switch (reader.TokenType) + { + case JsonToken.StartObject: + return this.ReadObject(JObject.Load(reader), path); + case JsonToken.String: + return this.ReadString(JToken.Load(reader).Value(), path); + default: + throw new SParseException($"Can't parse {typeof(T).Name} from {reader.TokenType} node (path: {reader.Path})."); + } + } + + + /********* + ** Protected methods + *********/ + /// Read a JSON object. + /// The JSON object to read. + /// The path to the current JSON node. + protected virtual T ReadObject(JObject obj, string path) + { + throw new SParseException($"Can't parse {typeof(T).Name} from object node (path: {path})."); + } + + /// Read a JSON string. + /// The JSON string value. + /// The path to the current JSON node. + protected virtual T ReadString(string str, string path) + { + throw new SParseException($"Can't parse {typeof(T).Name} from string node (path: {path})."); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs b/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs new file mode 100644 index 00000000..12b2c933 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs @@ -0,0 +1,21 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Toolkit.Serialisation +{ + /// Provides extension methods for parsing JSON. + public static class JsonExtensions + { + /// Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity. + /// The value type. + /// The JSON object to search. + /// The field name. + public static T ValueIgnoreCase(this JObject obj, string fieldName) + { + JToken token = obj.GetValue(fieldName, StringComparison.InvariantCultureIgnoreCase); + return token != null + ? token.Value() + : default(T); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs b/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs new file mode 100644 index 00000000..cf2ce0d1 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using StardewModdingAPI.Toolkit.Serialisation.Converters; + +namespace StardewModdingAPI.Toolkit.Serialisation +{ + /// Encapsulates SMAPI's JSON file parsing. + public class JsonHelper + { + /********* + ** Accessors + *********/ + /// The JSON settings to use when serialising and deserialising files. + public JsonSerializerSettings JsonSettings { get; } = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection values are duplicated each time the config is loaded + Converters = new List + { + new SemanticVersionConverter(), + new StringEnumConverter() + } + }; + + + /********* + ** Public methods + *********/ + /// Read a JSON file. + /// The model type. + /// The absolete file path. + /// The parsed content model. + /// Returns false if the file doesn't exist, else true. + /// The given is empty or invalid. + /// The file contains invalid JSON. + public bool ReadJsonFileIfExists(string fullPath, out TModel result) + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // read file + string json; + try + { + json = File.ReadAllText(fullPath); + } + catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) + { + result = default(TModel); + return false; + } + + // deserialise model + try + { + result = this.Deserialise(json); + return true; + } + catch (Exception ex) + { + string error = $"Can't parse JSON file at {fullPath}."; + + if (ex is JsonReaderException) + { + error += " This doesn't seem to be valid JSON."; + if (json.Contains("“") || json.Contains("”")) + error += " Found curly quotes in the text; note that only straight quotes are allowed in JSON."; + } + error += $"\nTechnical details: {ex.Message}"; + throw new JsonReaderException(error); + } + } + + /// Save to a JSON file. + /// The model type. + /// The absolete file path. + /// The model to save. + /// The given path is empty or invalid. + public void WriteJsonFile(string fullPath, TModel model) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // create directory if needed + string dir = Path.GetDirectoryName(fullPath); + if (dir == null) + throw new ArgumentException("The file path is invalid.", nameof(fullPath)); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + // write file + string json = this.Serialise(model); + File.WriteAllText(fullPath, json); + } + + /// Deserialize JSON text if possible. + /// The model type. + /// The raw JSON text. + public TModel Deserialise(string json) + { + try + { + return JsonConvert.DeserializeObject(json, this.JsonSettings); + } + catch (JsonReaderException) + { + // try replacing curly quotes + if (json.Contains("“") || json.Contains("”")) + { + try + { + return JsonConvert.DeserializeObject(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings); + } + catch { /* rethrow original error */ } + } + + throw; + } + } + + /// Serialize a model to JSON text. + /// The model type. + /// The model to serialise. + /// The formatting to apply. + public string Serialise(TModel model, Formatting formatting = Formatting.Indented) + { + return JsonConvert.SerializeObject(model, formatting, this.JsonSettings); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs new file mode 100644 index 00000000..6cb9496b --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation.Converters; + +namespace StardewModdingAPI.Toolkit.Serialisation.Models +{ + /// A manifest which describes a mod for SMAPI. + public class Manifest : IManifest + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// A brief description of the mod. + public string Description { get; set; } + + /// The mod author's name. + public string Author { get; set; } + + /// The mod version. + public ISemanticVersion Version { get; set; } + + /// The minimum SMAPI version required by this mod, if any. + public ISemanticVersion MinimumApiVersion { get; set; } + + /// The name of the DLL in the directory that has the Entry method. Mutually exclusive with . + public string EntryDll { get; set; } + + /// The mod which will read this as a content pack. Mutually exclusive with . + [JsonConverter(typeof(ManifestContentPackForConverter))] + public IManifestContentPackFor ContentPackFor { get; set; } + + /// The other mods that must be loaded before this mod. + [JsonConverter(typeof(ManifestDependencyArrayConverter))] + public IManifestDependency[] Dependencies { get; set; } + + /// The namespaced mod IDs to query for updates (like Nexus:541). + public string[] UpdateKeys { get; set; } + + /// The unique mod ID. + public string UniqueID { get; set; } + + /// Any manifest fields which didn't match a valid field. + [JsonExtensionData] + public IDictionary ExtraFields { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public Manifest() { } + + /// Construct an instance for a transitional content pack. + /// The unique mod ID. + /// The mod name. + /// The mod author's name. + /// A brief description of the mod. + /// The mod version. + /// The modID which will read this as a content pack. + public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string contentPackFor = null) + { + this.Name = name; + this.Author = author; + this.Description = description; + this.Version = version; + this.UniqueID = uniqueID; + this.UpdateKeys = new string[0]; + this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor }; + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs b/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs new file mode 100644 index 00000000..d0e42216 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Toolkit.Serialisation.Models +{ + /// Indicates which mod can read the content pack represented by the containing manifest. + public class ManifestContentPackFor : IManifestContentPackFor + { + /********* + ** Accessors + *********/ + /// The unique ID of the mod which can read this content pack. + public string UniqueID { get; set; } + + /// The minimum required version (if any). + public ISemanticVersion MinimumVersion { get; set; } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs b/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs new file mode 100644 index 00000000..8db58d5d --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs @@ -0,0 +1,35 @@ +namespace StardewModdingAPI.Toolkit.Serialisation.Models +{ + /// A mod dependency listed in a mod manifest. + public class ManifestDependency : IManifestDependency + { + /********* + ** Accessors + *********/ + /// The unique mod ID to require. + public string UniqueID { get; set; } + + /// The minimum required version (if any). + public ISemanticVersion MinimumVersion { get; set; } + + /// Whether the dependency must be installed to use the mod. + public bool IsRequired { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique mod ID to require. + /// The minimum required version (if any). + /// Whether the dependency must be installed to use the mod. + public ManifestDependency(string uniqueID, string minimumVersion, bool required = true) + { + this.UniqueID = uniqueID; + this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion) + ? new SemanticVersion(minimumVersion) + : null; + this.IsRequired = required; + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/SParseException.cs b/src/SMAPI.Toolkit/Serialisation/SParseException.cs new file mode 100644 index 00000000..61a7b305 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/SParseException.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Toolkit.Serialisation +{ + /// A format exception which provides a user-facing error message. + internal class SParseException : FormatException + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + /// The underlying exception, if any. + public SParseException(string message, Exception ex = null) + : base(message, ex) { } + } +} -- cgit