Merge pull request #2891 from SillyTavern/if-command-default-arguments `/if` and `/while` now allow "right" and "rule" to be optional

b025765a1eafb8b80a33f5a33b4b099f4568908f

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

Signed
1 files changed, +150 -119Ignore whitespace
public/scripts/variables.js+150 -119
@@ -12,7 +12,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
12import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';12import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
13import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';13import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
14import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';14import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
15import { isFalseBoolean, convertValueType } from './utils.js';15import { isFalseBoolean, convertValueType, isTrueBoolean } from './utils.js';
1616
17/** @typedef {import('./slash-commands/SlashCommandParser.js').NamedArguments} NamedArguments */17/** @typedef {import('./slash-commands/SlashCommandParser.js').NamedArguments} NamedArguments */
18/** @typedef {import('./slash-commands/SlashCommand.js').UnnamedArguments} UnnamedArguments */18/** @typedef {import('./slash-commands/SlashCommand.js').UnnamedArguments} UnnamedArguments */
@@ -488,7 +488,7 @@ function existsGlobalVariable(name) {
488/**488/**
489 * Parses boolean operands from command arguments.489 * Parses boolean operands from command arguments.
490 * @param {object} args Command arguments490 * @param {object} args Command arguments
491 * @returns {{a: string | number, b: string | number, rule: string}} Boolean operands491 * @returns {{a: string | number, b: string | number?, rule: string}} Boolean operands
492 */492 */
493export function parseBooleanOperands(args) {493export function parseBooleanOperands(args) {
494 // Resolution order: numeric literal, local variable, global variable, string literal494 // Resolution order: numeric literal, local variable, global variable, string literal
@@ -497,6 +497,9 @@ export function parseBooleanOperands(args) {
497 */497 */
498 function getOperand(operand) {498 function getOperand(operand) {
499 if (operand === undefined) {499 if (operand === undefined) {
500 return undefined;
501 }
502 if (operand === '') {
500 return '';503 return '';
501 }504 }
502505
@@ -525,8 +528,8 @@ export function parseBooleanOperands(args) {
525 return stringLiteral || '';528 return stringLiteral || '';
526 }529 }
527530
528 const left = getOperand(args.a || args.left || args.first || args.x);531 const left = getOperand(args.a ?? args.left ?? args.first ?? args.x);
529 const right = getOperand(args.b || args.right || args.second || args.y);532 const right = getOperand(args.b ?? args.right ?? args.second ?? args.y);
530 const rule = args.rule;533 const rule = args.rule;
531534
532 return { a: left, b: right, rule };535 return { a: left, b: right, rule };
@@ -534,84 +537,79 @@ export function parseBooleanOperands(args) {
534537
535/**538/**
536 * Evaluates a boolean comparison rule.539 * Evaluates a boolean comparison rule.
537 * @param {string} rule Boolean comparison rule540 *
541 * @param {string?} rule Boolean comparison rule
538 * @param {string|number} a The left operand542 * @param {string|number} a The left operand
539 * @param {string|number} b The right operand543 * @param {string|number?} b The right operand
540 * @returns {boolean} True if the rule yields true, false otherwise544 * @returns {boolean} True if the rule yields true, false otherwise
541 */545 */
542export function evalBoolean(rule, a, b) {546export function evalBoolean(rule, a, b) {
543 if (!rule) {547 if (a === undefined) {
544 toastr.warning('The rule must be specified for the boolean comparison.', 'Invalid command');548 throw new Error('Left operand is not provided');
545 throw new Error('Invalid command.');549 }
550
551 // If right-hand side was not provided, whe just check if the left side is truthy
552 if (b === undefined) {
553 switch (rule) {
554 case undefined:
555 case 'not': {
556 const resultOnTruthy = rule !== 'not';
557 if (isTrueBoolean(String(a))) return resultOnTruthy;
558 if (isFalseBoolean(String(a))) return !resultOnTruthy;
559 return a ? resultOnTruthy : !resultOnTruthy;
560 }
561 default:
562 throw new Error(`Unknown boolean comparison rule for truthy check. If right operand is not provided, the rule must not provided or be 'not'. Provided: ${rule}`);
563 }
546 }564 }
547565
548 let result = false;566 // If no rule was provided, we are implicitly using 'eq', as defined for the slash commands
567 rule ??= 'eq';
568
549 if (typeof a === 'number' && typeof b === 'number') {569 if (typeof a === 'number' && typeof b === 'number') {
550 // only do numeric comparison if both operands are numbers570 // only do numeric comparison if both operands are numbers
551 const aNumber = Number(a);571 const aNumber = Number(a);
552 const bNumber = Number(b);572 const bNumber = Number(b);
553573
554 switch (rule) {574 switch (rule) {
555 case 'not':
556 result = !aNumber;
557 break;
558 case 'gt':575 case 'gt':
559 result = aNumber > bNumber;576 return aNumber > bNumber;
560 break;
561 case 'gte':577 case 'gte':
562 result = aNumber >= bNumber;578 return aNumber >= bNumber;
563 break;
564 case 'lt':579 case 'lt':
565 result = aNumber < bNumber;580 return aNumber < bNumber;
566 break;
567 case 'lte':581 case 'lte':
568 result = aNumber <= bNumber;582 return aNumber <= bNumber;
569 break;
570 case 'eq':583 case 'eq':
571 result = aNumber === bNumber;584 return aNumber === bNumber;
572 break;
573 case 'neq':585 case 'neq':
574 result = aNumber !== bNumber;586 return aNumber !== bNumber;
575 break;
576 default:
577 toastr.error('Unknown boolean comparison rule for type number.', 'Invalid command');
578 throw new Error('Invalid command.');
579 }
580 } else {
581 // otherwise do case-insensitive string comparsion, stringify non-strings
582 let aString;
583 let bString;
584 if (typeof a == 'string') {
585 aString = a.toLowerCase();
586 } else {
587 aString = JSON.stringify(a).toLowerCase();
588 }
589 if (typeof b == 'string') {
590 bString = b.toLowerCase();
591 } else {
592 bString = JSON.stringify(b).toLowerCase();
593 }
594
595 switch (rule) {
596 case 'in':587 case 'in':
597 result = aString.includes(bString);
598 break;
599 case 'nin':588 case 'nin':
600 result = !aString.includes(bString);589 // Fall through to string comparison. Otherwise you could not check if 12345 contains 45 for example.
601 break;590 console.debug(`Boolean comparison rule '${rule}' is not supported for type number. Falling back to string comparison.`);
602 case 'eq':
603 result = aString === bString;
604 break;
605 case 'neq':
606 result = aString !== bString;
607 break;591 break;
608 default:592 default:
609 toastr.error('Unknown boolean comparison rule for type string.', 'Invalid /if command');593 throw new Error(`Unknown boolean comparison rule for type number. Accepted: gt, gte, lt, lte, eq, neq. Provided: ${rule}`);
610 throw new Error('Invalid command.');
611 }594 }
612 }595 }
613596
614 return result;597 // otherwise do case-insensitive string comparsion, stringify non-strings
598 let aString = (typeof a === 'string') ? a.toLowerCase() : JSON.stringify(a).toLowerCase();
599 let bString = (typeof b === 'string') ? b.toLowerCase() : JSON.stringify(b).toLowerCase();
600
601 switch (rule) {
602 case 'in':
603 return aString.includes(bString);
604 case 'nin':
605 return !aString.includes(bString);
606 case 'eq':
607 return aString === bString;
608 case 'neq':
609 return aString !== bString;
610 default:
611 throw new Error(`Unknown boolean comparison rule for type number. Accepted: in, nin, eq, neq. Provided: ${rule}`);
612 }
615}613}
616614
617/**615/**
@@ -1317,32 +1315,36 @@ export function registerVariableCommands() {
1317 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],1315 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
1318 isRequired: true,1316 isRequired: true,
1319 enumProvider: commonEnumProviders.variables('all'),1317 enumProvider: commonEnumProviders.variables('all'),
1320 forceEnum: false,
1321 }),1318 }),
1322 SlashCommandNamedArgument.fromProps({1319 SlashCommandNamedArgument.fromProps({
1323 name: 'right',1320 name: 'right',
1324 description: 'right operand',1321 description: 'right operand',
1325 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],1322 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
1326 isRequired: true,
1327 enumProvider: commonEnumProviders.variables('all'),1323 enumProvider: commonEnumProviders.variables('all'),
1328 forceEnum: false,
1329 }),1324 }),
1330 new SlashCommandNamedArgument(1325 SlashCommandNamedArgument.fromProps({
1331 'rule', 'comparison rule', [ARGUMENT_TYPE.STRING], true, false, null, [1326 name: 'rule',
1332 new SlashCommandEnumValue('gt', 'a > b'),1327 description: 'comparison rule',
1333 new SlashCommandEnumValue('gte', 'a >= b'),1328 typeList: [ARGUMENT_TYPE.STRING],
1334 new SlashCommandEnumValue('lt', 'a < b'),1329 defaultValue: 'eq',
1335 new SlashCommandEnumValue('lte', 'a <= b'),1330 enumList: [
1336 new SlashCommandEnumValue('eq', 'a == b'),1331 new SlashCommandEnumValue('eq', 'a == b (strings & numbers)'),
1337 new SlashCommandEnumValue('neq', 'a !== b'),1332 new SlashCommandEnumValue('neq', 'a !== b (strings & numbers)'),
1338 new SlashCommandEnumValue('not', '!a'),1333 new SlashCommandEnumValue('in', 'a includes b (strings & numbers as strings)'),
1339 new SlashCommandEnumValue('in', 'a includes b'),1334 new SlashCommandEnumValue('nin', 'a not includes b (strings & numbers as strings)'),
1340 new SlashCommandEnumValue('nin', 'a not includes b'),1335 new SlashCommandEnumValue('gt', 'a > b (numbers)'),
1341 ],1336 new SlashCommandEnumValue('gte', 'a >= b (numbers)'),
1342 ),1337 new SlashCommandEnumValue('lt', 'a < b (numbers)'),
1343 new SlashCommandNamedArgument(1338 new SlashCommandEnumValue('lte', 'a <= b (numbers)'),
1344 'else', 'command to execute if not true', [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], false,1339 new SlashCommandEnumValue('not', '!a (truthy)'),
1345 ),1340 ],
1341 forceEnum: true,
1342 }),
1343 SlashCommandNamedArgument.fromProps({
1344 name: 'else',
1345 description: 'command to execute if not true',
1346 typeList: [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND],
1347 }),
1346 ],1348 ],
1347 unnamedArgumentList: [1349 unnamedArgumentList: [
1348 new SlashCommandArgument(1350 new SlashCommandArgument(
@@ -1360,17 +1362,25 @@ export function registerVariableCommands() {
1360 Numeric values and string literals for left and right operands supported.1362 Numeric values and string literals for left and right operands supported.
1361 </div>1363 </div>
1362 <div>1364 <div>
1365 If the rule is not provided, it defaults to <code>eq</code>.
1366 </div>
1367 <div>
1368 If no right operand is provided, it defaults to checking the <code>left</code> value to be truthy.
1369 A non-empty string or non-zero number is considered truthy, as is the value <code>true</code> or <code>on</code>.<br />
1370 Only acceptable rules for no provided right operand are <code>not</code>, and no provided rule - which default to returning whether it is not or is truthy.
1371 </div>
1372 <div>
1363 <strong>Available rules:</strong>1373 <strong>Available rules:</strong>
1364 <ul>1374 <ul>
1365 <li>gt => a > b</li>1375 <li><code>eq</code> => a == b <small>(strings & numbers)</small></li>
1366 <li>gte => a >= b</li>1376 <li><code>neq</code> => a !== b <small>(strings & numbers)</small></li>
1367 <li>lt => a < b</li>1377 <li><code>in</code> => a includes b <small>(strings & numbers as strings)</small></li>
1368 <li>lte => a <= b</li>1378 <li><code>nin</code> => a not includes b <small>(strings & numbers as strings)</small></li>
1369 <li>eq => a == b</li>1379 <li><code>gt</code> => a > b <small>(numbers)</small></li>
1370 <li>neq => a != b</li>1380 <li><code>gte</code> => a >= b <small>(numbers)</small></li>
1371 <li>not => !a</li>1381 <li><code>lt</code> => a < b <small>(numbers)</small></li>
1372 <li>in (strings) => a includes b</li>1382 <li><code>lte</code> => a <= b <small>(numbers)</small></li>
1373 <li>nin (strings) => a not includes b</li>1383 <li><code>not</code> => !a <small>(truthy)</small></li>
1374 </ul>1384 </ul>
1375 </div>1385 </div>
1376 <div>1386 <div>
@@ -1380,6 +1390,17 @@ export function registerVariableCommands() {
1380 <pre><code class="language-stscript">/if left=score right=10 rule=gte "/speak You win"</code></pre>1390 <pre><code class="language-stscript">/if left=score right=10 rule=gte "/speak You win"</code></pre>
1381 triggers a /speak command if the value of "score" is greater or equals 10.1391 triggers a /speak command if the value of "score" is greater or equals 10.
1382 </li>1392 </li>
1393 <li>
1394 <pre><code class="language-stscript">/if left={{lastMessage}} rule=in right=surprise {: /echo SURPISE! :}</code></pre>
1395 executes a subcommand defined as a closure if the given value contains a specified word.
1396 <li>
1397 <pre><code class="language-stscript">/if left=myContent {: /echo My content had some content. :}</code></pre>
1398 executes the defined subcommand, if the provided value of left is truthy (contains some kind of contant that is not empty or false)
1399 </li>
1400 <li>
1401 <pre><code class="language-stscript">/if left=tree right={{getvar::object}} {: /echo The object is a tree! :}</code></pre>
1402 executes the defined subcommand, if the left and right values are equals.
1403 </li>
1383 </ul>1404 </ul>
1384 </div>1405 </div>
1385 `,1406 `,
@@ -1395,32 +1416,38 @@ export function registerVariableCommands() {
1395 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],1416 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
1396 isRequired: true,1417 isRequired: true,
1397 enumProvider: commonEnumProviders.variables('all'),1418 enumProvider: commonEnumProviders.variables('all'),
1398 forceEnum: false,
1399 }),1419 }),
1400 SlashCommandNamedArgument.fromProps({1420 SlashCommandNamedArgument.fromProps({
1401 name: 'right',1421 name: 'right',
1402 description: 'right operand',1422 description: 'right operand',
1403 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],1423 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
1404 isRequired: true,
1405 enumProvider: commonEnumProviders.variables('all'),1424 enumProvider: commonEnumProviders.variables('all'),
1406 forceEnum: false,
1407 }),1425 }),
1408 new SlashCommandNamedArgument(1426 SlashCommandNamedArgument.fromProps({
1409 'rule', 'comparison rule', [ARGUMENT_TYPE.STRING], true, false, null, [1427 name: 'rule',
1410 new SlashCommandEnumValue('gt', 'a > b'),1428 description: 'comparison rule',
1411 new SlashCommandEnumValue('gte', 'a >= b'),1429 typeList: [ARGUMENT_TYPE.STRING],
1412 new SlashCommandEnumValue('lt', 'a < b'),1430 defaultValue: 'eq',
1413 new SlashCommandEnumValue('lte', 'a <= b'),1431 enumList: [
1414 new SlashCommandEnumValue('eq', 'a == b'),1432 new SlashCommandEnumValue('eq', 'a == b (strings & numbers)'),
1415 new SlashCommandEnumValue('neq', 'a !== b'),1433 new SlashCommandEnumValue('neq', 'a !== b (strings & numbers)'),
1416 new SlashCommandEnumValue('not', '!a'),1434 new SlashCommandEnumValue('in', 'a includes b (strings & numbers as strings)'),
1417 new SlashCommandEnumValue('in', 'a includes b'),1435 new SlashCommandEnumValue('nin', 'a not includes b (strings & numbers as strings)'),
1418 new SlashCommandEnumValue('nin', 'a not includes b'),1436 new SlashCommandEnumValue('gt', 'a > b (numbers)'),
1419 ],1437 new SlashCommandEnumValue('gte', 'a >= b (numbers)'),
1420 ),1438 new SlashCommandEnumValue('lt', 'a < b (numbers)'),
1421 new SlashCommandNamedArgument(1439 new SlashCommandEnumValue('lte', 'a <= b (numbers)'),
1422 'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(),1440 new SlashCommandEnumValue('not', '!a (truthy)'),
1423 ),1441 ],
1442 forceEnum: true,
1443 }),
1444 SlashCommandNamedArgument.fromProps({
1445 name: 'guard',
1446 description: 'disable loop iteration limit',
1447 typeList: [ARGUMENT_TYPE.STRING],
1448 defaultValue: 'off',
1449 enumList: commonEnumProviders.boolean('onOff')(),
1450 }),
1424 ],1451 ],
1425 unnamedArgumentList: [1452 unnamedArgumentList: [
1426 new SlashCommandArgument(1453 new SlashCommandArgument(
@@ -1439,15 +1466,15 @@ export function registerVariableCommands() {
1439 <div>1466 <div>
1440 <strong>Available rules:</strong>1467 <strong>Available rules:</strong>
1441 <ul>1468 <ul>
1442 <li>gt => a > b</li>1469 <li><code>eq</code> => a == b <small>(strings & numbers)</small></li>
1443 <li>gte => a >= b</li>1470 <li><code>neq</code> => a !== b <small>(strings & numbers)</small></li>
1444 <li>lt => a < b</li>1471 <li><code>in</code> => a includes b <small>(strings & numbers as strings)</small></li>
1445 <li>lte => a <= b</li>1472 <li><code>nin</code> => a not includes b <small>(strings & numbers as strings)</small></li>
1446 <li>eq => a == b</li>1473 <li><code>gt</code> => a > b <small>(numbers)</small></li>
1447 <li>neq => a != b</li>1474 <li><code>gte</code> => a >= b <small>(numbers)</small></li>
1448 <li>not => !a</li>1475 <li><code>lt</code> => a < b <small>(numbers)</small></li>
1449 <li>in (strings) => a includes b</li>1476 <li><code>lte</code> => a <= b <small>(numbers)</small></li>
1450 <li>nin (strings) => a not includes b</li>1477 <li><code>not</code> => !a <small>(truthy)</small></li>
1451 </ul>1478 </ul>
1452 </div>1479 </div>
1453 <div>1480 <div>
@@ -1457,7 +1484,11 @@ export function registerVariableCommands() {
1457 <pre><code class="language-stscript">/setvar key=i 0 | /while left=i right=10 rule=lte "/addvar key=i 1"</code></pre>1484 <pre><code class="language-stscript">/setvar key=i 0 | /while left=i right=10 rule=lte "/addvar key=i 1"</code></pre>
1458 adds 1 to the value of "i" until it reaches 10.1485 adds 1 to the value of "i" until it reaches 10.
1459 </li>1486 </li>
1460 </ul>1487 <li>
1488 <pre><code class="language-stscript">/while left={{getvar::currentword}} {: /setvar key=currentword {: /do-something-and-return :}() | /echo The current work is "{{getvar::currentword}}" :}</code></pre>
1489 executes the defined subcommand as long as the "currentword" variable is truthy (has any content that is not false/empty)
1490 </ul>
1491 </li>
1461 </div>1492 </div>
1462 <div>1493 <div>
1463 Loops are limited to 100 iterations by default, pass <code>guard=off</code> to disable.1494 Loops are limited to 100 iterations by default, pass <code>guard=off</code> to disable.
@@ -1572,7 +1603,7 @@ export function registerVariableCommands() {
1572 typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME],1603 typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME],
1573 isRequired: true,1604 isRequired: true,
1574 acceptsMultiple: true,1605 acceptsMultiple: true,
1575 enumProvider: (executor, scope)=>{1606 enumProvider: (executor, scope) => {
1576 const vars = commonEnumProviders.variables('all')(executor, scope);1607 const vars = commonEnumProviders.variables('all')(executor, scope);
1577 vars.push(1608 vars.push(
1578 new SlashCommandEnumValue(1609 new SlashCommandEnumValue(
@@ -1580,16 +1611,16 @@ export function registerVariableCommands() {
1580 null,1611 null,
1581 enumTypes.variable,1612 enumTypes.variable,
1582 enumIcons.variable,1613 enumIcons.variable,
1583 (input)=>/^\w*$/.test(input),1614 (input) => /^\w*$/.test(input),
1584 (input)=>input,1615 (input) => input,
1585 ),1616 ),
1586 new SlashCommandEnumValue(1617 new SlashCommandEnumValue(
1587 'any number',1618 'any number',
1588 null,1619 null,
1589 enumTypes.number,1620 enumTypes.number,
1590 enumIcons.number,1621 enumIcons.number,
1591 (input)=>input == '' || !Number.isNaN(Number(input)),1622 (input) => input == '' || !Number.isNaN(Number(input)),
1592 (input)=>input,1623 (input) => input,
1593 ),1624 ),
1594 );1625 );
1595 return vars;1626 return vars;