Merge pull request #30565 from D0nGiovanni/m-template-literal-2
add refactoring: string concatenation to template literals
This commit is contained in:
commit
024b8c1e5f
|
@ -5349,6 +5349,10 @@
|
|||
"category": "Message",
|
||||
"code": 95095
|
||||
},
|
||||
"Convert to template string": {
|
||||
"category": "Message",
|
||||
"code": 95096
|
||||
},
|
||||
|
||||
"No value exists in scope for the shorthand property '{0}'. Either declare one or provide an initializer.": {
|
||||
"category": "Error",
|
||||
|
|
186
src/services/refactors/convertStringOrTemplateLiteral.ts
Normal file
186
src/services/refactors/convertStringOrTemplateLiteral.ts
Normal file
|
@ -0,0 +1,186 @@
|
|||
/* @internal */
|
||||
namespace ts.refactor.convertStringOrTemplateLiteral {
|
||||
const refactorName = "Convert to template string";
|
||||
const refactorDescription = getLocaleSpecificMessage(Diagnostics.Convert_to_template_string);
|
||||
|
||||
registerRefactor(refactorName, { getEditsForAction, getAvailableActions });
|
||||
|
||||
function getAvailableActions(context: RefactorContext): readonly ApplicableRefactorInfo[] {
|
||||
const { file, startPosition } = context;
|
||||
const node = getNodeOrParentOfParentheses(file, startPosition);
|
||||
const maybeBinary = getParentBinaryExpression(node);
|
||||
const refactorInfo: ApplicableRefactorInfo = { name: refactorName, description: refactorDescription, actions: [] };
|
||||
|
||||
if ((isBinaryExpression(maybeBinary) || isStringLiteral(maybeBinary)) && isStringConcatenationValid(maybeBinary)) {
|
||||
refactorInfo.actions.push({ name: refactorName, description: refactorDescription });
|
||||
return [refactorInfo];
|
||||
}
|
||||
return emptyArray;
|
||||
}
|
||||
|
||||
function getNodeOrParentOfParentheses(file: SourceFile, startPosition: number) {
|
||||
const node = getTokenAtPosition(file, startPosition);
|
||||
const nestedBinary = getParentBinaryExpression(node);
|
||||
const isNonStringBinary = !isStringConcatenationValid(nestedBinary);
|
||||
|
||||
if (
|
||||
isNonStringBinary &&
|
||||
isParenthesizedExpression(nestedBinary.parent) &&
|
||||
isBinaryExpression(nestedBinary.parent.parent)
|
||||
) {
|
||||
return nestedBinary.parent.parent;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function getEditsForAction(context: RefactorContext, actionName: string): RefactorEditInfo | undefined {
|
||||
const { file, startPosition } = context;
|
||||
const node = getNodeOrParentOfParentheses(file, startPosition);
|
||||
|
||||
switch (actionName) {
|
||||
case refactorDescription:
|
||||
return { edits: getEditsForToTemplateLiteral(context, node) };
|
||||
default:
|
||||
return Debug.fail("invalid action");
|
||||
}
|
||||
}
|
||||
|
||||
function getEditsForToTemplateLiteral(context: RefactorContext, node: Node) {
|
||||
const maybeBinary = getParentBinaryExpression(node);
|
||||
const file = context.file;
|
||||
|
||||
const templateLiteral = nodesToTemplate(treeToArray(maybeBinary), file);
|
||||
const trailingCommentRanges = getTrailingCommentRanges(file.text, maybeBinary.end);
|
||||
|
||||
if (trailingCommentRanges) {
|
||||
const lastComment = trailingCommentRanges[trailingCommentRanges.length - 1];
|
||||
const trailingRange = { pos: trailingCommentRanges[0].pos, end: lastComment.end };
|
||||
|
||||
// since suppressTrailingTrivia(maybeBinary) does not work, the trailing comment is removed manually
|
||||
// otherwise it would have the trailing comment twice
|
||||
return textChanges.ChangeTracker.with(context, t => {
|
||||
t.deleteRange(file, trailingRange);
|
||||
t.replaceNode(file, maybeBinary, templateLiteral);
|
||||
});
|
||||
}
|
||||
else {
|
||||
return textChanges.ChangeTracker.with(context, t => t.replaceNode(file, maybeBinary, templateLiteral));
|
||||
}
|
||||
}
|
||||
|
||||
function isNotEqualsOperator(node: BinaryExpression) {
|
||||
return node.operatorToken.kind !== SyntaxKind.EqualsToken;
|
||||
}
|
||||
|
||||
function getParentBinaryExpression(expr: Node) {
|
||||
while (isBinaryExpression(expr.parent) && isNotEqualsOperator(expr.parent)) {
|
||||
expr = expr.parent;
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
function isStringConcatenationValid(node: Node): boolean {
|
||||
const { containsString, areOperatorsValid } = treeToArray(node);
|
||||
return containsString && areOperatorsValid;
|
||||
}
|
||||
|
||||
function treeToArray(current: Node): { nodes: Expression[], operators: Token<BinaryOperator>[], containsString: boolean, areOperatorsValid: boolean} {
|
||||
if (isBinaryExpression(current)) {
|
||||
const { nodes, operators, containsString: leftHasString, areOperatorsValid: leftOperatorValid } = treeToArray(current.left);
|
||||
|
||||
if (!leftHasString && !isStringLiteral(current.right)) {
|
||||
return { nodes: [current], operators: [], containsString: false, areOperatorsValid: true };
|
||||
}
|
||||
|
||||
const currentOperatorValid = current.operatorToken.kind === SyntaxKind.PlusToken;
|
||||
const areOperatorsValid = leftOperatorValid && currentOperatorValid;
|
||||
|
||||
nodes.push(current.right);
|
||||
operators.push(current.operatorToken);
|
||||
|
||||
return { nodes, operators, containsString: true, areOperatorsValid };
|
||||
}
|
||||
|
||||
return { nodes: [current as Expression], operators: [], containsString: isStringLiteral(current), areOperatorsValid: true };
|
||||
}
|
||||
|
||||
// to copy comments following the operator
|
||||
// "foo" + /* comment */ "bar"
|
||||
const copyTrailingOperatorComments = (operators: Token<BinaryOperator>[], file: SourceFile) => (index: number, targetNode: Node) => {
|
||||
if (index < operators.length) {
|
||||
copyTrailingComments(operators[index], targetNode, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
|
||||
}
|
||||
};
|
||||
|
||||
// to copy comments following the string
|
||||
// "foo" /* comment */ + "bar" /* comment */ + "bar2"
|
||||
const copyCommentFromMultiNode = (nodes: readonly Expression[], file: SourceFile, copyOperatorComments: (index: number, targetNode: Node) => void) =>
|
||||
(indexes: number[], targetNode: Node) => {
|
||||
while (indexes.length > 0) {
|
||||
const index = indexes.shift()!;
|
||||
copyTrailingComments(nodes[index], targetNode, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
|
||||
copyOperatorComments(index, targetNode);
|
||||
}
|
||||
};
|
||||
|
||||
function concatConsecutiveString(index: number, nodes: readonly Expression[]): [number, string, number[]] {
|
||||
let text = "";
|
||||
const indexes = [];
|
||||
|
||||
while (index < nodes.length && isStringLiteral(nodes[index])) {
|
||||
const stringNode = nodes[index] as StringLiteral;
|
||||
text = text + stringNode.text;
|
||||
indexes.push(index);
|
||||
index++;
|
||||
}
|
||||
|
||||
text = escapeString(text);
|
||||
return [index, text, indexes];
|
||||
}
|
||||
|
||||
function nodesToTemplate({nodes, operators}: {nodes: readonly Expression[], operators: Token<BinaryOperator>[]}, file: SourceFile) {
|
||||
const copyOperatorComments = copyTrailingOperatorComments(operators, file);
|
||||
const copyCommentFromStringLiterals = copyCommentFromMultiNode(nodes, file, copyOperatorComments);
|
||||
const [begin, headText, headIndexes] = concatConsecutiveString(0, nodes);
|
||||
|
||||
if (begin === nodes.length) {
|
||||
const noSubstitutionTemplateLiteral = createNoSubstitutionTemplateLiteral(headText);
|
||||
copyCommentFromStringLiterals(headIndexes, noSubstitutionTemplateLiteral);
|
||||
return noSubstitutionTemplateLiteral;
|
||||
}
|
||||
|
||||
const templateSpans: TemplateSpan[] = [];
|
||||
const templateHead = createTemplateHead(headText);
|
||||
copyCommentFromStringLiterals(headIndexes, templateHead);
|
||||
|
||||
for (let i = begin; i < nodes.length; i++) {
|
||||
const currentNode = getExpressionFromParenthesesOrExpression(nodes[i]);
|
||||
copyOperatorComments(i, currentNode);
|
||||
|
||||
const [newIndex, subsequentText, stringIndexes] = concatConsecutiveString(i + 1, nodes);
|
||||
i = newIndex - 1;
|
||||
|
||||
const templatePart = i === nodes.length - 1 ? createTemplateTail(subsequentText) : createTemplateMiddle(subsequentText);
|
||||
copyCommentFromStringLiterals(stringIndexes, templatePart);
|
||||
templateSpans.push(createTemplateSpan(currentNode, templatePart));
|
||||
}
|
||||
|
||||
return createTemplateExpression(templateHead, templateSpans);
|
||||
}
|
||||
|
||||
// to copy comments following the opening & closing parentheses
|
||||
// "foo" + ( /* comment */ 5 + 5 ) /* comment */ + "bar"
|
||||
function copyCommentsWhenParenthesized(node: ParenthesizedExpression) {
|
||||
const file = node.getSourceFile();
|
||||
copyTrailingComments(node, node.expression, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
|
||||
copyTrailingAsLeadingComments(node.expression, node.expression, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
|
||||
}
|
||||
|
||||
function getExpressionFromParenthesesOrExpression(node: Expression) {
|
||||
if (isParenthesizedExpression(node)) {
|
||||
copyCommentsWhenParenthesized(node);
|
||||
node = node.expression;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
|
@ -95,6 +95,7 @@
|
|||
"refactors/moveToNewFile.ts",
|
||||
"refactors/addOrRemoveBracesToArrowFunction.ts",
|
||||
"refactors/convertParamsToDestructuredObject.ts",
|
||||
"refactors/convertStringOrTemplateLiteral.ts",
|
||||
"services.ts",
|
||||
"breakpoints.ts",
|
||||
"transform.ts",
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// console.log("/*x*/f/*y*/oobar is " + 32 + " years old")
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`console.log(\`foobar is \${32} years old\`)`,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/w/*y*/ith back`tick"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = `with back\\`tick`",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/f/*y*/oobar is " + (42 + 6) + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar is \${42 + 6} years old\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/f/*y*/oobar is " + (42 + 6)
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = `foobar is \${42 + 6}`",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = /* C0 */ /*x*/"/*y*/foo" /* C1 */ + " is" /* C2 */ + 42 /* C3 */ + " and bar" /* C4 */ + " is" /* C5 */ + 52/* C6 */
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = /* C0 */ `foo is\${ /* C1 */ /* C2 */42 /* C3 */} and bar is\${ /* C4 */ /* C5 */52 /* C6 */}`",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = /* C0 */ /*x*/"/*y*/foo" + /* C1 */ " is" + /* C2 */ 42 + /* C3 */ " and bar" + /* C4 */ " is" + /* C5 */ 52/* C6 */
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = /* C0 */ `foo is\${ /* C1 */ /* C2 */42 /* C3 */} and bar is\${ /* C4 */ /* C5 */52 /* C6 */}`",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = /* C0 */ /*x*/"/*y*/foo" /* C1 */ + " is"/* C2 */ /* C3 */
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = /* C0 */ `foo is` /* C1 */ /* C2 */ /* C3 */",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = /*x*/"/*y*/foobar is" + ( /* C1 */ 42 ) /* C2 */ + /* C3 */ " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = `foobar is\${/* C1 */ 42 /* C2 */ /* C3 */} years old`",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = /* C0 */ /*x*/"/*y*/foo" /* C1 */ + " is" /* C2 */ + 42/* C3 */
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = /* C0 */ `foo is\${ /* C1 */ /* C2 */42 /* C3 */}`",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = /* C0 */ /*x*/"/*y*/foo" /* C1 */ + " is" /* C2 */ + 42 + " years old"/* C3 */
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = /* C0 */ `foo is\${ /* C1 */ /* C2 */42} years old` /* C3 */",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/f/*y*/oobar is " + 42 + " years" + " old" + " and " + 6 + " cars" + " are" + " missing"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar is \${42} years old and \${6} cars are missing\``,
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const age = 22
|
||||
//// const name = "Eddy"
|
||||
//// const foo = /*x*/n/*y*/ame + " is " + age + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const age = 22
|
||||
const name = "Eddy"
|
||||
const foo = \`\${name} is \${age} years old\``,
|
||||
});
|
|
@ -0,0 +1,14 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const age = 42
|
||||
//// const foo = "/*x*/f/*y*/oobar is " + age + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const age = 42
|
||||
const foo = \`foobar is \${age} years old\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/f/*y*/oobar " + "rocks" + " fantastically"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
"const foo = `foobar rocks fantastically`",
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/f/*y*/oobar is " + 42 * 6 / 4 + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar is \${42 * 6 / 4} years old\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "foobar is " + (/*x*/42/*y*/ + 6) + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar is \${42 + 6} years old\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "foobar is " + /*x*/(/*y*/42 + 6) + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar is \${42 + 6} years old\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "foobar is " + (/*x*/42/*y*/ + 6 + "str") + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = "foobar is " + (\`\${42 + 6}str\`) + " years old"`,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/f/*y*/oobar is " + 42 + 6 + " years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar is \${42}\${6} years old\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = /*x*/4/*y*/2 - 6 * 4 + 23 / 12 +" years old"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`\${42 - 6 * 4 + 23 / 12} years old\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = "/*x*/f/*y*/oobar rocks"
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar rocks\``,
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference path='fourslash.ts' />
|
||||
|
||||
//// const foo = '/*x*/f/*y*/oobar rocks'
|
||||
|
||||
goTo.select("x", "y");
|
||||
edit.applyRefactor({
|
||||
refactorName: "Convert to template string",
|
||||
actionName: "Convert to template string",
|
||||
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
|
||||
newContent:
|
||||
`const foo = \`foobar rocks\``,
|
||||
});
|
Loading…
Reference in a new issue