diff --git a/module/api.mjs b/module/api.mjs index 45b7b79..0dbaf9d 100644 --- a/module/api.mjs +++ b/module/api.mjs @@ -10,36 +10,42 @@ import { MemoryDatabase } from "./utils/databases/Memory.mjs"; import { UserFlagDatabase } from "./utils/databases/UserFlag.mjs"; // Utils +import { barGraphSchema, numberBucketSchema, rowSchema, stringBucketSchema, tableSchema } from "./utils/databases/model.mjs"; import { filterPrivateRows, PrivacyMode } from "./utils/privacy.mjs"; import { validateBucketConfig, validateValue } from "./utils/buckets.mjs"; const { deepFreeze } = foundry.utils; -Object.defineProperty( - globalThis, - `stats`, - { - value: deepFreeze({ - Apps: { - TestApp, - StatsViewer, - TableCreator, - TableManager, - }, - utils: { - filterPrivateRows, - validateValue, - validateBucketConfig, - }, - enums: { - PrivacyMode, - }, - databases: { - Database, - MemoryDatabase, - UserFlagDatabase, - }, - }), - writable: false, +export const api = deepFreeze({ + Apps: { + TestApp, + StatsViewer, + TableCreator, + TableManager, }, -); + utils: { + filterPrivateRows, + validateValue, + validateBucketConfig, + }, + enums: { + PrivacyMode, + }, + databases: { + Database, + MemoryDatabase, + UserFlagDatabase, + }, + schemas: { + buckets: { + range: numberBucketSchema, + number: numberBucketSchema, + string: stringBucketSchema, + }, + graphs: { + bar: barGraphSchema, + }, + table: tableSchema, + row: rowSchema, + }, +}); diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index 0c8fcfe..1422bce 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -9,6 +9,7 @@ import { TableCreator } from "../Apps/TableCreator.mjs"; import { TableManager } from "../Apps/TableManager.mjs"; // Misc Imports +import { api } from "../api.mjs"; import helpers from "../handlebarsHelpers/_index.mjs"; import { Logger } from "../utils/Logger.mjs"; import { registerCustomComponents } from "../Apps/elements/_index.mjs"; @@ -45,6 +46,18 @@ Hooks.on(`init`, () => { CONFIG.stats.db = MemoryDatabase; }; + game.modules.get(__ID__).api = api; + if (game.settings.get(__ID__, `globalAPI`)) { + Object.defineProperty( + globalThis, + `stats`, + { + value: api, + writable: false, + }, + ); + }; + Handlebars.registerHelper(helpers); registerCustomComponents(); }); diff --git a/module/settings/world.mjs b/module/settings/world.mjs index 6da838c..7d44034 100644 --- a/module/settings/world.mjs +++ b/module/settings/world.mjs @@ -1,18 +1,26 @@ /* World Settings: - - Track rolls automatically - Track inactive rolls (e.g. the "lower" in a "kh" roll) - - Track self rolls (defaulta false) */ export function registerWorldSettings() { game.settings.register(__ID__, `autoTrackRolls`, { - name: `Roll Auto-Tracking`, - hint: `Whether or not the module should automatically add rolls made in the chat to the database. This is useful if the system you're using has implemented an integration with the module, or if you only want macros to handle the database additions.`, + name: `STAT_TRACKER.settings.autoTrackRolls.name`, + hint: `STAT_TRACKER.settings.autoTrackRolls.hint`, scope: `world`, type: Boolean, config: true, default: true, requiresReload: true, }); + + game.settings.register(__ID__, `globalAPI`, { + name: `STAT_TRACKER.settings.globalAPI.name`, + hint: `STAT_TRACKER.settings.globalAPI.hint`, + scope: `world`, + type: Boolean, + config: true, + default: import.meta.env.DEV, + requiresReload: true, + }); }; diff --git a/module/utils/databases/Database.mjs b/module/utils/databases/Database.mjs index 932ac46..8185b8f 100644 --- a/module/utils/databases/Database.mjs +++ b/module/utils/databases/Database.mjs @@ -1,6 +1,8 @@ /* eslint-disable no-unused-vars */ +import { BucketTypes, validateBucketConfig } from "../buckets.mjs"; +import { Logger } from "../Logger.mjs"; import { PrivacyMode } from "../privacy.mjs"; -import { validateBucketConfig } from "../buckets.mjs"; +import { tableSchema } from "./model.mjs"; /* NOTE: @@ -28,6 +30,12 @@ Default Subtables: const { deleteProperty, diffObject, expandObject, mergeObject } = foundry.utils; +/** + * The generic Database implementation, any subclasses should implement all of + * the required methods, optionally overriding the methods provided by this class, + * data validation should be used on any and all of the create* methods to ensure + * consistency across databases. + */ export class Database { // MARK: Table Ops static async createTable(tableConfig) { @@ -36,25 +44,42 @@ export class Database { return false; }; - const name = tableConfig.name; - if (name.split(`/`).length > 2) { - ui.notifications.error(`Subtables are not able to have subtables`); + const { error, value: corrected } = tableSchema.validate( + tableConfig, + { abortEarly: false, convert: true, dateFormat: `iso`, render: false }, + ); + if (error) { + ui.notifications.error(`Table being created did not conform to required schema, see console for more information.`, { console: false }); + Logger.error(error); return false; }; - const tables = game.settings.get(__ID__, `tables`); + const name = tableConfig.name; const [ table, subtable ] = name.split(`/`); + + const tables = game.settings.get(__ID__, `tables`); if (subtable && tables[table]) { ui.notifications.error(`Cannot add subtable for a table that already exists`); return false; }; + if (table === `Dice`) { + if (!subtable.match(/^d[0-9]+$/)) { + ui.notifications.error(`Cannot create a Dice subtable that doesn't use "dX" as it's subtable name.`); + return false; + }; + if (tableConfig.buckets.type === BucketTypes.RANGE) { + ui.notifications.error(`Cannot create a Dice subtable with a non-range bucket type`); + return false; + }; + }; + if (tables[name]) { ui.notifications.error(`Cannot create table that already exists`); return false; }; - tables[name] = tableConfig; + tables[name] = corrected; game.settings.set(__ID__, `tables`, tables); this.render({ tags: [`table`] }); return true; @@ -99,7 +124,7 @@ export class Database { try { updated.buckets = validateBucketConfig(updated.buckets); } catch (e) { - ui.notifications.error(e); + Logger.error(e); return false; }; diff --git a/module/utils/databases/UserFlag.mjs b/module/utils/databases/UserFlag.mjs index 245a978..7072c5c 100644 --- a/module/utils/databases/UserFlag.mjs +++ b/module/utils/databases/UserFlag.mjs @@ -1,6 +1,7 @@ import { filterPrivateRows, PrivacyMode } from "../privacy.mjs"; import { Database } from "./Database.mjs"; import { Logger } from "../Logger.mjs"; +import { rowSchema } from "./model.mjs"; const { hasProperty, mergeObject, randomID } = foundry.utils; @@ -13,12 +14,22 @@ export class UserFlagDatabase extends Database { let user = game.users.get(userID); if (!table || !user) { return }; - row._id ||= randomID(); + row._id = randomID(); row.timestamp = new Date().toISOString(); + const { error, value: corrected } = rowSchema.validate( + row, + { abortEarly: false, convert: true, dateFormat: `iso`, render: false }, + ); + if (error) { + ui.notifications.error(`Row being created did not conform to required schema, see console for more information.`, { console: false }); + Logger.error(error); + return false; + }; + const userData = user.getFlag(__ID__, dataFlag); userData[tableID] ??= []; - userData[tableID].push(row); + userData[tableID].push(corrected); await user.setFlag(__ID__, dataFlag, userData); if (rerender) { @@ -36,9 +47,21 @@ export class UserFlagDatabase extends Database { userData[tableID] ??= []; for (const row of rows) { - row._id ||= randomID(); + row._id = randomID(); row.timestamp = new Date().toISOString(); - userData[tableID].push(row); + + const { error, value: corrected } = rowSchema.validate( + row, + { abortEarly: false, convert: true, dateFormat: `iso`, render: false }, + ); + if (error) { + ui.notifications.error(`A row being created did not conform to required schema, see console for more information.`, { console: false }); + Logger.error(`Failing row:`, row); + Logger.error(error); + continue; + }; + + userData[tableID].push(corrected); }; await user.setFlag(__ID__, dataFlag, userData); diff --git a/module/utils/databases/model.mjs b/module/utils/databases/model.mjs index caa7b02..ed4afa8 100644 --- a/module/utils/databases/model.mjs +++ b/module/utils/databases/model.mjs @@ -1,7 +1,8 @@ import * as Joi from "joi"; +import { PrivacyMode } from "../privacy.mjs"; // MARK: Buckets -const numberBucketSchema = Joi.object({ +export const numberBucketSchema = Joi.object({ type: Joi.string().valid(`number`, `range`).required(), min: Joi .number() @@ -29,15 +30,18 @@ const numberBucketSchema = Joi.object({ }), }); -const stringBucketSchema = Joi.object({ +export const stringBucketSchema = Joi.object({ type: Joi.string().valid(`string`).required(), - choices: Joi.array(Joi.string()).optional(), + choices: Joi.array().items(Joi.string().trim().invalid(``)).optional(), }); // MARK: Graphs -const barGraphSchema = Joi.object({ +export const barGraphSchema = Joi.object({ type: Joi.string().valid(`bar`).required(), - stacked: Joi.boolean().required(), + stacked: Joi + .boolean() + .default(true) + .optional(), }); // MARK: Table @@ -45,16 +49,51 @@ export const tableSchema = Joi.object({ name: Joi .string() .trim() + .invalid(``) .required() - .pattern(/^[a-z \-_]+(\/[a-z \-_]+)?$/i), - buckets: Joi.alternatives([ - numberBucketSchema, - stringBucketSchema, - ]).match(`one`), - graph: Joi.alternatives([ - barGraphSchema, - ]).match(`one`), + .pattern(/^[0-9a-z \-_]+(\/[0-9a-z \-_]+)?$/i), + buckets: Joi + .alternatives() + .try( + numberBucketSchema, + stringBucketSchema, + ) + .match(`one`) + .required(), + graph: Joi + .alternatives() + .try( + barGraphSchema, + ) + .match(`one`) + .required(), }); // MARK: Row -export const rowSchema = Joi.object({}); +/** + * The schema for the row objects, this does not validate that the + * value of the row conforms to the bucket configurations, however + * it does validate that the value is at least one of the accepted + * types. For validation of the value itself check "validateValue" + * in `utils/buckets.mjs` + */ +export const rowSchema = Joi.object({ + _id: Joi + .string() + .alphanum() + .required(), + timestamp: Joi + .string() + .isoDate() + .required(), + value: Joi + .alternatives([ + Joi.string().trim().invalid(``), + Joi.number(), + ]) + .required(), + privacy: Joi + .string() + .valid(...Object.values(PrivacyMode)) + .required(), +}); diff --git a/public/langs/en-ca.json b/public/langs/en-ca.json index 4cf777c..7b30eea 100644 --- a/public/langs/en-ca.json +++ b/public/langs/en-ca.json @@ -5,6 +5,16 @@ "Subtable": "Subtable", "Users": "Users", "DataVisibility": "Data Visibility" + }, + "settings": { + "autoTrackRolls": { + "name": "Roll Auto-Tracking", + "hint": "Whether or not the module should automatically add rolls made in the chat to the database. This is useful if the system you're using has implemented an integration with the module, or if you only want macros to handle the database additions." + }, + "globalAPI": { + "name": "Global API", + "hint": "Whether or not the module provides a global interface for interacting with the module's backend. This is convenient for macros and using the dev console." + } } } }