From 41c709e291212291d3d1f61b5a25545258d0f5fb Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 22 Jul 2024 02:11:11 +0200 Subject: [PATCH 01/11] WI slash only reload world if it was selected --- public/scripts/world-info.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 99ac56f2b..83377d7ba 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -885,9 +885,15 @@ function setWorldInfoSettings(settings, data) { } function registerWorldInfoSlashCommands() { - function reloadEditor(file) { + /** + * Reloads the editor with the specified world info file + * @param {string} file - The file to load in the editor + * @param {boolean} [loadIfNotSelected=false] - Indicates whether to load the file even if it's not currently selected + */ + function reloadEditor(file, loadIfNotSelected = false) { + const currentIndex = $('#world_editor_select').val(); const selectedIndex = world_names.indexOf(file); - if (selectedIndex !== -1) { + if (selectedIndex !== -1 && (loadIfNotSelected || currentIndex === selectedIndex)) { $('#world_editor_select').val(selectedIndex).trigger('change'); } } From 731d2864de65e0b250ce3e56942ea97346bc8a08 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 22 Jul 2024 03:17:06 +0200 Subject: [PATCH 02/11] Proper caching for loaded WI - Implement StructurecCloneMap, which is a map that provides structured clones on both get and set - Don't delete WI cache on save, but update the cache - Ensure that cache is updated immediately, so any future get will load the new saved data already - Still be consistent with clones, so requested cache data that wasn't saved isn't taken into account --- public/scripts/util/StructuredCloneMap.js | 36 +++++++++++++++++++++++ public/scripts/world-info.js | 7 +++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 public/scripts/util/StructuredCloneMap.js diff --git a/public/scripts/util/StructuredCloneMap.js b/public/scripts/util/StructuredCloneMap.js new file mode 100644 index 000000000..51173e630 --- /dev/null +++ b/public/scripts/util/StructuredCloneMap.js @@ -0,0 +1,36 @@ +/** + * A specialized Map class that provides consistent data storage by performing deep cloning of values. + * + * @template K, V + * @extends Map + */ +export class StructuredCloneMap extends Map { + /** + * Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated. + * + * The set value will always be a deep clone of the provided value to provide consistent data storage. + * + * @param {K} key - The key to set + * @param {V} value - The value to set + * @returns {this} The updated map + */ + set(key, value) { + const clonedValue = structuredClone(value); + super.set(key, clonedValue); + return this; + } + + /** + * Returns a specified element from the Map object. + * If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map. + * + * The returned value will always be a deep clone of the cached value. + * + * @param {K} key - The key to get the value for + * @returns {V | undefined} Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned. + */ + get(key) { + const value = super.get(key); + return structuredClone(value); + } +} diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 83377d7ba..42cc49306 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -16,6 +16,7 @@ import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandE import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js'; +import { StructuredCloneMap } from './util/StructuredCloneMap.js'; export { world_info, @@ -746,7 +747,8 @@ export const wi_anchor_position = { after: 1, }; -const worldInfoCache = new Map(); +/** @type {StructuredCloneMap} */ +const worldInfoCache = new StructuredCloneMap(); /** * Gets the world info based on chat messages. @@ -3280,7 +3282,8 @@ async function saveWorldInfo(name, data, immediately) { return; } - worldInfoCache.delete(name); + // Update cache immediately, so any future call can pull from this + worldInfoCache.set(name, structuredClone(data)); if (immediately) { return await _save(name, data); From 0975843f1de69783247794dfa55e0d669854a6b7 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 22 Jul 2024 03:22:20 +0200 Subject: [PATCH 03/11] WI entry update slash commands use debounced - WI entry updates utilize debounced save - Trade-off between consistency of possible data loss and performance issues in STscript loops that update multiple things in a WI file are not worth it. --- public/scripts/world-info.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 42cc49306..1b1973346 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1057,7 +1057,7 @@ function registerWorldInfoSlashCommands() { entry.content = content; } - await saveWorldInfo(file, data, true); + await saveWorldInfo(file, data); reloadEditor(file); return String(entry.uid); @@ -1108,7 +1108,7 @@ function registerWorldInfoSlashCommands() { setOriginalDataValue(data, uid, originalDataKeyMap[field], entry[field]); } - await saveWorldInfo(file, data, true); + await saveWorldInfo(file, data); reloadEditor(file); return ''; } @@ -1934,7 +1934,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl if (counter > 0) { toastr.info(`Backfilled ${counter} titles`); - await saveWorldInfo(name, data, true); + await saveWorldInfo(name, data); updateEditor(navigation_option.previous); } }); @@ -1994,7 +1994,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl console.table(Object.keys(data.entries).map(uid => data.entries[uid]).map(x => ({ uid: x.uid, key: x.key.join(','), displayIndex: x.displayIndex }))); - await saveWorldInfo(name, data, true); + await saveWorldInfo(name, data); }, }); //$("#world_popup_entries_list").disableSelection(); @@ -3277,7 +3277,7 @@ async function _save(name, data) { eventSource.emit(event_types.WORLDINFO_UPDATED, name, data); } -async function saveWorldInfo(name, data, immediately) { +async function saveWorldInfo(name, data, immediately = false) { if (!name || !data) { return; } From 4acf68cc30f60da7c1281fa12ba826bab11871d1 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 22 Jul 2024 04:23:05 +0200 Subject: [PATCH 04/11] Explicitly use async for saveWorldInfo --- public/scripts/world-info.js | 139 ++++++++++++++++++----------------- 1 file changed, 70 insertions(+), 69 deletions(-) diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 1b1973346..5fbb8b20a 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1848,7 +1848,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl nextText: '>', formatNavigator: PAGINATION_TEMPLATE, showNavigator: true, - callback: function (/** @type {object[]} */ page) { + callback: async function (/** @type {object[]} */ page) { // We save costly performance by removing all events before emptying. Because we know there are no relevant event handlers reacting on removing elements // This prevents jQuery from actually going through all registered events on the controls for each entry when removing it worldEntriesList.find('*').off(); @@ -1875,7 +1875,8 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl Trigger % `; - const blocks = page.map(entry => getWorldEntry(name, data, entry)).filter(x => x); + const mappedEntryBlocksPromises = page.map(async entry => await getWorldEntry(name, data, entry)).filter(x => x); + const blocks = await Promise.all(mappedEntryBlocksPromises); const isCustomOrder = $('#world_info_sort_order').find(':selected').data('rule') === 'custom'; if (!isCustomOrder) { blocks.forEach(block => { @@ -2215,7 +2216,7 @@ function parseRegexFromString(input) { } } -function getWorldEntry(name, data, entry) { +async function getWorldEntry(name, data, entry) { if (!data.entries[entry.uid]) { return; } @@ -2266,7 +2267,7 @@ function getWorldEntry(name, data, entry) { templateResult: item => templateStyling(item, { searchStyle: true }), templateSelection: item => templateStyling(item), }); - input.on('change', function (_, { skipReset, noSave } = {}) { + input.on('change', async function (_, { skipReset, noSave } = {}) { const uid = $(this).data('uid'); /** @type {string[]} */ const keys = ($(this).select2('data')).map(x => x.text); @@ -2275,7 +2276,7 @@ function getWorldEntry(name, data, entry) { if (!noSave) { data.entries[uid][entryPropName] = keys; setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); } }); input.on('select2:select', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data])); @@ -2306,14 +2307,14 @@ function getWorldEntry(name, data, entry) { template.find(`select[name="${entryPropName}"]`).hide(); input.show(); - input.on('input', function (_, { skipReset, noSave } = {}) { + input.on('input', async function (_, { skipReset, noSave } = {}) { const uid = $(this).data('uid'); const value = String($(this).val()); !skipReset && resetScrollHeight(this); if (!noSave) { data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value); setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); } }); input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true }); @@ -2352,12 +2353,12 @@ function getWorldEntry(name, data, entry) { event.stopPropagation(); }); - selectiveLogicDropdown.on('input', function () { + selectiveLogicDropdown.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); data.entries[uid].selectiveLogic = !isNaN(value) ? value : world_info_logic.AND_ANY; setOriginalDataValue(data, uid, 'selectiveLogic', data.entries[uid].selectiveLogic); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); template @@ -2372,7 +2373,7 @@ function getWorldEntry(name, data, entry) { // exclude characters checkbox const characterExclusionInput = template.find('input[name="character_exclusion"]'); characterExclusionInput.data('uid', entry.uid); - characterExclusionInput.on('input', function () { + characterExclusionInput.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); characterFilterLabel.text(value ? 'Exclude Character(s)' : 'Filter to Character(s)'); @@ -2406,7 +2407,7 @@ function getWorldEntry(name, data, entry) { } setOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); characterExclusionInput.prop('checked', entry.characterFilter?.isExclude ?? false).trigger('input'); @@ -2468,24 +2469,24 @@ function getWorldEntry(name, data, entry) { ); } setOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); // comment const commentInput = template.find('textarea[name="comment"]'); const commentToggle = template.find('input[name="addMemo"]'); commentInput.data('uid', entry.uid); - commentInput.on('input', function (_, { skipReset } = {}) { + commentInput.on('input', async function (_, { skipReset } = {}) { const uid = $(this).data('uid'); const value = $(this).val(); !skipReset && resetScrollHeight(this); data.entries[uid].comment = value; setOriginalDataValue(data, uid, 'comment', data.entries[uid].comment); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); commentToggle.data('uid', entry.uid); - commentToggle.on('input', function () { + commentToggle.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); //console.log(value) @@ -2493,7 +2494,7 @@ function getWorldEntry(name, data, entry) { .closest('.world_entry') .find('.commentContainer'); data.entries[uid].addMemo = value; - saveWorldInfo(name, data); + await saveWorldInfo(name, data); value ? commentContainer.show() : commentContainer.hide(); }); @@ -2511,13 +2512,13 @@ function getWorldEntry(name, data, entry) { const contentInput = template.find('textarea[name="content"]'); contentInput.data('uid', entry.uid); - contentInput.on('input', function (_, { skipCount } = {}) { + contentInput.on('input', async function (_, { skipCount } = {}) { const uid = $(this).data('uid'); const value = $(this).val(); data.entries[uid].content = value; setOriginalDataValue(data, uid, 'content', data.entries[uid].content); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); if (skipCount) { return; @@ -2541,13 +2542,13 @@ function getWorldEntry(name, data, entry) { // selective const selectiveInput = template.find('input[name="selective"]'); selectiveInput.data('uid', entry.uid); - selectiveInput.on('input', function () { + selectiveInput.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); data.entries[uid].selective = value; setOriginalDataValue(data, uid, 'selective', data.entries[uid].selective); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); const keysecondary = $(this) .closest('.world_entry') @@ -2576,12 +2577,12 @@ function getWorldEntry(name, data, entry) { /* const constantInput = template.find('input[name="constant"]'); constantInput.data("uid", entry.uid); - constantInput.on("input", function () { + constantInput.on("input", async function () { const uid = $(this).data("uid"); const value = $(this).prop("checked"); data.entries[uid].constant = value; setOriginalDataValue(data, uid, "constant", data.entries[uid].constant); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); constantInput.prop("checked", entry.constant).trigger("input"); */ @@ -2589,14 +2590,14 @@ function getWorldEntry(name, data, entry) { // order const orderInput = template.find('input[name="order"]'); orderInput.data('uid', entry.uid); - orderInput.on('input', function () { + orderInput.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); data.entries[uid].order = !isNaN(value) ? value : 0; updatePosOrdDisplay(uid); setOriginalDataValue(data, uid, 'insertion_order', data.entries[uid].order); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); orderInput.val(entry.order).trigger('input'); orderInput.css('width', 'calc(3em + 15px)'); @@ -2604,13 +2605,13 @@ function getWorldEntry(name, data, entry) { // group const groupInput = template.find('input[name="group"]'); groupInput.data('uid', entry.uid); - groupInput.on('input', function () { + groupInput.on('input', async function () { const uid = $(this).data('uid'); const value = String($(this).val()).trim(); data.entries[uid].group = value; setOriginalDataValue(data, uid, 'extensions.group', data.entries[uid].group); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); groupInput.val(entry.group ?? '').trigger('input'); setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data), { allowMultiple: true }), 1); @@ -2618,19 +2619,19 @@ function getWorldEntry(name, data, entry) { // inclusion priority const groupOverrideInput = template.find('input[name="groupOverride"]'); groupOverrideInput.data('uid', entry.uid); - groupOverrideInput.on('input', function () { + groupOverrideInput.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); data.entries[uid].groupOverride = value; setOriginalDataValue(data, uid, 'extensions.group_override', data.entries[uid].groupOverride); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); groupOverrideInput.prop('checked', entry.groupOverride).trigger('input'); // group weight const groupWeightInput = template.find('input[name="groupWeight"]'); groupWeightInput.data('uid', entry.uid); - groupWeightInput.on('input', function () { + groupWeightInput.on('input', async function () { const uid = $(this).data('uid'); let value = Number($(this).val()); const min = Number($(this).attr('min')); @@ -2647,46 +2648,46 @@ function getWorldEntry(name, data, entry) { data.entries[uid].groupWeight = !isNaN(value) ? Math.abs(value) : 1; setOriginalDataValue(data, uid, 'extensions.group_weight', data.entries[uid].groupWeight); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); groupWeightInput.val(entry.groupWeight ?? DEFAULT_WEIGHT).trigger('input'); // sticky const sticky = template.find('input[name="sticky"]'); sticky.data('uid', entry.uid); - sticky.on('input', function () { + sticky.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); data.entries[uid].sticky = !isNaN(value) ? value : null; setOriginalDataValue(data, uid, 'extensions.sticky', data.entries[uid].sticky); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); sticky.val(entry.sticky > 0 ? entry.sticky : '').trigger('input'); // cooldown const cooldown = template.find('input[name="cooldown"]'); cooldown.data('uid', entry.uid); - cooldown.on('input', function () { + cooldown.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); data.entries[uid].cooldown = !isNaN(value) ? value : null; setOriginalDataValue(data, uid, 'extensions.cooldown', data.entries[uid].cooldown); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); cooldown.val(entry.cooldown > 0 ? entry.cooldown : '').trigger('input'); // delay const delay = template.find('input[name="delay"]'); delay.data('uid', entry.uid); - delay.on('input', function () { + delay.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); data.entries[uid].delay = !isNaN(value) ? value : null; setOriginalDataValue(data, uid, 'extensions.delay', data.entries[uid].delay); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); delay.val(entry.delay > 0 ? entry.delay : '').trigger('input'); @@ -2699,14 +2700,14 @@ function getWorldEntry(name, data, entry) { const depthInput = template.find('input[name="depth"]'); depthInput.data('uid', entry.uid); - depthInput.on('input', function () { + depthInput.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); data.entries[uid].depth = !isNaN(value) ? value : 0; updatePosOrdDisplay(uid); setOriginalDataValue(data, uid, 'extensions.depth', data.entries[uid].depth); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); depthInput.val(entry.depth ?? DEFAULT_DEPTH).trigger('input'); depthInput.css('width', 'calc(3em + 15px)'); @@ -2718,7 +2719,7 @@ function getWorldEntry(name, data, entry) { const probabilityInput = template.find('input[name="probability"]'); probabilityInput.data('uid', entry.uid); - probabilityInput.on('input', function () { + probabilityInput.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); @@ -2734,7 +2735,7 @@ function getWorldEntry(name, data, entry) { } setOriginalDataValue(data, uid, 'extensions.probability', data.entries[uid].probability); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); probabilityInput.val(entry.probability).trigger('input'); probabilityInput.css('width', 'calc(3em + 15px)'); @@ -2746,14 +2747,14 @@ function getWorldEntry(name, data, entry) { const probabilityToggle = template.find('input[name="useProbability"]'); probabilityToggle.data('uid', entry.uid); - probabilityToggle.on('input', function () { + probabilityToggle.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); data.entries[uid].useProbability = value; const probabilityContainer = $(this) .closest('.world_entry') .find('.probabilityContainer'); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); value ? probabilityContainer.show() : probabilityContainer.hide(); if (value && data.entries[uid].probability === null) { @@ -2782,7 +2783,7 @@ function getWorldEntry(name, data, entry) { // Prevent closing the drawer on clicking the input event.stopPropagation(); }); - positionInput.on('input', function () { + positionInput.on('input', async function () { const uid = $(this).data('uid'); const value = Number($(this).val()); data.entries[uid].position = !isNaN(value) ? value : 0; @@ -2804,7 +2805,7 @@ function getWorldEntry(name, data, entry) { // Write the original value as extensions field setOriginalDataValue(data, uid, 'extensions.position', data.entries[uid].position); setOriginalDataValue(data, uid, 'extensions.role', data.entries[uid].role); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); const roleValue = entry.position === world_info_position.atDepth ? String(entry.role ?? extension_prompt_roles.SYSTEM) : ''; @@ -2820,12 +2821,12 @@ function getWorldEntry(name, data, entry) { /* const disableInput = template.find('input[name="disable"]'); disableInput.data("uid", entry.uid); - disableInput.on("input", function () { + disableInput.on("input", async function () { const uid = $(this).data("uid"); const value = $(this).prop("checked"); data.entries[uid].disable = value; setOriginalDataValue(data, uid, "enabled", !data.entries[uid].disable); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); disableInput.prop("checked", entry.disable).trigger("input"); */ @@ -2837,7 +2838,7 @@ function getWorldEntry(name, data, entry) { // Prevent closing the drawer on clicking the input event.stopPropagation(); }); - entryStateSelector.on('input', function () { + entryStateSelector.on('input', async function () { const uid = entry.uid; const value = $(this).val(); switch (value) { @@ -2878,7 +2879,7 @@ function getWorldEntry(name, data, entry) { template.addClass('disabledWIEntry'); break; } - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); @@ -2898,52 +2899,52 @@ function getWorldEntry(name, data, entry) { .prop('selected', true) .trigger('input'); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); // exclude recursion const excludeRecursionInput = template.find('input[name="exclude_recursion"]'); excludeRecursionInput.data('uid', entry.uid); - excludeRecursionInput.on('input', function () { + excludeRecursionInput.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); data.entries[uid].excludeRecursion = value; setOriginalDataValue(data, uid, 'extensions.exclude_recursion', data.entries[uid].excludeRecursion); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); excludeRecursionInput.prop('checked', entry.excludeRecursion).trigger('input'); // prevent recursion const preventRecursionInput = template.find('input[name="prevent_recursion"]'); preventRecursionInput.data('uid', entry.uid); - preventRecursionInput.on('input', function () { + preventRecursionInput.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); data.entries[uid].preventRecursion = value; setOriginalDataValue(data, uid, 'extensions.prevent_recursion', data.entries[uid].preventRecursion); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); preventRecursionInput.prop('checked', entry.preventRecursion).trigger('input'); // delay until recursion const delayUntilRecursionInput = template.find('input[name="delay_until_recursion"]'); delayUntilRecursionInput.data('uid', entry.uid); - delayUntilRecursionInput.on('input', function () { + delayUntilRecursionInput.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).prop('checked'); data.entries[uid].delayUntilRecursion = value; setOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); delayUntilRecursionInput.prop('checked', entry.delayUntilRecursion).trigger('input'); // duplicate button const duplicateButton = template.find('.duplicate_entry_button'); duplicateButton.data('uid', entry.uid); - duplicateButton.on('click', function () { + duplicateButton.on('click', async function () { const uid = $(this).data('uid'); const entry = duplicateWorldInfoEntry(data, uid); if (entry) { - saveWorldInfo(name, data); + await saveWorldInfo(name, data); updateEditor(entry.uid); } }); @@ -2951,18 +2952,18 @@ function getWorldEntry(name, data, entry) { // delete button const deleteButton = template.find('.delete_entry_button'); deleteButton.data('uid', entry.uid); - deleteButton.on('click', function () { + deleteButton.on('click', async function () { const uid = $(this).data('uid'); deleteWorldInfoEntry(data, uid); deleteOriginalDataValue(data, uid); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); updateEditor(navigation_option.previous); }); // scan depth const scanDepthInput = template.find('input[name="scanDepth"]'); scanDepthInput.data('uid', entry.uid); - scanDepthInput.on('input', function () { + scanDepthInput.on('input', async function () { const uid = $(this).data('uid'); const isEmpty = $(this).val() === ''; const value = Number($(this).val()); @@ -2982,59 +2983,59 @@ function getWorldEntry(name, data, entry) { data.entries[uid].scanDepth = !isEmpty && !isNaN(value) && value >= 0 && value <= MAX_SCAN_DEPTH ? Math.floor(value) : null; setOriginalDataValue(data, uid, 'extensions.scan_depth', data.entries[uid].scanDepth); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); scanDepthInput.val(entry.scanDepth ?? null).trigger('input'); // case sensitive select const caseSensitiveSelect = template.find('select[name="caseSensitive"]'); caseSensitiveSelect.data('uid', entry.uid); - caseSensitiveSelect.on('input', function () { + caseSensitiveSelect.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).val(); data.entries[uid].caseSensitive = value === 'null' ? null : value === 'true'; setOriginalDataValue(data, uid, 'extensions.case_sensitive', data.entries[uid].caseSensitive); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); caseSensitiveSelect.val((entry.caseSensitive === null || entry.caseSensitive === undefined) ? 'null' : entry.caseSensitive ? 'true' : 'false').trigger('input'); // match whole words select const matchWholeWordsSelect = template.find('select[name="matchWholeWords"]'); matchWholeWordsSelect.data('uid', entry.uid); - matchWholeWordsSelect.on('input', function () { + matchWholeWordsSelect.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).val(); data.entries[uid].matchWholeWords = value === 'null' ? null : value === 'true'; setOriginalDataValue(data, uid, 'extensions.match_whole_words', data.entries[uid].matchWholeWords); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); matchWholeWordsSelect.val((entry.matchWholeWords === null || entry.matchWholeWords === undefined) ? 'null' : entry.matchWholeWords ? 'true' : 'false').trigger('input'); // use group scoring select const useGroupScoringSelect = template.find('select[name="useGroupScoring"]'); useGroupScoringSelect.data('uid', entry.uid); - useGroupScoringSelect.on('input', function () { + useGroupScoringSelect.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).val(); data.entries[uid].useGroupScoring = value === 'null' ? null : value === 'true'; setOriginalDataValue(data, uid, 'extensions.use_group_scoring', data.entries[uid].useGroupScoring); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); useGroupScoringSelect.val((entry.useGroupScoring === null || entry.useGroupScoring === undefined) ? 'null' : entry.useGroupScoring ? 'true' : 'false').trigger('input'); // automation id const automationIdInput = template.find('input[name="automationId"]'); automationIdInput.data('uid', entry.uid); - automationIdInput.on('input', function () { + automationIdInput.on('input', async function () { const uid = $(this).data('uid'); const value = $(this).val(); data.entries[uid].automationId = value; setOriginalDataValue(data, uid, 'extensions.automation_id', data.entries[uid].automationId); - saveWorldInfo(name, data); + await saveWorldInfo(name, data); }); automationIdInput.val(entry.automationId ?? '').trigger('input'); setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1); From 7cbaa15aad228835dc0de12e780287de5aea3760 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 22 Jul 2024 09:11:37 +0200 Subject: [PATCH 05/11] Fix double cloning and unnecessary await --- public/scripts/world-info.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 5fbb8b20a..f82200fec 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1875,7 +1875,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl Trigger % `; - const mappedEntryBlocksPromises = page.map(async entry => await getWorldEntry(name, data, entry)).filter(x => x); + const mappedEntryBlocksPromises = page.map(entry => getWorldEntry(name, data, entry)).filter(x => x); const blocks = await Promise.all(mappedEntryBlocksPromises); const isCustomOrder = $('#world_info_sort_order').find(':selected').data('rule') === 'custom'; if (!isCustomOrder) { @@ -3284,7 +3284,7 @@ async function saveWorldInfo(name, data, immediately = false) { } // Update cache immediately, so any future call can pull from this - worldInfoCache.set(name, structuredClone(data)); + worldInfoCache.set(name, data); if (immediately) { return await _save(name, data); From d57e43df54afc3acd48a43b08747a7a9c1d99bae Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 22 Jul 2024 09:22:02 +0200 Subject: [PATCH 06/11] Remove not needed await on global context --- public/scripts/world-info.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index f82200fec..ebd2c8a5f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -2899,7 +2899,7 @@ async function getWorldEntry(name, data, entry) { .prop('selected', true) .trigger('input'); - await saveWorldInfo(name, data); + saveWorldInfo(name, data); // exclude recursion const excludeRecursionInput = template.find('input[name="exclude_recursion"]'); From 8777526f8a396bd3e280e79b5abd7217f9cd5543 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:21:07 +0000 Subject: [PATCH 07/11] Unasync getWorldEntry --- public/scripts/world-info.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index ebd2c8a5f..0e12da10f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1875,8 +1875,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl Trigger % `; - const mappedEntryBlocksPromises = page.map(entry => getWorldEntry(name, data, entry)).filter(x => x); - const blocks = await Promise.all(mappedEntryBlocksPromises); + const blocks = page.map(entry => getWorldEntry(name, data, entry)).filter(x => x); const isCustomOrder = $('#world_info_sort_order').find(':selected').data('rule') === 'custom'; if (!isCustomOrder) { blocks.forEach(block => { @@ -2216,7 +2215,7 @@ function parseRegexFromString(input) { } } -async function getWorldEntry(name, data, entry) { +function getWorldEntry(name, data, entry) { if (!data.entries[entry.uid]) { return; } From 17dc3fa4b50746874cc870dd68cdd8c92cb58515 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Mon, 22 Jul 2024 22:20:03 +0300 Subject: [PATCH 08/11] Add debounce cancelling --- public/scripts/utils.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/public/scripts/utils.js b/public/scripts/utils.js index eb2f6b1b1..7a55ec3a7 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -270,6 +270,13 @@ export function getStringHash(str, seed = 0) { return 4294967296 * (2097151 & h2) + (h1 >>> 0); } +/** + * Map of debounced functions to their timers. + * Weak map is used to avoid memory leaks. + * @type {WeakMap} + */ +const debounceMap = new WeakMap(); + /** * Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked. * @param {function} func The function to debounce. @@ -281,9 +288,21 @@ export function debounce(func, timeout = debounce_timeout.standard) { return (...args) => { clearTimeout(timer); timer = setTimeout(() => { func.apply(this, args); }, timeout); + debounceMap.set(func, timer); }; } +/** + * Cancels a scheduled debounced function. Does nothing if the function is not debounced or not scheduled. + * @param {function} func The function to cancel. + */ +export function cancelDebounce(func) { + if (debounceMap.has(func)) { + clearTimeout(debounceMap.get(func)); + debounceMap.delete(func); + } +} + /** * Creates a throttled function that only invokes func at most once per every limit milliseconds. * @param {function} func The function to throttle. From 4de51087bce43aa4c2435d378176258186ed624a Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Mon, 22 Jul 2024 22:33:48 +0300 Subject: [PATCH 09/11] Allow cancel both by debounced and original functions --- public/scripts/utils.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 7a55ec3a7..ea7306c9c 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -285,16 +285,20 @@ const debounceMap = new WeakMap(); */ export function debounce(func, timeout = debounce_timeout.standard) { let timer; - return (...args) => { + let fn = (...args) => { clearTimeout(timer); timer = setTimeout(() => { func.apply(this, args); }, timeout); debounceMap.set(func, timer); + debounceMap.set(fn, timer); }; + + return fn; } /** - * Cancels a scheduled debounced function. Does nothing if the function is not debounced or not scheduled. - * @param {function} func The function to cancel. + * Cancels a scheduled debounced function. + * Does nothing if the function is not debounced or not scheduled. + * @param {function} func The function to cancel. Either the original or the debounced function. */ export function cancelDebounce(func) { if (debounceMap.has(func)) { From dabcf6e9943a3d13f5ce22858ee555ba970217e0 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Mon, 22 Jul 2024 22:34:03 +0300 Subject: [PATCH 10/11] Add config to StructuredCloneMap --- public/scripts/util/StructuredCloneMap.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/public/scripts/util/StructuredCloneMap.js b/public/scripts/util/StructuredCloneMap.js index 51173e630..42dea5faa 100644 --- a/public/scripts/util/StructuredCloneMap.js +++ b/public/scripts/util/StructuredCloneMap.js @@ -5,6 +5,18 @@ * @extends Map */ export class StructuredCloneMap extends Map { + /** + * Constructs a new StructuredCloneMap. + * @param {object} options - Options for the map + * @param {boolean} options.cloneOnGet - Whether to clone the value when getting it from the map + * @param {boolean} options.cloneOnSet - Whether to clone the value when setting it in the map + */ + constructor({ cloneOnGet, cloneOnSet } = { cloneOnGet: true, cloneOnSet: true }) { + super(); + this.cloneOnGet = cloneOnGet; + this.cloneOnSet = cloneOnSet; + } + /** * Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated. * @@ -15,6 +27,10 @@ export class StructuredCloneMap extends Map { * @returns {this} The updated map */ set(key, value) { + if (!this.cloneOnSet) { + return super.set(key, value); + } + const clonedValue = structuredClone(value); super.set(key, clonedValue); return this; @@ -30,6 +46,10 @@ export class StructuredCloneMap extends Map { * @returns {V | undefined} Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned. */ get(key) { + if (!this.cloneOnGet) { + return super.get(key); + } + const value = super.get(key); return structuredClone(value); } From 1e2293713d14d39b630ce1dee8a45393d5346a78 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Mon, 22 Jul 2024 22:34:53 +0300 Subject: [PATCH 11/11] Clone WI cache only on get --- public/scripts/world-info.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 0e12da10f..3e8ccab6f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1,5 +1,5 @@ import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js'; -import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray } from './utils.js'; +import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce } from './utils.js'; import { extension_settings, getContext } from './extensions.js'; import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; import { isMobile } from './RossAscends-mods.js'; @@ -748,7 +748,7 @@ export const wi_anchor_position = { }; /** @type {StructuredCloneMap} */ -const worldInfoCache = new StructuredCloneMap(); +const worldInfoCache = new StructuredCloneMap({ cloneOnGet: true, cloneOnSet: false }); /** * Gets the world info based on chat messages. @@ -2898,8 +2898,6 @@ function getWorldEntry(name, data, entry) { .prop('selected', true) .trigger('input'); - saveWorldInfo(name, data); - // exclude recursion const excludeRecursionInput = template.find('input[name="exclude_recursion"]'); excludeRecursionInput.data('uid', entry.uid); @@ -3269,6 +3267,9 @@ function createWorldInfoEntry(_name, data) { } async function _save(name, data) { + // Prevent double saving if both immediate and debounced save are called + cancelDebounce(saveWorldDebounced); + await fetch('/api/worldinfo/edit', { method: 'POST', headers: getRequestHeaders(),