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;
|
|
}
|