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 ****/ /// Read data from a JSON file in the mod's folder. /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. /// The file path relative to the mod folder. /// Returns the deserialized model, or null if the file doesn't exist or is empty. /// The is not relative or contains directory climbing (../). 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.NormalizePathSeparators(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) ? data : null; } /// Save data to a JSON file in the mod's folder. /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. /// The file path relative to the mod folder. /// The arbitrary data to save. /// The is not relative or contains directory climbing (../). 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.NormalizePathSeparators(path)); this.JsonHelper.WriteJsonFile(path, data); } /**** ** Save file ****/ /// Read arbitrary data stored in the current save slot. This is only possible if a save has been loaded. /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. /// The unique key identifying the data. /// Returns the parsed data, or null if the entry doesn't exist or is empty. /// The player hasn't loaded a save file yet or isn't the main player. 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 (!Game1.IsMasterGame) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (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; } /// Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day. /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. /// The unique key identifying the data. /// The arbitrary data to save. /// The player hasn't loaded a save file yet or isn't the main player. 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 (!Game1.IsMasterGame) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} because this isn't the main player. (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 ****/ /// Read arbitrary data stored on the local computer, synchronised by GOG/Steam if applicable. /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. /// The unique key identifying the data. /// Returns the parsed data, or null if the entry doesn't exist or is empty. public TModel ReadGlobalData(string key) where TModel : class { string path = this.GetGlobalDataPath(key); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) ? data : null; } /// Save arbitrary data to the local computer, synchronised by GOG/Steam if applicable. /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. /// The unique key identifying the data. /// The arbitrary data to save. 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.SavesPath, ".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); } } }