import { getContext } from './extensions.js'; import { callPopup, getRequestHeaders } from '../script.js'; import { isMobile } from './RossAscends-mods.js'; import { collapseNewlines } from './power-user.js'; import { debounce_timeout } from './constants.js'; /** * Pagination status string template. * @type {string} */ export const PAGINATION_TEMPLATE = '<%= rangeStart %>-<%= rangeEnd %> of <%= totalNumber %>'; /** * Navigation options for pagination. * @enum {number} */ export const navigation_option = { none: -2000, previous: -1000, }; export function escapeHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } export function isValidUrl(value) { try { new URL(value); return true; } catch (_) { return false; } } /** * Parses ranges like 10-20 or 10. * Range is inclusive. Start must be less than end. * Returns null if invalid. * @param {string} input The input string. * @param {number} min The minimum value. * @param {number} max The maximum value. * @returns {{ start: number, end: number }} The parsed range. */ export function stringToRange(input, min, max) { let start, end; if (typeof input !== 'string') { input = String(input); } if (input.includes('-')) { const parts = input.split('-'); start = parts[0] ? parseInt(parts[0], 10) : NaN; end = parts[1] ? parseInt(parts[1], 10) : NaN; } else { start = end = parseInt(input, 10); } if (isNaN(start) || isNaN(end) || start > end || start < min || end > max) { return null; } return { start, end }; } /** * Determines if a value is unique in an array. * @param {any} value Current value. * @param {number} index Current index. * @param {any} array The array being processed. * @returns {boolean} True if the value is unique, false otherwise. */ export function onlyUnique(value, index, array) { return array.indexOf(value) === index; } /** * Removes the first occurrence of a specified item from an array * * @param {*[]} array - The array from which to remove the item * @param {*} item - The item to remove from the array * @returns {boolean} - Returns true if the item was successfully removed, false otherwise. */ export function removeFromArray(array, item) { const index = array.indexOf(item); if (index === -1) return false; array.splice(index, 1); return true; } /** * Checks if a string only contains digits. * @param {string} str The string to check. * @returns {boolean} True if the string only contains digits, false otherwise. * @example * isDigitsOnly('123'); // true * isDigitsOnly('abc'); // false */ export function isDigitsOnly(str) { return /^\d+$/.test(str); } /** * Gets a drag delay for sortable elements. This is to prevent accidental drags when scrolling. * @returns {number} The delay in milliseconds. 50ms for desktop, 750ms for mobile. */ export function getSortableDelay() { return isMobile() ? 750 : 50; } export async function bufferToBase64(buffer) { // use a FileReader to generate a base64 data URI: const base64url = await new Promise(resolve => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.readAsDataURL(new Blob([buffer])); }); // remove the `data:...;base64,` part from the start return base64url.slice(base64url.indexOf(',') + 1); } /** * Rearranges an array in a random order. * @param {any[]} array The array to shuffle. * @returns {any[]} The shuffled array. * @example * shuffle([1, 2, 3]); // [2, 3, 1] */ export function shuffle(array) { let currentIndex = array.length, randomIndex; while (currentIndex != 0) { randomIndex = Math.floor(Math.random() * currentIndex); currentIndex--; [array[currentIndex], array[randomIndex]] = [ array[randomIndex], array[currentIndex], ]; } return array; } /** * Downloads a file to the user's devices. * @param {BlobPart} content File content to download. * @param {string} fileName File name. * @param {string} contentType File content type. */ export function download(content, fileName, contentType) { const a = document.createElement('a'); const file = new Blob([content], { type: contentType }); a.href = URL.createObjectURL(file); a.download = fileName; a.click(); URL.revokeObjectURL(a.href); } /** * Fetches a file by URL and parses its contents as data URI. * @param {string} url The URL to fetch. * @param {any} params Fetch parameters. * @returns {Promise} A promise that resolves to the data URI. */ export async function urlContentToDataUri(url, params) { const response = await fetch(url, params); const blob = await response.blob(); return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = function () { resolve(String(reader.result)); }; reader.onerror = function (error) { reject(error); }; reader.readAsDataURL(blob); }); } /** * Returns a promise that resolves to the file's text. * @param {Blob} file The file to read. * @returns {Promise} A promise that resolves to the file's text. */ export function getFileText(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsText(file); reader.onload = function () { resolve(String(reader.result)); }; reader.onerror = function (error) { reject(error); }; }); } /** * Returns a promise that resolves to the file's array buffer. * @param {Blob} file The file to read. */ export function getFileBuffer(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onload = function () { resolve(reader.result); }; reader.onerror = function (error) { reject(error); }; }); } /** * Returns a promise that resolves to the base64 encoded string of a file. * @param {Blob} file The file to read. * @returns {Promise} A promise that resolves to the base64 encoded string. */ export function getBase64Async(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = function () { resolve(String(reader.result)); }; reader.onerror = function (error) { reject(error); }; }); } /** * Parses a file blob as a JSON object. * @param {Blob} file The file to read. * @returns {Promise} A promise that resolves to the parsed JSON object. */ export async function parseJsonFile(file) { return new Promise((resolve, reject) => { const fileReader = new FileReader(); fileReader.readAsText(file); fileReader.onload = event => resolve(JSON.parse(String(event.target.result))); fileReader.onerror = error => reject(error); }); } /** * Calculates a hash code for a string. * @param {string} str The string to hash. * @param {number} [seed=0] The seed to use for the hash. * @returns {number} The hash code. */ export function getStringHash(str, seed = 0) { if (typeof str !== 'string') { return 0; } let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return 4294967296 * (2097151 & h2) + (h1 >>> 0); } /** * Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked. * @param {function} func The function to debounce. * @param {debounce_timeout|number} [timeout=debounce_timeout.default] The timeout based on the common enum values, or in milliseconds. * @returns {function} The debounced function. */ export function debounce(func, timeout = debounce_timeout.standard) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => { func.apply(this, args); }, timeout); }; } /** * Creates a throttled function that only invokes func at most once per every limit milliseconds. * @param {function} func The function to throttle. * @param {number} [limit=300] The limit in milliseconds. * @returns {function} The throttled function. */ export function throttle(func, limit = 300) { let lastCall; return (...args) => { const now = Date.now(); if (!lastCall || (now - lastCall) >= limit) { lastCall = now; func.apply(this, args); } }; } /** * Checks if an element is in the viewport. * @param {Element} el The element to check. * @returns {boolean} True if the element is in the viewport, false otherwise. */ export function isElementInViewport(el) { if (!el) { return false; } if (typeof jQuery === 'function' && el instanceof jQuery) { el = el[0]; } var rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */ rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */ ); } /** * Returns a name that is unique among the names that exist. * @param {string} name The name to check. * @param {{ (y: any): boolean; }} exists Function to check if name exists. * @returns {string} A unique name. */ export function getUniqueName(name, exists) { let i = 1; let baseName = name; while (exists(name)) { name = `${baseName} (${i})`; i++; } return name; } /** * Returns a promise that resolves after the specified number of milliseconds. * @param {number} ms The number of milliseconds to wait. * @returns {Promise} A promise that resolves after the specified number of milliseconds. */ export function delay(ms) { return new Promise((res) => setTimeout(res, ms)); } /** * Checks if an array is a subset of another array. * @param {any[]} a Array A * @param {any[]} b Array B * @returns {boolean} True if B is a subset of A, false otherwise. */ export function isSubsetOf(a, b) { return (Array.isArray(a) && Array.isArray(b)) ? b.every(val => a.includes(val)) : false; } /** * Increments the trailing number in a string. * @param {string} str The string to process. * @returns {string} The string with the trailing number incremented by 1. * @example * incrementString('Hello, world! 1'); // 'Hello, world! 2' */ export function incrementString(str) { // Find the trailing number or it will match the empty string const count = str.match(/\d*$/); // Take the substring up until where the integer was matched // Concatenate it to the matched count incremented by 1 return str.substring(0, count.index) + (Number(count[0]) + 1); } /** * Formats a string using the specified arguments. * @param {string} format The format string. * @returns {string} The formatted string. * @example * stringFormat('Hello, {0}!', 'world'); // 'Hello, world!' */ export function stringFormat(format) { const args = Array.prototype.slice.call(arguments, 1); return format.replace(/{(\d+)}/g, function (match, number) { return typeof args[number] != 'undefined' ? args[number] : match; }); } /** * Save the caret position in a contenteditable element. * @param {Element} element The element to save the caret position of. * @returns {{ start: number, end: number }} An object with the start and end offsets of the caret. */ export function saveCaretPosition(element) { // Get the current selection const selection = window.getSelection(); // If the selection is empty, return null if (selection.rangeCount === 0) { return null; } // Get the range of the current selection const range = selection.getRangeAt(0); // If the range is not within the specified element, return null if (!element.contains(range.commonAncestorContainer)) { return null; } // Return an object with the start and end offsets of the range const position = { start: range.startOffset, end: range.endOffset, }; console.debug('Caret saved', position); return position; } /** * Restore the caret position in a contenteditable element. * @param {Element} element The element to restore the caret position of. * @param {{ start: any; end: any; }} position An object with the start and end offsets of the caret. */ export function restoreCaretPosition(element, position) { // If the position is null, do nothing if (!position) { return; } console.debug('Caret restored', position); // Create a new range object const range = new Range(); // Set the start and end positions of the range within the element range.setStart(element.childNodes[0], position.start); range.setEnd(element.childNodes[0], position.end); // Create a new selection object and set the range const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } export async function resetScrollHeight(element) { $(element).css('height', '0px'); $(element).css('height', $(element).prop('scrollHeight') + 3 + 'px'); } /** * Sets the height of an element to its scroll height. * @param {JQuery} element The element to initialize the scroll height of. * @returns {Promise} A promise that resolves when the scroll height has been initialized. */ export async function initScrollHeight(element) { await delay(1); const curHeight = Number($(element).css('height').replace('px', '')); const curScrollHeight = Number($(element).prop('scrollHeight')); const diff = curScrollHeight - curHeight; if (diff < 3) { return; } //happens when the div isn't loaded yet const newHeight = curHeight + diff + 3; //the +3 here is to account for padding/line-height on text inputs //console.log(`init height to ${newHeight}`); $(element).css('height', ''); $(element).css('height', `${newHeight}px`); //resetScrollHeight(element); } /** * Compares elements by their CSS order property. Used for sorting. * @param {any} a The first element. * @param {any} b The second element. * @returns {number} A negative number if a is before b, a positive number if a is after b, or 0 if they are equal. */ export function sortByCssOrder(a, b) { const _a = Number($(a).css('order')); const _b = Number($(b).css('order')); return _a - _b; } /** * Trims a string to the end of a nearest sentence. * @param {string} input The string to trim. * @param {boolean} include_newline Whether to include a newline character in the trimmed string. * @returns {string} The trimmed string. * @example * trimToEndSentence('Hello, world! I am from'); // 'Hello, world!' */ export function trimToEndSentence(input, include_newline = false) { if (!input) { return ''; } const isEmoji = x => /(\p{Emoji_Presentation}|\p{Extended_Pictographic})/gu.test(x); const punctuation = new Set(['.', '!', '?', '*', '"', ')', '}', '`', ']', '$', '。', '!', '?', '”', ')', '】', '’', '」', '_']); // extend this as you see fit let last = -1; const characters = Array.from(input); for (let i = characters.length - 1; i >= 0; i--) { const char = characters[i]; if (punctuation.has(char) || isEmoji(char)) { if (i > 0 && /[\s\n]/.test(characters[i - 1])) { last = i - 1; } else { last = i; } break; } if (include_newline && char === '\n') { last = i; break; } } if (last === -1) { return input.trimEnd(); } return characters.slice(0, last + 1).join('').trimEnd(); } export function trimToStartSentence(input) { if (!input) { return ''; } let p1 = input.indexOf('.'); let p2 = input.indexOf('!'); let p3 = input.indexOf('?'); let p4 = input.indexOf('\n'); let first = p1; let skip1 = false; if (p2 > 0 && p2 < first) { first = p2; } if (p3 > 0 && p3 < first) { first = p3; } if (p4 > 0 && p4 < first) { first = p4; skip1 = true; } if (first > 0) { if (skip1) { return input.substring(first + 1); } else { return input.substring(first + 2); } } return input; } /** * Format bytes as human-readable text. * * @param bytes Number of bytes. * @param si True to use metric (SI) units, aka powers of 1000. False to use * binary (IEC), aka powers of 1024. * @param dp Number of decimal places to display. * * @return Formatted string. */ export function humanFileSize(bytes, si = false, dp = 1) { const thresh = si ? 1000 : 1024; if (Math.abs(bytes) < thresh) { return bytes + ' B'; } const units = si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; let u = -1; const r = 10 ** dp; do { bytes /= thresh; ++u; } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); return bytes.toFixed(dp) + ' ' + units[u]; } /** * Counts the number of occurrences of a character in a string. * @param {string} string The string to count occurrences in. * @param {string} character The character to count occurrences of. * @returns {number} The number of occurrences of the character in the string. * @example * countOccurrences('Hello, world!', 'l'); // 3 * countOccurrences('Hello, world!', 'x'); // 0 */ export function countOccurrences(string, character) { let count = 0; for (let i = 0; i < string.length; i++) { if (string.substring(i, i + character.length) === character) { count++; } } return count; } /** * Checks if a string is "true" value. * @param {string} arg String to check * @returns {boolean} True if the string is true, false otherwise. */ export function isTrueBoolean(arg) { return ['on', 'true', '1'].includes(arg?.trim()?.toLowerCase()); } /** * Checks if a string is "false" value. * @param {string} arg String to check * @returns {boolean} True if the string is false, false otherwise. */ export function isFalseBoolean(arg) { return ['off', 'false', '0'].includes(arg?.trim()?.toLowerCase()); } /** * Parses an array either as a comma-separated string or as a JSON array. * @param {string} value String to parse * @returns {string[]} The parsed array. */ export function parseStringArray(value) { if (!value || typeof value !== 'string') return []; try { const parsedValue = JSON.parse(value); if (!Array.isArray(parsedValue)) { throw new Error('Not an array'); } return parsedValue.map(x => String(x)); } catch (e) { return value.split(',').map(x => x.trim()).filter(x => x); } } /** * Checks if a number is odd. * @param {number} number The number to check. * @returns {boolean} True if the number is odd, false otherwise. * @example * isOdd(3); // true * isOdd(4); // false */ export function isOdd(number) { return number % 2 !== 0; } const dateCache = new Map(); /** * Cached version of moment() to avoid re-parsing the same date strings. * Important: Moment objects are mutable, so use clone() before modifying them! * @param {string|number} timestamp String or number representing a date. * @returns {moment.Moment} Moment object */ export function timestampToMoment(timestamp) { if (dateCache.has(timestamp)) { return dateCache.get(timestamp); } const moment = parseTimestamp(timestamp); dateCache.set(timestamp, moment); return moment; } function parseTimestamp(timestamp) { if (!timestamp) { return moment.invalid(); } // Unix time (legacy TAI / tags) if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) { if (isNaN(timestamp) || !isFinite(timestamp) || timestamp < 0) { return moment.invalid(); } return moment(Number(timestamp)); } // ST "humanized" format pattern const pattern1 = /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/; const replacement1 = (match, year, month, day, hour, minute, second, millisecond) => { return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}.${millisecond.padStart(3, '0')}Z`; }; const isoTimestamp1 = timestamp.replace(pattern1, replacement1); if (moment(isoTimestamp1).isValid()) { return moment(isoTimestamp1); } // New format pattern: "June 19, 2023 4:13pm" const pattern2 = /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i; const replacement2 = (match, month, day, year, hour, minute, meridiem) => { const monthNum = moment().month(month).format('MM'); const hour24 = meridiem.toLowerCase() === 'pm' ? (parseInt(hour, 10) % 12) + 12 : parseInt(hour, 10) % 12; return `${year}-${monthNum}-${day.padStart(2, '0')}T${hour24.toString().padStart(2, '0')}:${minute.padStart(2, '0')}:00`; }; const isoTimestamp2 = timestamp.replace(pattern2, replacement2); if (moment(isoTimestamp2).isValid()) { return moment(isoTimestamp2); } // If none of the patterns match, return an invalid moment object return moment.invalid(); } /** * Compare two moment objects for sorting. * @param {moment.Moment} a The first moment object. * @param {moment.Moment} b The second moment object. * @returns {number} A negative number if a is before b, a positive number if a is after b, or 0 if they are equal. */ export function sortMoments(a, b) { if (a.isBefore(b)) { return 1; } else if (a.isAfter(b)) { return -1; } else { return 0; } } /** Split string to parts no more than length in size. * @param {string} input The string to split. * @param {number} length The maximum length of each part. * @param {string[]} delimiters The delimiters to use when splitting the string. * @returns {string[]} The split string. * @example * splitRecursive('Hello, world!', 3); // ['Hel', 'lo,', 'wor', 'ld!'] */ export function splitRecursive(input, length, delimiters = ['\n\n', '\n', ' ', '']) { // Invalid length if (length <= 0) { return [input]; } const delim = delimiters[0] ?? ''; const parts = input.split(delim); const flatParts = parts.flatMap(p => { if (p.length < length) return p; return splitRecursive(p, length, delimiters.slice(1)); }); // Merge short chunks const result = []; let currentChunk = ''; for (let i = 0; i < flatParts.length;) { currentChunk = flatParts[i]; let j = i + 1; while (j < flatParts.length) { const nextChunk = flatParts[j]; if (currentChunk.length + nextChunk.length + delim.length <= length) { currentChunk += delim + nextChunk; } else { break; } j++; } i = j; result.push(currentChunk); } return result; } /** * Checks if a string is a valid data URL. * @param {string} str The string to check. * @returns {boolean} True if the string is a valid data URL, false otherwise. * @example * isDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...'); // true */ export function isDataURL(str) { const regex = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)*;?)?(base64)?,([a-z0-9!$&',()*+;=\-_%.~:@/?#]+)?$/i; return regex.test(str); } /** * Gets the size of an image from a data URL. * @param {string} dataUrl Image data URL * @returns {Promise<{ width: number, height: number }>} Image size */ export function getImageSizeFromDataURL(dataUrl) { const image = new Image(); image.src = dataUrl; return new Promise((resolve, reject) => { image.onload = function () { resolve({ width: image.width, height: image.height }); }; image.onerror = function () { reject(new Error('Failed to load image')); }; }); } export function getCharaFilename(chid) { const context = getContext(); const fileName = context.characters[chid ?? context.characterId].avatar; if (fileName) { return fileName.replace(/\.[^/.]+$/, ''); } } /** * Extracts words from a string. * @param {string} value The string to extract words from. * @returns {string[]} The extracted words. * @example * extractAllWords('Hello, world!'); // ['hello', 'world'] */ export function extractAllWords(value) { const words = []; if (!value) { return words; } const matches = value.matchAll(/\b\w+\b/gim); for (let match of matches) { words.push(match[0].toLowerCase()); } return words; } /** * Escapes a string for use in a regular expression. * @param {string} string The string to escape. * @returns {string} The escaped string. * @example * escapeRegex('^Hello$'); // '\\^Hello\\$' */ export function escapeRegex(string) { return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); } /** * Instantiates a regular expression from a string. * @param {string} input The input string. * @returns {RegExp} The regular expression instance. * @copyright Originally from: https://github.com/IonicaBizau/regex-parser.js/blob/master/lib/index.js */ export function regexFromString(input) { try { // Parse input var m = input.match(/(\/?)(.+)\1([a-z]*)/i); // Invalid flags if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3])) { return RegExp(input); } // Create the regular expression return new RegExp(m[2], m[3]); } catch { return; } } export class Stopwatch { /** * Initializes a Stopwatch class. * @param {number} interval Update interval in milliseconds. Must be a finite number above zero. */ constructor(interval) { if (isNaN(interval) || !isFinite(interval) || interval <= 0) { console.warn('Invalid interval for Stopwatch, setting to 1'); interval = 1; } this.interval = interval; this.lastAction = Date.now(); } /** * Executes a function if the interval passed. * @param {(arg0: any) => any} action Action function * @returns Promise */ async tick(action) { const passed = (Date.now() - this.lastAction); if (passed < this.interval) { return; } await action(); this.lastAction = Date.now(); } } /** * Provides an interface for rate limiting function calls. */ export class RateLimiter { /** * Creates a new RateLimiter. * @param {number} interval The interval in milliseconds. * @example * const rateLimiter = new RateLimiter(1000); * rateLimiter.waitForResolve().then(() => { * console.log('Waited 1000ms'); * }); */ constructor(interval) { this.interval = interval; this.lastResolveTime = 0; this.pendingResolve = Promise.resolve(); } /** * Waits for the remaining time in the interval. * @param {AbortSignal} abortSignal An optional AbortSignal to abort the wait. * @returns {Promise} A promise that resolves when the remaining time has elapsed. */ _waitRemainingTime(abortSignal) { const currentTime = Date.now(); const elapsedTime = currentTime - this.lastResolveTime; const remainingTime = Math.max(0, this.interval - elapsedTime); return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { resolve(); }, remainingTime); if (abortSignal) { abortSignal.addEventListener('abort', () => { clearTimeout(timeoutId); reject(new Error('Aborted')); }); } }); } /** * Waits for the next interval to elapse. * @param {AbortSignal} abortSignal An optional AbortSignal to abort the wait. * @returns {Promise} A promise that resolves when the next interval has elapsed. */ async waitForResolve(abortSignal) { await this.pendingResolve; this.pendingResolve = this._waitRemainingTime(abortSignal); // Update the last resolve time this.lastResolveTime = Date.now() + this.interval; console.debug(`RateLimiter.waitForResolve() ${this.lastResolveTime}`); } } /** * Extracts a JSON object from a PNG file. * Taken from https://github.com/LostRuins/lite.koboldai.net/blob/main/index.html * Adapted from png-chunks-extract under MIT license * @param {Uint8Array} data The PNG data to extract the JSON from. * @param {string} identifier The identifier to look for in the PNG tEXT data. * @returns {object} The extracted JSON object. */ export function extractDataFromPng(data, identifier = 'chara') { console.log('Attempting PNG import...'); let uint8 = new Uint8Array(4); let uint32 = new Uint32Array(uint8.buffer); //check if png header is valid if (!data || data[0] !== 0x89 || data[1] !== 0x50 || data[2] !== 0x4E || data[3] !== 0x47 || data[4] !== 0x0D || data[5] !== 0x0A || data[6] !== 0x1A || data[7] !== 0x0A) { console.log('PNG header invalid'); return null; } let ended = false; let chunks = []; let idx = 8; while (idx < data.length) { // Read the length of the current chunk, // which is stored as a Uint32. uint8[3] = data[idx++]; uint8[2] = data[idx++]; uint8[1] = data[idx++]; uint8[0] = data[idx++]; // Chunk includes name/type for CRC check (see below). let length = uint32[0] + 4; let chunk = new Uint8Array(length); chunk[0] = data[idx++]; chunk[1] = data[idx++]; chunk[2] = data[idx++]; chunk[3] = data[idx++]; // Get the name in ASCII for identification. let name = ( String.fromCharCode(chunk[0]) + String.fromCharCode(chunk[1]) + String.fromCharCode(chunk[2]) + String.fromCharCode(chunk[3]) ); // The IHDR header MUST come first. if (!chunks.length && name !== 'IHDR') { console.log('Warning: IHDR header missing'); } // The IEND header marks the end of the file, // so on discovering it break out of the loop. if (name === 'IEND') { ended = true; chunks.push({ name: name, data: new Uint8Array(0), }); break; } // Read the contents of the chunk out of the main buffer. for (let i = 4; i < length; i++) { chunk[i] = data[idx++]; } // Read out the CRC value for comparison. // It's stored as an Int32. uint8[3] = data[idx++]; uint8[2] = data[idx++]; uint8[1] = data[idx++]; uint8[0] = data[idx++]; // The chunk data is now copied to remove the 4 preceding // bytes used for the chunk name/type. let chunkData = new Uint8Array(chunk.buffer.slice(4)); chunks.push({ name: name, data: chunkData, }); } if (!ended) { console.log('.png file ended prematurely: no IEND header was found'); } //find the chunk with the chara name, just check first and last letter let found = chunks.filter(x => ( x.name == 'tEXt' && x.data.length > identifier.length && x.data.slice(0, identifier.length).every((v, i) => String.fromCharCode(v) == identifier[i]))); if (found.length == 0) { console.log('PNG Image contains no data'); return null; } else { try { let b64buf = ''; let bytes = found[0].data; //skip the chara for (let i = identifier.length + 1; i < bytes.length; i++) { b64buf += String.fromCharCode(bytes[i]); } let decoded = JSON.parse(atob(b64buf)); console.log(decoded); return decoded; } catch (e) { console.log('Error decoding b64 in image: ' + e); return null; } } } /** * Sends a request to the server to sanitize a given filename * * @param {string} fileName - The name of the file to sanitize * @returns {Promise} A Promise that resolves to the sanitized filename if successful, or rejects with an error message if unsuccessful */ export async function getSanitizedFilename(fileName) { try { const result = await fetch('/api/files/sanitize-filename', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ fileName: fileName, }), }); if (!result.ok) { const error = await result.text(); throw new Error(error); } const responseData = await result.json(); return responseData.fileName; } catch (error) { toastr.error(String(error), 'Could not sanitize fileName'); console.error('Could not sanitize fileName', error); throw error; } } /** * Sends a base64 encoded image to the backend to be saved as a file. * * @param {string} base64Data - The base64 encoded image data. * @param {string} characterName - The character name to determine the sub-directory for saving. * @param {string} ext - The file extension for the image (e.g., 'jpg', 'png', 'webp'). * * @returns {Promise} - Resolves to the saved image's path on the server. * Rejects with an error if the upload fails. */ export async function saveBase64AsFile(base64Data, characterName, filename = '', ext) { // Construct the full data URL const format = ext; // Extract the file extension (jpg, png, webp) const dataURL = `data:image/${format};base64,${base64Data}`; // Prepare the request body const requestBody = { image: dataURL, ch_name: characterName, filename: String(filename).replace(/\./g, '_'), }; // Send the data URL to your backend using fetch const response = await fetch('/api/images/upload', { method: 'POST', body: JSON.stringify(requestBody), headers: { ...getRequestHeaders(), 'Content-Type': 'application/json', }, }); // If the response is successful, get the saved image path from the server's response if (response.ok) { const responseData = await response.json(); return responseData.path; } else { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to upload the image to the server'); } } /** * Loads either a CSS or JS file and appends it to the appropriate document section. * * @param {string} url - The URL of the file to be loaded. * @param {string} type - The type of file to load: "css" or "js". * @returns {Promise} - Resolves when the file has loaded, rejects if there's an error or invalid type. */ export function loadFileToDocument(url, type) { return new Promise((resolve, reject) => { let element; if (type === 'css') { element = document.createElement('link'); element.rel = 'stylesheet'; element.href = url; } else if (type === 'js') { element = document.createElement('script'); element.src = url; } else { reject('Invalid type specified'); return; } element.onload = resolve; element.onerror = reject; type === 'css' ? document.head.appendChild(element) : document.body.appendChild(element); }); } /** * Ensure that we can import war crime image formats like WEBP and AVIF. * @param {File} file Input file * @returns {Promise} A promise that resolves to the supported file. */ export async function ensureImageFormatSupported(file) { const supportedTypes = [ 'image/jpeg', 'image/png', 'image/bmp', 'image/tiff', 'image/gif', 'image/apng', ]; if (supportedTypes.includes(file.type) || !file.type.startsWith('image/')) { return file; } return await convertImageFile(file, 'image/png'); } /** * Converts an image file to a given format. * @param {File} inputFile File to convert * @param {string} type Target file type * @returns {Promise} A promise that resolves to the converted file. */ export async function convertImageFile(inputFile, type = 'image/png') { const base64 = await getBase64Async(inputFile); const thumbnail = await createThumbnail(base64, null, null, type); const blob = await fetch(thumbnail).then(res => res.blob()); const outputFile = new File([blob], inputFile.name, { type }); return outputFile; } /** * Creates a thumbnail from a data URL. * @param {string} dataUrl The data URL encoded data of the image. * @param {number|null} maxWidth The maximum width of the thumbnail. * @param {number|null} maxHeight The maximum height of the thumbnail. * @param {string} [type='image/jpeg'] The type of the thumbnail. * @returns {Promise} A promise that resolves to the thumbnail data URL. */ export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type = 'image/jpeg') { // Someone might pass in a base64 encoded string without the data URL prefix if (!dataUrl.includes('data:')) { dataUrl = `data:image/jpeg;base64,${dataUrl}`; } return new Promise((resolve, reject) => { const img = new Image(); img.src = dataUrl; img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // Calculate the thumbnail dimensions while maintaining the aspect ratio const aspectRatio = img.width / img.height; let thumbnailWidth = maxWidth; let thumbnailHeight = maxHeight; if (maxWidth === null) { thumbnailWidth = img.width; maxWidth = img.width; } if (maxHeight === null) { thumbnailHeight = img.height; maxHeight = img.height; } if (img.width > img.height) { thumbnailHeight = maxWidth / aspectRatio; } else { thumbnailWidth = maxHeight * aspectRatio; } // Set the canvas dimensions and draw the resized image canvas.width = thumbnailWidth; canvas.height = thumbnailHeight; ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight); // Convert the canvas to a data URL and resolve the promise const thumbnailDataUrl = canvas.toDataURL(type); resolve(thumbnailDataUrl); }; img.onerror = () => { reject(new Error('Failed to load the image.')); }; }); } /** * Waits for a condition to be true. Throws an error if the condition is not true within the timeout. * @param {{ (): boolean; }} condition The condition to wait for. * @param {number} [timeout=1000] The timeout in milliseconds. * @param {number} [interval=100] The interval in milliseconds. * @returns {Promise} A promise that resolves when the condition is true. */ export async function waitUntilCondition(condition, timeout = 1000, interval = 100) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { clearInterval(intervalId); reject(new Error('Timed out waiting for condition to be true')); }, timeout); const intervalId = setInterval(() => { if (condition()) { clearTimeout(timeoutId); clearInterval(intervalId); resolve(); } }, interval); }); } /** * Returns a UUID v4 string. * @returns {string} A UUID v4 string. * @example * uuidv4(); // '3e2fd9e1-0a7a-4f6d-9aaf-8a7a4babe7eb' */ export function uuidv4() { if ('randomUUID' in crypto) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } function postProcessText(text, collapse = true) { // Remove carriage returns text = text.replace(/\r/g, ''); // Replace tabs with spaces text = text.replace(/\t/g, ' '); // Normalize unicode spaces text = text.replace(/\u00A0/g, ' '); // Collapse multiple newlines into one if (collapse) { text = collapseNewlines(text); // Trim leading and trailing whitespace, and remove empty lines text = text.split('\n').map(l => l.trim()).filter(Boolean).join('\n'); } else { // Replace more than 4 newlines with 4 newlines text = text.replace(/\n{4,}/g, '\n\n\n\n'); // Trim lines that contain nothing but whitespace text = text.split('\n').map(l => /^\s+$/.test(l) ? '' : l).join('\n'); } // Collapse multiple spaces into one (except for newlines) text = text.replace(/ {2,}/g, ' '); // Remove leading and trailing spaces text = text.trim(); return text; } /** * Uses Readability.js to parse the text from a web page. * @param {Document} document HTML document * @param {string} [textSelector='body'] The fallback selector for the text to parse. * @returns {Promise} A promise that resolves to the parsed text. */ export async function getReadableText(document, textSelector = 'body') { if (isProbablyReaderable(document)) { const parser = new Readability(document); const article = parser.parse(); return postProcessText(article.textContent, false); } const elements = document.querySelectorAll(textSelector); const rawText = Array.from(elements).map(e => e.textContent).join('\n'); const text = postProcessText(rawText); return text; } /** * Use pdf.js to load and parse text from PDF pages * @param {Blob} blob PDF file blob * @returns {Promise} A promise that resolves to the parsed text. */ export async function extractTextFromPDF(blob) { async function initPdfJs() { const promises = []; const workerPromise = new Promise((resolve, reject) => { const workerScript = document.createElement('script'); workerScript.type = 'module'; workerScript.async = true; workerScript.src = 'lib/pdf.worker.mjs'; workerScript.onload = resolve; workerScript.onerror = reject; document.head.appendChild(workerScript); }); promises.push(workerPromise); const pdfjsPromise = new Promise((resolve, reject) => { const pdfjsScript = document.createElement('script'); pdfjsScript.type = 'module'; pdfjsScript.async = true; pdfjsScript.src = 'lib/pdf.mjs'; pdfjsScript.onload = resolve; pdfjsScript.onerror = reject; document.head.appendChild(pdfjsScript); }); promises.push(pdfjsPromise); return Promise.all(promises); } if (!('pdfjsLib' in window)) { await initPdfJs(); } const buffer = await getFileBuffer(blob); const pdf = await pdfjsLib.getDocument(buffer).promise; const pages = []; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const textContent = await page.getTextContent(); const text = textContent.items.map(item => item.str).join(' '); pages.push(text); } return postProcessText(pages.join('\n')); } /** * Use DOMParser to load and parse text from HTML * @param {Blob} blob HTML content blob * @returns {Promise} A promise that resolves to the parsed text. */ export async function extractTextFromHTML(blob, textSelector = 'body') { const html = await blob.text(); const domParser = new DOMParser(); const document = domParser.parseFromString(DOMPurify.sanitize(html), 'text/html'); return await getReadableText(document, textSelector); } /** * Use showdown to load and parse text from Markdown * @param {Blob} blob Markdown content blob * @returns {Promise} A promise that resolves to the parsed text. */ export async function extractTextFromMarkdown(blob) { const markdown = await blob.text(); const text = postProcessText(markdown, false); return text; } export async function extractTextFromEpub(blob) { async function initEpubJs() { const epubScript = new Promise((resolve, reject) => { const epubScript = document.createElement('script'); epubScript.async = true; epubScript.src = 'lib/epub.min.js'; epubScript.onload = resolve; epubScript.onerror = reject; document.head.appendChild(epubScript); }); const jszipScript = new Promise((resolve, reject) => { const jszipScript = document.createElement('script'); jszipScript.async = true; jszipScript.src = 'lib/jszip.min.js'; jszipScript.onload = resolve; jszipScript.onerror = reject; document.head.appendChild(jszipScript); }); return Promise.all([epubScript, jszipScript]); } if (!('ePub' in window)) { await initEpubJs(); } const book = ePub(blob); await book.ready; const sectionPromises = []; book.spine.each((section) => { const sectionPromise = (async () => { const chapter = await book.load(section.href); if (!(chapter instanceof Document) || !chapter.body?.textContent) { return ''; } return chapter.body.textContent.trim(); })(); sectionPromises.push(sectionPromise); }); const content = await Promise.all(sectionPromises); const text = content.filter(text => text); return postProcessText(text.join('\n'), false); } /** * Extracts text from an Office document using the server plugin. * @param {File} blob File to extract text from * @returns {Promise} A promise that resolves to the extracted text. */ export async function extractTextFromOffice(blob) { async function checkPluginAvailability() { try { const result = await fetch('/api/plugins/office/probe', { method: 'POST', headers: getRequestHeaders(), }); return result.ok; } catch (error) { return false; } } const isPluginAvailable = await checkPluginAvailability(); if (!isPluginAvailable) { throw new Error('Importing Office documents requires a server plugin. Please refer to the documentation for more information.'); } const base64 = await getBase64Async(blob); const response = await fetch('/api/plugins/office/parse', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ data: base64 }), }); if (!response.ok) { throw new Error('Failed to parse the Office document'); } const data = await response.text(); return postProcessText(data, false); } /** * Sets a value in an object by a path. * @param {object} obj Object to set value in * @param {string} path Key path * @param {any} value Value to set * @returns {void} */ export function setValueByPath(obj, path, value) { const keyParts = path.split('.'); let currentObject = obj; for (let i = 0; i < keyParts.length - 1; i++) { const part = keyParts[i]; if (!Object.hasOwn(currentObject, part)) { currentObject[part] = {}; } currentObject = currentObject[part]; } currentObject[keyParts[keyParts.length - 1]] = value; } /** * Flashes the given HTML element via CSS flash animation for a defined period * @param {JQuery} element - The element to flash * @param {number} timespan - A numer in milliseconds how the flash should last */ export function flashHighlight(element, timespan = 2000) { element.addClass('flash animated'); setTimeout(() => element.removeClass('flash animated'), timespan); } /** * Checks if the given control has an animation applied to it * * @param {HTMLElement} control - The control element to check for animation * @returns {boolean} Whether the control has an animation applied */ export function hasAnimation(control) { const animatioName = getComputedStyle(control, null)['animation-name']; return animatioName != 'none'; } /** * Run an action once an animation on a control ends. If the control has no animation, the action will be executed immediately. * * @param {HTMLElement} control - The control element to listen for animation end event * @param {(control:*?) => void} callback - The callback function to be executed when the animation ends */ export function runAfterAnimation(control, callback) { if (hasAnimation(control)) { const onAnimationEnd = () => { control.removeEventListener('animationend', onAnimationEnd); callback(control); }; control.addEventListener('animationend', onAnimationEnd); } else { callback(control); } } /** * A common base function for case-insensitive and accent-insensitive string comparisons. * * @param {string} a - The first string to compare. * @param {string} b - The second string to compare. * @param {(a:string,b:string)=>boolean} comparisonFunction - The function to use for the comparison. * @returns {*} - The result of the comparison. */ export function compareIgnoreCaseAndAccents(a, b, comparisonFunction) { if (!a || !b) return comparisonFunction(a, b); // Return the comparison result if either string is empty // Normalize and remove diacritics, then convert to lower case const normalizedA = a.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); const normalizedB = b.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); // Check if the normalized strings are equal return comparisonFunction(normalizedA, normalizedB); } /** * Performs a case-insensitive and accent-insensitive substring search. * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents. * * @param {string} text - The text in which to search for the substring * @param {string} searchTerm - The substring to search for in the text * @returns {boolean} true if the searchTerm is found within the text, otherwise returns false */ export function includesIgnoreCaseAndAccents(text, searchTerm) { return compareIgnoreCaseAndAccents(text, searchTerm, (a, b) => a?.includes(b) === true); } /** * Performs a case-insensitive and accent-insensitive equality check. * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents. * * @param {string} a - The first string to compare * @param {string} b - The second string to compare * @returns {boolean} true if the strings are equal, otherwise returns false */ export function equalsIgnoreCaseAndAccents(a, b) { return compareIgnoreCaseAndAccents(a, b, (a, b) => a === b); } /** * @typedef {object} Select2Option The option object for select2 controls * @property {string} id - The unique ID inside this select * @property {string} text - The text for this option * @property {number?} [count] - Optionally show the count how often that option was chosen already */ /** * Returns a unique hash as ID for a select2 option text * * @param {string} option - The option * @returns {string} A hashed version of that option */ export function getSelect2OptionId(option) { return String(getStringHash(option)); } /** * Modifies the select2 options by adding not existing one and optionally selecting them * * @param {JQuery} element - The "select" element to add the options to * @param {string[]|Select2Option[]} items - The option items to build, add or select * @param {object} [options] - Optional arguments * @param {boolean} [options.select=false] - Whether the options should be selected right away * @param {object} [options.changeEventArgs=null] - Optional event args being passed into the "change" event when its triggered because a new options is selected */ export function select2ModifyOptions(element, items, { select = false, changeEventArgs = null } = {}) { if (!items.length) return; /** @type {Select2Option[]} */ const dataItems = items.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x); const existingValues = []; dataItems.forEach(item => { // Set the value, creating a new option if necessary if (element.find('option[value=\'' + item.id + '\']').length) { if (select) existingValues.push(item.id); } else { // Create a DOM Option and optionally pre-select by default var newOption = new Option(item.text, item.id, select, select); // Append it to the select element.append(newOption); if (select) element.trigger('change', changeEventArgs); } if (existingValues.length) element.val(existingValues).trigger('change', changeEventArgs); }); } /** * Returns the ajax settings that can be used on the select2 ajax property to dynamically get the data. * Can be used on a single global array, querying data from the server or anything similar. * * @param {function():Select2Option[]} dataProvider - The provider/function to retrieve the data - can be as simple as "() => myData" for arrays * @return {{transport: (params, success, failure) => any}} The ajax object with the transport function to use on the select2 ajax property */ export function dynamicSelect2DataViaAjax(dataProvider) { function dynamicSelect2DataTransport(params, success, failure) { var items = dataProvider(); // fitering if params.data.q available if (params.data && params.data.q) { items = items.filter(function (item) { return includesIgnoreCaseAndAccents(item.text, params.data.q); }); } var promise = new Promise(function (resolve, reject) { resolve({ results: items }); }); promise.then(success); promise.catch(failure); } const ajax = { transport: dynamicSelect2DataTransport, }; return ajax; } /** * Checks whether a given control is a select2 choice element - meaning one of the results being displayed in the select multi select box * @param {JQuery|HTMLElement} element - The element to check * @returns {boolean} Whether this is a choice element */ export function isSelect2ChoiceElement(element) { const $element = $(element); return ($element.hasClass('select2-selection__choice__display') || $element.parents('.select2-selection__choice__display').length > 0); } /** * Subscribes a 'click' event handler to the choice elements of a select2 multi-select control * * @param {JQuery} control The original control the select2 was applied to * @param {function(HTMLElement):void} action - The action to execute when a choice element is clicked * @param {object} options - Optional parameters * @param {boolean} [options.buttonStyle=false] - Whether the choices should be styles as a clickable button with color and hover transition, instead of just changed cursor * @param {boolean} [options.closeDrawer=false] - Whether the drawer should be closed and focus removed after the choice item was clicked * @param {boolean} [options.openDrawer=false] - Whether the drawer should be opened, even if this click would normally close it */ export function select2ChoiceClickSubscribe(control, action, { buttonStyle = false, closeDrawer = false, openDrawer = false } = {}) { // Add class for styling (hover color, changed cursor, etc) control.addClass('select2_choice_clickable'); if (buttonStyle) control.addClass('select2_choice_clickable_buttonstyle'); // Get the real container below and create a click handler on that one const select2Container = control.next('span.select2-container'); select2Container.on('click', function (event) { const isChoice = isSelect2ChoiceElement(event.target); if (isChoice) { event.preventDefault(); // select2 still bubbles the event to open the dropdown. So we close it here and remove focus if we want that if (closeDrawer) { control.select2('close'); setTimeout(() => select2Container.find('textarea').trigger('blur'), debounce_timeout.quick); } if (openDrawer) { control.select2('open'); } // Now execute the actual action that was subscribed action(event.target); } }); } /** * Applies syntax highlighting to a given regex string by generating HTML with classes * * @param {string} regexStr - The javascript compatible regex string * @returns {string} The html representation of the highlighted regex */ export function highlightRegex(regexStr) { // Function to escape HTML special characters for safety const escapeHtml = (str) => str.replace(/[&<>"']/g, match => ({ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''', })[match]); // Replace special characters with their HTML-escaped forms regexStr = escapeHtml(regexStr); // Patterns that we want to highlight only if they are not escaped function getPatterns() { try { return { brackets: new RegExp('(? { regexStr = regexStr.replace(pattern, match => `${match}`); }; // Apply highlighting patterns wrapPattern(patterns.brackets, 'regex-brackets'); wrapPattern(patterns.quantifiers, 'regex-quantifier'); wrapPattern(patterns.operators, 'regex-operator'); wrapPattern(patterns.specialChars, 'regex-special'); wrapPattern(patterns.flags, 'regex-flags'); wrapPattern(patterns.delimiters, 'regex-delimiter'); return `${regexStr}`; } /** * Confirms if the user wants to overwrite an existing data object (like character, world info, etc) if one exists. * If no data with the name exists, this simply returns true. * * @param {string} type - The type of the check ("World Info", "Character", etc) * @param {string[]} existingNames - The list of existing names to check against * @param {string} name - The new name * @param {object} options - Optional parameters * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when needing to overwrite an existing data object * @param {string} [options.actionName='overwrite'] - The action name to display in the confirmation dialog * @param {(existingName:string)=>void} [options.deleteAction=null] - Optional action to execute wen deleting an existing data object on overwrite * @returns {Promise} 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 callPopup(`

${type} ${actionName}

A ${type.toLowerCase()} with the same name already exists:
${existing}

Do you want to overwrite it?`, 'confirm') : false; 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)}`; }