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.

635 lines
20 KiB

  1. import { isInitialized } from '../token-variants.mjs';
  2. import { Fuse } from './fuse/fuse.js';
  3. import { getSearchOptions, TVA_CONFIG } from './settings.js';
  4. import {
  5. callForgeVTT,
  6. decodeURIComponentSafely,
  7. decodeURISafely,
  8. flattenSearchResults,
  9. getFileName,
  10. getFileNameWithExt,
  11. getFilePath,
  12. getFilters,
  13. isImage,
  14. isVideo,
  15. parseKeywords,
  16. SEARCH_TYPE,
  17. simplifyName,
  18. simplifyPath,
  19. } from './utils.js';
  20. // True if in the middle of caching image paths
  21. let caching = false;
  22. export function isCaching() {
  23. return caching;
  24. }
  25. // Cached images
  26. let CACHED_IMAGES = {};
  27. /**
  28. * @param {string} search Text to be used as the search criteria
  29. * @param {object} [options={}] Options which customize the search
  30. * @param {SEARCH_TYPE|string} [options.searchType] Controls filters applied to the search results
  31. * @param {Boolean} [options.simpleResults] Results will be returned as an array of all image paths found
  32. * @param {Function[]} [options.callback] Function to be called with the found images
  33. * @param {object} [options.searchOptions] Override search settings
  34. * @returns {Promise<Map<string, Array<object>|Array<string>>} All images found split by original criteria and keywords
  35. */
  36. export async function doImageSearch(
  37. search,
  38. { searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN, simpleResults = false, callback = null, searchOptions = {} } = {}
  39. ) {
  40. if (caching) return;
  41. searchOptions = mergeObject(searchOptions, getSearchOptions(), { overwrite: false });
  42. search = search.trim();
  43. if (TVA_CONFIG.debug) console.info('TVA | STARTING: Art Search', search, searchType, searchOptions);
  44. let searches = [search];
  45. let allImages = new Map();
  46. const keywords = parseKeywords(searchOptions.excludedKeywords);
  47. if (searchOptions.keywordSearch) {
  48. searches = searches.concat(
  49. search
  50. .split(/[_\- :,\|\(\)\[\]]/)
  51. .filter((word) => word.length > 2 && !keywords.includes(word.toLowerCase()))
  52. .reverse()
  53. );
  54. }
  55. let usedImages = new Set();
  56. for (const search of searches) {
  57. if (allImages.get(search) !== undefined) continue;
  58. let results = await findImages(search, searchType, searchOptions);
  59. results = results.filter((pathObj) => !usedImages.has(pathObj));
  60. allImages.set(search, results);
  61. results.forEach(usedImages.add, usedImages);
  62. }
  63. if (TVA_CONFIG.debug) console.info('TVA | ENDING: Art Search');
  64. if (simpleResults) {
  65. allImages = Array.from(usedImages).map((obj) => obj.path);
  66. }
  67. if (callback) callback(allImages);
  68. return allImages;
  69. }
  70. /**
  71. * @param {*} search Text to be used as the search criteria
  72. * @param {object} [options={}] Options which customize the search
  73. * @param {SEARCH_TYPE|string} [options.searchType] Controls filters applied to the search results
  74. * @param {Actor} [options.actor] Used to retrieve 'shared' images from if enabled in the Randomizer Settings
  75. * @param {Function[]} [options.callback] Function to be called with the random image
  76. * @param {object} [options.searchOptions] Override search settings
  77. * @param {object} [options.randomizerOptions] Override randomizer settings. These take precedence over searchOptions
  78. * @returns Array<string>|null} Image path and name
  79. */
  80. export async function doRandomSearch(
  81. search,
  82. {
  83. searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN,
  84. actor = null,
  85. callback = null,
  86. randomizerOptions = {},
  87. searchOptions = {},
  88. } = {}
  89. ) {
  90. if (caching) return null;
  91. let results = flattenSearchResults(
  92. await _randSearchUtil(search, {
  93. searchType: searchType,
  94. actor: actor,
  95. randomizerOptions: randomizerOptions,
  96. searchOptions: searchOptions,
  97. })
  98. );
  99. if (results.length === 0) return null;
  100. let result;
  101. // If `nonRepeat` option is enabled keep attempting random selection until a unique token image is found
  102. // in case of no such image, just pick a random one
  103. if (results.length !== 1 && randomizerOptions.nonRepeat && searchType === SEARCH_TYPE.TOKEN) {
  104. const tokens = canvas.tokens?.placeables || [];
  105. const placedImages = new Set(tokens.map((t) => t.document.texture.src));
  106. let checkedImages = [];
  107. let tmpResult = results[Math.floor(Math.random() * results.length)];
  108. while (results.length && !result) {
  109. if (placedImages.has(tmpResult.path)) {
  110. checkedImages.push(tmpResult);
  111. results.splice(results.indexOf(tmpResult), 1);
  112. tmpResult = results[Math.floor(Math.random() * results.length)];
  113. } else {
  114. result = tmpResult;
  115. }
  116. }
  117. if (!result) results = checkedImages;
  118. }
  119. if (!result) {
  120. result = results[Math.floor(Math.random() * results.length)];
  121. }
  122. // Pick random image
  123. if (callback) callback([result.path, result.name]);
  124. return [result.path, result.name];
  125. }
  126. export async function doSyncSearch(target, { searchType = SEARCH_TYPE.TOKEN } = {}) {
  127. if (caching) return null;
  128. const fResults = await findImages(target, searchType, { algorithm: { fuzzy: true } });
  129. if (fResults && fResults.length !== 0) {
  130. return [fResults[0].path, fResults[0].name];
  131. } else {
  132. return null;
  133. }
  134. }
  135. async function _randSearchUtil(
  136. search,
  137. { searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN, actor = null, randomizerOptions = {}, searchOptions = {} } = {}
  138. ) {
  139. const randSettings = mergeObject(randomizerOptions, TVA_CONFIG.randomizer, { overwrite: false });
  140. if (
  141. !(
  142. randSettings.tokenName ||
  143. randSettings.actorName ||
  144. randSettings.keywords ||
  145. randSettings.shared ||
  146. randSettings.wildcard
  147. )
  148. )
  149. return null;
  150. // Randomizer settings take precedence
  151. searchOptions.keywordSearch = randSettings.keywords;
  152. // Swap search to the actor name if need be
  153. if (randSettings.actorName && actor) {
  154. search = actor.name;
  155. }
  156. // Gather all images
  157. let results =
  158. randSettings.actorName || randSettings.tokenName || randSettings.keywords
  159. ? await doImageSearch(search, {
  160. searchType: searchType,
  161. searchOptions: searchOptions,
  162. })
  163. : new Map();
  164. if (!randSettings.tokenName && !randSettings.actorName) {
  165. results.delete(search);
  166. }
  167. if (randSettings.shared && actor) {
  168. let sharedVariants = actor.getFlag('token-variants', 'variants') || [];
  169. if (sharedVariants.length != 0) {
  170. const sv = [];
  171. sharedVariants.forEach((variant) => {
  172. variant.names.forEach((name) => {
  173. sv.push({ path: variant.imgSrc, name: name });
  174. });
  175. });
  176. results.set('variants95436723', sv);
  177. }
  178. }
  179. if (randSettings.wildcard && actor) {
  180. let protoImg = actor.prototypeToken.texture.src;
  181. if (protoImg.includes('*') || (protoImg.includes('{') && protoImg.includes('}'))) {
  182. // Modified version of Actor.getTokenImages()
  183. const getTokenImages = async (actor) => {
  184. if (actor._tokenImages) return actor._tokenImages;
  185. let source = 'data';
  186. const browseOptions = { wildcard: true };
  187. // Support non-user sources
  188. if (/\.s3\./.test(protoImg)) {
  189. source = 's3';
  190. const { bucket, keyPrefix } = FilePicker.parseS3URL(protoImg);
  191. if (bucket) {
  192. browseOptions.bucket = bucket;
  193. protoImg = keyPrefix;
  194. }
  195. } else if (protoImg.startsWith('icons/')) source = 'public';
  196. // Retrieve wildcard content
  197. try {
  198. const content = await FilePicker.browse(source, protoImg, browseOptions);
  199. return content.files;
  200. } catch (err) {
  201. return [];
  202. }
  203. };
  204. const wildcardImages = (await getTokenImages(actor))
  205. .filter((img) => !img.includes('*') && (isImage(img) || isVideo(img)))
  206. .map((variant) => {
  207. return { path: variant, name: getFileName(variant) };
  208. });
  209. results.set('variants95436623', wildcardImages);
  210. }
  211. }
  212. return results;
  213. }
  214. /**
  215. * Recursive image search through a directory
  216. * @param {*} path starting path
  217. * @param {*} options.apiKey ForgeVTT AssetLibrary API key
  218. * @param {*} found_images all the images found
  219. * @returns void
  220. */
  221. async function walkFindImages(path, { apiKey = '' } = {}, found_images) {
  222. let files = {};
  223. if (!path.source) {
  224. path.source = 'data';
  225. }
  226. const typeKey = path.types.sort().join(',');
  227. try {
  228. if (path.source.startsWith('s3:')) {
  229. files = await FilePicker.browse('s3', path.text, {
  230. bucket: path.source.replace('s3:', ''),
  231. });
  232. } else if (path.source.startsWith('forgevtt')) {
  233. if (apiKey) {
  234. const response = await callForgeVTT(path.text, apiKey);
  235. files.files = response.files.map((f) => f.url);
  236. } else {
  237. files = await FilePicker.browse('forgevtt', path.text, { recursive: true });
  238. }
  239. } else if (path.source.startsWith('forge-bazaar')) {
  240. files = await FilePicker.browse('forge-bazaar', path.text, { recursive: true });
  241. } else if (path.source.startsWith('imgur')) {
  242. await fetch('https://api.imgur.com/3/gallery/album/' + path.text, {
  243. headers: {
  244. Authorization: 'Client-ID ' + (TVA_CONFIG.imgurClientId ? TVA_CONFIG.imgurClientId : 'df9d991443bb222'),
  245. Accept: 'application/json',
  246. },
  247. })
  248. .then((response) => response.json())
  249. .then(async function (result) {
  250. if (!result.success) {
  251. return;
  252. }
  253. result.data.images.forEach((img) => {
  254. const rtName = img.title ?? img.description ?? getFileName(img.link);
  255. _addToFound({ path: decodeURISafely(img.link), name: rtName }, typeKey, found_images);
  256. });
  257. })
  258. .catch((error) => console.warn('TVA |', error));
  259. return;
  260. } else if (path.source.startsWith('rolltable')) {
  261. const table = game.tables.contents.find((t) => t.name === path.text);
  262. if (!table) {
  263. const rollTableName = path.text;
  264. ui.notifications.warn(
  265. game.i18n.format('token-variants.notifications.warn.invalid-table', {
  266. rollTableName,
  267. })
  268. );
  269. } else {
  270. for (let baseTableData of table.results) {
  271. const rtPath = baseTableData.img;
  272. const rtName = baseTableData.text || getFileName(rtPath);
  273. _addToFound({ path: decodeURISafely(rtPath), name: rtName }, typeKey, found_images);
  274. }
  275. }
  276. return;
  277. } else if (path.source.startsWith('json')) {
  278. await fetch(path.text, {
  279. headers: {
  280. Accept: 'application/json',
  281. },
  282. })
  283. .then((response) => response.json())
  284. .then(async function (result) {
  285. if (!result.length > 0) {
  286. return;
  287. }
  288. result.forEach((img) => {
  289. const rtName = img.name ?? getFileName(img.path);
  290. _addToFound({ path: decodeURISafely(img.path), name: rtName, tags: img.tags }, typeKey, found_images);
  291. });
  292. })
  293. .catch((error) => console.warn('TVA |', error));
  294. return;
  295. } else {
  296. files = await FilePicker.browse(path.source, path.text);
  297. }
  298. } catch (err) {
  299. console.warn(
  300. `TVA | ${game.i18n.localize('token-variants.notifications.warn.path-not-found')} ${path.source}:${path.text}`
  301. );
  302. return;
  303. }
  304. if (files.target == '.') return;
  305. if (files.files) {
  306. files.files.forEach((tokenSrc) => {
  307. _addToFound({ path: decodeURISafely(tokenSrc), name: getFileName(tokenSrc) }, typeKey, found_images);
  308. });
  309. }
  310. // ForgeVTT requires special treatment
  311. // Bazaar paths fail recursive search if one level above root
  312. if (path.source.startsWith('forgevtt')) return;
  313. else if (
  314. path.source.startsWith('forge-bazaar') &&
  315. !['modules', 'systems', 'worlds', 'assets'].includes(path.text.replaceAll(/[\/\\]/g, ''))
  316. ) {
  317. return;
  318. }
  319. for (let f_dir of files.dirs) {
  320. await walkFindImages({ text: f_dir, source: path.source, types: path.types }, { apiKey: apiKey }, found_images);
  321. }
  322. }
  323. function _addToFound(img, typeKey, found_images) {
  324. if (isImage(img.path) || isVideo(img.path)) {
  325. if (found_images[typeKey] == null) {
  326. found_images[typeKey] = [img];
  327. } else {
  328. found_images[typeKey].push(img);
  329. }
  330. }
  331. }
  332. /**
  333. * Recursive walks through all paths exposed to the module and caches them
  334. * @param {*} searchType
  335. * @returns
  336. */
  337. async function walkAllPaths(searchType) {
  338. const found_images = {};
  339. const paths = _filterPathsByType(TVA_CONFIG.searchPaths, searchType);
  340. for (const path of paths) {
  341. if ((path.cache && caching) || (!path.cache && !caching)) await walkFindImages(path, {}, found_images);
  342. }
  343. // ForgeVTT specific path handling
  344. const userId = typeof ForgeAPI !== 'undefined' ? await ForgeAPI.getUserId() : '';
  345. for (const uid in TVA_CONFIG.forgeSearchPaths) {
  346. const apiKey = TVA_CONFIG.forgeSearchPaths[uid].apiKey;
  347. const paths = _filterPathsByType(TVA_CONFIG.forgeSearchPaths[uid].paths, searchType);
  348. if (uid === userId) {
  349. for (const path of paths) {
  350. if ((path.cache && caching) || (!path.cache && !caching)) await walkFindImages(path, {}, found_images);
  351. }
  352. } else if (apiKey) {
  353. for (const path of paths) {
  354. if ((path.cache && caching) || (!path.cache && !caching)) {
  355. if (path.share) await walkFindImages(path, { apiKey: apiKey }, found_images);
  356. }
  357. }
  358. }
  359. }
  360. return found_images;
  361. }
  362. function _filterPathsByType(paths, searchType) {
  363. if (!searchType) return paths;
  364. return paths.filter((p) => p.types.includes(searchType));
  365. }
  366. export async function findImagesFuzzy(name, searchType, searchOptions, forceSearchName = false) {
  367. if (TVA_CONFIG.debug)
  368. console.info('TVA | STARTING: Fuzzy Image Search', name, searchType, searchOptions, forceSearchName);
  369. const filters = getFilters(searchType, searchOptions.searchFilters);
  370. const fuse = new Fuse([], {
  371. keys: [!forceSearchName && searchOptions.runSearchOnPath ? 'path' : 'name', 'tags'],
  372. includeScore: true,
  373. includeMatches: true,
  374. minMatchCharLength: 1,
  375. ignoreLocation: true,
  376. threshold: searchOptions.algorithm.fuzzyThreshold,
  377. });
  378. const found_images = await walkAllPaths(searchType);
  379. for (const container of [CACHED_IMAGES, found_images]) {
  380. for (const typeKey in container) {
  381. const types = typeKey.split(',');
  382. if (types.includes(searchType)) {
  383. for (const imgObj of container[typeKey]) {
  384. if (_imagePassesFilter(imgObj.name, imgObj.path, filters, searchOptions.runSearchOnPath)) {
  385. fuse.add(imgObj);
  386. }
  387. }
  388. }
  389. }
  390. }
  391. let results;
  392. if (name === '') {
  393. results = fuse.getIndex().docs.slice(0, searchOptions.algorithm.fuzzyLimit);
  394. } else {
  395. results = fuse.search(name).slice(0, searchOptions.algorithm.fuzzyLimit);
  396. results = results.map((r) => {
  397. r.item.indices = r.matches[0].indices;
  398. r.item.score = r.score;
  399. return r.item;
  400. });
  401. }
  402. if (TVA_CONFIG.debug) console.info('TVA | ENDING: Fuzzy Image Search', results);
  403. return results;
  404. }
  405. async function findImagesExact(name, searchType, searchOptions) {
  406. if (TVA_CONFIG.debug) console.info('TVA | STARTING: Exact Image Search', name, searchType, searchOptions);
  407. const found_images = await walkAllPaths(searchType);
  408. const simpleName = simplifyName(name);
  409. const filters = getFilters(searchType, searchOptions.searchFilters);
  410. const matchedImages = [];
  411. for (const container of [CACHED_IMAGES, found_images]) {
  412. for (const typeKey in container) {
  413. const types = typeKey.split(',');
  414. if (types.includes(searchType)) {
  415. for (const imgOBj of container[typeKey]) {
  416. if (_exactSearchMatchesImage(simpleName, imgOBj.path, imgOBj.name, filters, searchOptions.runSearchOnPath)) {
  417. matchedImages.push(imgOBj);
  418. }
  419. }
  420. }
  421. }
  422. }
  423. if (TVA_CONFIG.debug) console.info('TVA | ENDING: Exact Image Search', matchedImages);
  424. return matchedImages;
  425. }
  426. async function findImages(name, searchType = '', searchOptions = {}) {
  427. const sOptions = mergeObject(searchOptions, getSearchOptions(), { overwrite: false });
  428. if (sOptions.algorithm.exact) {
  429. return await findImagesExact(name, searchType, sOptions);
  430. } else {
  431. return await findImagesFuzzy(name, searchType, sOptions);
  432. }
  433. }
  434. /**
  435. * Checks if image path and name match the provided search text and filters
  436. * @param imagePath image path
  437. * @param imageName image name
  438. * @param filters filters to be applied
  439. * @returns true|false
  440. */
  441. function _exactSearchMatchesImage(simplifiedSearch, imagePath, imageName, filters, runSearchOnPath) {
  442. // Is the search text contained in the name/path
  443. const simplified = runSearchOnPath ? simplifyPath(imagePath) : simplifyName(imageName);
  444. if (!simplified.includes(simplifiedSearch)) {
  445. return false;
  446. }
  447. if (!filters) return true;
  448. return _imagePassesFilter(imageName, imagePath, filters, runSearchOnPath);
  449. }
  450. function _imagePassesFilter(imageName, imagePath, filters, runSearchOnPath) {
  451. // Filters are applied to path depending on the 'runSearchOnPath' setting, and actual or custom rolltable name
  452. let text;
  453. if (runSearchOnPath) {
  454. text = decodeURIComponentSafely(imagePath);
  455. } else if (getFileName(imagePath) === imageName) {
  456. text = getFileNameWithExt(imagePath);
  457. } else {
  458. text = imageName;
  459. }
  460. if (filters.regex) {
  461. return filters.regex.test(text);
  462. }
  463. if (filters.include) {
  464. if (!text.includes(filters.include)) return false;
  465. }
  466. if (filters.exclude) {
  467. if (text.includes(filters.exclude)) return false;
  468. }
  469. return true;
  470. }
  471. // ===================================
  472. // ==== CACHING RELATED FUNCTIONS ====
  473. // ===================================
  474. export async function saveCache(cacheFile) {
  475. const data = {};
  476. const caches = Object.keys(CACHED_IMAGES);
  477. for (const c of caches) {
  478. if (!(c in data)) data[c] = [];
  479. for (const img of CACHED_IMAGES[c]) {
  480. if (img.tags) {
  481. data[c].push([img.path, img.name, img.tags]);
  482. } else if (getFileName(img.path) === img.name) {
  483. data[c].push(img.path);
  484. } else {
  485. data[c].push([img.path, img.name]);
  486. }
  487. }
  488. }
  489. let file = new File([JSON.stringify(data)], getFileNameWithExt(cacheFile), {
  490. type: 'text/plain',
  491. });
  492. FilePicker.upload('data', getFilePath(cacheFile), file);
  493. }
  494. /**
  495. * Search for and cache all the found token art
  496. */
  497. export async function cacheImages({
  498. staticCache = TVA_CONFIG.staticCache,
  499. staticCacheFile = TVA_CONFIG.staticCacheFile,
  500. } = {}) {
  501. if (caching) return;
  502. caching = true;
  503. if (!isInitialized() && staticCache) {
  504. if (await _readCacheFromFile(staticCacheFile)) {
  505. caching = false;
  506. return;
  507. }
  508. }
  509. if (!TVA_CONFIG.disableNotifs)
  510. ui.notifications.info(game.i18n.format('token-variants.notifications.info.caching-started'));
  511. if (TVA_CONFIG.debug) console.info('TVA | STARTING: Token Caching');
  512. const found_images = await walkAllPaths();
  513. CACHED_IMAGES = found_images;
  514. if (TVA_CONFIG.debug) console.info('TVA | ENDING: Token Caching');
  515. caching = false;
  516. if (!TVA_CONFIG.disableNotifs)
  517. ui.notifications.info(
  518. game.i18n.format('token-variants.notifications.info.caching-finished', {
  519. imageCount: Object.keys(CACHED_IMAGES).reduce((count, types) => count + CACHED_IMAGES[types].length, 0),
  520. })
  521. );
  522. if (staticCache && game.user.isGM) {
  523. saveCache(staticCacheFile);
  524. }
  525. }
  526. async function _readCacheFromFile(fileName) {
  527. CACHED_IMAGES = {};
  528. try {
  529. await jQuery.getJSON(fileName, (json) => {
  530. for (let category in json) {
  531. CACHED_IMAGES[category] = [];
  532. for (const img of json[category]) {
  533. if (Array.isArray(img)) {
  534. if (img.length === 3) {
  535. CACHED_IMAGES[category].push({ path: img[0], name: img[1], tags: img[2] });
  536. } else {
  537. CACHED_IMAGES[category].push({ path: img[0], name: img[1] });
  538. }
  539. } else {
  540. CACHED_IMAGES[category].push({ path: img, name: getFileName(img) });
  541. }
  542. }
  543. }
  544. if (!TVA_CONFIG.disableNotifs)
  545. ui.notifications.info(
  546. `Token Variant Art: Using Static Cache (${Object.keys(CACHED_IMAGES).reduce(
  547. (count, c) => count + CACHED_IMAGES[c].length,
  548. 0
  549. )} images)`
  550. );
  551. });
  552. } catch (error) {
  553. ui.notifications.warn(`Token Variant Art: Static Cache not found`);
  554. CACHED_IMAGES = {};
  555. return false;
  556. }
  557. return true;
  558. }