Bulk extension field updates via merge-attributes with UNSET_VALUE sentinel (#5471) * feat: add bulk extension field updates with UNSET_VALUE sentinel for key deletion - Add `UNSET_VALUE` sentinel constant to signal complete field removal from character cards - Add `writeExtensionFieldBulk()` function to update extension fields across multiple characters in a single API call - Add `deleteValueByPath()` utility function to remove nested object keys by dot-path - Update `writeExtensionField()` to support `UNSET_VALUE` for deleting extension keys - Extend `/api/characters/merge-attributes * Revert package-lock.json changes * Allow null values in merge-attributes filter path validation Change filter.path existence check to only skip on undefined, not null. This allows merging attributes when the existing value is explicitly null, treating null as a valid value rather than absence of a value. * fix: share forbiddenRegExp between modules * feat: add writeExtensionFieldBulk and UNSET_VALUE constant to getContext * Update src/endpoints/characters.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: validate for .png extension * Update public/scripts/extensions.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: extract shouldSkip logic as a function param to avoid double parsing --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

d720605be856e6f484e4b972d73d84f761ac4051

Wolfsblvt <wolfsblvt@gmail.com>

Signed
5 files changed, +310 -27Showing whitespace changes
public/scripts/extensions.js+127 -4
@@ -3,7 +3,7 @@ import { DOMPurify, Popper } from '../lib.js';
3import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';3import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';
4import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';4import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
5import { renderTemplate, renderTemplateAsync } from './templates.js';5import { renderTemplate, renderTemplateAsync } from './templates.js';
6import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';6import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
7import { getContext } from './st-context.js';7import { getContext } from './st-context.js';
8import { isAdmin } from './user.js';8import { isAdmin } from './user.js';
9import { addLocaleData, getCurrentLocale, t } from './i18n.js';9import { addLocaleData, getCurrentLocale, t } from './i18n.js';
@@ -1840,6 +1840,18 @@ export async function runGenerationInterceptors(chat, contextSize, type) {
1840}1840}
18411841
1842/**1842/**
1843 * Sentinel value that signals a field should be completely removed (unset)
1844 * from the character card rather than being set to any value. Pass this as
1845 * the `value` argument to {@link writeExtensionField} or
1846 * {@link writeExtensionFieldBulk} to delete the key entirely.
1847 *
1848 * Using `null` as a value will set the field to `null` (the key remains).
1849 * Using this sentinel will delete the key from the character card.
1850 * @type {string}
1851 */
1852export const UNSET_VALUE = '__@@UNSET@@__';
1853
1854/**
1843 * Writes a field to the character's data extensions object.1855 * Writes a field to the character's data extensions object.
1844 * @param {number|string} characterId Index in the character array1856 * @param {number|string} characterId Index in the character array
1845 * @param {string} key Field name1857 * @param {string} key Field name
@@ -1853,13 +1865,23 @@ export async function writeExtensionField(characterId, key, value) {
1853 console.warn('Character not found', characterId);1865 console.warn('Character not found', characterId);
1854 return;1866 return;
1855 }1867 }
1856 const path = `data.extensions.${key}`;1868 const extensionPath = `data.extensions.${key}`;
1857 setValueByPath(character, path, value);1869 const isUnset = value === UNSET_VALUE;
1870
1871 if (isUnset) {
1872 deleteValueByPath(character, extensionPath);
1873 } else {
1874 setValueByPath(character, extensionPath, value);
1875 }
18581876
1859 // Process JSON data1877 // Process JSON data
1860 if (character.json_data) {1878 if (character.json_data) {
1861 const jsonData = JSON.parse(character.json_data);1879 const jsonData = JSON.parse(character.json_data);
1862 setValueByPath(jsonData, path, value);1880 if (isUnset) {
1881 deleteValueByPath(jsonData, extensionPath);
1882 } else {
1883 setValueByPath(jsonData, extensionPath, value);
1884 }
1863 character.json_data = JSON.stringify(jsonData);1885 character.json_data = JSON.stringify(jsonData);
18641886
1865 // Make sure the data doesn't get lost when saving the current character1887 // Make sure the data doesn't get lost when saving the current character
@@ -1889,6 +1911,107 @@ export async function writeExtensionField(characterId, key, value) {
1889}1911}
18901912
1891/**1913/**
1914 * @typedef {object} BulkExtensionFieldResult
1915 * @property {string[]} updated Avatar filenames that were successfully updated
1916 * @property {string[]} skipped Avatar filenames skipped (filter didn't match or unreadable)
1917 * @property {string[]} failed Avatar filenames where the update failed
1918 */
1919
1920/**
1921 * Writes (or deletes) an extension field for multiple characters in a single
1922 * bulk request. Unlike {@link writeExtensionField}, this sends one API call
1923 * for all characters, and the server processes them in parallel.
1924 *
1925 * When `value` is {@link UNSET_VALUE} the extension key is **deleted** from
1926 * each matching character card. Passing `null` sets the field to `null`
1927 * (the key is preserved).
1928 *
1929 * @param {string[]|null} avatars Avatar filenames to update. Pass `null` or an
1930 * empty array to target **all** characters in the user's character directory.
1931 * @param {string} key Extension field name (e.g. "greeting_tools")
1932 * @param {any} value Field value, `null` to set null, or
1933 * {@link UNSET_VALUE} to delete the key entirely
1934 * @param {object} [options={}] Optional settings
1935 * @param {string} [options.filterPath] Dot-path filter — the server will only
1936 * update characters where this path is present and not `undefined`;
1937 * `null` still counts as a match. Useful when the frontend has shallow
1938 * character data and cannot pre-filter.
1939 * Defaults to `data.extensions.<key>` when unsetting, so deletion requests
1940 * automatically skip characters where the field is missing/`undefined`.
1941 * @returns {Promise<BulkExtensionFieldResult>} Summary of the bulk operation
1942 */
1943export async function writeExtensionFieldBulk(avatars, key, value, { filterPath } = {}) {
1944 const context = getContext();
1945 const extensionPath = `data.extensions.${key}`;
1946 const isUnset = value === UNSET_VALUE;
1947
1948 // Build the server request
1949 const requestBody = {
1950 avatars: Array.isArray(avatars) && avatars.length > 0 ? avatars : [],
1951 data: {
1952 data: {
1953 extensions: {
1954 [key]: value,
1955 },
1956 },
1957 },
1958 };
1959
1960 // Default filter: when unsetting, only touch characters that have the field
1961 const resolvedFilterPath = filterPath ?? (isUnset ? extensionPath : undefined);
1962 if (resolvedFilterPath) {
1963 requestBody.filter = { path: resolvedFilterPath };
1964 }
1965
1966 const mergeResponse = await fetch('/api/characters/merge-attributes', {
1967 method: 'POST',
1968 headers: getRequestHeaders(),
1969 body: JSON.stringify(requestBody),
1970 });
1971
1972 if (!mergeResponse.ok) {
1973 console.error('Bulk extension field update failed', mergeResponse.statusText);
1974 return { updated: [], skipped: [], failed: [] };
1975 }
1976
1977 /** @type {BulkExtensionFieldResult} */
1978 const result = await mergeResponse.json();
1979
1980 // Sync in-memory character objects for successfully updated characters
1981 const updatedSet = new Set(result.updated);
1982 for (const character of context.characters) {
1983 if (!character || !updatedSet.has(character.avatar)) continue;
1984
1985 if (isUnset) {
1986 deleteValueByPath(character, extensionPath);
1987 } else {
1988 setValueByPath(character, extensionPath, value);
1989 }
1990
1991 // Keep json_data in sync
1992 if (character.json_data) {
1993 const jsonData = JSON.parse(character.json_data);
1994 if (isUnset) {
1995 deleteValueByPath(jsonData, extensionPath);
1996 } else {
1997 setValueByPath(jsonData, extensionPath, value);
1998 }
1999 character.json_data = JSON.stringify(jsonData);
2000 }
2001 }
2002
2003 // If the currently active character was updated, sync the hidden input
2004 if (context.characterId !== undefined) {
2005 const activeChar = context.characters[context.characterId];
2006 if (activeChar && updatedSet.has(activeChar.avatar) && activeChar.json_data) {
2007 $('#character_json_data').val(activeChar.json_data);
2008 }
2009 }
2010
2011 return result;
2012}
2013
2014/**
1892 * Prompts the user to enter the Git URL of the extension to import.2015 * Prompts the user to enter the Git URL of the extension to import.
1893 * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension.2016 * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension.
1894 * If the extension is imported successfully, a success message is displayed.2017 * If the extension is imported successfully, a success message is displayed.
public/scripts/st-context.js+6 -0
@@ -77,7 +77,9 @@ import {
77 renderExtensionTemplate,77 renderExtensionTemplate,
78 renderExtensionTemplateAsync,78 renderExtensionTemplateAsync,
79 saveMetadataDebounced,79 saveMetadataDebounced,
80 UNSET_VALUE,
80 writeExtensionField,81 writeExtensionField,
82 writeExtensionFieldBulk,
81} from './extensions.js';83} from './extensions.js';
82import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js';84import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js';
83import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js';85import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js';
@@ -202,6 +204,7 @@ export function getContext() {
202 generateRaw,204 generateRaw,
203 generateRawData,205 generateRawData,
204 writeExtensionField,206 writeExtensionField,
207 writeExtensionFieldBulk,
205 getThumbnailUrl,208 getThumbnailUrl,
206 selectCharacterById,209 selectCharacterById,
207 messageFormatting,210 messageFormatting,
@@ -296,6 +299,9 @@ export function getContext() {
296 symbols: {299 symbols: {
297 ignore: IGNORE_SYMBOL,300 ignore: IGNORE_SYMBOL,
298 },301 },
302 constants: {
303 unset: UNSET_VALUE,
304 },
299 };305 };
300}306}
301307
public/scripts/utils.js+17 -0
@@ -2062,6 +2062,23 @@ export function setValueByPath(obj, path, value) {
2062}2062}
20632063
2064/**2064/**
2065 * Deletes a value from a nested object at the given dot-separated path.
2066 * @param {object} obj Object to delete from
2067 * @param {string} path Dot-separated key path (e.g. "data.extensions.myKey")
2068 */
2069export function deleteValueByPath(obj, path) {
2070 const keyParts = path.split('.');
2071 let current = obj;
2072 for (let i = 0; i < keyParts.length - 1; i++) {
2073 if (!current || typeof current !== 'object') return;
2074 current = current[keyParts[i]];
2075 }
2076 if (current && typeof current === 'object') {
2077 delete current[keyParts[keyParts.length - 1]];
2078 }
2079}
2080
2081/**
2065 * Flashes the given HTML element via CSS flash animation for a defined period2082 * Flashes the given HTML element via CSS flash animation for a defined period
2066 * @param {JQuery<HTMLElement>} element - The element to flash2083 * @param {JQuery<HTMLElement>} element - The element to flash
2067 * @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)2084 * @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)
src/endpoints/characters.js+158 -22
@@ -13,7 +13,7 @@ import { Jimp, JimpMime } from '../jimp.js';
13import storage from 'node-persist';13import storage from 'node-persist';
1414
15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';
16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction, forbiddenRegExp } from '../middleware/validateFileName.js';
17import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';17import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';
18import { TavernCardValidator } from '../validator/TavernCardValidator.js';18import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19import { parse, read, write } from '../character-card-parser.js';19import { parse, read, write } from '../character-card-parser.js';
@@ -1220,45 +1220,181 @@ router.post('/edit-attribute', validateAvatarUrlMiddleware, async function (requ
1220});1220});
12211221
1222/**1222/**
1223 * Handle a POST request to edit character properties.1223 * Sentinel value that signals a field should be completely removed (unset)
1224 *1224 * from the character card rather than being set to any value. Use this in
1225 * Merges the request body with the selected character and1225 * the merge payload wherever a key should be deleted.
1226 * validates the result against TavernCard V2 specification.
1227 *
1228 * @param {Object} request - The HTTP request object.
1229 * @param {Object} response - The HTTP response object.
1230 *1226 *
1231 * @returns {void}1227 * Both the server and the frontend share this constant so that callers can
1232 * */1228 * explicitly opt into deletion without overloading `null`.
1233router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) {1229 * @type {string}
1234 try {1230 */
1235 const update = request.body;1231const UNSET_SENTINEL = '__@@UNSET@@__';
1236 const avatarPath = path.join(request.user.directories.characters, update.avatar);
12371232
1238 const pngStringData = await readCharacterData(avatarPath);1233/** Maximum number of characters processed in parallel during bulk merge */
1234const BULK_MERGE_CONCURRENCY = 10;
12391235
1236/**
1237 * Recursively walks `source` and removes any key from `target` whose
1238 * corresponding value in `source` equals the {@link UNSET_SENTINEL}.
1239 * Called after {@link deepMerge} so that the sentinel gets replaced by
1240 * an actual key deletion.
1241 * @param {object} target The merged character object to clean up
1242 * @param {object} source The original update payload (pre-merge clone)
1243 */
1244function processUnsetSentinels(target, source) {
1245 for (const key of Object.keys(source)) {
1246 if (source[key] === UNSET_SENTINEL) {
1247 _.unset(target, key);
1248 } else if (_.isPlainObject(source[key]) && _.isPlainObject(target[key])) {
1249 processUnsetSentinels(target[key], source[key]);
1250 }
1251 }
1252}
1253
1254/**
1255 * Reads a character card, applies a merge update (with sentinel-based
1256 * unsetting), validates the result, and writes it back.
1257 * @param {string} avatarPath Full path to the character PNG
1258 * @param {string} avatar Avatar filename (e.g. "char.png")
1259 * @param {object} updateData The merge payload to apply
1260 * @param {import("express").Request} request Express request object
1261 * @param {((data: any) => boolean) | null} [shouldSkip] Optional function to determine if a character should be skipped based on its original data (used for bulk merge filtering)
1262 * @returns {Promise<{ok: boolean, error?: string, skipped?: boolean}>} Result of the merge operation, including any validation error
1263 */
1264async function mergeCharacterUpdate(avatarPath, avatar, updateData, request, shouldSkip = null) {
1265 const pngStringData = await readCharacterData(avatarPath);
1240 if (!pngStringData) {1266 if (!pngStringData) {
1241 console.error('Error: invalid character file.');1267 return { ok: false, error: 'Invalid character file' };
1242 return response.status(400).send('Error: invalid character file.');
1243 }1268 }
12441269
1245 let character = JSON.parse(pngStringData);1270 let character = JSON.parse(pngStringData);
12461271
1272 if (typeof shouldSkip === 'function' && shouldSkip(character)) {
1273 return { ok: false, skipped: true };
1274 }
1275
1276 const update = _.cloneDeep(updateData);
1247 _.unset(update, 'json_data');1277 _.unset(update, 'json_data');
1248 _.unset(character, 'json_data');1278 _.unset(character, 'json_data');
12491279
1250 character = deepMerge(character, update);1280 character = deepMerge(character, update);
1281 processUnsetSentinels(character, update);
12511282
1252 const validator = new TavernCardValidator(character);1283 const validator = new TavernCardValidator(character);
1253 const targetImg = (update.avatar).replace('.png', '');
1254
1255 //Accept either V1 or V2.1284 //Accept either V1 or V2.
1256 if (validator.validate()) {1285 if (!validator.validate()) {
1286 return { ok: false, error: validator.lastValidationError ?? 'Validation failed' };
1287 }
1288
1289 const targetImg = avatar.replace('.png', '');
1257 await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);1290 await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);
1291 return { ok: true };
1292}
1293
1294/**
1295 * Handle a POST request to edit character properties.
1296 *
1297 * Operates in two modes depending on the request body:
1298 *
1299 * **Single mode** (default behavior) — when `avatar` (string) is present:
1300 * Merges the request body with the selected character and validates the
1301 * result against TavernCard V2 specification.
1302 *
1303 * **Bulk mode** — when `avatars` (array) is present:
1304 * Applies the same merge to multiple characters in parallel. Supports:
1305 * - An explicit list of avatars, or all characters when the array is empty
1306 * - An optional server-side `filter` so only characters where a given
1307 * JSON path exists and is non-null are updated
1308 *
1309 * In both modes, any value equal to the sentinel `__@@UNSET@@__` will cause
1310 * that key to be **deleted** from the character card instead of being set.
1311 *
1312 * @param {import("express").Request} request - The HTTP request object
1313 * @param {import("express").Response} response - The HTTP response object
1314 * @returns {void}
1315 */
1316router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) {
1317 try {
1318 // ── Bulk mode: avatars array is present ──────────────────
1319 if (Array.isArray(request.body.avatars)) {
1320 const { avatars, data, filter } = request.body;
1321
1322 if (!_.isPlainObject(data)) {
1323 return response.status(400).send({ message: 'No valid update data provided.' });
1324 }
1325
1326 // Determine which avatar files to process
1327 let targetAvatars;
1328 if (avatars.length > 0) {
1329 for (const avatar of avatars) {
1330 if (typeof avatar !== 'string' || forbiddenRegExp.test(avatar) || path.extname(avatar).toLowerCase() !== '.png') {
1331 return response.status(400).send({ message: `Invalid avatar filename: ${avatar}` });
1332 }
1333 }
1334 targetAvatars = avatars;
1335 } else {
1336 // Empty array → scan all characters in the directory
1337 const files = fs.readdirSync(request.user.directories.characters);
1338 targetAvatars = files.filter(file => path.extname(file).toLowerCase() === '.png');
1339 }
1340
1341 const updated = [];
1342 const skipped = [];
1343 const failed = [];
1344
1345 /**
1346 * Process a single character in bulk: read, filter, merge, validate, write.
1347 * @param {string} avatar Avatar filename
1348 */
1349 const processOne = async (avatar) => {
1350 const avatarPath = path.join(request.user.directories.characters, avatar);
1351
1352 try {
1353 /** @type {(character: object) => boolean} */
1354 let shouldSkip = () => false;
1355
1356 // Apply optional server-side filter before updating the card
1357 if (filter && typeof filter.path === 'string') {
1358 shouldSkip = (character) => {
1359 const value = _.get(character, filter.path);
1360 return value === undefined;
1361 };
1362 }
1363
1364 const result = await mergeCharacterUpdate(avatarPath, avatar, data, request, shouldSkip);
1365 if (result.ok) {
1366 updated.push(avatar);
1367 } else if (result.skipped) {
1368 skipped.push(avatar);
1369 } else {
1370 console.warn(`Bulk merge failed for ${avatar}:`, result.error);
1371 failed.push(avatar);
1372 }
1373 } catch (error) {
1374 console.error(`Bulk merge failed for ${avatar}:`, error);
1375 failed.push(avatar);
1376 }
1377 };
1378
1379 // Process in parallel with a concurrency limit
1380 for (let i = 0; i < targetAvatars.length; i += BULK_MERGE_CONCURRENCY) {
1381 const batch = targetAvatars.slice(i, i + BULK_MERGE_CONCURRENCY);
1382 await Promise.allSettled(batch.map(processOne));
1383 }
1384
1385 return response.send({ updated, skipped, failed });
1386 }
1387
1388 // ── Single mode (default behavior) ───────────────────────
1389 const update = request.body;
1390 const avatarPath = path.join(request.user.directories.characters, update.avatar);
1391
1392 const result = await mergeCharacterUpdate(avatarPath, update.avatar, update, request);
1393 if (result.ok) {
1258 response.sendStatus(200);1394 response.sendStatus(200);
1259 } else {1395 } else {
1260 console.warn(validator.lastValidationError);1396 console.warn(result.error);
1261 response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError });1397 response.status(400).send({ message: `Validation failed for ${update.avatar}`, error: result.error });
1262 }1398 }
1263 } catch (exception) {1399 } catch (exception) {
1264 response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() });1400 response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() });
src/middleware/validateFileName.js+2 -1
@@ -1,5 +1,7 @@
1import path from 'node:path';1import path from 'node:path';
22
3export const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/;
4
3/**5/**
4 * Checks if an object has a toString method.6 * Checks if an object has a toString method.
5 * @param {object} o Object to check7 * @param {object} o Object to check
@@ -23,7 +25,6 @@ export function getFileNameValidationFunction(fieldName) {
23 */25 */
24 return function validateAvatarUrlMiddleware(req, res, next) {26 return function validateAvatarUrlMiddleware(req, res, next) {
25 if (req.body && fieldName in req.body && (typeof req.body[fieldName] === 'string' || hasToString(req.body[fieldName]))) {27 if (req.body && fieldName in req.body && (typeof req.body[fieldName] === 'string' || hasToString(req.body[fieldName]))) {
26 const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/;
27 if (forbiddenRegExp.test(req.body[fieldName])) {28 if (forbiddenRegExp.test(req.body[fieldName])) {
28 console.error('An error occurred while validating the request body', {29 console.error('An error occurred while validating the request body', {
29 handle: req.user.profile.handle,30 handle: req.user.profile.handle,