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 { /// <summary>Provides an API for reading and storing local mod data.</summary> internal class DataHelper : BaseHelper, IDataHelper { /********* ** Fields *********/ /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; /// <summary>The absolute path to the mod folder.</summary> private readonly string ModFolderPath; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="modID">The unique ID of the relevant mod.</param> /// <param name="modFolderPath">The absolute path to the mod folder.</param> /// <param name="jsonHelper">The absolute path to the mod folder.</param> public DataHelper(string modID, string modFolderPath, JsonHelper jsonHelper) : base(modID) { this.ModFolderPath = modFolderPath; this.JsonHelper = jsonHelper; } /**** ** JSON file ****/ /// <inheritdoc /> public TModel ReadJsonFile<TModel>(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; } /// <inheritdoc /> public void WriteJsonFile<TModel>(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)); this.JsonHelper.WriteJsonFile(path, data); } /**** ** Save file ****/ /// <inheritdoc /> public TModel ReadSaveData<TModel>(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<string, string> dataField in this.GetDataFields(Context.LoadStage)) { if (dataField.TryGetValue(internalKey, out string value)) return this.JsonHelper.Deserialize<TModel>(value); } return null; } /// <inheritdoc /> public void WriteSaveData<TModel>(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<string, string> dataField in this.GetDataFields(Context.LoadStage)) { if (data != null) dataField[internalKey] = data; else dataField.Remove(internalKey); } } /**** ** Global app data ****/ /// <inheritdoc /> public TModel ReadGlobalData<TModel>(string key) where TModel : class { string path = this.GetGlobalDataPath(key); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) ? data : null; } /// <inheritdoc /> public void WriteGlobalData<TModel>(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 *********/ /// <summary>Get the unique key for a save file data entry.</summary> /// <param name="key">The unique key identifying the data.</param> private string GetSaveFileKey(string key) { this.AssertSlug(key, nameof(key)); return $"smapi/mod-data/{this.ModID}/{key}".ToLower(); } /// <summary>Get the data fields to read/write for save data.</summary> /// <param name="stage">The current load stage.</param> private IEnumerable<IDictionary<string, string>> GetDataFields(LoadStage stage) { if (stage == LoadStage.None) yield break; yield return Game1.CustomData; if (SaveGame.loaded != null) yield return SaveGame.loaded.CustomData; } /// <summary>Get the absolute path for a global data file.</summary> /// <param name="key">The unique key identifying the data.</param> private string GetGlobalDataPath(string key) { this.AssertSlug(key, nameof(key)); return Path.Combine(Constants.DataPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower()); } /// <summary>Assert that a key contains only characters that are safe in all contexts.</summary> /// <param name="key">The key to check.</param> /// <param name="paramName">The argument name for any assertion error.</param> 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); } } }