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 {
419419 namedArgumentList: [
420420 SlashCommandNamedArgument.fromProps({
421421 name: 'set',
422422 description: 'Name of QR set nameto add the context menu to',
423423 typeList: [ARGUMENT_TYPE.STRING],
424424 isRequired: true,
425425 enumProvider: localEnumProviders.qrSets,
426426 }),
427427 SlashCommandNamedArgument.fromProps({
428428 name: 'label',
429429 description: 'Label of Quick Reply labelto add the context menu to',
430430 typeList: [ARGUMENT_TYPE.STRING],
431431 enumProvider: localEnumProviders.qrEntries,
432432 }),
433433 SlashCommandNamedArgument.fromProps({
434434 name: 'id',
435435 description: 'numericNumeric ID of Quick Reply to add the QRcontext menu to, e.g., id=42',
436436 typeList: [ARGUMENT_TYPE.NUMBER],
437437 enumProvider: localEnumProviders.qrIds,
438438 }),
439439 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',
441446 ),
442447 ],
443448 unnamedArgumentList: [
444449 SlashCommandArgument.fromProps({
445450 description: 'Name of QR set nameto add as a context menu',
446451 typeList: [ARGUMENT_TYPE.STRING],
447452 isRequired: true,
448453 enumProvider: localEnumProviders.qrSets,
@@ -450,13 +455,16 @@ export class SlashCommandHandler {
450455 ],
451456 helpString: `
452457 <div>
453458 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.
454462 </div>
455463 <div>
456464 <strong>Example:</strong>
457465 <ul>
458466 <li>
459467 <pre><code>/qr-contextadd set=MyPresetMyQRSetWithTheButton label=MyButton chain=true MyOtherPresetMyQRSetWithContextItems</code></pre>
460468 </li>
461469 </ul>
462470 </div>
@@ -470,27 +478,27 @@ export class SlashCommandHandler {
470478 namedArgumentList: [
471479 SlashCommandNamedArgument.fromProps({
472480 name: 'set',
473481 description: 'Name of QR set nameto remove the context menu from',
474482 typeList: [ARGUMENT_TYPE.STRING],
475483 isRequired: true,
476484 enumProvider: localEnumProviders.qrSets,
477485 }),
478486 SlashCommandNamedArgument.fromProps({
479487 name: 'label',
480488 description: 'Label of Quick Reply labelto remove the context menu from',
481489 typeList: [ARGUMENT_TYPE.STRING],
482490 enumProvider: localEnumProviders.qrEntries,
483491 }),
484492 SlashCommandNamedArgument.fromProps({
485493 name: 'id',
486494 description: 'numericNumeric ID of Quick Reply to remove the QRcontext menu from, e.g., id=42',
487495 typeList: [ARGUMENT_TYPE.NUMBER],
488496 enumProvider: localEnumProviders.qrIds,
489497 }),
490498 ],
491499 unnamedArgumentList: [
492500 SlashCommandArgument.fromProps({
493501 description: 'Name of QR set nameto remove',
494502 typeList: [ARGUMENT_TYPE.STRING],
495503 isRequired: true,
496504 enumProvider: localEnumProviders.qrSets,
@@ -501,6 +509,9 @@ export class SlashCommandHandler {
501509 Remove context menu preset from a QR.
502510 </div>
503511 <div>
512+ If <code>id</code> and <code>label</code> are both provided, <code>id</code> will be used.
513+ </div>
514+ <div>
504515 <strong>Example:</strong>
505516 <ul>
506517 <li>
@@ -542,6 +553,9 @@ export class SlashCommandHandler {
542553 Remove all context menu presets from a QR.
543554 </div>
544555 <div>
556+ If <code>id</code> and a label are both provided, <code>id</code> will be used.
557+ </div>
558+ <div>
545559 <strong>Example:</strong>
546560 <ul>
547561 <li>
@@ -908,12 +922,11 @@ export class SlashCommandHandler {
908922 }
909923 }
910924
911-
912925 createContextItem(args, name) {
913926 try {
914927 this.api.createContextItem(
915928 args.set,
916- args.label,
929+ args.id !== undefined ? Number(args.id) : args.label,
917930 name,
918931 isTrueBoolean(args.chain),
919932 );
@@ -923,14 +936,14 @@ export class SlashCommandHandler {
923936 }
924937 deleteContextItem(args, name) {
925938 try {
926939 this.api.deleteContextItem(args.set, args.id !== undefined ? Number(args.id) : args.label, name);
927940 } catch (ex) {
928941 toastr.error(ex.message);
929942 }
930943 }
931944 clearContextMenu(args, label) {
932945 try {
933946 this.api.clearContextMenu(args.set, args.id !== undefined ? Number(args.id) : args.label ?? label);
934947 } catch (ex) {
935948 toastr.error(ex.message);
936949 }
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js+5 -1
@@ -19,7 +19,7 @@ export class ContextMenu {
1919 this.itemList = this.build(qr).children;
2020 this.itemList.forEach(item => {
2121 item.onExpand = () => {
2222 this.itemList.filter(it => it !== item)
2323 .forEach(it => it.collapse());
2424 };
2525 });
@@ -36,7 +36,9 @@ export class ContextMenu {
3636 icon: qr.icon,
3737 showLabel: qr.showLabel,
3838 label: qr.label,
39+ title: qr.title,
3940 message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
41+ isHidden: qr.isHidden,
4042 children: [],
4143 };
4244 qr.contextList.forEach((cl) => {
@@ -51,7 +53,9 @@ export class ContextMenu {
5153 subTree.icon,
5254 subTree.showLabel,
5355 subTree.label,
56+ subTree.title,
5457 subTree.message,
58+ subTree.isHidden,
5559 (evt) => {
5660 evt.stopPropagation();
5761 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
33export class MenuHeader extends MenuItem {
44 constructor(/**@type {String}*/label) {
55 super(null, null, label, null, null, false, null, []);
66 }
77
88
public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js+39 -7
@@ -4,11 +4,12 @@ export class MenuItem {
44 /**@type {string}*/ icon;
55 /**@type {boolean}*/ showLabel;
66 /**@type {string}*/ label;
7+ /**@type {string}*/ title;
78 /**@type {object}*/ value;
9+ /**@type {boolean}*/ isHidden = false;
810 /**@type {function}*/ callback;
911 /**@type {MenuItem[]}*/ childList = [];
1012 /**@type {SubMenu}*/ subMenu;
11- /**@type {boolean}*/ isForceExpanded = false;
1213
1314 /**@type {HTMLElement}*/ root;
1415
@@ -19,35 +20,67 @@ export class MenuItem {
1920
2021 /**
2122 *
2223 * @param {?string} icon
2324 * @param {?boolean} showLabel
2425 * @param {string} label
26+ * @param {?string} title Tooltip
2527 * @param {object} value
28+ * @param {boolean} isHidden QR is Invisible (auto-execute only)
2629 * @param {function} callback
2730 * @param {MenuItem[]} children
2831 */
2932 constructor(icon, showLabel, label, title, value, isHidden, callback, children = []) {
3033 this.icon = icon;
3134 this.showLabel = showLabel;
3235 this.label = label;
36+ this.title = title;
3337 this.value = value;
38+ this.isHidden = isHidden;
3439 this.callback = callback;
3540 this.childList = children;
3641 }
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+ */
3966 render() {
4067 if (!this.root) {
4168 const item = document.createElement('li'); {
4269 this.root = item;
4370 item.classList.add('list-group-item');
4471 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+
4680 if (this.callback) {
4781 item.addEventListener('click', (evt) => this.callback(evt, this));
4882 }
4983 const icon = document.createElement('div'); {
50- this.domIcon = icon;
5184 icon.classList.add('qr--button-icon');
5285 icon.classList.add('fa-solid');
5386 if (!this.icon) icon.classList.add('qr--hidden');
@@ -55,7 +88,6 @@ export class MenuItem {
5588 item.append(icon);
5689 }
5790 const lbl = document.createElement('div'); {
58- this.domLabel = lbl;
5991 lbl.classList.add('qr--button-label');
6092 if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
6193 lbl.textContent = this.label;
src/endpoints/characters.js+6 -2
@@ -14,7 +14,7 @@ import jimp from 'jimp';
1414
1515import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
1616import { jsonParser, urlencodedParser } from '../express-common.js';
1717import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap } from '../util.js';
1818import { TavernCardValidator } from '../validator/TavernCardValidator.js';
1919import { parse, write } from '../character-card-parser.js';
2020import { readWorldInfoFile } from './worldinfo.js';
@@ -23,7 +23,8 @@ import { importRisuSprites } from './sprites.js';
2323const defaultAvatarPath = './public/img/ai4.png';
2424
2525// KV-store for parsed character data
26-const characterDataCache = new Map();
26+// 100 MB limit. Would take roughly 3000 characters to reach this limit
27+const characterDataCache = new MemoryLimitedMap(1024 * 1024 * 100);
2728// Some Android devices require tighter memory management
2829const isAndroid = process.platform === 'android';
2930
@@ -58,6 +59,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
5859 try {
5960 // Reset the cache
6061 for (const key of characterDataCache.keys()) {
62+ if (Buffer.isBuffer(inputFile)) {
63+ break;
64+ }
6165 if (key.startsWith(inputFile)) {
6266 characterDataCache.delete(key);
6367 break;
src/util.js+179 -0
@@ -670,3 +670,182 @@ export function isValidUrl(url) {
670670 return false;
671671 }
672672}
673+
674+/**
675+ * MemoryLimitedMap class that limits the memory usage of string values.
676+ */
677+export 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+}