Add data validation and a world setting for enabling the Global API

This commit is contained in:
Oliver-Akins 2025-05-25 02:00:13 -06:00
parent c3d632274a
commit 354b22da57
7 changed files with 180 additions and 56 deletions

View file

@ -10,16 +10,13 @@ import { MemoryDatabase } from "./utils/databases/Memory.mjs";
import { UserFlagDatabase } from "./utils/databases/UserFlag.mjs"; import { UserFlagDatabase } from "./utils/databases/UserFlag.mjs";
// Utils // Utils
import { barGraphSchema, numberBucketSchema, rowSchema, stringBucketSchema, tableSchema } from "./utils/databases/model.mjs";
import { filterPrivateRows, PrivacyMode } from "./utils/privacy.mjs"; import { filterPrivateRows, PrivacyMode } from "./utils/privacy.mjs";
import { validateBucketConfig, validateValue } from "./utils/buckets.mjs"; import { validateBucketConfig, validateValue } from "./utils/buckets.mjs";
const { deepFreeze } = foundry.utils; const { deepFreeze } = foundry.utils;
Object.defineProperty( export const api = deepFreeze({
globalThis,
`stats`,
{
value: deepFreeze({
Apps: { Apps: {
TestApp, TestApp,
StatsViewer, StatsViewer,
@ -39,7 +36,16 @@ Object.defineProperty(
MemoryDatabase, MemoryDatabase,
UserFlagDatabase, UserFlagDatabase,
}, },
}), schemas: {
writable: false, buckets: {
range: numberBucketSchema,
number: numberBucketSchema,
string: stringBucketSchema,
}, },
); graphs: {
bar: barGraphSchema,
},
table: tableSchema,
row: rowSchema,
},
});

View file

@ -9,6 +9,7 @@ import { TableCreator } from "../Apps/TableCreator.mjs";
import { TableManager } from "../Apps/TableManager.mjs"; import { TableManager } from "../Apps/TableManager.mjs";
// Misc Imports // Misc Imports
import { api } from "../api.mjs";
import helpers from "../handlebarsHelpers/_index.mjs"; import helpers from "../handlebarsHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs"; import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../Apps/elements/_index.mjs"; import { registerCustomComponents } from "../Apps/elements/_index.mjs";
@ -45,6 +46,18 @@ Hooks.on(`init`, () => {
CONFIG.stats.db = MemoryDatabase; 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); Handlebars.registerHelper(helpers);
registerCustomComponents(); registerCustomComponents();
}); });

View file

