Blame Raw
Cohee · 51ad27fb · · 3112 lines (109.6 KB)
3 contributors
1import {
2 moment,
3 DOMPurify,
4 Readability,
5 isProbablyReaderable,
6 lodash,
7} from '../lib.js';
8
9import { getContext } from './extensions.js';
10import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js';
11import { isMobile } from './RossAscends-mods.js';
12import { collapseNewlines, power_user } from './power-user.js';
13import { debounce_timeout } from './constants.js';
14import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
15import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
16import { getTagsList } from './tags.js';
17import { groups, selected_group } from './group-chats.js';
18import { getCurrentLocale, t } from './i18n.js';
19import { importWorldInfo } from './world-info.js';
20
21export const shiftUpByOne = (e, i, a) => a[i] = e + 1;
22export const shiftDownByOne = (e, i, a) => a[i] = e - 1;
23
24/**
25 * Pagination status string template.
26 * @type {string}
27 */
28export const PAGINATION_TEMPLATE = '<%= rangeStart %>-<%= rangeEnd %> .. <%= totalNumber %>';
29
30export const localizePagination = function (container) {
31 container.find('[title="Next page"]').attr('title', t`Next page`);
32 container.find('[title="Previous page"]').attr('title', t`Previous page`);
33 container.find('[title="First page"]').attr('title', t`First page`);
34 container.find('[title="Last page"]').attr('title', t`Last page`);
35};
36
37/**
38 * Checks if the current environment supports negative lookbehind in regular expressions.
39 * @type {{ (): boolean; result?: boolean }} Defines the function as a memoized object with a cached result.
40 * @returns {boolean} True if negative lookbehind is supported, false otherwise.
41 */
42export function canUseNegativeLookbehind() {
43 /**
44 * A reference to the function itself, typed as a callable object with a cache property.
45 * @type {{ (): boolean; result?: boolean }}
46 */
47 const fn = canUseNegativeLookbehind;
48 let result = fn.result;
49 if (typeof result !== 'boolean') {
50 try {
51 new RegExp('(?<!_)');
52 result = true;
53 } catch (e) {
54 result = false;
55 }
56 fn.result = result;
57 }
58 return result;
59}
60
61/**
62 * Renders a dropdown for selecting page size in pagination.
63 * @param {number} pageSize Page size
64 * @param {number[]} sizeChangerOptions Array of page size options
65 * @returns {string} The rendered dropdown element as a string
66 */
67export const renderPaginationDropdown = function (pageSize, sizeChangerOptions) {
68 const sizeSelect = document.createElement('select');
69 sizeSelect.classList.add('J-paginationjs-size-select');
70
71 if (sizeChangerOptions.indexOf(pageSize) === -1) {
72 sizeChangerOptions.unshift(pageSize);
73 sizeChangerOptions.sort((a, b) => a - b);
74 }
75
76 for (let i = 0; i < sizeChangerOptions.length; i++) {
77 const option = document.createElement('option');
78 option.value = `${sizeChangerOptions[i]}`;
79 option.textContent = `${sizeChangerOptions[i]} ${t`/ page`}`;
80 if (sizeChangerOptions[i] === pageSize) {
81 option.setAttribute('selected', 'selected');
82 }
83 sizeSelect.appendChild(option);
84 }
85
86 return sizeSelect.outerHTML;
87};
88
89export const paginationDropdownChangeHandler = function (event, size) {
90 let dropdown = $(event?.originalEvent?.currentTarget || event.delegateTarget).find('select');
91 dropdown.find('[selected]').removeAttr('selected');
92 dropdown.find(`[value=${size}]`).attr('selected', '');
93};
94
95/**
96 * Navigation options for pagination.
97 * @enum {number}
98 */
99export const navigation_option = {
100 none: -2000,
101 previous: -1000,
102};
103
104/**
105 * Determines if a value is an object.
106 * @param {any} item The item to check.
107 * @returns {boolean} True if the item is an object, false otherwise.
108 */
109export function isObject(item) {
110 return (item && typeof item === 'object' && !Array.isArray(item));
111}
112
113/**
114 * Merges properties of two objects. If the property is an object, it will be merged recursively.
115 * @param {object} target The target object
116 * @param {object} source The source object
117 * @returns {object} Merged object
118 */
119export function deepMerge(target, source) {
120 let output = Object.assign({}, target);
121 if (isObject(target) && isObject(source)) {
122 Object.keys(source).forEach(key => {
123 if (isObject(source[key])) {
124 if (!(key in target))
125 Object.assign(output, { [key]: source[key] });
126 else
127 output[key] = deepMerge(target[key], source[key]);
128 } else {
129 Object.assign(output, { [key]: source[key] });
130 }
131 });
132 }
133 return output;
134}
135
136/**
137 * Ensures that the provided object is a plain object.
138 * @param {object} obj Object to ensure is a plain object
139 * @return {object} A plain object, or an empty object if the input is not an object.
140 */
141export function ensurePlainObject(obj) {
142 if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
143 return {};
144 }
145
146 return obj;
147}
148
149/**
150 * Escapes text for safe HTML rendering.
151 * @param {string?} str
152 * @returns {string}
153 */
154export function escapeHtml(str) {
155 return String(str ?? '')
156 .replace(/&/g, '&amp;')
157 .replace(/</g, '&lt;')
158 .replace(/>/g, '&gt;')
159 .replace(/"/g, '&quot;')
160 .replace(/'/g, '&#39;');
161}
162
163/**
164 * Make string safe for use as a CSS selector.
165 * @param {string} str String to sanitize
166 * @param {string} replacement Replacement for invalid characters
167 * @returns {string} Sanitized string
168 */
169export function sanitizeSelector(str, replacement = '_') {
170 return String(str).replace(/[^a-z0-9_-]/ig, replacement);
171}
172
173export function isValidUrl(value) {
174 try {
175 new URL(value);
176 return true;
177 } catch (_) {
178 return false;
179 }
180}
181
182/**
183 * Checks if a URL is external to the current domain.
184 * @param {string} url URL to check
185 * @returns {boolean} True if the URL is external, false otherwise
186 */
187export function isExternalUrl(url) {
188 return (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin);
189}
190
191/**
192 * Checks if a string is a valid UUID (version 1-5).
193 * @param {string} value String to check
194 * @returns {boolean} True if the string is a valid UUID, false otherwise.
195 */
196export function isUuid(value) {
197 // Regular expression to match UUIDs
198 const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
199 return uuidRegex.test(value);
200}
201
202/**
203 * Converts string to a value of a given type. Includes pythonista-friendly aliases.
204 * @param {string|SlashCommandClosure} value String value
205 * @param {string} type Type to convert to
206 * @returns {any} Converted value
207 */
208export function convertValueType(value, type) {
209 if (value instanceof SlashCommandClosure || typeof type !== 'string') {
210 return value;
211 }
212
213 switch (type.trim().toLowerCase()) {
214 case 'string':
215 case 'str':
216 return String(value);
217
218 case 'null':
219 return null;
220
221 case 'undefined':
222 case 'none':
223 return undefined;
224
225 case 'number':
226 return Number(value);
227
228 case 'int':
229 return parseInt(value, 10);
230
231 case 'float':
232 return parseFloat(value);
233
234 case 'boolean':
235 case 'bool':
236 return isTrueBoolean(value);
237
238 case 'list':
239 case 'array':
240 try {
241 const parsedArray = JSON.parse(value);
242 if (Array.isArray(parsedArray)) {
243 return parsedArray;
244 }
245 // The value is not an array
246 return [];
247 } catch {
248 return [];
249 }
250
251 case 'object':
252 case 'dict':
253 case 'dictionary':
254 try {
255 const parsedObject = JSON.parse(value);
256 if (typeof parsedObject === 'object') {
257 return parsedObject;
258 }
259 // The value is not an object
260 return {};
261 } catch {
262 return {};
263 }
264
265 default:
266 return value;
267 }
268}
269
270/**
271 * Parses ranges like 10-20 or 10.
272 * Range is inclusive. Start must be less than end.
273 * Returns null if invalid.
274 * @param {string} input The input string.
275 * @param {number} min The minimum value.
276 * @param {number} max The maximum value.
277 * @returns {{ start: number, end: number }} The parsed range.
278 */
279export function stringToRange(input, min, max) {
280 let start, end;
281
282 if (typeof input !== 'string') {
283 input = String(input);
284 }
285
286 if (input.includes('-')) {
287 const parts = input.split('-');
288 start = parts[0] ? parseInt(parts[0], 10) : NaN;
289 end = parts[1] ? parseInt(parts[1], 10) : NaN;
290 } else {
291 start = end = parseInt(input, 10);
292 }
293
294 if (isNaN(start) || isNaN(end) || start > end || start < min || end > max) {
295 return null;
296 }
297
298 return { start, end };
299}
300
301/**
302 * Determines if a value is unique in an array.
303 * @param {any} value Current value.
304 * @param {number} index Current index.
305 * @param {any} array The array being processed.
306 * @returns {boolean} True if the value is unique, false otherwise.
307 */
308export function onlyUnique(value, index, array) {
309 return array.indexOf(value) === index;
310}
311
312/**
313 * Determines if a value is unique in an array of objects.
314 * @param {any} value Current value.
315 * @param {number} index Current index.
316 * @param {any[]} array The array being processed.
317 * @returns {boolean} True if the value is unique, false otherwise.
318 */
319export function onlyUniqueJson(value, index, array) {
320 return array.map(v => JSON.stringify(v)).indexOf(JSON.stringify(value)) === index;
321}
322
323/**
324 * Removes the first occurrence of a specified item from an array
325 *
326 * @param {*[]} array - The array from which to remove the item
327 * @param {*} item - The item to remove from the array
328 * @returns {boolean} - Returns true if the item was successfully removed, false otherwise.
329 */
330export function removeFromArray(array, item) {
331 const index = array.indexOf(item);
332 if (index === -1) return false;
333 array.splice(index, 1);
334 return true;
335}
336
337/**
338 * Normalizes an array by removing duplicates, trimming strings, and filtering out empty values.
339 * @param {any[]} arr - The array to normalize.
340 * @returns {any[]} The normalized array.
341 */
342export function normalizeArray(arr) {
343 return [...new Set((arr ?? []).map(s => typeof s === 'string' ? s.trim() : s).filter(Boolean))];
344}
345
346/**
347 * Checks if a string only contains digits.
348 * @param {string} str The string to check.
349 * @returns {boolean} True if the string only contains digits, false otherwise.
350 * @example
351 * isDigitsOnly('123'); // true
352 * isDigitsOnly('abc'); // false
353 */
354export function isDigitsOnly(str) {
355 return /^\d+$/.test(str);
356}
357
358/**
359 * Gets a drag delay for sortable elements. This is to prevent accidental drags when scrolling.
360 * @returns {number} The delay in milliseconds. 50ms for desktop, 750ms for mobile.
361 */
362export function getSortableDelay() {
363 return isMobile() ? 750 : 50;
364}
365
366export async function bufferToBase64(buffer) {
367 // use a FileReader to generate a base64 data URI:
368 const base64url = await new Promise(resolve => {
369 const reader = new FileReader();
370 reader.onload = () => resolve(reader.result);
371 reader.readAsDataURL(new Blob([buffer]));
372 });
373 // remove the `data:...;base64,` part from the start
374 return base64url.slice(base64url.indexOf(',') + 1);
375}
376
377/**
378 * Rearranges an array in a random order.
379 * @param {any[]} array The array to shuffle.
380 * @returns {any[]} The shuffled array.
381 * @example
382 * shuffle([1, 2, 3]); // [2, 3, 1]
383 */
384export function shuffle(array) {
385 let currentIndex = array.length,
386 randomIndex;
387
388 while (currentIndex != 0) {
389 randomIndex = Math.floor(Math.random() * currentIndex);
390 currentIndex--;
391 [array[currentIndex], array[randomIndex]] = [
392 array[randomIndex],
393 array[currentIndex],
394 ];
395 }
396 return array;
397}
398
399/**
400 * Downloads a file to the user's devices.
401 * @param {BlobPart} content File content to download.
402 * @param {string} fileName File name.
403 * @param {string} contentType File content type.
404 */
405export function download(content, fileName, contentType) {
406 const a = document.createElement('a');
407 const file = new Blob([content], { type: contentType });
408 a.href = URL.createObjectURL(file);
409 a.download = fileName;
410 a.click();
411 URL.revokeObjectURL(a.href);
412}
413
414/**
415 * Fetches a file by URL and parses its contents as data URI.
416 * @param {string} url The URL to fetch.
417 * @param {any} params Fetch parameters.
418 * @returns {Promise<string>} A promise that resolves to the data URI.
419 */
420export async function urlContentToDataUri(url, params) {
421 const response = await fetch(url, params);
422 const blob = await response.blob();
423 return await new Promise((resolve, reject) => {
424 const reader = new FileReader();
425 reader.onload = function () {
426 resolve(String(reader.result));
427 };
428 reader.onerror = function (error) {
429 reject(error);
430 };
431 reader.readAsDataURL(blob);
432 });
433}
434
435/**
436 * Fuzzily compares two files for equality. Only checks attributes, not contents.
437 * @param {File} a First file
438 * @param {File} b Second file
439 * @returns {boolean} True if the files are probably the same, false otherwise.
440 */
441export function isSameFile(a, b) {
442 return a.lastModified === b.lastModified && a.name === b.name && a.size === b.size && a.type === b.type;
443}
444
445/**
446 * Returns a promise that resolves to the file's text.
447 * @param {Blob} file The file to read.
448 * @returns {Promise<string>} A promise that resolves to the file's text.
449 */
450export function getFileText(file) {
451 return new Promise((resolve, reject) => {
452 const reader = new FileReader();
453 reader.readAsText(file);
454 reader.onload = function () {
455 resolve(String(reader.result));
456 };
457 reader.onerror = function (error) {
458 reject(error);
459 };
460 });
461}
462
463/**
464 * Returns a promise that resolves to the file's array buffer.
465 * @param {Blob} file The file to read.
466 */
467export function getFileBuffer(file) {
468 return new Promise((resolve, reject) => {
469 const reader = new FileReader();
470 reader.readAsArrayBuffer(file);
471 reader.onload = function () {
472 resolve(reader.result);
473 };
474 reader.onerror = function (error) {
475 reject(error);
476 };
477 });
478}
479
480/**
481 * Returns a promise that resolves to the base64 encoded string of a file.
482 * @param {Blob} file The file to read.
483 * @returns {Promise<string>} A promise that resolves to the base64 encoded string.
484 */
485export function getBase64Async(file) {
486 return new Promise((resolve, reject) => {
487 const reader = new FileReader();
488 reader.readAsDataURL(file);
489 reader.onload = function () {
490 resolve(String(reader.result));
491 };
492 reader.onerror = function (error) {
493 reject(error);
494 };
495 });
496}
497
498/**
499 * Parses a file blob as a JSON object.
500 * @param {Blob} file The file to read.
501 * @returns {Promise<any>} A promise that resolves to the parsed JSON object.
502 */
503export async function parseJsonFile(file) {
504 return new Promise((resolve, reject) => {
505 const fileReader = new FileReader();
506 fileReader.readAsText(file);
507 fileReader.onload = event => resolve(JSON.parse(String(event.target.result)));
508 fileReader.onerror = error => reject(error);
509 });
510}
511
512/**
513 * Calculates a hash code for a string.
514 * cyrb53 (c) 2018 bryc ({@link https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js|github.com/bryc})
515 * License: Public domain (or MIT if needed). Attribution appreciated.
516 * A fast and simple 53-bit string hash function with decent collision resistance.
517 * Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
518 * @param {string} str The string to hash.
519 * @param {number} [seed=0] The seed to use for the hash.
520 * @returns {number} The hash code.
521 */
522export function getStringHash(str, seed = 0) {
523 if (typeof str !== 'string') {
524 return 0;
525 }
526
527 let h1 = 0xdeadbeef ^ seed,
528 h2 = 0x41c6ce57 ^ seed;
529 for (let i = 0, ch; i < str.length; i++) {
530 ch = str.charCodeAt(i);
531 h1 = Math.imul(h1 ^ ch, 2654435761);
532 h2 = Math.imul(h2 ^ ch, 1597334677);
533 }
534
535 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
536 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
537
538 return 4294967296 * (2097151 & h2) + (h1 >>> 0);
539}
540
541/**
542 * Copy text to clipboard. Use navigator.clipboard.writeText if available, otherwise use document.execCommand.
543 * @param {string} text - The text to copy to the clipboard.
544 * @returns {Promise<void>} A promise that resolves when the text has been copied to the clipboard.
545 */
546export function copyText(text) {
547 if (navigator.clipboard) {
548 return navigator.clipboard.writeText(text);
549 }
550
551 const parent = document.querySelector('dialog[open]:last-of-type') ?? document.body;
552 const textArea = document.createElement('textarea');
553 textArea.value = text;
554 parent.appendChild(textArea);
555 textArea.focus();
556 textArea.select();
557 document.execCommand('copy');
558 parent.removeChild(textArea);
559}
560
561/**
562 * Map of debounced functions to their timers.
563 * Weak map is used to avoid memory leaks.
564 * @type {WeakMap<function, any>}
565 */
566const debounceMap = new WeakMap();
567
568/**
569 * Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked.
570 * @param {function} func The function to debounce.
571 * @param {debounce_timeout|number} [timeout=debounce_timeout.default] The timeout based on the common enum values, or in milliseconds.
572 * @returns {function} The debounced function.
573 */
574export function debounce(func, timeout = debounce_timeout.standard) {
575 let timer;
576 let fn = (...args) => {
577 clearTimeout(timer);
578 timer = setTimeout(() => { func.apply(this, args); }, timeout);
579 debounceMap.set(func, timer);
580 debounceMap.set(fn, timer);
581 };
582
583 return fn;
584}
585
586/**
587 * Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked.
588 * @param {Function} func The function to debounce.
589 * @param {Number} [timeout=300] The timeout in milliseconds.
590 * @returns {Function} The debounced function.
591 */
592export function debounceAsync(func, timeout = debounce_timeout.standard) {
593 let timer;
594 /**@type {Promise}*/
595 let debouncePromise;
596 /**@type {Function}*/
597 let debounceResolver;
598 return (...args) => {
599 clearTimeout(timer);
600 if (!debouncePromise) {
601 debouncePromise = new Promise(resolve => {
602 debounceResolver = resolve;
603 });
604 }
605 timer = setTimeout(() => {
606 debounceResolver(func.apply(this, args));
607 debouncePromise = null;
608 }, timeout);
609 return debouncePromise;
610 };
611}
612
613/**
614 * Cancels a scheduled debounced function.
615 * Does nothing if the function is not debounced or not scheduled.
616 * @param {function} func The function to cancel. Either the original or the debounced function.
617 */
618export function cancelDebounce(func) {
619 if (debounceMap.has(func)) {
620 clearTimeout(debounceMap.get(func));
621 debounceMap.delete(func);
622 }
623}
624
625/**
626 * Creates a throttled function that only invokes func at most once per every limit milliseconds.
627 * @param {function} func The function to throttle.
628 * @param {number} [limit=300] The limit in milliseconds.
629 * @returns {function} The throttled function.
630 */
631export function throttle(func, limit = 300) {
632 let lastCall;
633 return (...args) => {
634 const now = Date.now();
635 if (!lastCall || (now - lastCall) >= limit) {
636 lastCall = now;
637 func.apply(this, args);
638 }
639 };
640}
641
642/**
643 * Creates a debounced throttle function that only invokes func at most once per every limit milliseconds.
644 * @param {function} func The function to throttle.
645 * @param {number} [limit=300] The limit in milliseconds.
646 * @returns {function} The throttled function.
647 */
648export function debouncedThrottle(func, limit = 300) {
649 let last, deferTimer;
650 let db = debounce(func);
651
652 return function () {
653 let now = +new Date, args = arguments;
654 if (!last || (last && now < last + limit)) {
655 clearTimeout(deferTimer);
656 db.apply(this, args);
657 deferTimer = setTimeout(function () {
658 last = now;
659 func.apply(this, args);
660 }, limit);
661 } else {
662 last = now;
663 func.apply(this, args);
664 }
665 };
666}
667
668/**
669 * Checks if an element is in the viewport.
670 * @param {Element} el The element to check.
671 * @returns {boolean} True if the element is in the viewport, false otherwise.
672 */
673export function isElementInViewport(el) {
674 if (!el) {
675 return false;
676 }
677 if (typeof jQuery === 'function' && el instanceof jQuery) {
678 el = el[0];
679 }
680 var rect = el.getBoundingClientRect();
681 return (
682 rect.top >= 0 &&
683 rect.left >= 0 &&
684 rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */
685 rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */
686 );
687}
688
689/**
690 * Returns a name that is unique among the names that exist.
691 * @param {string} baseName The name to check.
692 * @param {{ (name: string): boolean; }} exists Function to check if name exists.
693 * @param {Object} [options] The options.
694 * @param {((baseName: string, i: number) => string)|null} [options.nameBuilder=null] Function to build the name.
695 * Starts with the index provided by `startIndex` (default is 1). If not provided, uses "${baseName} (${i})".
696 * @param {number} [options.maxTries=1000] The maximum number of tries to find a unique name. Default is 1000.
697 * @param {number} [options.startIndex=1] The index to start with when building the name. Default is 1.
698 * When set to 0, the intention is to also check if the basename (without applied index) is free.
699 * @returns {string|null} A unique name. Null if no unique name could be found in `maxTries`.
700 */
701export function getUniqueName(baseName, exists, { nameBuilder = null, maxTries = 1000, startIndex = 1 } = {}) {
702 nameBuilder ??= (baseName, i) => i === 0 ? baseName : `${baseName} (${i})`;
703 let i = startIndex;
704 let name;
705 while (i < maxTries + startIndex) {
706 name = nameBuilder(baseName, i);
707 if (!exists(name)) {
708 return name;
709 }
710 i++;
711 }
712 return null;
713}
714
715/**
716 * Returns a promise that resolves after the specified number of milliseconds.
717 * @param {number} ms The number of milliseconds to wait.
718 * @returns {Promise<void>} A promise that resolves after the specified number of milliseconds.
719 */
720export function delay(ms) {
721 return new Promise((res) => setTimeout(res, ms));
722}
723
724/**
725 * Checks if an array is a subset of another array.
726 * @param {any[]} a Array A
727 * @param {any[]} b Array B
728 * @returns {boolean} True if B is a subset of A, false otherwise.
729 */
730export function isSubsetOf(a, b) {
731 return (Array.isArray(a) && Array.isArray(b)) ? b.every(val => a.includes(val)) : false;
732}
733
734/**
735 * Increments the trailing number in a string.
736 * @param {string} str The string to process.
737 * @returns {string} The string with the trailing number incremented by 1.
738 * @example
739 * incrementString('Hello, world! 1'); // 'Hello, world! 2'
740 */
741export function incrementString(str) {
742 // Find the trailing number or it will match the empty string
743 const count = str.match(/\d*$/);
744
745 // Take the substring up until where the integer was matched
746 // Concatenate it to the matched count incremented by 1
747 return str.substring(0, count.index) + (Number(count[0]) + 1);
748}
749
750/**
751 * Formats a string using the specified arguments.
752 * @param {string} format The format string.
753 * @returns {string} The formatted string.
754 * @example
755 * stringFormat('Hello, {0}!', 'world'); // 'Hello, world!'
756 */
757export function stringFormat(format) {
758 const args = Array.prototype.slice.call(arguments, 1);
759 return format.replace(/{(\d+)}/g, function (match, number) {
760 return typeof args[number] != 'undefined'
761 ? args[number]
762 : match;
763 });
764}
765
766/**
767 * Save the caret position in a contenteditable element.
768 * @param {Element} element The element to save the caret position of.
769 * @returns {{ start: number, end: number }} An object with the start and end offsets of the caret.
770 */
771export function saveCaretPosition(element) {
772 // Get the current selection
773 const selection = window.getSelection();
774
775 // If the selection is empty, return null
776 if (selection.rangeCount === 0) {
777 return null;
778 }
779
780 // Get the range of the current selection
781 const range = selection.getRangeAt(0);
782
783 // If the range is not within the specified element, return null
784 if (!element.contains(range.commonAncestorContainer)) {
785 return null;
786 }
787
788 // Return an object with the start and end offsets of the range
789 const position = {
790 start: range.startOffset,
791 end: range.endOffset,
792 };
793
794 console.debug('Caret saved', position);
795
796 return position;
797}
798
799/**
800 * Restore the caret position in a contenteditable element.
801 * @param {Element} element The element to restore the caret position of.
802 * @param {{ start: any; end: any; }} position An object with the start and end offsets of the caret.
803 */
804export function restoreCaretPosition(element, position) {
805 // If the position is null, do nothing
806 if (!position) {
807 return;
808 }
809
810 console.debug('Caret restored', position);
811
812 // Create a new range object
813 const range = new Range();
814
815 // Set the start and end positions of the range within the element
816 range.setStart(element.childNodes[0], position.start);
817 range.setEnd(element.childNodes[0], position.end);
818
819 // Create a new selection object and set the range
820 const selection = window.getSelection();
821 selection.removeAllRanges();
822 selection.addRange(range);
823}
824
825export async function resetScrollHeight(element) {
826 $(element).css('height', '0px');
827 $(element).css('height', $(element).prop('scrollHeight') + 3 + 'px');
828}
829
830/**
831 * Sets the height of an element to its scroll height.
832 * @param {JQuery<HTMLElement>} element The element to initialize the scroll height of.
833 * @returns {Promise<void>} A promise that resolves when the scroll height has been initialized.
834 */
835export async function initScrollHeight(element) {
836 await delay(1);
837
838 const curHeight = Number($(element).css('height').replace('px', ''));
839 const curScrollHeight = Number($(element).prop('scrollHeight'));
840 const diff = curScrollHeight - curHeight;
841
842 if (diff < 3) { return; } //happens when the div isn't loaded yet
843
844 const newHeight = curHeight + diff + 3; //the +3 here is to account for padding/line-height on text inputs
845 //console.log(`init height to ${newHeight}`);
846 $(element).css('height', '');
847 $(element).css('height', `${newHeight}px`);
848 //resetScrollHeight(element);
849}
850
851/**
852 * Compares elements by their CSS order property. Used for sorting.
853 * @param {any} a The first element.
854 * @param {any} b The second element.
855 * @returns {number} A negative number if a is before b, a positive number if a is after b, or 0 if they are equal.
856 */
857export function sortByCssOrder(a, b) {
858 const _a = Number($(a).css('order'));
859 const _b = Number($(b).css('order'));
860 return _a - _b;
861}
862
863/**
864 * Trims leading and trailing whitespace from the input string based on a configuration setting.
865 * @param {string} input - The string to be trimmed
866 * @returns {string} The trimmed string if trimming is enabled; otherwise, returns the original string
867 */
868
869export function trimSpaces(input) {
870 if (!input || typeof input !== 'string') {
871 return input;
872 }
873 return power_user.trim_spaces ? input.trim() : input;
874}
875
876/**
877 * Trims a string to the end of a nearest sentence.
878 * @param {string} input The string to trim.
879 * @returns {string} The trimmed string.
880 * @example
881 * trimToEndSentence('Hello, world! I am from'); // 'Hello, world!'
882 */
883export function trimToEndSentence(input) {
884 if (!input) {
885 return '';
886 }
887
888 const isEmoji = x => /(\p{Emoji_Presentation}|\p{Extended_Pictographic})/gu.test(x);
889 const punctuation = new Set(['.', '!', '?', '*', '"', ')', '}', '`', ']', '$', '。', '!', '?', '”', ')', '】', '’', '」', '_']); // extend this as you see fit
890 let last = -1;
891
892 const characters = Array.from(input);
893 for (let i = characters.length - 1; i >= 0; i--) {
894 const char = characters[i];
895 const emoji = isEmoji(char);
896
897 if (punctuation.has(char) || emoji) {
898 if (!emoji && i > 0 && /[\s\n]/.test(characters[i - 1])) {
899 last = i - 1;
900 } else {
901 last = i;
902 }
903 break;
904 }
905 }
906
907 if (last === -1) {
908 return input.trimEnd();
909 }
910
911 return characters.slice(0, last + 1).join('').trimEnd();
912}
913
914export function trimToStartSentence(input) {
915 if (!input) {
916 return '';
917 }
918
919 let p1 = input.indexOf('.');
920 let p2 = input.indexOf('!');
921 let p3 = input.indexOf('?');
922 let p4 = input.indexOf('\n');
923 let first = p1;
924 let skip1 = false;
925 if (p2 > 0 && p2 < first) { first = p2; }
926 if (p3 > 0 && p3 < first) { first = p3; }
927 if (p4 > 0 && p4 < first) { first = p4; skip1 = true; }
928 if (first > 0) {
929 if (skip1) {
930 return input.substring(first + 1);
931 } else {
932 return input.substring(first + 2);
933 }
934 }
935 return input;
936}
937
938/**
939 * Format bytes as human-readable text.
940 *
941 * @param bytes Number of bytes.
942 * @param si True to use metric (SI) units, aka powers of 1000. False to use
943 * binary (IEC), aka powers of 1024.
944 * @param dp Number of decimal places to display.
945 *
946 * @return Formatted string.
947 */
948export function humanFileSize(bytes, si = false, dp = 1) {
949 const thresh = si ? 1000 : 1024;
950
951 if (Math.abs(bytes) < thresh) {
952 return bytes + ' B';
953 }
954
955 const units = si
956 ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
957 : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
958 let u = -1;
959 const r = 10 ** dp;
960
961 do {
962 bytes /= thresh;
963 ++u;
964 } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
965
966
967 return bytes.toFixed(dp) + ' ' + units[u];
968}
969
970/**
971 * Formats time in seconds to MM:SS format
972 * @param {number} seconds - Time in seconds
973 * @returns {string} Formatted time string
974 */
975export function formatTime(seconds) {
976 if (!isFinite(seconds) || isNaN(seconds)) {
977 return '0:00';
978 }
979
980 const minutes = Math.floor(seconds / 60);
981 const secs = Math.floor(seconds % 60);
982 return `${minutes}:${secs.toString().padStart(2, '0')}`;
983}
984
985/**
986 * Counts the number of occurrences of a character in a string.
987 * @param {string} string The string to count occurrences in.
988 * @param {string} character The character to count occurrences of.
989 * @returns {number} The number of occurrences of the character in the string.
990 * @example
991 * countOccurrences('Hello, world!', 'l'); // 3
992 * countOccurrences('Hello, world!', 'x'); // 0
993 */
994export function countOccurrences(string, character) {
995 let count = 0;
996
997 for (let i = 0; i < string.length; i++) {
998 if (string.substring(i, i + character.length) === character) {
999 count++;
1000 }
1001 }
1002
1003 return count;
1004}
1005
1006/**
1007 * Checks if a string is "true" value.
1008 * @param {string} arg String to check
1009 * @returns {boolean} True if the string is true, false otherwise.
1010 */
1011export function isTrueBoolean(arg) {
1012 return ['on', 'true', '1'].includes(arg?.trim()?.toLowerCase());
1013}
1014
1015/**
1016 * Checks if a string is "false" value.
1017 * @param {string} arg String to check
1018 * @returns {boolean} True if the string is false, false otherwise.
1019 */
1020export function isFalseBoolean(arg) {
1021 return ['off', 'false', '0'].includes(arg?.trim()?.toLowerCase());
1022}
1023
1024/**
1025 * Parses an array either as a comma-separated string or as a JSON array.
1026 * @param {string} value String to parse
1027 * @returns {string[]} The parsed array.
1028 */
1029export function parseStringArray(value) {
1030 if (!value || typeof value !== 'string') return [];
1031
1032 try {
1033 const parsedValue = JSON.parse(value);
1034 if (!Array.isArray(parsedValue)) {
1035 throw new Error('Not an array');
1036 }
1037 return parsedValue.map(x => String(x));
1038 } catch (e) {
1039 return value.split(',').map(x => x.trim()).filter(x => x);
1040 }
1041}
1042
1043/**
1044 * Checks if a number is odd.
1045 * @param {number} number The number to check.
1046 * @returns {boolean} True if the number is odd, false otherwise.
1047 * @example
1048 * isOdd(3); // true
1049 * isOdd(4); // false
1050 */
1051export function isOdd(number) {
1052 return number % 2 !== 0;
1053}
1054
1055/**
1056 * Compare two moment objects for sorting.
1057 * @param {import('moment').Moment} a The first moment object.
1058 * @param {import('moment').Moment} b The second moment object.
1059 * @returns {number} A negative number if a is before b, a positive number if a is after b, or 0 if they are equal.
1060 */
1061export function sortMoments(a, b) {
1062 if (a.isBefore(b)) {
1063 return 1;
1064 } else if (a.isAfter(b)) {
1065 return -1;
1066 } else {
1067 return 0;
1068 }
1069}
1070
1071const dateCache = new Map();
1072
1073/**
1074 * Cached version of moment() to avoid re-parsing the same date strings.
1075 * Important: Moment objects are mutable, so use clone() before modifying them!
1076 * @param {MessageTimestamp} timestamp String or number representing a date.
1077 * @returns {import('moment').Moment} Moment object
1078 */
1079export function timestampToMoment(timestamp) {
1080 if (dateCache.has(timestamp)) {
1081 return dateCache.get(timestamp);
1082 }
1083
1084 const iso8601 = parseTimestamp(timestamp);
1085 const objMoment = iso8601 ? moment(iso8601).locale(getCurrentLocale()) : moment.invalid();
1086
1087 dateCache.set(timestamp, objMoment);
1088 return objMoment;
1089}
1090
1091/**
1092 * Parses a timestamp and returns a moment object representing the parsed date and time.
1093 * @param {MessageTimestamp} timestamp - The timestamp to parse. It can be a string or a number.
1094 * @returns {string} - If the timestamp is valid, returns an ISO 8601 string.
1095 */
1096function parseTimestamp(timestamp) {
1097 if (!timestamp) return;
1098
1099 // Date object
1100 if (timestamp instanceof Date) {
1101 return timestamp.toISOString();
1102 }
1103
1104 // Unix time (legacy TAI / tags)
1105 if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
1106 const unixTime = Number(timestamp);
1107 const isValid = Number.isFinite(unixTime) && !Number.isNaN(unixTime) && unixTime >= 0;
1108 if (!isValid) return;
1109 return new Date(unixTime).toISOString();
1110 }
1111
1112 // ISO 8601
1113 if (moment(timestamp, moment.ISO_8601, true).isValid()) {
1114 return timestamp;
1115 }
1116
1117 let dtFmt = [];
1118
1119 // meridiem-based format
1120 const convertFromMeridiemBased = (_, month, day, year, hour, minute, meridiem) => {
1121 const monthNum = moment().month(month).format('MM');
1122 const hour24 = meridiem.toLowerCase() === 'pm' ? (parseInt(hour, 10) % 12) + 12 : parseInt(hour, 10) % 12;
1123 return `${year}-${monthNum}-${day.padStart(2, '0')}T${hour24.toString().padStart(2, '0')}:${minute.padStart(2, '0')}:00`;
1124 };
1125 // June 19, 2023 2:20pm
1126 dtFmt.push({ callback: convertFromMeridiemBased, pattern: /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i });
1127
1128 // ST "humanized" format patterns
1129 const convertFromHumanized = (_, year, month, day, hour, min, sec, ms) => {
1130 ms = typeof ms !== 'undefined' ? `.${ms.padStart(3, '0')}` : '';
1131 return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}${ms}Z`;
1132 };
1133 // 2024-07-12@01h31m37s123ms
1134 dtFmt.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s(\d{1,3})ms/ });
1135 // 2024-7-12@01h31m37s
1136 dtFmt.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s/ });
1137 // 2024-6-5 @14h 56m 50s 682ms
1138 dtFmt.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/ });
1139
1140 for (const x of dtFmt) {
1141 let rgxMatch = timestamp.match(x.pattern);
1142 if (!rgxMatch) continue;
1143 return x.callback(...rgxMatch);
1144 }
1145
1146 return;
1147}
1148
1149/** Split string to parts no more than length in size.
1150 * @param {string} input The string to split.
1151 * @param {number} length The maximum length of each part.
1152 * @param {string[]} delimiters The delimiters to use when splitting the string.
1153 * @returns {string[]} The split string.
1154 * @example
1155 * splitRecursive('Hello, world!', 3); // ['Hel', 'lo,', 'wor', 'ld!']
1156*/
1157export function splitRecursive(input, length, delimiters = ['\n\n', '\n', ' ', '']) {
1158 // Invalid length
1159 if (length <= 0) {
1160 return [input];
1161 }
1162
1163 const delim = delimiters[0] ?? '';
1164 const parts = input.split(delim);
1165
1166 const flatParts = parts.flatMap(p => {
1167 if (p.length < length) return p;
1168 return splitRecursive(p, length, delimiters.slice(1));
1169 });
1170
1171 // Merge short chunks
1172 const result = [];
1173 let currentChunk = '';
1174 for (let i = 0; i < flatParts.length;) {
1175 currentChunk = flatParts[i];
1176 let j = i + 1;
1177 while (j < flatParts.length) {
1178 const nextChunk = flatParts[j];
1179 if (currentChunk.length + nextChunk.length + delim.length <= length) {
1180 currentChunk += delim + nextChunk;
1181 } else {
1182 break;
1183 }
1184 j++;
1185 }
1186 i = j;
1187 result.push(currentChunk);
1188 }
1189 return result;
1190}
1191
1192/**
1193 * Checks if a string is a valid data URL.
1194 * @param {string} str The string to check.
1195 * @returns {boolean} True if the string is a valid data URL, false otherwise.
1196 * @example
1197 * isDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...'); // true
1198 */
1199export function isDataURL(str) {
1200 const regex = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)*;?)?(base64)?,([a-z0-9!$&',()*+;=\-_%.~:@/?#]+)?$/i;
1201 return typeof str === 'string' && regex.test(str);
1202}
1203
1204/**
1205 * Gets the size of an image from a data URL.
1206 * @param {string} dataUrl Image data URL
1207 * @returns {Promise<{ width: number, height: number }>} Image size
1208 */
1209export function getImageSizeFromDataURL(dataUrl) {
1210 const image = new Image();
1211 image.src = dataUrl;
1212 return new Promise((resolve, reject) => {
1213 image.onload = function () {
1214 resolve({ width: image.width, height: image.height });
1215 };
1216 image.onerror = function () {
1217 reject(new Error('Failed to load image'));
1218 };
1219 });
1220}
1221
1222/**
1223 * Gets the duration of a video from a data URL.
1224 * @param {string} dataUrl Video data URL
1225 * @returns {Promise<number>} Duration in seconds
1226 */
1227export function getVideoDurationFromDataURL(dataUrl) {
1228 const video = document.createElement('video');
1229 video.src = dataUrl;
1230 return new Promise((resolve, reject) => {
1231 video.onloadedmetadata = function () {
1232 resolve(video.duration);
1233 };
1234 video.onerror = function () {
1235 reject(new Error('Failed to load video'));
1236 };
1237 });
1238}
1239
1240/**
1241 * Gets a thumbnail image from a video URL.
1242 * @param {string} videoUrl URL of the video
1243 * @param {number|null} [maxWidth=null] Maximum width of the thumbnail
1244 * @param {number|null} [maxHeight=null] Maximum height of the thumbnail
1245 * @param {string} [type='image/jpeg'] MIME type of the thumbnail
1246 * @returns {Promise<string>} Promise that resolves to a data URL of the video thumbnail
1247 */
1248export function getVideoThumbnail(videoUrl, maxWidth = null, maxHeight = null, type = 'image/jpeg') {
1249 const video = document.createElement('video');
1250 video.src = videoUrl;
1251 return new Promise((resolve, reject) => {
1252 video.onloadeddata = function () {
1253 // Set the time to capture the thumbnail at the middle of the video
1254 video.currentTime = video.duration / 2;
1255 };
1256 video.onseeked = function () {
1257 // Create a canvas to draw the thumbnail
1258 const canvas = document.createElement('canvas');
1259 const ctx = canvas.getContext('2d');
1260 const { thumbnailWidth, thumbnailHeight } = calculateThumbnailSize(video.videoWidth, video.videoHeight, maxWidth, maxHeight);
1261
1262 canvas.width = thumbnailWidth;
1263 canvas.height = thumbnailHeight;
1264 ctx.imageSmoothingEnabled = true;
1265 ctx.imageSmoothingQuality = 'high';
1266 ctx.fillStyle = 'black';
1267 ctx.fillRect(0, 0, thumbnailWidth, thumbnailHeight);
1268 ctx.drawImage(video, 0, 0, thumbnailWidth, thumbnailHeight);
1269 // Get the data URL of the thumbnail
1270 const dataUrl = canvas.toDataURL(type);
1271 resolve(dataUrl);
1272 };
1273 video.onerror = function () {
1274 reject(new Error('Failed to load video'));
1275 };
1276 });
1277}
1278
1279/**
1280 * Calculates the thumbnail size for a media element while maintaining aspect ratio.
1281 * @param {number} width Media width
1282 * @param {number} height Media height
1283 * @param {number?} maxWidth Max width (null = no limit)
1284 * @param {number?} maxHeight Max height (null = no limit)
1285 * @returns {{ thumbnailWidth: number, thumbnailHeight: number }} Thumbnail size
1286 */
1287export function calculateThumbnailSize(width, height, maxWidth, maxHeight) {
1288 // Calculate the thumbnail dimensions while maintaining the aspect ratio
1289 const aspectRatio = width / height;
1290 let thumbnailWidth = maxWidth;
1291 let thumbnailHeight = maxHeight;
1292
1293 if (maxWidth === null) {
1294 thumbnailWidth = width;
1295 maxWidth = width;
1296 }
1297
1298 if (maxHeight === null) {
1299 thumbnailHeight = height;
1300 maxHeight = height;
1301 }
1302
1303 // Do not upscale if image is already smaller than max dimensions
1304 if (width <= maxWidth && height <= maxHeight) {
1305 thumbnailWidth = width;
1306 thumbnailHeight = height;
1307 } else {
1308 if (width > height) {
1309 thumbnailHeight = maxWidth / aspectRatio;
1310 } else {
1311 thumbnailWidth = maxHeight * aspectRatio;
1312 }
1313 }
1314
1315 return { thumbnailWidth: Math.round(thumbnailWidth), thumbnailHeight: Math.round(thumbnailHeight) };
1316}
1317
1318/**
1319 * Gets the duration of an audio from a data URL.
1320 * @param {string} dataUrl Audio data URL
1321 * @returns {Promise<number>} Duration in seconds
1322 */
1323export function getAudioDurationFromDataURL(dataUrl) {
1324 const audio = document.createElement('audio');
1325 audio.src = dataUrl;
1326 return new Promise((resolve, reject) => {
1327 audio.onloadedmetadata = function () {
1328 resolve(audio.duration);
1329 };
1330 audio.onerror = function () {
1331 reject(new Error('Failed to load audio'));
1332 };
1333 });
1334}
1335
1336/**
1337 * Gets the filename of the character avatar without extension
1338 * @param {string|number?} [chid=null] - Character ID. If not provided, uses the current character ID
1339 * @param {object} [options={}] - Options arguments
1340 * @param {string?} [options.manualAvatarKey=null] - Manually take the following avatar key, instead of using the chid to determine the name
1341 * @returns {string?} The filename of the character avatar without extension, or null if the character ID is invalid
1342 */
1343export function getCharaFilename(chid = null, { manualAvatarKey = null } = {}) {
1344 const context = getContext();
1345 const fileName = manualAvatarKey ?? context.characters[chid ?? context.characterId]?.avatar;
1346
1347 return fileName?.replace(/\.[^/.]+$/, '') ?? null;
1348}
1349
1350/**
1351 * Extracts words from a string.
1352 * @param {string} value The string to extract words from.
1353 * @returns {string[]} The extracted words.
1354 * @example
1355 * extractAllWords('Hello, world!'); // ['hello', 'world']
1356 */
1357export function extractAllWords(value) {
1358 const words = [];
1359
1360 if (!value) {
1361 return words;
1362 }
1363
1364 const matches = value.matchAll(/\b\w+\b/gim);
1365 for (let match of matches) {
1366 words.push(match[0].toLowerCase());
1367 }
1368 return words;
1369}
1370
1371/**
1372 * Escapes a string for use in a regular expression.
1373 * @param {string} string The string to escape.
1374 * @returns {string} The escaped string.
1375 * @example
1376 * escapeRegex('^Hello$'); // '\\^Hello\\$'
1377 */
1378export function escapeRegex(string) {
1379 return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
1380}
1381
1382/**
1383 * Instantiates a regular expression from a string.
1384 * @param {string} input The input string.
1385 * @returns {RegExp} The regular expression instance.
1386 * @copyright Originally from: https://github.com/IonicaBizau/regex-parser.js/blob/master/lib/index.js
1387 */
1388export function regexFromString(input) {
1389 try {
1390 // Parse input
1391 var m = input.match(/(\/?)(.+)\1([a-z]*)/i);
1392
1393 // Invalid flags
1394 if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3])) {
1395 return RegExp(input);
1396 }
1397
1398 // Create the regular expression
1399 return new RegExp(m[2], m[3]);
1400 } catch {
1401 return;
1402 }
1403}
1404
1405export class Stopwatch {
1406 /**
1407 * Initializes a Stopwatch class.
1408 * @param {number} interval Update interval in milliseconds. Must be a finite number above zero.
1409 */
1410 constructor(interval) {
1411 if (isNaN(interval) || !isFinite(interval) || interval <= 0) {
1412 console.warn('Invalid interval for Stopwatch, setting to 1');
1413 interval = 1;
1414 }
1415
1416 this.interval = interval;
1417 this.lastAction = Date.now();
1418 }
1419
1420 /**
1421 * Executes a function if the interval passed.
1422 * @param {(arg0: any) => any} action Action function
1423 * @returns Promise<void>
1424 */
1425 async tick(action) {
1426 const passed = (Date.now() - this.lastAction);
1427
1428 if (passed < this.interval) {
1429 return;
1430 }
1431
1432 await action();
1433 this.lastAction = Date.now();
1434 }
1435}
1436
1437/**
1438 * Provides an interface for rate limiting function calls.
1439 */
1440export class RateLimiter {
1441 /**
1442 * Creates a new RateLimiter.
1443 * @param {number} interval The interval in milliseconds.
1444 * @example
1445 * const rateLimiter = new RateLimiter(1000);
1446 * rateLimiter.waitForResolve().then(() => {
1447 * console.log('Waited 1000ms');
1448 * });
1449 */
1450 constructor(interval) {
1451 this.interval = interval;
1452 this.lastResolveTime = 0;
1453 this.pendingResolve = Promise.resolve();
1454 }
1455
1456 /**
1457 * Waits for the remaining time in the interval.
1458 * @param {AbortSignal} abortSignal An optional AbortSignal to abort the wait.
1459 * @returns {Promise<void>} A promise that resolves when the remaining time has elapsed.
1460 */
1461 _waitRemainingTime(abortSignal) {
1462 const currentTime = Date.now();
1463 const elapsedTime = currentTime - this.lastResolveTime;
1464 const remainingTime = Math.max(0, this.interval - elapsedTime);
1465
1466 return new Promise((resolve, reject) => {
1467 const timeoutId = setTimeout(() => {
1468 resolve();
1469 }, remainingTime);
1470
1471 if (abortSignal) {
1472 abortSignal.addEventListener('abort', () => {
1473 clearTimeout(timeoutId);
1474 reject(new Error('Aborted'));
1475 });
1476 }
1477 });
1478 }
1479
1480 /**
1481 * Waits for the next interval to elapse.
1482 * @param {AbortSignal} abortSignal An optional AbortSignal to abort the wait.
1483 * @returns {Promise<void>} A promise that resolves when the next interval has elapsed.
1484 */
1485 async waitForResolve(abortSignal) {
1486 await this.pendingResolve;
1487 this.pendingResolve = this._waitRemainingTime(abortSignal);
1488
1489 // Update the last resolve time
1490 this.lastResolveTime = Date.now() + this.interval;
1491 console.debug(`RateLimiter.waitForResolve() ${this.lastResolveTime}`);
1492 }
1493}
1494
1495/**
1496 * Extracts a JSON object from a PNG file.
1497 * Taken from https://github.com/LostRuins/lite.koboldai.net/blob/main/index.html
1498 * Adapted from png-chunks-extract under MIT license
1499 * @param {Uint8Array} data The PNG data to extract the JSON from.
1500 * @param {string} identifier The identifier to look for in the PNG tEXT data.
1501 * @returns {object} The extracted JSON object.
1502 */
1503export function extractDataFromPng(data, identifier = 'chara') {
1504 console.log('Attempting PNG import...');
1505 let uint8 = new Uint8Array(4);
1506 let uint32 = new Uint32Array(uint8.buffer);
1507
1508 //check if png header is valid
1509 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) {
1510 console.log('PNG header invalid');
1511 return null;
1512 }
1513
1514 let ended = false;
1515 let chunks = [];
1516 let idx = 8;
1517
1518 while (idx < data.length) {
1519 // Read the length of the current chunk,
1520 // which is stored as a Uint32.
1521 uint8[3] = data[idx++];
1522 uint8[2] = data[idx++];
1523 uint8[1] = data[idx++];
1524 uint8[0] = data[idx++];
1525
1526 // Chunk includes name/type for CRC check (see below).
1527 let length = uint32[0] + 4;
1528 let chunk = new Uint8Array(length);
1529 chunk[0] = data[idx++];
1530 chunk[1] = data[idx++];
1531 chunk[2] = data[idx++];
1532 chunk[3] = data[idx++];
1533
1534 // Get the name in ASCII for identification.
1535 let name = (
1536 String.fromCharCode(chunk[0]) +
1537 String.fromCharCode(chunk[1]) +
1538 String.fromCharCode(chunk[2]) +
1539 String.fromCharCode(chunk[3])
1540 );
1541
1542 // The IHDR header MUST come first.
1543 if (!chunks.length && name !== 'IHDR') {
1544 console.log('Warning: IHDR header missing');
1545 }
1546
1547 // The IEND header marks the end of the file,
1548 // so on discovering it break out of the loop.
1549 if (name === 'IEND') {
1550 ended = true;
1551 chunks.push({
1552 name: name,
1553 data: new Uint8Array(0),
1554 });
1555 break;
1556 }
1557
1558 // Read the contents of the chunk out of the main buffer.
1559 for (let i = 4; i < length; i++) {
1560 chunk[i] = data[idx++];
1561 }
1562
1563 // Read out the CRC value for comparison.
1564 // It's stored as an Int32.
1565 uint8[3] = data[idx++];
1566 uint8[2] = data[idx++];
1567 uint8[1] = data[idx++];
1568 uint8[0] = data[idx++];
1569
1570
1571 // The chunk data is now copied to remove the 4 preceding
1572 // bytes used for the chunk name/type.
1573 let chunkData = new Uint8Array(chunk.buffer.slice(4));
1574
1575 chunks.push({
1576 name: name,
1577 data: chunkData,
1578 });
1579 }
1580
1581 if (!ended) {
1582 console.log('.png file ended prematurely: no IEND header was found');
1583 }
1584
1585 //find the chunk with the chara name, just check first and last letter
1586 let found = chunks.filter(x => (
1587 x.name == 'tEXt'
1588 && x.data.length > identifier.length
1589 && x.data.slice(0, identifier.length).every((v, i) => String.fromCharCode(v) == identifier[i])));
1590
1591 if (found.length == 0) {
1592 console.log('PNG Image contains no data');
1593 return null;
1594 } else {
1595 try {
1596 let b64buf = '';
1597 let bytes = found[0].data; //skip the chara
1598 for (let i = identifier.length + 1; i < bytes.length; i++) {
1599 b64buf += String.fromCharCode(bytes[i]);
1600 }
1601 let decoded = JSON.parse(atob(b64buf));
1602 console.log(decoded);
1603 return decoded;
1604 } catch (e) {
1605 console.log('Error decoding b64 in image: ' + e);
1606 return null;
1607 }
1608 }
1609}
1610
1611/**
1612 * Sends a request to the server to sanitize a given filename
1613 *
1614 * @param {string} fileName - The name of the file to sanitize
1615 * @returns {Promise<string>} A Promise that resolves to the sanitized filename if successful, or rejects with an error message if unsuccessful
1616 */
1617export async function getSanitizedFilename(fileName) {
1618 try {
1619 const result = await fetch('/api/files/sanitize-filename', {
1620 method: 'POST',
1621 headers: getRequestHeaders(),
1622 body: JSON.stringify({
1623 fileName: fileName,
1624 }),
1625 });
1626
1627 if (!result.ok) {
1628 const error = await result.text();
1629 throw new Error(error);
1630 }
1631
1632 const responseData = await result.json();
1633 return responseData.fileName;
1634 } catch (error) {
1635 toastr.error(String(error), 'Could not sanitize fileName');
1636 console.error('Could not sanitize fileName', error);
1637 throw error;
1638 }
1639}
1640
1641/**
1642 * Sends a base64 encoded image to the backend to be saved as a file.
1643 *
1644 * @param {string} base64Data - The base64 encoded image data.
1645 * @param {string} subFolder - The character name to determine the sub-directory for saving.
1646 * @param {string} fileName - The name of the file to save the image as (without extension).
1647 * @param {string} extension - The file extension for the image (e.g., 'jpg', 'png', 'webp').
1648 *
1649 * @returns {Promise<string>} - Resolves to the saved image's path on the server.
1650 * Rejects with an error if the upload fails.
1651 */
1652export async function saveBase64AsFile(base64Data, subFolder, fileName, extension) {
1653 // Prepare the request body
1654 const requestBody = {
1655 image: base64Data,
1656 format: extension,
1657 ch_name: subFolder,
1658 filename: String(fileName).replace(/\./g, '_'),
1659 };
1660
1661 // Send the data URL to your backend using fetch
1662 const response = await fetch('/api/images/upload', {
1663 method: 'POST',
1664 headers: getRequestHeaders(),
1665 body: JSON.stringify(requestBody),
1666 });
1667
1668 // If the response is successful, get the saved image path from the server's response
1669 if (response.ok) {
1670 const responseData = await response.json();
1671 return responseData.path;
1672 } else {
1673 const errorData = await response.json();
1674 throw new Error(errorData.error || 'Failed to upload the image to the server');
1675 }
1676}
1677
1678/**
1679 * Gets the file extension from a File object.
1680 * @param {File} file The file to get the extension from
1681 * @returns {string} The file extension of the given file
1682 */
1683export function getFileExtension(file) {
1684 return file.name.substring((file.name.lastIndexOf('.') + file.name.length) % file.name.length + 1).toLowerCase().trim();
1685}
1686
1687/**
1688 * Converts UTF-8 string into Base64-encoded string.
1689 *
1690 * @param {string} text The UTF-8 string
1691 * @returns {string} The Base64-encoded string
1692 */
1693export function convertTextToBase64(text) {
1694 const encoder = new TextEncoder();
1695 const utf8Bytes = encoder.encode(text);
1696 /**
1697 * return `true` if `Uint8Array.prototype.toBase64` function is supported.
1698 * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64|MDN Reference}
1699 */
1700 if ('toBase64' in Uint8Array.prototype) {
1701 return utf8Bytes.toBase64();
1702 }
1703 // Creates binary string, where each character's code point directly matches the byte value (0-255).
1704 let binaryString = '';
1705 const chunkSize = 8192;
1706 for (let i = 0; i < utf8Bytes.length; i += chunkSize) {
1707 binaryString += String.fromCharCode(...utf8Bytes.subarray(i, i + chunkSize));
1708 }
1709 return window.btoa(binaryString);
1710}
1711
1712/**
1713 * Loads either a CSS or JS file and appends it to the appropriate document section.
1714 *
1715 * @param {string} url - The URL of the file to be loaded.
1716 * @param {string} type - The type of file to load: "css" or "js".
1717 * @returns {Promise} - Resolves when the file has loaded, rejects if there's an error or invalid type.
1718 */
1719export function loadFileToDocument(url, type) {
1720 return new Promise((resolve, reject) => {
1721 let element;
1722
1723 if (type === 'css') {
1724 element = document.createElement('link');
1725 element.rel = 'stylesheet';
1726 element.href = url;
1727 } else if (type === 'js') {
1728 element = document.createElement('script');
1729 element.src = url;
1730 } else {
1731 reject('Invalid type specified');
1732 return;
1733 }
1734
1735 element.onload = resolve;
1736 element.onerror = reject;
1737
1738 type === 'css'
1739 ? document.head.appendChild(element)
1740 : document.body.appendChild(element);
1741 });
1742}
1743
1744/**
1745 * Opens a file picker dialog for selecting an image.
1746 * @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
1747 */
1748export async function promptForAvatarFile() {
1749 return new Promise(resolve => {
1750 const input = document.createElement('input');
1751 input.type = 'file';
1752 input.accept = supportedImageMimeTypes.join(',');
1753 input.onchange = async (e) => {
1754 if (!(e.target instanceof HTMLInputElement)) {
1755 return '';
1756 }
1757 const file = e.target?.files?.[0];
1758 if (!file) {
1759 resolve(null);
1760 return;
1761 }
1762 try {
1763 const converted = await ensureImageFormatSupported(file);
1764 const base64 = await getBase64Async(converted);
1765 resolve(base64);
1766 } catch (error) {
1767 console.error('Error processing selected image:', error);
1768 toastr.error(t`Failed to process selected image: ${error.message}`);
1769 resolve(null);
1770 }
1771 };
1772 input.oncancel = () => resolve(null);
1773 input.click();
1774 });
1775}
1776
1777/**
1778 * Resolves avatar data from various input formats (base64, local path, or prompt).
1779 * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
1780 * @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
1781 */
1782export async function resolveAvatarData(input) {
1783 if (!input || typeof input !== 'string') {
1784 return null;
1785 }
1786
1787 const trimmed = input.trim();
1788
1789 // Special value "prompt" opens file picker
1790 if (trimmed.toLowerCase() === 'prompt') {
1791 return await promptForAvatarFile();
1792 }
1793
1794 // Already a base64 data URL
1795 if (trimmed.startsWith('data:image/')) {
1796 return trimmed;
1797 }
1798
1799 // External URLs are not supported
1800 if (isExternalUrl(trimmed)) {
1801 toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
1802 return null;
1803 }
1804 // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
1805 // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
1806 // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
1807 if (trimmed.includes('/') || trimmed.endsWith('.png')) {
1808 try {
1809 // Construct the URL to fetch the local file
1810 let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
1811 // Handle same-origin URLs
1812 if (trimmed.startsWith(window.location.origin)) {
1813 url = new URL(trimmed).pathname;
1814 }
1815 // If there is no subfolder, we guess this should be a character image
1816 if (!url.includes('/', 1)) {
1817 url = '/characters/' + trimmed;
1818 }
1819
1820 const response = await fetch(url);
1821 if (!response.ok) {
1822 throw new Error(`File not found or inaccessible: ${response.status}`);
1823 }
1824 const blob = await response.blob();
1825 if (!blob.type.startsWith('image/')) {
1826 throw new Error('File is not an image');
1827 }
1828 const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
1829 return await getBase64Async(converted);
1830 } catch (error) {
1831 console.error('Error fetching local avatar:', error);
1832 toastr.warning(t`Failed to load avatar from path: ${error.message}`);
1833 return null;
1834 }
1835 }
1836
1837 // Unknown format
1838 console.warn('Unknown avatar format:', trimmed.substring(0, 50));
1839 toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
1840 return null;
1841}
1842
1843/**
1844 * An array of all supported image MIME types.
1845 */
1846export const supportedImageMimeTypes = Object.freeze([
1847 'image/jpeg',
1848 'image/png',
1849 'image/bmp',
1850 'image/tiff',
1851 'image/gif',
1852 'image/apng',
1853 'image/webp',
1854 'image/avif',
1855]);
1856
1857/**
1858 * Ensure that we can import war crime image formats like WEBP and AVIF.
1859 * @param {File} file Input file
1860 * @returns {Promise<File>} A promise that resolves to the supported file.
1861 */
1862export async function ensureImageFormatSupported(file) {
1863 if (supportedImageMimeTypes.includes(file.type) || !file.type.startsWith('image/')) {
1864 return file;
1865 }
1866
1867 return await convertImageFile(file, 'image/png');
1868}
1869
1870/**
1871 * Converts an image file to a given format.
1872 * @param {File} inputFile File to convert
1873 * @param {string} type Target file type
1874 * @returns {Promise<File>} A promise that resolves to the converted file.
1875 */
1876export async function convertImageFile(inputFile, type = 'image/png') {
1877 const base64 = await getBase64Async(inputFile);
1878 const thumbnail = await createThumbnail(base64, null, null, type);
1879 const blob = await fetch(thumbnail).then(res => res.blob());
1880 const outputFile = new File([blob], inputFile.name, { type });
1881 return outputFile;
1882}
1883
1884/**
1885 * Creates a thumbnail from a data URL.
1886 * @param {string} dataUrl The data URL encoded data of the image.
1887 * @param {number|null} maxWidth The maximum width of the thumbnail.
1888 * @param {number|null} maxHeight The maximum height of the thumbnail.
1889 * @param {string} [type='image/jpeg'] The type of the thumbnail.
1890 * @returns {Promise<string>} A promise that resolves to the thumbnail data URL.
1891 */
1892export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type = 'image/jpeg') {
1893 // Someone might pass in a base64 encoded string without the data URL prefix
1894 if (!dataUrl.includes('data:')) {
1895 dataUrl = `data:image/jpeg;base64,${dataUrl}`;
1896 }
1897
1898 return new Promise((resolve, reject) => {
1899 const img = new Image();
1900 img.src = dataUrl;
1901 img.onload = () => {
1902 const canvas = document.createElement('canvas');
1903 const ctx = canvas.getContext('2d');
1904 const { thumbnailWidth, thumbnailHeight } = calculateThumbnailSize(img.width, img.height, maxWidth, maxHeight);
1905
1906 // Set the canvas dimensions and draw the resized image
1907 canvas.width = thumbnailWidth;
1908 canvas.height = thumbnailHeight;
1909 ctx.imageSmoothingEnabled = true;
1910 ctx.imageSmoothingQuality = 'high';
1911 ctx.fillStyle = 'white';
1912 ctx.fillRect(0, 0, thumbnailWidth, thumbnailHeight);
1913 ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight);
1914
1915 // Convert the canvas to a data URL and resolve the promise
1916 const thumbnailDataUrl = canvas.toDataURL(type);
1917 resolve(thumbnailDataUrl);
1918 };
1919
1920 img.onerror = () => {
1921 reject(new Error('Failed to load the image.'));
1922 };
1923 });
1924}
1925
1926/**
1927 * Waits for a condition to be true. Throws an error if the condition is not true within the timeout.
1928 * @param {{ (): boolean; }} condition The condition to wait for.
1929 * @param {number} [timeout=1000] The timeout in milliseconds.
1930 * @param {number} [interval=100] The interval in milliseconds.
1931 * @param {object} [options] Options object
1932 * @param {boolean} [options.rejectOnTimeout=true] Whether to reject the promise on timeout or resolve it.
1933 * @returns {Promise<void>} A promise that resolves when the condition is true.
1934 */
1935export async function waitUntilCondition(condition, timeout = 1000, interval = 100, options = {}) {
1936 const { rejectOnTimeout = true } = options;
1937
1938 return new Promise((resolve, reject) => {
1939 const timeoutId = setTimeout(() => {
1940 clearInterval(intervalId);
1941 const timeoutFn = rejectOnTimeout ? reject : resolve;
1942 timeoutFn(new Error('Timed out waiting for condition to be true'));
1943 }, timeout);
1944
1945 const intervalId = setInterval(() => {
1946 if (condition()) {
1947 clearTimeout(timeoutId);
1948 clearInterval(intervalId);
1949 resolve();
1950 }
1951 }, interval);
1952 });
1953}
1954
1955/**
1956 * Returns a UUID v4 string.
1957 * @returns {string} A UUID v4 string.
1958 * @example
1959 * uuidv4(); // '3e2fd9e1-0a7a-4f6d-9aaf-8a7a4babe7eb'
1960 */
1961export function uuidv4() {
1962 if ('randomUUID' in crypto) {
1963 return crypto.randomUUID();
1964 }
1965 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
1966 const r = Math.random() * 16 | 0;
1967 const v = c === 'x' ? r : (r & 0x3 | 0x8);
1968 return v.toString(16);
1969 });
1970}
1971
1972/**
1973 * Collapses multiple spaces in a strings into one.
1974 * @param {string} s String to process
1975 * @returns {string} String with collapsed spaces
1976 */
1977export function collapseSpaces(s) {
1978 return s.replace(/\s+/g, ' ').trim();
1979}
1980
1981function postProcessText(text, collapse = true) {
1982 // Remove carriage returns
1983 text = text.replace(/\r/g, '');
1984 // Replace tabs with spaces
1985 text = text.replace(/\t/g, ' ');
1986 // Normalize unicode spaces
1987 text = text.replace(/\u00A0/g, ' ');
1988 // Collapse multiple newlines into one
1989 if (collapse) {
1990 text = collapseNewlines(text);
1991 // Trim leading and trailing whitespace, and remove empty lines
1992 text = text.split('\n').map(l => l.trim()).filter(Boolean).join('\n');
1993 } else {
1994 // Replace more than 4 newlines with 4 newlines
1995 text = text.replace(/\n{4,}/g, '\n\n\n\n');
1996 // Trim lines that contain nothing but whitespace
1997 text = text.split('\n').map(l => /^\s+$/.test(l) ? '' : l).join('\n');
1998 }
1999 // Collapse multiple spaces into one (except for newlines)
2000 text = text.replace(/ {2,}/g, ' ');
2001 // Remove leading and trailing spaces
2002 text = text.trim();
2003 return text;
2004}
2005
2006/**
2007 * Uses Readability.js to parse the text from a web page.
2008 * @param {Document} document HTML document
2009 * @param {string} [textSelector='body'] The fallback selector for the text to parse.
2010 * @returns {Promise<string>} A promise that resolves to the parsed text.
2011 */
2012export async function getReadableText(document, textSelector = 'body') {
2013 if (isProbablyReaderable(document)) {
2014 const parser = new Readability(document);
2015 const article = parser.parse();
2016 return postProcessText(article.textContent, false);
2017 }
2018
2019 const elements = document.querySelectorAll(textSelector);
2020 const rawText = Array.from(elements).map(e => e.textContent).join('\n');
2021 const text = postProcessText(rawText);
2022 return text;
2023}
2024
2025/**
2026 * Use pdf.js to load and parse text from PDF pages
2027 * @param {Blob} blob PDF file blob
2028 * @returns {Promise<string>} A promise that resolves to the parsed text.
2029 */
2030export async function extractTextFromPDF(blob) {
2031 if (!('pdfjsLib' in window)) {
2032 await import('../lib/pdf.min.mjs');
2033 await import('../lib/pdf.worker.min.mjs');
2034 }
2035
2036 const buffer = await getFileBuffer(blob);
2037 const pdf = await pdfjsLib.getDocument(buffer).promise;
2038 const pages = [];
2039 for (let i = 1; i <= pdf.numPages; i++) {
2040 const page = await pdf.getPage(i);
2041 const textContent = await page.getTextContent();
2042 const text = textContent.items.map(item => item.str).join(' ');
2043 pages.push(text);
2044 }
2045 return postProcessText(pages.join('\n'));
2046}
2047
2048/**
2049 * Use DOMParser to load and parse text from HTML
2050 * @param {Blob} blob HTML content blob
2051 * @returns {Promise<string>} A promise that resolves to the parsed text.
2052 */
2053export async function extractTextFromHTML(blob, textSelector = 'body') {
2054 const html = await blob.text();
2055 const domParser = new DOMParser();
2056 const document = domParser.parseFromString(DOMPurify.sanitize(html), 'text/html');
2057 return await getReadableText(document, textSelector);
2058}
2059
2060/**
2061 * Use showdown to load and parse text from Markdown
2062 * @param {Blob} blob Markdown content blob
2063 * @returns {Promise<string>} A promise that resolves to the parsed text.
2064 */
2065export async function extractTextFromMarkdown(blob) {
2066 const markdown = await blob.text();
2067 const text = postProcessText(markdown, false);
2068 return text;
2069}
2070
2071export async function extractTextFromEpub(blob) {
2072 if (!('ePub' in window)) {
2073 await import('../lib/jszip.min.js');
2074 await import('../lib/epub.min.js');
2075 }
2076
2077 const book = ePub(blob);
2078 await book.ready;
2079 const sectionPromises = [];
2080
2081 book.spine.each((section) => {
2082 const sectionPromise = (async () => {
2083 const chapter = await book.load(section.href);
2084 if (!(chapter instanceof Document) || !chapter.body?.textContent) {
2085 return '';
2086 }
2087 return chapter.body.textContent.trim();
2088 })();
2089
2090 sectionPromises.push(sectionPromise);
2091 });
2092
2093 const content = await Promise.all(sectionPromises);
2094 const text = content.filter(text => text);
2095 return postProcessText(text.join('\n'), false);
2096}
2097
2098/**
2099 * Extracts text from an Office document using the server plugin.
2100 * @param {File} blob File to extract text from
2101 * @returns {Promise<string>} A promise that resolves to the extracted text.
2102 */
2103export async function extractTextFromOffice(blob) {
2104 async function checkPluginAvailability() {
2105 try {
2106 const result = await fetch('/api/plugins/office/probe', {
2107 method: 'POST',
2108 headers: getRequestHeaders({ omitContentType: true }),
2109 });
2110
2111 return result.ok;
2112 } catch (error) {
2113 return false;
2114 }
2115 }
2116
2117 const isPluginAvailable = await checkPluginAvailability();
2118
2119 if (!isPluginAvailable) {
2120 throw new Error('Importing Office documents requires a server plugin. Please refer to the documentation for more information.');
2121 }
2122
2123 const base64 = await getBase64Async(blob);
2124
2125 const response = await fetch('/api/plugins/office/parse', {
2126 method: 'POST',
2127 headers: getRequestHeaders(),
2128 body: JSON.stringify({ data: base64 }),
2129 });
2130
2131 if (!response.ok) {
2132 throw new Error('Failed to parse the Office document');
2133 }
2134
2135 const data = await response.text();
2136 return postProcessText(data, false);
2137}
2138
2139/**
2140 * Sets a value in an object by a path.
2141 * @param {object} obj Object to set value in
2142 * @param {string} path Key path
2143 * @param {any} value Value to set
2144 * @returns {void}
2145 */
2146export function setValueByPath(obj, path, value) {
2147 const keyParts = path.split('.');
2148 let currentObject = obj;
2149
2150 for (let i = 0; i < keyParts.length - 1; i++) {
2151 const part = keyParts[i];
2152
2153 if (!Object.hasOwn(currentObject, part)) {
2154 currentObject[part] = {};
2155 }
2156
2157 currentObject = currentObject[part];
2158 }
2159
2160 currentObject[keyParts[keyParts.length - 1]] = value;
2161}
2162
2163/**
2164 * Deletes a value from a nested object at the given dot-separated path.
2165 * @param {object} obj Object to delete from
2166 * @param {string} path Dot-separated key path (e.g. "data.extensions.myKey")
2167 */
2168export function deleteValueByPath(obj, path) {
2169 const keyParts = path.split('.');
2170 let current = obj;
2171 for (let i = 0; i < keyParts.length - 1; i++) {
2172 if (!current || typeof current !== 'object') return;
2173 current = current[keyParts[i]];
2174 }
2175 if (current && typeof current === 'object') {
2176 delete current[keyParts[keyParts.length - 1]];
2177 }
2178}
2179
2180/**
2181 * Flashes the given HTML element via CSS flash animation for a defined period
2182 * @param {JQuery<HTMLElement>} element - The element to flash
2183 * @param {number} timespan - A number in milliseconds how the flash should last (default is 2000ms. Multiples of 1000ms work best, as they end with the flash animation being at 100% opacity)
2184 */
2185export function flashHighlight(element, timespan = 2000) {
2186 const flashDuration = 2000; // Duration of a single flash cycle in milliseconds
2187
2188 element.addClass('flash animated');
2189 element.css('--animation-duration', `${flashDuration}ms`);
2190
2191 // Repeat the flash animation
2192 const intervalId = setInterval(() => {
2193 element.removeClass('flash animated');
2194 void element[0].offsetWidth; // Trigger reflow to restart animation
2195 element.addClass('flash animated');
2196 }, flashDuration);
2197
2198 setTimeout(() => {
2199 clearInterval(intervalId);
2200 element.removeClass('flash animated');
2201 element.css('--animation-duration', '');
2202 }, timespan);
2203}
2204
2205
2206/**
2207 * Checks if the given control has an animation applied to it
2208 *
2209 * @param {HTMLElement} control - The control element to check for animation
2210 * @returns {boolean} Whether the control has an animation applied
2211 */
2212export function hasAnimation(control) {
2213 const animatioName = getComputedStyle(control, null)['animation-name'];
2214 return animatioName != 'none';
2215}
2216
2217/**
2218 * Run an action once an animation on a control ends. If the control has no animation, the action will be executed immediately.
2219 * The action will be executed after the animation ends or after the timeout, whichever comes first.
2220 * @param {HTMLElement} control - The control element to listen for animation end event
2221 * @param {(control:*?) => void} callback - The callback function to be executed when the animation ends
2222 * @param {number} [timeout=500] - The timeout in milliseconds to wait for the animation to end before executing the callback
2223 */
2224export function runAfterAnimation(control, callback, timeout = 500) {
2225 if (hasAnimation(control)) {
2226 Promise.race([
2227 new Promise((r) => setTimeout(r, timeout)), // Fallback timeout
2228 new Promise((r) => control.addEventListener('animationend', r, { once: true })),
2229 ]).finally(() => callback(control));
2230 } else {
2231 callback(control);
2232 }
2233}
2234
2235/**
2236 * A common base function for case-insensitive and accent-insensitive string comparisons.
2237 *
2238 * @param {string} a - The first string to compare.
2239 * @param {string} b - The second string to compare.
2240 * @param {(a:string,b:string)=>T} comparisonFunction - The function to use for the comparison.
2241 * @returns {T} - The result of the comparison.
2242 * @template T
2243 */
2244export function compareIgnoreCaseAndAccents(a, b, comparisonFunction) {
2245 if (!a || !b) return comparisonFunction(a, b); // Return the comparison result if either string is empty
2246
2247 // Normalize and remove diacritics, then convert to lower case
2248 const normalizedA = a.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
2249 const normalizedB = b.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
2250
2251 // Check if the normalized strings are equal
2252 return comparisonFunction(normalizedA, normalizedB);
2253}
2254
2255/**
2256 * Performs a case-insensitive and accent-insensitive substring search.
2257 * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents.
2258 *
2259 * @param {string} text - The text in which to search for the substring
2260 * @param {string} searchTerm - The substring to search for in the text
2261 * @returns {boolean} true if the searchTerm is found within the text, otherwise returns false
2262 */
2263export function includesIgnoreCaseAndAccents(text, searchTerm) {
2264 return compareIgnoreCaseAndAccents(text, searchTerm, (a, b) => a?.includes(b) === true);
2265}
2266
2267/**
2268 * Performs a case-insensitive and accent-insensitive equality check.
2269 * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents.
2270 *
2271 * @param {string} a - The first string to compare
2272 * @param {string} b - The second string to compare
2273 * @returns {boolean} true if the strings are equal, otherwise returns false
2274 */
2275export function equalsIgnoreCaseAndAccents(a, b) {
2276 return compareIgnoreCaseAndAccents(a, b, (a, b) => a === b);
2277}
2278
2279/**
2280 * Performs a case-insensitive and accent-insensitive sort.
2281 * @param {string} a - The first string to compare
2282 * @param {string} b - The second string to compare
2283 * @returns {number} -1 if a < b, 1 if a > b, 0 if a === b
2284 */
2285export function sortIgnoreCaseAndAccents(a, b) {
2286 return compareIgnoreCaseAndAccents(a, b, (a, b) => a?.localeCompare(b));
2287}
2288
2289/**
2290 * @typedef {object} Select2Option The option object for select2 controls
2291 * @property {string} id - The unique ID inside this select
2292 * @property {string} text - The text for this option
2293 * @property {number?} [count] - Optionally show the count how often that option was chosen already
2294 */
2295
2296/**
2297 * Returns a unique hash as ID for a select2 option text
2298 *
2299 * @param {string} option - The option
2300 * @returns {string} A hashed version of that option
2301 */
2302export function getSelect2OptionId(option) {
2303 return String(getStringHash(option));
2304}
2305
2306/**
2307 * Modifies the select2 options by adding not existing one and optionally selecting them
2308 *
2309 * @param {JQuery<HTMLElement>} element - The "select" element to add the options to
2310 * @param {string[]|Select2Option[]} items - The option items to build, add or select
2311 * @param {object} [options] - Optional arguments
2312 * @param {boolean} [options.select=false] - Whether the options should be selected right away
2313 * @param {object} [options.changeEventArgs=null] - Optional event args being passed into the "change" event when its triggered because a new options is selected
2314 */
2315export function select2ModifyOptions(element, items, { select = false, changeEventArgs = null } = {}) {
2316 if (!items.length) return;
2317 /** @type {Select2Option[]} */
2318 const dataItems = items.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x);
2319
2320 const optionsToSelect = [];
2321 const newOptions = [];
2322
2323 dataItems.forEach(item => {
2324 // Set the value, creating a new option if necessary
2325 if (element.find('option[value=\'' + item.id + '\']').length) {
2326 if (select) optionsToSelect.push(item.id);
2327 } else {
2328 // Create a DOM Option and optionally pre-select by default
2329 var newOption = new Option(item.text, item.id, select, select);
2330 // Append it to the select
2331 newOptions.push(newOption);
2332 if (select) optionsToSelect.push(item.id);
2333 }
2334 });
2335
2336 element.append(newOptions);
2337 if (optionsToSelect.length) element.val(optionsToSelect).trigger('change', changeEventArgs);
2338}
2339
2340/**
2341 * Returns the ajax settings that can be used on the select2 ajax property to dynamically get the data.
2342 * Can be used on a single global array, querying data from the server or anything similar.
2343 *
2344 * @param {function():Select2Option[]} dataProvider - The provider/function to retrieve the data - can be as simple as "() => myData" for arrays
2345 * @return {{transport: (params, success, failure) => any}} The ajax object with the transport function to use on the select2 ajax property
2346 */
2347export function dynamicSelect2DataViaAjax(dataProvider) {
2348 function dynamicSelect2DataTransport(params, success, failure) {
2349 var items = dataProvider();
2350 // fitering if params.data.q available
2351 if (params.data && params.data.q) {
2352 items = items.filter(function (item) {
2353 return includesIgnoreCaseAndAccents(item.text, params.data.q);
2354 });
2355 }
2356 var promise = new Promise(function (resolve, reject) {
2357 resolve({ results: items });
2358 });
2359 promise.then(success);
2360 promise.catch(failure);
2361 }
2362 const ajax = {
2363 transport: dynamicSelect2DataTransport,
2364 };
2365 return ajax;
2366}
2367
2368/**
2369 * Checks whether a given control is a select2 choice element - meaning one of the results being displayed in the select multi select box
2370 * @param {JQuery<HTMLElement>|HTMLElement} element - The element to check
2371 * @returns {boolean} Whether this is a choice element
2372 */
2373export function isSelect2ChoiceElement(element) {
2374 const $element = $(element);
2375 return ($element.hasClass('select2-selection__choice__display') || $element.parents('.select2-selection__choice__display').length > 0);
2376}
2377
2378/**
2379 * Subscribes a 'click' event handler to the choice elements of a select2 multi-select control
2380 *
2381 * @param {JQuery<HTMLElement>} control The original control the select2 was applied to
2382 * @param {function(HTMLElement):void} action - The action to execute when a choice element is clicked
2383 * @param {object} options - Optional parameters
2384 * @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
2385 * @param {boolean} [options.closeDrawer=false] - Whether the drawer should be closed and focus removed after the choice item was clicked
2386 * @param {boolean} [options.openDrawer=false] - Whether the drawer should be opened, even if this click would normally close it
2387 */
2388export function select2ChoiceClickSubscribe(control, action, { buttonStyle = false, closeDrawer = false, openDrawer = false } = {}) {
2389 // Add class for styling (hover color, changed cursor, etc)
2390 control.addClass('select2_choice_clickable');
2391 if (buttonStyle) control.addClass('select2_choice_clickable_buttonstyle');
2392
2393 // Get the real container below and create a click handler on that one
2394 const select2Container = control.next('span.select2-container');
2395 select2Container.on('click', function (event) {
2396 const isChoice = isSelect2ChoiceElement(event.target);
2397 if (isChoice) {
2398 event.preventDefault();
2399
2400 // select2 still bubbles the event to open the dropdown. So we close it here and remove focus if we want that
2401 if (closeDrawer) {
2402 control.select2('close');
2403 setTimeout(() => select2Container.find('textarea').trigger('blur'), debounce_timeout.quick);
2404 }
2405 if (openDrawer) {
2406 control.select2('open');
2407 }
2408
2409 // Now execute the actual action that was subscribed
2410 action(event.target);
2411 }
2412 });
2413}
2414
2415/**
2416 * Applies syntax highlighting to a given regex string by generating HTML with classes
2417 *
2418 * @param {string} regexStr - The javascript compatible regex string
2419 * @returns {string} The html representation of the highlighted regex
2420 */
2421export function highlightRegex(regexStr) {
2422 // Function to escape special characters for safety or readability
2423 const escape = (str) => str.replace(/[&<>"'\x01]/g, match => ({
2424 '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;', '\x01': '\\x01',
2425 })[match]);
2426
2427 // Replace special characters with their escaped forms
2428 regexStr = escape(regexStr);
2429
2430 // Patterns that we want to highlight only if they are not escaped
2431 function getPatterns() {
2432 try {
2433 return {
2434 brackets: new RegExp('(?<!\\\\)\\[.*?\\]', 'g'), // Non-escaped square brackets
2435 quantifiers: new RegExp('(?<!\\\\)[*+?{}]', 'g'), // Non-escaped quantifiers
2436 operators: new RegExp('(?<!\\\\)[|.^$()]', 'g'), // Non-escaped operators like | and ()
2437 specialChars: new RegExp('\\\\.', 'g'),
2438 flags: new RegExp('(?<=\\/)([gimsuy]*)$', 'g'), // Match trailing flags
2439 delimiters: new RegExp('^\\/|(?<![\\\\<])\\/', 'g'), // Match leading or trailing delimiters
2440 };
2441 } catch (error) {
2442 return {
2443 brackets: new RegExp('(\\\\)?\\[.*?\\]', 'g'), // Non-escaped square brackets
2444 quantifiers: new RegExp('(\\\\)?[*+?{}]', 'g'), // Non-escaped quantifiers
2445 operators: new RegExp('(\\\\)?[|.^$()]', 'g'), // Non-escaped operators like | and ()
2446 specialChars: new RegExp('\\\\.', 'g'),
2447 flags: new RegExp('/([gimsuy]*)$', 'g'), // Match trailing flags
2448 delimiters: new RegExp('^/|[^\\\\](/)', 'g'), // Match leading or trailing delimiters
2449 };
2450 }
2451 }
2452
2453 const patterns = getPatterns();
2454
2455 // Function to replace each pattern with a highlighted HTML span
2456 const wrapPattern = (pattern, className) => {
2457 regexStr = regexStr.replace(pattern, match => `<span class="${className}">${match}</span>`);
2458 };
2459
2460 // Apply highlighting patterns
2461 wrapPattern(patterns.brackets, 'regex-brackets');
2462 wrapPattern(patterns.quantifiers, 'regex-quantifier');
2463 wrapPattern(patterns.operators, 'regex-operator');
2464 wrapPattern(patterns.specialChars, 'regex-special');
2465 wrapPattern(patterns.flags, 'regex-flags');
2466 wrapPattern(patterns.delimiters, 'regex-delimiter');
2467
2468 return `<span class="regex-highlight">${regexStr}</span>`;
2469}
2470
2471/**
2472 * Confirms if the user wants to overwrite an existing data object (like character, world info, etc) if one exists.
2473 * If no data with the name exists, this simply returns true.
2474 *
2475 * @param {string} type - The type of the check ("World Info", "Character", etc)
2476 * @param {string[]} existingNames - The list of existing names to check against
2477 * @param {string} name - The new name
2478 * @param {object} options - Optional parameters
2479 * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when needing to overwrite an existing data object
2480 * @param {string} [options.actionName='overwrite'] - The action name to display in the confirmation dialog
2481 * @param {(existingName:string)=>void} [options.deleteAction=null] - Optional action to execute wen deleting an existing data object on overwrite
2482 * @returns {Promise<boolean>} True if the user confirmed the overwrite or there is no overwrite needed, false otherwise
2483 */
2484export async function checkOverwriteExistingData(type, existingNames, name, { interactive = false, actionName = 'Overwrite', deleteAction = null } = {}) {
2485 const existing = existingNames.find(x => equalsIgnoreCaseAndAccents(x, name));
2486 if (!existing) {
2487 return true;
2488 }
2489
2490 const overwrite = interactive && await Popup.show.confirm(`${type} ${actionName}`, `<p>A ${type.toLowerCase()} with the same name already exists:<br />${escapeHtml(existing)}</p>Do you want to overwrite it?`);
2491 if (!overwrite) {
2492 toastr.warning(`${type} ${actionName.toLowerCase()} cancelled. A ${type.toLowerCase()} with the same name already exists:<br />${escapeHtml(existing)}`, `${type} ${actionName}`, { escapeHtml: false });
2493 return false;
2494 }
2495
2496 toastr.info(`Overwriting Existing ${type}:<br />${escapeHtml(existing)}`, `${type} ${actionName}`, { escapeHtml: false });
2497
2498 // 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
2499 if (deleteAction) {
2500 deleteAction(existing);
2501 }
2502
2503 return true;
2504}
2505
2506/**
2507 * Generates a free name by appending a counter to the given name if it already exists in the list
2508 *
2509 * @param {string} name - The original name to check for existence in the list
2510 * @param {string[]} list - The list of names to check for existence
2511 * @param {(n: number) => string} [numberFormatter=(n) => ` #${n}`] - The function used to format the counter
2512 * @returns {string} The generated free name
2513 */
2514export function getFreeName(name, list, numberFormatter = (n) => ` #${n}`) {
2515 if (!list.includes(name)) {
2516 return name;
2517 }
2518 let counter = 1;
2519 while (list.includes(`${name} #${counter}`)) {
2520 counter++;
2521 }
2522 return `${name}${numberFormatter(counter)}`;
2523}
2524
2525
2526/**
2527 * Toggles the visibility of a drawer by changing the display style of its content.
2528 * This function skips the usual drawer animation.
2529 *
2530 * @param {HTMLElement} drawer - The drawer element to toggle
2531 * @param {boolean} [expand=true] - Whether to expand or collapse the drawer
2532 */
2533export function toggleDrawer(drawer, expand = true) {
2534 /** @type {HTMLElement} */
2535 const icon = drawer.querySelector(':scope > .inline-drawer-header .inline-drawer-icon');
2536 /** @type {HTMLElement} */
2537 const content = drawer.querySelector(':scope > .inline-drawer-content');
2538
2539 if (!icon || !content) {
2540 console.debug('toggleDrawer: No icon or content found in the drawer element.');
2541 return;
2542 }
2543
2544 if (expand) {
2545 icon.classList.remove('down', 'fa-circle-chevron-down');
2546 icon.classList.add('up', 'fa-circle-chevron-up');
2547 content.style.display = 'block';
2548 } else {
2549 icon.classList.remove('up', 'fa-circle-chevron-up');
2550 icon.classList.add('down', 'fa-circle-chevron-down');
2551 content.style.display = 'none';
2552 }
2553
2554 drawer.dispatchEvent(new CustomEvent('inline-drawer-toggle', { bubbles: true }));
2555
2556 // Set the height of "autoSetHeight" textareas within the inline-drawer to their scroll height
2557 if (!CSS.supports('field-sizing', 'content')) {
2558 content.querySelectorAll('textarea.autoSetHeight').forEach(resetScrollHeight);
2559 }
2560}
2561
2562/**
2563 * Sets or removes a dataset property on an HTMLElement
2564 *
2565 * Utility function to make it easier to reset dataset properties on null, without them being "null" as value.
2566 *
2567 * @param {HTMLElement} element - The element to modify
2568 * @param {string} name - The name of the dataset property
2569 * @param {string|null} value - The value to set - If null, the dataset property will be removed
2570 */
2571export function setDatasetProperty(element, name, value) {
2572 if (value === null) {
2573 delete element.dataset[name];
2574 } else {
2575 element.dataset[name] = value;
2576 }
2577}
2578
2579export async function fetchFaFile(name) {
2580 const style = document.createElement('style');
2581 style.innerHTML = await (await fetch(`/css/${name}`)).text();
2582 document.head.append(style);
2583 const sheet = style.sheet;
2584 style.remove();
2585 return [...sheet.cssRules]
2586 .filter(rule => (rule instanceof CSSStyleRule && rule.style?.content))
2587 .map(rule => rule['selectorText'].split(/,\s*/).map(selector => selector.split('::').shift().slice(1)))
2588 ;
2589}
2590
2591export async function fetchFa() {
2592 return [...new Set((await Promise.all([
2593 fetchFaFile('fontawesome.min.css'),
2594 ])).flat())];
2595}
2596/**
2597 * Opens a popup with all the available Font Awesome icons and returns the selected icon's name.
2598 * @prop {string[]} customList A custom list of Font Awesome icons to use instead of all available icons.
2599 * @returns {Promise<string>} The icon name (fa-pencil) or null if cancelled.
2600 */
2601export async function showFontAwesomePicker(customList = null) {
2602 const faList = customList ?? await fetchFa();
2603 const fas = {};
2604 const dom = document.createElement('div'); {
2605 dom.classList.add('faPicker-container');
2606 const search = document.createElement('div'); {
2607 search.classList.add('faQuery-container');
2608 const qry = document.createElement('input'); {
2609 qry.classList.add('text_pole');
2610 qry.classList.add('faQuery');
2611 qry.type = 'search';
2612 qry.placeholder = 'Filter icons';
2613 qry.autofocus = true;
2614 const qryDebounced = debounce(() => {
2615 const result = faList.filter(fa => fa.find(className => className.includes(qry.value.toLowerCase())));
2616 for (const fa of faList) {
2617 if (!result.includes(fa)) {
2618 fas[fa].classList.add('hidden');
2619 } else {
2620 fas[fa].classList.remove('hidden');
2621 }
2622 }
2623 });
2624 qry.addEventListener('input', () => qryDebounced());
2625 search.append(qry);
2626 }
2627 dom.append(search);
2628 }
2629 const grid = document.createElement('div'); {
2630 grid.classList.add('faPicker');
2631 for (const fa of faList) {
2632 const opt = document.createElement('div'); {
2633 fas[fa] = opt;
2634 opt.classList.add('menu_button');
2635 opt.classList.add('fa-solid');
2636 opt.classList.add(fa[0]);
2637 opt.title = fa.map(it => it.slice(3)).join(', ');
2638 opt.dataset.result = POPUP_RESULT.AFFIRMATIVE.toString();
2639 opt.addEventListener('click', () => value = fa[0]);
2640 grid.append(opt);
2641 }
2642 }
2643 dom.append(grid);
2644 }
2645 }
2646 let value = '';
2647 const picker = new Popup(dom, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true, okButton: 'No Icon', cancelButton: 'Cancel' });
2648 await picker.show();
2649 if (picker.result == POPUP_RESULT.AFFIRMATIVE) {
2650 return value;
2651 }
2652 return null;
2653}
2654
2655/**
2656 * Finds a persona by name, with optional filtering and precedence for avatars
2657 * @param {object} [options={}] - The options for the search
2658 * @param {string?} [options.name=null] - The name to search for
2659 * @param {boolean} [options.allowAvatar=true] - Whether to allow searching by avatar
2660 * @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
2661 * @param {boolean} [options.preferCurrentPersona=true] - Whether to prefer the current persona(s)
2662 * @param {boolean} [options.quiet=false] - Whether to suppress warnings
2663 * @returns {PersonaViewModel} The persona object
2664 * @typedef {object} PersonaViewModel
2665 * @property {string} avatar - The avatar of the persona
2666 * @property {string} name - The name of the persona
2667 */
2668export function findPersona({ name = null, allowAvatar = true, insensitive = true, preferCurrentPersona = true, quiet = false } = {}) {
2669 /** @type {PersonaViewModel[]} */
2670 const personas = Object.entries(power_user.personas).map(([avatar, name]) => ({ avatar, name }));
2671 const matches = (/** @type {PersonaViewModel} */ persona) => !name || (allowAvatar && persona.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(persona.name, name) : persona.name === name);
2672
2673 // If we have a current persona and prefer it, return that if it matches
2674 const currentPersona = personas.find(a => a.avatar === user_avatar);
2675 if (preferCurrentPersona && currentPersona && matches(currentPersona)) {
2676 return currentPersona;
2677 }
2678
2679 // If allowAvatar is true, search by avatar first
2680 if (allowAvatar && name) {
2681 const personaByAvatar = personas.find(a => a.avatar === name);
2682 if (personaByAvatar && matches(personaByAvatar)) {
2683 return personaByAvatar;
2684 }
2685 }
2686
2687 // Search for matching personas by name
2688 const matchingPersonas = personas.filter(a => matches(a));
2689 if (matchingPersonas.length > 1) {
2690 if (!quiet) toastr.warning(t`Multiple personas found for given conditions.`);
2691 else console.warn(t`Multiple personas found for given conditions. Returning the first match.`);
2692 }
2693
2694 return matchingPersonas[0] || null;
2695}
2696
2697/**
2698 * Finds a character by name, with optional filtering and precedence for avatars
2699 * @param {object} [options={}] - The options for the search
2700 * @param {string?} [options.name=null] - The name to search for
2701 * @param {boolean} [options.allowAvatar=true] - Whether to allow searching by avatar
2702 * @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
2703 * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
2704 * @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s)
2705 * @param {boolean} [options.quiet=false] - Whether to suppress warnings
2706 * @returns {Character?} - The found character or null if not found
2707 */
2708export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) {
2709 const matches = (char) => !name || (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name);
2710
2711 // Filter characters by tags if provided
2712 let filteredCharacters = characters;
2713 if (filteredByTags) {
2714 filteredCharacters = characters.filter(char => {
2715 const charTags = getTagsList(char.avatar, false);
2716 return filteredByTags.every(tagName => charTags.some(x => x.name == tagName));
2717 });
2718 }
2719
2720 // Get the current character(s)
2721 /** @type {any[]} */
2722 const currentChars = selected_group ? groups.find(group => group.id === selected_group)?.members.map(member => filteredCharacters.find(char => char.avatar === member))
2723 : filteredCharacters.filter(char => characters[this_chid]?.avatar === char.avatar);
2724
2725 // If we have a current char and prefer it, return that if it matches
2726 if (preferCurrentChar) {
2727 const preferredCharSearch = currentChars.filter(matches);
2728 if (preferredCharSearch.length > 1) {
2729 if (!quiet) toastr.warning(t`Multiple characters found for given conditions.`);
2730 else console.warn(t`Multiple characters found for given conditions. Returning the first match.`);
2731 }
2732 if (preferredCharSearch.length) {
2733 return preferredCharSearch[0];
2734 }
2735 }
2736
2737 // If allowAvatar is true, search by avatar first
2738 if (allowAvatar && name) {
2739 const characterByAvatar = filteredCharacters.find(char => char.avatar === name || (!name.endsWith('.png') && char.avatar === `${name}.png`));
2740 if (characterByAvatar) {
2741 return characterByAvatar;
2742 }
2743 }
2744
2745 // Search for matching characters by name
2746 const matchingCharacters = name ? filteredCharacters.filter(matches) : filteredCharacters;
2747 if (matchingCharacters.length > 1) {
2748 if (!quiet) toastr.warning('Multiple characters found for given conditions.');
2749 else console.warn('Multiple characters found for given conditions. Returning the first match.');
2750 }
2751
2752 return matchingCharacters[0] || null;
2753}
2754
2755/**
2756 * Gets the index of a character based on the character object
2757 * @param {object} char - The character object to find the index for
2758 * @throws {Error} If the character is not found
2759 * @returns {number} The index of the character in the characters array
2760 */
2761export function getCharIndex(char) {
2762 if (!char) throw new Error('Character is undefined');
2763 const index = characters.findIndex(c => c.avatar === char.avatar);
2764 if (index === -1) throw new Error(`Character not found: ${char.avatar}`);
2765 return index;
2766}
2767
2768/**
2769 * Compares two arrays for equality
2770 * @param {any[]} a - The first array
2771 * @param {any[]} b - The second array
2772 * @returns {boolean} True if the arrays are equal, false otherwise
2773 */
2774export function arraysEqual(a, b) {
2775 if (a === b) return true;
2776 if (a == null || b == null) return false;
2777 if (a.length !== b.length) return false;
2778
2779 for (let i = 0; i < a.length; i++) {
2780 if (a[i] !== b[i]) return false;
2781 }
2782 return true;
2783}
2784
2785/**
2786 * Updates the content and style of an information block
2787 * @param {string | HTMLElement} target - The CSS selector or the HTML element of the information block
2788 * @param {string | HTMLElement?} content - The message to display inside the information block (supports HTML) or an HTML element
2789 * @param {'hint' | 'info' | 'warning' | 'error'} [type='info'] - The type of message, which determines the styling of the information block
2790 */
2791export function setInfoBlock(target, content, type = 'info') {
2792 if (!content) {
2793 clearInfoBlock(target);
2794 return;
2795 }
2796
2797 const infoBlock = typeof target === 'string' ? document.querySelector(target) : target;
2798 if (infoBlock) {
2799 infoBlock.className = `info-block ${type}`;
2800 if (typeof content === 'string') {
2801 infoBlock.innerHTML = content;
2802 } else {
2803 infoBlock.innerHTML = '';
2804 infoBlock.appendChild(content);
2805 }
2806 }
2807}
2808
2809/**
2810 * Clears the content and style of an information block.
2811 * @param {string | HTMLElement} target - The CSS selector or the HTML element of the information block
2812 */
2813export function clearInfoBlock(target) {
2814 const infoBlock = typeof target === 'string' ? document.querySelector(target) : target;
2815 if (infoBlock && infoBlock.classList.contains('info-block')) {
2816 infoBlock.className = '';
2817 infoBlock.innerHTML = '';
2818 }
2819}
2820
2821/**
2822 * Provides a matcher function for select2 that matches both the text and value of options.
2823 * @param {import('select2').SearchOptions} params
2824 * @param {import('select2').OptGroupData|import('select2').OptionData} data
2825 * @return {import('select2').OptGroupData|import('select2').OptionData|null}
2826 */
2827export function textValueMatcher(params, data) {
2828 // Always return the object if there is nothing to compare
2829 if (params.term == null || params.term.trim() === '') {
2830 return data;
2831 }
2832
2833 // Do a recursive check for options with children
2834 if (data.children && data.children.length > 0) {
2835 // Clone the data object if there are children
2836 // This is required as we modify the object to remove any non-matches
2837 const match = $.extend(true, {}, data);
2838
2839 // Check each child of the option
2840 for (let c = data.children.length - 1; c >= 0; c--) {
2841 const child = data.children[c];
2842
2843 const matches = textValueMatcher(params, child);
2844
2845 // If there wasn't a match, remove the object in the array
2846 if (matches == null) {
2847 match.children.splice(c, 1);
2848 }
2849 }
2850
2851 // If any children matched, return the new object
2852 if (match.children.length > 0) {
2853 return match;
2854 }
2855
2856 // If there were no matching children, check just the plain object
2857 return textValueMatcher(params, match);
2858 }
2859
2860 const textMatch = compareIgnoreCaseAndAccents(data.text, params.term, (a, b) => a.indexOf(b) > -1);
2861 const valueMatch = data.element instanceof HTMLOptionElement && compareIgnoreCaseAndAccents(data.element.value, params.term, (a, b) => a.indexOf(b) > -1);
2862
2863 if (textMatch || valueMatch) {
2864 return data;
2865 }
2866
2867 // If it doesn't contain the term, don't return anything
2868 return null;
2869}
2870
2871/**
2872 * Compares two version numbers, returning true if srcVersion >= minVersion
2873 * @param {string} srcVersion The current version.
2874 * @param {string} minVersion The target version number to test against
2875 * @returns {boolean} True if srcVersion >= minVersion, false if not
2876 */
2877export function versionCompare(srcVersion, minVersion) {
2878 return (srcVersion || '0.0.0').localeCompare(minVersion, undefined, { numeric: true, sensitivity: 'base' }) > -1;
2879}
2880
2881/**
2882 * Logs a warning to the console for slash command executions.
2883 * Strips internal arguments (starting with '_') from the args object for cleaner logging.
2884 * @param {string} message - The warning message to log.
2885 * @param {Object} args - The arguments object from the slash command, including named arguments and internal values.
2886 * @param {{[unnamedArgName: string]: string}} [valueObj=null] - The user-built object containing context for the warning (e.g., { uid: uid }).
2887 * @returns {void}
2888 */
2889export function logSlashCommandWarn(message, args, valueObj = null) {
2890 if (valueObj !== null && valueObj !== undefined) {
2891 console.warn(message, valueObj, stripInternalArgs(args));
2892 } else {
2893 console.warn(message, stripInternalArgs(args));
2894 }
2895 return;
2896 function stripInternalArgs(args) {
2897 // strip all args/properties that start with an underscore
2898 const result = {};
2899 for (const [key, value] of Object.entries(args)) {
2900 if (!key.startsWith('_')) {
2901 result[key] = value;
2902 }
2903 }
2904 return result;
2905 }
2906}
2907
2908/**
2909 * Sets up the scroll-to-top button functionality.
2910 * @param {object} params Parameters object
2911 * @param {string} params.scrollContainerId Scrollable container element ID
2912 * @param {string} params.buttonId Button element ID
2913 * @param {string} params.drawerId Drawer element ID
2914 * @param {number} [params.visibilityThreshold] Scroll position (px) to show the button (default: 300)
2915 * @returns {() => void} Cleanup function to remove event listeners
2916 */
2917export function setupScrollToTop({ scrollContainerId, buttonId, drawerId, visibilityThreshold = 300 }) {
2918 const scrollContainer = document.getElementById(scrollContainerId);
2919 const btn = document.getElementById(buttonId);
2920 const drawer = document.getElementById(drawerId);
2921
2922 if (!btn || !drawer) {
2923 // Not fatal; the drawer or button may not exist in some builds. Use debug level.
2924 console.debug('Scroll-to-top: button or drawer not found during setup.');
2925 return () => { /* noop cleanup */ };
2926 }
2927
2928 if (!scrollContainer) {
2929 console.debug('Scroll-to-top: scroll container not found during setup.');
2930 return () => { /* noop cleanup */ };
2931 }
2932
2933 const updateButtonVisibility = () => btn.classList.toggle('visible', scrollContainer.scrollTop > visibilityThreshold);
2934 const updateButtonVisibilityThrottled = lodash.throttle(updateButtonVisibility, debounce_timeout.standard, { leading: true, trailing: true });
2935 const onScroll = () => updateButtonVisibilityThrottled();
2936 scrollContainer.addEventListener('scroll', onScroll, { passive: true });
2937
2938 // Scroll to top on click (button semantics provide keyboard activation natively)
2939 const onActivate = (/** @type {MouseEvent} */ e) => {
2940 e.preventDefault();
2941 e.stopPropagation();
2942
2943 const userPrefersReduced = power_user.reduced_motion;
2944 scrollContainer.scrollTo({ top: 0, behavior: userPrefersReduced ? 'auto' : 'smooth' });
2945 };
2946 btn.addEventListener('click', onActivate);
2947
2948 let frameHandle = null;
2949 const resizeObserver = new ResizeObserver(() => {
2950 if (frameHandle !== null) {
2951 cancelAnimationFrame(frameHandle);
2952 }
2953 frameHandle = requestAnimationFrame(() => {
2954 updateButtonVisibilityThrottled();
2955 });
2956 });
2957 resizeObserver.observe(drawer);
2958
2959 // Initial state check
2960 updateButtonVisibility();
2961
2962 // Return cleanup function for caller to hold and invoke when appropriate
2963 return () => {
2964 scrollContainer.removeEventListener('scroll', onScroll);
2965 btn.removeEventListener('click', onActivate);
2966 resizeObserver.disconnect();
2967 };
2968}
2969
2970/**
2971 * Imports content from an external URL.
2972 * @param {string} url URL or UUID of the content to import.
2973 * @param {Object} [options={}] Options object.
2974 * @param {string|null} [options.preserveFileName=null] Optional file name to use for the imported content.
2975 * @returns {Promise<void>} A promise that resolves when the import is complete.
2976 */
2977export async function importFromExternalUrl(url, { preserveFileName = null } = {}) {
2978 let request;
2979
2980 if (isValidUrl(url)) {
2981 console.debug('Custom content import started for URL: ', url);
2982 request = await fetch('/api/content/importURL', {
2983 method: 'POST',
2984 headers: getRequestHeaders(),
2985 body: JSON.stringify({ url }),
2986 });
2987 } else {
2988 console.debug('Custom content import started for Char UUID: ', url);
2989 request = await fetch('/api/content/importUUID', {
2990 method: 'POST',
2991 headers: getRequestHeaders(),
2992 body: JSON.stringify({ url }),
2993 });
2994 }
2995
2996 if (!request.ok) {
2997 toastr.info(request.statusText, 'Custom content import failed');
2998 console.error('Custom content import failed', request.status, request.statusText);
2999 return;
3000 }
3001
3002 const data = await request.blob();
3003 const customContentType = request.headers.get('X-Custom-Content-Type');
3004 let fileName = request.headers.get('Content-Disposition').split('filename=')[1].replace(/"/g, '');
3005 const file = new File([data], fileName, { type: data.type });
3006
3007 const extraData = new Map();
3008 if (preserveFileName) {
3009 fileName = preserveFileName;
3010 extraData.set(file, preserveFileName);
3011 }
3012
3013 switch (customContentType) {
3014 case 'character':
3015 await processDroppedFiles([file], extraData);
3016 break;
3017 case 'lorebook':
3018 await importWorldInfo(file);
3019 break;
3020 default:
3021 toastr.warning('Unknown content type');
3022 console.error('Unknown content type', customContentType);
3023 break;
3024 }
3025}
3026
3027/**
3028 * If value is less than min, it's set to min.
3029 * If value is greater than max, it's set to max.
3030 * @param {number} value The target value.
3031 * @param {number} min The minimum for value.
3032 * @param {number} max The maximum for value.
3033 * @returns {number} The clamped value.
3034 */
3035export const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
3036
3037/**
3038 * Shakes the targetElement.
3039 * @param {HTMLElement|JQuery<HTMLElement>} targetElement
3040 * @param {number} distance Distance in pixels.
3041 * @param {number} duration Duration in milliseconds.
3042 * @param {string} easing CSS easing function.
3043 */
3044export function shakeElement(targetElement, distance = 10, duration = 100, easing = 'ease-in-out') {
3045 // Don't call the JQuery animation.
3046 // https://developer.mozilla.org/en-US/docs/Web/API/Element/animate
3047 if (targetElement instanceof jQuery) targetElement = targetElement[0];
3048
3049 return targetElement.animate([
3050 { transform: 'translateX(0)' },
3051 { transform: `translateX(${distance}px)` },
3052 { transform: 'translateX(0)' },
3053 ], { duration, easing });
3054}
3055
3056/**
3057 * Creates a promise that rejects after a specified delay.
3058 * Used for Promise.race fallbacks.
3059 * @param {number} ms The delay in milliseconds.
3060 * @param {string?} [errorMessage='']
3061 * @returns {Promise<never>} A promise that rejects.
3062 */
3063export function createTimeout(ms, errorMessage = '') {
3064 errorMessage ??= `Operation timed out after ${ms}ms.`;
3065 return new Promise((_, reject) => {
3066 setTimeout(() => reject(new Error(errorMessage)), ms);
3067 });
3068}
3069
3070/**
3071 * Registers a long-press (touch hold) event as an alternative to modifier+click.
3072 * Supports event delegation for dynamically created elements.
3073 * @param {string} selector CSS selector for target elements
3074 * @param {(e: TouchEvent) => void} callback Callback to invoke on long-press, `this` is the matched element
3075 * @param {number} [delay=500] Long-press duration in ms
3076 */
3077export function addLongPressEvent(selector, callback, delay = 500) {
3078 let timer = null;
3079 let fired = false;
3080 let target = null;
3081
3082 document.addEventListener('touchstart', function (event) {
3083 if (!(event.target instanceof Element)) return;
3084 const el = event.target.closest(selector);
3085 if (!el) return;
3086 target = el;
3087 fired = false;
3088 timer = setTimeout(() => {
3089 fired = true;
3090 event.preventDefault();
3091 callback.call(el, event);
3092 }, delay);
3093 }, { passive: false });
3094
3095 document.addEventListener('touchend', cancelTimer);
3096 document.addEventListener('touchmove', cancelTimer);
3097 document.addEventListener('touchcancel', cancelTimer);
3098
3099 document.addEventListener('click', function (event) {
3100 if (fired && target && target.contains(event.target)) {
3101 event.preventDefault();
3102 event.stopImmediatePropagation();
3103 fired = false;
3104 target = null;
3105 }
3106 }, true);
3107
3108 function cancelTimer() {
3109 clearTimeout(timer);
3110 timer = null;
3111 }
3112}