| 1 | import { |
| 2 | moment, |
| 3 | DOMPurify, |
| 4 | Readability, |
| 5 | isProbablyReaderable, |
| 6 | lodash, |
| 7 | } from '../lib.js'; |
| 8 | |
| 9 | import { getContext } from './extensions.js'; |
| 10 | import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js'; |
| 11 | import { isMobile } from './RossAscends-mods.js'; |
| 12 | import { collapseNewlines, power_user } from './power-user.js'; |
| 13 | import { debounce_timeout } from './constants.js'; |
| 14 | import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; |
| 15 | import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; |
| 16 | import { getTagsList } from './tags.js'; |
| 17 | import { groups, selected_group } from './group-chats.js'; |
| 18 | import { getCurrentLocale, t } from './i18n.js'; |
| 19 | import { importWorldInfo } from './world-info.js'; |
| 20 | |
| 21 | export const shiftUpByOne = (e, i, a) => a[i] = e + 1; |
| 22 | export const shiftDownByOne = (e, i, a) => a[i] = e - 1; |
| 23 | |
| 24 | /** |
| 25 | * Pagination status string template. |
| 26 | * @type {string} |
| 27 | */ |
| 28 | export const PAGINATION_TEMPLATE = '<%= rangeStart %>-<%= rangeEnd %> .. <%= totalNumber %>'; |
| 29 | |
| 30 | export 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 | */ |
| 42 | export 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 | */ |
| 67 | export 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 | |
| 89 | export 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 | */ |
| 99 | export 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 | */ |
| 109 | export 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 | */ |
| 119 | export 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 | */ |
| 141 | export 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 | */ |
| 154 | export function escapeHtml(str) { |
| 155 | return String(str ?? '') |
| 156 | .replace(/&/g, '&') |
| 157 | .replace(/</g, '<') |
| 158 | .replace(/>/g, '>') |
| 159 | .replace(/"/g, '"') |
| 160 | .replace(/'/g, '''); |
| 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 | */ |
| 169 | export function sanitizeSelector(str, replacement = '_') { |
| 170 | return String(str).replace(/[^a-z0-9_-]/ig, replacement); |
| 171 | } |
| 172 | |
| 173 | export 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 | */ |
| 187 | export 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 | */ |
| 196 | export 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 | */ |
| 208 | export 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 | */ |
| 279 | export 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 | */ |
| 308 | export 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 | */ |
| 319 | export 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 | */ |
| 330 | export 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 | */ |
| 342 | export 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 | */ |
| 354 | export 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 | */ |
| 362 | export function getSortableDelay() { |
| 363 | return isMobile() ? 750 : 50; |
| 364 | } |
| 365 | |
| 366 | export 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 | */ |
| 384 | export 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 | */ |
| 405 | export 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 | */ |
| 420 | export 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 | */ |
| 441 | export 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 | */ |
| 450 | export 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 | */ |
| 467 | export 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 | */ |
| 485 | export 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 | */ |
| 503 | export 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 | */ |
| 522 | export 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 | */ |
| 546 | export 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 | */ |
| 566 | const 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 | */ |
| 574 | export 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 | */ |
| 592 | export 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 | */ |
| 618 | export 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 | */ |
| 631 | export 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 | */ |
| 648 | export 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 | */ |
| 673 | export 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 | */ |
| 701 | export 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 | */ |
| 720 | export 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 | */ |
| 730 | export 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 | */ |
| 741 | export 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 | */ |
| 757 | export 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 | */ |
| 771 | export 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 | */ |
| 804 | export 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 | |
| 825 | export 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 | */ |
| 835 | export 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 | */ |
| 857 | export 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 | |
| 869 | export 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 | */ |
| 883 | export 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 | |
| 914 | export 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 | */ |
| 948 | export 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 | */ |
| 975 | export 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 | */ |
| 994 | export 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 | */ |
| 1011 | export 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 | */ |
| 1020 | export 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 | */ |
| 1029 | export 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 | */ |
| 1051 | export 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 | */ |
| 1061 | export 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 | |
| 1071 | const 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 | */ |
| 1079 | export 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 | */ |
| 1096 | function 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 | */ |
| 1157 | export 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 | */ |
| 1199 | export 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 | */ |
| 1209 | export 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 | */ |
| 1227 | export 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 | */ |
| 1248 | export 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 | */ |
| 1287 | export 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 | */ |
| 1323 | export 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 | */ |
| 1343 | export 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 | */ |
| 1357 | export 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 | */ |
| 1378 | export 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 | */ |
| 1388 | export 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 | |
| 1405 | export 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 | */ |
| 1440 | export 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 | */ |
| 1503 | export 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 | */ |
| 1617 | export 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 | */ |
| 1652 | export 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 | */ |
| 1683 | export 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 | */ |
| 1693 | export 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 | */ |
| 1719 | export 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 | */ |
| 1748 | export 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 | */ |
| 1782 | export 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 | */ |
| 1846 | export 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 | */ |
| 1862 | export 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 | */ |
| 1876 | export 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 | */ |
| 1892 | export 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 | */ |
| 1935 | export 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 | */ |
| 1961 | export 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 | */ |
| 1977 | export function collapseSpaces(s) { |
| 1978 | return s.replace(/\s+/g, ' ').trim(); |
| 1979 | } |
| 1980 | |
| 1981 | function 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 | */ |
| 2012 | export 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 | */ |
| 2030 | export 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 | */ |
| 2053 | export 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 | */ |
| 2065 | export async function extractTextFromMarkdown(blob) { |
| 2066 | const markdown = await blob.text(); |
| 2067 | const text = postProcessText(markdown, false); |
| 2068 | return text; |
| 2069 | } |
| 2070 | |
| 2071 | export 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 | */ |
| 2103 | export 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 | */ |
| 2146 | export 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 | */ |
| 2168 | export 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 | */ |
| 2185 | export 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 | */ |
| 2212 | export 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 | */ |
| 2224 | export 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 | */ |
| 2244 | export 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 | */ |
| 2263 | export 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 | */ |
| 2275 | export 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 | */ |
| 2285 | export 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 | */ |
| 2302 | export 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 | */ |
| 2315 | export 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 | */ |
| 2347 | export 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 | */ |
| 2373 | export 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 | */ |
| 2388 | export 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 | */ |
| 2421 | export function highlightRegex(regexStr) { |
| 2422 | // Function to escape special characters for safety or readability |
| 2423 | const escape = (str) => str.replace(/[&<>"'\x01]/g, match => ({ |
| 2424 | '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''', '\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 | */ |
| 2484 | export 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 | */ |
| 2514 | export 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 | */ |
| 2533 | export 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 | */ |
| 2571 | export function setDatasetProperty(element, name, value) { |
| 2572 | if (value === null) { |
| 2573 | delete element.dataset[name]; |
| 2574 | } else { |
| 2575 | element.dataset[name] = value; |
| 2576 | } |
| 2577 | } |
| 2578 | |
| 2579 | export 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 | |
| 2591 | export 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 | */ |
| 2601 | export 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 | */ |
| 2668 | export 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 | */ |
| 2708 | export 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 | */ |
| 2761 | export 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 | */ |
| 2774 | export 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 | */ |
| 2791 | export 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 | */ |
| 2813 | export 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 | */ |
| 2827 | export 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 | */ |
| 2877 | export 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 | */ |
| 2889 | export 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 | */ |
| 2917 | export 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 | */ |
| 2977 | export 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 | */ |
| 3035 | export 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 | */ |
| 3044 | export 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 | */ |
| 3063 | export 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 | */ |
| 3077 | export 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 | } |