All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

780 lines
27 KiB

import { cacheImages } from '../scripts/search.js';
import { TVA_CONFIG, updateSettings } from '../scripts/settings.js';
import { getFileName } from '../scripts/utils.js';
import EffectMappingForm from './effectMappingForm.js';
import { showPathSelectCategoryDialog, showPathSelectConfigForm } from './dialogs.js';
export default class ConfigureSettings extends FormApplication {
constructor(
dummySettings,
{
searchPaths = true,
searchFilters = true,
searchAlgorithm = true,
randomizer = true,
popup = true,
permissions = true,
worldHud = true,
misc = true,
activeEffects = true,
features = false,
} = {}
) {
super({}, {});
this.enabledTabs = {
searchPaths,
searchFilters,
searchAlgorithm,
randomizer,
features,
popup,
permissions,
worldHud,
misc,
activeEffects,
};
this.settings = foundry.utils.deepClone(TVA_CONFIG);
if (dummySettings) {
this.settings = mergeObject(this.settings, dummySettings, { insertKeys: false });
this.dummySettings = dummySettings;
}
}
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
id: 'token-variants-configure-settings',
classes: ['sheet'],
template: 'modules/token-variants/templates/configureSettings.html',
resizable: false,
minimizable: false,
title: 'Configure Settings',
width: 700,
height: 'auto',
tabs: [{ navSelector: '.sheet-tabs', contentSelector: '.content', initial: 'searchPaths' }],
});
}
async getData(options) {
const data = super.getData(options);
const settings = this.settings;
data.enabledTabs = this.enabledTabs;
// === Search Paths ===
const paths = settings.searchPaths.map((path) => {
const r = {};
r.text = path.text;
r.icon = this._pathIcon(path.source || '');
r.cache = path.cache;
r.source = path.source || '';
r.types = path.types.join(',');
r.config = JSON.stringify(path.config ?? {});
r.hasConfig = path.config && !isEmpty(path.config);
return r;
});
data.searchPaths = paths;
// === Search Filters ===
data.searchFilters = settings.searchFilters;
for (const filter in data.searchFilters) {
data.searchFilters[filter].label = filter;
}
// === Algorithm ===
data.algorithm = deepClone(settings.algorithm);
data.algorithm.fuzzyThreshold = 100 - data.algorithm.fuzzyThreshold * 100;
// === Randomizer ===
// Get all actor types defined by the game system
data.randomizer = deepClone(settings.randomizer);
const actorTypes = (game.system.entityTypes ?? game.system.documentTypes)['Actor'];
data.randomizer.actorTypes = actorTypes.reduce((obj, t) => {
const label = CONFIG['Actor']?.typeLabels?.[t] ?? t;
obj[t] = {
label: game.i18n.has(label) ? game.i18n.localize(label) : t,
disable: settings.randomizer[`${t}Disable`] ?? false,
};
return obj;
}, {});
data.randomizer.tokenToPortraitDisabled =
!(settings.randomizer.tokenCreate || settings.randomizer.tokenCopyPaste) || data.randomizer.diffImages;
// === Pop-up ===
data.popup = deepClone(settings.popup);
// Get all actor types defined by the game system
data.popup.actorTypes = actorTypes.reduce((obj, t) => {
const label = CONFIG['Actor']?.typeLabels?.[t] ?? t;
obj[t] = {
type: t,
label: game.i18n.has(label) ? game.i18n.localize(label) : t,
disable: settings.popup[`${t}Disable`] ?? false,
};
return obj;
}, {});
// Split into arrays of max length 3
let allTypes = [];
let tempTypes = [];
let i = 0;
for (const [key, value] of Object.entries(data.popup.actorTypes)) {
tempTypes.push(value);
i++;
if (i % 3 == 0) {
allTypes.push(tempTypes);
tempTypes = [];
}
}
if (tempTypes.length > 0) allTypes.push(tempTypes);
data.popup.actorTypes = allTypes;
// === Permissions ===
data.permissions = settings.permissions;
// === Token HUD ===
data.worldHud = deepClone(settings.worldHud);
data.worldHud.tokenHUDWildcardActive = game.modules.get('token-hud-wildcard')?.active;
// === Internal Effects ===
data.internalEffects = deepClone(settings.internalEffects);
// === Misc ===
data.keywordSearch = settings.keywordSearch;
data.excludedKeywords = settings.excludedKeywords;
data.systemHpPath = settings.systemHpPath;
data.runSearchOnPath = settings.runSearchOnPath;
data.imgurClientId = settings.imgurClientId;
data.enableStatusConfig = settings.enableStatusConfig;
data.disableNotifs = settings.disableNotifs;
data.staticCache = settings.staticCache;
data.staticCacheFile = settings.staticCacheFile;
data.stackStatusConfig = settings.stackStatusConfig;
data.mergeGroup = settings.mergeGroup;
data.customImageCategories = settings.customImageCategories.join(',');
data.disableEffectIcons = settings.disableEffectIcons;
data.displayEffectIconsOnHover = settings.displayEffectIconsOnHover;
data.filterEffectIcons = settings.filterEffectIcons;
data.hideElevationTooltip = settings.hideElevationTooltip;
data.hideTokenBorder = settings.hideTokenBorder;
data.filterCustomEffectIcons = settings.filterCustomEffectIcons;
data.filterIconList = settings.filterIconList.join(',');
data.tilesEnabled = settings.tilesEnabled;
data.updateTokenProto = settings.updateTokenProto;
data.imgNameContainsDimensions = settings.imgNameContainsDimensions;
data.imgNameContainsFADimensions = settings.imgNameContainsFADimensions;
data.playVideoOnHover = settings.playVideoOnHover;
data.pauseVideoOnHoverOut = settings.pauseVideoOnHoverOut;
data.disableImageChangeOnPolymorphed = settings.disableImageChangeOnPolymorphed;
data.disableImageUpdateOnNonPrototype = settings.disableImageUpdateOnNonPrototype;
data.disableTokenUpdateAnimation = settings.disableTokenUpdateAnimation;
data.mappingsCurrentSceneOnly = settings.mappingsCurrentSceneOnly;
data.evaluateOverlayOnHover = settings.evaluateOverlayOnHover;
// Controls
data.pathfinder = ['pf1e', 'pf2e'].includes(game.system.id);
data.dnd5e = game.system.id === 'dnd5e';
return data;
}
/**
* @param {JQuery} html
*/
activateListeners(html) {
super.activateListeners(html);
// Search Paths
super.activateListeners(html);
html.find('a.create-path').click(this._onCreatePath.bind(this));
html.on('input', '.searchSource', this._onSearchSourceTextChange.bind(this));
$(html).on('click', 'a.delete-path', this._onDeletePath.bind(this));
$(html).on('click', 'a.convert-imgur', this._onConvertImgurPath.bind(this));
$(html).on('click', 'a.convert-json', this._onConvertJsonPath.bind(this));
$(html).on('click', '.path-image.source-icon a', this._onBrowseFolder.bind(this));
$(html).on('click', 'a.select-category', showPathSelectCategoryDialog.bind(this));
$(html).on('click', 'a.select-config', showPathSelectConfigForm.bind(this));
// Search Filters
html.on('input', 'input.filterRegex', this._validateRegex.bind(this));
// Active Effects
const disableEffectIcons = html.find('[name="disableEffectIcons"]');
const filterEffectIcons = html.find('[name="filterEffectIcons"]');
disableEffectIcons
.on('change', (e) => {
if (e.target.checked) filterEffectIcons.prop('checked', false);
})
.trigger('change');
filterEffectIcons.on('change', (e) => {
if (e.target.checked) disableEffectIcons.prop('checked', false);
});
// Algorithm
const algorithmTab = $(html).find('div[data-tab="searchAlgorithm"]');
algorithmTab.find(`input[name="algorithm.exact"]`).change((e) => {
$(e.target).closest('form').find('input[name="algorithm.fuzzy"]').prop('checked', !e.target.checked);
});
algorithmTab.find(`input[name="algorithm.fuzzy"]`).change((e) => {
$(e.target).closest('form').find('input[name="algorithm.exact"]').prop('checked', !e.target.checked);
});
algorithmTab.find('input[name="algorithm.fuzzyThreshold"]').change((e) => {
$(e.target).siblings('.token-variants-range-value').html(`${e.target.value}%`);
});
// Randomizer
const tokenCreate = html.find('input[name="randomizer.tokenCreate"]');
const tokenCopyPaste = html.find('input[name="randomizer.tokenCopyPaste"]');
const tokenToPortrait = html.find('input[name="randomizer.tokenToPortrait"]');
const _toggle = () => {
tokenToPortrait.prop('disabled', !(tokenCreate.is(':checked') || tokenCopyPaste.is(':checked')));
};
tokenCreate.change(_toggle);
tokenCopyPaste.change(_toggle);
const diffImages = html.find('input[name="randomizer.diffImages"]');
const syncImages = html.find('input[name="randomizer.syncImages"]');
diffImages.change(() => {
syncImages.prop('disabled', !diffImages.is(':checked'));
tokenToPortrait.prop('disabled', diffImages.is(':checked'));
});
// Token HUD
html.find('input[name="worldHud.updateActorImage"]').change((event) => {
$(event.target)
.closest('form')
.find('input[name="worldHud.useNameSimilarity"]')
.prop('disabled', !event.target.checked);
});
// Static Cache
html.find('button.token-variants-cache-images').click((event) => {
const tab = $(event.target).closest('.tab');
const staticOn = tab.find('input[name="staticCache"]');
const staticFile = tab.find('input[name="staticCacheFile"]');
cacheImages({ staticCache: staticOn.is(':checked'), staticCacheFile: staticFile.val() });
});
// Global Mappings
html.find('button.token-variants-global-mapping').click(() => {
const setting = game.settings.get('core', DefaultTokenConfig.SETTING);
const data = new foundry.data.PrototypeToken(setting);
const token = new TokenDocument(data, { actor: null });
new EffectMappingForm(token, { globalMappings: true }).render(true);
});
}
/**
* Validates regex entered into Search Filter's RegEx input field
*/
async _validateRegex(event) {
if (this._validRegex(event.target.value)) {
event.target.style.backgroundColor = '';
} else {
event.target.style.backgroundColor = '#ff7066';
}
}
_validRegex(val) {
if (val) {
try {
new RegExp(val);
} catch (e) {
return false;
}
}
return true;
}
/**
* Open a FilePicker so the user can select a local folder to use as an image source
*/
async _onBrowseFolder(event) {
const pathInput = $(event.target).closest('.table-row').find('.path-text input');
const sourceInput = $(event.target).closest('.table-row').find('.path-source input');
let activeSource = sourceInput.val() || 'data';
let current = pathInput.val();
if (activeSource.startsWith('s3:')) {
const bucketName = activeSource.replace('s3:', '');
current = `${game.data.files.s3?.endpoint.protocol}//${bucketName}.${game.data.files.s3?.endpoint.host}/${current}`;
} else if (activeSource.startsWith('rolltable')) {
let content = `<select style="width: 100%;" name="table-name" id="output-tableKey">`;
game.tables.forEach((rollTable) => {
content += `<option value='${rollTable.name}'>${rollTable.name}</option>`;
});
content += `</select>`;
new Dialog({
title: `Select a Rolltable`,
content: content,
buttons: {
yes: {
icon: "<i class='fas fa-check'></i>",
label: 'Select',
callback: (html) => {
pathInput.val();
const tableName = html.find("select[name='table-name']").val();
pathInput.val(tableName);
},
},
},
default: 'yes',
}).render(true);
return;
}
if (activeSource === 'json') {
new FilePicker({
type: 'text',
activeSource: 'data',
current: current,
callback: (path, fp) => {
pathInput.val(path);
},
}).render(true);
} else {
new FilePicker({
type: 'folder',
activeSource: activeSource,
current: current,
callback: (path, fp) => {
pathInput.val(fp.result.target);
if (fp.activeSource === 's3') {
sourceInput.val(`s3:${fp.result.bucket}`);
} else {
sourceInput.val(fp.activeSource);
}
},
}).render(true);
}
}
/**
* Converts Imgur path to a rolltable
*/
async _onConvertImgurPath(event) {
event.preventDefault();
const pathInput = $(event.target).closest('.table-row').find('.path-text input');
const sourceInput = $(event.target).closest('.table-row').find('.path-source input');
const albumHash = pathInput.val();
const imgurClientId = TVA_CONFIG.imgurClientId === '' ? 'df9d991443bb222' : TVA_CONFIG.imgurClientId;
fetch('https://api.imgur.com/3/gallery/album/' + albumHash, {
headers: {
Authorization: 'Client-ID ' + imgurClientId,
Accept: 'application/json',
},
})
.then((response) => response.json())
.then(
async function (result) {
if (!result.success && location.hostname === 'localhost') {
ui.notifications.warn(game.i18n.format('token-variants.notifications.warn.imgur-localhost'));
return;
}
const data = result.data;
let resultsArray = [];
data.images.forEach((img, i) => {
resultsArray.push({
type: 0,
text: img.title ?? img.description ?? '',
weight: 1,
range: [i + 1, i + 1],
collection: 'Text',
drawn: false,
img: img.link,
});
});
await RollTable.create({
name: data.title,
description: 'Token Variant Art auto generated RollTable: https://imgur.com/gallery/' + albumHash,
results: resultsArray,
replacement: true,
displayRoll: true,
img: 'modules/token-variants/img/token-images.svg',
});
pathInput.val(data.title);
sourceInput.val('rolltable').trigger('input');
}.bind(this)
)
.catch((error) => console.warn('TVA | ', error));
}
/**
* Converts Json path to a rolltable
*/
async _onConvertJsonPath(event) {
event.preventDefault();
const pathInput = $(event.target).closest('.table-row').find('.path-text input');
const sourceInput = $(event.target).closest('.table-row').find('.path-source input');
const jsonPath = pathInput.val();
fetch(jsonPath, {
headers: {
Accept: 'application/json',
},
})
.then((response) => response.json())
.then(
async function (result) {
if (!result.length > 0) {
ui.notifications.warn(game.i18n.format('token-variants.notifications.warn.json-localhost'));
return;
}
const data = result;
data.title = getFileName(jsonPath);
let resultsArray = [];
data.forEach((img, i) => {
resultsArray.push({
type: 0,
text: img.name ?? '',
weight: 1,
range: [i + 1, i + 1],
collection: 'Text',
drawn: false,
img: img.path,
});
});
await RollTable.create({
name: data.title,
description: 'Token Variant Art auto generated RollTable: ' + jsonPath,
results: resultsArray,
replacement: true,
displayRoll: true,
img: 'modules/token-variants/img/token-images.svg',
});
pathInput.val(data.title);
sourceInput.val('rolltable').trigger('input');
}.bind(this)
)
.catch((error) => console.warn('TVA | ', error));
}
/**
* Generates a new search path row
*/
async _onCreatePath(event) {
event.preventDefault();
const table = $(event.currentTarget).closest('.token-variant-table');
let row = `
<li class="table-row flexrow">
<div class="path-image source-icon">
<a><i class="${this._pathIcon('')}"></i></a>
</div>
<div class="path-source">
<input class="searchSource" type="text" name="searchPaths.source" value="" placeholder="data"/>
</div>
<div class="path-text">
<input class="searchPath" type="text" name="searchPaths.text" value="" placeholder="Path to folder"/>
</div>
<div class="imgur-control">
<a class="convert-imgur" title="Convert to Rolltable"><i class="fas fa-angle-double-left"></i></a>
</div>
<div class="json-control">
<a class="convert-json" title="Convert to Rolltable"><i class="fas fa-angle-double-left"></i></a>
</div>
<div class="path-category">
<a class="select-category" title="Select image categories/filters"><i class="fas fa-swatchbook"></i></a>
<input type="hidden" name="searchPaths.types" value="Portrait,Token,PortraitAndToken">
</div>
<div class="path-config">
<a class="select-config" title="Apply configuration to images under this path."><i class="fas fa-cog fa-lg"></i></a>
<input type="hidden" name="searchPaths.config" value="{}">
</div>
<div class="path-cache">
<input type="checkbox" name="searchPaths.cache" data-dtype="Boolean" checked/>
</div>
<div class="path-controls">
<a class="delete-path" title="Delete path"><i class="fas fa-trash"></i></a>
</div>
</li>
`;
table.append(row);
this._reIndexPaths(table);
this.setPosition(); // Auto-resize window
}
async _reIndexPaths(table) {
table
.find('.path-source')
.find('input')
.each(function (index) {
$(this).attr('name', `searchPaths.${index}.source`);
});
table
.find('.path-text')
.find('input')
.each(function (index) {
$(this).attr('name', `searchPaths.${index}.text`);
});
table
.find('.path-cache')
.find('input')
.each(function (index) {
$(this).attr('name', `searchPaths.${index}.cache`);
});
table
.find('.path-category')
.find('input')
.each(function (index) {
$(this).attr('name', `searchPaths.${index}.types`);
});
table
.find('.path-config')
.find('input')
.each(function (index) {
$(this).attr('name', `searchPaths.${index}.config`);
});
}
async _onDeletePath(event) {
event.preventDefault();
const li = event.currentTarget.closest('.table-row');
li.remove();
const table = $(event.currentTarget).closest('.token-variant-table');
this._reIndexPaths(table);
this.setPosition(); // Auto-resize window
}
async _onSearchSourceTextChange(event) {
const image = this._pathIcon(event.target.value);
const imgur = image === 'fas fa-info';
const json = image === 'fas fa-brackets-curly';
const imgurControl = $(event.currentTarget).closest('.table-row').find('.imgur-control');
if (imgur) imgurControl.addClass('active');
else imgurControl.removeClass('active');
const jsonControl = $(event.currentTarget).closest('.table-row').find('.json-control');
if (json) jsonControl.addClass('active');
else jsonControl.removeClass('active');
$(event.currentTarget).closest('.table-row').find('.path-image i').attr('class', image);
}
// Return icon appropriate for the path provided
_pathIcon(source) {
if (source.startsWith('s3')) {
return 'fas fa-database';
} else if (source.startsWith('rolltable')) {
return 'fas fa-dice';
} else if (source.startsWith('forgevtt') || source.startsWith('forge-bazaar')) {
return 'fas fa-hammer';
} else if (source.startsWith('imgur')) {
return 'fas fa-info';
} else if (source.startsWith('json')) {
return 'fas fa-brackets-curly';
}
return 'fas fa-folder';
}
/**
* @param {Event} event
* @param {Object} formData
*/
async _updateObject(event, formData) {
const settings = this.settings;
formData = expandObject(formData);
// Search Paths
settings.searchPaths = formData.hasOwnProperty('searchPaths') ? Object.values(formData.searchPaths) : [];
settings.searchPaths.forEach((path) => {
if (!path.source) path.source = 'data';
if (path.types) path.types = path.types.split(',');
else path.types = [];
if (path.config) {
try {
path.config = JSON.parse(path.config);
} catch (e) {
delete path.config;
}
} else delete path.config;
});
// Search Filters
for (const filter in formData.searchFilters) {
if (!this._validRegex(formData.searchFilters[filter].regex)) formData.searchFilters[filter].regex = '';
}
mergeObject(settings.searchFilters, formData.searchFilters);
// Algorithm
formData.algorithm.fuzzyLimit = parseInt(formData.algorithm.fuzzyLimit);
if (isNaN(formData.algorithm.fuzzyLimit) || formData.algorithm.fuzzyLimit < 1) formData.algorithm.fuzzyLimit = 50;
formData.algorithm.fuzzyThreshold = (100 - formData.algorithm.fuzzyThreshold) / 100;
mergeObject(settings.algorithm, formData.algorithm);
// Randomizer
mergeObject(settings.randomizer, formData.randomizer);
// Pop-up
mergeObject(settings.popup, formData.popup);
// Permissions
mergeObject(settings.permissions, formData.permissions);
// Token HUD
mergeObject(settings.worldHud, formData.worldHud);
// Internal Effects
mergeObject(settings.internalEffects, formData.internalEffects);
// Misc
mergeObject(settings, {
keywordSearch: formData.keywordSearch,
excludedKeywords: formData.excludedKeywords,
systemHpPath: formData.systemHpPath?.trim(),
runSearchOnPath: formData.runSearchOnPath,
imgurClientId: formData.imgurClientId,
enableStatusConfig: formData.enableStatusConfig,
disableNotifs: formData.disableNotifs,
staticCache: formData.staticCache,
staticCacheFile: formData.staticCacheFile,
tilesEnabled: formData.tilesEnabled,
stackStatusConfig: formData.stackStatusConfig,
mergeGroup: formData.mergeGroup,
customImageCategories: (formData.customImageCategories || '')
.split(',')
.map((t) => t.trim())
.filter((t) => t),
disableEffectIcons: formData.disableEffectIcons,
displayEffectIconsOnHover: formData.displayEffectIconsOnHover,
filterEffectIcons: formData.filterEffectIcons,
hideElevationTooltip: formData.hideElevationTooltip,
hideTokenBorder: formData.hideTokenBorder,
filterCustomEffectIcons: formData.filterCustomEffectIcons,
filterIconList: (formData.filterIconList || '')
.split(',')
.map((t) => t.trim())
.filter((t) => t),
updateTokenProto: formData.updateTokenProto,
imgNameContainsDimensions: formData.imgNameContainsDimensions,
imgNameContainsFADimensions: formData.imgNameContainsFADimensions,
playVideoOnHover: formData.playVideoOnHover,
pauseVideoOnHoverOut: formData.pauseVideoOnHoverOut,
disableImageChangeOnPolymorphed: formData.disableImageChangeOnPolymorphed,
disableImageUpdateOnNonPrototype: formData.disableImageUpdateOnNonPrototype,
disableTokenUpdateAnimation: formData.disableTokenUpdateAnimation,
mappingsCurrentSceneOnly: formData.mappingsCurrentSceneOnly,
evaluateOverlayOnHover: formData.evaluateOverlayOnHover,
});
// Global Mappings
settings.globalMappings = TVA_CONFIG.globalMappings;
// Save Settings
if (this.dummySettings) {
mergeObjectFix(this.dummySettings, settings, { insertKeys: false });
} else {
updateSettings(settings);
}
}
}
// ========================
// v8 support, broken merge
// ========================
export function mergeObjectFix(
original,
other = {},
{
insertKeys = true,
insertValues = true,
overwrite = true,
recursive = true,
inplace = true,
enforceTypes = false,
} = {},
_d = 0
) {
other = other || {};
if (!(original instanceof Object) || !(other instanceof Object)) {
throw new Error('One of original or other are not Objects!');
}
const options = { insertKeys, insertValues, overwrite, recursive, inplace, enforceTypes };
// Special handling at depth 0
if (_d === 0) {
if (!inplace) original = deepClone(original);
if (Object.keys(original).some((k) => /\./.test(k))) original = expandObject(original);
if (Object.keys(other).some((k) => /\./.test(k))) other = expandObject(other);
}
// Iterate over the other object
for (let k of Object.keys(other)) {
const v = other[k];
if (original.hasOwnProperty(k)) _mergeUpdate(original, k, v, options, _d + 1);
else _mergeInsertFix(original, k, v, options, _d + 1);
}
return original;
}
function _mergeInsertFix(original, k, v, { insertKeys, insertValues } = {}, _d) {
// Recursively create simple objects
if (v?.constructor === Object && insertKeys) {
original[k] = mergeObjectFix({}, v, { insertKeys: true, inplace: true });
return;
}
// Delete a key
if (k.startsWith('-=')) {
delete original[k.slice(2)];
return;
}
// Insert a key
const canInsert = (_d <= 1 && insertKeys) || (_d > 1 && insertValues);
if (canInsert) original[k] = v;
}
function _mergeUpdate(original, k, v, { insertKeys, insertValues, enforceTypes, overwrite, recursive } = {}, _d) {
const x = original[k];
const tv = getType(v);
const tx = getType(x);
// Recursively merge an inner object
if (tv === 'Object' && tx === 'Object' && recursive) {
return mergeObjectFix(
x,
v,
{
insertKeys: insertKeys,
insertValues: insertValues,
overwrite: overwrite,
inplace: true,
enforceTypes: enforceTypes,
},
_d
);
}
// Overwrite an existing value
if (overwrite) {
if (tx !== 'undefined' && tv !== tx && enforceTypes) {
throw new Error(`Mismatched data types encountered during object merge.`);
}
original[k] = v;
}
}