Merge branch 'staging' into webpack-memory

c0b37631bc783a0368803531bde190038c8fe9a0

Cohee <18619528+Cohee1207@users.noreply.github.com>

6 files changed, +258 -26Ignore whitespace
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js+28 -15
@@ -419,30 +419,35 @@ export class SlashCommandHandler {
419 namedArgumentList: [419 namedArgumentList: [
420 SlashCommandNamedArgument.fromProps({420 SlashCommandNamedArgument.fromProps({
421 name: 'set',421 name: 'set',
422 description: 'QR set name',422 description: 'Name of QR set to add the context menu to',
423 typeList: [ARGUMENT_TYPE.STRING],423 typeList: [ARGUMENT_TYPE.STRING],
424 isRequired: true,424 isRequired: true,
425 enumProvider: localEnumProviders.qrSets,425 enumProvider: localEnumProviders.qrSets,
426 }),426 }),
427 SlashCommandNamedArgument.fromProps({427 SlashCommandNamedArgument.fromProps({
428 name: 'label',428 name: 'label',
429 description: 'Quick Reply label',429 description: 'Label of Quick Reply to add the context menu to',
430 typeList: [ARGUMENT_TYPE.STRING],430 typeList: [ARGUMENT_TYPE.STRING],
431 enumProvider: localEnumProviders.qrEntries,431 enumProvider: localEnumProviders.qrEntries,
432 }),432 }),
433 SlashCommandNamedArgument.fromProps({433 SlashCommandNamedArgument.fromProps({
434 name: 'id',434 name: 'id',
435 description: 'numeric ID of the QR, e.g., id=42',435 description: 'Numeric ID of Quick Reply to add the context menu to, e.g. id=42',
436 typeList: [ARGUMENT_TYPE.NUMBER],436 typeList: [ARGUMENT_TYPE.NUMBER],
437 enumProvider: localEnumProviders.qrIds,437 enumProvider: localEnumProviders.qrIds,
438 }),438 }),
439 new SlashCommandNamedArgument(439 new SlashCommandNamedArgument(
440 'chain', 'boolean', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',440 'chain',
441 'If true, button QR is sent together with (before) the clicked QR from the context menu',
442 [ARGUMENT_TYPE.BOOLEAN],
443 false,
444 false,
445 'false',
441 ),446 ),
442 ],447 ],
443 unnamedArgumentList: [448 unnamedArgumentList: [
444 SlashCommandArgument.fromProps({449 SlashCommandArgument.fromProps({
445 description: 'QR set name',450 description: 'Name of QR set to add as a context menu',
446 typeList: [ARGUMENT_TYPE.STRING],451 typeList: [ARGUMENT_TYPE.STRING],
447 isRequired: true,452 isRequired: true,
448 enumProvider: localEnumProviders.qrSets,453 enumProvider: localEnumProviders.qrSets,
@@ -450,13 +455,16 @@ export class SlashCommandHandler {
450 ],455 ],
451 helpString: `456 helpString: `
452 <div>457 <div>
453 Add context menu preset to a QR.458 Add a context menu preset to a QR.
459 </div>
460 <div>
461 If <code>id</code> and <code>label</code> are both provided, <code>id</code> will be used.
454 </div>462 </div>
455 <div>463 <div>
456 <strong>Example:</strong>464 <strong>Example:</strong>
457 <ul>465 <ul>
458 <li>466 <li>
459 <pre><code>/qr-contextadd set=MyPreset label=MyButton chain=true MyOtherPreset</code></pre>467 <pre><code>/qr-contextadd set=MyQRSetWithTheButton label=MyButton chain=true MyQRSetWithContextItems</code></pre>
460 </li>468 </li>
461 </ul>469 </ul>
462 </div>470 </div>
@@ -470,27 +478,27 @@ export class SlashCommandHandler {
470 namedArgumentList: [478 namedArgumentList: [
471 SlashCommandNamedArgument.fromProps({479 SlashCommandNamedArgument.fromProps({
472 name: 'set',480 name: 'set',
473 description: 'QR set name',481 description: 'Name of QR set to remove the context menu from',
474 typeList: [ARGUMENT_TYPE.STRING],482 typeList: [ARGUMENT_TYPE.STRING],
475 isRequired: true,483 isRequired: true,
476 enumProvider: localEnumProviders.qrSets,484 enumProvider: localEnumProviders.qrSets,
477 }),485 }),
478 SlashCommandNamedArgument.fromProps({486 SlashCommandNamedArgument.fromProps({
479 name: 'label',487 name: 'label',
480 description: 'Quick Reply label',488 description: 'Label of Quick Reply to remove the context menu from',
481 typeList: [ARGUMENT_TYPE.STRING],489 typeList: [ARGUMENT_TYPE.STRING],
482 enumProvider: localEnumProviders.qrEntries,490 enumProvider: localEnumProviders.qrEntries,
483 }),491 }),
484 SlashCommandNamedArgument.fromProps({492 SlashCommandNamedArgument.fromProps({
485 name: 'id',493 name: 'id',
486 description: 'numeric ID of the QR, e.g., id=42',494 description: 'Numeric ID of Quick Reply to remove the context menu from, e.g. id=42',
487 typeList: [ARGUMENT_TYPE.NUMBER],495 typeList: [ARGUMENT_TYPE.NUMBER],
488 enumProvider: localEnumProviders.qrIds,496 enumProvider: localEnumProviders.qrIds,
489 }),497 }),
490 ],498 ],
491 unnamedArgumentList: [499 unnamedArgumentList: [
492 SlashCommandArgument.fromProps({500 SlashCommandArgument.fromProps({
493 description: 'QR set name',501 description: 'Name of QR set to remove',
494 typeList: [ARGUMENT_TYPE.STRING],502 typeList: [ARGUMENT_TYPE.STRING],
495 isRequired: true,503 isRequired: true,
496 enumProvider: localEnumProviders.qrSets,504 enumProvider: localEnumProviders.qrSets,
@@ -501,6 +509,9 @@ export class SlashCommandHandler {
501 Remove context menu preset from a QR.509 Remove context menu preset from a QR.
502 </div>510 </div>
503 <div>511 <div>
512 If <code>id</code> and <code>label</code> are both provided, <code>id</code> will be used.
513 </div>
514 <div>
504 <strong>Example:</strong>515 <strong>Example:</strong>
505 <ul>516 <ul>
506 <li>517 <li>
@@ -542,6 +553,9 @@ export class SlashCommandHandler {
542 Remove all context menu presets from a QR.553 Remove all context menu presets from a QR.
543 </div>554 </div>
544 <div>555 <div>
556 If <code>id</code> and a label are both provided, <code>id</code> will be used.
557 </div>
558 <div>
545 <strong>Example:</strong>559 <strong>Example:</strong>
546 <ul>560 <ul>
547 <li>561 <li>
@@ -908,12 +922,11 @@ export class SlashCommandHandler {
908 }922 }
909 }923 }
910924
911
912 createContextItem(args, name) {925 createContextItem(args, name) {
913 try {926 try {
914 this.api.createContextItem(927 this.api.createContextItem(
915 args.set,928 args.set,
916 args.label,929 args.id !== undefined ? Number(args.id) : args.label,
917 name,930 name,
918 isTrueBoolean(args.chain),931 isTrueBoolean(args.chain),
919 );932 );
@@ -923,14 +936,14 @@ export class SlashCommandHandler {
923 }936 }
924 deleteContextItem(args, name) {937 deleteContextItem(args, name) {
925 try {938 try {
926 this.api.deleteContextItem(args.set, args.label, name);939 this.api.deleteContextItem(args.set, args.id !== undefined ? Number(args.id) : args.label, name);
927 } catch (ex) {940 } catch (ex) {
928 toastr.error(ex.message);941 toastr.error(ex.message);
929 }942 }
930 }943 }
931 clearContextMenu(args, label) {944 clearContextMenu(args, label) {
932 try {945 try {
933 this.api.clearContextMenu(args.set, args.label ?? label);946 this.api.clearContextMenu(args.set, args.id !== undefined ? Number(args.id) : args.label ?? label);
934 } catch (ex) {947 } catch (ex) {
935 toastr.error(ex.message);948 toastr.error(ex.message);
936 }949 }
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js+5 -1
@@ -19,7 +19,7 @@ export class ContextMenu {
19 this.itemList = this.build(qr).children;19 this.itemList = this.build(qr).children;
20 this.itemList.forEach(item => {20 this.itemList.forEach(item => {
21 item.onExpand = () => {21 item.onExpand = () => {
22 this.itemList.filter(it => it != item)22 this.itemList.filter(it => it !== item)
23 .forEach(it => it.collapse());23 .forEach(it => it.collapse());
24 };24 };
25 });25 });
@@ -36,7 +36,9 @@ export class ContextMenu {
36 icon: qr.icon,36 icon: qr.icon,
37 showLabel: qr.showLabel,37 showLabel: qr.showLabel,
38 label: qr.label,38 label: qr.label,
39 title: qr.title,
39 message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,40 message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
41 isHidden: qr.isHidden,
40 children: [],42 children: [],
41 };43 };
42 qr.contextList.forEach((cl) => {44 qr.contextList.forEach((cl) => {
@@ -51,7 +53,9 @@ export class ContextMenu {
51 subTree.icon,53 subTree.icon,
52 subTree.showLabel,54 subTree.showLabel,
53 subTree.label,55 subTree.label,
56 subTree.title,
54 subTree.message,57 subTree.message,
58 subTree.isHidden,
55 (evt) => {59 (evt) => {
56 evt.stopPropagation();60 evt.stopPropagation();
57 const finalQr = Object.assign(new QuickReply(), subQr);61 const finalQr = Object.assign(new QuickReply(), subQr);
public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js+1 -1
@@ -2,7 +2,7 @@ import { MenuItem } from './MenuItem.js';
22
3export class MenuHeader extends MenuItem {3export class MenuHeader extends MenuItem {
4 constructor(/**@type {String}*/label) {4 constructor(/**@type {String}*/label) {
5 super(null, null, label, null, null);5 super(null, null, label, null, null, false, null, []);
6 }6 }
77
88
public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js+39 -7
@@ -4,11 +4,12 @@ export class MenuItem {
4 /**@type {string}*/ icon;4 /**@type {string}*/ icon;
5 /**@type {boolean}*/ showLabel;5 /**@type {boolean}*/ showLabel;
6 /**@type {string}*/ label;6 /**@type {string}*/ label;
7 /**@type {string}*/ title;
7 /**@type {object}*/ value;8 /**@type {object}*/ value;
9 /**@type {boolean}*/ isHidden = false;
8 /**@type {function}*/ callback;10 /**@type {function}*/ callback;
9 /**@type {MenuItem[]}*/ childList = [];11 /**@type {MenuItem[]}*/ childList = [];
10 /**@type {SubMenu}*/ subMenu;12 /**@type {SubMenu}*/ subMenu;
11 /**@type {boolean}*/ isForceExpanded = false;
1213
13 /**@type {HTMLElement}*/ root;14 /**@type {HTMLElement}*/ root;
1415
@@ -19,35 +20,67 @@ export class MenuItem {
1920
20 /**21 /**
21 *22 *
22 * @param {string} icon23 * @param {?string} icon
23 * @param {boolean} showLabel24 * @param {?boolean} showLabel
24 * @param {string} label25 * @param {string} label
26 * @param {?string} title Tooltip
25 * @param {object} value27 * @param {object} value
28 * @param {boolean} isHidden QR is Invisible (auto-execute only)
26 * @param {function} callback29 * @param {function} callback
27 * @param {MenuItem[]} children30 * @param {MenuItem[]} children
28 */31 */
29 constructor(icon, showLabel, label, value, callback, children = []) {32 constructor(icon, showLabel, label, title, value, isHidden, callback, children = []) {
30 this.icon = icon;33 this.icon = icon;
31 this.showLabel = showLabel;34 this.showLabel = showLabel;
32 this.label = label;35 this.label = label;
36 this.title = title;
33 this.value = value;37 this.value = value;
38 this.isHidden = isHidden;
34 this.callback = callback;39 this.callback = callback;
35 this.childList = children;40 this.childList = children;
36 }41 }
3742
3843
44 /**
45 * Renders the MenuItem
46 *
47 * A .qr--hidden class is added to:
48 * - the item if it is "Invisible (auto-execute only)"
49 * - the icon if no icon is set
50 * - the label if an icon is set and showLabel is false
51 *
52 * There is no .qr--hidden class defined in default CSS, since having items
53 * that are invisible on the QR bar but visible in the context menu,
54 * or icon-only on the QR bar but labelled in the context menu, is a valid use case.
55 *
56 * To hide optional labels when icons are present, add this user CSS:
57 * .ctx-menu .ctx-item .qr--button-label.qr--hidden {display: none;}
58 * To hide icons when no icon is present (removes unwanted padding):
59 * .ctx-menu .ctx-item .qr--button-icon.qr--hidden {display: none;}
60 * To hide items that are set "invisible":
61 * .ctx-menu .ctx-item.qr--hidden {display: none;}
62 * To target submenus only, use .ctx-menu .ctx-sub-menu .qr--hidden {display: none;}
63 *
64 * @returns {HTMLElement}
65 */
39 render() {66 render() {
40 if (!this.root) {67 if (!this.root) {
41 const item = document.createElement('li'); {68 const item = document.createElement('li'); {
42 this.root = item;69 this.root = item;
43 item.classList.add('list-group-item');70 item.classList.add('list-group-item');
44 item.classList.add('ctx-item');71 item.classList.add('ctx-item');
45 item.title = this.value;72
73 // if this item is Invisible, add the hidden class
74 if (this.isHidden) item.classList.add('qr--hidden');
75
76 // if a title/tooltip is set, add it, otherwise use the QR content
77 // same as for the main QR list
78 item.title = this.title || this.value;
79
46 if (this.callback) {80 if (this.callback) {
47 item.addEventListener('click', (evt) => this.callback(evt, this));81 item.addEventListener('click', (evt) => this.callback(evt, this));
48 }82 }
49 const icon = document.createElement('div'); {83 const icon = document.createElement('div'); {
50 this.domIcon = icon;
51 icon.classList.add('qr--button-icon');84 icon.classList.add('qr--button-icon');
52 icon.classList.add('fa-solid');85 icon.classList.add('fa-solid');
53 if (!this.icon) icon.classList.add('qr--hidden');86 if (!this.icon) icon.classList.add('qr--hidden');
@@ -55,7 +88,6 @@ export class MenuItem {
55 item.append(icon);88 item.append(icon);
56 }89 }
57 const lbl = document.createElement('div'); {90 const lbl = document.createElement('div'); {
58 this.domLabel = lbl;
59 lbl.classList.add('qr--button-label');91 lbl.classList.add('qr--button-label');
60 if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');92 if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
61 lbl.textContent = this.label;93 lbl.textContent = this.label;
src/endpoints/characters.js+6 -2
@@ -14,7 +14,7 @@ import jimp from 'jimp';
1414
15import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';15import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
16import { jsonParser, urlencodedParser } from '../express-common.js';16import { jsonParser, urlencodedParser } from '../express-common.js';
17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer } from '../util.js';17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap } from '../util.js';
18import { TavernCardValidator } from '../validator/TavernCardValidator.js';18import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19import { parse, write } from '../character-card-parser.js';19import { parse, write } from '../character-card-parser.js';
20import { readWorldInfoFile } from './worldinfo.js';20import { readWorldInfoFile } from './worldinfo.js';
@@ -23,7 +23,8 @@ import { importRisuSprites } from './sprites.js';
23const defaultAvatarPath = './public/img/ai4.png';23const defaultAvatarPath = './public/img/ai4.png';
2424
25// KV-store for parsed character data25// KV-store for parsed character data
26const characterDataCache = new Map();26// 100 MB limit. Would take roughly 3000 characters to reach this limit
27const characterDataCache = new MemoryLimitedMap(1024 * 1024 * 100);
27// Some Android devices require tighter memory management28// Some Android devices require tighter memory management
28const isAndroid = process.platform === 'android';29const isAndroid = process.platform === 'android';
2930
@@ -58,6 +59,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
58 try {59 try {
59 // Reset the cache60 // Reset the cache
60 for (const key of characterDataCache.keys()) {61 for (const key of characterDataCache.keys()) {
62 if (Buffer.isBuffer(inputFile)) {
63 break;
64 }
61 if (key.startsWith(inputFile)) {65 if (key.startsWith(inputFile)) {
62 characterDataCache.delete(key);66 characterDataCache.delete(key);
63 break;67 break;
src/util.js+179 -0
@@ -670,3 +670,182 @@ export function isValidUrl(url) {
670 return false;670 return false;
671 }671 }
672}672}
673
674/**
675 * MemoryLimitedMap class that limits the memory usage of string values.
676 */
677export class MemoryLimitedMap {
678 /**
679 * Creates an instance of MemoryLimitedMap.
680 * @param {number} maxMemoryInBytes - The maximum allowed memory in bytes for string values.
681 */
682 constructor(maxMemoryInBytes) {
683 if (typeof maxMemoryInBytes !== 'number' || maxMemoryInBytes <= 0) {
684 throw new Error('maxMemoryInBytes must be a positive number');
685 }
686 this.maxMemory = maxMemoryInBytes;
687 this.currentMemory = 0;
688 this.map = new Map();
689 this.queue = [];
690 }
691
692 /**
693 * Estimates the memory usage of a string in bytes.
694 * Assumes each character occupies 2 bytes (UTF-16).
695 * @param {string} str
696 * @returns {number}
697 */
698 static estimateStringSize(str) {
699 return str ? str.length * 2 : 0;
700 }
701
702 /**
703 * Adds or updates a key-value pair in the map.
704 * If adding the new value exceeds the memory limit, evicts oldest entries.
705 * @param {string} key
706 * @param {string} value
707 */
708 set(key, value) {
709 if (typeof key !== 'string' || typeof value !== 'string') {
710 return;
711 }
712
713 const newValueSize = MemoryLimitedMap.estimateStringSize(value);
714
715 // If the new value itself exceeds the max memory, reject it
716 if (newValueSize > this.maxMemory) {
717 return;
718 }
719
720 // Check if the key already exists to adjust memory accordingly
721 if (this.map.has(key)) {
722 const oldValue = this.map.get(key);
723 const oldValueSize = MemoryLimitedMap.estimateStringSize(oldValue);
724 this.currentMemory -= oldValueSize;
725 // Remove the key from its current position in the queue
726 const index = this.queue.indexOf(key);
727 if (index > -1) {
728 this.queue.splice(index, 1);
729 }
730 }
731
732 // Evict oldest entries until there's enough space
733 while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) {
734 const oldestKey = this.queue.shift();
735 const oldestValue = this.map.get(oldestKey);
736 const oldestValueSize = MemoryLimitedMap.estimateStringSize(oldestValue);
737 this.map.delete(oldestKey);
738 this.currentMemory -= oldestValueSize;
739 }
740
741 // After eviction, check again if there's enough space
742 if (this.currentMemory + newValueSize > this.maxMemory) {
743 return;
744 }
745
746 // Add the new key-value pair
747 this.map.set(key, value);
748 this.queue.push(key);
749 this.currentMemory += newValueSize;
750 }
751
752 /**
753 * Retrieves the value associated with the given key.
754 * @param {string} key
755 * @returns {string | undefined}
756 */
757 get(key) {
758 return this.map.get(key);
759 }
760
761 /**
762 * Checks if the map contains the given key.
763 * @param {string} key
764 * @returns {boolean}
765 */
766 has(key) {
767 return this.map.has(key);
768 }
769
770 /**
771 * Deletes the key-value pair associated with the given key.
772 * @param {string} key
773 * @returns {boolean} - Returns true if the key was found and deleted, else false.
774 */
775 delete(key) {
776 if (!this.map.has(key)) {
777 return false;
778 }
779 const value = this.map.get(key);
780 const valueSize = MemoryLimitedMap.estimateStringSize(value);
781 this.map.delete(key);
782 this.currentMemory -= valueSize;
783
784 // Remove the key from the queue
785 const index = this.queue.indexOf(key);
786 if (index > -1) {
787 this.queue.splice(index, 1);
788 }
789
790 return true;
791 }
792
793 /**
794 * Clears all entries from the map.
795 */
796 clear() {
797 this.map.clear();
798 this.queue = [];
799 this.currentMemory = 0;
800 }
801
802 /**
803 * Returns the number of key-value pairs in the map.
804 * @returns {number}
805 */
806 size() {
807 return this.map.size;
808 }
809
810 /**
811 * Returns the current memory usage in bytes.
812 * @returns {number}
813 */
814 totalMemory() {
815 return this.currentMemory;
816 }
817
818 /**
819 * Returns an iterator over the keys in the map.
820 * @returns {IterableIterator<string>}
821 */
822 keys() {
823 return this.map.keys();
824 }
825
826 /**
827 * Returns an iterator over the values in the map.
828 * @returns {IterableIterator<string>}
829 */
830 values() {
831 return this.map.values();
832 }
833
834 /**
835 * Iterates over the map in insertion order.
836 * @param {Function} callback - Function to execute for each element.
837 */
838 forEach(callback) {
839 this.map.forEach((value, key) => {
840 callback(value, key, this);
841 });
842 }
843
844 /**
845 * Makes the MemoryLimitedMap iterable.
846 * @returns {Iterator} - Iterator over [key, value] pairs.
847 */
848 [Symbol.iterator]() {
849 return this.map[Symbol.iterator]();
850 }
851}