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

import { isInitialized } from '../token-variants.mjs';
import { Fuse } from './fuse/fuse.js';
import { getSearchOptions, TVA_CONFIG } from './settings.js';
import {
callForgeVTT,
decodeURIComponentSafely,
decodeURISafely,
flattenSearchResults,
getFileName,
getFileNameWithExt,
getFilePath,
getFilters,
isImage,
isVideo,
parseKeywords,
SEARCH_TYPE,
simplifyName,
simplifyPath,
} from './utils.js';
// True if in the middle of caching image paths
let caching = false;
export function isCaching() {
return caching;
}
// Cached images
let CACHED_IMAGES = {};
/**
* @param {string} search Text to be used as the search criteria
* @param {object} [options={}] Options which customize the search
* @param {SEARCH_TYPE|string} [options.searchType] Controls filters applied to the search results
* @param {Boolean} [options.simpleResults] Results will be returned as an array of all image paths found
* @param {Function[]} [options.callback] Function to be called with the found images
* @param {object} [options.searchOptions] Override search settings
* @returns {Promise<Map<string, Array<object>|Array<string>>} All images found split by original criteria and keywords
*/
export async function doImageSearch(
search,
{ searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN, simpleResults = false, callback = null, searchOptions = {} } = {}
) {
if (caching) return;
searchOptions = mergeObject(searchOptions, getSearchOptions(), { overwrite: false });
search = search.trim();
if (TVA_CONFIG.debug) console.info('TVA | STARTING: Art Search', search, searchType, searchOptions);
let searches = [search];
let allImages = new Map();
const keywords = parseKeywords(searchOptions.excludedKeywords);
if (searchOptions.keywordSearch) {
searches = searches.concat(
search
.split(/[_\- :,\|\(\)\[\]]/)
.filter((word) => word.length > 2 && !keywords.includes(word.toLowerCase()))
.reverse()
);
}
let usedImages = new Set();
for (const search of searches) {
if (allImages.get(search) !== undefined) continue;
let results = await findImages(search, searchType, searchOptions);
results = results.filter((pathObj) => !usedImages.has(pathObj));
allImages.set(search, results);
results.forEach(usedImages.add, usedImages);
}
if (TVA_CONFIG.debug) console.info('TVA | ENDING: Art Search');
if (simpleResults) {
allImages = Array.from(usedImages).map((obj) => obj.path);
}
if (callback) callback(allImages);
return allImages;
}
/**
* @param {*} search Text to be used as the search criteria
* @param {object} [options={}] Options which customize the search
* @param {SEARCH_TYPE|string} [options.searchType] Controls filters applied to the search results
* @param {Actor} [options.actor] Used to retrieve 'shared' images from if enabled in the Randomizer Settings
* @param {Function[]} [options.callback] Function to be called with the random image
* @param {object} [options.searchOptions] Override search settings
* @param {object} [options.randomizerOptions] Override randomizer settings. These take precedence over searchOptions
* @returns Array<string>|null} Image path and name
*/
export async function doRandomSearch(
search,
{
searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN,
actor = null,
callback = null,
randomizerOptions = {},
searchOptions = {},
} = {}
) {
if (caching) return null;
let results = flattenSearchResults(
await _randSearchUtil(search, {
searchType: searchType,
actor: actor,
randomizerOptions: randomizerOptions,
searchOptions: searchOptions,
})
);
if (results.length === 0) return null;
let result;
// If `nonRepeat` option is enabled keep attempting random selection until a unique token image is found
// in case of no such image, just pick a random one
if (results.length !== 1 && randomizerOptions.nonRepeat && searchType === SEARCH_TYPE.TOKEN) {
const tokens = canvas.tokens?.placeables || [];
const placedImages = new Set(tokens.map((t) => t.document.texture.src));
let checkedImages = [];
let tmpResult = results[Math.floor(Math.random() * results.length)];
while (results.length && !result) {
if (placedImages.has(tmpResult.path)) {
checkedImages.push(tmpResult);
results.splice(results.indexOf(tmpResult), 1);
tmpResult = results[Math.floor(Math.random() * results.length)];
} else {
result = tmpResult;
}
}
if (!result) results = checkedImages;
}
if (!result) {
result = results[Math.floor(Math.random() * results.length)];
}
// Pick random image
if (callback) callback([result.path, result.name]);
return [result.path, result.name];
}
export async function doSyncSearch(target, { searchType = SEARCH_TYPE.TOKEN } = {}) {
if (caching) return null;
const fResults = await findImages(target, searchType, { algorithm: { fuzzy: true } });
if (fResults && fResults.length !== 0) {
return [fResults[0].path, fResults[0].name];
} else {
return null;
}
}
async function _randSearchUtil(
search,
{ searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN, actor = null, randomizerOptions = {}, searchOptions = {} } = {}
) {
const randSettings = mergeObject(randomizerOptions, TVA_CONFIG.randomizer, { overwrite: false });
if (
!(
randSettings.tokenName ||
randSettings.actorName ||
randSettings.keywords ||
randSettings.shared ||
randSettings.wildcard
)
)
return null;
// Randomizer settings take precedence
searchOptions.keywordSearch = randSettings.keywords;
// Swap search to the actor name if need be
if (randSettings.actorName && actor) {
search = actor.name;
}
// Gather all images
let results =
randSettings.actorName || randSettings.tokenName || randSettings.keywords
? await doImageSearch(search, {
searchType: searchType,
searchOptions: searchOptions,
})
: new Map();
if (!randSettings.tokenName && !randSettings.actorName) {
results.delete(search);
}
if (randSettings.shared && actor) {
let sharedVariants = actor.getFlag('token-variants', 'variants') || [];
if (sharedVariants.length != 0) {
const sv = [];
sharedVariants.forEach((variant) => {
variant.names.forEach((name) => {
sv.push({ path: variant.imgSrc, name: name });
});
});
results.set('variants95436723', sv);
}
}
if (randSettings.wildcard && actor) {
let protoImg = actor.prototypeToken.texture.src;
if (protoImg.includes('*') || (protoImg.includes('{') && protoImg.includes('}'))) {
// Modified version of Actor.getTokenImages()
const getTokenImages = async (actor) => {
if (actor._tokenImages) return actor._tokenImages;
let source = 'data';
const browseOptions = { wildcard: true };
// Support non-user sources
if (/\.s3\./.test(protoImg)) {
source = 's3';
const { bucket, keyPrefix } = FilePicker.parseS3URL(protoImg);
if (bucket) {
browseOptions.bucket = bucket;
protoImg = keyPrefix;
}
} else if (protoImg.startsWith('icons/')) source = 'public';
// Retrieve wildcard content
try {
const content = await FilePicker.browse(source, protoImg, browseOptions);
return content.files;
} catch (err) {
return [];
}
};
const wildcardImages = (await getTokenImages(actor))
.filter((img) => !img.includes('*') && (isImage(img) || isVideo(img)))
.map((variant) => {
return { path: variant, name: getFileName(variant) };
});
results.set('variants95436623', wildcardImages);
}
}
return results;
}
/**
* Recursive image search through a directory
* @param {*} path starting path
* @param {*} options.apiKey ForgeVTT AssetLibrary API key
* @param {*} found_images all the images found
* @returns void
*/
async function walkFindImages(path, { apiKey = '' } = {}, found_images) {
let files = {};
if (!path.source) {
path.source = 'data';
}
const typeKey = path.types.sort().join(',');
try {
if (path.source.startsWith('s3:')) {
files = await FilePicker.browse('s3', path.text, {
bucket: path.source.replace('s3:', ''),
});
} else if (path.source.startsWith('forgevtt')) {
if (apiKey) {
const response = await callForgeVTT(path.text, apiKey);
files.files = response.files.map((f) => f.url);
} else {
files = await FilePicker.browse('forgevtt', path.text, { recursive: true });
}
} else if (path.source.startsWith('forge-bazaar')) {
files = await FilePicker.browse('forge-bazaar', path.text, { recursive: true });
} else if (path.source.startsWith('imgur')) {
await fetch('https://api.imgur.com/3/gallery/album/' + path.text, {
headers: {
Authorization: 'Client-ID ' + (TVA_CONFIG.imgurClientId ? TVA_CONFIG.imgurClientId : 'df9d991443bb222'),
Accept: 'application/json',
},
})
.then((response) => response.json())
.then(async function (result) {
if (!result.success) {
return;
}
result.data.images.forEach((img) => {
const rtName = img.title ?? img.description ?? getFileName(img.link);
_addToFound({ path: decodeURISafely(img.link), name: rtName }, typeKey, found_images);
});
})
.catch((error) => console.warn('TVA |', error));
return;
} else if (path.source.startsWith('rolltable')) {
const table = game.tables.contents.find((t) => t.name === path.text);
if (!table) {
const rollTableName = path.text;
ui.notifications.warn(
game.i18n.format('token-variants.notifications.warn.invalid-table', {
rollTableName,
})
);
} else {
for (let baseTableData of table.results) {
const rtPath = baseTableData.img;
const rtName = baseTableData.text || getFileName(rtPath);
_addToFound({ path: decodeURISafely(rtPath), name: rtName }, typeKey, found_images);
}
}
return;
} else if (path.source.startsWith('json')) {
await fetch(path.text, {
headers: {
Accept: 'application/json',
},
})
.then((response) => response.json())
.then(async function (result) {
if (!result.length > 0) {
return;
}
result.forEach((img) => {
const rtName = img.name ?? getFileName(img.path);
_addToFound({ path: decodeURISafely(img.path), name: rtName, tags: img.tags }, typeKey, found_images);
});
})
.catch((error) => console.warn('TVA |', error));
return;
} else {
files = await FilePicker.browse(path.source, path.text);
}
} catch (err) {
console.warn(
`TVA | ${game.i18n.localize('token-variants.notifications.warn.path-not-found')} ${path.source}:${path.text}`
);
return;
}
if (files.target == '.') return;
if (files.files) {
files.files.forEach((tokenSrc) => {
_addToFound({ path: decodeURISafely(tokenSrc), name: getFileName(tokenSrc) }, typeKey, found_images);
});
}
// ForgeVTT requires special treatment
// Bazaar paths fail recursive search if one level above root
if (path.source.startsWith('forgevtt')) return;
else if (
path.source.startsWith('forge-bazaar') &&
!['modules', 'systems', 'worlds', 'assets'].includes(path.text.replaceAll(/[\/\\]/g, ''))
) {
return;
}
for (let f_dir of files.dirs) {
await walkFindImages({ text: f_dir, source: path.source, types: path.types }, { apiKey: apiKey }, found_images);
}
}
function _addToFound(img, typeKey, found_images) {
if (isImage(img.path) || isVideo(img.path)) {
if (found_images[typeKey] == null) {
found_images[typeKey] = [img];
} else {
found_images[typeKey].push(img);
}
}
}
/**
* Recursive walks through all paths exposed to the module and caches them
* @param {*} searchType
* @returns
*/
async function walkAllPaths(searchType) {
const found_images = {};
const paths = _filterPathsByType(TVA_CONFIG.searchPaths, searchType);
for (const path of paths) {
if ((path.cache && caching) || (!path.cache && !caching)) await walkFindImages(path, {}, found_images);
}
// ForgeVTT specific path handling
const userId = typeof ForgeAPI !== 'undefined' ? await ForgeAPI.getUserId() : '';
for (const uid in TVA_CONFIG.forgeSearchPaths) {
const apiKey = TVA_CONFIG.forgeSearchPaths[uid].apiKey;
const paths = _filterPathsByType(TVA_CONFIG.forgeSearchPaths[uid].paths, searchType);
if (uid === userId) {
for (const path of paths) {
if ((path.cache && caching) || (!path.cache && !caching)) await walkFindImages(path, {}, found_images);
}
} else if (apiKey) {
for (const path of paths) {
if ((path.cache && caching) || (!path.cache && !caching)) {
if (path.share) await walkFindImages(path, { apiKey: apiKey }, found_images);
}
}
}
}
return found_images;
}
function _filterPathsByType(paths, searchType) {
if (!searchType) return paths;
return paths.filter((p) => p.types.includes(searchType));
}
export async function findImagesFuzzy(name, searchType, searchOptions, forceSearchName = false) {
if (TVA_CONFIG.debug)
console.info('TVA | STARTING: Fuzzy Image Search', name, searchType, searchOptions, forceSearchName);
const filters = getFilters(searchType, searchOptions.searchFilters);
const fuse = new Fuse([], {
keys: [!forceSearchName && searchOptions.runSearchOnPath ? 'path' : 'name', 'tags'],
includeScore: true,
includeMatches: true,
minMatchCharLength: 1,
ignoreLocation: true,
threshold: searchOptions.algorithm.fuzzyThreshold,
});
const found_images = await walkAllPaths(searchType);
for (const container of [CACHED_IMAGES, found_images]) {
for (const typeKey in container) {
const types = typeKey.split(',');
if (types.includes(searchType)) {
for (const imgObj of container[typeKey]) {
if (_imagePassesFilter(imgObj.name, imgObj.path, filters, searchOptions.runSearchOnPath)) {
fuse.add(imgObj);
}
}
}
}
}
let results;
if (name === '') {
results = fuse.getIndex().docs.slice(0, searchOptions.algorithm.fuzzyLimit);
} else {
results = fuse.search(name).slice(0, searchOptions.algorithm.fuzzyLimit);
results = results.map((r) => {
r.item.indices = r.matches[0].indices;
r.item.score = r.score;
return r.item;
});
}
if (TVA_CONFIG.debug) console.info('TVA | ENDING: Fuzzy Image Search', results);
return results;
}
async function findImagesExact(name, searchType, searchOptions) {
if (TVA_CONFIG.debug) console.info('TVA | STARTING: Exact Image Search', name, searchType, searchOptions);
const found_images = await walkAllPaths(searchType);
const simpleName = simplifyName(name);
const filters = getFilters(searchType, searchOptions.searchFilters);
const matchedImages = [];
for (const container of [CACHED_IMAGES, found_images]) {
for (const typeKey in container) {
const types = typeKey.split(',');
if (types.includes(searchType)) {
for (const imgOBj of container[typeKey]) {
if (_exactSearchMatchesImage(simpleName, imgOBj.path, imgOBj.name, filters, searchOptions.runSearchOnPath)) {
matchedImages.push(imgOBj);
}
}
}
}
}
if (TVA_CONFIG.debug) console.info('TVA | ENDING: Exact Image Search', matchedImages);
return matchedImages;
}
async function findImages(name, searchType = '', searchOptions = {}) {
const sOptions = mergeObject(searchOptions, getSearchOptions(), { overwrite: false });
if (sOptions.algorithm.exact) {
return await findImagesExact(name, searchType, sOptions);
} else {
return await findImagesFuzzy(name, searchType, sOptions);
}
}
/**
* Checks if image path and name match the provided search text and filters
* @param imagePath image path
* @param imageName image name
* @param filters filters to be applied
* @returns true|false
*/
function _exactSearchMatchesImage(simplifiedSearch, imagePath, imageName, filters, runSearchOnPath) {
// Is the search text contained in the name/path
const simplified = runSearchOnPath ? simplifyPath(imagePath) : simplifyName(imageName);
if (!simplified.includes(simplifiedSearch)) {
return false;
}
if (!filters) return true;
return _imagePassesFilter(imageName, imagePath, filters, runSearchOnPath);
}
function _imagePassesFilter(imageName, imagePath, filters, runSearchOnPath) {
// Filters are applied to path depending on the 'runSearchOnPath' setting, and actual or custom rolltable name
let text;
if (runSearchOnPath) {
text = decodeURIComponentSafely(imagePath);
} else if (getFileName(imagePath) === imageName) {
text = getFileNameWithExt(imagePath);
} else {
text = imageName;
}
if (filters.regex) {
return filters.regex.test(text);
}
if (filters.include) {
if (!text.includes(filters.include)) return false;
}
if (filters.exclude) {
if (text.includes(filters.exclude)) return false;
}
return true;
}
// ===================================
// ==== CACHING RELATED FUNCTIONS ====
// ===================================
export async function saveCache(cacheFile) {
const data = {};
const caches = Object.keys(CACHED_IMAGES);
for (const c of caches) {
if (!(c in data)) data[c] = [];
for (const img of CACHED_IMAGES[c]) {
if (img.tags) {
data[c].push([img.path, img.name, img.tags]);
} else if (getFileName(img.path) === img.name) {
data[c].push(img.path);
} else {
data[c].push([img.path, img.name]);
}
}
}
let file = new File([JSON.stringify(data)], getFileNameWithExt(cacheFile), {
type: 'text/plain',
});
FilePicker.upload('data', getFilePath(cacheFile), file);
}
/**
* Search for and cache all the found token art
*/
export async function cacheImages({
staticCache = TVA_CONFIG.staticCache,
staticCacheFile = TVA_CONFIG.staticCacheFile,
} = {}) {
if (caching) return;
caching = true;
if (!isInitialized() && staticCache) {
if (await _readCacheFromFile(staticCacheFile)) {
caching = false;
return;
}
}
if (!TVA_CONFIG.disableNotifs)
ui.notifications.info(game.i18n.format('token-variants.notifications.info.caching-started'));
if (TVA_CONFIG.debug) console.info('TVA | STARTING: Token Caching');
const found_images = await walkAllPaths();
CACHED_IMAGES = found_images;
if (TVA_CONFIG.debug) console.info('TVA | ENDING: Token Caching');
caching = false;
if (!TVA_CONFIG.disableNotifs)
ui.notifications.info(
game.i18n.format('token-variants.notifications.info.caching-finished', {
imageCount: Object.keys(CACHED_IMAGES).reduce((count, types) => count + CACHED_IMAGES[types].length, 0),
})
);
if (staticCache && game.user.isGM) {
saveCache(staticCacheFile);
}
}
async function _readCacheFromFile(fileName) {
CACHED_IMAGES = {};
try {
await jQuery.getJSON(fileName, (json) => {
for (let category in json) {
CACHED_IMAGES[category] = [];
for (const img of json[category]) {
if (Array.isArray(img)) {
if (img.length === 3) {
CACHED_IMAGES[category].push({ path: img[0], name: img[1], tags: img[2] });
} else {
CACHED_IMAGES[category].push({ path: img[0], name: img[1] });
}
} else {
CACHED_IMAGES[category].push({ path: img, name: getFileName(img) });
}
}
}
if (!TVA_CONFIG.disableNotifs)
ui.notifications.info(
`Token Variant Art: Using Static Cache (${Object.keys(CACHED_IMAGES).reduce(
(count, c) => count + CACHED_IMAGES[c].length,
0
)} images)`
);
});
} catch (error) {
ui.notifications.warn(`Token Variant Art: Static Cache not found`);
CACHED_IMAGES = {};
return false;
}
return true;
}