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.

1126 lines
35 KiB

  1. import { TVA_CONFIG, updateSettings, _arrayAwareDiffObject } from './settings.js';
  2. import { showArtSelect } from '../token-variants.mjs';
  3. import EffectMappingForm from '../applications/effectMappingForm.js';
  4. import CompendiumMapConfig from '../applications/compendiumMap.js';
  5. import { toggleTemplateDialog } from '../applications/dialogs.js';
  6. const simplifyRegex = new RegExp(/[^A-Za-z0-9/\\]/g);
  7. export const SUPPORTED_COMP_ATTRIBUTES = ['rotation', 'elevation'];
  8. export const EXPRESSION_OPERATORS = ['\\(', '\\)', '&&', '||', '\\!'];
  9. // Types of searches
  10. export const SEARCH_TYPE = {
  11. PORTRAIT: 'Portrait',
  12. TOKEN: 'Token',
  13. PORTRAIT_AND_TOKEN: 'PortraitAndToken',
  14. TILE: 'Tile',
  15. ITEM: 'Item',
  16. JOURNAL: 'JournalEntry',
  17. MACRO: 'Macro',
  18. };
  19. export const BASE_IMAGE_CATEGORIES = [
  20. 'Portrait',
  21. 'Token',
  22. 'PortraitAndToken',
  23. 'Tile',
  24. 'Item',
  25. 'JournalEntry',
  26. 'Macro',
  27. 'RollTable',
  28. ];
  29. export const PRESSED_KEYS = {
  30. popupOverride: false,
  31. config: false,
  32. };
  33. const BATCH_UPDATES = {
  34. TOKEN: [],
  35. TOKEN_CALLBACKS: [],
  36. TOKEN_CONTEXT: { animate: true },
  37. ACTOR: [],
  38. ACTOR_CONTEXT: null,
  39. };
  40. export function startBatchUpdater() {
  41. canvas.app.ticker.add(() => {
  42. if (BATCH_UPDATES.TOKEN.length) {
  43. canvas.scene.updateEmbeddedDocuments('Token', BATCH_UPDATES.TOKEN, BATCH_UPDATES.TOKEN_CONTEXT).then(() => {
  44. for (const cb of BATCH_UPDATES.TOKEN_CALLBACKS) {
  45. cb();
  46. }
  47. BATCH_UPDATES.TOKEN_CALLBACKS = [];
  48. });
  49. BATCH_UPDATES.TOKEN = [];
  50. }
  51. if (BATCH_UPDATES.ACTOR.length !== 0) {
  52. if (BATCH_UPDATES.ACTOR_CONTEXT) Actor.updateDocuments(BATCH_UPDATES.ACTOR, BATCH_UPDATES.ACTOR_CONTEXT);
  53. else Actor.updateDocuments(BATCH_UPDATES.ACTOR);
  54. BATCH_UPDATES.ACTOR = [];
  55. BATCH_UPDATES.ACTOR_CONTEXT = null;
  56. }
  57. });
  58. }
  59. export function queueTokenUpdate(id, update, callback = null, animate = true) {
  60. update._id = id;
  61. BATCH_UPDATES.TOKEN.push(update);
  62. BATCH_UPDATES.TOKEN_CONTEXT = { animate };
  63. if (callback) BATCH_UPDATES.TOKEN_CALLBACKS.push(callback);
  64. }
  65. export function queueActorUpdate(id, update, context = null) {
  66. update._id = id;
  67. BATCH_UPDATES.ACTOR.push(update);
  68. BATCH_UPDATES.ACTOR_CONTEXT = context;
  69. }
  70. /**
  71. * Updates Token and/or Proto Token with the new image and custom configuration if one exists.
  72. * @param {string} imgSrc Image source path/url
  73. * @param {object} [options={}] Update options
  74. * @param {Token[]} [options.token] Token to be updated with the new image
  75. * @param {Actor} [options.actor] Actor with Proto Token to be updated with the new image
  76. * @param {string} [options.imgName] Image name if it differs from the file name. Relevant for rolltable sourced images.
  77. * @param {object} [options.tokenUpdate] Token update to be merged and performed at the same time as image update
  78. * @param {object} [options.actorUpdate] Actor update to be merged and performed at the same time as image update
  79. * @param {string} [options.pack] Compendium pack of the Actor being updated
  80. * @param {func} [options.callback] Callback to be executed when a batch update has been performed
  81. * @param {object} [options.config] Token Configuration settings to be applied to the token
  82. */
  83. export async function updateTokenImage(
  84. imgSrc,
  85. {
  86. token = null,
  87. actor = null,
  88. imgName = null,
  89. tokenUpdate = {},
  90. actorUpdate = {},
  91. pack = '',
  92. callback = null,
  93. config = undefined,
  94. animate = true,
  95. update = null,
  96. applyDefaultConfig = true,
  97. } = {}
  98. ) {
  99. if (!(token || actor)) {
  100. console.warn(game.i18n.localize('token-variants.notifications.warn.update-image-no-token-actor'));
  101. return;
  102. }
  103. token = token?.document ?? token;
  104. // Check if it's a wildcard image
  105. if ((imgSrc && imgSrc.includes('*')) || (imgSrc.includes('{') && imgSrc.includes('}'))) {
  106. const images = await wildcardImageSearch(imgSrc);
  107. if (images.length) {
  108. imgSrc = images[Math.floor(Math.random() * images.length)];
  109. }
  110. }
  111. if (!actor && token.actor) {
  112. actor = game.actors.get(token.actor.id);
  113. }
  114. const getDefaultConfig = (token, actor) => {
  115. let configEntries = [];
  116. if (token) configEntries = token.getFlag('token-variants', 'defaultConfig') || [];
  117. else if (actor) {
  118. const tokenData = actor.prototypeToken;
  119. if ('token-variants' in tokenData.flags && 'defaultConfig' in tokenData['token-variants'])
  120. configEntries = tokenData['token-variants']['defaultConfig'];
  121. }
  122. return expandObject(Object.fromEntries(configEntries));
  123. };
  124. const constructDefaultConfig = (origData, customConfig) => {
  125. const flatOrigData = flattenObject(origData);
  126. TokenDataAdapter.dataToForm(flatOrigData);
  127. const flatCustomConfig = flattenObject(customConfig);
  128. let filtered = filterObject(flatOrigData, flatCustomConfig);
  129. // Flags need special treatment as once set they are not removed via absence of them in the update
  130. for (let [k, v] of Object.entries(flatCustomConfig)) {
  131. if (k.startsWith('flags.')) {
  132. if (!(k in flatOrigData)) {
  133. let splitK = k.split('.');
  134. splitK[splitK.length - 1] = '-=' + splitK[splitK.length - 1];
  135. filtered[splitK.join('.')] = null;
  136. }
  137. }
  138. }
  139. return Object.entries(filtered);
  140. };
  141. let tokenUpdateObj = tokenUpdate;
  142. if (imgSrc) {
  143. setProperty(tokenUpdateObj, 'texture.src', imgSrc);
  144. if (imgName && getFileName(imgSrc) === imgName) setProperty(tokenUpdateObj, 'flags.token-variants.-=name', null);
  145. else setProperty(tokenUpdateObj, 'flags.token-variants.name', imgName);
  146. }
  147. const tokenCustomConfig = mergeObject(
  148. getTokenConfigForUpdate(imgSrc || token?.texture.src, imgName, token),
  149. config ?? {}
  150. );
  151. const usingCustomConfig = token?.getFlag('token-variants', 'usingCustomConfig');
  152. const defaultConfig = getDefaultConfig(token);
  153. if (!isEmpty(tokenCustomConfig) || usingCustomConfig) {
  154. tokenUpdateObj = modMergeObject(tokenUpdateObj, defaultConfig);
  155. }
  156. if (!isEmpty(tokenCustomConfig)) {
  157. if (token) {
  158. setProperty(tokenUpdateObj, 'flags.token-variants.usingCustomConfig', true);
  159. let doc = token.document ?? token;
  160. const tokenData = doc.toObject ? doc.toObject() : deepClone(doc);
  161. const defConf = constructDefaultConfig(mergeObject(tokenData, defaultConfig), tokenCustomConfig);
  162. setProperty(tokenUpdateObj, 'flags.token-variants.defaultConfig', defConf);
  163. } else if (actor && !token) {
  164. setProperty(tokenUpdateObj, 'flags.token-variants.usingCustomConfig', true);
  165. const tokenData = actor.prototypeToken instanceof Object ? actor.prototypeToken : actor.prototypeToken.toObject();
  166. const defConf = constructDefaultConfig(tokenData, tokenCustomConfig);
  167. setProperty(tokenUpdateObj, 'flags.token-variants.defaultConfig', defConf);
  168. }
  169. // Fix, an empty flag may be passed which would overwrite any current flags in the updateObj
  170. // Remove it before doing the merge
  171. if (!tokenCustomConfig.flags) {
  172. delete tokenCustomConfig.flags;
  173. }
  174. tokenUpdateObj = modMergeObject(tokenUpdateObj, tokenCustomConfig);
  175. } else if (usingCustomConfig) {
  176. setProperty(tokenUpdateObj, 'flags.token-variants.-=usingCustomConfig', null);
  177. delete tokenUpdateObj?.flags?.['token-variants']?.defaultConfig;
  178. setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultConfig', null);
  179. }
  180. if (!applyDefaultConfig) {
  181. setProperty(tokenUpdateObj, 'flags.token-variants.-=usingCustomConfig', null);
  182. delete tokenUpdateObj?.flags?.['token-variants']?.defaultConfig;
  183. setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultConfig', null);
  184. }
  185. if (!isEmpty(tokenUpdateObj)) {
  186. if (actor && !token) {
  187. TokenDataAdapter.formToData(actor.prototypeToken, tokenUpdateObj);
  188. actorUpdate.token = tokenUpdateObj;
  189. if (pack) {
  190. queueActorUpdate(actor.id, actorUpdate, { pack: pack });
  191. } else {
  192. await (actor.document ?? actor).update(actorUpdate);
  193. }
  194. }
  195. if (token) {
  196. TokenDataAdapter.formToData(token, tokenUpdateObj);
  197. if (TVA_CONFIG.updateTokenProto && token.actor) {
  198. if (update) {
  199. mergeObject(update, { token: tokenUpdateObj });
  200. } else {
  201. // Timeout to prevent race conditions with other modules namely MidiQOL
  202. // this is a low priority update so it should be Ok to do
  203. if (token.actorLink) {
  204. setTimeout(() => queueActorUpdate(token.actor.id, { token: tokenUpdateObj }), 500);
  205. } else {
  206. setTimeout(() => token.actor.update({ token: tokenUpdateObj }), 500);
  207. }
  208. }
  209. }
  210. if (update) {
  211. mergeObject(update, tokenUpdateObj);
  212. } else {
  213. if (token.object) queueTokenUpdate(token.id, tokenUpdateObj, callback, animate);
  214. else {
  215. await token.update(tokenUpdateObj, { animate });
  216. callback();
  217. }
  218. }
  219. }
  220. }
  221. }
  222. /**
  223. * Assign new artwork to the actor
  224. */
  225. export async function updateActorImage(actor, imgSrc, directUpdate = true, pack = '') {
  226. if (!actor) return;
  227. if (directUpdate) {
  228. await (actor.document ?? actor).update({
  229. img: imgSrc,
  230. });
  231. } else {
  232. queueActorUpdate(
  233. actor.id,
  234. {
  235. img: imgSrc,
  236. },
  237. pack ? { pack: pack } : null
  238. );
  239. }
  240. }
  241. async function showTileArtSelect() {
  242. for (const tile of canvas.tiles.controlled) {
  243. const tileName = tile.document.getFlag('token-variants', 'tileName') || tile.id;
  244. showArtSelect(tileName, {
  245. callback: async function (imgSrc, name) {
  246. tile.document.update({ img: imgSrc });
  247. },
  248. searchType: SEARCH_TYPE.TILE,
  249. });
  250. }
  251. }
  252. /**
  253. * Checks if a key is pressed taking into account current game version.
  254. * @param {string} key v/Ctrl/Shift/Alt
  255. * @returns
  256. */
  257. export function keyPressed(key) {
  258. if (key === 'v') return game.keyboard.downKeys.has('KeyV');
  259. return PRESSED_KEYS[key];
  260. }
  261. export function registerKeybinds() {
  262. game.keybindings.register('token-variants', 'popupOverride', {
  263. name: 'Popup Override',
  264. hint: 'When held will trigger popups even when they are disabled.',
  265. editable: [
  266. {
  267. key: 'ShiftLeft',
  268. },
  269. ],
  270. onDown: () => {
  271. PRESSED_KEYS.popupOverride = true;
  272. },
  273. onUp: () => {
  274. PRESSED_KEYS.popupOverride = false;
  275. },
  276. restricted: false,
  277. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  278. });
  279. game.keybindings.register('token-variants', 'config', {
  280. name: 'Config',
  281. hint: 'When held during a mouse Left-Click of an Image or an Active Affect will display a configuration window.',
  282. editable: [
  283. {
  284. key: 'ShiftLeft',
  285. },
  286. ],
  287. onDown: () => {
  288. PRESSED_KEYS.config = true;
  289. },
  290. onUp: () => {
  291. PRESSED_KEYS.config = false;
  292. },
  293. restricted: false,
  294. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  295. });
  296. game.keybindings.register('token-variants', 'showArtSelectPortrait', {
  297. name: 'Show Art Select: Portrait',
  298. hint: 'Brings up an Art Select pop-up to change the portrait images of the selected tokens.',
  299. editable: [
  300. {
  301. key: 'Digit1',
  302. modifiers: ['Shift'],
  303. },
  304. ],
  305. onDown: () => {
  306. for (const token of canvas.tokens.controlled) {
  307. const actor = token.actor;
  308. if (!actor) continue;
  309. showArtSelect(actor.name, {
  310. callback: async function (imgSrc, name) {
  311. await updateActorImage(actor, imgSrc);
  312. },
  313. searchType: SEARCH_TYPE.PORTRAIT,
  314. object: actor,
  315. });
  316. }
  317. if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
  318. },
  319. restricted: true,
  320. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  321. });
  322. game.keybindings.register('token-variants', 'showArtSelectToken', {
  323. name: 'Show Art Select: Token',
  324. hint: 'Brings up an Art Select pop-up to change the token images of the selected tokens.',
  325. editable: [
  326. {
  327. key: 'Digit2',
  328. modifiers: ['Shift'],
  329. },
  330. ],
  331. onDown: () => {
  332. for (const token of canvas.tokens.controlled) {
  333. showArtSelect(token.name, {
  334. callback: async function (imgSrc, imgName) {
  335. updateTokenImage(imgSrc, {
  336. actor: token.actor,
  337. imgName: imgName,
  338. token: token,
  339. });
  340. },
  341. searchType: SEARCH_TYPE.TOKEN,
  342. object: token,
  343. });
  344. }
  345. if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
  346. },
  347. restricted: true,
  348. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  349. });
  350. game.keybindings.register('token-variants', 'showArtSelectGeneral', {
  351. name: 'Show Art Select: Portrait+Token',
  352. hint: 'Brings up an Art Select pop-up to change both Portrait and Token images of the selected tokens.',
  353. editable: [
  354. {
  355. key: 'Digit3',
  356. modifiers: ['Shift'],
  357. },
  358. ],
  359. onDown: () => {
  360. for (const token of canvas.tokens.controlled) {
  361. const actor = token.actor;
  362. showArtSelect(token.name, {
  363. callback: async function (imgSrc, imgName) {
  364. if (actor) await updateActorImage(actor, imgSrc);
  365. updateTokenImage(imgSrc, {
  366. actor: token.actor,
  367. imgName: imgName,
  368. token: token,
  369. });
  370. },
  371. searchType: SEARCH_TYPE.PORTRAIT_AND_TOKEN,
  372. object: token,
  373. });
  374. }
  375. if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
  376. },
  377. restricted: true,
  378. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  379. });
  380. game.keybindings.register('token-variants', 'openGlobalMappings', {
  381. name: 'Open Global Effect Configurations',
  382. hint: 'Brings up the settings window for Global Effect Configurations',
  383. editable: [
  384. {
  385. key: 'KeyG',
  386. modifiers: ['Shift'],
  387. },
  388. ],
  389. onDown: () => {
  390. const setting = game.settings.get('core', DefaultTokenConfig.SETTING);
  391. const data = new foundry.data.PrototypeToken(setting);
  392. const token = new TokenDocument(data, { actor: null });
  393. new EffectMappingForm(token, { globalMappings: true }).render(true);
  394. },
  395. restricted: true,
  396. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  397. });
  398. game.keybindings.register('token-variants', 'compendiumMapper', {
  399. name: 'Compendium Mapper',
  400. hint: 'Opens Compendium Mapper',
  401. editable: [
  402. {
  403. key: 'KeyM',
  404. modifiers: ['Shift'],
  405. },
  406. ],
  407. onDown: () => {
  408. new CompendiumMapConfig().render(true);
  409. },
  410. restricted: true,
  411. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  412. });
  413. game.keybindings.register('token-variants', 'toggleTemplate', {
  414. name: 'Toggle Template Dialog',
  415. hint: 'Brings up a dialog from which you can toggle templates on currently selected tokens.',
  416. editable: [],
  417. onDown: toggleTemplateDialog,
  418. restricted: true,
  419. precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
  420. });
  421. }
  422. /**
  423. * Retrieves a custom token configuration if one exists for the given image
  424. */
  425. export function getTokenConfig(imgSrc, imgName) {
  426. if (!imgName) imgName = getFileName(imgSrc);
  427. const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
  428. return tokenConfigs.find((config) => config.tvImgSrc == imgSrc && config.tvImgName == imgName) ?? {};
  429. }
  430. /**
  431. * Retrieves a custom token configuration if one exists for the given image and removes control keys
  432. * returning a clean config that can be used in token update.
  433. */
  434. export function getTokenConfigForUpdate(imgSrc, imgName, token) {
  435. if (!imgSrc) return {};
  436. let tokenConfig = {};
  437. for (const path of TVA_CONFIG.searchPaths) {
  438. if (path.config && imgSrc.startsWith(path.text)) {
  439. mergeObject(tokenConfig, path.config);
  440. }
  441. }
  442. let imgConfig = getTokenConfig(imgSrc, imgName ?? getFileName(imgSrc));
  443. if (!isEmpty(imgConfig)) {
  444. imgConfig = deepClone(imgConfig);
  445. delete imgConfig.tvImgSrc;
  446. delete imgConfig.tvImgName;
  447. if (token) TokenDataAdapter.formToData(token, imgConfig);
  448. for (var key in imgConfig) {
  449. if (!key.startsWith('tvTab_')) {
  450. tokenConfig[key] = imgConfig[key];
  451. }
  452. }
  453. }
  454. if (TVA_CONFIG.imgNameContainsDimensions || TVA_CONFIG.imgNameContainsFADimensions) {
  455. extractDimensionsFromImgName(imgSrc, tokenConfig);
  456. }
  457. return tokenConfig;
  458. }
  459. /**
  460. * Adds or removes a custom token configuration
  461. */
  462. export function setTokenConfig(imgSrc, imgName, tokenConfig) {
  463. const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
  464. const tcIndex = tokenConfigs.findIndex((config) => config.tvImgSrc == imgSrc && config.tvImgName == imgName);
  465. let deleteConfig = !tokenConfig || Object.keys(tokenConfig).length === 0;
  466. if (!deleteConfig) {
  467. tokenConfig['tvImgSrc'] = imgSrc;
  468. tokenConfig['tvImgName'] = imgName;
  469. }
  470. if (tcIndex != -1 && !deleteConfig) {
  471. tokenConfigs[tcIndex] = tokenConfig;
  472. } else if (tcIndex != -1 && deleteConfig) {
  473. tokenConfigs.splice(tcIndex, 1);
  474. } else if (!deleteConfig) {
  475. tokenConfigs.push(tokenConfig);
  476. }
  477. updateSettings({ tokenConfigs: tokenConfigs });
  478. return !deleteConfig;
  479. }
  480. /**
  481. * Extracts the file name from the given path.
  482. */
  483. export function getFileName(path) {
  484. if (!path) return '';
  485. return decodeURISafely(path).split('\\').pop().split('/').pop().split('.').slice(0, -1).join('.');
  486. }
  487. /**
  488. * Extracts the file name including the extension from the given path.
  489. */
  490. export function getFileNameWithExt(path) {
  491. if (!path) return '';
  492. return decodeURISafely(path).split('\\').pop().split('/').pop();
  493. }
  494. /**
  495. * Extract the directory path excluding the file name.
  496. */
  497. export function getFilePath(path) {
  498. return decodeURISafely(path).match(/(.*)[\/\\]/)[1] || '';
  499. }
  500. /**
  501. * Simplify name.
  502. */
  503. export function simplifyName(name) {
  504. return name.replace(simplifyRegex, '').toLowerCase();
  505. }
  506. export function simplifyPath(path) {
  507. return decodeURIComponentSafely(path).replace(simplifyRegex, '').toLowerCase();
  508. }
  509. /**
  510. * Parses the 'excludedKeyword' setting (a comma separated string) into a Set
  511. */
  512. export function parseKeywords(keywords) {
  513. return keywords
  514. .split(/\W/)
  515. .map((word) => simplifyName(word))
  516. .filter((word) => word != '');
  517. }
  518. /**
  519. * Returns true of provided path points to an image
  520. */
  521. export function isImage(path) {
  522. var extension = path.split('.');
  523. extension = extension[extension.length - 1].toLowerCase();
  524. return ['jpg', 'jpeg', 'png', 'svg', 'webp', 'gif'].includes(extension);
  525. }
  526. /**
  527. * Returns true of provided path points to a video
  528. */
  529. export function isVideo(path) {
  530. var extension = path.split('.');
  531. extension = extension[extension.length - 1].toLowerCase();
  532. return ['mp4', 'ogg', 'webm', 'm4v'].includes(extension);
  533. }
  534. /**
  535. * Send a recursive HTTP asset browse request to ForgeVTT
  536. * @param {string} path Asset Library path
  537. * @param {string} apiKey Key with read access to the Asset Library
  538. * @returns
  539. */
  540. export async function callForgeVTT(path, apiKey) {
  541. return new Promise(async (resolve, reject) => {
  542. if (typeof ForgeVTT === 'undefined' || !ForgeVTT.usingTheForge) return resolve({});
  543. const url = `${ForgeVTT.FORGE_URL}/api/assets/browse`;
  544. const xhr = new XMLHttpRequest();
  545. xhr.withCredentials = true;
  546. xhr.open('POST', url);
  547. xhr.setRequestHeader('Access-Key', apiKey);
  548. xhr.setRequestHeader('X-XSRF-TOKEN', await ForgeAPI.getXSRFToken());
  549. xhr.responseType = 'json';
  550. xhr.onreadystatechange = () => {
  551. if (xhr.readyState !== 4) return;
  552. resolve(xhr.response);
  553. };
  554. xhr.onerror = (err) => {
  555. resolve({ code: 500, error: err.message });
  556. };
  557. let formData = {
  558. path: path,
  559. options: {
  560. recursive: true,
  561. },
  562. };
  563. formData = JSON.stringify(formData);
  564. xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
  565. xhr.send(formData);
  566. });
  567. }
  568. /**
  569. * Retrieves filters based on the type of search.
  570. * @param {SEARCH_TYPE} searchType
  571. */
  572. export function getFilters(searchType, filters) {
  573. // Select filters based on type of search
  574. filters = filters ? filters : TVA_CONFIG.searchFilters;
  575. if (filters[searchType]) {
  576. filters = filters[searchType];
  577. } else {
  578. filters = {
  579. include: '',
  580. exclude: '',
  581. regex: '',
  582. };
  583. }
  584. if (filters.regex) filters.regex = new RegExp(filters.regex);
  585. return filters;
  586. }
  587. export function userRequiresImageCache(perm) {
  588. const permissions = perm ? perm : TVA_CONFIG.permissions;
  589. const role = game.user.role;
  590. return (
  591. permissions.popups[role] ||
  592. permissions.portrait_right_click[role] ||
  593. permissions.image_path_button[role] ||
  594. permissions.hudFullAccess[role]
  595. );
  596. }
  597. export async function waitForTokenTexture(token, callback, checks = 40) {
  598. // v10/v9 compatibility
  599. if (!token.mesh || !token.mesh.texture) {
  600. checks--;
  601. if (checks > 1)
  602. new Promise((resolve) => setTimeout(resolve, 1)).then(() => waitForTokenTexture(token, callback, checks));
  603. return;
  604. }
  605. callback(token);
  606. }
  607. export function flattenSearchResults(results) {
  608. let flattened = [];
  609. if (!results) return flattened;
  610. results.forEach((images) => {
  611. flattened = flattened.concat(images);
  612. });
  613. return flattened;
  614. }
  615. // Slightly modified version of mergeObject; added an option to ignore -= keys
  616. export function modMergeObject(
  617. original,
  618. other = {},
  619. {
  620. insertKeys = true,
  621. insertValues = true,
  622. overwrite = true,
  623. recursive = true,
  624. inplace = true,
  625. enforceTypes = false,
  626. } = {},
  627. _d = 0
  628. ) {
  629. other = other || {};
  630. if (!(original instanceof Object) || !(other instanceof Object)) {
  631. throw new Error('One of original or other are not Objects!');
  632. }
  633. const options = {
  634. insertKeys,
  635. insertValues,
  636. overwrite,
  637. recursive,
  638. inplace,
  639. enforceTypes,
  640. };
  641. // Special handling at depth 0
  642. if (_d === 0) {
  643. if (!inplace) original = deepClone(original);
  644. if (Object.keys(original).some((k) => /\./.test(k))) original = expandObject(original);
  645. if (Object.keys(other).some((k) => /\./.test(k))) other = expandObject(other);
  646. }
  647. // Iterate over the other object
  648. for (let k of Object.keys(other)) {
  649. const v = other[k];
  650. if (original.hasOwnProperty('-=' + k)) {
  651. original[k] = original['-=' + k];
  652. delete original['-=' + k];
  653. }
  654. if (original.hasOwnProperty(k)) _modMergeUpdate(original, k, v, options, _d + 1);
  655. else _modMergeInsert(original, k, v, options, _d + 1);
  656. }
  657. return original;
  658. }
  659. /**
  660. * A helper function for merging objects when the target key does not exist in the original
  661. * @private
  662. */
  663. function _modMergeInsert(original, k, v, { insertKeys, insertValues } = {}, _d) {
  664. // Recursively create simple objects
  665. if (v?.constructor === Object) {
  666. original[k] = modMergeObject({}, v, {
  667. insertKeys: true,
  668. inplace: true,
  669. });
  670. return;
  671. }
  672. // Delete a key
  673. // if (k.startsWith('-=')) {
  674. // delete original[k.slice(2)];
  675. // return;
  676. // }
  677. // Insert a key
  678. const canInsert = (_d <= 1 && insertKeys) || (_d > 1 && insertValues);
  679. if (canInsert) original[k] = v;
  680. }
  681. /**
  682. * A helper function for merging objects when the target key exists in the original
  683. * @private
  684. */
  685. function _modMergeUpdate(original, k, v, { insertKeys, insertValues, enforceTypes, overwrite, recursive } = {}, _d) {
  686. const x = original[k];
  687. const tv = getType(v);
  688. const tx = getType(x);
  689. // Recursively merge an inner object
  690. if (tv === 'Object' && tx === 'Object' && recursive) {
  691. return modMergeObject(
  692. x,
  693. v,
  694. {
  695. insertKeys: insertKeys,
  696. insertValues: insertValues,
  697. overwrite: overwrite,
  698. inplace: true,
  699. enforceTypes: enforceTypes,
  700. },
  701. _d
  702. );
  703. }
  704. // Overwrite an existing value
  705. if (overwrite) {
  706. if (tx !== 'undefined' && tv !== tx && enforceTypes) {
  707. throw new Error(`Mismatched data types encountered during object merge.`);
  708. }
  709. original[k] = v;
  710. }
  711. }
  712. export async function tv_executeScript(script, { actor, token, tvaUpdate } = {}) {
  713. // Add variables to the evaluation scope
  714. const speaker = ChatMessage.getSpeaker();
  715. const character = game.user.character;
  716. token = token?.object || token || (canvas.ready ? canvas.tokens.get(speaker.token) : null);
  717. actor = actor || token?.actor || game.actors.get(speaker.actor);
  718. // Attempt script execution
  719. const AsyncFunction = async function () {}.constructor;
  720. try {
  721. const fn = AsyncFunction('speaker', 'actor', 'token', 'character', 'tvaUpdate', `${script}`);
  722. await fn.call(null, speaker, actor, token, character, tvaUpdate);
  723. } catch (err) {
  724. ui.notifications.error(`There was an error in your script syntax. See the console (F12) for details`);
  725. console.error(err);
  726. }
  727. }
  728. export async function executeMacro(macroName, token) {
  729. token = token?.object || token;
  730. game.macros.find((m) => m.name === macroName)?.execute({ token });
  731. }
  732. export async function applyTMFXPreset(token, presetName, action = 'apply') {
  733. token = token.object ?? token;
  734. if (game.modules.get('tokenmagic')?.active && token.document) {
  735. const preset = TokenMagic.getPreset(presetName);
  736. if (preset) {
  737. if (action === 'apply') {
  738. await TokenMagic.addUpdateFilters(token, preset);
  739. } else if (action === 'remove') {
  740. await TokenMagic.deleteFilters(token, presetName);
  741. }
  742. }
  743. }
  744. }
  745. export async function toggleTMFXPreset(token, presetName) {
  746. token = token.object ?? token;
  747. if (game.modules.get('tokenmagic')?.active && token.document) {
  748. if (TokenMagic.hasFilterId(token, presetName)) {
  749. applyTMFXPreset(token, presetName, 'remove');
  750. } else {
  751. applyTMFXPreset(token, presetName, 'apply');
  752. }
  753. }
  754. }
  755. export async function applyCEEffect(tokenDoc, ceEffect, action = 'apply') {
  756. if (game.modules.get('dfreds-convenient-effects')?.active) {
  757. if (!ceEffect.apply && !ceEffect.remove) return;
  758. else if (!ceEffect.apply || !ceEffect.remove) {
  759. if (action === 'apply') {
  760. if (ceEffect.remove) action = 'remove';
  761. } else return;
  762. }
  763. let uuid = tokenDoc.actor?.uuid;
  764. if (uuid) {
  765. if (action === 'apply') {
  766. await game.dfreds.effectInterface.addEffect({
  767. effectName: ceEffect.name,
  768. uuid,
  769. origin: 'token-variants',
  770. overlay: false,
  771. });
  772. } else {
  773. await game.dfreds.effectInterface.removeEffect({ effectName: ceEffect.name, uuid });
  774. }
  775. }
  776. }
  777. }
  778. export async function toggleCEEffect(token, effectName) {
  779. if (game.modules.get('dfreds-convenient-effects')?.active) {
  780. let uuid = (token.document ?? token).actor?.uuid;
  781. await game.dfreds.effectInterface.toggleEffect(effectName, {
  782. uuids: [uuid],
  783. overlay: false,
  784. });
  785. }
  786. }
  787. export class TokenDataAdapter {
  788. static dataToForm(data) {
  789. if ('texture.scaleX' in data) {
  790. data.scale = Math.abs(data['texture.scaleX']);
  791. data.mirrorX = data['texture.scaleX'] < 0;
  792. }
  793. if ('texture.scaleY' in data) {
  794. data.scale = Math.abs(data['texture.scaleY']);
  795. data.mirrorY = data['texture.scaleY'] < 0;
  796. }
  797. }
  798. static formToData(token, formData) {
  799. // Scale/mirroring
  800. if ('scale' in formData || 'mirrorX' in formData || 'mirrorY' in formData) {
  801. const doc = token.document ? token.document : token;
  802. if (!('scale' in formData)) formData.scale = Math.abs(doc.texture.scaleX);
  803. if (!('mirrorX' in formData)) formData.mirrorX = doc.texture.scaleX < 0;
  804. if (!('mirrorY' in formData)) formData.mirrorY = doc.texture.scaleY < 0;
  805. setProperty(formData, 'texture.scaleX', formData.scale * (formData.mirrorX ? -1 : 1));
  806. setProperty(formData, 'texture.scaleY', formData.scale * (formData.mirrorY ? -1 : 1));
  807. ['scale', 'mirrorX', 'mirrorY'].forEach((k) => delete formData[k]);
  808. }
  809. }
  810. }
  811. export function determineAddedRemovedEffects(addedEffects, removedEffects, newEffects, oldEffects) {
  812. for (const ef of newEffects) {
  813. if (!oldEffects.includes(ef)) {
  814. addedEffects.push(ef);
  815. }
  816. }
  817. for (const ef of oldEffects) {
  818. if (!newEffects.includes(ef)) {
  819. removedEffects.push(ef);
  820. }
  821. }
  822. }
  823. export async function wildcardImageSearch(imgSrc) {
  824. let source = 'data';
  825. const browseOptions = { wildcard: true };
  826. // Support non-user sources
  827. if (/\.s3\./.test(imgSrc)) {
  828. source = 's3';
  829. const { bucket, keyPrefix } = FilePicker.parseS3URL(imgSrc);
  830. if (bucket) {
  831. browseOptions.bucket = bucket;
  832. imgSrc = keyPrefix;
  833. }
  834. } else if (imgSrc.startsWith('icons/')) source = 'public';
  835. // Retrieve wildcard content
  836. try {
  837. const content = await FilePicker.browse(source, imgSrc, browseOptions);
  838. return content.files;
  839. } catch (err) {}
  840. return [];
  841. }
  842. /**
  843. * Returns a random name generated using Name Forge module
  844. * @param {*} randomizerSettings
  845. * @returns
  846. */
  847. export async function nameForgeRandomize(randomizerSettings) {
  848. const nameForgeSettings = randomizerSettings.nameForge;
  849. if (nameForgeSettings?.randomize && nameForgeSettings?.models) {
  850. const nameForge = game.modules.get('nameforge');
  851. if (nameForge?.active) {
  852. const randomNames = [];
  853. for (const modelKey of nameForgeSettings.models) {
  854. const modelProp = getProperty(nameForge.models, modelKey);
  855. if (modelProp) {
  856. const model = await nameForge.api.createModel(modelProp);
  857. if (model) {
  858. randomNames.push(nameForge.api.generateName(model)[0]);
  859. }
  860. }
  861. }
  862. return randomNames[Math.floor(Math.random() * randomNames.length)];
  863. }
  864. }
  865. return null;
  866. }
  867. /**
  868. * Upload Token and associated overlays as a single image
  869. */
  870. export async function uploadTokenImage(token, options) {
  871. let renderTexture = captureToken(token, options);
  872. if (renderTexture) {
  873. const b64 = canvas.app.renderer.extract.base64(renderTexture, 'image/webp', 1);
  874. let res = await fetch(b64);
  875. let blob = await res.blob();
  876. const filename = options.name + `.webp`;
  877. let file = new File([blob], filename, { type: 'image/webp' });
  878. await FilePicker.upload('data', options.path, file, {});
  879. }
  880. }
  881. /**
  882. * Modified version of 'dev7355608' captureCanvas function. Captures combined Token and Overlay image
  883. */
  884. function captureToken(token, { scale = 3, width = null, height = null } = {}) {
  885. if (!canvas.ready || !token) {
  886. return;
  887. }
  888. width = width ?? token.texture.width;
  889. height = height ?? token.texture.height;
  890. scale = scale * Math.min(width / token.texture.width, height / token.texture.height);
  891. const renderer = canvas.app.renderer;
  892. const viewPosition = { ...canvas.scene._viewPosition };
  893. renderer.resize(width ?? renderer.screen.width, height ?? renderer.screen.height);
  894. width = canvas.screenDimensions[0] = renderer.screen.width;
  895. height = canvas.screenDimensions[1] = renderer.screen.height;
  896. canvas.stage.position.set(width / 2, height / 2);
  897. canvas.pan({
  898. x: token.center.x,
  899. y: token.center.y,
  900. scale,
  901. });
  902. const renderTexture = PIXI.RenderTexture.create({
  903. width,
  904. height,
  905. resolution: token.texture.resolution,
  906. });
  907. const cacheParent = canvas.stage.enableTempParent();
  908. canvas.stage.updateTransform();
  909. canvas.stage.disableTempParent(cacheParent);
  910. let spritesToRender = [token.mesh];
  911. if (token.tvaOverlays) spritesToRender = spritesToRender.concat(token.tvaOverlays);
  912. spritesToRender.sort((sprite) => sprite.sort);
  913. for (const sprite of spritesToRender) {
  914. renderer.render(sprite, { renderTexture, skipUpdateTransform: true, clear: false });
  915. }
  916. canvas._onResize();
  917. canvas.pan(viewPosition);
  918. return renderTexture;
  919. }
  920. export function getAllActorTokens(actor, linked = false, document = false) {
  921. if (actor.isToken) {
  922. if (document) return [actor.token];
  923. else if (actor.token.object) return [actor.token.object];
  924. else return [];
  925. }
  926. const tokens = [];
  927. game.scenes.forEach((scene) =>
  928. scene.tokens.forEach((token) => {
  929. if (token.actorId === actor.id) {
  930. if (linked && token.actorLink) tokens.push(token);
  931. else if (!linked) tokens.push(token);
  932. }
  933. })
  934. );
  935. if (document) return tokens;
  936. else return tokens.map((token) => token.object).filter((token) => token);
  937. }
  938. export function extractDimensionsFromImgName(img, dimensions = {}) {
  939. const name = getFileName(img);
  940. let scale;
  941. if (TVA_CONFIG.imgNameContainsDimensions) {
  942. const height = name.match(/_height(.*)_/)?.[1];
  943. if (height) dimensions.height = parseFloat(height);
  944. const width = name.match(/_width(.*)_/)?.[1];
  945. if (width) dimensions.width = parseFloat(width);
  946. scale = name.match(/_scale(.*)_/)?.[1];
  947. if (scale) scale = Math.max(parseFloat(scale), 0.2);
  948. }
  949. if (TVA_CONFIG.imgNameContainsFADimensions) {
  950. scale = name.match(/_Scale(\d+)_/)?.[1];
  951. if (scale) {
  952. scale = Math.max(parseInt(scale) / 100, 0.2);
  953. }
  954. }
  955. if (scale) {
  956. dimensions['texture.scaleX'] = scale;
  957. dimensions['texture.scaleY'] = scale;
  958. }
  959. return dimensions;
  960. }
  961. export function string2Hex(hexString) {
  962. return PIXI.utils.string2hex(hexString);
  963. }
  964. export function decodeURISafely(uri) {
  965. try {
  966. return decodeURI(uri);
  967. } catch (e) {
  968. console.warn('URI Component not decodable: ' + uri);
  969. return uri;
  970. }
  971. }
  972. export function decodeURIComponentSafely(uri) {
  973. try {
  974. return decodeURIComponent(uri);
  975. } catch (e) {
  976. console.warn('URI Component not decodable: ' + uri);
  977. return uri;
  978. }
  979. }
  980. export function mergeMappings(from, to) {
  981. const changedIDs = {};
  982. for (const m of from) {
  983. const i = to.findIndex((mapping) => mapping.label === m.label && mapping.group === m.group);
  984. if (i === -1) to.push(m);
  985. else {
  986. changedIDs[to.id] = m.id;
  987. if (to[i].tokens?.length) {
  988. if (!m.tokens) m.tokens = [];
  989. to[i].tokens.forEach((id) => {
  990. if (!m.tokens.includes(id)) m.tokens.push(id);
  991. });
  992. }
  993. to[i] = m;
  994. }
  995. }
  996. // If parent's id has been changed we need to update all the children
  997. to.forEach((m) => {
  998. let pID = m.overlayConfig?.parentID;
  999. if (pID && pID in changedIDs) {
  1000. m.overlayConfig.parentID = changedIDs[pID];
  1001. }
  1002. });
  1003. return to;
  1004. }
  1005. export function isResponsibleGM() {
  1006. const isResponsibleGM = !game.users
  1007. .filter((user) => user.isGM && (user.active || user.isActive))
  1008. .some((other) => other.id < game.user.id);
  1009. return isResponsibleGM;
  1010. }