From 1c5f990d64aa3773491b9fe9bb388076e8ded0d8 Mon Sep 17 00:00:00 2001 From: CPTN Cosmo Date: Sat, 17 Jan 2026 16:00:51 +0100 Subject: [PATCH] feat: Add Daggerheart actor item updater module for automatic and manual item synchronization with compendiums. --- README.md | 48 ++++++++++ module.json | 38 ++++++++ scripts/apps/updater-app.js | 112 +++++++++++++++++++++++ scripts/main.js | 92 +++++++++++++++++++ scripts/updater.js | 171 ++++++++++++++++++++++++++++++++++++ styles/updater.css | 82 +++++++++++++++++ templates/updater.hbs | 51 +++++++++++ 7 files changed, 594 insertions(+) create mode 100644 README.md create mode 100644 module.json create mode 100644 scripts/apps/updater-app.js create mode 100644 scripts/main.js create mode 100644 scripts/updater.js create mode 100644 styles/updater.css create mode 100644 templates/updater.hbs diff --git a/README.md b/README.md new file mode 100644 index 0000000..12fae2c --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Daggerheart Actor Updater + +A FoundryVTT module for the Daggerheart system that automatically checks your actors for outdated items against the SRD Compendium. + +## Features + +- **Automatic Checks**: Checks for updates on first load and whenever the Daggerheart system version changes. +- **Manual Check**: API available for macros/manual checks. +- **Detailed Comparison**: Compares names, images, and system data (descriptions, damage, etc.). +- **Selective Updates**: Choose which items to update or ignore specific items (e.g., custom homebrew items). +- **Supports**: Classes, Subclasses, Ancestries, Communities, Domains, Weapons, Armor, Consumables, Loot, and Beastforms. + +## Installation + +### Installation via Manifest URL + +1. In Foundry VTT, go to the **Add-on Modules** tab. +2. Click **Install Module**. +3. Paste the following URL into the **Manifest URL** field: + ``` + https://git.geeks.gay/cosmo/dh-actor-updater/raw/branch/main/module.json + ``` +4. Click **Install**. + +## Usage + +1. Enable the module in your world settings. +2. On login (if a new system version is detected) or via macro, the "Daggerheart Actor Updater" window will appear if differences are found. +3. Review the list of changes. +4. Click **Update** to sync with the compendium. +5. Click **Ignore** to suppress warnings for a specific item (useful for customized items). + +## Manual Check + +To manually trigger a check, you can use the button in the module settings or run the following macro code: + +```javascript +game.modules.get('dh-actor-updater').api.checkAll(); +``` + +## Compatibility + +- **Foundry VTT**: v13+ +- **Daggerheart System**: Verified on 1.5.4 + +## Important Note + +This module modifies your actor data directly. **Always back up your world** before performing bulk updates, especially if you have significant customizations on your actors. diff --git a/module.json b/module.json new file mode 100644 index 0000000..0cbb46a --- /dev/null +++ b/module.json @@ -0,0 +1,38 @@ +{ + "id": "dh-actor-updater", + "title": "Daggerheart Actor Updater", + "version": "1.0.0", + "compatibility": { + "minimum": "13", + "verified": "13" + }, + "authors": [ + { + "name": "CPTN Cosmo", + "email": "cptncosmo@gmail.com", + "url": "https://github.com/cptn-cosmo", + "discord": "cptn_cosmo", + "flags": {} + } + ], + "relationships": { + "systems": [ + { + "id": "daggerheart", + "type": "system", + "manifest": "", + "compatibility": {} + } + ] + }, + "esmodules": [ + "scripts/main.js" + ], + "styles": [ + "styles/updater.css" + ], + "description": "Automatically update actors in your world to match the latest compendium items.", + "url": "https://github.com/cptn-cosmo/dh-actor-updater", + "manifest": "https://git.geeks.gay/cosmo/dh-actor-updater/raw/branch/main/module.json", + "download": "https://git.geeks.gay/cosmo/dh-actor-updater/releases/download/1.0.0/dh-actor-updater.zip" +} \ No newline at end of file diff --git a/scripts/apps/updater-app.js b/scripts/apps/updater-app.js new file mode 100644 index 0000000..23f85c3 --- /dev/null +++ b/scripts/apps/updater-app.js @@ -0,0 +1,112 @@ +import { DHUpdater } from '../updater.js'; + +export class ActorUpdaterApp extends Application { + + constructor(updates, options = {}) { + super(options); + this.updates = updates; + this.summaryLog = []; + this.isComplete = false; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'dh-actor-updater', + title: 'Daggerheart Actor Updater', + template: 'modules/dh-actor-updater/templates/updater.hbs', // We'll need to register this or inject HTML + width: 600, + height: 500, + resizable: true, + classes: ['dh-actor-updater-window'] + }); + } + + // Since I didn't verify if I can easily register templates, I'll use a render hook or just inject HTML string if template loading fails, + // but standard module structure supports templates. For simplicity and speed without extra file lookups, I'll override _render to simple HTML or create the template file. + // Let's create the template file properly. + + getData() { + // If all updates are processed (null), show summary + const remaining = this.updates.filter(u => u !== null).length; + if (remaining === 0 && this.summaryLog.length > 0) { + this.isComplete = true; + } + + return { + summary: this.isComplete, + summaryLog: this.summaryLog, + updates: this.updates.map((u, i) => { + if (!u) return null; + return { + id: i, + actorName: u.actor.name, + itemName: u.item.name, + itemImg: u.item.img, + changes: Object.keys(u.diff).length + }; + }) + }; + } + + activateListeners(html) { + super.activateListeners(html); + + html.find('.btn-update').click(async (ev) => { + const idx = $(ev.currentTarget).data('index'); + await this._doUpdate(idx); + }); + + html.find('.btn-ignore').click(async (ev) => { + const idx = $(ev.currentTarget).data('index'); + await this._doIgnore(idx); + }); + + html.find('.btn-update-all').click(async () => { + const confirmed = await Dialog.confirm({ + title: "Confirm Update All", + content: "

