From 4c41fb7f370bb77855c8e6bc86583e51efda6628 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 13 Dec 2024 16:18:18 -0500 Subject: [PATCH 1/9] Print included query info fields as yaml --- src/commands/query.mjs | 18 +++++---- src/commands/shell.mjs | 11 +++-- src/lib/command-helpers.mjs | 34 +++++++++++----- src/lib/fauna-client.mjs | 67 +++++++++++++++++++++++++++++++ src/lib/fauna.mjs | 2 +- src/lib/formatting/codeToAnsi.mjs | 3 +- src/lib/formatting/colorize.mjs | 16 ++++++++ 7 files changed, 129 insertions(+), 22 deletions(-) diff --git a/src/commands/query.mjs b/src/commands/query.mjs index f0446210..9538af28 100644 --- a/src/commands/query.mjs +++ b/src/commands/query.mjs @@ -13,8 +13,8 @@ import { } from "../lib/errors.mjs"; import { formatError, + formatQueryInfo, formatQueryResponse, - formatQuerySummary, getSecret, } from "../lib/fauna-client.mjs"; import { isTTY } from "../lib/misc.mjs"; @@ -89,8 +89,8 @@ async function queryCommand(argv) { typecheck, apiVersion, performanceHints, - summary, color, + include, } = argv; // If we're writing to a file, don't colorize the output regardless of the user's preference @@ -110,12 +110,16 @@ async function queryCommand(argv) { color: useColor, }); - // If performance hints are enabled, print the summary to stderr. + // If any query info should be displayed, print to stderr. // This is only supported in v10. - if ((summary || performanceHints) && apiVersion === "10") { - const formattedSummary = formatQuerySummary(results.summary); - if (formattedSummary) { - logger.stderr(formattedSummary); + if (include.length > 0 && apiVersion === "10") { + const queryInfo = formatQueryInfo(results, { + apiVersion, + color: useColor, + include, + }); + if (queryInfo) { + logger.stderr(queryInfo); } } diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index 2bb7340d..a6b3bd0d 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -12,8 +12,8 @@ import { yargsWithCommonConfigurableQueryOptions, } from "../lib/command-helpers.mjs"; import { + formatQueryInfo, formatQueryResponse, - formatQuerySummary, getSecret, } from "../lib/fauna-client.mjs"; import { clearHistoryStorage, initHistoryStorage } from "../lib/file-util.mjs"; @@ -149,7 +149,7 @@ async function buildCustomEval(argv) { if (cmd.trim() === "") return cb(); // These are options used for querying and formatting the response - const { apiVersion, color } = argv; + const { apiVersion, color, include } = argv; const performanceHints = getArgvOrCtx("performanceHints", argv, ctx); const summary = getArgvOrCtx("summary", argv, ctx); @@ -167,7 +167,7 @@ async function buildCustomEval(argv) { let res; try { const secret = await getSecret(); - const { url, timeout, typecheck } = argv; + const { color, timeout, typecheck, url } = argv; res = await runQueryFromString(cmd, { apiVersion, @@ -180,7 +180,10 @@ async function buildCustomEval(argv) { }); if ((summary || performanceHints) && apiVersion === "10") { - const formattedSummary = formatQuerySummary(res.summary); + const formattedSummary = formatQueryInfo( + { summary: res.summary }, + { apiVersion, color, include }, + ); if (formattedSummary) { logger.stdout(formattedSummary); } diff --git a/src/lib/command-helpers.mjs b/src/lib/command-helpers.mjs index 9f61b5a4..438c1fe2 100644 --- a/src/lib/command-helpers.mjs +++ b/src/lib/command-helpers.mjs @@ -114,20 +114,19 @@ const COMMON_CONFIGURABLE_QUERY_OPTIONS = { default: 5000, group: "API:", }, - summary: { - type: "boolean", - description: - "Output the summary field of the API response or nothing when it's empty. Only applies to v10 queries.", - default: false, - group: "API:", - }, performanceHints: { type: "boolean", description: - "Output the performance hints for the current query or nothing when no hints are available. Only applies to v10 queries.", + "Output the performance hints for the current query or nothing when no hints are available. Only applies to v10 queries. Sets the '--includes summary'", default: false, group: "API:", }, + include: { + type: "array", + choices: ["all", "txnTs", "schemaVersion", "summary", "queryTags", "stats"], + default: [], + describe: "Select additional query information to include in the output", + }, }; export function yargsWithCommonQueryOptions(yargs) { @@ -135,7 +134,24 @@ export function yargsWithCommonQueryOptions(yargs) { } export function yargsWithCommonConfigurableQueryOptions(yargs) { - return yargsWithCommonOptions(yargs, COMMON_CONFIGURABLE_QUERY_OPTIONS); + return yargsWithCommonOptions( + yargs, + COMMON_CONFIGURABLE_QUERY_OPTIONS, + ).middleware((argv) => { + if (argv.include.includes("all")) { + argv.include = [ + "txnTs", + "schemaVersion", + "summary", + "queryTags", + "stats", + ]; + } + + if (argv.performanceHints && !argv.include.includes("summary")) { + argv.include.push("summary"); + } + }); } export function yargsWithCommonOptions(yargs, options) { diff --git a/src/lib/fauna-client.mjs b/src/lib/fauna-client.mjs index 69949e68..6a0c93ff 100644 --- a/src/lib/fauna-client.mjs +++ b/src/lib/fauna-client.mjs @@ -179,3 +179,70 @@ export const formatQuerySummary = (summary) => { return summary; } }; + +/** + * Selects a subset of query info fields from a v10 query response. + * @param {import("fauna").QueryInfo} response - The query response + * @param {string[]} include - The query info fields to include + * @returns {object} An object with the selected query info fields + */ +const pickAndCamelCaseQueryInfo = (response, include) => { + const queryInfo = {}; + + if (include.includes("txnTs") && response.txn_ts) + queryInfo.txnTs = response.txn_ts; + if (include.includes("schemaVersion") && response.schema_version) + queryInfo.schemaVersion = response.schema_version.toString(); + if (include.includes("summary") && response.summary) + queryInfo.summary = response.summary; + if (include.includes("queryTags") && response.query_tags) + queryInfo.queryTags = response.query_tags; + if (include.includes("stats") && response.stats) + queryInfo.stats = response.stats; + + return queryInfo; +}; + +/** + * + * @param {object} response - The v4 or v10 query response with query info + * @param {object} opts + * @param {string} opts.apiVersion - The API version + * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include + * @returns + */ +export const formatQueryInfo = (response, { apiVersion, color, include }) => { + if (apiVersion === "4" && include.includes("stats")) { + /** @type {import("faunadb").MetricsResponse} */ + const metricsResponse = response; + const colorized = colorize( + { metrics: metricsResponse.metrics }, + { color, format: Format.YAML }, + ); + + return `${colorized}\n`; + } else if (apiVersion === "10") { + const queryInfoToDisplay = pickAndCamelCaseQueryInfo(response, include); + + if (Object.keys(queryInfoToDisplay).length === 0) return ""; + + const SUMMARY_IN_QUERY_INFO_FQL_REGEX = /^(\s\s\s\s\|)|(\d\s\|)/; + const colorized = colorize(queryInfoToDisplay, { + color, + format: Format.YAML, + }) + .split("\n") + .map((line) => { + if (!line.match(SUMMARY_IN_QUERY_INFO_FQL_REGEX)) { + return line; + } + return colorize(line, { format: Format.FQL }); + }) + .join("\n"); + + return `${colorized}\n`; + } + + return ""; +}; diff --git a/src/lib/fauna.mjs b/src/lib/fauna.mjs index 93b708f1..090ad756 100644 --- a/src/lib/fauna.mjs +++ b/src/lib/fauna.mjs @@ -138,7 +138,7 @@ export const formatError = (err, _opts = {}) => { typeof err.queryInfo.summary === "string" ) { // Otherwise, return the summary and fall back to the message. - return `${chalk.red("The query failed with the following error:")}\n\n${formatQuerySummary(err.queryInfo?.summary) ?? err.message}`; + return `${chalk.red("The query failed with the following error:")}\n\n${formatQuerySummary(err.queryInfo?.summary, { color: color ?? true }) ?? err.message}`; } else { if (err.name === "NetworkError") { return `The query failed unexpectedly with the following error:\n\n${NETWORK_ERROR_MESSAGE}`; diff --git a/src/lib/formatting/codeToAnsi.mjs b/src/lib/formatting/codeToAnsi.mjs index 91b7c196..675745b2 100644 --- a/src/lib/formatting/codeToAnsi.mjs +++ b/src/lib/formatting/codeToAnsi.mjs @@ -3,6 +3,7 @@ import { createHighlighterCoreSync } from "shiki/core"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; import json from "shiki/langs/json.mjs"; import log from "shiki/langs/log.mjs"; +import yaml from "shiki/langs/yaml.mjs"; import githubDarkHighContrast from "shiki/themes/github-dark-high-contrast.mjs"; import { isTTY } from "../misc.mjs"; @@ -13,7 +14,7 @@ const THEME = "github-dark-high-contrast"; export const createHighlighter = () => { const highlighter = createHighlighterCoreSync({ themes: [githubDarkHighContrast], - langs: [fql, log, json], + langs: [fql, log, json, yaml], engine: createJavaScriptRegexEngine(), }); diff --git a/src/lib/formatting/colorize.mjs b/src/lib/formatting/colorize.mjs index a162cc54..1d9c9e38 100644 --- a/src/lib/formatting/colorize.mjs +++ b/src/lib/formatting/colorize.mjs @@ -1,4 +1,5 @@ import stripAnsi from "strip-ansi"; +import YAML from "yaml"; import { container } from "../../cli.mjs"; import { codeToAnsi } from "./codeToAnsi.mjs"; @@ -8,6 +9,7 @@ export const Format = { LOG: "log", JSON: "json", TEXT: "text", + YAML: "yaml", }; const objToString = (obj) => JSON.stringify(obj, null, 2); @@ -52,6 +54,18 @@ const logToAnsi = (obj) => { return res.trim(); }; +const yamlToAnsi = (obj) => { + const codeToAnsi = container.resolve("codeToAnsi"); + const stringified = YAML.stringify(obj); + const res = codeToAnsi(stringified, "yaml"); + + if (!res) { + return ""; + } + + return res.trim(); +}; + /** * Formats an object for display with ANSI color codes. * @param {any} obj - The object to format @@ -67,6 +81,8 @@ export const toAnsi = (obj, { format = Format.TEXT } = {}) => { return jsonToAnsi(obj); case Format.LOG: return logToAnsi(obj); + case Format.YAML: + return yamlToAnsi(obj); default: return textToAnsi(obj); } From f91c108be5985594fd0f1d39b7cf00062c1f8cb5 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 13 Dec 2024 16:40:22 -0500 Subject: [PATCH 2/9] disable line folding in yaml --- src/lib/formatting/colorize.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/formatting/colorize.mjs b/src/lib/formatting/colorize.mjs index 1d9c9e38..88e5a072 100644 --- a/src/lib/formatting/colorize.mjs +++ b/src/lib/formatting/colorize.mjs @@ -56,7 +56,7 @@ const logToAnsi = (obj) => { const yamlToAnsi = (obj) => { const codeToAnsi = container.resolve("codeToAnsi"); - const stringified = YAML.stringify(obj); + const stringified = YAML.stringify(obj, { lineWidth: 0 }); const res = codeToAnsi(stringified, "yaml"); if (!res) { From 9779b52b7b3eea0bfb3bd93a7d909c7cbdc5d3de Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 13 Dec 2024 16:49:05 -0500 Subject: [PATCH 3/9] fix color change --- src/lib/fauna.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/fauna.mjs b/src/lib/fauna.mjs index 090ad756..93b708f1 100644 --- a/src/lib/fauna.mjs +++ b/src/lib/fauna.mjs @@ -138,7 +138,7 @@ export const formatError = (err, _opts = {}) => { typeof err.queryInfo.summary === "string" ) { // Otherwise, return the summary and fall back to the message. - return `${chalk.red("The query failed with the following error:")}\n\n${formatQuerySummary(err.queryInfo?.summary, { color: color ?? true }) ?? err.message}`; + return `${chalk.red("The query failed with the following error:")}\n\n${formatQuerySummary(err.queryInfo?.summary) ?? err.message}`; } else { if (err.name === "NetworkError") { return `The query failed unexpectedly with the following error:\n\n${NETWORK_ERROR_MESSAGE}`; From e84987cb55e779c3537b60c78e49102e01fe004d Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 13 Dec 2024 17:00:08 -0500 Subject: [PATCH 4/9] enable query info in shell --- src/commands/shell.mjs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index a6b3bd0d..b24688dd 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -151,7 +151,6 @@ async function buildCustomEval(argv) { // These are options used for querying and formatting the response const { apiVersion, color, include } = argv; const performanceHints = getArgvOrCtx("performanceHints", argv, ctx); - const summary = getArgvOrCtx("summary", argv, ctx); // Using --json output takes precedence over --format const outputFormat = resolveFormat({ ...argv }); @@ -179,13 +178,16 @@ async function buildCustomEval(argv) { format: outputFormat, }); - if ((summary || performanceHints) && apiVersion === "10") { - const formattedSummary = formatQueryInfo( - { summary: res.summary }, - { apiVersion, color, include }, - ); - if (formattedSummary) { - logger.stdout(formattedSummary); + // If any query info should be displayed, print to stderr. + // This is only supported in v10. + if (include.length > 0 && apiVersion === "10") { + const queryInfo = formatQueryInfo(res, { + apiVersion, + color, + include, + }); + if (queryInfo) { + logger.stdout(queryInfo); } } } catch (err) { From 4ccaba9442c908483aa30560968f475e10e5bc85 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 13 Dec 2024 17:27:09 -0500 Subject: [PATCH 5/9] implement query info toggling in shell --- src/commands/shell.mjs | 22 +++++++++++++++++----- src/lib/command-helpers.mjs | 10 +++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index b24688dd..2f338414 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -7,6 +7,7 @@ import * as esprima from "esprima"; import { container } from "../cli.mjs"; import { + QUERY_INFO_CHOICES, resolveFormat, validateDatabaseOrSecret, yargsWithCommonConfigurableQueryOptions, @@ -66,6 +67,8 @@ async function shellCommand(argv) { shell.on("exit", resolve); }); + shell.context.include = argv.include; + [ { cmd: "clear", @@ -114,13 +117,21 @@ async function shellCommand(argv) { }, }, { - cmd: "toggleSummary", - help: "Enable or disable the summary field of the API response. Disabled by default. If enabled, outputs the summary field of the API response.", + cmd: "toggleInfo", + help: "Enable or disable the query info fields of the API response. Disabled by default. If enabled, outputs the included fields of the API response.", action: () => { - shell.context.summary = !shell.context.summary; + shell.context.include = + shell.context.include.length === 0 + ? // if we are toggling on and no include was provided, turn everything on + argv.include.length === 0 + ? QUERY_INFO_CHOICES + : argv.include + : []; + logger.stderr( - `Summary in shell: ${shell.context.summary ? "on" : "off"}`, + `Query info in shell: ${shell.context.include.length === 0 ? "off" : shell.context.include.join(", ")}`, ); + shell.prompt(); }, }, @@ -149,7 +160,8 @@ async function buildCustomEval(argv) { if (cmd.trim() === "") return cb(); // These are options used for querying and formatting the response - const { apiVersion, color, include } = argv; + const { apiVersion, color } = argv; + const include = getArgvOrCtx("include", argv, ctx); const performanceHints = getArgvOrCtx("performanceHints", argv, ctx); // Using --json output takes precedence over --format diff --git a/src/lib/command-helpers.mjs b/src/lib/command-helpers.mjs index 438c1fe2..ddbc02fb 100644 --- a/src/lib/command-helpers.mjs +++ b/src/lib/command-helpers.mjs @@ -79,6 +79,14 @@ const COMMON_QUERY_OPTIONS = { }, }; +export const QUERY_INFO_CHOICES = [ + "txnTs", + "schemaVersion", + "summary", + "queryTags", + "stats", +]; + // used for queries customers can configure const COMMON_CONFIGURABLE_QUERY_OPTIONS = { ...COMMON_QUERY_OPTIONS, @@ -123,7 +131,7 @@ const COMMON_CONFIGURABLE_QUERY_OPTIONS = { }, include: { type: "array", - choices: ["all", "txnTs", "schemaVersion", "summary", "queryTags", "stats"], + choices: ["all", ...QUERY_INFO_CHOICES], default: [], describe: "Select additional query information to include in the output", }, From 28b4c131f45032caa491fa13de989ae1652987b0 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 13 Dec 2024 17:29:30 -0500 Subject: [PATCH 6/9] help suggestions --- src/lib/command-helpers.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/command-helpers.mjs b/src/lib/command-helpers.mjs index ddbc02fb..a622b148 100644 --- a/src/lib/command-helpers.mjs +++ b/src/lib/command-helpers.mjs @@ -125,7 +125,7 @@ const COMMON_CONFIGURABLE_QUERY_OPTIONS = { performanceHints: { type: "boolean", description: - "Output the performance hints for the current query or nothing when no hints are available. Only applies to v10 queries. Sets the '--includes summary'", + "Output the performance hints for the current query or nothing when no hints are available. Only applies to v10 queries. Sets '--include summary'", default: false, group: "API:", }, @@ -133,7 +133,7 @@ const COMMON_CONFIGURABLE_QUERY_OPTIONS = { type: "array", choices: ["all", ...QUERY_INFO_CHOICES], default: [], - describe: "Select additional query information to include in the output", + describe: "Include additional query response data in the output.", }, }; From 330d522527a5554c3869539816c0e5538db6d441 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Sat, 14 Dec 2024 13:35:40 -0500 Subject: [PATCH 7/9] include summary by default --- src/lib/command-helpers.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/command-helpers.mjs b/src/lib/command-helpers.mjs index a622b148..b86f500b 100644 --- a/src/lib/command-helpers.mjs +++ b/src/lib/command-helpers.mjs @@ -131,8 +131,8 @@ const COMMON_CONFIGURABLE_QUERY_OPTIONS = { }, include: { type: "array", - choices: ["all", ...QUERY_INFO_CHOICES], - default: [], + choices: ["all", "none", ...QUERY_INFO_CHOICES], + default: ["summary"], describe: "Include additional query response data in the output.", }, }; @@ -146,6 +146,15 @@ export function yargsWithCommonConfigurableQueryOptions(yargs) { yargs, COMMON_CONFIGURABLE_QUERY_OPTIONS, ).middleware((argv) => { + if (argv.include.includes("none")) { + if (argv.include.length !== 1) { + throw new ValidationError( + `'--include none' cannot be used with other include options. Provided options: '${argv.include.join(", ")}'`, + ); + } + argv.include = []; + } + if (argv.include.includes("all")) { argv.include = [ "txnTs", From 9022f021ccca6f9584ffea60b46e44977a0960f8 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Mon, 16 Dec 2024 13:29:25 -0500 Subject: [PATCH 8/9] testing --- src/lib/command-helpers.mjs | 8 +----- test/query.mjs | 52 ++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/lib/command-helpers.mjs b/src/lib/command-helpers.mjs index b86f500b..fda16e61 100644 --- a/src/lib/command-helpers.mjs +++ b/src/lib/command-helpers.mjs @@ -156,13 +156,7 @@ export function yargsWithCommonConfigurableQueryOptions(yargs) { } if (argv.include.includes("all")) { - argv.include = [ - "txnTs", - "schemaVersion", - "summary", - "queryTags", - "stats", - ]; + argv.include = [...QUERY_INFO_CHOICES]; } if (argv.performanceHints && !argv.include.includes("summary")) { diff --git a/test/query.mjs b/test/query.mjs index 69103263..be43c7e5 100644 --- a/test/query.mjs +++ b/test/query.mjs @@ -172,6 +172,19 @@ describe("query", function () { colorize([], { format: "json", color: false }), ); }); + + it("cannot specify '--include none' with any other options", async function () { + try { + await run( + `query "foo" --secret=foo --include none summary`, + container, + ); + } catch (e) {} + + expect(logger.stderr).to.have.been.calledWith( + sinon.match("'--include none' cannot be used with other include options."), + ); + }); }); describe("--local usage", function () { @@ -308,7 +321,31 @@ describe("query", function () { ); }); - it("can display performance hints", async function () { + // Add FormatQueryInfo to container in order to test which options are were passed + it.skip("can set the include option to an array"); + it.skip("can specify '--include all' to set all include options"); + it.skip("can specify '--include none' to set no include options"); + + it("displays summary by default", async function () { + runQueryFromString.resolves({ + summary: "info at *query*:1: hello world", + data: "fql", + }); + + await run( + `query "Database.all()" --performanceHints --secret=foo`, + container, + ); + + expect(logger.stderr).to.have.been.calledWith(sinon.match(/hello world/)); + expect(container.resolve("codeToAnsi")).to.have.been.calledWith( + sinon.match(/hello world/), + "yaml", + ); + expect(logger.stdout).to.have.been.calledWith(sinon.match(/fql/)); + }); + + it("still displays performance hints if '--include none' is used", async function () { runQueryFromString.resolves({ summary: "performance_hint: use a more efficient query\n1 | use a more efficient query", @@ -316,7 +353,7 @@ describe("query", function () { }); await run( - `query "Database.all()" --performanceHints --secret=foo`, + `query "Database.all()" --performanceHints --secret=foo --include none`, container, ); @@ -330,16 +367,17 @@ describe("query", function () { expect(logger.stdout).to.have.been.calledWith(sinon.match(/fql/)); }); - it("does not display anything if summary is empty", async function () { + it("does not display anything if info fields are empty", async function () { runQueryFromString.resolves({ + txnTs: "", + schemaVersion: "", summary: "", + queryTags: "", + stats: "", data: "fql", }); - await run( - `query "Database.all()" --performanceHints --secret=foo`, - container, - ); + await run(`query "Database.all()" --secret=foo --include all`, container); expect(logger.stderr).to.not.be.called; expect(logger.stdout).to.have.been.calledWith(sinon.match(/fql/)); From 00de830f88a77fd8f8a19ec314c7d5369d2a5209 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Mon, 16 Dec 2024 13:29:42 -0500 Subject: [PATCH 9/9] formatting --- test/query.mjs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/query.mjs b/test/query.mjs index be43c7e5..eaa04eef 100644 --- a/test/query.mjs +++ b/test/query.mjs @@ -175,14 +175,13 @@ describe("query", function () { it("cannot specify '--include none' with any other options", async function () { try { - await run( - `query "foo" --secret=foo --include none summary`, - container, - ); + await run(`query "foo" --secret=foo --include none summary`, container); } catch (e) {} expect(logger.stderr).to.have.been.calledWith( - sinon.match("'--include none' cannot be used with other include options."), + sinon.match( + "'--include none' cannot be used with other include options.", + ), ); }); });