} True if the user confirmed the overwrite or there is no overwrite needed, false otherwise
*/
export async function checkOverwriteExistingData(type, existingNames, name, { interactive = false, actionName = 'Overwrite', deleteAction = null } = {}) {
const existing = existingNames.find(x => equalsIgnoreCaseAndAccents(x, name));
if (!existing) {
return true;
}
const overwrite = interactive && await Popup.show.confirm(`${type} ${actionName}`, `A ${type.toLowerCase()} with the same name already exists:
${existing}
Do you want to overwrite it?`);
if (!overwrite) {
toastr.warning(`${type} ${actionName.toLowerCase()} cancelled. A ${type.toLowerCase()} with the same name already exists:
${existing}`, `${type} ${actionName}`, { escapeHtml: false });
return false;
}
toastr.info(`Overwriting Existing ${type}:
${existing}`, `${type} ${actionName}`, { escapeHtml: false });
// If there is an action to delete the existing data, do it, as the name might be slightly different so file name would not be the same
if (deleteAction) {
deleteAction(existing);
}
return true;
}
/**
* Generates a free name by appending a counter to the given name if it already exists in the list
*
* @param {string} name - The original name to check for existence in the list
* @param {string[]} list - The list of names to check for existence
* @param {(n: number) => string} [numberFormatter=(n) => ` #${n}`] - The function used to format the counter
* @returns {string} The generated free name
*/
export function getFreeName(name, list, numberFormatter = (n) => ` #${n}`) {
if (!list.includes(name)) {
return name;
}
let counter = 1;
while (list.includes(`${name} #${counter}`)) {
counter++;
}
return `${name}${numberFormatter(counter)}`;
}
/**
* Toggles the visibility of a drawer by changing the display style of its content.
* This function skips the usual drawer animation.
*
* @param {HTMLElement} drawer - The drawer element to toggle
* @param {boolean} [expand=true] - Whether to expand or collapse the drawer
*/
export function toggleDrawer(drawer, expand = true) {
/** @type {HTMLElement} */
const icon = drawer.querySelector('.inline-drawer-icon');
/** @type {HTMLElement} */
const content = drawer.querySelector('.inline-drawer-content');
if (expand) {
icon.classList.remove('up', 'fa-circle-chevron-up');
icon.classList.add('down', 'fa-circle-chevron-down');
content.style.display = 'block';
} else {
icon.classList.remove('down', 'fa-circle-chevron-down');
icon.classList.add('up', 'fa-circle-chevron-up');
content.style.display = 'none';
}
// Set the height of "autoSetHeight" textareas within the inline-drawer to their scroll height
if (!CSS.supports('field-sizing', 'content')) {
content.querySelectorAll('textarea.autoSetHeight').forEach(resetScrollHeight);
}
}
/**
* Sets or removes a dataset property on an HTMLElement
*
* Utility function to make it easier to reset dataset properties on null, without them being "null" as value.
*
* @param {HTMLElement} element - The element to modify
* @param {string} name - The name of the dataset property
* @param {string|null} value - The value to set - If null, the dataset property will be removed
*/
export function setDatasetProperty(element, name, value) {
if (value === null) {
delete element.dataset[name];
} else {
element.dataset[name] = value;
}
}
export async function fetchFaFile(name) {
const style = document.createElement('style');
style.innerHTML = await (await fetch(`/css/${name}`)).text();
document.head.append(style);
const sheet = style.sheet;
style.remove();
return [...sheet.cssRules]
.filter(rule => rule.style?.content)
.map(rule => rule.selectorText.split(/,\s*/).map(selector => selector.split('::').shift().slice(1)))
;
}
export async function fetchFa() {
return [...new Set((await Promise.all([
fetchFaFile('fontawesome.min.css'),
])).flat())];
}
/**
* Opens a popup with all the available Font Awesome icons and returns the selected icon's name.
* @prop {string[]} customList A custom list of Font Awesome icons to use instead of all available icons.
* @returns {Promise} The icon name (fa-pencil) or null if cancelled.
*/
export async function showFontAwesomePicker(customList = null) {
const faList = customList ?? await fetchFa();
const fas = {};
const dom = document.createElement('div'); {
dom.classList.add('faPicker-container');
const search = document.createElement('div'); {
search.classList.add('faQuery-container');
const qry = document.createElement('input'); {
qry.classList.add('text_pole');
qry.classList.add('faQuery');
qry.type = 'search';
qry.placeholder = 'Filter icons';
qry.autofocus = true;
const qryDebounced = debounce(() => {
const result = faList.filter(fa => fa.find(className => className.includes(qry.value.toLowerCase())));
for (const fa of faList) {
if (!result.includes(fa)) {
fas[fa].classList.add('hidden');
} else {
fas[fa].classList.remove('hidden');
}
}
});
qry.addEventListener('input', () => qryDebounced());
search.append(qry);
}
dom.append(search);
}
const grid = document.createElement('div'); {
grid.classList.add('faPicker');
for (const fa of faList) {
const opt = document.createElement('div'); {
fas[fa] = opt;
opt.classList.add('menu_button');
opt.classList.add('fa-solid');
opt.classList.add(fa[0]);
opt.title = fa.map(it => it.slice(3)).join(', ');
opt.dataset.result = POPUP_RESULT.AFFIRMATIVE.toString();
opt.addEventListener('click', () => value = fa[0]);
grid.append(opt);
}
}
dom.append(grid);
}
}
let value = '';
const picker = new Popup(dom, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true, okButton: 'No Icon', cancelButton: 'Cancel' });
await picker.show();
if (picker.result == POPUP_RESULT.AFFIRMATIVE) {
return value;
}
return null;
}
/**
* Finds a persona by name, with optional filtering and precedence for avatars
* @param {object} [options={}] - The options for the search
* @param {string?} [options.name=null] - The name to search for
* @param {boolean} [options.allowAvatar=true] - Whether to allow searching by avatar
* @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
* @param {boolean} [options.preferCurrentPersona=true] - Whether to prefer the current persona(s)
* @param {boolean} [options.quiet=false] - Whether to suppress warnings
* @returns {PersonaViewModel} The persona object
* @typedef {object} PersonaViewModel
* @property {string} avatar - The avatar of the persona
* @property {string} name - The name of the persona
*/
export function findPersona({ name = null, allowAvatar = true, insensitive = true, preferCurrentPersona = true, quiet = false } = {}) {
/** @type {PersonaViewModel[]} */
const personas = Object.entries(power_user.personas).map(([avatar, name]) => ({ avatar, name }));
const matches = (/** @type {PersonaViewModel} */ persona) => !name || (allowAvatar && persona.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(persona.name, name) : persona.name === name);
// If we have a current persona and prefer it, return that if it matches
const currentPersona = personas.find(a => a.avatar === user_avatar);
if (preferCurrentPersona && currentPersona && matches(currentPersona)) {
return currentPersona;
}
// If allowAvatar is true, search by avatar first
if (allowAvatar && name) {
const personaByAvatar = personas.find(a => a.avatar === name);
if (personaByAvatar && matches(personaByAvatar)) {
return personaByAvatar;
}
}
// Search for matching personas by name
const matchingPersonas = personas.filter(a => matches(a));
if (matchingPersonas.length > 1) {
if (!quiet) toastr.warning(t`Multiple personas found for given conditions.`);
else console.warn(t`Multiple personas found for given conditions. Returning the first match.`);
}
return matchingPersonas[0] || null;
}
/**
* Finds a character by name, with optional filtering and precedence for avatars
* @param {object} [options={}] - The options for the search
* @param {string?} [options.name=null] - The name to search for
* @param {boolean} [options.allowAvatar=true] - Whether to allow searching by avatar
* @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
* @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
* @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s)
* @param {boolean} [options.quiet=false] - Whether to suppress warnings
* @returns {import('./char-data.js').v1CharData?} - The found character or null if not found
*/
export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) {
const matches = (char) => !name || (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name);
// Filter characters by tags if provided
let filteredCharacters = characters;
if (filteredByTags) {
filteredCharacters = characters.filter(char => {
const charTags = getTagsList(char.avatar, false);
return filteredByTags.every(tagName => charTags.some(x => x.name == tagName));
});
}
// Get the current character(s)
/** @type {any[]} */
const currentChars = selected_group ? groups.find(group => group.id === selected_group)?.members.map(member => filteredCharacters.find(char => char.avatar === member))
: filteredCharacters.filter(char => characters[this_chid]?.avatar === char.avatar);
// If we have a current char and prefer it, return that if it matches
if (preferCurrentChar) {
const preferredCharSearch = currentChars.filter(matches);
if (preferredCharSearch.length > 1) {
if (!quiet) toastr.warning(t`Multiple characters found for given conditions.`);
else console.warn(t`Multiple characters found for given conditions. Returning the first match.`);
}
if (preferredCharSearch.length) {
return preferredCharSearch[0];
}
}
// If allowAvatar is true, search by avatar first
if (allowAvatar && name) {
const characterByAvatar = filteredCharacters.find(char => char.avatar === name);
if (characterByAvatar) {
return characterByAvatar;
}
}
// Search for matching characters by name
const matchingCharacters = name ? filteredCharacters.filter(matches) : filteredCharacters;
if (matchingCharacters.length > 1) {
if (!quiet) toastr.warning('Multiple characters found for given conditions.');
else console.warn('Multiple characters found for given conditions. Returning the first match.');
}
return matchingCharacters[0] || null;
}
/**
* Gets the index of a character based on the character object
* @param {object} char - The character object to find the index for
* @throws {Error} If the character is not found
* @returns {number} The index of the character in the characters array
*/
export function getCharIndex(char) {
if (!char) throw new Error('Character is undefined');
const index = characters.findIndex(c => c.avatar === char.avatar);
if (index === -1) throw new Error(`Character not found: ${char.avatar}`);
return index;
}
/**
* Compares two arrays for equality
* @param {any[]} a - The first array
* @param {any[]} b - The second array
* @returns {boolean} True if the arrays are equal, false otherwise
*/
export function arraysEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Updates the content and style of an information block
* @param {string | HTMLElement} target - The CSS selector or the HTML element of the information block
* @param {string | HTMLElement?} content - The message to display inside the information block (supports HTML) or an HTML element
* @param {'hint' | 'info' | 'warning' | 'error'} [type='info'] - The type of message, which determines the styling of the information block
*/
export function setInfoBlock(target, content, type = 'info') {
if (!content) {
clearInfoBlock(target);
return;
}
const infoBlock = typeof target === 'string' ? document.querySelector(target) : target;
if (infoBlock) {
infoBlock.className = `info-block ${type}`;
if (typeof content === 'string') {
infoBlock.innerHTML = content;
} else {
infoBlock.innerHTML = '';
infoBlock.appendChild(content);
}
}
}
/**
* Clears the content and style of an information block.
* @param {string | HTMLElement} target - The CSS selector or the HTML element of the information block
*/
export function clearInfoBlock(target) {
const infoBlock = typeof target === 'string' ? document.querySelector(target) : target;
if (infoBlock && infoBlock.classList.contains('info-block')) {
infoBlock.className = '';
infoBlock.innerHTML = '';
}
}