This commit is contained in:
2025-09-14 23:43:10 +08:00
commit abdbd63d63
1222 changed files with 720115 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
/**
* @abstract
* @implements {EventTarget}
*/
export class AbstractEventTarget {
constructor() {
this.listeners = {};
}
addEventListener(type, callback, _options) {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
this.listeners[type].push(callback);
}
dispatchEvent(event) {
if (!this.listeners[event.type] || this.listeners[event.type].length === 0) {
return true;
}
this.listeners[event.type].forEach(listener => {
listener(event);
});
return true;
}
removeEventListener(type, callback, _options) {
if (!this.listeners[type]) {
return;
}
const index = this.listeners[type].indexOf(callback);
if (index !== -1) {
this.listeners[type].splice(index, 1);
}
}
}

View File

@@ -0,0 +1,429 @@
import { hljs } from '../../lib.js';
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
import { SlashCommandArgument, SlashCommandNamedArgument } from './SlashCommandArgument.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandDebugController } from './SlashCommandDebugController.js';
import { SlashCommandScope } from './SlashCommandScope.js';
/**
* @typedef {{
* _scope:SlashCommandScope,
* _parserFlags:import('./SlashCommandParser.js').ParserFlags,
* _abortController:SlashCommandAbortController,
* _debugController:SlashCommandDebugController,
* _hasUnnamedArgument:boolean,
* [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined,
* }} NamedArguments
*/
/**
* Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments
* @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined}} NamedArgumentsCapture
*/
/**
* @typedef {string|SlashCommandClosure|(string|SlashCommandClosure)[]} UnnamedArguments
*/
export class SlashCommand {
/**
* Creates a SlashCommand from a properties object.
* @param {Object} props
* @param {string} [props.name]
* @param {(namedArguments:NamedArguments|NamedArgumentsCapture, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>} [props.callback]
* @param {string} [props.helpString]
* @param {boolean} [props.splitUnnamedArgument]
* @param {Number} [props.splitUnnamedArgumentCount]
* @param {string[]} [props.aliases]
* @param {string} [props.returns]
* @param {SlashCommandNamedArgument[]} [props.namedArgumentList]
* @param {SlashCommandArgument[]} [props.unnamedArgumentList]
*/
static fromProps(props) {
const instance = Object.assign(new this(), props);
return instance;
}
/**@type {string}*/ name;
/**@type {(namedArguments:{_scope:SlashCommandScope, _abortController:SlashCommandAbortController, [id:string]:string|SlashCommandClosure}, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
/**@type {string}*/ helpString;
/**@type {boolean}*/ splitUnnamedArgument = false;
/**@type {Number}*/ splitUnnamedArgumentCount;
/**@type {string[]}*/ aliases = [];
/**@type {string}*/ returns;
/**@type {SlashCommandNamedArgument[]}*/ namedArgumentList = [];
/**@type {SlashCommandArgument[]}*/ unnamedArgumentList = [];
/**@type {Object.<string, HTMLElement>}*/ helpCache = {};
/**@type {Object.<string, DocumentFragment>}*/ helpDetailsCache = {};
/**@type {boolean}*/ isExtension = false;
/**@type {boolean}*/ isThirdParty = false;
/**@type {string}*/ source;
renderHelpItem(key = null) {
key = key ?? this.name;
if (!this.helpCache[key]) {
const typeIcon = '[/]';
const li = document.createElement('li'); {
li.classList.add('item');
const type = document.createElement('span'); {
type.classList.add('type');
type.classList.add('monospace');
type.textContent = typeIcon;
li.append(type);
}
const specs = document.createElement('span'); {
specs.classList.add('specs');
const name = document.createElement('span'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = '/';
key.split('').forEach(char=>{
const span = document.createElement('span'); {
span.textContent = char;
name.append(span);
}
});
specs.append(name);
}
const body = document.createElement('span'); {
body.classList.add('body');
const args = document.createElement('span'); {
args.classList.add('arguments');
for (const arg of this.namedArgumentList) {
const argItem = document.createElement('span'); {
argItem.classList.add('argument');
argItem.classList.add('namedArgument');
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
const name = document.createElement('span'); {
name.classList.add('argument-name');
name.textContent = arg.name;
argItem.append(name);
}
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e.value;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
args.append(argItem);
}
}
for (const arg of this.unnamedArgumentList) {
const argItem = document.createElement('span'); {
argItem.classList.add('argument');
argItem.classList.add('unnamedArgument');
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e.value;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
args.append(argItem);
}
}
body.append(args);
}
const returns = document.createElement('span'); {
returns.classList.add('returns');
returns.textContent = this.returns ?? 'void';
body.append(returns);
}
specs.append(body);
}
li.append(specs);
}
const stopgap = document.createElement('span'); {
stopgap.classList.add('stopgap');
stopgap.textContent = '';
li.append(stopgap);
}
const help = document.createElement('span'); {
help.classList.add('help');
const content = document.createElement('span'); {
content.classList.add('helpContent');
content.innerHTML = this.helpString;
const text = content.textContent;
content.innerHTML = '';
content.textContent = text;
help.append(content);
}
li.append(help);
}
if (this.aliases.length > 0) {
const aliases = document.createElement('span'); {
aliases.classList.add('aliases');
aliases.append(' (alias: ');
for (const aliasName of this.aliases) {
const alias = document.createElement('span'); {
alias.classList.add('monospace');
alias.textContent = `/${aliasName}`;
aliases.append(alias);
}
}
aliases.append(')');
// li.append(aliases);
}
}
}
this.helpCache[key] = li;
}
return /**@type {HTMLElement}*/(this.helpCache[key].cloneNode(true));
}
renderHelpDetails(key = null) {
key = key ?? this.name;
if (!this.helpDetailsCache[key]) {
const frag = document.createDocumentFragment();
const cmd = this;
const namedArguments = cmd.namedArgumentList ?? [];
const unnamedArguments = cmd.unnamedArgumentList ?? [];
const returnType = cmd.returns ?? 'void';
const helpString = cmd.helpString ?? 'NO DETAILS';
const aliasList = [cmd.name, ...(cmd.aliases ?? [])].filter(it=>it != key);
const specs = document.createElement('div'); {
specs.classList.add('specs');
const head = document.createElement('div'); {
head.classList.add('head');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.title = 'command name';
name.textContent = `/${key}`;
head.append(name);
}
const src = document.createElement('div'); {
src.classList.add('source');
src.classList.add('fa-solid');
if (this.isExtension) {
src.classList.add('isExtension');
src.classList.add('fa-cubes');
if (this.isThirdParty) src.classList.add('isThirdParty');
else src.classList.add('isCore');
} else {
src.classList.add('isCore');
src.classList.add('fa-star-of-life');
}
src.title = [
this.isExtension ? 'Extension' : 'Core',
this.isThirdParty ? 'Third Party' : (this.isExtension ? 'Core' : null),
this.source,
].filter(it=>it).join('\n');
head.append(src);
}
specs.append(head);
}
const body = document.createElement('div'); {
body.classList.add('body');
const args = document.createElement('ul'); {
args.classList.add('arguments');
for (const arg of namedArguments) {
const listItem = document.createElement('li'); {
listItem.classList.add('argumentItem');
const argSpec = document.createElement('div'); {
argSpec.classList.add('argumentSpec');
const argItem = document.createElement('div'); {
argItem.classList.add('argument');
argItem.classList.add('namedArgument');
argItem.title = `${arg.isRequired ? '' : 'optional '}named argument`;
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
const name = document.createElement('span'); {
name.classList.add('argument-name');
name.title = `${argItem.title} - name`;
name.textContent = arg.name;
argItem.append(name);
}
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
enums.title = `${argItem.title} - accepted values`;
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e.value;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
types.title = `${argItem.title} - accepted types`;
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
argSpec.append(argItem);
}
if (arg.defaultValue !== null) {
const argDefault = document.createElement('div'); {
argDefault.classList.add('argument-default');
argDefault.title = 'default value';
argDefault.textContent = arg.defaultValue.toString();
argSpec.append(argDefault);
}
}
listItem.append(argSpec);
}
const desc = document.createElement('div'); {
desc.classList.add('argument-description');
desc.innerHTML = arg.description;
listItem.append(desc);
}
args.append(listItem);
}
}
for (const arg of unnamedArguments) {
const listItem = document.createElement('li'); {
listItem.classList.add('argumentItem');
const argSpec = document.createElement('div'); {
argSpec.classList.add('argumentSpec');
const argItem = document.createElement('div'); {
argItem.classList.add('argument');
argItem.classList.add('unnamedArgument');
argItem.title = `${arg.isRequired ? '' : 'optional '}unnamed argument`;
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
enums.title = `${argItem.title} - accepted values`;
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e.value;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
types.title = `${argItem.title} - accepted types`;
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
argSpec.append(argItem);
}
if (arg.defaultValue !== null) {
const argDefault = document.createElement('div'); {
argDefault.classList.add('argument-default');
argDefault.title = 'default value';
argDefault.textContent = arg.defaultValue.toString();
argSpec.append(argDefault);
}
}
listItem.append(argSpec);
}
const desc = document.createElement('div'); {
desc.classList.add('argument-description');
desc.innerHTML = arg.description;
listItem.append(desc);
}
args.append(listItem);
}
}
body.append(args);
}
const returns = document.createElement('span'); {
returns.classList.add('returns');
returns.title = [null, undefined, 'void'].includes(returnType) ? 'command does not return anything' : 'return value';
returns.textContent = returnType ?? 'void';
body.append(returns);
}
specs.append(body);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.innerHTML = helpString;
for (const code of help.querySelectorAll('pre > code')) {
code.classList.add('language-stscript');
hljs.highlightElement(code);
}
frag.append(help);
}
if (aliasList.length > 0) {
const aliases = document.createElement('span'); {
aliases.classList.add('aliases');
for (const aliasName of aliasList) {
const alias = document.createElement('span'); {
alias.classList.add('alias');
alias.textContent = `/${aliasName}`;
aliases.append(alias);
}
}
frag.append(aliases);
}
}
this.helpDetailsCache[key] = frag;
}
const frag = document.createDocumentFragment();
frag.append(this.helpDetailsCache[key].cloneNode(true));
return frag;
}
}

