diff --git a/.env.template b/.env.template
new file mode 100644
index 0000000..180dbd6
--- /dev/null
+++ b/.env.template
@@ -0,0 +1,2 @@
+# The absolute path to the Foundry installation to create symlinks to
+FOUNDRY_ROOT=""
diff --git a/.github/ISSUE_TEMPLATE/BugReport.yaml b/.github/ISSUE_TEMPLATE/BugReport.yaml
new file mode 100644
index 0000000..4e77681
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BugReport.yaml
@@ -0,0 +1,53 @@
+name: Bug Report
+description: Have a bug to report, use this template!
+labels:
+ - Bug
+body:
+ - type: input
+ id: affected-version
+ attributes:
+ label: Affected Versions
+ description: >-
+ What version(s) of the stat-tracker module are you seeing this bug
+ happen on? If you put "latest" your bug report will be invalidated.
+ validations:
+ required: true
+ - type: input
+ id: system-id
+ attributes:
+ label: Game System
+ description: >-
+ What game system are you using the module with when you encounter the
+ bug?
+ placeholder: dnd5e
+ validations:
+ required: true
+ - type: textarea
+ id: description
+ attributes:
+ label: Bug Description
+ description: >-
+ Describe the bug you're encountering, the more detail you provide the
+ better.
+ validations:
+ required: true
+ - type: checkboxes
+ id: affected-environments
+ attributes:
+ label: Affected Environments
+ description: How are you accessing Foundry when you encounter this bug?
+ options:
+ - label: Desktop App
+ required: false
+ - label: Chrome
+ required: false
+ - label: Firefox
+ required: false
+ - type: textarea
+ id: support-summary
+ attributes:
+ label: Support Data
+ description: >-
+ Please open the settings tab in Foundry's sidebar, click "Support &
+ Issues", then press "Copy Report to Clipboard" and then paste that text
+ into the box below
diff --git a/.github/ISSUE_TEMPLATE/FeatureRequest.yaml b/.github/ISSUE_TEMPLATE/FeatureRequest.yaml
new file mode 100644
index 0000000..86caba4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FeatureRequest.yaml
@@ -0,0 +1,11 @@
+name: Feature Request
+description: Have a feature request? Use this template!
+labels:
+ - Feature
+body:
+ - type: textarea
+ attributes:
+ label: Description
+ description: A clear and concise description of the problem or missing capability
+ validations:
+ required: true
\ No newline at end of file
diff --git a/.github/workflows/draft-release.yaml b/.github/workflows/draft-release.yaml
new file mode 100644
index 0000000..d38d6ab
--- /dev/null
+++ b/.github/workflows/draft-release.yaml
@@ -0,0 +1,62 @@
+name: Create Draft Release
+on: [workflow_dispatch]
+jobs:
+ everything:
+ runs-on: ubuntu-latest
+ steps:
+ # Checkout the repository
+ - uses: actions/checkout@v4
+
+ # Install node and NPM
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "19"
+
+ # Install required packages
+ - run: npm install
+ - run: npm run build
+
+ - name: Reading the system.json for the version
+ id: "version"
+ run: cat prod.dist/module.json | echo version=`jq -r ".version"` >> "$GITHUB_OUTPUT"
+
+ # Check that tag doesn't exist
+ - uses: mukunku/tag-exists-action@v1.5.0
+ id: check-tag
+ with:
+ tag: "v${{ steps.version.outputs.version }}"
+
+ - name: "Ensure that the tag doesn't exist"
+ if: ${{ steps.check-tag.outputs.exists == 'true' }}
+ run: exit 1
+
+ - name: Move the manifest to a temp file
+ id: manifest-move
+ run: mv prod.dist/module.json prod.dist/module.temp.json
+
+ - name: Update the download property in the manifest
+ id: manifest-update
+ run: cat prod.dist/module.temp.json | jq -r --tab '.download = "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/release.zip"' > prod.dist/module.json
+
+ - name: Delete temp files from build
+ run: rm prod.dist/*.temp.*
+
+ - name: Create the zip
+ run: cd prod.dist; zip -r release.zip *
+
+ - name: Create the draft release
+ uses: ncipollo/release-action@v1
+ with:
+ tag: "v${{ steps.version.outputs.version }}"
+ commit: ${{ github.ref }}
+ draft: true
+ body: >
+ |
+ |
+ | ### Changes:
+ | -
+ |
+ | This version can be installed using this manifest URL: https://github.com/Oliver-Akins/Foundry-Stat-Tracker/releases/download/v${{ steps.version.outputs.version }}/module.json
+ generateReleaseNotes: true
+ artifacts: "prod.dist/release.zip,prod.dist/module.json"
+ artifactsErrorsFailBuild: true
diff --git a/.gitignore b/.gitignore
index 775a33f..ff8974d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,10 @@
+# Ignore all of the binaries and stuff that gets built for Foundry from the raw
+# JSON data because it's annoying seeing it in my git changes when it isn't actually
+# needed.
+/packs/**/*
+!/packs/**/*/
+!/packs/**/*.json
+
# Logs
logs
*.log
@@ -10,6 +17,8 @@ lerna-debug.log*
node_modules
/*.dist
*.local
+.env
+/foundry
# Editor directories and files
.vscode/*
diff --git a/.promo/imgs/example-dice-stat.png b/.promo/imgs/example-dice-stat.png
new file mode 100644
index 0000000..63cb502
Binary files /dev/null and b/.promo/imgs/example-dice-stat.png differ
diff --git a/.promo/imgs/example-string-stat.png b/.promo/imgs/example-string-stat.png
new file mode 100644
index 0000000..fa884dc
Binary files /dev/null and b/.promo/imgs/example-string-stat.png differ
diff --git a/.promo/imgs/example-table-config.png b/.promo/imgs/example-table-config.png
new file mode 100644
index 0000000..a86cdf5
Binary files /dev/null and b/.promo/imgs/example-table-config.png differ
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..dbdb0fa
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
\ No newline at end of file
diff --git a/README.md b/README.md
index ccf8f72..61e924e 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,30 @@
-# Foundry-Stat-Tracker
-A Foundry module that allows tracking arbitrary stats.
+# Stats Tracker
+This FoundryVTT module aims to provide a clean way of keeping track of any data
+points you want within Foundry, whether that be dice rolls, or other things like
+how many natural 1s to natural 20s you get.
+
+I was inspired by the dicestats module, however it only allows tracking dice
+statistics, which is something I found myself needing to work around and struggle
+against, so I decided to make this module to fill that gap while improving upon
+the graph rendering.
+
+For more information on how to use this module, check out the "Documentation"
+compendium within your world!
+
+## Installation
+
+
+
+
+
+You can find a history of all releases on the [Foundry package listing](https://foundryvtt.com/packages/stat-tracker)
+or in the [GitHub releases tab](https://github.com/Oliver-Akins/Foundry-Stat-Tracker/releases).
+Prereleases will only be released to the GitHub page, so if you want to check
+out those releases, you'll need to use those manifest links directly.
+
+## Bugs or Feature Requests
+Bugs and Feature Requests can be submitted via the [GitHub issues](https://github.com/Oliver-Akins/Foundry-Stat-Tracker/issues/new/choose).
+Planned features can also be seen in the GitHub issues list.
+
+## Contribution
+Contribution guidelines / requirements coming soon.
diff --git a/augments.d.ts b/augments.d.ts
new file mode 100644
index 0000000..a08bb60
--- /dev/null
+++ b/augments.d.ts
@@ -0,0 +1,14 @@
+declare global {
+ class Hooks extends foundry.helpers.Hooks {};
+ const fromUuid = foundry.utils.fromUuid;
+}
+
+interface Actor {
+ /** The system-specific data */
+ system: any;
+};
+
+interface Item {
+ /** The system-specific data */
+ system: any;
+};
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ea4be48..dd8bf28 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -45,6 +45,7 @@ export default [
},
languageOptions: {
globals: {
+ __TITLE__: `readonly`,
__ID__: `readonly`,
__VERSION__: `readonly`,
},
diff --git a/jsconfig.json b/jsconfig.json
index 8b97154..27f6e7c 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -1,10 +1,22 @@
{
"compilerOptions": {
"module": "ES2020",
- "target": "ES2020"
+ "target": "ES2020",
+ "types": [
+ "./augments.d.ts"
+ ],
+ "paths": {
+ "@client/*": ["./foundry/client/*"],
+ "@common/*": ["./foundry/common/*"],
+ }
},
"exclude": ["node_modules", "**/node_modules/*"],
- "include": ["module/**/*"],
+ "include": [
+ "module/**/*",
+ "foundry/client/client.mjs",
+ "foundry/client/global.d.mts",
+ "foundry/common/primitives/global.d.mts"
+ ],
"typeAcquisition": {
"include": ["joi"]
}
diff --git a/module/Apps/StatSidebar.mjs b/module/Apps/StatSidebar.mjs
index 664d93e..0542ca7 100644
--- a/module/Apps/StatSidebar.mjs
+++ b/module/Apps/StatSidebar.mjs
@@ -1,13 +1,9 @@
import { filePath } from "../consts.mjs";
-import { Logger } from "../utils/Logger.mjs";
-import { PrivacyMode } from "../utils/privacy.mjs";
-const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
+const { HandlebarsApplicationMixin } = foundry.applications.api;
const { AbstractSidebarTab } = foundry.applications.sidebar;
-const { getType } = foundry.utils;
export class StatSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab) {
- // #region Options
/** @override */
static DEFAULT_OPTIONS = {
classes: [
@@ -18,7 +14,9 @@ export class StatSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab)
title: `SIDEBAR.TabSettings`,
},
actions: {
- openApp: this.#openApp,
+ openStats: this.#openStats,
+ manageTables: this.#manageTables,
+ createTable: this.#createTable,
},
};
@@ -27,78 +25,29 @@ export class StatSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab)
/** @override */
static PARTS = {
- // stats: {
- // template: filePath(`templates/Apps/StatSidebar/main.hbs`),
- // root: true,
- // },
- summaryText: {
- template: filePath(`templates/Apps/StatSidebar/.hbs`),
- },
- summaryGraph: {
- template: filePath(`templates/Apps/StatSidebar/.hbs`),
- },
- appControls: {
- template: filePath(`templates/Apps/StatSidebar/controlSection.hbs`),
+ stats: {
+ template: filePath(`templates/Apps/StatSidebar/main.hbs`),
+ root: true,
},
};
- // #endregion Options
- // #region Lifecycle
- async render(options, _options) {
- const { userUpdated = null } = (getType(options) === `Object` ? options : _options) ?? {};
- if (userUpdated && userUpdated !== game.user.id) {
- // TODO: Update the data in the graph
- return;
- };
- return super.render(options, _options);
- };
-
- async _onFirstRender(context, options) {
- await super._onFirstRender(context, options);
- CONFIG.stats.db.addApp(this);
- };
- // #endregion Lifecycle
-
- // #region Data Prep
- async _preparePartContext(context, options) {
+ async _prepareContext(options) {
const ctx = await super._prepareContext(options);
-
- this.#prepareApps(ctx);
-
- return ctx;
- };
-
- async #prepareSummary(ctx) {
const db = CONFIG.stats.db;
- const tables = await db.getTables();
- ctx.tableCount = tables.length;
- ctx.rowCount = {
- total: 0,
- public: 0,
- self: 0,
- private: 0,
- gm: 0,
- };
- for (const table of tables) {
- const rows = await db.getRows(table.name, [game.user.id], Object.values(PrivacyMode));
- for (const row of rows[game.user.id] ?? []) {
- ctx.rowCount[row.privacy]++;
- ctx.rowCount.total++;
- };
- };
- };
+ ctx.tableCount = (await db.getTables()).length;
- async #prepareApps(ctx) {
const controls = {
- openStats: { label: `View Stats`, action: `openApp`, appKey: `viewer` },
- createTable: { label: `Create New Table`, action: `openApp`, appKey: `creator` },
- manageTables: { label: `Manage Tables`, action: `openApp`, appKey: `tableManager` },
- // manageData: { label: `Manage Data`, action: `openApp`, appKey: `rowManager` },
+ openStats: { label: `View Stats`, action: `openStats` },
+ createTable: { label: `Create New Table`, action: `createTable` },
+ manageTables: { label: `Manage Tables`, action: `manageTables` },
+ // manageData: { label: `Manage Data`, action: `` },
};
- if (!game.user.isGM) {
+ if (!db.canCreateTables()) {
delete controls.createTable;
+ };
+ if (!db.canEditTables()) {
delete controls.manageTables;
};
@@ -108,21 +57,26 @@ export class StatSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab)
// delete controls.manageData;
// };
- Hooks.callAll(`${__ID__}.getStatsSidebarApps`, controls);
ctx.controls = Object.values(controls);
- };
- // #endregion Data Prep
- // #region Actions
- static async #openApp(target) {
- const { appKey } = target.dataset;
- const cls = CONFIG.stats[appKey];
- if (!(cls.prototype instanceof ApplicationV2)) {
- Logger.error(`Cannot create an app from`, cls);
- return;
- };
- const app = new cls();
+ return ctx;
+ };
+
+ /** @this {StatSidebar} */
+ static async #openStats() {
+ const app = new CONFIG.stats.viewer;
+ app.render({ force: true });
+ };
+
+ /** @this {StatSidebar} */
+ static async #manageTables() {
+ const app = new CONFIG.stats.manager;
+ app.render({ force: true });
+ };
+
+ /** @this {StatSidebar} */
+ static async #createTable() {
+ const app = new CONFIG.stats.creator;
app.render({ force: true });
};
- // #endregion Actions
};
diff --git a/module/Apps/StatsViewer.mjs b/module/Apps/StatsViewer.mjs
index bfa8447..152be1c 100644
--- a/module/Apps/StatsViewer.mjs
+++ b/module/Apps/StatsViewer.mjs
@@ -21,10 +21,10 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
resizable: true,
minimizable: true,
controls: [
- {
- label: `Add All Users To Graph`,
- action: `addAllUsers`,
- },
+ // {
+ // label: `Add All Users To Graph`,
+ // action: `addAllUsers`,
+ // },
],
},
position: {
@@ -84,11 +84,11 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
// #endregion Instance Data
// #region Lifecycle
- async render({ userUpdated, ...opts } = {}) {
+ async render({ userUpdated, ...opts } = {}, _options) {
if (userUpdated && !this._selectedUsers.includes(userUpdated)) {
return;
}
- await super.render(opts);
+ await super.render(opts, _options);
};
async _onFirstRender(context, options) {
@@ -213,8 +213,6 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
this._privacySetting,
);
- Logger.log(userData);
-
const data = {};
const allBuckets = new Set();
@@ -266,6 +264,11 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
stacked: table.graph?.stacked ?? false,
},
},
+ plugins: {
+ legend: {
+ onClick: null,
+ },
+ },
},
data: {
labels: sortedBucketNames,
@@ -281,7 +284,6 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
}),
},
};
- console.log(`graphData`, this._graphData);
};
// #endregion Data Prep
diff --git a/module/Apps/TableCreator.mjs b/module/Apps/TableCreator.mjs
index 5d9f511..1422fbf 100644
--- a/module/Apps/TableCreator.mjs
+++ b/module/Apps/TableCreator.mjs
@@ -75,7 +75,7 @@ export class TableCreator extends HandlebarsApplicationMixin(ApplicationV2) {
if (this._name.startsWith(`Dice`)) {
ctx.createButtonDisabled = !this._name.match(diceNamespacePattern);
ctx.typeDisabled = true;
- ctx.type = BucketTypes.RANGE;
+ ctx.type = BucketTypes.NUMBER;
this.#diceNamespaceAlert ??= ui.notifications.info(
`Tables in the "Dice" namespace must be formatted as "Dice/dX" where X is the number of sides on the die and are restricted to be ranges 1 to X.`,
{ permanent: true },
@@ -104,11 +104,11 @@ export class TableCreator extends HandlebarsApplicationMixin(ApplicationV2) {
return;
};
- Logger.log(`updating ${binding} value to ${target.value}`);
this[binding] = target.value;
this.render();
};
+ /** @this {TableCreator} */
static async #createTable() {
/** @type {string} */
const name = this._name;
@@ -122,25 +122,38 @@ export class TableCreator extends HandlebarsApplicationMixin(ApplicationV2) {
return;
};
+ let created = false;
if (name.startsWith(`Dice`)) {
if (!name.match(diceNamespacePattern)) {
ui.notifications.error(`Table name doesn't conform to the "Dice/dX" format required by the Dice namespace.`);
return;
};
const size = Number(name.replace(`Dice/d`, ``));
- await CONFIG.stats.db.createTable(createDiceTable(size));
- return;
- };
+ created = await CONFIG.stats.db.createTable(createDiceTable(size));
+ if (created) {
+ this.close();
+ ui.notifications.remove(this.#diceNamespaceAlert);
+ this.#diceNamespaceAlert = null;
+ };
+ } else {
+ created = await CONFIG.stats.db.createTable({
+ name,
+ buckets: {
+ type: this._type,
+ },
+ graph: {
+ type: `bar`,
+ stacked: true,
+ },
+ });
+ }
- await CONFIG.stats.db.createTable({
- name,
- buckets: {
- type: this._type,
- },
- graph: {
- type: `bar`,
- stacked: true,
- },
- });
+ if (created) {
+ this.close();
+ if (this.#diceNamespaceAlert) {
+ ui.notifications.remove(this.#diceNamespaceAlert);
+ this.#diceNamespaceAlert = null;
+ };
+ };
};
};
diff --git a/module/Apps/TableManager.mjs b/module/Apps/TableManager.mjs
index 7516d51..f413805 100644
--- a/module/Apps/TableManager.mjs
+++ b/module/Apps/TableManager.mjs
@@ -4,7 +4,7 @@ import { filePath } from "../consts.mjs";
import { Logger } from "../utils/Logger.mjs";
import { smallToLarge } from "../utils/sorters/smallToLarge.mjs";
-const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
+const { HandlebarsApplicationMixin, ApplicationV2, DialogV2 } = foundry.applications.api;
const { isEmpty } = foundry.utils;
export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
@@ -24,6 +24,12 @@ export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
contentClasses: [`st-scrollable`],
controls: [
// Add action for deleting the table
+ {
+ icon: `fa-solid fa-trash`,
+ label: `Delete Selected Table`,
+ action: `deleteTable`,
+ visible: () => CONFIG.stats.db.canDeleteTables(),
+ },
],
},
position: {
@@ -35,7 +41,9 @@ export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
closeOnSubmit: false,
handler: this.#submit,
},
- actions: {},
+ actions: {
+ deleteTable: this.#deleteTable,
+ },
};
static PARTS = {
@@ -202,7 +210,7 @@ export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
};
async _prepareStringContext(ctx, table) {
- ctx.buckets.choices = [...table.buckets.choices];
+ ctx.buckets.choices = [...(table.buckets.choices ?? [])];
};
// #endregion Data Prep
@@ -239,5 +247,31 @@ export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
}
await CONFIG.stats.db.updateTable(this.activeTableID, formData.object);
};
+
+ /**
+ * @this {TableManager}
+ */
+ static async #deleteTable() {
+ const table = await CONFIG.stats.db.getTable(this.activeTableID);
+ if (!table) {
+ ui.notifications.error(
+ `You must select a table before you can delete it`,
+ { console: false },
+ );
+ return;
+ };
+
+ const confirmed = await DialogV2.confirm({
+ window: {
+ title: `Confirm Deletion`,
+ },
+ content: `
Are you sure you want to delete the table: ${this.activeTableID}
`,
+ });
+ if (!confirmed) { return };
+
+ CONFIG.stats.db.deleteTable(this.activeTableID);
+ this._selectedTable = null;
+ this.render();
+ };
// #endregion Actions
};
diff --git a/module/__tests__/README.md b/module/__tests__/README.md
new file mode 100644
index 0000000..e90289f
--- /dev/null
+++ b/module/__tests__/README.md
@@ -0,0 +1,5 @@
+## Testing
+
+The stat-tracker module utilizes [quench](https://foundryvtt.com/packages/quench)
+for it's end-to-end tests and unit tests, enabling us to be sure that the module
+is as stable as possible and detect when there are breaking changes.
diff --git a/module/__tests__/registration.mjs b/module/__tests__/registration.mjs
new file mode 100644
index 0000000..0d45207
--- /dev/null
+++ b/module/__tests__/registration.mjs
@@ -0,0 +1,13 @@
+import { barGraphTests } from "./schemas/barGraph.test.mjs";
+import { numberBucketTests } from "./schemas/numberBucket.test.mjs";
+import { rowTests } from "./schemas/row.test.mjs";
+import { stringBucketTests } from "./schemas/stringBucket.test.mjs";
+import { tableTests } from "./schemas/table.test.mjs";
+
+Hooks.on(`quenchReady`, (quench) => {
+ numberBucketTests(quench);
+ stringBucketTests(quench);
+ barGraphTests(quench);
+ tableTests(quench);
+ rowTests(quench);
+});
diff --git a/module/__tests__/schemas/barGraph.test.mjs b/module/__tests__/schemas/barGraph.test.mjs
new file mode 100644
index 0000000..a2b9fea
--- /dev/null
+++ b/module/__tests__/schemas/barGraph.test.mjs
@@ -0,0 +1,51 @@
+import { api } from "../../api.mjs";
+
+export function barGraphTests(quench) {
+ quench.registerBatch(
+ `${__ID__}.barGraphSchema`,
+ (ctx) => {
+ const { describe, it, expect } = ctx;
+
+ describe(`the bar graph schema`, () => {
+ it(`should default any additional properties left out`, () => {
+ const { value, error } = api.schemas.graphs.bar.validate(
+ { type: `bar` },
+ );
+ expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow stacked to be provided specifically`, () => {
+ const { value, error } = api.schemas.graphs.bar.validate(
+ { type: `bar`, stacked: true },
+ );
+ expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow showEmptyBuckets to be provided specifically`, () => {
+ const { value, error } = api.schemas.graphs.bar.validate(
+ { type: `bar`, showEmptyBuckets: true },
+ );
+ expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
+ expect(error).to.be.undefined;
+ });
+
+ it(`should only allow showEmptyBuckets to be a boolean`, () => {
+ const { value, error } = api.schemas.graphs.bar.validate(
+ { type: `bar`, showEmptyBuckets: `a potato` },
+ );
+ expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`should only allow stacked to be a boolean`, () => {
+ const { error } = api.schemas.graphs.bar.validate(
+ { type: `bar`, stacked: `a potato` },
+ );
+ expect(error).not.to.be.undefined;
+ });
+ });
+ },
+ );
+};
diff --git a/module/__tests__/schemas/numberBucket.test.mjs b/module/__tests__/schemas/numberBucket.test.mjs
new file mode 100644
index 0000000..2ebba9a
--- /dev/null
+++ b/module/__tests__/schemas/numberBucket.test.mjs
@@ -0,0 +1,61 @@
+import { api } from "../../api.mjs";
+
+export function numberBucketTests(quench) {
+ quench.registerBatch(
+ `${__ID__}.numberBucketSchema`,
+ (ctx) => {
+ const { describe, it, expect } = ctx;
+
+ describe(`the number bucket schema`, () => {
+ it(`should allow all additional properties to be left out`, () => {
+ const { error } = api.schemas.buckets.number.validate(
+ { type: `number` },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow the min additional property if only it is provided with the type`, () => {
+ const { error } = api.schemas.buckets.number.validate(
+ { type: `number`, min: 0 },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow the max additional property if only it is provided with the type`, () => {
+ const { error } = api.schemas.buckets.number.validate(
+ { type: `number`, max: 10 },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`should not allow the step additional property if only it is provided with the type`, () => {
+ const { error } = api.schemas.buckets.number.validate(
+ { type: `number`, step: 1 },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`should not allow max to be less than min`, () => {
+ const { error } = api.schemas.buckets.number.validate(
+ { type: `number`, min: 10, max: 5 },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`should not allow max to be less than min`, () => {
+ const { error } = api.schemas.buckets.number.validate(
+ { type: `number`, min: 10, max: 15 },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow step when min is also provided`, () => {
+ const { error } = api.schemas.buckets.number.validate(
+ { type: `number`, min: 10, step: 5 },
+ );
+ expect(error).to.be.undefined;
+ });
+ });
+ },
+ );
+};
diff --git a/module/__tests__/schemas/row.test.mjs b/module/__tests__/schemas/row.test.mjs
new file mode 100644
index 0000000..394e42e
--- /dev/null
+++ b/module/__tests__/schemas/row.test.mjs
@@ -0,0 +1,96 @@
+import { api } from "../../api.mjs";
+import { PrivacyMode } from "../../utils/privacy.mjs";
+
+export function rowTests(quench) {
+ quench.registerBatch(
+ `${__ID__}.rowSchema`,
+ (ctx) => {
+ const { describe, it, expect } = ctx;
+
+ describe(`the row schema`, () => {
+ it(`should allow number-based values`, () => {
+ const { error } = api.schemas.row.validate(
+ {
+ _id: `1`,
+ timestamp: (new Date()).toISOString(),
+ value: 1,
+ privacy: PrivacyMode.PUBLIC,
+ },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow string-based values`, () => {
+ const { error } = api.schemas.row.validate(
+ {
+ _id: `1`,
+ timestamp: (new Date()).toISOString(),
+ value: `apple`,
+ privacy: PrivacyMode.PUBLIC,
+ },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`shouldn't allow invalid privacy modes`, () => {
+ const { error } = api.schemas.row.validate(
+ {
+ _id: `1`,
+ timestamp: (new Date()).toISOString(),
+ value: 1,
+ privacy: `yahaha`,
+ },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`shouldn't allow invalid value modes`, () => {
+ const { error } = api.schemas.row.validate(
+ {
+ _id: `1`,
+ timestamp: (new Date()).toISOString(),
+ value: true,
+ privacy: PrivacyMode.PUBLIC,
+ },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`shouldn't allow non-ISO date formats`, () => {
+ const { error } = api.schemas.row.validate(
+ {
+ _id: `1`,
+ timestamp: (new Date()).toDateString(),
+ value: 1,
+ privacy: PrivacyMode.PUBLIC,
+ },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`should require an ID to be present`, () => {
+ const { error } = api.schemas.row.validate(
+ {
+ timestamp: (new Date()).toISOString(),
+ value: true,
+ privacy: PrivacyMode.PUBLIC,
+ },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`shouldn't allow empty string as a value`, () => {
+ const { error } = api.schemas.row.validate(
+ {
+ _id: `1`,
+ timestamp: (new Date()).toISOString(),
+ value: ``,
+ privacy: PrivacyMode.PUBLIC,
+ },
+ );
+ expect(error).not.to.be.undefined;
+ });
+ });
+ },
+ );
+};
diff --git a/module/__tests__/schemas/stringBucket.test.mjs b/module/__tests__/schemas/stringBucket.test.mjs
new file mode 100644
index 0000000..d992d63
--- /dev/null
+++ b/module/__tests__/schemas/stringBucket.test.mjs
@@ -0,0 +1,40 @@
+import { api } from "../../api.mjs";
+
+export function stringBucketTests(quench) {
+ quench.registerBatch(
+ `${__ID__}.stringBucketSchema`,
+ (ctx) => {
+ const { describe, it, expect } = ctx;
+
+ describe(`the string bucket schema`, () => {
+ it(`should allow all additional properties to be left out`, () => {
+ const { error } = api.schemas.buckets.string.validate(
+ { type: `string` },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow specific choices to be provided`, () => {
+ const { error } = api.schemas.buckets.string.validate(
+ { type: `string`, choices: [`choice 1`, `choice 2`] },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`shouldn't allow specific choices to be empty`, () => {
+ const { error } = api.schemas.buckets.string.validate(
+ { type: `string`, choices: [] },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`should only allow specific choices to be strings`, () => {
+ const { error } = api.schemas.buckets.string.validate(
+ { type: `string`, choices: [`choice 1`, 5] },
+ );
+ expect(error).not.to.be.undefined;
+ });
+ });
+ },
+ );
+};
diff --git a/module/__tests__/schemas/table.test.mjs b/module/__tests__/schemas/table.test.mjs
new file mode 100644
index 0000000..c3d430e
--- /dev/null
+++ b/module/__tests__/schemas/table.test.mjs
@@ -0,0 +1,43 @@
+import { api } from "../../api.mjs";
+
+const graph = { type: `bar` };
+const buckets = { type: `string` };
+
+export function tableTests(quench) {
+ quench.registerBatch(
+ `${__ID__}.tableSchema`,
+ (ctx) => {
+ const { describe, it, expect } = ctx;
+
+ describe(`the table schema`, () => {
+ it(`should require that name be a non-empty string`, () => {
+ const { error } = api.schemas.table.validate(
+ { name: ``, graph, buckets },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`should require that name only contain alphanumeric characters`, () => {
+ const { error } = api.schemas.table.validate(
+ { name: `:(`, graph, buckets },
+ );
+ expect(error).not.to.be.undefined;
+ });
+
+ it(`should allow the name to contain spaces`, () => {
+ const { error } = api.schemas.table.validate(
+ { name: `a name with spaces`, graph, buckets },
+ );
+ expect(error).to.be.undefined;
+ });
+
+ it(`should allow a single forward slash for subtables`, () => {
+ const { error } = api.schemas.table.validate(
+ { name: `Table/subtable`, graph, buckets },
+ );
+ expect(error).to.be.undefined;
+ });
+ });
+ },
+ );
+};
diff --git a/module/api.mjs b/module/api.mjs
index 45b7b79..274f255 100644
--- a/module/api.mjs
+++ b/module/api.mjs
@@ -10,36 +10,44 @@ import { MemoryDatabase } from "./utils/databases/Memory.mjs";
import { UserFlagDatabase } from "./utils/databases/UserFlag.mjs";
// Utils
-import { filterPrivateRows, PrivacyMode } from "./utils/privacy.mjs";
+import { barGraphSchema, numberBucketSchema, rowSchema, stringBucketSchema, tableSchema } from "./utils/databases/model.mjs";
+import { determinePrivacyFromRollMode, filterPrivateRows, PrivacyMode } from "./utils/privacy.mjs";
import { validateBucketConfig, validateValue } from "./utils/buckets.mjs";
+import { inferRollMode } from "./utils/inferRollMode.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: {
+ determinePrivacyFromRollMode,
+ inferRollMode,
+ filterPrivateRows,
+ validateValue,
+ validateBucketConfig,
+ },
+ enums: {
+ PrivacyMode,
+ },
+ databases: {
+ Database,
+ MemoryDatabase,
+ UserFlagDatabase,
+ },
+ schemas: {
+ buckets: {
+ number: numberBucketSchema,
+ string: stringBucketSchema,
+ },
+ graphs: {
+ bar: barGraphSchema,
+ },
+ table: tableSchema,
+ row: rowSchema,
+ },
+});
diff --git a/module/hooks/createChatMessage.mjs b/module/hooks/createChatMessage.mjs
new file mode 100644
index 0000000..e686c70
--- /dev/null
+++ b/module/hooks/createChatMessage.mjs
@@ -0,0 +1,40 @@
+import { determinePrivacyFromRollMode } from "../utils/privacy.mjs";
+import { inferRollMode } from "../utils/inferRollMode.mjs";
+
+Hooks.on(`createChatMessage`, (message, options, author) => {
+ const isSelf = author === game.user.id;
+ const isNew = options.action === `create`;
+ const hasRolls = message.rolls?.length > 0;
+ const autoTracking = game.settings.get(__ID__, `autoTrackRolls`);
+ if (!isSelf || !isNew || !hasRolls || !autoTracking) { return };
+
+ /** An object of dice denomination to database rows */
+ const rows = {};
+
+ const privacy = determinePrivacyFromRollMode(options.rollMode ?? inferRollMode(message));
+
+ /*
+ Goes through all of the dice within the message and grabs their result in order
+ to batch-save them all to the database handler.
+ */
+ for (const roll of message.rolls) {
+ for (const die of roll.dice) {
+ const size = die.denomination;
+ rows[size] ??= [];
+ for (const result of die.results) {
+ rows[size].push({ privacy, value: result.result });
+ };
+ };
+ };
+
+ // save all the rows, then rerender once we're properly done
+ for (const denomination in rows) {
+ CONFIG.stats.db.createRows(
+ `Dice/${denomination}`,
+ author,
+ rows[denomination],
+ { rerender: false },
+ );
+ };
+ CONFIG.stats.db.render({ userUpdated: author });
+});
diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs
index bf5bf97..e7e1d2d 100644
--- a/module/hooks/init.mjs
+++ b/module/hooks/init.mjs
@@ -9,43 +9,60 @@ 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";
import { registerMetaSettings } from "../settings/meta.mjs";
+import { registerUserSettings } from "../settings/user.mjs";
import { registerWorldSettings } from "../settings/world.mjs";
Hooks.on(`init`, () => {
Logger.debug(`Initializing`);
- // Add a custom sidebar tab for the module
- CONFIG.ui.sidebar.TABS.stats = {
- active: false,
- icon: `fa-solid fa-chart-line`,
- tooltip: `Stats!`,
- };
- CONFIG.ui.stats = StatSidebar;
-
- // Inject the tab right before settings;
- const temp = CONFIG.ui.sidebar.TABS.settings;
- delete CONFIG.ui.sidebar.TABS.settings;
- CONFIG.ui.sidebar.TABS.settings = temp;
-
registerMetaSettings();
registerWorldSettings();
+ registerUserSettings();
+
+ // Add a custom sidebar tab for the module
+ if (game.settings.get(__ID__, `statsSidebarTab`)) {
+ CONFIG.ui.sidebar.TABS.stats = {
+ active: false,
+ icon: `fa-solid fa-chart-line`,
+ tooltip: `Stats!`,
+ };
+ CONFIG.ui.stats = StatSidebar;
+
+ // Inject the custom tab right before settings
+ const temp = CONFIG.ui.sidebar.TABS.settings;
+ delete CONFIG.ui.sidebar.TABS.settings;
+ CONFIG.ui.sidebar.TABS.settings = temp;
+ };
+
CONFIG.stats = {
db: UserFlagDatabase,
viewer: StatsViewer,
creator: TableCreator,
- tableManager: TableManager,
- rowManager: null,
+ manager: TableManager,
};
if (import.meta.env.DEV) {
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/hooks/preCreateChatMessage.mjs b/module/hooks/preCreateChatMessage.mjs
deleted file mode 100644
index 05a4d41..0000000
--- a/module/hooks/preCreateChatMessage.mjs
+++ /dev/null
@@ -1,30 +0,0 @@
-import { determinePrivacyFromRollMode } from "../utils/privacy.mjs";
-
-Hooks.on(`preCreateChatMessage`, (_message, context, options, author) => {
- const isNew = options.action === `create`;
- const hasRolls = context.rolls?.length > 0;
- const autoTracking = game.settings.get(__ID__, `autoTrackRolls`);
- if (!isNew || !hasRolls || !autoTracking) { return };
-
- /** An object of dice denomination to rows to add */
- const rows = {};
-
- const privacy = determinePrivacyFromRollMode(options.rollMode);
- for (const roll of context.rolls) {
- for (const die of roll.dice) {
- const size = die.denomination;
- rows[size] ??= [];
- for (const result of die.results) {
- rows[size].push({ privacy, value: result.result });
- };
- };
- };
-
- for (const denomination in rows) {
- CONFIG.stats.db.createRows(
- `Dice/${denomination}`,
- author,
- rows[denomination],
- );
- };
-});
diff --git a/module/hooks/ready.mjs b/module/hooks/ready.mjs
index 38980e5..025ed1b 100644
--- a/module/hooks/ready.mjs
+++ b/module/hooks/ready.mjs
@@ -1,12 +1,55 @@
import { Database } from "../utils/databases/Database.mjs";
import { Logger } from "../utils/Logger.mjs";
+import { NilDatabase } from "../utils/databases/NilDatabase.mjs";
Hooks.on(`ready`, () => {
Logger.log(`Version: ${__VERSION__}`);
// Alert GMs when the configured DB is invalid
if (!(CONFIG.stats.db.prototype instanceof Database) && game.user.isGM) {
- ui.notifications.error(`The database handler does not conform to the required heirarchy, the stats tracker module will almost certainly not work correctly.`, { permanent: true });
+ ui.notifications.error(`The database adapter does not conform to the required specification, the stats tracker module overrode the configured database adapter with a stub to protect data that exists already.`, { permanent: true });
+ CONFIG.stats.db = NilDatabase;
};
+
+ /*
+ Perform any required data migration if any is required for the version
+ jump that the user may have caused. This only migrates the data iff the
+ currently authenticated user is able to perform the full migration of
+ data.
+ */
+ const db = CONFIG.stats.db;
+ const lastVersion = game.settings.get(__ID__, `lastVersion`);
+ const canDoMigration = db.canPerformMigration();
+ const requiresMigration = db.requiresMigrationFrom(lastVersion);
+ if (requiresMigration) {
+ if (canDoMigration) {
+ const notif = ui.notifications.info(
+ `${__TITLE__} | Performing data migration, please do not close the window`,
+ { progress: true, permanent: true },
+ );
+
+ // Fire and forget
+ CONFIG.stats.db.migrateData(lastVersion, notif)
+ .then(() => {
+ game.settings.set(__ID__, `lastVersion`, __VERSION__);
+ setTimeout(() => ui.notifications.remove(notif), 5_000);
+ });
+ } else {
+ ui.notifications.error(
+ `The stat-tracker database is out of date, temporarily disabling the stat-tracker module's functionality until the migration can be performed by a GM user logging into the world.`,
+ { console: false, permanent: true },
+ );
+ CONFIG.stats.db = NilDatabase;
+ };
+ };
+
+ /*
+ Prevent any run-time modifications to the CONFIG API so that users can't wreck
+ themselves nor their data by fooling around with the values.
+ */
+ if (import.meta.env.PROD) {
+ Object.freeze(CONFIG.stats);
+ };
+
CONFIG.stats.db.registerListeners();
});
diff --git a/module/main.mjs b/module/main.mjs
index b3a76f4..4a90d3f 100644
--- a/module/main.mjs
+++ b/module/main.mjs
@@ -5,4 +5,9 @@ import "./hooks/init.mjs";
import "./hooks/ready.mjs";
// Document Hooks
-import "./hooks/preCreateChatMessage.mjs";
+import "./hooks/createChatMessage.mjs";
+
+// Dev Only imports
+if (import.meta.env.DEV) {
+ import(`./__tests__/registration.mjs`);
+};
diff --git a/module/settings/meta.mjs b/module/settings/meta.mjs
index c236f7e..63e1b48 100644
--- a/module/settings/meta.mjs
+++ b/module/settings/meta.mjs
@@ -23,4 +23,11 @@ export function registerMetaSettings() {
config: false,
requiresReload: false,
});
+
+ game.settings.register(__ID__, `lastVersion`, {
+ scope: `world`,
+ type: String,
+ config: false,
+ requiresReload: false,
+ });
};
diff --git a/module/settings/user.mjs b/module/settings/user.mjs
new file mode 100644
index 0000000..41f384d
--- /dev/null
+++ b/module/settings/user.mjs
@@ -0,0 +1,11 @@
+export function registerUserSettings() {
+ game.settings.register(__ID__, `statsSidebarTab`, {
+ name: `STAT_TRACKER.settings.statsSidebarTab.name`,
+ hint: `STAT_TRACKER.settings.statsSidebarTab.hint`,
+ scope: `user`,
+ type: Boolean,
+ config: true,
+ default: true,
+ requiresReload: true,
+ });
+};
diff --git a/module/settings/world.mjs b/module/settings/world.mjs
index 6da838c..87e7872 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: false,
+ });
+
+ 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/buckets.mjs b/module/utils/buckets.mjs
index c689445..53c78f6 100644
--- a/module/utils/buckets.mjs
+++ b/module/utils/buckets.mjs
@@ -6,7 +6,6 @@ const { StringField, NumberField } = foundry.data.fields;
export const BucketTypes = {
STRING: `string`,
NUMBER: `number`,
- RANGE: `range`,
};
/**
@@ -52,8 +51,7 @@ export function validateBucketConfig(config) {
const validator = validators[conf.type];
if (validator == null) {
- Logger.error(`Failed to find type validator for: ${conf.type}`);
- return false;
+ throw new Error(`Failed to find type validator for: ${conf.type}`);
};
// Disallow function choices if present
@@ -62,7 +60,7 @@ export function validateBucketConfig(config) {
delete conf.choices;
};
- validator.validateConfig(conf);
+ validator.validateConfig?.(conf);
return conf;
};
@@ -75,7 +73,7 @@ const validators = {
opts.trim = true;
opts.blank = false;
},
- validateConfig: (config) => {
+ transformConfig: (config) => {
if (config.choices.length === 0) {
delete config.choices;
config[`-=choices`] = null;
@@ -85,35 +83,6 @@ const validators = {
[BucketTypes.NUMBER]: {
field: NumberField,
transformOptions: transformNumberFieldOptions,
- validateConfig: (config) => {
- if (config.step != null && config.min == null) {
- delete config.step;
- config[`-=step`] = null;
- };
- if (
- config.min != null
- && config.max != null
- && config.min > config.max
- ) {
- throw new Error(`"min" must be less than "max"`);
- }
- },
- },
- [BucketTypes.RANGE]: {
- field: NumberField,
- transformOptions: transformNumberFieldOptions,
- validateConfig: (config) => {
- if (config.min == null) {
- throw new Error(`"min" must be defined for range buckets`);
- };
- if (config.max == null) {
- throw new Error(`"max" must be defined for range buckets`);
- };
- if (config.min > config.max) {
- throw new Error(`"min" must be less than "max"`);
- }
- config.step ??= 1;
- },
},
};
diff --git a/module/utils/databases/Database.mjs b/module/utils/databases/Database.mjs
index 932ac46..9cd4be1 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:
@@ -26,35 +28,86 @@ Default Subtables:
tables that are parents to other tables.
*/
-const { deleteProperty, diffObject, expandObject, mergeObject } = foundry.utils;
+const { deleteProperty, diffObject, expandObject, isNewerVersion, 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: Permissions
+ /**
+ * Indicates whether the authenticated user has permission and is able to
+ * create tables with the specific database implementation. (because tables
+ * are stored as world settings by default, this checks if the user is a GM)
+ */
+ static canCreateTables() {
+ return game.user.isGM;
+ };
+
+ /**
+ * Indicates whether the authenticated user has permission and is able to
+ * edit tables with the specific database implementation. (because tables
+ * are stored as world settings by default, this checks if the user is a GM)
+ */
+ static canEditTables() {
+ return game.user.isGM;
+ };
+
+ /**
+ * Indicates whether the authenticated user has permission and is able to
+ * delete tables with the specific database implementation. (because tables
+ * are stored as world settings by default, this checks if the user is a GM)
+ */
+ static canDeleteTables() {
+ return game.user.isGM;
+ };
+
// MARK: Table Ops
static async createTable(tableConfig) {
- if (!game.user.isGM) {
+ if (!this.canCreateTables()) {
ui.notifications.error(`You do not have the required permission to create a new table`);
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;
@@ -73,8 +126,13 @@ export class Database {
};
static async updateTable(tableID, changes) {
- const table = this.getTable(tableID);
- if (!tables[tableID]) {
+ if (!this.canEditTables()) {
+ ui.notifications.error(`You don't have the required permission to edit tables`);
+ return false;
+ };
+
+ const table = await this.getTable(tableID);
+ if (!table) {
ui.notifications.error(`Cannot update table that doesn't exist`);
return false;
};
@@ -86,7 +144,7 @@ export class Database {
const diff = diffObject(
table,
expandObject(changes),
- { inner: true, deletionKeys: true },
+ { deletionKeys: true },
);
if (Object.keys(diff).length === 0) { return false };
@@ -99,7 +157,7 @@ export class Database {
try {
updated.buckets = validateBucketConfig(updated.buckets);
} catch (e) {
- ui.notifications.error(e);
+ Logger.error(e);
return false;
};
@@ -111,7 +169,7 @@ export class Database {
};
static async deleteTable(tableID) {
- if (!game.user.isGM) {
+ if (!this.canDeleteTables()) {
ui.notifications.error(`You do not have the required permission to delete a table`);
return false;
};
@@ -195,6 +253,63 @@ export class Database {
static async triggerListeners() {};
static async unregisterListeners() {};
+
+ // MARK: Migrations
+ /**
+ * Determines if the currently authenticated user is capable of running
+ * the full migration on their own.
+ *
+ * @returns {boolean}
+ */
+ static canPerformMigration() {
+ // TODO: this *must* account for isActiveGM, because otherwise the
+ // world setting cannot be updated after the migration finishes.
+ return game.user.isActiveGM;
+ };
+
+ /**
+ * Determines if the previous version of the plugin that was active
+ * needs to be migrated in order to work with the new version.
+ *
+ * @param {string} lastVersion The version that was last active
+ * @returns {boolean}
+ */
+ static requiresMigrationFrom(lastVersion) {
+ return isNewerVersion(__VERSION__, lastVersion);
+ };
+
+ /**
+ * This method migrates ALL of the database data from one version of
+ * the module to the currently installed module. This is not guaranteed
+ * to run only on one client, so it should be made to be either
+ * idempotent, or have an operation locking mechanism that can prevent
+ * other clients from executing it if there's a migration in-progress.
+ *
+ * @param {string} lastVersion The last version that the user had active
+ * @param {Notification} notif The progress bar notification used for
+ * user feedback while performing migrations.
+ */
+ static async migrateData(lastVersion, notif) {
+ const totalSteps = 1;
+
+ /*
+ This migration is for going up to 1.0.3, getting rid of any tables that have
+ a bucket type of range, since those were not supported within the initial
+ release, but could still accidentally be created by users.
+ */
+ if (isNewerVersion(`1.0.3`, lastVersion)) {
+ Logger.log(`Migrating up to the v1.0.3 data structure`);
+ const tables = game.settings.get(__ID__, `tables`);
+ for (const table of Object.values(tables)) {
+ if (table.buckets.type !== `range`) { continue };
+ table.buckets.type = BucketTypes.NUMBER;
+ table.buckets.showEmptyBuckets = true;
+ };
+ await game.settings.set(__ID__, `tables`, tables);
+ notif.update({ pct: notif.pct + (1 / totalSteps) });
+ };
+
+ };
};
/* eslint-enable no-unused-vars */
diff --git a/module/utils/databases/Memory.mjs b/module/utils/databases/Memory.mjs
index 6540ba7..b32555a 100644
--- a/module/utils/databases/Memory.mjs
+++ b/module/utils/databases/Memory.mjs
@@ -1,7 +1,6 @@
import { filterPrivateRows, PrivacyMode } from "../privacy.mjs";
import { createDiceTable } from "./utils.mjs";
import { Database } from "./Database.mjs";
-import { Logger } from "../Logger.mjs";
import { validateBucketConfig } from "../buckets.mjs";
const { deleteProperty, diffObject, expandObject, mergeObject, randomID } = foundry.utils;
@@ -117,6 +116,10 @@ export class MemoryDatabase extends Database {
return true;
};
+ static async deleteTable(tableID) {
+ return delete this.#tables[tableID];
+ };
+
static async createRow(table, userID, row, { rerender = true } = {}) {
if (!this.#tables[table]) { return };
this.#rows[userID] ??= {};
@@ -126,7 +129,6 @@ export class MemoryDatabase extends Database {
row._id ||= randomID();
row.timestamp = new Date().toISOString();
- Logger.debug(`Adding row:`, row);
this.#rows[userID][table].push(row);
if (rerender) {
this.render({ userUpdated: userID });
diff --git a/module/utils/databases/NilDatabase.mjs b/module/utils/databases/NilDatabase.mjs
new file mode 100644
index 0000000..c526a2d
--- /dev/null
+++ b/module/utils/databases/NilDatabase.mjs
@@ -0,0 +1,39 @@
+import { Database } from "./Database.mjs";
+
+/**
+ * This database implemention is not recommended for any actual usage,
+ * it is intended for overriding the current database implementation
+ * when a non-conforming Database is provided as the CONFIG.stats.db
+ * value in order to maintain the API interface for dependant modules
+ * and systems.
+ */
+export class NilDatabase extends Database {
+ // MARK: Table Ops
+ static async createTable() {};
+ static async getTables() {};
+ static async getTable() {};
+ static async updateTable() {};
+ static async deleteTable() {};
+
+ // MARK: Row Ops
+ static async createRow() {};
+ static async createRows() {};
+ static async getRows() {};
+ static async updateRow() {};
+ static async deleteRow() {};
+
+ // MARK: Applications
+ static addApp() {};
+ static removeApp() {};
+ static async render() {};
+
+ // MARK: Listeners
+ static async registerListeners() {};
+ static async triggerListeners() {};
+ static async unregisterListeners() {};
+
+ // MARK: Migrations
+ static async canPerformMigration() { return true };
+ static async requiresMigrationFrom() { return false };
+ static async migrateData() {};
+};
diff --git a/module/utils/databases/UserFlag.mjs b/module/utils/databases/UserFlag.mjs
index 245a978..c5292cd 100644
--- a/module/utils/databases/UserFlag.mjs
+++ b/module/utils/databases/UserFlag.mjs
@@ -1,6 +1,8 @@
import { filterPrivateRows, PrivacyMode } from "../privacy.mjs";
import { Database } from "./Database.mjs";
import { Logger } from "../Logger.mjs";
+import { rowSchema } from "./model.mjs";
+import { validateValue } from "../buckets.mjs";
const { hasProperty, mergeObject, randomID } = foundry.utils;
@@ -13,12 +15,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 userData = user.getFlag(__ID__, dataFlag);
+ 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) {
@@ -35,10 +47,44 @@ export class UserFlagDatabase extends Database {
const userData = user.getFlag(__ID__, dataFlag) ?? {};
userData[tableID] ??= [];
+ let valueErrorPosted = false;
+ let validationErrorPosted = false;
+
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) {
+ if (!validationErrorPosted) {
+ ui.notifications.error(
+ `One or more rows being created did not conform to required schema, skipping row and see console for more information.`,
+ { console: false },
+ );
+ validationErrorPosted = true;
+ };
+ Logger.error(`Failing row:`, row);
+ Logger.error(error);
+ continue;
+ };
+
+ const validValue = validateValue(corrected.value, table.buckets);
+ if (!validValue) {
+ if (!valueErrorPosted) {
+ ui.notifications.warn(
+ `One or more rows being created did not contain a valid value, skipping row and see console for more information.`,
+ { console: false },
+ );
+ valueErrorPosted = true;
+ };
+ Logger.warn(`Row with invalid value:`, row);
+ continue;
+ };
+
+ userData[tableID].push(corrected);
};
await user.setFlag(__ID__, dataFlag, userData);
@@ -77,22 +123,36 @@ export class UserFlagDatabase extends Database {
const table = await this.getTable(tableID);
if (!table) {
Logger.error(`Cannot find the table with ID "${tableID}"`);
- return;
+ return false;
};
const user = game.users.get(userID);
if (!user) {
Logger.error(`Can't find the user with ID "${tableID}"`);
- return;
+ return false;
};
const userData = user.getFlag(__ID__, dataFlag) ?? {};
let row = userData[tableID]?.find(row => row._id === rowID);
- if (!row) { return };
+ if (!row) { return false };
+
+ if (hasProperty(changes, `value`)) {
+ const validValue = validateValue(changes.value, table.buckets);
+ if (!validValue) {
+ ui.notifications.warn(
+ `One or more rows being created did not contain a valid value, skipping row and see console for more information.`,
+ { console: false },
+ );
+ Logger.warn(`Row with invalid value:`, row);
+ return false;
+ };
+ };
+
mergeObject(row, changes);
await user.setFlag(__ID__, dataFlag, userData);
this.render({ userUpdated: userID });
this.triggerListeners();
+ return true;
};
static async deleteRow(tableID, userID, rowID) {
@@ -123,7 +183,6 @@ export class UserFlagDatabase extends Database {
if (this.#listener !== null) { return };
this.#listener = Hooks.on(`updateUser`, (doc, diff, options, userID) => {
- Logger.debug({ diff, userID, doc });
// Shortcircuit when on the client that triggered the update
if (userID === game.user.id) { return };
if (!hasProperty(diff, `flags.${__ID__}.${dataFlag}`)) { return };
diff --git a/module/utils/databases/model.mjs b/module/utils/databases/model.mjs
index caa7b02..ed83e23 100644
--- a/module/utils/databases/model.mjs
+++ b/module/utils/databases/model.mjs
@@ -1,43 +1,45 @@
import * as Joi from "joi";
+import { PrivacyMode } from "../privacy.mjs";
// MARK: Buckets
-const numberBucketSchema = Joi.object({
- type: Joi.string().valid(`number`, `range`).required(),
+export const numberBucketSchema = Joi.object({
+ type: Joi.string().valid(`number`).required(),
min: Joi
.number()
.integer()
- .when(`type`, {
- is: Joi.string().valid(`range`),
+ .when(`step`, {
+ is: Joi.exist(),
then: Joi.required(),
- otherwise: Joi.optional(),
}),
max: Joi
.number()
.integer()
- .when(`type`, {
- is: Joi.string().valid(`range`),
- then: Joi.required(),
- otherwise: Joi.optional(),
+ .when(`min`, {
+ is: Joi.exist(),
+ then: Joi.number().greater(Joi.ref(`min`)),
}),
step: Joi
.number()
.integer()
- .when(`type`, {
- is: Joi.string().valid(`range`),
- then: Joi.required(),
- otherwise: Joi.optional(),
- }),
+ .min(1),
});
-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(``),
+ )
+ .min(1)
+ .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().optional().default(true),
+ showEmptyBuckets: Joi.boolean().optional().default(false),
});
// MARK: Table
@@ -45,16 +47,67 @@ 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()
+ .conditional(
+ `/buckets.type`,
+ {
+ switch: [
+ {
+ is: `number`,
+ then: numberBucketSchema,
+ },
+ {
+ is: `string`,
+ then: stringBucketSchema,
+ },
+ ],
+ otherwise: Joi.forbidden(),
+ },
+ )
+ .required(),
+ graph: Joi
+ .alternatives()
+ .conditional(
+ `/graph.type`,
+ {
+ switch: [
+ { is: `bar`, then: barGraphSchema },
+ ],
+ otherwise: Joi.forbidden(),
+ },
+ )
+ .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/module/utils/databases/utils.mjs b/module/utils/databases/utils.mjs
index ba4cf69..58c3907 100644
--- a/module/utils/databases/utils.mjs
+++ b/module/utils/databases/utils.mjs
@@ -2,7 +2,7 @@ export function createDiceTable(size) {
return {
name: `Dice/d${size}`,
buckets: {
- type: `range`,
+ type: `number`,
min: 1,
max: size,
step: 1,
@@ -10,6 +10,7 @@ export function createDiceTable(size) {
graph: {
type: `bar`,
stacked: true,
+ showEmptyBuckets: true,
},
};
};
diff --git a/module/utils/inferRollMode.mjs b/module/utils/inferRollMode.mjs
new file mode 100644
index 0000000..4880c21
--- /dev/null
+++ b/module/utils/inferRollMode.mjs
@@ -0,0 +1,36 @@
+/**
+ * A helper function to try and infer what roll mode was used when creating a
+ * chat message in case the roll mode was not provided during the createChatMessage
+ * hook for whatever reason.
+ *
+ * **Disclaimer**: This inference is not totally correct. Particularly when inferring
+ * a GM's message, as it won't be able to distinguish between a self-roll and a
+ * private GM roll when it's
+ *
+ * @param {ChatMessage} message The ChatMessage document to infer from
+ * @returns The Foundry-specified roll mode
+ */
+export function inferRollMode(message) {
+ const whisperCount = message.whisper.length;
+ if (whisperCount === 0) {
+ return CONST.DICE_ROLL_MODES.PUBLIC;
+ };
+
+ if (whisperCount === 1 && message.whisper[0] === game.user.id) {
+ return CONST.DICE_ROLL_MODES.SELF;
+ };
+
+ let allGMs = true;
+ for (const userID of message.whisper) {
+ const user = game.users.get(userID);
+ if (!user) { continue };
+ allGMs &&= user.isGM;
+ };
+
+ if (!allGMs) {
+ return CONST.DICE_ROLL_MODES.PUBLIC;
+ };
+ return message.blind
+ ? CONST.DICE_ROLL_MODES.BLIND
+ : CONST.DICE_ROLL_MODES.PRIVATE;
+};
diff --git a/module/utils/privacy.mjs b/module/utils/privacy.mjs
index 20ed3ff..b437880 100644
--- a/module/utils/privacy.mjs
+++ b/module/utils/privacy.mjs
@@ -11,6 +11,8 @@ export const PrivacyMode = Object.freeze({
export function determinePrivacyFromRollMode(rollMode) {
switch (rollMode) {
+ case CONST.DICE_ROLL_MODES.PUBLIC:
+ return PrivacyMode.PUBLIC;
case CONST.DICE_ROLL_MODES.BLIND:
return PrivacyMode.GM;
case CONST.DICE_ROLL_MODES.PRIVATE:
@@ -18,7 +20,7 @@ export function determinePrivacyFromRollMode(rollMode) {
case CONST.DICE_ROLL_MODES.SELF:
return PrivacyMode.SELF;
}
- return PrivacyMode.PUBLIC;
+ return PrivacyMode.SELF;
};
/**
@@ -31,7 +33,6 @@ export function determinePrivacyFromRollMode(rollMode) {
* @returns The filtered rows
*/
export function filterPrivateRows(rows, userID, privacies) {
- console.log({rows, userID, privacies});
const filtered = [];
const isMe = userID === game.user.id;
diff --git a/package-lock.json b/package-lock.json
index 9aa4a5d..6594a26 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,11 +11,13 @@
"joi": "^17.13.3"
},
"devDependencies": {
+ "@foundryvtt/foundryvtt-cli": "^1.1.0",
"@stylistic/eslint-plugin": "^4.2.0",
+ "dotenv": "^17.2.2",
"eslint": "^9.25.0",
"glob": "^11.0.1",
"terser": "^5.39.0",
- "vite": "^6.3.1"
+ "vite": "^6.3.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -590,6 +592,39 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@foundryvtt/foundryvtt-cli": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@foundryvtt/foundryvtt-cli/-/foundryvtt-cli-1.1.0.tgz",
+ "integrity": "sha512-ergKZDUSgQ79168r38ORyN4v/UTliA40rxElaUh5iS27Qw9H8Ep/ll8j3/HfiikO3XUDwYxZLfDJfbcyj2i9TQ==",
+ "dev": true,
+ "dependencies": {
+ "chalk": "^5.4.1",
+ "classic-level": "^1.4.1",
+ "esm": "^3.2.25",
+ "js-yaml": "^4.1.0",
+ "mkdirp": "^3.0.1",
+ "nedb-promises": "^6.2.3",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "fvtt": "fvtt.mjs"
+ },
+ "engines": {
+ "node": ">17.0.0"
+ }
+ },
+ "node_modules/@foundryvtt/foundryvtt-cli/node_modules/chalk": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
+ "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
+ "dev": true,
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -1039,6 +1074,23 @@
"win32"
]
},
+ "node_modules/@seald-io/binary-search-tree": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@seald-io/binary-search-tree/-/binary-search-tree-1.0.3.tgz",
+ "integrity": "sha512-qv3jnwoakeax2razYaMsGI/luWdliBLHTdC6jU55hQt1hcFqzauH/HsBollQ7IR4ySTtYhT+xyHoijpA16C+tA==",
+ "dev": true
+ },
+ "node_modules/@seald-io/nedb": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@seald-io/nedb/-/nedb-4.1.1.tgz",
+ "integrity": "sha512-u7fVfzKQ/3ZaIOnYQONf2lPZtGUeQtMPjfcaQkCw/GZv5dzn20qKW6sfN0NkVbr0ksJMlWcFXNGcXYsQSb1a1g==",
+ "dev": true,
+ "dependencies": {
+ "@seald-io/binary-search-tree": "^1.0.3",
+ "localforage": "^1.9.0",
+ "util": "^0.12.4"
+ }
+ },
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -1184,6 +1236,24 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/abstract-level": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.4.tgz",
+ "integrity": "sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg==",
+ "dev": true,
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "catering": "^2.1.0",
+ "is-buffer": "^2.0.5",
+ "level-supports": "^4.0.0",
+ "level-transcoder": "^1.0.1",
+ "module-error": "^1.0.1",
+ "queue-microtask": "^1.2.3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -1254,12 +1324,47 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -1281,12 +1386,83 @@
"node": ">=8"
}
},
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "dev": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1296,6 +1472,15 @@
"node": ">=6"
}
},
+ "node_modules/catering": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/catering/-/catering-2.1.1.tgz",
+ "integrity": "sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1323,6 +1508,95 @@
"pnpm": ">=8"
}
},
+ "node_modules/classic-level": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-1.4.1.tgz",
+ "integrity": "sha512-qGx/KJl3bvtOHrGau2WklEZuXhS3zme+jf+fsu6Ej7W7IP/C49v7KNlWIsT1jZu0YnfzSIYDGcEWpCa1wKGWXQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "abstract-level": "^1.0.2",
+ "catering": "^2.1.0",
+ "module-error": "^1.0.1",
+ "napi-macros": "^2.2.2",
+ "node-gyp-build": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1390,6 +1664,50 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.2.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
+ "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -1402,6 +1720,36 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
@@ -1442,6 +1790,15 @@
"@esbuild/win32-x64": "0.25.2"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -1564,6 +1921,15 @@
"node": "*"
}
},
+ "node_modules/esm": {
+ "version": "3.2.25",
+ "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
+ "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
@@ -1679,10 +2045,14 @@
}
},
"node_modules/fdir": {
- "version": "6.4.3",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz",
- "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -1751,6 +2121,21 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -1781,6 +2166,61 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/glob": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz",
@@ -1843,6 +2283,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1852,6 +2304,77 @@
"node": ">=8"
}
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1861,6 +2384,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "dev": true
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -1886,6 +2415,63 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "dev": true,
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-buffer": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
+ "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -1904,6 +2490,24 @@
"node": ">=8"
}
},
+ "node_modules/is-generator-function": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
+ "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.0",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1925,6 +2529,39 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -1997,6 +2634,28 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/level-supports": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-4.0.1.tgz",
+ "integrity": "sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/level-transcoder": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz",
+ "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==",
+ "dev": true,
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "module-error": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2010,6 +2669,24 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
+ "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
+ "dev": true,
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/localforage": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
+ "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
+ "dev": true,
+ "dependencies": {
+ "lie": "3.1.1"
+ }
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2040,6 +2717,15 @@
"node": "20 || >=22"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2098,6 +2784,30 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/mkdirp": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+ "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "dist/cjs/src/bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/module-error": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz",
+ "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2122,12 +2832,38 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/napi-macros": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz",
+ "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==",
+ "dev": true
+ },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
+ "node_modules/nedb-promises": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/nedb-promises/-/nedb-promises-6.2.3.tgz",
+ "integrity": "sha512-enq0IjNyBz9Qy9W/QPCcLGh/QORGBjXbIeZeWvIjO3OMLyAvlKT3hiJubP2BKEiFniUlR3L01o18ktqgn5jxqA==",
+ "dev": true,
+ "dependencies": {
+ "@seald-io/nedb": "^4.0.2"
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "dev": true,
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2234,10 +2970,11 @@
"dev": true
},
"node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=12"
},
@@ -2245,6 +2982,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@@ -2311,6 +3057,15 @@
}
]
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -2392,6 +3147,23 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
@@ -2404,6 +3176,23 @@
"node": ">=10"
}
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -2604,13 +3393,14 @@
}
},
"node_modules/tinyglobby": {
- "version": "0.2.12",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz",
- "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==",
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "fdir": "^6.4.3",
- "picomatch": "^4.0.2"
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -2678,18 +3468,32 @@
"punycode": "^2.1.0"
}
},
- "node_modules/vite": {
- "version": "6.3.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz",
- "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==",
+ "node_modules/util": {
+ "version": "0.12.5",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
+ "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "is-arguments": "^1.0.4",
+ "is-generator-function": "^1.0.7",
+ "is-typed-array": "^1.1.3",
+ "which-typed-array": "^1.1.2"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
+ "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
- "fdir": "^6.4.3",
+ "fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
- "tinyglobby": "^0.2.12"
+ "tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
@@ -2767,6 +3571,27 @@
"node": ">= 8"
}
},
+ "node_modules/which-typed-array": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+ "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "dev": true,
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -2864,6 +3689,83 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 2008f04..ad4b381 100644
--- a/package.json
+++ b/package.json
@@ -7,16 +7,17 @@
"lint:nofix": "eslint",
"dev": "NODE_ENV=development vite build --mode dev --watch",
"dev:once": "NODE_ENV=development vite build --mode dev",
- "staging": "NODE_ENV=staging vite build --mode dev --watch",
- "staging:once": "NODE_ENV=staging vite build --mode dev",
+ "staging": "NODE_ENV=staging vite build --mode staging",
"build": "NODE_ENV=production vite build --mode prod"
},
"devDependencies": {
+ "@foundryvtt/foundryvtt-cli": "^1.1.0",
"@stylistic/eslint-plugin": "^4.2.0",
+ "dotenv": "^17.2.2",
"eslint": "^9.25.0",
"glob": "^11.0.1",
"terser": "^5.39.0",
- "vite": "^6.3.1"
+ "vite": "^6.3.4"
},
"dependencies": {
"chart.js": "^4.4.9",
diff --git a/packs/docs/_source/English_pBOyeBDuTeowuDOE.json b/packs/docs/_source/English_pBOyeBDuTeowuDOE.json
new file mode 100644
index 0000000..f6e18d1
--- /dev/null
+++ b/packs/docs/_source/English_pBOyeBDuTeowuDOE.json
@@ -0,0 +1,592 @@
+{
+ "name": "English",
+ "_id": "pBOyeBDuTeowuDOE",
+ "pages": [
+ {
+ "sort": 100000,
+ "name": "Getting Started",
+ "type": "text",
+ "category": "mrZHFR2i0MYp7aaY",
+ "_id": "xcR48pakEm49hbc2",
+ "system": {},
+ "title": {
+ "show": true,
+ "level": 1
+ },
+ "image": {},
+ "text": {
+ "format": 1,
+ "content": "
Thank you for installing Stat Tracker!
This module aims to provide a clean way of keeping track of any data points you want within Foundry, whether that be dice rolls, or other things like how many natural 1s to natural 20s you get.
I was inspired by the dicestats module, however it only allows tracking dice statistics, which is something I found myself needing to work around and struggle against, so I decided to make this module to fill that gap while improving upon the graph rendering.
You've already done the hardest part of set up for this module to work on the basic level, installing it! However if you want a more advanced set up for the module, this Journal is your go-to for information.
This module makes use of multiple key terms which are important for your understanding, the main terms you need to understand are:
You can find information about what each of those terms mean within the \"Key Concepts\" section of the documentation or by clicking the above links.
Dice Stats
If you just want the module to track dice stats, then it's probably already done for you! All you need to do is start playing your game.
The only situation in which you need to do additional configuration for your game is if you are playing a game which doesn't use the standard dice sizes (d4, d6, d8, d10, d12, d20, d100), however you can add any dice size you want by creating a new table named Dice/dX where X is the number of sides the dice should have (e.g. for a 3-sided dice you would make the table name be Dice/d3). By adding a table in this way, it's configuration will be locked for editing and the only way to change it will be to delete the table entirely.
System Support
Currently this module does not support system-specific implementations, however in the future I am planning on adding support for systems as desired by users (the dnd5e system will be the first one supported once I can).
Tables are quite close to being a \"container\" for every piece of data within the module, every @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.S7Z6mZ0JablJVQJu]{row} within the module must be associated with a particular table. The table is responsible for maintaining all of the @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.e9FYKidbfFnnTspO]{bucket} and @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.IXZpEBEJsvOpY3OL]{graph} configurations, controlling what data is allowed to be saved and how to represent that data.
Subtables
Subtables are a concept used to group multiple tables together in a logical way, taking the Dice/d10 table as an example, the \"table ID\" is the full \"Dice/d10\", while the table name is \"Dice\" and the subtable name is \"d10\". This allows the module to group all of the \"Dice\" tables together in the user interfaces.
Limitations
Subtables can only go one level deep (e.g. Table/SubTable/SubSubTable is an invalid table ID and the module will reject it)
Subtables within the \"Dice\" table cannot be edited, they are required to use a @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.e9FYKidbfFnnTspO#range-buckets]{Range Bucket} and the settings are locked. This is because the module makes certain assumptions about the subtables within that space adhering to those requirements and making changes to them causes some issues. If you want to make a table that doesn't conform to those requirements, you can make a new table for your own purposes though!
Rows make up the vast majority of data that the stat tracker stores. Every dice roll, or custom entry, is saved as a single row.
Each row consists of the following data:
The value (e.g. the result of a single dice roll)
A timestamp of when it was added to the database
A privacy mode (one for each of the Foundry-provided roll modes, see @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.WYaZPgSRDx8L7Zmq#privacy-modes]{Privacy Modes})
An identifier
This combination of data can end up becoming a lot of data to load when opening Foundry, if you don't think you'll be resetting the data very often, please consider checking out the different @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.PcdmuLgNM15h0in1]{Database Adapters} and picking one of them that is more suitable for your campaign.
A bucket is the term used to identify a group of allowed values within a @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.ugzCCxQskUSYMZR4]{table}, each bucket must have a type, and a number of additional settings depending on what type it is.
String Buckets
This is the most simple type of bucket, it allows a string to be added as the row's value. The only additional configuration for this type of bucket is restricting what strings can be added be added.
e.g. you can limit each row to only have a value of \"Critical Success\", or \"Critical Failure\" and if someone tries to add \"Apple Sauce\" into the table, it will reject that row.
Number Buckets
This type of bucket is likely the one you will utilize the most, it allows storing any number. It accepts an set of additional options described below, all of which are optional.
Setting
Description
Minimum
The minimum allowed value.
Required when Step is provided.
Maximum
The maximum allowed value, must be greater than Minimum
Step
When a step is set it requires each number to be a \"step\" away from the lower one. So if you have a minimum of 2 and a step of 4, the allowed values are: 2, 6, 10, 14, 18, etc.
This stat tracker comes with a custom sidebar tab that provides a general overview of the module's state as well as short-cuts for opening and controlling settings of the module.
The primary actions within the sidebar are:
Viewing the data in graphs
Creating a new @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.ugzCCxQskUSYMZR4]{table} (Gamemasters only)
The module provides a bunch of settings to be able to control how it interacts with Foundry in various ways. Each setting has a description provided in the settings configuration window, but these descriptions will go more in depth than the ones in there. This will not include all settings, just the ones that would be more beneficial to have additional clarification for.
Roll Auto-Tracking
This tells the module to automatically track rolls that are sent to the chat, this includes systems, modules, and other rolls like RollTables. As long as it gets sent to the chat, this will allow that roll to automatically be tracked.
By default, this only tracks the standard dice sizes (d4, d6, d8, d10, d12, d20, d100), however you can add any dice size you want by creating a new table named Dice/dX where X is the number of sides the dice should have (e.g. for a 3-sided dice you would make the table name be Dice/d3). By adding a table in this way, it's configuration will be locked for editing and the only way to change it will be to delete the table entirely.
For most systems you will want to leave this setting enabled, because otherwise there is a chance that no dice rolls will be tracked at all unless the system has specifically implemented an integration with the module.
Global API
This setting is primarily targeted at users who would like to integrate stats tracking into macros, as it exposes a globally available stats variable with references to all of the exposed methods and utility helpers of the module. This can sometimes cause conflicts with systems or other modules, so make sure that there isn't already another a global variable named stats before enabling this setting.
Below is an example of how to retrieve the module's API both with and without this setting enabled:
// with it enabled:\nconst statViewer = new stats.Apps.StatsViewer;\nstatViewer.render({ force: true });\n\n// with it disabled\nconst api = game.modules.get(`stat-tracker`)?.api;\nconst statViewer = new api.Apps.StatsViewer;\nstatViewer?.render({ force: true });
The module provides a multitude of utility functions through it's API for usage however desired. This will go over them and describe their purpose.
filterPrivateRows
This method is intended to take @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.S7Z6mZ0JablJVQJu]{rows} provided by the database and filter out any that the user would not be able to see normally. This is usually called by the database adapters so there's unlikely to be any reason to use it externally.
Available under <api>.utils.filterPrivateRows.
inferRollMode
This utility is intended to try and determine what roll mode was used to create a chat message. The inference is not entirely accurate because it struggles to differentiate between a GM rolling with a Private GM Roll and a Self Roll when there is only one GM present in the world.
This is a list of all available database adapters and how they're configured.
Database
This database adapter isn't a full adapter, this is an abstract class that is used by the other database adapters to enforce a consistent method / interface specification. The general interface includes implementation details for storing the table data in a world setting as well as adding/removing any applications that are rendered as part of the module's operations.
Available under <api>.databases.Database.
User Flag Database
This database adapter uses Foundry's flag system in order to store the row data in the User document, leveraging Foundry's automatic database update propagation to other clients. The application handling and table storage utilizes the abstract implementations.
Available under <api>.databases.UserFlagDatabase.
Condensed User Flag Database
This is unimplemented at the moment. But it will be a database that makes long-term campaign storage more viable, at the tradeoff of not being able to filter data as granularly.
Memory Database
This database adapter should not be used in any actual games, it is intended for development only.
All of these enums are available within <api>.enums, they are read-only and cannot be modified by other plugins.
Privacy Modes
This enum is used by the module to specify all of the privacy levels that it uses.
The valid values are:
GM - Representing that only gamemasters and assistant gamemasters will be able to see this data entry. This mode is similar to the \"Blind GM Roll\" roll mode.
PRIVATE - Indicating that this is a piece of private data, that only gamemasters, assistant gamemasters, and the user who owns the piece of data will be able to see it.
SELF - Similar to the \"GM\" level, but instead of gamemasters, it's only the user who owns the piece of data that's able to see it.
This module was designed from the ground up with the desire to be able to be used within macros, because of this it has first-class Macro support through the entire API.
Getting the API
The primary API that you will interact with inside of macros is held within CONFIG.stats, this is the core API which allows interacting with the stored data however the user wants it to be stored, it allows accessing the apps to view stats, manage the tables, and create new tables.
The API Methods
Each of the items within CONFIG.stats has a different purpose, but most of the programmatic interactions for the module are most likely to be interested in the CONFIG.stats.db part of the API, which handles all of the data within the module in your configured preference.
This is the interface which all @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.PcdmuLgNM15h0in1]{Database Adapters} must conform to in order for the module to function. If they do not conform to this a warning will be thrown and the module will override the provided database with a database adapter which does nothing, so that the existing data will be protected from errors.
The best way to learn about the required database interface is to read the implementation of the interface that all database adapters are required to extend.
"
+ },
+ "video": {
+ "controls": true,
+ "volume": 0.5
+ },
+ "src": null,
+ "ownership": {
+ "default": -1,
+ "t2sWGWEYSMFrfBu3": 3
+ },
+ "flags": {},
+ "_stats": {
+ "compendiumSource": null,
+ "duplicateSource": null,
+ "exportSource": null,
+ "coreVersion": "13.344",
+ "systemId": "empty-system",
+ "systemVersion": "0.0.0",
+ "createdTime": 1748408916163,
+ "modifiedTime": 1748656904440,
+ "lastModifiedBy": "t2sWGWEYSMFrfBu3"
+ },
+ "_key": "!journal.pages!pBOyeBDuTeowuDOE.PlKHrrb61Uc1sGbN"
+ }
+ ],
+ "folder": null,
+ "categories": [
+ {
+ "name": "Overview",
+ "sort": 100000,
+ "_id": "mrZHFR2i0MYp7aaY",
+ "flags": {},
+ "_stats": {
+ "compendiumSource": null,
+ "duplicateSource": null,
+ "exportSource": null,
+ "coreVersion": "13.344",
+ "systemId": "empty-system",
+ "systemVersion": "0.0.0",
+ "createdTime": 1748328842906,
+ "modifiedTime": 1748328842906,
+ "lastModifiedBy": "t2sWGWEYSMFrfBu3"
+ },
+ "_key": "!journal.categories!pBOyeBDuTeowuDOE.mrZHFR2i0MYp7aaY"
+ },
+ {
+ "name": "API",
+ "sort": 300000,
+ "_id": "KGdeJUfatQ9v0raI",
+ "flags": {},
+ "_stats": {
+ "compendiumSource": null,
+ "duplicateSource": null,
+ "exportSource": null,
+ "coreVersion": "13.344",
+ "systemId": "empty-system",
+ "systemVersion": "0.0.0",
+ "createdTime": 1748328851997,
+ "modifiedTime": 1748328851997,
+ "lastModifiedBy": "t2sWGWEYSMFrfBu3"
+ },
+ "_key": "!journal.categories!pBOyeBDuTeowuDOE.KGdeJUfatQ9v0raI"
+ },
+ {
+ "name": "Key Concepts",
+ "sort": 200000,
+ "_id": "ZPAbuPbVOLWh75hL",
+ "flags": {},
+ "_stats": {
+ "compendiumSource": null,
+ "duplicateSource": null,
+ "exportSource": null,
+ "coreVersion": "13.344",
+ "systemId": "empty-system",
+ "systemVersion": "0.0.0",
+ "createdTime": 1748329482648,
+ "modifiedTime": 1748329482648,
+ "lastModifiedBy": "t2sWGWEYSMFrfBu3"
+ },
+ "_key": "!journal.categories!pBOyeBDuTeowuDOE.ZPAbuPbVOLWh75hL"
+ }
+ ],
+ "sort": 0,
+ "ownership": {
+ "default": 0,
+ "t2sWGWEYSMFrfBu3": 3
+ },
+ "flags": {
+ "core": {
+ "locked": false
+ }
+ },
+ "_stats": {
+ "compendiumSource": null,
+ "duplicateSource": null,
+ "exportSource": null,
+ "coreVersion": "13.344",
+ "systemId": "empty-system",
+ "systemVersion": "0.0.0",
+ "createdTime": 1748328832096,
+ "modifiedTime": 1748393668194,
+ "lastModifiedBy": "t2sWGWEYSMFrfBu3"
+ },
+ "_key": "!journal!pBOyeBDuTeowuDOE"
+}
diff --git a/public/langs/en-ca.json b/public/langs/en-ca.json
index 4cf777c..18bc399 100644
--- a/public/langs/en-ca.json
+++ b/public/langs/en-ca.json
@@ -5,6 +5,20 @@
"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."
+ },
+ "statsSidebarTab": {
+ "name": "Stats Sidebar Tab",
+ "hint": "Adds a custom sidebar tab to view and control the module with ease. With the sidebar tab disabled the only way to control the module is via the public API."
+ }
}
}
}
diff --git a/public/module.json b/public/module.json
index 0894968..431b68d 100644
--- a/public/module.json
+++ b/public/module.json
@@ -1,12 +1,23 @@
{
"id": "stat-tracker",
"title": "Stats Tracker",
- "version": "0.0.1",
+ "description": "
A user-first approach to stat tracking. Designed from the ground up with the intent of being able to track whatever statistics you want.