diff --git a/public/scripts/extensions/quick-reply/src/SlashCommandHandler.js b/public/scripts/extensions/quick-reply/src/SlashCommandHandler.js
index f6b339cdf..15f7524a4 100644
--- a/public/scripts/extensions/quick-reply/src/SlashCommandHandler.js
+++ b/public/scripts/extensions/quick-reply/src/SlashCommandHandler.js
@@ -419,30 +419,35 @@ export class SlashCommandHandler {
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'set',
- description: 'QR set name',
+ description: 'Name of QR set to add the context menu to',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.qrSets,
}),
SlashCommandNamedArgument.fromProps({
name: 'label',
- description: 'Quick Reply label',
+ description: 'Label of Quick Reply to add the context menu to',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: localEnumProviders.qrEntries,
}),
SlashCommandNamedArgument.fromProps({
name: 'id',
- description: 'numeric ID of the QR, e.g., id=42',
+ description: 'Numeric ID of Quick Reply to add the context menu to, e.g. id=42',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: localEnumProviders.qrIds,
}),
new SlashCommandNamedArgument(
- 'chain', 'boolean', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
+ 'chain',
+ 'If true, button QR is sent together with (before) the clicked QR from the context menu',
+ [ARGUMENT_TYPE.BOOLEAN],
+ false,
+ false,
+ 'false',
),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
- description: 'QR set name',
+ description: 'Name of QR set to add as a context menu',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.qrSets,
@@ -450,13 +455,16 @@ export class SlashCommandHandler {
],
helpString: `
- Add context menu preset to a QR.
+ Add a context menu preset to a QR.
+
+
+ If id
and label
are both provided, id
will be used.
@@ -470,27 +478,27 @@ export class SlashCommandHandler {
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'set',
- description: 'QR set name',
+ description: 'Name of QR set to remove the context menu from',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.qrSets,
}),
SlashCommandNamedArgument.fromProps({
name: 'label',
- description: 'Quick Reply label',
+ description: 'Label of Quick Reply to remove the context menu from',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: localEnumProviders.qrEntries,
}),
SlashCommandNamedArgument.fromProps({
name: 'id',
- description: 'numeric ID of the QR, e.g., id=42',
+ description: 'Numeric ID of Quick Reply to remove the context menu from, e.g. id=42',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: localEnumProviders.qrIds,
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
- description: 'QR set name',
+ description: 'Name of QR set to remove',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.qrSets,
@@ -500,6 +508,9 @@ export class SlashCommandHandler {
Remove context menu preset from a QR.
+
+ If id
and label
are both provided, id
will be used.
+
Example:
@@ -541,6 +552,9 @@ export class SlashCommandHandler {
Remove all context menu presets from a QR.
+
+ If id
and a label are both provided, id
will be used.
+
Example:
@@ -908,12 +922,11 @@ export class SlashCommandHandler {
}
}
-
createContextItem(args, name) {
try {
this.api.createContextItem(
args.set,
- args.label,
+ args.id !== undefined ? Number(args.id) : args.label,
name,
isTrueBoolean(args.chain),
);
@@ -923,14 +936,14 @@ export class SlashCommandHandler {
}
deleteContextItem(args, name) {
try {
- this.api.deleteContextItem(args.set, args.label, name);
+ this.api.deleteContextItem(args.set, args.id !== undefined ? Number(args.id) : args.label, name);
} catch (ex) {
toastr.error(ex.message);
}
}
clearContextMenu(args, label) {
try {
- this.api.clearContextMenu(args.set, args.label ?? label);
+ this.api.clearContextMenu(args.set, args.id !== undefined ? Number(args.id) : args.label ?? label);
} catch (ex) {
toastr.error(ex.message);
}
diff --git a/public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js b/public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
index 48ac3fbae..3741d2869 100644
--- a/public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
+++ b/public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
@@ -19,7 +19,7 @@ export class ContextMenu {
this.itemList = this.build(qr).children;
this.itemList.forEach(item => {
item.onExpand = () => {
- this.itemList.filter(it => it != item)
+ this.itemList.filter(it => it !== item)
.forEach(it => it.collapse());
};
});
@@ -36,7 +36,9 @@ export class ContextMenu {
icon: qr.icon,
showLabel: qr.showLabel,
label: qr.label,
+ title: qr.title,
message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
+ isHidden: qr.isHidden,
children: [],
};
qr.contextList.forEach((cl) => {
@@ -51,7 +53,9 @@ export class ContextMenu {
subTree.icon,
subTree.showLabel,
subTree.label,
+ subTree.title,
subTree.message,
+ subTree.isHidden,
(evt) => {
evt.stopPropagation();
const finalQr = Object.assign(new QuickReply(), subQr);
diff --git a/public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js
index 2fe6f7a68..c4c10148c 100644
--- a/public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js
+++ b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js
@@ -2,7 +2,7 @@ import { MenuItem } from './MenuItem.js';
export class MenuHeader extends MenuItem {
constructor(/**@type {String}*/label) {
- super(null, null, label, null, null);
+ super(null, null, label, null, null, false, null, []);
}
diff --git a/public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js
index 2b4daba99..f6a5db069 100644
--- a/public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js
+++ b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js
@@ -4,11 +4,12 @@ export class MenuItem {
/**@type {string}*/ icon;
/**@type {boolean}*/ showLabel;
/**@type {string}*/ label;
+ /**@type {string}*/ title;
/**@type {object}*/ value;
+ /**@type {boolean}*/ isHidden = false;
/**@type {function}*/ callback;
/**@type {MenuItem[]}*/ childList = [];
/**@type {SubMenu}*/ subMenu;
- /**@type {boolean}*/ isForceExpanded = false;
/**@type {HTMLElement}*/ root;
@@ -19,35 +20,67 @@ export class MenuItem {
/**
*
- * @param {string} icon
- * @param {boolean} showLabel
+ * @param {?string} icon
+ * @param {?boolean} showLabel
* @param {string} label
+ * @param {?string} title Tooltip
* @param {object} value
+ * @param {boolean} isHidden QR is Invisible (auto-execute only)
* @param {function} callback
* @param {MenuItem[]} children
*/
- constructor(icon, showLabel, label, value, callback, children = []) {
+ constructor(icon, showLabel, label, title, value, isHidden, callback, children = []) {
this.icon = icon;
this.showLabel = showLabel;
this.label = label;
+ this.title = title;
this.value = value;
+ this.isHidden = isHidden;
this.callback = callback;
this.childList = children;
}
+ /**
+ * Renders the MenuItem
+ *
+ * A .qr--hidden class is added to:
+ * - the item if it is "Invisible (auto-execute only)"
+ * - the icon if no icon is set
+ * - the label if an icon is set and showLabel is false
+ *
+ * There is no .qr--hidden class defined in default CSS, since having items
+ * that are invisible on the QR bar but visible in the context menu,
+ * or icon-only on the QR bar but labelled in the context menu, is a valid use case.
+ *
+ * To hide optional labels when icons are present, add this user CSS:
+ * .ctx-menu .ctx-item .qr--button-label.qr--hidden {display: none;}
+ * To hide icons when no icon is present (removes unwanted padding):
+ * .ctx-menu .ctx-item .qr--button-icon.qr--hidden {display: none;}
+ * To hide items that are set "invisible":
+ * .ctx-menu .ctx-item.qr--hidden {display: none;}
+ * To target submenus only, use .ctx-menu .ctx-sub-menu .qr--hidden {display: none;}
+ *
+ * @returns {HTMLElement}
+ */
render() {
if (!this.root) {
const item = document.createElement('li'); {
this.root = item;
item.classList.add('list-group-item');
item.classList.add('ctx-item');
- item.title = this.value;
+
+ // if this item is Invisible, add the hidden class
+ if (this.isHidden) item.classList.add('qr--hidden');
+
+ // if a title/tooltip is set, add it, otherwise use the QR content
+ // same as for the main QR list
+ item.title = this.title || this.value;
+
if (this.callback) {
item.addEventListener('click', (evt) => this.callback(evt, this));
}
const icon = document.createElement('div'); {
- this.domIcon = icon;
icon.classList.add('qr--button-icon');
icon.classList.add('fa-solid');
if (!this.icon) icon.classList.add('qr--hidden');
@@ -55,7 +88,6 @@ export class MenuItem {
item.append(icon);
}
const lbl = document.createElement('div'); {
- this.domLabel = lbl;
lbl.classList.add('qr--button-label');
if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
lbl.textContent = this.label;
diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js
index f2bd478e8..b3be9d6f4 100644
--- a/src/endpoints/characters.js
+++ b/src/endpoints/characters.js
@@ -14,7 +14,7 @@ import jimp from 'jimp';
import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
import { jsonParser, urlencodedParser } from '../express-common.js';
-import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer } from '../util.js';
+import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap } from '../util.js';
import { TavernCardValidator } from '../validator/TavernCardValidator.js';
import { parse, write } from '../character-card-parser.js';
import { readWorldInfoFile } from './worldinfo.js';
@@ -23,7 +23,8 @@ import { importRisuSprites } from './sprites.js';
const defaultAvatarPath = './public/img/ai4.png';
// KV-store for parsed character data
-const characterDataCache = new Map();
+// 100 MB limit. Would take roughly 3000 characters to reach this limit
+const characterDataCache = new MemoryLimitedMap(1024 * 1024 * 100);
// Some Android devices require tighter memory management
const isAndroid = process.platform === 'android';
@@ -58,6 +59,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
try {
// Reset the cache
for (const key of characterDataCache.keys()) {
+ if (Buffer.isBuffer(inputFile)) {
+ break;
+ }
if (key.startsWith(inputFile)) {
characterDataCache.delete(key);
break;
diff --git a/src/util.js b/src/util.js
index 7cebdc031..623626d3d 100644
--- a/src/util.js
+++ b/src/util.js
@@ -670,3 +670,182 @@ export function isValidUrl(url) {
return false;
}
}
+
+/**
+ * MemoryLimitedMap class that limits the memory usage of string values.
+ */
+export class MemoryLimitedMap {
+ /**
+ * Creates an instance of MemoryLimitedMap.
+ * @param {number} maxMemoryInBytes - The maximum allowed memory in bytes for string values.
+ */
+ constructor(maxMemoryInBytes) {
+ if (typeof maxMemoryInBytes !== 'number' || maxMemoryInBytes <= 0) {
+ throw new Error('maxMemoryInBytes must be a positive number');
+ }
+ this.maxMemory = maxMemoryInBytes;
+ this.currentMemory = 0;
+ this.map = new Map();
+ this.queue = [];
+ }
+
+ /**
+ * Estimates the memory usage of a string in bytes.
+ * Assumes each character occupies 2 bytes (UTF-16).
+ * @param {string} str
+ * @returns {number}
+ */
+ static estimateStringSize(str) {
+ return str ? str.length * 2 : 0;
+ }
+
+ /**
+ * Adds or updates a key-value pair in the map.
+ * If adding the new value exceeds the memory limit, evicts oldest entries.
+ * @param {string} key
+ * @param {string} value
+ */
+ set(key, value) {
+ if (typeof key !== 'string' || typeof value !== 'string') {
+ return;
+ }
+
+ const newValueSize = MemoryLimitedMap.estimateStringSize(value);
+
+ // If the new value itself exceeds the max memory, reject it
+ if (newValueSize > this.maxMemory) {
+ return;
+ }
+
+ // Check if the key already exists to adjust memory accordingly
+ if (this.map.has(key)) {
+ const oldValue = this.map.get(key);
+ const oldValueSize = MemoryLimitedMap.estimateStringSize(oldValue);
+ this.currentMemory -= oldValueSize;
+ // Remove the key from its current position in the queue
+ const index = this.queue.indexOf(key);
+ if (index > -1) {
+ this.queue.splice(index, 1);
+ }
+ }
+
+ // Evict oldest entries until there's enough space
+ while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) {
+ const oldestKey = this.queue.shift();
+ const oldestValue = this.map.get(oldestKey);
+ const oldestValueSize = MemoryLimitedMap.estimateStringSize(oldestValue);
+ this.map.delete(oldestKey);
+ this.currentMemory -= oldestValueSize;
+ }
+
+ // After eviction, check again if there's enough space
+ if (this.currentMemory + newValueSize > this.maxMemory) {
+ return;
+ }
+
+ // Add the new key-value pair
+ this.map.set(key, value);
+ this.queue.push(key);
+ this.currentMemory += newValueSize;
+ }
+
+ /**
+ * Retrieves the value associated with the given key.
+ * @param {string} key
+ * @returns {string | undefined}
+ */
+ get(key) {
+ return this.map.get(key);
+ }
+
+ /**
+ * Checks if the map contains the given key.
+ * @param {string} key
+ * @returns {boolean}
+ */
+ has(key) {
+ return this.map.has(key);
+ }
+
+ /**
+ * Deletes the key-value pair associated with the given key.
+ * @param {string} key
+ * @returns {boolean} - Returns true if the key was found and deleted, else false.
+ */
+ delete(key) {
+ if (!this.map.has(key)) {
+ return false;
+ }
+ const value = this.map.get(key);
+ const valueSize = MemoryLimitedMap.estimateStringSize(value);
+ this.map.delete(key);
+ this.currentMemory -= valueSize;
+
+ // Remove the key from the queue
+ const index = this.queue.indexOf(key);
+ if (index > -1) {
+ this.queue.splice(index, 1);
+ }
+
+ return true;
+ }
+
+ /**
+ * Clears all entries from the map.
+ */
+ clear() {
+ this.map.clear();
+ this.queue = [];
+ this.currentMemory = 0;
+ }
+
+ /**
+ * Returns the number of key-value pairs in the map.
+ * @returns {number}
+ */
+ size() {
+ return this.map.size;
+ }
+
+ /**
+ * Returns the current memory usage in bytes.
+ * @returns {number}
+ */
+ totalMemory() {
+ return this.currentMemory;
+ }
+
+ /**
+ * Returns an iterator over the keys in the map.
+ * @returns {IterableIterator}
+ */
+ keys() {
+ return this.map.keys();
+ }
+
+ /**
+ * Returns an iterator over the values in the map.
+ * @returns {IterableIterator}
+ */
+ values() {
+ return this.map.values();
+ }
+
+ /**
+ * Iterates over the map in insertion order.
+ * @param {Function} callback - Function to execute for each element.
+ */
+ forEach(callback) {
+ this.map.forEach((value, key) => {
+ callback(value, key, this);
+ });
+ }
+
+ /**
+ * Makes the MemoryLimitedMap iterable.
+ * @returns {Iterator} - Iterator over [key, value] pairs.
+ */
+ [Symbol.iterator]() {
+ return this.map[Symbol.iterator]();
+ }
+}