View File

@@ -0,0 +1,34 @@
import { AbstractEventTarget } from './AbstractEventTarget.js';
export class SlashCommandAbortController extends AbstractEventTarget {
/**@type {SlashCommandAbortSignal}*/ signal;
constructor() {
super();
this.signal = new SlashCommandAbortSignal();
}
abort(reason = 'No reason.', isQuiet = false) {
this.signal.isQuiet = isQuiet;
this.signal.aborted = true;
this.signal.reason = reason;
this.dispatchEvent(new Event('abort'));
}
pause(reason = 'No reason.') {
this.signal.paused = true;
this.signal.reason = reason;
this.dispatchEvent(new Event('pause'));
}
continue(reason = 'No reason.') {
this.signal.paused = false;
this.signal.reason = reason;
this.dispatchEvent(new Event('continue'));
}
}
export class SlashCommandAbortSignal {
/**@type {boolean}*/ isQuiet = false;
/**@type {boolean}*/ paused = false;
/**@type {boolean}*/ aborted = false;
/**@type {string}*/ reason = null;
}

View File

@@ -0,0 +1,133 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
import { SlashCommandScope } from './SlashCommandScope.js';
/**@readonly*/
/**@enum {string}*/
export const ARGUMENT_TYPE = {
'STRING': 'string',
'NUMBER': 'number',
'RANGE': 'range',
'BOOLEAN': 'bool',
'VARIABLE_NAME': 'varname',
'CLOSURE': 'closure',
'SUBCOMMAND': 'subcommand',
'LIST': 'list',
'DICTIONARY': 'dictionary',
};
export class SlashCommandArgument {
/**
* Creates an unnamed argument from a properties object.
* @param {Object} props
* @param {string} props.description description of the argument
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} [props.typeList=[ARGUMENT_TYPE.STRING]] default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
* @param {boolean} [props.isRequired=false] default: false - whether the argument is required (false = optional argument)
* @param {boolean} [props.acceptsMultiple=false] default: false - whether argument accepts multiple values
* @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
* @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
*/
static fromProps(props) {
return new SlashCommandArgument(
props.description,
props.typeList ?? [ARGUMENT_TYPE.STRING],
props.isRequired ?? false,
props.acceptsMultiple ?? false,
props.defaultValue ?? null,
props.enumList ?? [],
props.enumProvider ?? null,
props.forceEnum ?? false,
);
}
/**@type {string}*/ description;
/**@type {ARGUMENT_TYPE[]}*/ typeList = [];
/**@type {boolean}*/ isRequired = false;
/**@type {boolean}*/ acceptsMultiple = false;
/**@type {string|SlashCommandClosure}*/ defaultValue;
/**@type {SlashCommandEnumValue[]}*/ enumList = [];
/**@type {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]}*/ enumProvider = null;
/**@type {boolean}*/ forceEnum = false;
/**
* @param {string} description
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
* @param {string|SlashCommandClosure} defaultValue
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options
*/
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = false) {
this.description = description;
this.typeList = types ? Array.isArray(types) ? types : [types] : [];
this.isRequired = isRequired ?? false;
this.acceptsMultiple = acceptsMultiple ?? false;
this.defaultValue = defaultValue;
this.enumList = (enums ? Array.isArray(enums) ? enums : [enums] : []).map(it=>{
if (it instanceof SlashCommandEnumValue) return it;
return new SlashCommandEnumValue(it);
});
this.enumProvider = enumProvider;
this.forceEnum = forceEnum;
// If no enums were set explictly and the type is one where we know possible enum values, we set them here
if (!this.enumList.length && this.typeList.length === 1 && this.typeList.includes(ARGUMENT_TYPE.BOOLEAN)) this.enumList = commonEnumProviders.boolean()();
}
}
export class SlashCommandNamedArgument extends SlashCommandArgument {
/**
* Creates an unnamed argument from a properties object.
* @param {Object} props
* @param {string} props.name the argument's name
* @param {string} props.description description of the argument
* @param {string[]} [props.aliasList=[]] list of aliases
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} [props.typeList=[ARGUMENT_TYPE.STRING]] default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
* @param {boolean} [props.isRequired=false] default: false - whether the argument is required (false = optional argument)
* @param {boolean} [props.acceptsMultiple=false] default: false - whether argument accepts multiple values
* @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
* @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
*/
static fromProps(props) {
return new SlashCommandNamedArgument(
props.name,
props.description,
props.typeList ?? [ARGUMENT_TYPE.STRING],
props.isRequired ?? false,
props.acceptsMultiple ?? false,
props.defaultValue ?? null,
props.enumList ?? [],
props.aliasList ?? [],
props.enumProvider ?? null,
props.forceEnum ?? false,
);
}
/**@type {string}*/ name;
/**@type {string[]}*/ aliasList = [];
/**
* @param {string} name
* @param {string} description
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
* @param {boolean} [isRequired=false]
* @param {boolean} [acceptsMultiple=false]
* @param {string|SlashCommandClosure} [defaultValue=null]
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [enums=[]]
* @param {string[]} [aliases=[]]
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [enumProvider=null] function that returns auto complete options
* @param {boolean} [forceEnum=false]
*/
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = false) {
super(description, types, isRequired, acceptsMultiple, defaultValue, enums, enumProvider, forceEnum);
this.name = name;
this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : [];
}
}

View File

@@ -0,0 +1,191 @@
import { AutoCompleteNameResult } from '../autocomplete/AutoCompleteNameResult.js';
import { AutoCompleteSecondaryNameResult } from '../autocomplete/AutoCompleteSecondaryNameResult.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandCommandAutoCompleteOption } from './SlashCommandCommandAutoCompleteOption.js';
import { SlashCommandEnumAutoCompleteOption } from './SlashCommandEnumAutoCompleteOption.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
import { SlashCommandNamedArgumentAutoCompleteOption } from './SlashCommandNamedArgumentAutoCompleteOption.js';
import { SlashCommandScope } from './SlashCommandScope.js';
export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
/**@type {SlashCommandExecutor}*/ executor;
/**@type {SlashCommandScope}*/ scope;
/**
* @param {SlashCommandExecutor} executor
* @param {SlashCommandScope} scope
* @param {Object.<string,SlashCommand>} commands
*/
constructor(executor, scope, commands) {
super(
executor.name,
executor.start,
Object
.keys(commands)
.map(key=>new SlashCommandCommandAutoCompleteOption(commands[key], key))
,
false,
()=>`No matching slash commands for "/${this.name}"`,
()=>'No slash commands found!',
);
this.executor = executor;
this.scope = scope;
}
getSecondaryNameAt(text, index, isSelect) {
const namedResult = this.getNamedArgumentAt(text, index, isSelect);
if (!namedResult || namedResult.optionList.length == 0 || !namedResult.isRequired) {
const unnamedResult = this.getUnnamedArgumentAt(text, index, isSelect);
if (!namedResult) return unnamedResult;
if (namedResult && unnamedResult) {
const combinedResult = new AutoCompleteSecondaryNameResult(
namedResult.name,
namedResult.start,
[...namedResult.optionList, ...unnamedResult.optionList],
);
combinedResult.isRequired = namedResult.isRequired || unnamedResult.isRequired;
combinedResult.forceMatch = namedResult.forceMatch && unnamedResult.forceMatch;
return combinedResult;
}
}
return namedResult;
}
getNamedArgumentAt(text, index, isSelect) {
function getSplitRegex() {
try {
return new RegExp('(?<==)');
} catch {
// For browsers that don't support lookbehind
return new RegExp('=(.*)');
}
}
if (!Array.isArray(this.executor.command?.namedArgumentList)) {
return null;
}
const notProvidedNamedArguments = this.executor.command.namedArgumentList.filter(arg=>!this.executor.namedArgumentList.find(it=>it.name == arg.name));
let name;
let value;
let start;
let cmdArg;
let argAssign;
const unamedArgLength = this.executor.endUnnamedArgs - this.executor.startUnnamedArgs;
const namedArgsFollowedBySpace = text[this.executor.endNamedArgs] == ' ';
if (this.executor.startNamedArgs <= index && this.executor.endNamedArgs + (namedArgsFollowedBySpace ? 1 : 0) >= index) {
// cursor is somewhere within the named arguments (including final space)
argAssign = this.executor.namedArgumentList.find(it=>it.start <= index && it.end >= index);
if (argAssign) {
const [argName, ...v] = text.slice(argAssign.start, index).split(getSplitRegex());
name = argName;
value = v.join('');
start = argAssign.start;
cmdArg = this.executor.command.namedArgumentList.find(it=>[it.name, `${it.name}=`].includes(argAssign.name));
if (cmdArg) notProvidedNamedArguments.push(cmdArg);
} else {
name = '';
start = index;
}
} else if (unamedArgLength > 0 && index >= this.executor.startUnnamedArgs && index <= this.executor.endUnnamedArgs) {
// cursor is somewhere within the unnamed arguments
// if index is in first array item and that is a string, treat it as an unfinished named arg
if (typeof this.executor.unnamedArgumentList[0]?.value == 'string') {
if (index <= this.executor.startUnnamedArgs + this.executor.unnamedArgumentList[0].value.length) {
name = this.executor.unnamedArgumentList[0].value.slice(0, index - this.executor.startUnnamedArgs);
start = this.executor.startUnnamedArgs;
} else {
return null;
}
} else {
return null;
}
} else {
return null;
}
if (name.includes('=') && cmdArg) {
// if cursor is already behind "=" check for enums
const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
if (cmdArg && enumList?.length) {
if (isSelect && enumList.find(it=>it.value == value) && argAssign && argAssign.end == index) {
return null;
}
const result = new AutoCompleteSecondaryNameResult(
value,
start + name.length,
enumList.map(it=>SlashCommandEnumAutoCompleteOption.from(this.executor.command, it)),
true,
);
result.isRequired = true;
result.forceMatch = cmdArg.forceEnum;
return result;
}
}
if (notProvidedNamedArguments.length > 0) {
const result = new AutoCompleteSecondaryNameResult(
name,
start,
notProvidedNamedArguments.map(it=>new SlashCommandNamedArgumentAutoCompleteOption(it, this.executor.command)),
false,
);
result.isRequired = notProvidedNamedArguments.find(it=>it.isRequired) != null;
return result;
}
return null;
}
getUnnamedArgumentAt(text, index, isSelect) {
if (!Array.isArray(this.executor.command?.unnamedArgumentList)) {
return null;
}
const lastArgIsBlank = this.executor.unnamedArgumentList.slice(-1)[0]?.value == '';
const notProvidedArguments = this.executor.command.unnamedArgumentList.slice(this.executor.unnamedArgumentList.length - (lastArgIsBlank ? 1 : 0));
let value;
let start;
let cmdArg;
let argAssign;
if (this.executor.startUnnamedArgs <= index && this.executor.endUnnamedArgs + 1 >= index) {
// cursor is somwehere in the unnamed args
const idx = this.executor.unnamedArgumentList.findIndex(it=>it.start <= index && it.end >= index);
if (idx > -1) {
argAssign = this.executor.unnamedArgumentList[idx];
cmdArg = this.executor.command.unnamedArgumentList[idx];
if (cmdArg === undefined && this.executor.command.unnamedArgumentList.slice(-1)[0]?.acceptsMultiple) {
cmdArg = this.executor.command.unnamedArgumentList.slice(-1)[0];
}
const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
if (cmdArg && enumList.length > 0) {
value = argAssign.value.toString().slice(0, index - argAssign.start);
start = argAssign.start;
} else {
return null;
}
} else {
value = '';
start = index;
cmdArg = notProvidedArguments[0];
if (cmdArg === undefined && this.executor.command.unnamedArgumentList.slice(-1)[0]?.acceptsMultiple) {
cmdArg = this.executor.command.unnamedArgumentList.slice(-1)[0];
}
}
} else {
return null;
}
const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
if (cmdArg == null || enumList.length == 0) return null;
const result = new AutoCompleteSecondaryNameResult(
value,
start,
enumList.map(it=>SlashCommandEnumAutoCompleteOption.from(this.executor.command, it)),
false,
);
const isCompleteValue = enumList.find(it=>it.value == value);
const isSelectedValue = isSelect && isCompleteValue;
result.isRequired = cmdArg.isRequired && !isSelectedValue;
result.forceMatch = cmdArg.forceEnum;
return result;
}
}

