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.

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