@ -1,18 +1,26 @@
/* /*
World Settings: World Settings:
- Track rolls automatically
- Track inactive rolls (e.g. the "lower" in a "kh" roll) - Track inactive rolls (e.g. the "lower" in a "kh" roll)
- Track self rolls (defaulta false)
*/ */
export function registerWorldSettings() { export function registerWorldSettings() {
game.settings.register(__ID__, `autoTrackRolls`, { game.settings.register(__ID__, `autoTrackRolls`, {
name: `Roll Auto-Tracking`, name: `STAT_TRACKER.settings.autoTrackRolls.name`,
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.`, hint: `STAT_TRACKER.settings.autoTrackRolls.hint`,
scope: `world`, scope: `world`,
type: Boolean, type: Boolean,
config: true, config: true,
default: true, default: true,
requiresReload: 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,
});
}; };

View file

@ -1,6 +1,8 @@
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
import { BucketTypes, validateBucketConfig } from "../buckets.mjs";
import { Logger } from "../Logger.mjs";
import { PrivacyMode } from "../privacy.mjs"; import { PrivacyMode } from "../privacy.mjs";
import { validateBucketConfig } from "../buckets.mjs"; import { tableSchema } from "./model.mjs";
/* /*
NOTE: NOTE:
@ -28,6 +30,12 @@ Default Subtables:
const { deleteProperty, diffObject, expandObject, mergeObject } = foundry.utils; 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 { export class Database {
// MARK: Table Ops // MARK: Table Ops
static async createTable(tableConfig) { static async createTable(tableConfig) {
@ -36,25 +44,42 @@ export class Database {
return false; return false;
}; };
const name = tableConfig.name; const { error, value: corrected } = tableSchema.validate(
if (name.split(`/`).length > 2) { tableConfig,
ui.notifications.error(`Subtables are not able to have subtables`); { 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; return false;
}; };
const tables = game.settings.get(__ID__, `tables`); const name = tableConfig.name;
const [ table, subtable ] = name.split(`/`); const [ table, subtable ] = name.split(`/`);
const tables = game.settings.get(__ID__, `tables`);
if (subtable && tables[table]) { if (subtable && tables[table]) {
ui.notifications.error(`Cannot add subtable for a table that already exists`); ui.notifications.error(`Cannot add subtable for a table that already exists`);
return false; 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]) { if (tables[name]) {
ui.notifications.error(`Cannot create table that already exists`); ui.notifications.error(`Cannot create table that already exists`);
return false; return false;
}; };
tables[name] = tableConfig; tables[name] = corrected;
game.settings.set(__ID__, `tables`, tables); game.settings.set(__ID__, `tables`, tables);
this.render({ tags: [`table`] }); this.render({ tags: [`table`] });
return true; return true;
@ -99,7 +124,7 @@ export class Database {
try { try {
updated.buckets = validateBucketConfig(updated.buckets); updated.buckets = validateBucketConfig(updated.buckets);
} catch (e) { } catch (e) {
ui.notifications.error(e); Logger.error(e);
return false; return false;
}; };

View file

@ -1,6 +1,7 @@
import { filterPrivateRows, PrivacyMode } from "../privacy.mjs"; import { filterPrivateRows, PrivacyMode } from "../privacy.mjs";
import { Database } from "./Database.mjs"; import { Database } from "./Database.mjs";
import { Logger } from "../Logger.mjs"; import { Logger } from "../Logger.mjs";
import { rowSchema } from "./model.mjs";
const { hasProperty, mergeObject, randomID } = foundry.utils; const { hasProperty, mergeObject, randomID } = foundry.utils;
@ -13,12 +14,22 @@ export class UserFlagDatabase extends Database {
let user = game.users.get(userID); let user = game.users.get(userID);
if (!table || !user) { return }; if (!table || !user) { return };
row._id ||= randomID(); row._id = randomID();
row.timestamp = new Date().toISOString(); 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); const userData = user.getFlag(__ID__, dataFlag);
userData[tableID] ??= []; userData[tableID] ??= [];
userData[tableID].push(row); userData[tableID].push(corrected);
await user.setFlag(__ID__, dataFlag, userData); await user.setFlag(__ID__, dataFlag, userData);
if (rerender) { if (rerender) {
@ -36,9 +47,21 @@ export class UserFlagDatabase extends Database {
userData[tableID] ??= []; userData[tableID] ??= [];
for (const row of rows) { for (const row of rows) {
row._id ||= randomID(); row._id = randomID();
row.timestamp = new Date().toISOString(); 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); await user.setFlag(__ID__, dataFlag, userData);

View file

@ -1,7 +1,8 @@
import * as Joi from "joi"; import * as Joi from "joi";
import { PrivacyMode } from "../privacy.mjs";
// MARK: Buckets // MARK: Buckets
const numberBucketSchema = Joi.object({ export const numberBucketSchema = Joi.object({
type: Joi.string().valid(`number`, `range`).required(), type: Joi.string().valid(`number`, `range`).required(),
min: Joi min: Joi
.number() .number()
@ -29,15 +30,18 @@ const numberBucketSchema = Joi.object({
}), }),
}); });
const stringBucketSchema = Joi.object({ export const stringBucketSchema = Joi.object({
type: Joi.string().valid(`string`).required(), type: Joi.string().valid(`string`).required(),
choices: Joi.array(Joi.string()).optional(), choices: Joi.array().items(Joi.string().trim().invalid(``)).optional(),
}); });
// MARK: Graphs // MARK: Graphs
const barGraphSchema = Joi.object({ export const barGraphSchema = Joi.object({
type: Joi.string().valid(`bar`).required(), type: Joi.string().valid(`bar`).required(),
stacked: Joi.boolean().required(), stacked: Joi
.boolean()
.default(true)
.optional(),
}); });
// MARK: Table // MARK: Table
@ -45,16 +49,51 @@ export const tableSchema = Joi.object({
name: Joi name: Joi
.string() .string()
.trim() .trim()
.invalid(``)
.required() .required()
.pattern(/^[a-z \-_]+(\/[a-z \-_]+)?$/i), .pattern(/^[0-9a-z \-_]+(\/[0-9a-z \-_]+)?$/i),
buckets: Joi.alternatives([ buckets: Joi
.alternatives()
.try(
numberBucketSchema, numberBucketSchema,
stringBucketSchema, stringBucketSchema,
]).match(`one`), )
graph: Joi.alternatives([ .match(`one`)
.required(),
graph: Joi
.alternatives()
.try(
barGraphSchema, barGraphSchema,
]).match(`one`), )
.match(`one`)
.required(),
}); });
// MARK: Row // 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(),
});

View file

@ -5,6 +5,16 @@
"Subtable": "Subtable", "Subtable": "Subtable",
"Users": "Users", "Users": "Users",
"DataVisibility": "Data Visibility" "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."
}
} }
} }
} }