View File

@@ -0,0 +1,7 @@
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
export class SlashCommandBreak extends SlashCommandExecutor {
get value() {
return this.unnamedArgumentList[0]?.value;
}
}

View File

@@ -0,0 +1,7 @@
export class SlashCommandBreakController {
/**@type {boolean} */ isBreak = false;
break() {
this.isBreak = true;
}
}

View File

@@ -0,0 +1,3 @@
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
export class SlashCommandBreakPoint extends SlashCommandExecutor {}

View File

@@ -0,0 +1,147 @@
import { escapeRegex } from '../utils.js';
import { SlashCommandParser } from './SlashCommandParser.js';
export class SlashCommandBrowser {
/**@type {SlashCommand[]}*/ cmdList;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ search;
/**@type {HTMLElement}*/ details;
/**@type {Object.<string,HTMLElement>}*/ itemMap = {};
/**@type {MutationObserver}*/ mo;
renderInto(parent) {
if (!this.dom) {
const queryRegex = /(?:(?:^|\s+)([^\s"][^\s]*?)(?:\s+|$))|(?:(?:^|\s+)"(.*?)(?:"|$)(?:\s+|$))/;
const root = document.createElement('div'); {
this.dom = root;
const search = document.createElement('div'); {
search.classList.add('search');
const lbl = document.createElement('label'); {
lbl.classList.add('searchLabel');
lbl.textContent = 'Search: ';
const inp = document.createElement('input'); {
this.search = inp;
inp.classList.add('searchInput');
inp.classList.add('text_pole');
inp.type = 'search';
inp.placeholder = 'Search slash commands - use quotes to search "literal" instead of fuzzy';
inp.addEventListener('input', ()=>{
this.details?.remove();
this.details = null;
let query = inp.value.trim();
if (query.slice(-1) === '"' && !/(?:^|\s+)"/.test(query)) {
query = `"${query}`;
}
let fuzzyList = [];
let quotedList = [];
while (query.length > 0) {
const match = queryRegex.exec(query);
if (!match) break;
if (match[1] !== undefined) {
fuzzyList.push(new RegExp(`^(.*?)${match[1].split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i'));
} else if (match[2] !== undefined) {
quotedList.push(match[2]);
}
query = query.slice(match.index + match[0].length);
}
for (const cmd of this.cmdList) {
const targets = [
cmd.name,
...cmd.namedArgumentList.map(it=>it.name),
...cmd.namedArgumentList.map(it=>it.description),
...cmd.namedArgumentList.map(it=>it.enumList.map(e=>e.value)).flat(),
...cmd.namedArgumentList.map(it=>it.typeList).flat(),
...cmd.unnamedArgumentList.map(it=>it.description),
...cmd.unnamedArgumentList.map(it=>it.enumList.map(e=>e.value)).flat(),
...cmd.unnamedArgumentList.map(it=>it.typeList).flat(),
...cmd.aliases,
cmd.helpString,
];
const find = ()=>targets.find(t=>(fuzzyList.find(f=>f.test(t)) ?? quotedList.find(q=>t.includes(q))) !== undefined) !== undefined;
if (fuzzyList.length + quotedList.length === 0 || find()) {
this.itemMap[cmd.name].classList.remove('isFiltered');
} else {
this.itemMap[cmd.name].classList.add('isFiltered');
}
}
});
lbl.append(inp);
}
search.append(lbl);
}
root.append(search);
}
const container = document.createElement('div'); {
container.classList.add('commandContainer');
const list = document.createElement('div'); {
list.classList.add('autoComplete');
this.cmdList = Object
.keys(SlashCommandParser.commands)
.filter(key => SlashCommandParser.commands[key].name === key) // exclude aliases
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(key => SlashCommandParser.commands[key])
;
for (const cmd of this.cmdList) {
const item = cmd.renderHelpItem();
this.itemMap[cmd.name] = item;
let details;
item.addEventListener('click', ()=>{
if (!details) {
details = document.createElement('div'); {
details.classList.add('autoComplete-detailsWrap');
const inner = document.createElement('div'); {
inner.classList.add('autoComplete-details');
inner.append(cmd.renderHelpDetails());
details.append(inner);
}
}
}
if (this.details !== details) {
Array.from(list.querySelectorAll('.selected')).forEach(it=>it.classList.remove('selected'));
item.classList.add('selected');
this.details?.remove();
container.append(details);
this.details = details;
const pRect = list.getBoundingClientRect();
const rect = item.children[0].getBoundingClientRect();
details.style.setProperty('--targetOffset', rect.top - pRect.top);
} else {
item.classList.remove('selected');
details.remove();
this.details = null;
}
});
list.append(item);
}
container.append(list);
}
root.append(container);
}
root.classList.add('slashCommandBrowser');
}
}
parent.append(this.dom);
this.mo = new MutationObserver(muts=>{
if (muts.find(mut=>Array.from(mut.removedNodes).find(it=>it === this.dom || it.contains(this.dom)))) {
this.mo.disconnect();
window.removeEventListener('keydown', boundHandler);
}
});
this.mo.observe(document.querySelector('#chat'), { childList:true, subtree:true });
const boundHandler = this.handleKeyDown.bind(this);
window.addEventListener('keydown', boundHandler);
return this.dom;
}
handleKeyDown(evt) {
if (!evt.shiftKey && !evt.altKey && evt.ctrlKey && evt.key.toLowerCase() === 'f') {
if (!this.dom.closest('body')) return;
if (this.dom.closest('.mes') && !this.dom.closest('.last_mes')) return;
evt.preventDefault();
evt.stopPropagation();
evt.stopImmediatePropagation();
this.search.focus();
}
}
}

View File

@@ -0,0 +1,532 @@
import { substituteParams } from '../../script.js';
import { delay, escapeRegex, uuidv4 } from '../utils.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
import { SlashCommandBreak } from './SlashCommandBreak.js';
import { SlashCommandBreakController } from './SlashCommandBreakController.js';
import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
import { SlashCommandClosureResult } from './SlashCommandClosureResult.js';
import { SlashCommandDebugController } from './SlashCommandDebugController.js';
import { SlashCommandExecutionError } from './SlashCommandExecutionError.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
import { SlashCommandScope } from './SlashCommandScope.js';
export class SlashCommandClosure {
/** @type {SlashCommandScope} */ scope;
/** @type {boolean} */ executeNow = false;
/** @type {SlashCommandNamedArgumentAssignment[]} */ argumentList = [];
/** @type {SlashCommandNamedArgumentAssignment[]} */ providedArgumentList = [];
/** @type {SlashCommandExecutor[]} */ executorList = [];
/** @type {SlashCommandAbortController} */ abortController;
/** @type {SlashCommandBreakController} */ breakController;
/** @type {SlashCommandDebugController} */ debugController;
/** @type {(done:number, total:number)=>void} */ onProgress;
/** @type {string} */ rawText;
/** @type {string} */ fullText;
/** @type {string} */ parserContext;
/** @type {string} */ #source = uuidv4();
get source() { return this.#source; }
set source(value) {
this.#source = value;
for (const executor of this.executorList) {
executor.source = value;
}
}
/**@type {number}*/
get commandCount() {
return this.executorList.map(executor=>executor.commandCount).reduce((sum,cur)=>sum + cur, 0);
}
constructor(parent) {
this.scope = new SlashCommandScope(parent);
}
toString() {
return `[Closure]${this.executeNow ? '()' : ''}`;
}
/**
*
* @param {string} text
* @param {SlashCommandScope} scope
* @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]}
*/
substituteParams(text, scope = null) {
let isList = false;
let listValues = [];
scope = scope ?? this.scope;
const escapeMacro = (it, isAnchored = false)=>{
const regexText = escapeRegex(it.key.replace(/\*/g, '~~~WILDCARD~~~'))
.replaceAll('~~~WILDCARD~~~', '(?:(?:(?!(?:::|}})).)*)')
;
if (isAnchored) {
return `^${regexText}$`;
}
return regexText;
};
const macroList = scope.macroList.toSorted((a,b)=>{
if (a.key.includes('*') && !b.key.includes('*')) return 1;
if (!a.key.includes('*') && b.key.includes('*')) return -1;
if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');
return 0;
});
const macros = macroList.map(it=>escapeMacro(it)).join('|');
const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);
let done = '';
let remaining = text;
while (re.test(remaining)) {
const match = re.exec(remaining);
const before = substituteParams(remaining.slice(0, match.index));
const after = remaining.slice(match.index + match[0].length);
const replacer = match.groups.pipe ? scope.pipe : match.groups.var ? scope.getVariable(match.groups.var, match.groups.index) : macroList.find(it=>it.key == match.groups.macro || new RegExp(escapeMacro(it, true)).test(match.groups.macro))?.value;
if (replacer instanceof SlashCommandClosure) {
replacer.abortController = this.abortController;
replacer.breakController = this.breakController;
replacer.scope.parent = this.scope;
if (this.debugController && !replacer.debugController) {
replacer.debugController = this.debugController;
}
isList = true;
if (match.index > 0) {
listValues.push(before);
}
listValues.push(replacer);
if (match.index + match[0].length + 1 < remaining.length) {
const rest = this.substituteParams(after, scope);
listValues.push(...(Array.isArray(rest) ? rest : [rest]));
}
break;
} else {
done = `${done}${before}${replacer}`;
remaining = after;
}
}
if (!isList) {
text = `${done}${substituteParams(remaining)}`;
}
if (isList) {
if (listValues.length > 1) return listValues;
return listValues[0];
}
return text;
}
getCopy() {
const closure = new SlashCommandClosure();
closure.scope = this.scope.getCopy();
closure.executeNow = this.executeNow;
closure.argumentList = this.argumentList;
closure.providedArgumentList = this.providedArgumentList;
closure.executorList = this.executorList;
closure.abortController = this.abortController;
closure.breakController = this.breakController;
closure.debugController = this.debugController;
closure.rawText = this.rawText;
closure.fullText = this.fullText;
closure.parserContext = this.parserContext;
closure.source = this.source;
closure.onProgress = this.onProgress;
return closure;
}
/**
*
* @returns {Promise<SlashCommandClosureResult>}
*/
async execute() {
// execute a copy of the closure to no taint it and its scope with the effects of its execution
// as this would affect the closure being called a second time (e.g., loop, multiple /run calls)
const closure = this.getCopy();
const gen = closure.executeDirect();
let step;
while (!step?.done) {
step = await gen.next(this.debugController?.testStepping(this) ?? false);
if (!(step.value instanceof SlashCommandClosureResult) && this.debugController) {
this.debugController.isStepping = await this.debugController.awaitBreakPoint(step.value.closure, step.value.executor);
}
}
return step.value;
}
async * executeDirect() {
this.debugController?.down(this);
// closure arguments
for (const arg of this.argumentList) {
let v = arg.value;
if (v instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = v;
closure.scope.parent = this.scope;
closure.breakController = this.breakController;
if (closure.executeNow) {
v = (await closure.execute())?.pipe;
} else {
v = closure;
}
} else {
v = this.substituteParams(v);
}
// unescape value
if (typeof v == 'string') {
v = v
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
}
this.scope.letVariable(arg.name, v);
}
for (const arg of this.providedArgumentList) {
let v = arg.value;
if (v instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = v;
closure.scope.parent = this.scope;
closure.breakController = this.breakController;
if (closure.executeNow) {
v = (await closure.execute())?.pipe;
} else {
v = closure;
}
} else {
v = this.substituteParams(v, this.scope.parent);
}
// unescape value
if (typeof v == 'string') {
v = v
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
}
this.scope.setVariable(arg.name, v);
}
if (this.executorList.length == 0) {
this.scope.pipe = '';
}
const stepper = this.executeStep();
let step;
while (!step?.done && !this.breakController?.isBreak) {
// get executor before execution
step = await stepper.next();
if (step.value instanceof SlashCommandBreakPoint) {
console.log('encountered SlashCommandBreakPoint');
if (this.debugController) {
// resolve args
step = await stepper.next();
// "execute" breakpoint
step = await stepper.next();
// get next executor
step = await stepper.next();
// breakpoint has to yield before arguments are resolved if one of the
// arguments is an immediate closure, otherwise you cannot step into the
// immediate closure
const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
this.debugController.isStepping = yield { closure:this, executor:step.value };
} else {
this.debugController.isStepping = true;
this.debugController.stepStack[this.debugController.stepStack.length - 1] = true;
}
}
} else if (!step.done && this.debugController?.testStepping(this)) {
this.debugController.isSteppingInto = false;
// if stepping, have to yield before arguments are resolved if one of the arguments
// is an immediate closure, otherwise you cannot step into the immediate closure
const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
this.debugController.isStepping = yield { closure:this, executor:step.value };
}
}
// resolve args
step = await stepper.next();
if (step.value instanceof SlashCommandBreak) {
console.log('encountered SlashCommandBreak');
if (this.breakController) {
this.breakController?.break();
break;
}
} else if (!step.done && this.debugController?.testStepping(this)) {
this.debugController.isSteppingInto = false;
this.debugController.isStepping = yield { closure:this, executor:step.value };
}
// execute executor
step = await stepper.next();
}
// if execution has returned a closure result, return that (should only happen on abort)
if (step.value instanceof SlashCommandClosureResult) {
this.debugController?.up();
return step.value;
}
/**@type {SlashCommandClosureResult} */
const result = Object.assign(new SlashCommandClosureResult(), { pipe: this.scope.pipe, isBreak: this.breakController?.isBreak ?? false });
this.debugController?.up();
return result;
}
/**
* Generator that steps through the executor list.
* Every executor is split into three steps:
* - before arguments are resolved
* - after arguments are resolved
* - after execution
*/
async * executeStep() {
let done = 0;
let isFirst = true;
for (const executor of this.executorList) {
this.onProgress?.(done, this.commandCount);
if (this.debugController) {
this.debugController.setExecutor(executor);
this.debugController.namedArguments = undefined;
this.debugController.unnamedArguments = undefined;
}
// yield before doing anything with this executor, the debugger might want to do
// something with it (e.g., breakpoint, immediate closures that need resolving
// or stepping into)
yield executor;
/**@type {import('./SlashCommand.js').NamedArguments} */
// @ts-ignore
let args = {
_scope: this.scope,
_parserFlags: executor.parserFlags,
_abortController: this.abortController,
_debugController: this.debugController,
_hasUnnamedArgument: executor.unnamedArgumentList.length > 0,
};
if (executor instanceof SlashCommandBreakPoint) {
// nothing to do for breakpoints, just raise counter and yield for "before exec"
done++;
yield executor;
isFirst = false;
} else if (executor instanceof SlashCommandBreak) {
// /break need to resolve the unnamed arg and put it into pipe, then yield
// for "before exec"
const value = await this.substituteUnnamedArgument(executor, isFirst, args);
done += this.executorList.length - this.executorList.indexOf(executor);
this.scope.pipe = value ?? this.scope.pipe;
yield executor;
isFirst = false;
} else {
// regular commands do all the argument resolving logic...
await this.substituteNamedArguments(executor, args);
let value = await this.substituteUnnamedArgument(executor, isFirst, args);
let abortResult = await this.testAbortController();
if (abortResult) {
return abortResult;
}
if (this.debugController) {
this.debugController.namedArguments = args;
this.debugController.unnamedArguments = value ?? '';
}
// then yield for "before exec"
yield executor;
// followed by command execution
executor.onProgress = (subDone, subTotal)=>this.onProgress?.(done + subDone, this.commandCount);
const isStepping = this.debugController?.testStepping(this);
if (this.debugController) {
this.debugController.isStepping = false || this.debugController.isSteppingInto;
}
try {
this.scope.pipe = await executor.command.callback(args, value ?? '');
} catch (ex) {
throw new SlashCommandExecutionError(ex, ex.message, executor.name, executor.start, executor.end, this.fullText.slice(executor.start, executor.end), this.fullText);
}
if (this.debugController) {
this.debugController.namedArguments = undefined;
this.debugController.unnamedArguments = undefined;
this.debugController.isStepping = isStepping;
}
this.#lintPipe(executor.command);
done += executor.commandCount;
this.onProgress?.(done, this.commandCount);
abortResult = await this.testAbortController();
if (abortResult) {
return abortResult;
}
}
// finally, yield for "after exec"
yield executor;
isFirst = false;
}
}
async testPaused() {
while (!this.abortController?.signal?.aborted && this.abortController?.signal?.paused) {
await delay(200);
}
}
async testAbortController() {
await this.testPaused();
if (this.abortController?.signal?.aborted) {
const result = new SlashCommandClosureResult();
result.isAborted = true;
result.isQuietlyAborted = this.abortController.signal.isQuiet;
result.abortReason = this.abortController.signal.reason.toString();
return result;
}
}
/**
* @param {SlashCommandExecutor} executor
* @param {import('./SlashCommand.js').NamedArguments} args
*/
async substituteNamedArguments(executor, args) {
/**
* Handles the assignment of named arguments, considering if they accept multiple values
* @param {string} name The name of the argument, as defined for the command execution
* @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]} value The value to be assigned
*/
const assign = (name, value) => {
// If an array is supposed to be assigned, assign it one by one
if (Array.isArray(value)) {
for (const val of value) {
assign(name, val);
}
return;
}
const definition = executor.command.namedArgumentList.find(x => x.name == name);
// Prefer definition name if a valid named args defintion is found
name = definition?.name ?? name;
// Unescape named argument
if (value && typeof value == 'string') {
value = value
.replace(/\\\{/g, '{')
.replace(/\\\}/g, '}');
}
// If the named argument accepts multiple values, we have to make sure to build an array correctly
if (definition?.acceptsMultiple) {
if (args[name] !== undefined) {
// If there already is something for that named arg, make the value is an array and add to it
let currentValue = args[name];
if (!Array.isArray(currentValue)) {
currentValue = [currentValue];
}
currentValue.push(value);
args[name] = currentValue;
} else {
// If there is nothing in there, we create an array with that singular value
args[name] = [value];
}
} else {
args[name] !== undefined && console.debug(`Named argument assigned multiple times: ${name}`);
args[name] = value;
}
};
// substitute named arguments
for (const arg of executor.namedArgumentList) {
if (arg.value instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = arg.value;
closure.scope.parent = this.scope;
closure.breakController = this.breakController;
if (this.debugController && !closure.debugController) {
closure.debugController = this.debugController;
}
if (closure.executeNow) {
assign(arg.name, (await closure.execute())?.pipe);
} else {
assign(arg.name, closure);
}
} else {
assign(arg.name, this.substituteParams(arg.value));
}
}
}
/**
* @param {SlashCommandExecutor} executor
* @param {boolean} isFirst
* @param {import('./SlashCommand.js').NamedArguments} args
* @returns {Promise<string|SlashCommandClosure|(string|SlashCommandClosure)[]>}
*/
async substituteUnnamedArgument(executor, isFirst, args) {
let value;
// substitute unnamed argument
if (executor.unnamedArgumentList.length == 0) {
if (!isFirst && executor.injectPipe) {
value = this.scope.pipe;
args._hasUnnamedArgument = this.scope.pipe !== null && this.scope.pipe !== undefined;
}
} else {
value = [];
for (let i = 0; i < executor.unnamedArgumentList.length; i++) {
/** @type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */
let v = executor.unnamedArgumentList[i].value;
if (v instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = v;
closure.scope.parent = this.scope;
closure.breakController = this.breakController;
if (this.debugController && !closure.debugController) {
closure.debugController = this.debugController;
}
if (closure.executeNow) {
v = (await closure.execute())?.pipe;
} else {
v = closure;
}
} else {
v = this.substituteParams(v);
}
value[i] = v;
}
if (!executor.command.splitUnnamedArgument) {
if (value.length == 1) {
value = value[0];
} else if (!value.find(it=>it instanceof SlashCommandClosure)) {
value = value.join('');
}
}
}
// unescape unnamed argument
if (typeof value == 'string') {
value = value
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
} else if (Array.isArray(value)) {
value = value.map(v=>{
if (typeof v == 'string') {
return v
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}');
}
return v;
});
}
value ??= '';
// Make sure that if unnamed args are split, it should always return an array
if (executor.command.splitUnnamedArgument && !Array.isArray(value)) {
value = [value];
}
return value;
}
/**
* Auto-fixes the pipe if it is not a valid result for STscript.
* @param {SlashCommand} command Command being executed
*/
#lintPipe(command) {
if (this.scope.pipe === undefined || this.scope.pipe === null) {
console.warn(`/${command.name} returned undefined or null. Auto-fixing to empty string.`);
this.scope.pipe = '';
} else if (!(typeof this.scope.pipe == 'string' || this.scope.pipe instanceof SlashCommandClosure)) {
console.warn(`/${command.name} returned illegal type (${typeof this.scope.pipe} - ${this.scope.pipe.constructor?.name ?? ''}). Auto-fixing to stringified JSON.`);
this.scope.pipe = JSON.stringify(this.scope.pipe) ?? '';
}
}
}

View File

@@ -0,0 +1,10 @@
export class SlashCommandClosureResult {
/**@type {boolean}*/ interrupt = false;
/**@type {string}*/ pipe;
/**@type {boolean}*/ isBreak = false;
/**@type {boolean}*/ isAborted = false;
/**@type {boolean}*/ isQuietlyAborted = false;
/**@type {string}*/ abortReason;
/**@type {boolean}*/ isError = false;
/**@type {string}*/ errorMessage;
}

View File

@@ -0,0 +1,37 @@
import { SlashCommand } from './SlashCommand.js';
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
export class SlashCommandCommandAutoCompleteOption extends AutoCompleteOption {
/**@type {SlashCommand}*/ command;
get value() {
return this.command;
}
/**
* @param {SlashCommand} command
* @param {string} name
*/
constructor(command, name) {
super(name);
this.command = command;
}
renderItem() {
let li;
li = this.command.renderHelpItem(this.name);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'command');
return li;
}
renderDetails() {
return this.command.renderHelpDetails(this.name);
}
}

View File

@@ -0,0 +1,314 @@
import { chat_metadata, characters, substituteParams, chat, extension_prompt_roles, extension_prompt_types, name2, neutralCharacterName } from '../../script.js';
import { extension_settings } from '../extensions.js';
import { getGroupMembers, groups } from '../group-chats.js';
import { power_user } from '../power-user.js';
import { searchCharByName, getTagsList, tags, tag_map } from '../tags.js';
import { onlyUniqueJson, sortIgnoreCaseAndAccents } from '../utils.js';
import { world_names } from '../world-info.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';
/** @typedef {import('./SlashCommandExecutor.js').SlashCommandExecutor} SlashCommandExecutor */
/** @typedef {import('./SlashCommandScope.js').SlashCommandScope} SlashCommandScope */
/**
* A collection of regularly used enum icons
*/
export const enumIcons = {
default: '◊',
// Variables
variable: '𝑥',
localVariable: 'L',
globalVariable: 'G',
scopeVariable: 'S',
// Common types
character: '👤',
group: '🧑‍🤝‍🧑',
persona: '🧙‍♂️',
qr: 'QR',
closure: '𝑓',
macro: '{{',
tag: '🏷️',
world: '🌐',
preset: '⚙️',
file: '📄',
message: '💬',
reasoning: '💡',
voice: '🎤',
server: '🖥️',
popup: '🗔',
image: '🖼️',
true: '✔️',
false: '❌',
null: '🚫',
undefined: '❓',
// Value types
boolean: '🔲',
string: '📝',
number: '1⃣',
array: '[]',
enum: '📚',
dictionary: '{}',
// Roles
system: '⚙️',
user: '👤',
assistant: '🤖',
// WI Icons
constant: '🔵',
normal: '🟢',
disabled: '❌',
vectorized: '🔗',
/**
* Returns the appropriate state icon based on a boolean
*
* @param {boolean} state - The state to determine the icon for
* @returns {string} The corresponding state icon
*/
getStateIcon: (state) => {
return state ? enumIcons.true : enumIcons.false;
},
/**
* Returns the appropriate WI icon based on the entry
*
* @param {Object} entry - WI entry
* @returns {string} The corresponding WI icon
*/
getWiStatusIcon: (entry) => {
if (entry.constant) return enumIcons.constant;
if (entry.disable) return enumIcons.disabled;
if (entry.vectorized) return enumIcons.vectorized;
return enumIcons.normal;
},
/**
* Returns the appropriate icon based on the role
*
* @param {extension_prompt_roles} role - The role to get the icon for
* @returns {string} The corresponding icon
*/
getRoleIcon: (role) => {
switch (role) {
case extension_prompt_roles.SYSTEM: return enumIcons.system;
case extension_prompt_roles.USER: return enumIcons.user;
case extension_prompt_roles.ASSISTANT: return enumIcons.assistant;
default: return enumIcons.default;
}
},
/**
* A function to get the data type icon
*
* @param {string} type - The type of the data
* @returns {string} The corresponding data type icon
*/
getDataTypeIcon: (type) => {
// Remove possible nullable types definition to match type icon
type = type.replace(/\?$/, '');
return enumIcons[type] ?? enumIcons.default;
},
};
/**
* A collection of common enum providers
*
* Can be used on `SlashCommandNamedArgument` and `SlashCommandArgument` and their `enumProvider` property.
*/
export const commonEnumProviders = {
/**
* Enum values for booleans. Either using true/false or on/off
* Optionally supports "toggle".
*
* @param {('onOff'|'onOffToggle'|'trueFalse')?} [mode='trueFalse'] - The mode to use. Default is 'trueFalse'.
* @returns {() => SlashCommandEnumValue[]}
*/
boolean: (mode = 'trueFalse') => () => {
switch (mode) {
case 'onOff': return [new SlashCommandEnumValue('on', null, 'macro', enumIcons.true), new SlashCommandEnumValue('off', null, 'macro', enumIcons.false)];
case 'onOffToggle': return [new SlashCommandEnumValue('on', null, 'macro', enumIcons.true), new SlashCommandEnumValue('off', null, 'macro', enumIcons.false), new SlashCommandEnumValue('toggle', null, 'macro', enumIcons.boolean)];
case 'trueFalse': return [new SlashCommandEnumValue('true', null, 'macro', enumIcons.true), new SlashCommandEnumValue('false', null, 'macro', enumIcons.false)];
default: throw new Error(`Invalid boolean enum provider mode: ${mode}`);
}
},
/**
* All possible variable names
*
* Can be filtered by `type` to only show global or local variables
*
* @param {...('global'|'local'|'scope'|'all')} type - The type of variables to include in the array. Can be 'all', 'global', or 'local'.
* @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
*/
variables: (...type) => (_, scope) => {
const types = type.flat();
const isAll = types.includes('all');
return [
...isAll || types.includes('scope') ? scope.allVariableNames.map(name => new SlashCommandEnumValue(name, null, enumTypes.variable, enumIcons.scopeVariable)) : [],
...isAll || types.includes('local') ? Object.keys(chat_metadata.variables ?? []).map(name => new SlashCommandEnumValue(name, null, enumTypes.name, enumIcons.localVariable)) : [],
...isAll || types.includes('global') ? Object.keys(extension_settings.variables.global ?? []).map(name => new SlashCommandEnumValue(name, null, enumTypes.macro, enumIcons.globalVariable)) : [],
].filter((item, idx, list)=>idx == list.findIndex(it=>it.value == item.value));
},
/**
* Enum values for numbers and variable names
*
* Includes all variable names and the ability to specify any number
*
* @param {SlashCommandExecutor} executor - The executor of the slash command
* @param {SlashCommandScope} scope - The scope of the slash command
* @returns {SlashCommandEnumValue[]} The enum values
*/
numbersAndVariables: (executor, scope) => [
...commonEnumProviders.variables('all')(executor, scope),
new SlashCommandEnumValue(
'any variable name',
null,
enumTypes.variable,
enumIcons.variable,
(input) => /^\w*$/.test(input),
(input) => input,
),
new SlashCommandEnumValue(
'any number',
null,
enumTypes.number,
enumIcons.number,
(input) => input == '' || !Number.isNaN(Number(input)),
(input) => input,
),
],
/**
* All possible char entities, like characters and groups. Can be filtered down to just one type.
*
* @param {('all' | 'character' | 'group')?} [mode='all'] - Which type to return
* @returns {() => SlashCommandEnumValue[]}
*/
characters: (mode = 'all') => () => {
return [
...['all', 'character'].includes(mode) ? characters.map(char => new SlashCommandEnumValue(char.name, null, enumTypes.name, enumIcons.character)) : [],
...['all', 'group'].includes(mode) ? groups.map(group => new SlashCommandEnumValue(group.name, null, enumTypes.qr, enumIcons.group)) : [],
...(name2 === neutralCharacterName) ? [new SlashCommandEnumValue(neutralCharacterName, null, enumTypes.name, '🥸')] : [],
];
},
/**
* All group members of the given group, or default the current active one
*
* @param {string?} groupId - The id of the group - pass in `undefined` to use the current active group
* @returns {() =>SlashCommandEnumValue[]}
*/
groupMembers: (groupId = undefined) => () => getGroupMembers(groupId).map((character, index) => new SlashCommandEnumValue(String(index), character.name, enumTypes.enum, enumIcons.character)),
/**
* All possible personas
*
* @returns {SlashCommandEnumValue[]}
*/
personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
/**
* All possible tags, or only those that have been assigned
*
* @param {('all' | 'assigned')} [mode='all'] - Which types of tags to show
* @returns {() => SlashCommandEnumValue[]}
*/
tags: (mode = 'all') => () => {
let assignedTags = mode === 'assigned' ? new Set(Object.values(tag_map).flat()) : new Set();
return tags.filter(tag => mode === 'all' || (mode === 'assigned' && assignedTags.has(tag.id)))
.map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
},
/**
* All possible tags for a given char/group entity
*
* @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
* @returns {() => SlashCommandEnumValue[]}
*/
tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor) => {
// Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.
const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;
if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
const key = searchCharByName(substituteParams(charName), { suppressLogging: true });
const assigned = key ? getTagsList(key) : [];
return tags.filter(it => mode === 'all' || mode === 'existing' && assigned.includes(it) || mode === 'not-existing' && !assigned.includes(it))
.map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
},
/**
* All messages in the current chat, returning the message id
*
* Optionally supports variable names, and/or a placeholder for the last/new message id
*
* @param {object} [options={}] - Optional arguments
* @param {boolean} [options.allowIdAfter=false] - Whether to add an enum option for the new message id after the last message
* @param {boolean} [options.allowVars=false] - Whether to add enum option for variable names
* @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
*/
messages: ({ allowIdAfter = false, allowVars = false } = {}) => (executor, scope) => {
const nameFilter = executor.namedArgumentList.find(it => it.name == 'name')?.value || '';
return [
...chat.map((message, index) => new SlashCommandEnumValue(String(index), `${message.name}: ${message.mes}`, enumTypes.number, message.is_user ? enumIcons.user : message.is_system ? enumIcons.system : enumIcons.assistant)).filter(value => !nameFilter || value.description.startsWith(`${nameFilter}:`)),
...allowIdAfter ? [new SlashCommandEnumValue(String(chat.length), '>> After Last Message >>', enumTypes.enum, '')] : [],
...allowVars ? commonEnumProviders.variables('all')(executor, scope) : [],
];
},
/**
* All names used in the current chat.
*
* @returns {SlashCommandEnumValue[]}
*/
messageNames: () => chat
.map(message => ({
name: message.name,
icon: message.is_user ? enumIcons.user : enumIcons.assistant,
}))
.filter(onlyUniqueJson)
.sort((a, b) => sortIgnoreCaseAndAccents(a.name, b.name))
.map(name => new SlashCommandEnumValue(name.name, null, null, name.icon)),
/**
* All existing worlds / lorebooks
*
* @returns {SlashCommandEnumValue[]}
*/
worlds: () => world_names.map(worldName => new SlashCommandEnumValue(worldName, null, enumTypes.name, enumIcons.world)),
/**
* All existing injects for the current chat
*
* @returns {SlashCommandEnumValue[]}
*/
injects: () => {
if (!chat_metadata.script_injects || !Object.keys(chat_metadata.script_injects).length) return [];
return Object.entries(chat_metadata.script_injects)
.map(([id, inject]) => {
const positionName = (Object.entries(extension_prompt_types)).find(([_, value]) => value === inject.position)?.[0] ?? 'unknown';
return new SlashCommandEnumValue(id, `${enumIcons.getRoleIcon(inject.role ?? extension_prompt_roles.SYSTEM)}[Inject](${positionName}, depth: ${inject.depth}, scan: ${inject.scan ?? false}) ${inject.value}`,
enumTypes.enum, '💉');
});
},
/**
* Gets somewhat recognizable STscript types.
*
* @returns {SlashCommandEnumValue[]}
*/
types: () => [
new SlashCommandEnumValue('string', null, enumTypes.type, enumIcons.string),
new SlashCommandEnumValue('number', null, enumTypes.type, enumIcons.number),
new SlashCommandEnumValue('boolean', null, enumTypes.type, enumIcons.boolean),
new SlashCommandEnumValue('array', null, enumTypes.type, enumIcons.array),
new SlashCommandEnumValue('object', null, enumTypes.type, enumIcons.dictionary),
new SlashCommandEnumValue('null', null, enumTypes.type, enumIcons.null),
new SlashCommandEnumValue('undefined', null, enumTypes.type, enumIcons.undefined),
],
};

View File

@@ -0,0 +1,83 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
export class SlashCommandDebugController {
/** @type {SlashCommandClosure[]} */ stack = [];
/** @type {SlashCommandExecutor[]} */ cmdStack = [];
/** @type {boolean[]} */ stepStack = [];
/** @type {boolean} */ isStepping = false;
/** @type {boolean} */ isSteppingInto = false;
/** @type {boolean} */ isSteppingOut = false;
/** @type {object} */ namedArguments;
/** @type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */ unnamedArguments;
/** @type {Promise<boolean>} */ continuePromise;
/** @type {(boolean)=>void} */ continueResolver;
/** @type {(closure:SlashCommandClosure, executor:SlashCommandExecutor)=>Promise<boolean>} */ onBreakPoint;
testStepping(closure) {
return this.stepStack[this.stack.indexOf(closure)];
}
down(closure) {
this.stack.push(closure);
if (this.stepStack.length < this.stack.length) {
this.stepStack.push(this.isSteppingInto);
}
}
up() {
this.stack.pop();
while (this.cmdStack.length > this.stack.length) this.cmdStack.pop();
this.stepStack.pop();
}
setExecutor(executor) {
this.cmdStack[this.stack.length - 1] = executor;
}
resume() {
this.continueResolver?.(false);
this.continuePromise = null;
this.stepStack.forEach((_,idx)=>this.stepStack[idx] = false);
}
step() {
this.stepStack.forEach((_,idx)=>this.stepStack[idx] = true);
this.continueResolver?.(true);
this.continuePromise = null;
}
stepInto() {
this.isSteppingInto = true;
this.stepStack.forEach((_,idx)=>this.stepStack[idx] = true);
this.continueResolver?.(true);
this.continuePromise = null;
}
stepOut() {
this.isSteppingOut = true;
this.stepStack[this.stepStack.length - 1] = false;
this.continueResolver?.(false);
this.continuePromise = null;
}
async awaitContinue() {
this.continuePromise ??= new Promise(resolve=>{
this.continueResolver = resolve;
});
this.isStepping = await this.continuePromise;
return this.isStepping;
}
async awaitBreakPoint(closure, executor) {
this.isStepping = await this.onBreakPoint(closure, executor);
return this.isStepping;
}
}

View File

@@ -0,0 +1,45 @@
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption {
/**
* @param {SlashCommand} cmd
* @param {SlashCommandEnumValue} enumValue
* @returns {SlashCommandEnumAutoCompleteOption}
*/
static from(cmd, enumValue) {
const mapped = this.valueToOptionMap.find(it=>enumValue instanceof it.value)?.option ?? this;
return new mapped(cmd, enumValue);
}
/**@type {{value:(typeof SlashCommandEnumValue), option:(typeof SlashCommandEnumAutoCompleteOption)}[]} */
static valueToOptionMap = [];
/**@type {SlashCommand}*/ cmd;
/**@type {SlashCommandEnumValue}*/ enumValue;
/**
* @param {SlashCommand} cmd
* @param {SlashCommandEnumValue} enumValue
*/
constructor(cmd, enumValue) {
super(enumValue.value, enumValue.typeIcon, enumValue.type, enumValue.matchProvider, enumValue.valueProvider, enumValue.makeSelectable);
this.cmd = cmd;
this.enumValue = enumValue;
}
renderItem() {
let li;
li = this.makeItem(this.name, this.typeIcon, true, [], [], null, this.enumValue.description);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', this.type);
return li;
}
renderDetails() {
return this.cmd.renderHelpDetails();
}
}

View File

@@ -0,0 +1,75 @@
/**
* @typedef {'enum' | 'command' | 'namedArgument' | 'variable' | 'qr' | 'macro' | 'number' | 'name'} EnumType
*/
/**
* Collection of the enum types that can be used with `SlashCommandEnumValue`
*
* Contains documentation on which color this will result to
*/
export const enumTypes = {
/** 'enum' - [string] - light orange @type {EnumType} */
enum: 'enum',
/** 'command' - [cmd] - light yellow @type {EnumType} */
command: 'command',
/** 'namedArgument' - [argName] - sky blue @type {EnumType} */
namedArgument: 'namedArgument',
/** 'variable' - [punctuationL1] - pink @type {EnumType} */
variable: 'variable',
/** 'qr' - [variable] - light blue @type {EnumType} */
qr: 'qr',
/** 'macro' - [variableLanguage] - blue @type {EnumType} */
macro: 'macro',
/** 'number' - [number] - light green @type {EnumType} */
number: 'number',
/** 'name' - [type] - forest green @type {EnumType} */
name: 'name',
/**
* Gets the value of the enum type based on the provided index
*
* Can be used to get differing colors or even random colors, by providing the index of a unique set
*
* @param {number?} index - The index used to retrieve the enum type
* @return {EnumType} The enum type corresponding to the index
*/
getBasedOnIndex(index) {
const keys = Object.keys(this);
return this[keys[(index ?? 0) % keys.length]];
},
};
export class SlashCommandEnumValue {
/**@type {string}*/ value;
/**@type {string}*/ description;
/**@type {EnumType}*/ type = 'enum';
/**@type {string}*/ typeIcon = '◊';
/**@type {(input:string)=>boolean}*/ matchProvider;
/**@type {(input:string)=>string}*/ valueProvider;
/**@type {boolean}*/ makeSelectable = false;
/**
* A constructor for creating a SlashCommandEnumValue instance.
*
* @param {string} value - The value
* @param {string?} description - Optional description, displayed in a second line
* @param {EnumType?} type - type of the enum (defining its color)
* @param {string?} typeIcon - The icon to display (Can be pulled from `enumIcons` for common ones)
* @param {(input:string)=>boolean?} matchProvider - A custom function to match autocomplete input instead of startsWith/includes/fuzzy. Should only be used for generic options like "any number" or "any string". "input" is the part of the text that is getting auto completed.
* @param {(input:string)=>string?} valueProvider - A function returning a value to be used in autocomplete instead of the enum value. "input" is the part of the text that is getting auto completed. By default, values with a valueProvider will not be selectable in the autocomplete (with tab/enter).
* @param {boolean?} makeSelectable - Set to true to make the value selectable (through tab/enter) even though a valueProvider exists.
*/
constructor(value, description = null, type = 'enum', typeIcon = '◊', matchProvider = null, valueProvider = null, makeSelectable = false) {
this.value = value;
this.description = description;
this.type = type ?? 'enum';
this.typeIcon = typeIcon;
this.matchProvider = matchProvider;
this.valueProvider = valueProvider;
this.makeSelectable = makeSelectable;
}
toString() {
return this.value;
}
}

View File

@@ -0,0 +1,60 @@
export class SlashCommandExecutionError extends Error {
/**@type {string} */ commandName;
/**@type {number} */ start;
/**@type {number} */ end;
/**@type {string} */ commandText;
/**@type {string} */ text;
get index() { return this.start; }
get line() {
return this.text.slice(0, this.index).replace(/[^\n]/g, '').length;
}
get column() {
return this.text.slice(0, this.index).split('\n').pop().length;
}
get hint() {
let lineOffset = this.line.toString().length;
let lineStart = this.index;
let start = this.index;
let end = this.index;
let offset = 0;
let lineCount = 0;
while (offset < 10000 && lineCount < 3 && start >= 0) {
if (this.text[start] == '\n') lineCount++;
if (lineCount == 0) lineStart--;
offset++;
start--;
}
if (this.text[start + 1] == '\n') start++;
offset = 0;
while (offset < 10000 && this.text[end] != '\n') {
offset++;
end++;
}
let hint = [];
let lines = this.text.slice(start + 1, end - 1).split('\n');
let lineNum = this.line - lines.length + 1;
let tabOffset = 0;
for (const line of lines) {
const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
lineNum++;
const untabbedLine = line.replace(/\t/g, ' '.repeat(4));
tabOffset = untabbedLine.length - line.length;
hint.push(`${num}: ${untabbedLine}`);
}
hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1 + tabOffset)}^^^^^`);
return hint.join('\n');
}
constructor(cause, message, commandName, start, end, commandText, fullText) {
super(message, { cause });
this.commandName = commandName;
this.start = start;
this.end = end;
this.commandText = commandText;
this.text = fullText;
}
}

View File

@@ -0,0 +1,52 @@
import { uuidv4 } from '../utils.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
export class SlashCommandExecutor {
/**@type {Boolean}*/ injectPipe = true;
/**@type {Number}*/ start;
/**@type {Number}*/ end;
/**@type {Number}*/ startNamedArgs;
/**@type {Number}*/ endNamedArgs;
/**@type {Number}*/ startUnnamedArgs;
/**@type {Number}*/ endUnnamedArgs;
/**@type {String}*/ name = '';
/**@type {String}*/ #source = uuidv4();
get source() { return this.#source; }
set source(value) {
this.#source = value;
for (const arg of this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) {
arg.value.source = value;
}
for (const arg of this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) {
arg.value.source = value;
}
}
/** @type {SlashCommand} */ command;
/** @type {SlashCommandNamedArgumentAssignment[]} */ namedArgumentList = [];
/** @type {SlashCommandUnnamedArgumentAssignment[]} */ unnamedArgumentList = [];
/** @type {import('./SlashCommandParser.js').ParserFlags} */ parserFlags;
get commandCount() {
return 1
+ this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>/**@type {SlashCommandClosure}*/(it.value).commandCount).reduce((cur, sum)=>cur + sum, 0)
+ this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>/**@type {SlashCommandClosure}*/(it.value).commandCount).reduce((cur, sum)=>cur + sum, 0)
;
}
set onProgress(value) {
const closures = /**@type {SlashCommandClosure[]}*/([
...this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>it.value),
...this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>it.value),
]);
for (const closure of closures) {
closure.onProgress = value;
}
}
constructor(start) {
this.start = start;
}
}

View File

@@ -0,0 +1,12 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
export class SlashCommandNamedArgumentAssignment {
/** @type {number} */ start;
/** @type {number} */ end;
/** @type {string} */ name;
/** @type {string|SlashCommandClosure} */ value;
constructor() {
}
}

View File

@@ -0,0 +1,31 @@
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandNamedArgument } from './SlashCommandArgument.js';
export class SlashCommandNamedArgumentAutoCompleteOption extends AutoCompleteOption {
/** @type {SlashCommandNamedArgument} */ arg;
/** @type {SlashCommand} */ cmd;
/**
* @param {SlashCommandNamedArgument} arg
*/
constructor(arg, cmd) {
super(`${arg.name}=`);
this.arg = arg;
this.cmd = cmd;
}
renderItem() {
let li;
li = this.makeItem(this.name, '⌗', true, [], [], null, `${this.arg.isRequired ? '' : '(optional) '}${this.arg.description ?? ''}`);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'namedArgument');
return li;
}
renderDetails() {
return this.cmd.renderHelpDetails();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
export class SlashCommandParserError extends Error {
/**@type {String}*/ text;
/**@type {Number}*/ index;
get line() {
return this.text.slice(0, this.index).replace(/[^\n]/g, '').length;
}
get column() {
return this.text.slice(0, this.index).split('\n').pop().length;
}
get hint() {
let lineOffset = this.line.toString().length;
let lineStart = this.index;
let start = this.index;
let end = this.index;
let offset = 0;
let lineCount = 0;
while (offset < 10000 && lineCount < 3 && start >= 0) {
if (this.text[start] == '\n') lineCount++;
if (lineCount == 0) lineStart--;
offset++;
start--;
}
if (this.text[start + 1] == '\n') start++;
offset = 0;
while (offset < 10000 && this.text[end] != '\n') {
offset++;
end++;
}
let hint = [];
let lines = this.text.slice(start + 1, end - 1).split('\n');
let lineNum = this.line - lines.length + 1;
let tabOffset = 0;
for (const line of lines) {
const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
lineNum++;
const untabbedLine = line.replace(/\t/g, ' '.repeat(4));
tabOffset = untabbedLine.length - line.length;
hint.push(`${num}: ${untabbedLine}`);
}
hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1 + tabOffset)}^^^^^`);
return hint.join('\n');
}
constructor(message, text, index) {
super(message);
this.text = text;
this.index = index;
}
}

View File

@@ -0,0 +1,40 @@
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
export class SlashCommandQuickReplyAutoCompleteOption extends AutoCompleteOption {
/**
* @param {string} name
*/
constructor(name) {
super(name);
}
renderItem() {
let li;
li = this.makeItem(this.name, 'QR', true);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'qr');
return li;
}
renderDetails() {
const frag = document.createDocumentFragment();
const specs = document.createElement('div'); {
specs.classList.add('specs');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = this.name;
specs.append(name);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.textContent = 'Quick Reply';
frag.append(help);
}
return frag;
}
}

View File

@@ -0,0 +1,81 @@
import { DOMPurify, showdown } from '../../lib.js';
import { sendSystemMessage, system_message_types } from '../../script.js';
import { callGenericPopup, POPUP_TYPE } from '../popup.js';
import { escapeHtml } from '../utils.js';
import { enumIcons } from './SlashCommandCommonEnumsProvider.js';
import { enumTypes, SlashCommandEnumValue } from './SlashCommandEnumValue.js';
/** @typedef {'pipe'|'object'|'chat-html'|'chat-text'|'popup-html'|'popup-text'|'toast-html'|'toast-text'|'console'|'none'} SlashCommandReturnType */
export const slashCommandReturnHelper = {
// Without this, VSCode formatter fucks up JS docs. Don't ask me why.
_: false,
/**
* Gets/creates the enum list of types of return relevant for a slash command
*
* @param {object} [options={}] Options
* @param {boolean} [options.allowPipe=true] Allow option to pipe the return value
* @param {boolean} [options.allowObject=false] Allow option to return the value as an object
* @param {boolean} [options.allowChat=false] Allow option to return the value as a chat message
* @param {boolean} [options.allowPopup=false] Allow option to return the value as a popup
* @param {boolean}[options.allowTextVersion=true] Used in combination with chat/popup/toast, some of them do not make sense for text versions, e.g.if you are building a HTML string anyway
* @returns {SlashCommandEnumValue[]} The enum list
*/
enumList: ({ allowPipe = true, allowObject = false, allowChat = false, allowPopup = false, allowTextVersion = true } = {}) => [
allowPipe && new SlashCommandEnumValue('pipe', 'Return to the pipe for the next command', enumTypes.name, '|'),
allowObject && new SlashCommandEnumValue('object', 'Return as an object (or array) to the pipe for the next command', enumTypes.variable, enumIcons.dictionary),
allowChat && new SlashCommandEnumValue('chat-html', 'Sending a chat message with the return value - Can display HTML', enumTypes.command, enumIcons.message),
allowChat && allowTextVersion && new SlashCommandEnumValue('chat-text', 'Sending a chat message with the return value - Will only display as text', enumTypes.qr, enumIcons.message),
allowPopup && new SlashCommandEnumValue('popup-html', 'Showing as a popup with the return value - Can display HTML', enumTypes.command, enumIcons.popup),
allowPopup && allowTextVersion && new SlashCommandEnumValue('popup-text', 'Showing as a popup with the return value - Will only display as text', enumTypes.qr, enumIcons.popup),
new SlashCommandEnumValue('toast-html', 'Show the return value as a toast notification - Can display HTML', enumTypes.command, ''),
allowTextVersion && new SlashCommandEnumValue('toast-text', 'Show the return value as a toast notification - Will only display as text', enumTypes.qr, ''),
new SlashCommandEnumValue('console', 'Log the return value (object, if it can be one) to the console', enumTypes.enum, '>'),
new SlashCommandEnumValue('none', 'No return value'),
].filter(x => !!x),
/**
* Handles the return value based on the specified type
*
* @param {SlashCommandReturnType} type The type of return
* @param {object|number|string} value The value to return
* @param {object} [options={}] Options
* @param {(o: object) => string} [options.objectToStringFunc=null] Function to convert the object to a string, if object was provided and 'object' was not the chosen return type
* @param {(o: object) => string} [options.objectToHtmlFunc=null] Analog to 'objectToStringFunc', which will be used here if not provided - but can do a different string layout if HTML is requested
* @returns {Promise<*>} The processed return value
*/
async doReturn(type, value, { objectToStringFunc = o => o?.toString(), objectToHtmlFunc = null } = {}) {
const shouldHtml = type.endsWith('html');
const actualConverterFunc = shouldHtml && objectToHtmlFunc ? objectToHtmlFunc : objectToStringFunc;
const stringValue = typeof value !== 'string' ? actualConverterFunc(value) : value;
switch (type) {
case 'popup-html':
case 'popup-text':
case 'chat-text':
case 'chat-html':
case 'toast-text':
case 'toast-html': {
const htmlOrNotHtml = shouldHtml ? DOMPurify.sanitize((new showdown.Converter()).makeHtml(stringValue)) : escapeHtml(stringValue);
if (type.startsWith('popup')) await callGenericPopup(htmlOrNotHtml, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true, wide: true });
if (type.startsWith('chat')) sendSystemMessage(system_message_types.GENERIC, htmlOrNotHtml);
if (type.startsWith('toast')) toastr.info(htmlOrNotHtml, null, { escapeHtml: !shouldHtml });
return '';
}
case 'pipe':
return stringValue ?? '';
case 'object':
return JSON.stringify(value);
case 'console':
console.info(value);
return '';
case 'none':
return '';
default:
throw new Error(`Unknown return type: ${type}`);
}
},
};

View File

@@ -0,0 +1,117 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { convertValueType } from '../utils.js';
export class SlashCommandScope {
/** @type {string[]} */ variableNames = [];
get allVariableNames() {
const names = [...this.variableNames, ...(this.parent?.allVariableNames ?? [])];
return names.filter((it,idx)=>idx == names.indexOf(it));
}
// @ts-ignore
/** @type {object.<string, string|SlashCommandClosure>} */ variables = {};
// @ts-ignore
/** @type {object.<string, string|SlashCommandClosure>} */ macros = {};
/** @type {{key:string, value:string|SlashCommandClosure}[]} */
get macroList() {
return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])];
}
/** @type {SlashCommandScope} */ parent;
/** @type {string} */ #pipe;
get pipe() {
return this.#pipe ?? this.parent?.pipe;
}
set pipe(value) {
this.#pipe = value;
}
constructor(parent) {
this.parent = parent;
}
getCopy() {
const scope = new SlashCommandScope(this.parent);
scope.variableNames = [...this.variableNames];
scope.variables = Object.assign({}, this.variables);
scope.macros = Object.assign({}, this.macros);
scope.#pipe = this.#pipe;
return scope;
}
setMacro(key, value, overwrite = true) {
if (overwrite || !this.macroList.find(it=>it.key == key)) {
this.macros[key] = value;
}
}
existsVariableInScope(key) {
return Object.keys(this.variables).includes(key);
}
existsVariable(key) {
return Object.keys(this.variables).includes(key) || this.parent?.existsVariable(key);
}
letVariable(key, value = undefined) {
if (this.existsVariableInScope(key)) throw new SlashCommandScopeVariableExistsError(`Variable named "${key}" already exists.`);
this.variables[key] = value;
}
setVariable(key, value, index = null, type = null) {
if (this.existsVariableInScope(key)) {
if (index !== null && index !== undefined) {
let v = this.variables[key];
try {
v = JSON.parse(v);
const numIndex = Number(index);
if (Number.isNaN(numIndex)) {
v[index] = convertValueType(value, type);
} else {
v[numIndex] = convertValueType(value, type);
}
v = JSON.stringify(v);
} catch {
v[index] = convertValueType(value, type);
}
this.variables[key] = v;
} else {
this.variables[key] = value;
}
return value;
}
if (this.parent) {
return this.parent.setVariable(key, value, index, type);
}
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
}
getVariable(key, index = null) {
if (this.existsVariableInScope(key)) {
if (index !== null && index !== undefined) {
let v = this.variables[key];
try { v = JSON.parse(v); } catch { /* empty */ }
const numIndex = Number(index);
if (Number.isNaN(numIndex)) {
v = v[index];
} else {
v = v[numIndex];
}
if (typeof v == 'object') return JSON.stringify(v);
return v ?? '';
} else {
const value = this.variables[key];
return (value?.trim?.() === '' || isNaN(Number(value))) ? (value || '') : Number(value);
}
}
if (this.parent) {
return this.parent.getVariable(key, index);
}
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
}
}
export class SlashCommandScopeVariableExistsError extends Error {}
export class SlashCommandScopeVariableNotFoundError extends Error {}

View File

@@ -0,0 +1,11 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
export class SlashCommandUnnamedArgumentAssignment {
/** @type {number} */ start;
/** @type {number} */ end;
/** @type {string|SlashCommandClosure} */ value;
constructor() {
}
}

View File

@@ -0,0 +1,40 @@
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
export class SlashCommandVariableAutoCompleteOption extends AutoCompleteOption {
/**
* @param {string} name
*/
constructor(name) {
super(name);
}
renderItem() {
let li;
li = this.makeItem(this.name, '[𝑥]', true);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'variable');
return li;
}
renderDetails() {
const frag = document.createDocumentFragment();
const specs = document.createElement('div'); {
specs.classList.add('specs');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = this.name;
specs.append(name);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.textContent = 'scoped variable';
frag.append(help);
}
return frag;
}
}