Are you sure you want to update ALL listed items? This will overwrite their data with the SRD Compendium versions. This cannot be undone.

", + defaultYes: false + }); + + if (!confirmed) return; + + // Clone array indices to iterate safely + const indices = this.updates.map((u, i) => u ? i : -1).filter(i => i >= 0); + for (let i of indices) { + await this._doUpdate(i, false); // Don't re-render on each step + } + this.render(); + }); + } + + async _doUpdate(index, render = true) { + const update = this.updates[index]; + if (!update) return; + + await DHUpdater.updateItem(update.item, update.compendiumItem); + const msg = `Updated ${update.item.name} on ${update.actor.name}`; + this.summaryLog.push(msg); + ui.notifications.info(msg); + + // Remove from list + this.updates[index] = null; + if (render) this.render(); + } + + async _doIgnore(index) { + const update = this.updates[index]; + if (!update) return; + + const ignored = game.settings.get('dh-actor-updater', 'ignoredItems') || {}; + ignored[update.item.uuid] = true; + await game.settings.set('dh-actor-updater', 'ignoredItems', ignored); + + const msg = `Ignored ${update.item.name} on ${update.actor.name}`; + this.summaryLog.push(msg); + ui.notifications.info(msg); + + // Remove from list + this.updates[index] = null; + this.render(); + } +} diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..869ed81 --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,92 @@ +import { DHUpdater } from './updater.js'; +import { ActorUpdaterApp } from './apps/updater-app.js'; + +Hooks.once('init', () => { + game.settings.register('dh-actor-updater', 'ignoredItems', { + name: 'Ignored Items', + scope: 'world', + config: false, + type: Object, + default: {} + }); + + game.settings.register('dh-actor-updater', 'lastCheckedVersion', { + name: 'Last Checked System Version', + scope: 'world', + config: false, + type: String, + default: '0.0.0' + }); + + game.settings.registerMenu('dh-actor-updater', 'manualCheck', { + name: 'Check for Updates', + label: 'Check Now', + hint: 'Manually check for updates to your actors against the compendium.', + icon: 'fas fa-search', + type: ManualCheckConfig, + restricted: true + }); +}); + +class ManualCheckConfig extends FormApplication { + render() { + game.modules.get('dh-actor-updater').api.checkAll(); + // Don't actually render a form + return this; + } +} + +Hooks.on('ready', async () => { + if (!game.user.isGM) return; + + // Manual check hook exposed to API + game.modules.get('dh-actor-updater').api = { + checkAll: async () => { + await checkAndPrompt(); + } + }; + + // Auto-check logic + const lastVersion = game.settings.get('dh-actor-updater', 'lastCheckedVersion'); + const currentVersion = game.system.version; + + if (currentVersion !== lastVersion) { + console.log("Daggerheart Actor Updater | System update detected. Checking for item updates..."); + await checkAndPrompt(); + await game.settings.set('dh-actor-updater', 'lastCheckedVersion', currentVersion); + } else { + // Optional: Run on every startup? User "re-prompt again on manual check", not necessarily every startup unless version change. + // "First run check" imply checking if it hasn't been checked before (implied by version 0.0.0). + // "Re-prompt on system version update" -> Covered. + // "Manual check" -> Covered by API. + + // Let's add a setting for "Check on every startup" if wanted, but instructions implied first run or version update. + // "module should on first run check" -> handled by version diff check. + } +}); + +async function checkAndPrompt() { + ui.notifications.info("Daggerheart Actor Updater | Checking for updates..."); + + // Collect updates from all actors + // "configured actors" - likely means all actors or player characters. + // Let's check all actors the GM has permission to update (which is all) + + let allUpdates = []; + for (const actor of game.actors) { + // Maybe restrict to Characters/Companions? + // "configured actors" - usually implies Characters. Adversaries in world might not need updates or might be customizations. + // Let's check 'character' and 'companion' types primarily, but maybe 'adversary' too if they are in the world. + // User didn't strictly specify, but usually players care about their sheets. + if (['character', 'companion'].includes(actor.type)) { + const updates = await DHUpdater.checkActor(actor); + allUpdates = allUpdates.concat(updates); + } + } + + if (allUpdates.length > 0) { + new ActorUpdaterApp(allUpdates).render(true); + } else { + ui.notifications.info("Daggerheart Actor Updater | No updates found."); + } +} diff --git a/scripts/updater.js b/scripts/updater.js new file mode 100644 index 0000000..86181f5 --- /dev/null +++ b/scripts/updater.js @@ -0,0 +1,171 @@ +export class DHUpdater { + + static PACK_MAPPING = { + 'class': 'daggerheart.classes', + 'subclass': 'daggerheart.subclasses', + 'ancestry': 'daggerheart.ancestries', + 'community': 'daggerheart.communities', + 'domainCard': 'daggerheart.domains', + 'weapon': 'daggerheart.weapons', + 'armor': 'daggerheart.armors', + 'consumable': 'daggerheart.consumables', + 'loot': 'daggerheart.loot', + 'beastform': 'daggerheart.beastforms' + }; + + static IGNORED_FIELDS = [ + '_id', + 'sort', + 'ownership', + 'flags', + '_stats', + 'system.quantity', + 'system.equipped', + 'system.attuned', + 'system.favorite', + 'system.features', + 'system.subclasses', + 'system.inventory', + 'system.characterGuide' + ]; + + /** + * Check for updates for a specific actor + * @param {Actor} actor + * @returns {Promise} List of updates found + */ + static async checkActor(actor) { + if (!actor) return []; + + const updates = []; + const ignoredItems = game.settings.get('dh-actor-updater', 'ignoredItems') || {}; + + for (const item of actor.items) { + // Skip if ignored + if (ignoredItems[item.uuid]) continue; + + const packId = this.PACK_MAPPING[item.type]; + if (!packId) continue; // Not a trackable item type + + const pack = game.packs.get(packId); + if (!pack) continue; + + // Try to find the source item in the compendium + // We search by name first. + // Ideally we'd match by flags.core.sourceId if available, + // but Daggerheart items might have just been dropped without keeping link, + // or we want to match by name for "generic" updates. + // The user prompt implies "compare them to the SRD compendium version", likely by name or origin. + + let compendiumItem = null; + const sourceId = item.flags.core?.sourceId; + + if (sourceId && sourceId.startsWith(packId)) { + const id = sourceId.split('.').pop(); + compendiumItem = await pack.getDocument(id); + } + + if (!compendiumItem) { + // Fallback to name match + const index = pack.index.find(i => i.name === item.name); + if (index) { + compendiumItem = await pack.getDocument(index._id); + } + } + + if (compendiumItem) { + const diff = this.compareItems(item, compendiumItem); + if (diff) { + updates.push({ + actor: actor, + item: item, + compendiumItem: compendiumItem, + diff: diff + }); + } + } + } + return updates; + } + + /** + * Compare actor item with compendium item + * @param {Item} item + * @param {Item} compendiumItem + * @returns {Object|null} Diff object or null if identical + */ + static compareItems(item, compendiumItem) { + const itemData = item.toObject(); + const compendiumData = compendiumItem.toObject(); + + const diff = {}; + let hasChanges = false; + + // Basic fields + if (itemData.name !== compendiumData.name) { + // If we matched by ID, name might have changed + diff.name = { old: itemData.name, new: compendiumData.name }; + hasChanges = true; + } + + if (itemData.img !== compendiumData.img) { + diff.img = { old: itemData.img, new: compendiumData.img }; + hasChanges = true; + } + + // System data comparison + const flatItemSystem = foundry.utils.flattenObject(itemData.system); + const flatCompendiumSystem = foundry.utils.flattenObject(compendiumData.system); + + for (const [key, value] of Object.entries(flatCompendiumSystem)) { + // Check for ignored fields + if (this.IGNORED_FIELDS.some(ignored => key.startsWith(ignored) || `system.${key}` === ignored)) continue; + + // Should probably check if the field exists in compendium but is different in item + if (flatItemSystem[key] !== value) { + // Check if it's "effectively" the same (e.g. null vs undefined or html differences) + // Simple strict equality for now + + // Handle embedded arrays explicitly?? + // flattenObject handles arrays with numeric indices e.g. "array.0.field" + + diff[`system.${key}`] = { old: flatItemSystem[key], new: value }; + hasChanges = true; + } + } + + // Also check if item has keys that compendium doesn't (removed fields) + // Might be tricky if users added custom data. + // We generally care if the Compendium version is "Standard" and we want to enforce it. + + return hasChanges ? diff : null; + } + + static async updateItem(item, compendiumItem) { + const itemData = compendiumItem.toObject(); + delete itemData._id; + delete itemData.ownership; + delete itemData.folder; + delete itemData.sort; + delete itemData.flags; + delete itemData._stats; + + // Fields to preserve from Actor (do not overwrite with Compendium data) + const PRESERVED_SYSTEM_FIELDS = [ + // State fields + 'quantity', 'equipped', 'attuned', 'favorite', + // Link fields (referencing other items) + 'features', 'subclasses', 'inventory', 'characterGuide' + ]; + + if (itemData.system) { + for (const field of PRESERVED_SYSTEM_FIELDS) { + if (field in itemData.system) { + delete itemData.system[field]; + } + } + } + + return item.update(itemData); + } +} diff --git a/styles/updater.css b/styles/updater.css new file mode 100644 index 0000000..6b73864 --- /dev/null +++ b/styles/updater.css @@ -0,0 +1,82 @@ +.dh-actor-updater-window .window-content { + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +.dh-actor-updater-list { + flex: 1; + overflow-y: auto; + padding: 10px; + list-style: none; /* Remove default bullet points */ + margin: 0; +} + +.dh-actor-updater-item { + border: 1px solid var(--color-border-light-2); + background: rgba(0, 0, 0, 0.2); + margin-bottom: 5px; + padding: 8px; + border-radius: 4px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.dh-actor-updater-item.ignored { + opacity: 0.6; + background: rgba(50, 0, 0, 0.2); +} + +.dh-actor-updater-info { + flex: 1; +} + +.dh-actor-updater-name { + font-weight: bold; + font-size: 1.1em; +} + +.dh-actor-updater-details { + font-size: 0.9em; + color: var(--color-text-light-2); +} + +.dh-actor-updater-controls { + display: flex; + gap: 5px; +} + +.dh-actor-updater-controls button { + width: auto; + padding: 2px 8px; + font-size: 0.9em; +} + +.dh-actor-updater-diff { + margin-top: 5px; + font-family: monospace; + font-size: 0.85em; + background: rgba(0, 0, 0, 0.3); + padding: 5px; + border-radius: 3px; + white-space: pre-wrap; +} + +.dh-actor-updater-diff .diff-add { + color: #aaffaa; +} + +.dh-actor-updater-diff .diff-del { + color: #ffaaaa; +} + +/* Footer buttons */ +.dh-actor-updater-footer { + padding: 10px; + border-top: 1px solid var(--color-border-light-2); + display: flex; + justify-content: flex-end; + gap: 10px; +} diff --git a/templates/updater.hbs b/templates/updater.hbs new file mode 100644 index 0000000..b2a9516 --- /dev/null +++ b/templates/updater.hbs @@ -0,0 +1,51 @@ +
+
+

The following items on your actors differ from the SRD Compendium versions.

+
+ + {{#if summary}} +
+

Update Summary

+
    + {{#each summaryLog}} +
  • {{this}}
  • + {{/each}} +
+
+ {{else}} +
    + {{#each updates}} + {{#if this}} +
  • +
    +
    {{itemName}}
    +
    + Actor: {{actorName}} | Changes: {{changes}} fields +
    +
    +
    + + +
    +
  • + {{/if}} + {{else}} +
  • +
    + No updates found. +
    +
  • + {{/each}} +
+ + + {{/if}} +
\ No newline at end of file