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