const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; let registeredKeys = new Set(); /** * Apply the custom attribution sources to the system CONFIG object. */ export function applyAttributionSources() { if (!CONFIG.DH?.GENERAL?.attributionSources) return; // Remove previously registered custom sources for (const key of registeredKeys) { delete CONFIG.DH.GENERAL.attributionSources[key]; } registeredKeys.clear(); // Get current custom sources from setting const customSources = game.settings.get("dh-attribution-sources", "customSources") || []; // Inject custom sources for (const source of customSources) { if (!source.id) continue; const key = `custom_${source.id}`; CONFIG.DH.GENERAL.attributionSources[key] = { label: source.label || source.id, values: (source.values || []).map(val => ({ label: val.label || "", hint: val.hint || "" })) }; registeredKeys.add(key); } } /** * Custom application sheet to add, update, and remove custom attribution sources. */ export class CustomAttributionSourcesForm extends HandlebarsApplicationMixin(ApplicationV2) { constructor(options = {}) { super(options); const saved = game.settings.get("dh-attribution-sources", "customSources") || []; this.sources = foundry.utils.deepClone(saved); } static DEFAULT_OPTIONS = { tag: 'form', id: 'dh-attribution-sources-settings', classes: ['dh-attribution-sources', 'dialog', 'settings-app'], position: { width: 620, height: 650 }, window: { title: "Custom Attribution Sources", icon: 'fa-solid fa-signature', resizable: true }, actions: { addSource: CustomAttributionSourcesForm.#onAddSource, removeSource: CustomAttributionSourcesForm.#onRemoveSource, addValue: CustomAttributionSourcesForm.#onAddValue, removeValue: CustomAttributionSourcesForm.#onRemoveValue, reset: CustomAttributionSourcesForm.#onReset, save: CustomAttributionSourcesForm.#onSave }, form: { handler: CustomAttributionSourcesForm.#onSubmit, submitOnChange: false, closeOnSubmit: false } }; static PARTS = { main: { template: 'modules/dh-attribution-sources/templates/settings.hbs', scrollable: ['.sources-list'] }, footer: { template: 'modules/dh-attribution-sources/templates/footer.hbs' } }; async _prepareContext(options) { const context = await super._prepareContext(options); context.sources = this.sources; return context; } static #serializeForm(target) { const form = target.closest('form'); const formData = new foundry.applications.ux.FormDataExtended(form); const data = foundry.utils.expandObject(formData.object); // Convert dot-notation structures back to nested arrays const sources = Object.values(data.sources || {}).map(src => { const values = Object.values(src.values || {}).map(val => ({ label: val.label || "", hint: val.hint || "" })); return { id: src.id || "", label: src.label || "", values: values }; }); return sources; } static async #onAddSource(event, target) { const sources = CustomAttributionSourcesForm.#serializeForm(target); sources.push({ id: `group-${foundry.utils.randomID(4)}`, label: "New Source Group", values: [{ label: "", hint: "" }] }); this.sources = sources; this.render(); } static async #onRemoveSource(event, target) { const sources = CustomAttributionSourcesForm.#serializeForm(target); const index = parseInt(target.dataset.sourceIndex); if (!isNaN(index)) { sources.splice(index, 1); } this.sources = sources; this.render(); } static async #onAddValue(event, target) { const sources = CustomAttributionSourcesForm.#serializeForm(target); const index = parseInt(target.dataset.sourceIndex); if (!isNaN(index)) { sources[index].values.push({ label: "", hint: "" }); } this.sources = sources; this.render(); } static async #onRemoveValue(event, target) { const sources = CustomAttributionSourcesForm.#serializeForm(target); const sourceIndex = parseInt(target.dataset.sourceIndex); const valueIndex = parseInt(target.dataset.valueIndex); if (!isNaN(sourceIndex) && !isNaN(valueIndex)) { sources[sourceIndex].values.splice(valueIndex, 1); } this.sources = sources; this.render(); } static async #onReset(event, target) { const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: "Reset Custom Attribution Sources" }, content: "Are you sure you want to discard your unsaved changes and reload settings?" }); if (!confirmed) return; const saved = game.settings.get("dh-attribution-sources", "customSources") || []; this.sources = foundry.utils.deepClone(saved); this.render(); } static async #onSave(event, target) { const sources = CustomAttributionSourcesForm.#serializeForm(target); // Validation const ids = new Set(); for (const src of sources) { const cleanId = src.id.trim(); if (!cleanId) { ui.notifications.error("All attribution source groups must have a non-empty ID."); return; } if (ids.has(cleanId)) { ui.notifications.error(`Duplicate ID found: "${cleanId}". Each attribution source group must have a unique ID.`); return; } ids.add(cleanId); src.id = cleanId; src.label = src.label.trim() || cleanId; if (src.values.length === 0) { ui.notifications.error(`Group "${src.label}" must contain at least one attribution value.`); return; } for (const val of src.values) { val.label = val.label.trim(); val.hint = val.hint.trim(); if (!val.label) { ui.notifications.error(`Group "${src.label}" has an empty attribution label.`); return; } } } await game.settings.set("dh-attribution-sources", "customSources", sources); applyAttributionSources(); ui.notifications.info("Custom attribution sources saved successfully."); this.close(); } static async #onSubmit(event, form, formData) { // Form submit falls through; handled by onSave button click } } // Hook into initialization to register the settings and configuration panel Hooks.once('init', () => { game.settings.register("dh-attribution-sources", "customSources", { name: "Custom Attribution Sources List", scope: "world", config: false, type: Array, default: [] }); game.settings.registerMenu("dh-attribution-sources", "manageSources", { name: "Custom Attribution Sources", label: "Manage Custom Sources", hint: "Configure your custom item attribution sources.", icon: "fas fa-signature", type: CustomAttributionSourcesForm, restricted: true }); }); // Setup hook to run once the system has registered its configuration Hooks.on('setup', () => { applyAttributionSources(); });