using System; using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using StardewModdingAPI.Enums; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers { /// Provides an API for reading and storing local mod data. internal class DataHelper : BaseHelper, IDataHelper { /********* ** Fields *********/ /// Encapsulates SMAPI's JSON file parsing. private readonly JsonHelper JsonHelper; /// The absolute path to the mod folder. private readonly string ModFolderPath; /********* ** Public methods *********/ /// Construct an instance. /// The unique ID of the relevant mod. /// The absolute path to the mod folder. /// The absolute path to the mod folder. public DataHelper(string modID, string modFolderPath, JsonHelper jsonHelper) : base(modID) { this.ModFolderPath = modFolderPath; this.JsonHelper = jsonHelper; } /**** ** JSON file ****/ /// public TModel? ReadJsonFile(string path) where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IModHelper.Data)}.{nameof(this.ReadJsonFile)} with a relative path."); path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePath(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel? data) ? data : null; } /// public void WriteJsonFile(string path, TModel? data) where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing)."); path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePath(path)); if (data != null) this.JsonHelper.WriteJsonFile(path, data); else File.Delete(path); } /**** ** Save file ****/ /// public TModel? ReadSaveData(string key) where TModel : class { if (Context.LoadStage == LoadStage.None) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded."); if (!Context.IsOnHostComputer) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when connected to a remote host. (Save files are stored on the main player's computer.)"); string internalKey = this.GetSaveFileKey(key); foreach (IDictionary dataField in this.GetDataFields(Context.LoadStage)) { if (dataField.TryGetValue(internalKey, out string? value)) return this.JsonHelper.Deserialize(value); } return null; } /// public void WriteSaveData(string key, TModel? model) where TModel : class { if (Context.LoadStage == LoadStage.None) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded."); if (!Context.IsOnHostComputer) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when connected to a remote host. (Save files are stored on the main player's computer.)"); string internalKey = this.GetSaveFileKey(key); string? data = model != null ? this.JsonHelper.Serialize(model, Formatting.None) : null; foreach (IDictionary dataField in this.GetDataFields(Context.LoadStage)) { if (data != null) dataField[internalKey] = data; else dataField.Remove(internalKey); } } /**** ** Global app data ****/ /// public TModel? ReadGlobalData(string key) where TModel : class { string path = this.GetGlobalDataPath(key); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel? data) ? data : null; } /// public void WriteGlobalData(string key, TModel? data) where TModel : class { string path = this.GetGlobalDataPath(key); if (data != null) this.JsonHelper.WriteJsonFile(path, data); else File.Delete(path); } /********* ** Public methods *********/ /// Get the unique key for a save file data entry. /// The unique key identifying the data. private string GetSaveFileKey(string key) { this.AssertSlug(key, nameof(key)); return $"smapi/mod-data/{this.ModID}/{key}".ToLower(); } /// Get the data fields to read/write for save data. /// The current load stage. private IEnumerable> GetDataFields(LoadStage stage) { if (stage == LoadStage.None) yield break; yield return Game1.CustomData; if (SaveGame.loaded != null) yield return SaveGame.loaded.CustomData; } /// Get the absolute path for a global data file. /// The unique key identifying the data. private string GetGlobalDataPath(string key) { this.AssertSlug(key, nameof(key)); return Path.Combine(Constants.DataPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower()); } /// Assert that a key contains only characters that are safe in all contexts. /// The key to check. /// The argument name for any assertion error. private void AssertSlug(string key, string paramName) { if (!PathUtilities.IsSlug(key)) throw new ArgumentException("The data key is invalid (keys must only contain letters, numbers, underscores, periods, or hyphens).", paramName); } } }