diff --git a/frontend/package.json b/frontend/package.json index 3e7b9d3..fc0ae22 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,12 +16,14 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", "@rjsf/core": "^6.4.1", "@rjsf/mui": "^6.4.1", "@rjsf/utils": "^6.4.1", "@rjsf/validator-ajv8": "^6.4.1", + "monaco-editor": "^0.56.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fe81d07..3c2e8ee 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -72,6 +72,27 @@ export type HistoryEntry = { details?: string | null; }; +export type ExpressionDiagnostic = { + message: string; + start_line: number; + start_column: number; + end_line: number; + end_column: number; +}; + +export async function validateExpression( + expression: string, +): Promise { + const r = await fetch("/api/expressions/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expression }), + }); + if (!r.ok) throw new Error(`validate: ${r.status}`); + const body = (await r.json()) as { diagnostics: ExpressionDiagnostic[] }; + return body.diagnostics; +} + export async function fetchHistory(): Promise { const r = await fetch("/api/history"); if (!r.ok) throw new Error(`history: ${r.status}`); diff --git a/frontend/src/components/ExpressionWidget.test.tsx b/frontend/src/components/ExpressionWidget.test.tsx new file mode 100644 index 0000000..af3c0e1 --- /dev/null +++ b/frontend/src/components/ExpressionWidget.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import type { WidgetProps } from "@rjsf/utils"; +import { describe, expect, it, vi } from "vitest"; +import { ExpressionWidget, findToken } from "./ExpressionWidget"; + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { + ...actual, + validateExpression: vi.fn().mockResolvedValue([]), + }; +}); + +const tokens = [ + { + label: "col", + insert: 'col("$' + '{1:name}")', + kind: "function" as const, + detail: "Rawdata column", + }, + { + label: "deg", + insert: "deg", + kind: "constant" as const, + detail: "Named constant", + }, +]; + +function renderWidget(onChange = vi.fn(), value = "") { + const props = { + id: "root_expression", + name: "expression", + label: "Designation expression", + value, + onChange, + options: { tokens }, + schema: { type: "string" }, + } as unknown as WidgetProps; + return render(); +} + +describe("ExpressionWidget", () => { + it("renders an editor", () => { + renderWidget(); + expect(screen.getByLabelText("Designation expression")).toBeInTheDocument(); + }); +}); + +describe("findToken", () => { + it("returns the token matching a hovered word", () => { + expect(findToken("col", tokens)?.detail).toBe("Rawdata column"); + expect(findToken("deg", tokens)?.detail).toBe("Named constant"); + }); + + it("returns undefined for unknown words", () => { + expect(findToken("unknown", tokens)).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/ExpressionWidget.tsx b/frontend/src/components/ExpressionWidget.tsx new file mode 100644 index 0000000..17d2de6 --- /dev/null +++ b/frontend/src/components/ExpressionWidget.tsx @@ -0,0 +1,283 @@ +import Editor, { type Monaco } from "@monaco-editor/react"; +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import type { WidgetProps } from "@rjsf/utils"; +import type { editor, Position } from "monaco-editor"; +import { useEffect, useRef } from "react"; +import { type ExpressionDiagnostic, validateExpression } from "../api"; + +const LANGUAGE_ID = "hyperleda-expression"; +const MARKER_OWNER = "hyperleda-expression"; + +export type ExpressionToken = { + label: string; + insert: string; + kind: "function" | "constant"; + detail: string; +}; + +let languageRegistered = false; +let currentTokens: ExpressionToken[] = []; + +function isExpressionToken(value: unknown): value is ExpressionToken { + if (typeof value !== "object" || value === null) { + return false; + } + const token = value as Record; + return ( + typeof token.label === "string" && + typeof token.insert === "string" && + (token.kind === "function" || token.kind === "constant") && + typeof token.detail === "string" + ); +} + +function readTokens(options: WidgetProps["options"]): ExpressionToken[] { + const raw = options.tokens; + if (!Array.isArray(raw)) { + return []; + } + return raw.filter(isExpressionToken); +} + +export function findToken( + word: string, + tokens: ExpressionToken[], +): ExpressionToken | undefined { + return tokens.find((token) => token.label === word); +} + +function toMarkers( + monaco: Monaco, + diagnostics: ExpressionDiagnostic[], +): editor.IMarkerData[] { + return diagnostics.map((diagnostic) => ({ + severity: monaco.MarkerSeverity.Error, + message: diagnostic.message, + startLineNumber: diagnostic.start_line, + startColumn: diagnostic.start_column, + endLineNumber: diagnostic.end_line, + endColumn: diagnostic.end_column, + })); +} + +function registerExpressionLanguage( + monaco: Monaco, + tokens: ExpressionToken[], +): void { + currentTokens = tokens; + if (languageRegistered) { + return; + } + monaco.languages.register({ id: LANGUAGE_ID }); + monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, { + keywords: tokens.map((token) => token.label), + tokenizer: { + root: [ + [/"(?:\\.|[^"\\])*"/, "string"], + [/'[^']*'/, "string"], + [/\d+(?:\.\d+)?/, "number"], + [/[+\-*/%=()]/, "delimiter"], + [ + /[a-zA-Z_]\w*/, + { + cases: { + "@keywords": "keyword", + "@default": "identifier", + }, + }, + ], + ], + }, + }); + monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, { + triggerCharacters: ["(", '"'], + provideCompletionItems(model: editor.ITextModel, position: Position) { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + return { + suggestions: currentTokens.map((token) => ({ + label: token.label, + kind: + token.kind === "function" + ? monaco.languages.CompletionItemKind.Function + : monaco.languages.CompletionItemKind.Constant, + insertText: token.insert, + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: token.detail, + documentation: token.detail, + range, + })), + }; + }, + }); + monaco.languages.registerHoverProvider(LANGUAGE_ID, { + provideHover(model: editor.ITextModel, position: Position) { + const word = model.getWordAtPosition(position); + if (!word) { + return null; + } + const token = findToken(word.word, currentTokens); + if (!token) { + return null; + } + return { + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }, + contents: [{ value: `**${token.label}**` }, { value: token.detail }], + }; + }, + }); + languageRegistered = true; +} + +export function ExpressionWidget(props: WidgetProps) { + const theme = useTheme(); + const tokens = readTokens(props.options); + const value = typeof props.value === "string" ? props.value : ""; + const isDark = theme.palette.mode === "dark"; + const editorRef = useRef(null); + const monacoRef = useRef(null); + const requestIdRef = useRef(0); + const timeoutRef = useRef(null); + + useEffect( + () => () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + requestIdRef.current += 1; + }, + [], + ); + + function applyMarkers(diagnostics: ExpressionDiagnostic[]) { + const editorInstance = editorRef.current; + const monaco = monacoRef.current; + const model = editorInstance?.getModel(); + if (!editorInstance || !monaco || !model) { + return; + } + monaco.editor.setModelMarkers( + model, + MARKER_OWNER, + toMarkers(monaco, diagnostics), + ); + } + + function scheduleValidation(text: string) { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + const requestId = ++requestIdRef.current; + validateExpression(text) + .then((diagnostics) => { + if (requestId !== requestIdRef.current) { + return; + } + applyMarkers(diagnostics); + }) + .catch(() => { + if (requestId !== requestIdRef.current) { + return; + } + applyMarkers([]); + }); + }, 200); + } + + function handleBeforeMount(monaco: Monaco) { + registerExpressionLanguage(monaco, tokens); + } + + function handleMount( + editorInstance: editor.IStandaloneCodeEditor, + monaco: Monaco, + ) { + editorRef.current = editorInstance; + monacoRef.current = monaco; + editorInstance.onKeyDown((e) => { + if (e.keyCode === monaco.KeyCode.Enter) { + e.preventDefault(); + e.stopPropagation(); + } + }); + applyMarkers([]); + scheduleValidation(value.replace(/\r?\n/g, "")); + } + + return ( + + { + const singleLine = (next ?? "").replace(/\r?\n/g, ""); + props.onChange(singleLine); + scheduleValidation(singleLine); + }} + beforeMount={handleBeforeMount} + onMount={handleMount} + options={{ + ariaLabel: props.label, + readOnly: props.disabled || props.readonly, + minimap: { enabled: false }, + lineNumbers: "off", + folding: false, + glyphMargin: false, + lineDecorationsWidth: 12, + lineNumbersMinChars: 0, + scrollBeyondLastLine: false, + wordWrap: "off", + fontSize: 14, + lineHeight: 22, + padding: { top: 10, bottom: 10 }, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + renderLineHighlight: "none", + scrollbar: { + vertical: "hidden", + horizontal: "auto", + alwaysConsumeMouseWheel: false, + }, + quickSuggestions: { other: true, comments: false, strings: true }, + wordBasedSuggestions: "off", + suggestOnTriggerCharacters: true, + acceptSuggestionOnEnter: "off", + tabCompletion: "on", + automaticLayout: true, + contextmenu: false, + fixedOverflowWidgets: true, + }} + loading={} + /> + + ); +} diff --git a/frontend/src/components/TaskPage.test.tsx b/frontend/src/components/TaskPage.test.tsx index 4c49e42..ad9cced 100644 --- a/frontend/src/components/TaskPage.test.tsx +++ b/frontend/src/components/TaskPage.test.tsx @@ -20,6 +20,21 @@ const fakeTaskSchema = { title: "Count", default: 1, }, + expression: { + type: "string", + title: "Designation expression", + "ui:widget": "expression", + "ui:options": { + tokens: [ + { + label: "col", + insert: 'col("$' + '{1:name}")', + kind: "function", + detail: "Rawdata column", + }, + ], + }, + }, }, required: ["name"], }, @@ -64,6 +79,9 @@ describe("TaskPage", () => { expect(screen.getByText("A task used in tests.")).toBeInTheDocument(); expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/count/i)).toBeInTheDocument(); + expect( + screen.getByLabelText(/designation expression/i), + ).toBeInTheDocument(); expect(screen.getByText("string")).toBeInTheDocument(); expect(screen.getByText("string").tagName).toBe("CODE"); }); diff --git a/frontend/src/components/TaskPage.tsx b/frontend/src/components/TaskPage.tsx index e04b222..c251036 100644 --- a/frontend/src/components/TaskPage.tsx +++ b/frontend/src/components/TaskPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import Form from "@rjsf/mui"; +import type { RegistryWidgetsType } from "@rjsf/utils"; import validator from "@rjsf/validator-ajv8"; import InfoOutlined from "@mui/icons-material/InfoOutlined"; import Alert from "@mui/material/Alert"; @@ -10,10 +11,16 @@ import IconButton from "@mui/material/IconButton"; import Popover from "@mui/material/Popover"; import Typography from "@mui/material/Typography"; import { fetchTaskSchema, submitTask } from "../api"; +import { extractUiSchema } from "../extractUiSchema"; +import { ExpressionWidget } from "./ExpressionWidget"; import { FoldableObjectFieldTemplate } from "./FoldableObjectFieldTemplate"; import { Markdown } from "./Markdown"; import { ProgressView } from "./ProgressView"; +const widgets: RegistryWidgetsType = { + expression: ExpressionWidget, +}; + export function TaskPage() { const { taskId } = useParams<{ taskId: string }>(); const location = useLocation(); @@ -89,6 +96,13 @@ export function TaskPage() { ); } + const uiSchema = { + ...extractUiSchema(schema), + "ui:globalOptions": { + enableMarkdownInDescription: true, + }, + }; + return ( { setSubmitError(null); diff --git a/frontend/src/extractUiSchema.test.ts b/frontend/src/extractUiSchema.test.ts new file mode 100644 index 0000000..088471b --- /dev/null +++ b/frontend/src/extractUiSchema.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { extractUiSchema } from "./extractUiSchema"; + +describe("extractUiSchema", () => { + it("copies ui keys from nested properties", () => { + const schema = { + type: "object", + properties: { + password: { + type: "string", + "ui:widget": "password", + }, + expression: { + type: "string", + "ui:widget": "expression", + "ui:options": { tokens: [{ label: "col" }] }, + }, + advanced: { + type: "object", + properties: { + secret: { + type: "string", + "ui:widget": "password", + }, + }, + }, + }, + }; + + expect(extractUiSchema(schema)).toEqual({ + password: { "ui:widget": "password" }, + expression: { + "ui:widget": "expression", + "ui:options": { tokens: [{ label: "col" }] }, + }, + advanced: { + secret: { "ui:widget": "password" }, + }, + }); + }); +}); diff --git a/frontend/src/extractUiSchema.ts b/frontend/src/extractUiSchema.ts new file mode 100644 index 0000000..ca31dba --- /dev/null +++ b/frontend/src/extractUiSchema.ts @@ -0,0 +1,34 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function extractUiSchema( + schema: Record, +): Record { + const ui: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (key.startsWith("ui:")) { + ui[key] = value; + } + } + const properties = schema.properties; + if (isRecord(properties)) { + for (const [name, sub] of Object.entries(properties)) { + if (!isRecord(sub)) { + continue; + } + const nested = extractUiSchema(sub); + if (Object.keys(nested).length > 0) { + ui[name] = nested; + } + } + } + const items = schema.items; + if (isRecord(items)) { + const nested = extractUiSchema(items); + if (Object.keys(nested).length > 0) { + ui.items = nested; + } + } + return ui; +} diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index d0de870..e7075a5 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -1 +1,22 @@ +import { createElement, type ChangeEvent } from "react"; +import { vi } from "vitest"; import "@testing-library/jest-dom"; + +vi.mock("@monaco-editor/react", () => { + function Default(props: { + value?: string; + onChange?: (value: string | undefined) => void; + options?: { ariaLabel?: string }; + }) { + return createElement("textarea", { + "aria-label": props.options?.ariaLabel, + value: props.value ?? "", + onChange: (event: ChangeEvent) => + props.onChange?.(event.target.value), + }); + } + return { + default: Default, + loader: { config: () => undefined }, + }; +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5b8655e..5313759 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4,9 +4,13 @@ "@adobe/css-tools@^4.4.0": version "4.5.0" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.5.0.tgz#b5b71a25a4d16afa2482592ddfa62fccc60bc7d1" + integrity sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q== "@asamuzakjp/css-color@^5.0.1": version "5.1.11" + resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz#28a0aac8220a4cc19045ac3bd9a813d4060bd375" + integrity sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg== dependencies: "@asamuzakjp/generational-cache" "^1.0.1" "@csstools/css-calc" "^3.2.0" @@ -16,6 +20,8 @@ "@asamuzakjp/dom-selector@^7.0.2": version "7.1.1" + resolved "https://registry.yarnpkg.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz#01880086bb2490098f167beb58555da1a6c9adbd" + integrity sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ== dependencies: "@asamuzakjp/generational-cache" "^1.0.1" "@asamuzakjp/nwsapi" "^2.3.9" @@ -25,9 +31,13 @@ "@asamuzakjp/generational-cache@^1.0.1": version "1.0.1" + resolved "https://registry.yarnpkg.com/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz#3d0bf6be4fc059851390a7070720c6007af793ec" + integrity sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg== "@asamuzakjp/nwsapi@^2.3.9": version "2.3.9" + resolved "https://registry.yarnpkg.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz#ad5549322dfe9d153d4b4dd6f7ff2ae234b06e24" + integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q== "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" @@ -43,7 +53,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz" integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== -"@babel/core@^7.0.0", "@babel/core@^7.24.4": +"@babel/core@^7.24.4": version "7.29.0" resolved "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz" integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== @@ -175,29 +185,43 @@ "@bramus/specificity@^2.4.2": version "2.4.2" + resolved "https://registry.yarnpkg.com/@bramus/specificity/-/specificity-2.4.2.tgz#aa8db8eb173fdee7324f82284833106adeecc648" + integrity sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw== dependencies: css-tree "^3.0.0" -"@csstools/color-helpers@^6.0.2": - version "6.0.2" +"@csstools/color-helpers@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-6.1.0.tgz#18903248db1da28285e8458793b038f60d738cf1" + integrity sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg== -"@csstools/css-calc@^3.2.0", "@csstools/css-calc@^3.2.1": - version "3.2.1" +"@csstools/css-calc@^3.2.0", "@csstools/css-calc@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.3.0.tgz#33cc4bdbab8edf1b6e3ab8c1eba849c41a324f1a" + integrity sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ== "@csstools/css-color-parser@^4.1.0": - version "4.1.1" + version "4.1.10" + resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz#56f3f5bbed663d7631616a8a8bf34f23b4f9ca77" + integrity sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw== dependencies: - "@csstools/color-helpers" "^6.0.2" - "@csstools/css-calc" "^3.2.1" + "@csstools/color-helpers" "^6.1.0" + "@csstools/css-calc" "^3.3.0" "@csstools/css-parser-algorithms@^4.0.0": version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164" + integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w== "@csstools/css-syntax-patches-for-csstree@^1.1.1": - version "1.1.4" + version "1.1.7" + resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz#67a65f4b26766c315ddfd2bdb89ac4c53fdd51e7" + integrity sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig== "@csstools/css-tokenizer@^4.0.0": version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f" + integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== "@emotion/babel-plugin@^11.13.5": version "11.13.5" @@ -244,7 +268,7 @@ resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== -"@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.14.0", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0", "@emotion/react@^11.7.0": +"@emotion/react@^11.14.0": version "11.14.0" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz" integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== @@ -274,7 +298,7 @@ resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== -"@emotion/styled@^11.14.1", "@emotion/styled@^11.3.0", "@emotion/styled@^11.6.0": +"@emotion/styled@^11.14.1": version "11.14.1" resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz" integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw== @@ -356,7 +380,7 @@ minimatch "^3.1.5" strip-json-comments "^3.1.1" -"@eslint/js@^9.39.4", "@eslint/js@9.39.4": +"@eslint/js@9.39.4", "@eslint/js@^9.39.4": version "9.39.4" resolved "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz" integrity sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw== @@ -376,6 +400,8 @@ "@exodus/bytes@^1.11.0", "@exodus/bytes@^1.15.0", "@exodus/bytes@^1.6.0": version "1.15.1" + resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.1.tgz#b13bc464ca162c17abf0837fb3a11aeab79e45d1" + integrity sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q== "@humanfs/core@^0.19.1": version "0.19.1" @@ -434,19 +460,33 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@monaco-editor/loader@^1.5.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.7.0.tgz#967aaa4601b19e913627688dfe8159d57549e793" + integrity sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA== + dependencies: + state-local "^1.0.6" + +"@monaco-editor/react@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.7.0.tgz#35a1ec01bfe729f38bfc025df7b7bac145602a60" + integrity sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA== + dependencies: + "@monaco-editor/loader" "^1.5.0" + "@mui/core-downloads-tracker@^7.3.9": version "7.3.9" resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.9.tgz" integrity sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw== -"@mui/icons-material@^7.0.0", "@mui/icons-material@^7.3.9": +"@mui/icons-material@^7.3.9": version "7.3.9" resolved "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.9.tgz" integrity sha512-BT+zPJXss8Hg/oEMRmHl17Q97bPACG4ufFSfGEdhiE96jOyR5Dz1ty7ZWt1fVGR0y1p+sSgEwQT/MNZQmoWDCw== dependencies: "@babel/runtime" "^7.28.6" -"@mui/material@^7.0.0", "@mui/material@^7.3.9": +"@mui/material@^7.3.9": version "7.3.9" resolved "https://registry.npmjs.org/@mui/material/-/material-7.3.9.tgz" integrity sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ== @@ -518,6 +558,13 @@ prop-types "^15.8.1" react-is "^19.2.3" +"@napi-rs/wasm-runtime@^1.1.1": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz#97e3d45d7424dc5da1d4e32f3bf3b292f6c1b44c" + integrity sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@oxc-project/runtime@0.115.0": version "0.115.0" resolved "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz" @@ -533,7 +580,7 @@ resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== -"@rjsf/core@^6.4.1", "@rjsf/core@^6.4.x": +"@rjsf/core@^6.4.1": version "6.4.1" resolved "https://registry.npmjs.org/@rjsf/core/-/core-6.4.1.tgz" integrity sha512-+QaiSgQnOuO6ghIsohH2u/QcylkN+Da2968a75g/i4oARYJRYVxXDm2u3JR5aXndpMb4t4jTFrYyG8cNIv6oEg== @@ -548,7 +595,7 @@ resolved "https://registry.npmjs.org/@rjsf/mui/-/mui-6.4.1.tgz" integrity sha512-1oz5INDZ7/rEwGaCn52ZMt+EqROeHNAI5kL6us2AMl7yKc3D6K1/X3H5quHdnr0dnh9Y8ivpOXroGy1ka4IBsA== -"@rjsf/utils@^6.4.1", "@rjsf/utils@^6.4.x": +"@rjsf/utils@^6.4.1": version "6.4.1" resolved "https://registry.npmjs.org/@rjsf/utils/-/utils-6.4.1.tgz" integrity sha512-5NL3jwt3rIS5/WRTrKt++y40FS/ScKGVwYJ3jIrHSQHSwBdLnd4cHf2zcnA97L1Klj8I6tvS/ugh+blf/Diwuw== @@ -570,11 +617,83 @@ lodash "^4.17.23" lodash-es "^4.17.23" +"@rolldown/binding-android-arm64@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz#4bbd28868564948c2bf04b3ca117a6828f95626c" + integrity sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg== + "@rolldown/binding-darwin-arm64@1.0.0-rc.9": version "1.0.0-rc.9" resolved "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz" integrity sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ== +"@rolldown/binding-darwin-x64@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz#747b698878b6f44d817f87e9e3cb197b16076d2a" + integrity sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg== + +"@rolldown/binding-freebsd-x64@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz#35c29d7c83aa75429c74d7d1ee9c7d3e61f4552c" + integrity sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz#36d2bcbcf07f17f18fb2df727a62f16e5295c816" + integrity sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz#5b03c11f2b661a275f2d7628e4f456783e1b9f63" + integrity sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg== + +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz#d3cbd1b1760d34b5789af89f4bcc09a1446d3eb5" + integrity sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg== + +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz#8e971e7f066b2c0876e20c9f6174d645f31efb84" + integrity sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w== + +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz#e7283523780741f07a4441c7c8af5b2550faadf2" + integrity sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA== + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz#da2302e079bb5f3a98edf75608621e94f1fb550e" + integrity sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz#3f27a620d56b93644fd1b6fad58fc2dbe93d5d71" + integrity sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA== + +"@rolldown/binding-openharmony-arm64@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz#9c307777157d029aaf8db1a09221b9275dbe5547" + integrity sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog== + +"@rolldown/binding-wasm32-wasi@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz#c3a82bef0ddd644efa74c050c26223f29f55039c" + integrity sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g== + dependencies: + "@napi-rs/wasm-runtime" "^1.1.1" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz#27e23cbd53b7095d0b66191ef999327b4684a6cf" + integrity sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA== + +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.9": + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz#96046309142b398c9c2a9a0a052e7355535e69c8" + integrity sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ== + "@rolldown/pluginutils@1.0.0-rc.7": version "1.0.0-rc.7" resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz" @@ -587,8 +706,10 @@ "@standard-schema/spec@^1.1.0": version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== -"@testing-library/dom@^10.0.0", "@testing-library/dom@^10.4.1": +"@testing-library/dom@^10.4.1": version "10.4.1" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz" integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== @@ -621,6 +742,13 @@ dependencies: "@babel/runtime" "^7.12.5" +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz" @@ -628,6 +756,8 @@ "@types/chai@^5.2.2": version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== dependencies: "@types/deep-eql" "*" assertion-error "^2.0.1" @@ -641,6 +771,8 @@ "@types/deep-eql@*": version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== "@types/estree-jsx@^1.0.0": version "1.0.5" @@ -678,7 +810,7 @@ resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== -"@types/node@^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node@^20.19.0 || >=22.12.0", "@types/node@^24.12.0": +"@types/node@^24.12.0": version "24.12.0" resolved "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz" integrity sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ== @@ -695,7 +827,7 @@ resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== -"@types/react-dom@^18.0.0 || ^19.0.0", "@types/react-dom@^19.2.3": +"@types/react-dom@^19.2.3": version "19.2.3" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz" integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== @@ -705,13 +837,18 @@ resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*", "@types/react@^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react@^18.0.0 || ^19.0.0", "@types/react@^19.2.0", "@types/react@^19.2.14", "@types/react@>=18": +"@types/react@^19.2.14": version "19.2.14" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz" integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== dependencies: csstype "^3.2.2" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz" @@ -736,7 +873,7 @@ natural-compare "^1.4.0" ts-api-utils "^2.4.0" -"@typescript-eslint/parser@^8.57.1", "@typescript-eslint/parser@8.57.1": +"@typescript-eslint/parser@8.57.1": version "8.57.1" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz" integrity sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw== @@ -764,7 +901,7 @@ "@typescript-eslint/types" "8.57.1" "@typescript-eslint/visitor-keys" "8.57.1" -"@typescript-eslint/tsconfig-utils@^8.57.1", "@typescript-eslint/tsconfig-utils@8.57.1": +"@typescript-eslint/tsconfig-utils@8.57.1", "@typescript-eslint/tsconfig-utils@^8.57.1": version "8.57.1" resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz" integrity sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg== @@ -780,7 +917,7 @@ debug "^4.4.3" ts-api-utils "^2.4.0" -"@typescript-eslint/types@^8.57.1", "@typescript-eslint/types@8.57.1": +"@typescript-eslint/types@8.57.1", "@typescript-eslint/types@^8.57.1": version "8.57.1" resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz" integrity sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ== @@ -832,6 +969,8 @@ "@vitest/expect@4.1.0": version "4.1.0" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.0.tgz#2f6c7d19cfbe778bfb42d73f77663ec22163fcbb" + integrity sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA== dependencies: "@standard-schema/spec" "^1.1.0" "@types/chai" "^5.2.2" @@ -842,6 +981,8 @@ "@vitest/mocker@4.1.0": version "4.1.0" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.0.tgz#2aabf6079ad472f89a212d322f7d5da7ad628a0e" + integrity sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw== dependencies: "@vitest/spy" "4.1.0" estree-walker "^3.0.3" @@ -849,17 +990,23 @@ "@vitest/pretty-format@4.1.0": version "4.1.0" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.0.tgz#b6ccf2868130a647d24af3696d58c09a95eb83c1" + integrity sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A== dependencies: tinyrainbow "^3.0.3" "@vitest/runner@4.1.0": version "4.1.0" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.0.tgz#4e12c0f086eb3a4ae3fae84d9d68b22d02942cbf" + integrity sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ== dependencies: "@vitest/utils" "4.1.0" pathe "^2.0.3" "@vitest/snapshot@4.1.0": version "4.1.0" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.0.tgz#67372979da692ccf5dfa4a3bb603f683c0640202" + integrity sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg== dependencies: "@vitest/pretty-format" "4.1.0" "@vitest/utils" "4.1.0" @@ -868,9 +1015,13 @@ "@vitest/spy@4.1.0": version "4.1.0" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.0.tgz#b9143a63cca83de34ac1777c733f8561b73fa9ba" + integrity sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw== "@vitest/utils@4.1.0": version "4.1.0" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.0.tgz#2baf26a2a28c4aabe336315dc59722df2372c38d" + integrity sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw== dependencies: "@vitest/pretty-format" "4.1.0" convert-source-map "^2.0.0" @@ -888,7 +1039,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0: +acorn@^8.15.0: version "8.16.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== @@ -942,7 +1093,7 @@ argparse@^2.0.1: resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-query@^5.0.0, aria-query@5.3.0: +aria-query@5.3.0, aria-query@^5.0.0: version "5.3.0" resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz" integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== @@ -951,6 +1102,8 @@ aria-query@^5.0.0, aria-query@5.3.0: assertion-error@^2.0.1: version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== babel-plugin-macros@^3.1.0: version "3.1.0" @@ -983,6 +1136,8 @@ baseline-browser-mapping@^2.9.0: bidi-js@^1.0.3: version "1.0.3" + resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2" + integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== dependencies: require-from-string "^2.0.2" @@ -1001,7 +1156,7 @@ brace-expansion@^5.0.2: dependencies: balanced-match "^4.0.2" -browserslist@^4.24.0, "browserslist@>= 4.21.0": +browserslist@^4.24.0: version "4.28.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz" integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== @@ -1029,6 +1184,8 @@ ccount@^2.0.0: chai@^6.2.2: version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== chalk@^4.0.0: version "4.1.2" @@ -1122,12 +1279,16 @@ cross-spawn@^7.0.6: css-tree@^3.0.0, css-tree@^3.2.1: version "3.2.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.2.1.tgz#86cac7011561272b30e6b1e042ba6ce047aa7518" + integrity sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA== dependencies: mdn-data "2.27.1" source-map-js "^1.2.1" css.escape@^1.5.1: version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== csstype@^3.0.2, csstype@^3.2.2, csstype@^3.2.3: version "3.2.3" @@ -1136,6 +1297,8 @@ csstype@^3.0.2, csstype@^3.2.2, csstype@^3.2.3: data-urls@^7.0.0: version "7.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-7.0.0.tgz#6dce8b63226a1ecfdd907ce18a8ccfb1eee506d3" + integrity sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA== dependencies: whatwg-mimetype "^5.0.0" whatwg-url "^16.0.0" @@ -1149,6 +1312,8 @@ debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: decimal.js@^10.6.0: version "10.6.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== decode-named-character-reference@^1.0.0: version "1.3.0" @@ -1186,6 +1351,8 @@ dom-accessibility-api@^0.5.9: dom-accessibility-api@^0.6.3: version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== dom-helpers@^5.0.1: version "5.2.1" @@ -1195,6 +1362,13 @@ dom-helpers@^5.0.1: "@babel/runtime" "^7.8.7" csstype "^3.0.2" +dompurify@3.4.8: + version "3.4.8" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.8.tgz#6c54f8c207160e7f83fcb7f4fd05a82ac36b1cdc" + integrity sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + electron-to-chromium@^1.5.263: version "1.5.313" resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz" @@ -1202,6 +1376,8 @@ electron-to-chromium@^1.5.263: entities@^8.0.0: version "8.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-8.0.0.tgz#c1df5fe3602429747fa233d0dd26f142f0ce4743" + integrity sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA== error-ex@^1.3.1: version "1.3.4" @@ -1211,7 +1387,9 @@ error-ex@^1.3.1: is-arrayish "^0.2.1" es-module-lexer@^2.0.0: - version "2.1.0" + version "2.3.1" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz#5bf2df06999dbbe5f006a5f46a11fb9f5b7b391b" + integrity sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA== escalade@^3.2.0: version "3.2.0" @@ -1267,7 +1445,7 @@ eslint-visitor-keys@^5.0.0: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -"eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0 || ^10.0.0", "eslint@^9 || ^10", eslint@^9.39.4, eslint@>=7.0.0: +eslint@^9.39.4: version "9.39.4" resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz" integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ== @@ -1342,6 +1520,8 @@ estree-util-is-identifier-name@^3.0.0: estree-walker@^3.0.3: version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== dependencies: "@types/estree" "^1.0.0" @@ -1351,7 +1531,9 @@ esutils@^2.0.2: integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== expect-type@^1.3.0: - version "1.3.0" + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== extend@^3.0.0: version "3.0.2" @@ -1509,6 +1691,8 @@ hoist-non-react-statics@^3.3.1: html-encoding-sniffer@^6.0.0: version "6.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz#f8d9390b3b348b50d4f61c16dd2ef5c05980a882" + integrity sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg== dependencies: "@exodus/bytes" "^1.6.0" @@ -1542,6 +1726,8 @@ imurmurhash@^0.1.4: indent-string@^4.0.0: version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== inline-style-parser@0.2.7: version "0.2.7" @@ -1602,6 +1788,8 @@ is-plain-obj@^4.0.0: is-potential-custom-element-name@^1.0.1: version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== isexe@^2.0.0: version "2.0.0" @@ -1620,7 +1808,7 @@ js-yaml@^4.1.1: dependencies: argparse "^2.0.1" -jsdom@*, jsdom@^29.0.0: +jsdom@^29.0.0: version "29.0.0" resolved "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz" integrity sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ== @@ -1702,11 +1890,61 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lightningcss-android-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" + integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + lightningcss-darwin-arm64@1.32.0: version "1.32.0" resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz" integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== +lightningcss-darwin-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e" + integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + +lightningcss-freebsd-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575" + integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + +lightningcss-linux-arm-gnueabihf@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d" + integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + +lightningcss-linux-arm64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335" + integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + +lightningcss-linux-arm64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133" + integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + +lightningcss-win32-arm64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38" + integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + +lightningcss-win32-x64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a" + integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + lightningcss@^1.32.0: version "1.32.0" resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz" @@ -1766,7 +2004,9 @@ loose-envify@^1.4.0: js-tokens "^3.0.0 || ^4.0.0" lru-cache@^11.2.7: - version "11.5.0" + version "11.5.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760" + integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g== lru-cache@^5.1.1: version "5.1.1" @@ -1782,6 +2022,8 @@ lz-string@^1.5.0: magic-string@^0.30.21: version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" @@ -1790,6 +2032,11 @@ markdown-to-jsx@^8.0.0: resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz" integrity sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA== +marked@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-14.0.0.tgz#79a1477358a59e0660276f8fec76de2c33f35d83" + integrity sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ== + mdast-util-from-markdown@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz" @@ -1897,6 +2144,8 @@ mdast-util-to-string@^4.0.0: mdn-data@2.27.1: version "2.27.1" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.27.1.tgz#e37b9c50880b75366c4d40ac63d9bbcacdb61f0e" + integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ== micromark-core-commonmark@^2.0.0: version "2.0.3" @@ -2094,6 +2343,8 @@ micromark@^4.0.0: min-indent@^1.0.0: version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== minimatch@^10.2.2: version "10.2.4" @@ -2109,6 +2360,14 @@ minimatch@^3.1.5: dependencies: brace-expansion "^1.1.7" +monaco-editor@^0.56.0: + version "0.56.0" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.56.0.tgz#e85ac00b7ab7092ca1d077894504919af5073cf0" + integrity sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg== + dependencies: + dompurify "3.4.8" + marked "14.0.0" + ms@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" @@ -2135,7 +2394,9 @@ object-assign@^4.1.1: integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== obug@^2.1.1: - version "2.1.1" + version "2.1.4" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.4.tgz#9090d8a548a522517915d2aa6aae907197ac6cf8" + integrity sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA== optionator@^0.9.3: version "0.9.4" @@ -2195,6 +2456,8 @@ parse-json@^5.0.0: parse5@^8.0.0: version "8.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-8.0.1.tgz#f43bcd2cd683efe084075333e9ce0da7d06da31e" + integrity sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw== dependencies: entities "^8.0.0" @@ -2220,13 +2483,15 @@ path-type@^4.0.0: pathe@^2.0.3: version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -picocolors@^1.1.1, picocolors@1.1.1: +picocolors@1.1.1, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -"picomatch@^3 || ^4", picomatch@^4.0.3: +picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -2278,7 +2543,7 @@ punycode@^2.1.0, punycode@^2.3.1: resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -"react-dom@^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18.0.0 || ^19.0.0", react-dom@^19.2.4, react-dom@>=16.6.0, react-dom@>=18: +react-dom@^19.2.4: version "19.2.4" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz" integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== @@ -2347,13 +2612,15 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" -"react@^17.0.0 || ^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0", react@^19.2.4, "react@>= 0.14.0", react@>=16.6.0, react@>=16.8.0, react@>=18: +react@^19.2.4: version "19.2.4" resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz" integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== redent@^3.0.0: version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== dependencies: indent-string "^4.0.0" strip-indent "^3.0.0" @@ -2424,6 +2691,8 @@ rolldown@1.0.0-rc.9: saxes@^6.0.0: version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== dependencies: xmlchars "^2.2.0" @@ -2461,6 +2730,8 @@ shebang-regex@^3.0.0: siginfo@^2.0.0: version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== source-map-js@^1.2.1: version "1.2.1" @@ -2479,9 +2750,18 @@ space-separated-tokens@^2.0.0: stackback@0.0.2: version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +state-local@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5" + integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== std-env@^4.0.0-rc.1: - version "4.1.0" + version "4.2.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== stringify-entities@^4.0.0: version "4.0.4" @@ -2493,6 +2773,8 @@ stringify-entities@^4.0.0: strip-indent@^3.0.0: version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== dependencies: min-indent "^1.0.0" @@ -2534,12 +2816,18 @@ supports-preserve-symlinks-flag@^1.0.0: symbol-tree@^3.2.4: version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== tinybench@^2.9.0: version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== tinyexec@^1.0.2: - version "1.2.2" + version "1.3.0" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.3.0.tgz#aacc1dbb1d4e93e6ad8dd64944e09f9ad147a474" + integrity sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ== tinyglobby@^0.2.15: version "0.2.15" @@ -2550,23 +2838,33 @@ tinyglobby@^0.2.15: picomatch "^4.0.3" tinyrainbow@^3.0.3: - version "3.1.0" + version "3.1.1" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== -tldts-core@^7.0.32: - version "7.0.32" +tldts-core@^7.4.10: + version "7.4.10" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.4.10.tgz#33b31839bc37e8283f97d8b5741aa665691701f0" + integrity sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw== tldts@^7.0.5: - version "7.0.32" + version "7.4.10" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.4.10.tgz#c1f2804bb028062ae5345cb0adb129cd50f94471" + integrity sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog== dependencies: - tldts-core "^7.0.32" + tldts-core "^7.4.10" tough-cookie@^6.0.1: - version "6.0.1" + version "6.0.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.2.tgz#7b1f22fcf2daf06c4ff9d53ec1845f44c6627062" + integrity sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA== dependencies: tldts "^7.0.5" tr46@^6.0.0: version "6.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-6.0.0.tgz#f5a1ae546a0adb32a277a2278d0d17fa2f9093e6" + integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw== dependencies: punycode "^2.3.1" @@ -2585,6 +2883,11 @@ ts-api-utils@^2.4.0: resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz" integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" @@ -2602,7 +2905,7 @@ typescript-eslint@^8.56.1: "@typescript-eslint/typescript-estree" "8.57.1" "@typescript-eslint/utils" "8.57.1" -typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@~5.9.3: +typescript@~5.9.3: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -2613,7 +2916,9 @@ undici-types@~7.16.0: integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== undici@^7.24.3: - version "7.25.0" + version "7.29.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.29.0.tgz#ae0f6f62e06e057a9cbb7b2b5fde2bb74f791b8f" + integrity sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw== unified@^11.0.0: version "11.0.5" @@ -2739,17 +3044,25 @@ vitest@^4.1.0: w3c-xmlserializer@^5.0.0: version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== dependencies: xml-name-validator "^5.0.0" webidl-conversions@^8.0.1: version "8.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686" + integrity sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ== whatwg-mimetype@^5.0.0: version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz#d8232895dbd527ceaee74efd4162008fb8a8cf48" + integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw== whatwg-url@^16.0.0, whatwg-url@^16.0.1: version "16.0.1" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-16.0.1.tgz#047f7f4bd36ef76b7198c172d1b1cebc66f764dd" + integrity sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw== dependencies: "@exodus/bytes" "^1.11.0" tr46 "^6.0.0" @@ -2764,6 +3077,8 @@ which@^2.0.1: why-is-node-running@^2.3.0: version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== dependencies: siginfo "^2.0.0" stackback "0.0.2" @@ -2775,9 +3090,13 @@ word-wrap@^1.2.5: xml-name-validator@^5.0.0: version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== xmlchars@^2.2.0: version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== yallist@^3.0.2: version "3.1.1" diff --git a/tests/test_formula_tokens.py b/tests/test_formula_tokens.py new file mode 100644 index 0000000..be02027 --- /dev/null +++ b/tests/test_formula_tokens.py @@ -0,0 +1,22 @@ +from uploader.app.lib.formula import expression_json_schema_extra, expression_tokens +from uploader.forms.structured_designation import StructuredDesignationForm + + +def test_expression_tokens_include_language_names() -> None: + labels = {token["label"] for token in expression_tokens()} + assert labels >= {"col", "sin", "cos", "str", "to_deg", "pi", "deg", "arcsec", "mag"} + + +def test_designation_form_marks_expression_widget() -> None: + schema = StructuredDesignationForm.model_json_schema() + properties = schema["properties"] + assert isinstance(properties, dict) + expression = properties["expression"] + assert isinstance(expression, dict) + extra = expression_json_schema_extra() + assert expression["ui:widget"] == extra["ui:widget"] + options = expression["ui:options"] + extra_options = extra["ui:options"] + assert isinstance(options, dict) + assert isinstance(extra_options, dict) + assert options["tokens"] == extra_options["tokens"] diff --git a/tests/test_formula_validate.py b/tests/test_formula_validate.py new file mode 100644 index 0000000..ccc365c --- /dev/null +++ b/tests/test_formula_validate.py @@ -0,0 +1,38 @@ +from dataclasses import asdict + +from uploader.app.lib.formula import validate_expression + + +def test_validate_expression_accepts_valid() -> None: + assert validate_expression('to_deg(col("RAJ2000"))') == [] + assert validate_expression("sin(pi) + 1.5 * deg") == [] + assert validate_expression("") == [] + + +def test_validate_expression_reports_python_syntax_errors() -> None: + unclosed_string = validate_expression('col("a)') + assert len(unclosed_string) == 1 + assert "string" in unclosed_string[0].message.lower() + assert unclosed_string[0].start_line == 1 + assert unclosed_string[0].start_column >= 1 + + unclosed_paren = validate_expression('col("a"') + assert len(unclosed_paren) == 1 + assert unclosed_paren[0].message == "'(' was never closed" + assert asdict(unclosed_paren[0])["start_column"] == 4 + + unmatched = validate_expression("1)") + assert len(unmatched) == 1 + assert unmatched[0].message == "unmatched ')'" + + +def test_validate_expression_reports_unknown_names() -> None: + diagnostics = validate_expression('foo + col("a")') + assert len(diagnostics) == 1 + assert "foo" in diagnostics[0].message + assert diagnostics[0].start_column == 1 + assert diagnostics[0].end_column == 4 + + +def test_validate_expression_ignores_names_inside_strings() -> None: + assert validate_expression('col("foo_bar")') == [] diff --git a/tests/test_server_integration.py b/tests/test_server_integration.py index f778ad6..461de89 100644 --- a/tests/test_server_integration.py +++ b/tests/test_server_integration.py @@ -150,3 +150,18 @@ def cancellable_handler(form: FakeTaskForm, emit: Callable[[report.Event], None] fake_entry = next(item for item in history_data if item["task_id"] == "fake-task-cancel") assert fake_entry["status"] == "cancelled" assert fake_entry["message"] == "Task was cancelled by user." + + +def test_validate_expression_endpoint() -> None: + client = TestClient(app) + ok = client.post("/api/expressions/validate", json={"expression": 'to_deg(col("RAJ2000"))'}) + assert ok.status_code == 200 + assert ok.json() == {"diagnostics": []} + + bad = client.post("/api/expressions/validate", json={"expression": 'col("a)'}) + assert bad.status_code == 200 + diagnostics = bad.json()["diagnostics"] + assert len(diagnostics) == 1 + assert "string" in diagnostics[0]["message"].lower() + assert diagnostics[0]["start_line"] == 1 + assert diagnostics[0]["start_column"] >= 1 diff --git a/uploader/app/lib/formula/__init__.py b/uploader/app/lib/formula/__init__.py index a1ef213..1ab0c6a 100644 --- a/uploader/app/lib/formula/__init__.py +++ b/uploader/app/lib/formula/__init__.py @@ -4,11 +4,17 @@ ExpressionEvaluationError, ExpressionSyntaxError, ) -from uploader.app.lib.formula.namespace import expression_syntax_help +from uploader.app.lib.formula.namespace import ( + expression_json_schema_extra, + expression_syntax_help, + expression_tokens, +) +from uploader.app.lib.formula.validate import ExpressionDiagnostic, validate_expression from uploader.app.lib.formula.values import TextValue, Value, column_quantity __all__ = [ "Expression", + "ExpressionDiagnostic", "ExpressionError", "ExpressionEvaluationError", "ExpressionSyntaxError", @@ -16,6 +22,9 @@ "Value", "column_quantity", "evaluate", + "expression_json_schema_extra", "expression_syntax_help", + "expression_tokens", "parse", + "validate_expression", ] diff --git a/uploader/app/lib/formula/core.py b/uploader/app/lib/formula/core.py index b47d315..b232a5c 100644 --- a/uploader/app/lib/formula/core.py +++ b/uploader/app/lib/formula/core.py @@ -1,4 +1,3 @@ -import ast from collections.abc import Mapping from dataclasses import dataclass from types import CodeType @@ -7,21 +6,11 @@ import astropy.units as u from uploader.app.lib.formula.errors import ExpressionEvaluationError, ExpressionSyntaxError -from uploader.app.lib.formula.namespace import COL_FUNCTION, FUNCTIONS, NAMED_CONSTANTS, build_namespace +from uploader.app.lib.formula.namespace import build_namespace +from uploader.app.lib.formula.validate import diagnose_expression from uploader.app.lib.formula.values import Value -def _column_from_call(node: ast.Call) -> str | None: - if not isinstance(node.func, ast.Name) or node.func.id != COL_FUNCTION: - return None - if node.keywords or len(node.args) != 1: - raise ValueError(f"{COL_FUNCTION}() takes exactly one string argument") - arg = node.args[0] - if not isinstance(arg, ast.Constant) or not isinstance(arg.value, str): - raise ValueError(f"{COL_FUNCTION}() argument must be a string literal") - return arg.value - - @final @dataclass(frozen=True) class Expression: @@ -30,14 +19,10 @@ class Expression: def parse(source: str) -> Expression: - try: - tree = ast.parse(source.strip(), mode="eval") - except SyntaxError as e: - raise ExpressionSyntaxError(str(e)) from e - try: - referenced_columns = frozenset(_ColumnCollector().collect(tree.body)) - except ValueError as e: - raise ExpressionSyntaxError(str(e)) from e + tree, referenced_columns, diagnostics = diagnose_expression(source.strip()) + if tree is None or diagnostics: + message = diagnostics[0].message if diagnostics else "invalid expression" + raise ExpressionSyntaxError(message) code = compile(tree, "", "eval") return Expression(referenced_columns=referenced_columns, code=code) @@ -47,34 +32,3 @@ def evaluate(expression: Expression, columns: Mapping[str, Value]) -> Value: return eval(expression.code, build_namespace(columns)) # noqa: S307 except (KeyError, NameError, TypeError, ValueError, ZeroDivisionError, u.UnitsError) as e: raise ExpressionEvaluationError(str(e)) from e - - -@final -class _ColumnCollector(ast.NodeVisitor): - def __init__(self) -> None: - self.columns: set[str] = set() - - def collect(self, node: ast.AST) -> set[str]: - self.visit(node) - return self.columns - - def visit_Call(self, node: ast.Call) -> None: - column = _column_from_call(node) - if column is not None: - self.columns.add(column) - return - if not isinstance(node.func, ast.Name): - raise ValueError("only simple function calls are allowed") - if node.func.id not in FUNCTIONS: - raise ValueError(f"unknown function: {node.func.id}") - if node.keywords: - raise ValueError("keyword arguments are not allowed") - for arg in node.args: - self.visit(arg) - - def visit_Name(self, node: ast.Name) -> None: - if node.id in NAMED_CONSTANTS or node.id in FUNCTIONS: - return - raise ValueError( - f"unknown name {node.id!r}; use {COL_FUNCTION}() for columns or a predefined constant/function", - ) diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index ce5cee8..4160d86 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -1,4 +1,6 @@ from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal, TypedDict, final import astropy.constants as const import astropy.units as u @@ -7,17 +9,63 @@ from uploader.app.lib.formula.values import TextValue, Value -COL_FUNCTION = "col" -NAMED_CONSTANTS: dict[str, u.Quantity] = { - "pi": np.pi * u.dimensionless_unscaled, - "c": const.c, - "deg": 1 * u.deg, - "rad": 1 * u.rad, - "arcmin": 1 * u.arcmin, - "arcsec": 1 * u.arcsec, - "mag": 1 * u.mag, -} +@final +@dataclass(frozen=True) +class FunctionDef: + name: str + detail: str + impl: object | None = None + placeholder: str = "${1:x}" + + @property + def insert(self) -> str: + return f"{self.name}({self.placeholder})" + + @property + def signature(self) -> str: + args = self.placeholder.replace("${1:", "").replace("}", "") + return f"{self.name}({args})" + + +@final +@dataclass(frozen=True) +class ConstantDef: + name: str + value: u.Quantity + detail: str + + @property + def insert(self) -> str: + return self.name + + +@final +@dataclass(frozen=True) +class OperatorDef: + name: str + detail: str + + +NAMED_CONSTANTS: tuple[ConstantDef, ...] = ( + ConstantDef("pi", np.pi * u.dimensionless_unscaled, "Pi"), + ConstantDef("c", const.c, "Speed of light"), + ConstantDef("deg", 1 * u.deg, "Degree"), + ConstantDef("rad", 1 * u.rad, "Radian"), + ConstantDef("arcmin", 1 * u.arcmin, "Arcminute"), + ConstantDef("arcsec", 1 * u.arcsec, "Arcsecond"), + ConstantDef("mag", 1 * u.mag, "Magnitude"), +) + + +OPERATORS: tuple[OperatorDef, ...] = ( + OperatorDef("+", "Addition; also concatenates strings"), + OperatorDef("-", "Subtraction"), + OperatorDef("*", "Multiplication"), + OperatorDef("/", "Division"), + OperatorDef("**", "Exponentiation"), + OperatorDef("%", 'Modulo; divisor must carry units (e.g. col("pa") % (180 * deg))'), +) def _scalar_to_str(value: float | int | np.number) -> str: @@ -53,38 +101,76 @@ def _to_deg(value: object) -> u.Quantity: raise TypeError(f"to_deg() expected angle or coordinate string, got {type(value).__name__}") -FUNCTIONS: dict[str, object] = { - "sin": np.sin, - "cos": np.cos, - "str": _formula_str, - "to_deg": _to_deg, -} +COL_FUNCTION = FunctionDef("col", "Rawdata column", placeholder='"${1:name}"') + +FUNCTIONS: tuple[FunctionDef, ...] = ( + FunctionDef("sin", "Sine (argument must be an angle)", np.sin), + FunctionDef("cos", "Cosine (argument must be an angle)", np.cos), + FunctionDef("str", "Convert to text", _formula_str), + FunctionDef("to_deg", "Convert to degrees; parses coordinate strings or angle quantities", _to_deg), +) + + +class ExpressionToken(TypedDict): + label: str + insert: str + kind: Literal["function", "constant"] + detail: str + + +def expression_tokens() -> list[ExpressionToken]: + tokens: list[ExpressionToken] = [] + for fn in (COL_FUNCTION, *FUNCTIONS): + tokens.append( + { + "label": fn.name, + "insert": fn.insert, + "kind": "function", + "detail": fn.detail, + }, + ) + for constant in NAMED_CONSTANTS: + tokens.append( + { + "label": constant.name, + "insert": constant.insert, + "kind": "constant", + "detail": constant.detail, + }, + ) + return tokens + + +def expression_json_schema_extra() -> dict[str, Any]: + return { + "ui:widget": "expression", + "ui:options": {"tokens": expression_tokens()}, + } def build_namespace(columns: Mapping[str, Value]) -> dict[str, object]: return { "__builtins__": {}, - COL_FUNCTION: lambda name: columns[name], - **NAMED_CONSTANTS, - **FUNCTIONS, + COL_FUNCTION.name: lambda name: columns[name], + **{constant.name: constant.value for constant in NAMED_CONSTANTS}, + **{fn.name: fn.impl for fn in FUNCTIONS if fn.impl is not None}, } def expression_syntax_help() -> str: - constants = ", ".join(f"`{name}`" for name in sorted(NAMED_CONSTANTS)) + constants = ", ".join(f"`{c.name}` ({c.detail})" for c in NAMED_CONSTANTS) + functions = ", ".join(f"`{fn.signature}` ({fn.detail})" for fn in (COL_FUNCTION, *FUNCTIONS)) + operators = ", ".join(f"`{op.name}` ({op.detail})" for op in OPERATORS) return f"""\ ## Expression syntax -Use `{COL_FUNCTION}("name")` to refer to rawdata columns (e.g. `col("a")`). Expressions are unit-aware and units are taken from column metadata. Mathematical operations: -- Operators: `+` `-` `*` `/` `**` `%` -- Functions: `sin(x)`, `cos(x)` (argument must be an angle), `str(x)`, `to_deg(x)` -- `to_deg(x)` parses coordinate strings or converts angle quantities to degrees +- Operators: {operators} +- Functions: {functions} - Numbers are dimensionless -- String literals and `+` concatenation are supported -- Modulo divisors must carry units (e.g. `col("pa") % (180 * deg)`) +- String literals are supported - Log columns (`mag`/`dex`) yield the bare exponent; multiply by the scale yourself Available constants: {constants}. @@ -95,6 +181,6 @@ def expression_syntax_help() -> str: - `1.5` - `180 * deg` - `"G"` - fills the column with a text "G" -- Copy another column: `{COL_FUNCTION}("ra")` -- Sexagesimal coordinates: `to_deg({COL_FUNCTION}("RAJ2000"))` -- Mathematical expression: `3 * 10 ** {COL_FUNCTION}("logd25") * arcsec`""" +- Copy another column: `col("ra")` +- Sexagesimal coordinates: `to_deg(col("RAJ2000"))` +- Mathematical expression: `3 * 10 ** col("logd25") * arcsec`""" diff --git a/uploader/app/lib/formula/validate.py b/uploader/app/lib/formula/validate.py new file mode 100644 index 0000000..58d8f6f --- /dev/null +++ b/uploader/app/lib/formula/validate.py @@ -0,0 +1,114 @@ +import ast +from dataclasses import dataclass +from typing import final + +from uploader.app.lib.formula.namespace import COL_FUNCTION, FUNCTIONS, NAMED_CONSTANTS + + +@final +@dataclass(frozen=True) +class ExpressionDiagnostic: + message: str + start_line: int + start_column: int + end_line: int + end_column: int + + +def _from_syntax_error(error: SyntaxError, source: str) -> ExpressionDiagnostic: + start_line = error.lineno if error.lineno and error.lineno > 0 else 1 + start_column = error.offset if error.offset and error.offset > 0 else 1 + end_line = error.end_lineno if error.end_lineno and error.end_lineno > 0 else start_line + end_column = error.end_offset if error.end_offset and error.end_offset > start_column else start_column + 1 + lines = source.splitlines() or [source] + if start_line == end_line and start_line <= len(lines): + end_column = min(max(end_column, start_column + 1), len(lines[start_line - 1]) + 1) + return ExpressionDiagnostic( + message=error.msg or "invalid syntax", + start_line=start_line, + start_column=start_column, + end_line=end_line, + end_column=end_column, + ) + + +def _from_node(node: ast.AST, message: str) -> ExpressionDiagnostic: + start_line = getattr(node, "lineno", 1) or 1 + start_column = (getattr(node, "col_offset", 0) or 0) + 1 + end_line = getattr(node, "end_lineno", None) or start_line + end_offset = getattr(node, "end_col_offset", None) + end_column = end_offset + 1 if end_offset is not None else start_column + 1 + if end_line == start_line and end_column <= start_column: + end_column = start_column + 1 + return ExpressionDiagnostic( + message=message, + start_line=start_line, + start_column=start_column, + end_line=end_line, + end_column=end_column, + ) + + +def _column_from_call(node: ast.Call) -> str | None: + if not isinstance(node.func, ast.Name) or node.func.id != COL_FUNCTION.name: + return None + if node.keywords or len(node.args) != 1: + raise ValueError(f"{COL_FUNCTION.name}() takes exactly one string argument") + arg = node.args[0] + if not isinstance(arg, ast.Constant) or not isinstance(arg.value, str): + raise ValueError(f"{COL_FUNCTION.name}() argument must be a string literal") + return arg.value + + +@final +class _DiagnosticCollector(ast.NodeVisitor): + def __init__(self) -> None: + self.columns: set[str] = set() + self.diagnostics: list[ExpressionDiagnostic] = [] + + def visit_Call(self, node: ast.Call) -> None: + try: + column = _column_from_call(node) + except ValueError as e: + self.diagnostics.append(_from_node(node, str(e))) + return + if column is not None: + self.columns.add(column) + return + if not isinstance(node.func, ast.Name): + self.diagnostics.append(_from_node(node.func, "only simple function calls are allowed")) + self.generic_visit(node) + return + if all(fn.name != node.func.id for fn in FUNCTIONS): + self.diagnostics.append(_from_node(node.func, f"unknown function: {node.func.id}")) + if node.keywords: + self.diagnostics.append(_from_node(node, "keyword arguments are not allowed")) + for arg in node.args: + self.visit(arg) + + def visit_Name(self, node: ast.Name) -> None: + if any(c.name == node.id for c in NAMED_CONSTANTS) or any(fn.name == node.id for fn in FUNCTIONS): + return + self.diagnostics.append( + _from_node( + node, + f"unknown name {node.id!r}; use {COL_FUNCTION.name}() for columns or a predefined constant/function", + ), + ) + + +def diagnose_expression(source: str) -> tuple[ast.AST | None, frozenset[str], list[ExpressionDiagnostic]]: + try: + tree = ast.parse(source, mode="eval") + except SyntaxError as e: + return None, frozenset(), [_from_syntax_error(e, source)] + collector = _DiagnosticCollector() + collector.visit(tree.body) + return tree, frozenset(collector.columns), collector.diagnostics + + +def validate_expression(source: str) -> list[ExpressionDiagnostic]: + if not source.strip(): + return [] + _, _, diagnostics = diagnose_expression(source) + return diagnostics diff --git a/uploader/cli.py b/uploader/cli.py index be3675a..bf24feb 100644 --- a/uploader/cli.py +++ b/uploader/cli.py @@ -2,6 +2,7 @@ import importlib.metadata import json import pathlib +from dataclasses import asdict from typing import Any import click @@ -9,8 +10,9 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError +from uploader.app.lib.formula import validate_expression from uploader.history import load_history from uploader.task_registry import register_all_tasks from uploader.tasks import TASKS, cancel_run, get_run, start_task @@ -49,6 +51,15 @@ def list_history() -> list[dict[str, object]]: return [entry.model_dump() for entry in load_history()] +class ValidateExpressionRequest(BaseModel): + expression: str + + +@app.post("/api/expressions/validate") +def validate_expression_endpoint(body: ValidateExpressionRequest) -> dict[str, object]: + return {"diagnostics": [asdict(item) for item in validate_expression(body.expression)]} + + @app.get("/api/tasks/{task_id}/schema") def task_schema(task_id: str) -> dict[str, object]: if task_id not in TASKS: diff --git a/uploader/forms/structured_catalog.py b/uploader/forms/structured_catalog.py index a9958cd..4eecba4 100644 --- a/uploader/forms/structured_catalog.py +++ b/uploader/forms/structured_catalog.py @@ -9,7 +9,7 @@ from uploader.app import log from uploader.app.catalogs import fetch_catalogs from uploader.app.endpoints import db_dsn_map, env_map -from uploader.app.lib.formula import expression_syntax_help +from uploader.app.lib.formula import expression_json_schema_extra, expression_syntax_help from uploader.app.storage import PgStorage from uploader.app.structured.generic import upload_catalog_columns from uploader.clients.gen.client import adminapi @@ -63,15 +63,16 @@ def build_catalog_form(schema: CatalogSchema) -> type[BaseModel]: if field.name in _RESERVED_FORM_FIELDS: raise RuntimeError(f"Catalog {schema.catalog!r} field {field.name!r} conflicts with reserved form field") description = _field_description(field) + extra = expression_json_schema_extra() if _field_required(field): field_definitions[field.name] = ( str, - Field(..., title=field.name, description=description), + Field(..., title=field.name, description=description, json_schema_extra=extra), ) else: field_definitions[field.name] = ( str, - Field(default="", title=field.name, description=description), + Field(default="", title=field.name, description=description, json_schema_extra=extra), ) field_definitions["write"] = ( bool, diff --git a/uploader/forms/structured_designation.py b/uploader/forms/structured_designation.py index 8c21814..94f879a 100644 --- a/uploader/forms/structured_designation.py +++ b/uploader/forms/structured_designation.py @@ -7,6 +7,7 @@ import uploader.app.report as report from uploader.app.endpoints import db_dsn_map, env_map +from uploader.app.lib.formula import expression_json_schema_extra from uploader.app.storage import PgStorage from uploader.app.structured.designations import upload_designations as run_upload_designations from uploader.clients.gen.client import adminapi @@ -37,6 +38,7 @@ class StructuredDesignationForm(BaseModel): "Expression yielding the object designation per row. " 'Examples: col("designation"), col("weird name"), col("prefix") + " " + col("number").' ), + json_schema_extra=expression_json_schema_extra(), ) write: bool = Field( default=False,