diff --git a/backend/funix/__init__.py b/backend/funix/__init__.py index 3426372..00ee22b 100644 --- a/backend/funix/__init__.py +++ b/backend/funix/__init__.py @@ -58,6 +58,7 @@ new_funix_type = hint.new_funix_type set_app_secret = secret.set_app_secret generate_redirect_link = widget.generate_redirect_link +generate_redirect_button = widget.generate_redirect_button mime_encode = mime_encoded # ---- Util ---- diff --git a/backend/funix/__main__.py b/backend/funix/__main__.py index 0a914a2..13bd47b 100644 --- a/backend/funix/__main__.py +++ b/backend/funix/__main__.py @@ -81,7 +81,7 @@ def cli_main(): This function is called when you run `python -m funix` or `funix` from the command line. """ - plac.call(main, version="Funix 0.6.3") + plac.call(main, version="Funix 0.6.4") if __name__ == "__main__": diff --git a/backend/funix/app/__init__.py b/backend/funix/app/__init__.py index 5926d04..30694f7 100644 --- a/backend/funix/app/__init__.py +++ b/backend/funix/app/__init__.py @@ -12,11 +12,12 @@ from urllib.parse import urlparse from uuid import uuid4 -from flask import Flask, Response, abort, request +from flask import Flask, Response, abort, request, send_file from flask_sock import Sock from sqlalchemy import create_engine, text from sqlalchemy.pool import SingletonThreadPool +from funix.config import banned_function_name_and_path from funix.config.switch import GlobalSwitchOption from funix.decorator.file import get_file_info from funix.decorator.lists import get_uuid_with_name @@ -367,3 +368,19 @@ def enable_funix_host_checker(regex: str): def funix_host_check(): if len(re.findall(regex_string, request.host)) == 0: abort(403) + + +def serve_file(route_or_path: str, path: str = None): + if route_or_path and path: + if route_or_path in banned_function_name_and_path: + raise ValueError(f"{route_or_path} is not allowed, banned names: {banned_function_name_and_path}") + @app.route(route_or_path) + def __serve_file(): + return send_file(path) + else: + safe_route = os.path.basename(route_or_path) + if safe_route in banned_function_name_and_path: + raise ValueError(f"{safe_route} is not allowed, banned names: {banned_function_name_and_path}") + @app.route(safe_route) + def __serve_file(): + return send_file(route_or_path) diff --git a/backend/funix/config/__init__.py b/backend/funix/config/__init__.py index 0da0cbd..98028a0 100644 --- a/backend/funix/config/__init__.py +++ b/backend/funix/config/__init__.py @@ -27,7 +27,7 @@ A list, contains the upload widget names. """ -banned_function_name_and_path = ["list", "file", "static", "config", "param", "call"] +banned_function_name_and_path = ["list", "file", "static", "config", "param", "call", "update"] """ The banned function name and path. diff --git a/backend/funix/decorator/__init__.py b/backend/funix/decorator/__init__.py index 4de862e..3497c00 100644 --- a/backend/funix/decorator/__init__.py +++ b/backend/funix/decorator/__init__.py @@ -51,7 +51,7 @@ set_function_secret, ) from funix.decorator.theme import get_parsed_theme_fot_funix -from funix.decorator.widget import parse_argument_config, widget_parse +from funix.decorator.widget import get_uuid_by_callable, parse_argument_config, widget_parse from funix.hint import ( AcceptableWidgetsList, ArgumentConfigType, @@ -64,6 +64,7 @@ InputLayout, LabelsType, Markdown, + NextTo, OutputLayout, PreFillType, ReactiveType, @@ -246,6 +247,7 @@ def funix( order: Optional[int] = None, just_run: bool = False, class_line: Optional[int] = None, + next_to: Optional[Callable] = None, ): """ Decorator for functions to convert them to web apps @@ -301,6 +303,7 @@ def funix( order(int): the order of the function just_run(bool): just run the function, no input panel for the function class_line(int): the line number of the class + next_to(Callable): the function to be called next Returns: function: the decorated function @@ -489,6 +492,13 @@ def decorator(function: Callable[P, R]) -> Callable[P, R]: need_websocket = True setattr(function_signature, "_return_annotation", Markdown) + if next_to: + print(f"WARNING: the {function_name} function has next to, the output type will be ignored.") + setattr(function_signature, "_return_annotation", NextTo) + + if need_websocket and next_to: + raise ValueError("You cannot use next to with websocket mode.") + has_reactive_params = False reactive_config: dict[str, tuple[Callable, dict[str, str]]] = {} @@ -751,6 +761,7 @@ def decorated_function_param_getter(): param_widget_whitelist_callable, param_widget_example_callable, class_method_qualname if is_class_method else function.__qualname__, + next_to ) decorated_function_param_getter_name = f"{function_name}_param_getter" @@ -817,6 +828,7 @@ def wrapper(ws=None): secret_key, matplotlib_format, ws, + next_to, ) if result is not None: return result diff --git a/backend/funix/decorator/call.py b/backend/funix/decorator/call.py index f4dfdea..2386fd8 100644 --- a/backend/funix/decorator/call.py +++ b/backend/funix/decorator/call.py @@ -1,7 +1,7 @@ import sys from copy import deepcopy from functools import wraps -from inspect import isgeneratorfunction +from inspect import isgeneratorfunction, signature from json import dumps, loads from traceback import format_exc from typing import Any, Callable @@ -9,6 +9,8 @@ from uuid import uuid4 from flask import request, session +from funix.decorator.encoder import FunixJsonEncoder +from git import exc from requests import post from funix.app.websocket import StdoutToWebsocket @@ -19,6 +21,7 @@ from funix.decorator.pre_fill import get_pre_fill_metadata from funix.decorator.response import response_item_to_class from funix.decorator.secret import get_secret_by_id +from funix.decorator.widget import get_path_by_callable from funix.hint import PreFillEmpty, WrapperException from funix.session import set_global_variable @@ -104,6 +107,7 @@ def funix_call( secret_key: bool, matplotlib_format: str, ws=None, + next_to: Callable | None = None, ): for limiter in global_rate_limiters + limiters: limit_result = limiter.rate_limit() @@ -189,6 +193,24 @@ def wrapped_function(**wrapped_function_kwargs): function_call_result = call_function_get_frame( function, **wrapped_function_kwargs ) + if next_to: + try: + if isinstance(function_call_result[1], tuple): + arguments = signature(next_to).bind(*function_call_result[1]) + arguments.apply_defaults() + arguments = arguments.arguments + elif isinstance(function_call_result[1], dict): + arguments = signature(next_to).bind(**function_call_result[1]) + arguments.apply_defaults() + arguments = arguments.arguments + else: + print("Warning: You must return a tuple or a dict.") + arguments = function_call_result[1] + except: + print("Warning: Cannot bind arguments, it means the type of the arguments is not correct.") + arguments = function_call_result[1] + + return dumps({"args": arguments, "path": get_path_by_callable(next_to) }, cls=FunixJsonEncoder) return pre_anal_result(function_call_result[0], function_call_result[1]) except WrapperException as e: return { diff --git a/backend/funix/decorator/encoder.py b/backend/funix/decorator/encoder.py new file mode 100644 index 0000000..c988ff8 --- /dev/null +++ b/backend/funix/decorator/encoder.py @@ -0,0 +1,21 @@ +from json import JSONEncoder +from datetime import datetime + + +class FunixJsonEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, datetime): + return o.isoformat() + if hasattr(o, "__class__"): + clz = o.__class__ + if hasattr(clz, "__base__"): + base = clz.__base__ + # Check pydantic + try: + from pydantic import BaseModel + + if issubclass(base, BaseModel): + return o.model_dump(mode="json") + except ImportError: + return super().default(o) + return super().default(o) diff --git a/backend/funix/decorator/param.py b/backend/funix/decorator/param.py index cb992e0..60d63c7 100644 --- a/backend/funix/decorator/param.py +++ b/backend/funix/decorator/param.py @@ -4,7 +4,7 @@ from json import dumps, JSONEncoder from types import MappingProxyType from typing import Any, Callable -from datetime import datetime + from flask import Response @@ -16,12 +16,14 @@ get_type_dict, get_type_widget_prop, ) +from funix.decorator.widget import get_uuid_by_callable from funix.session import get_global_variable from funix.decorator.layout import ( pydantic_layout_dict, pydantic_name_dict, pydantic_widget_dict, ) +from funix.decorator.encoder import FunixJsonEncoder dataframe_parse_metadata: dict[str, dict[str, list[str]]] = {} """ @@ -34,25 +36,6 @@ """ -class FunixJsonEncoder(JSONEncoder): - def default(self, o): - if isinstance(o, datetime): - return o.isoformat() - if hasattr(o, "__class__"): - clz = o.__class__ - if hasattr(clz, "__base__"): - base = clz.__base__ - # Check pydantic - try: - from pydantic import BaseModel - - if issubclass(base, BaseModel): - return o.model_dump(mode="json") - except ImportError: - return super().default(o) - return super().default(o) - - def apply_decorated_params( item_props: dict, decorated_params: dict, param_name: str, function_name: str = None ) -> None: @@ -701,6 +684,7 @@ def get_param_for_funix( param_widget_whitelist_callable: dict, param_widget_example_callable: dict, qualname: str, + next_to: Callable | None, ): new_decorated_function = deepcopy(decorated_function) if pre_fill is not None: @@ -876,6 +860,8 @@ def get_param_for_funix( des = get_global_variable(session_description) new_decorated_function["description"] = des new_decorated_function["schema"]["description"] = des + if next_to: + new_decorated_function["nextToUuid"] = get_uuid_by_callable(next_to) return Response( dumps(new_decorated_function, cls=FunixJsonEncoder), mimetype="application/json" ) diff --git a/backend/funix/decorator/widget.py b/backend/funix/decorator/widget.py index 8891a0f..aca8462 100644 --- a/backend/funix/decorator/widget.py +++ b/backend/funix/decorator/widget.py @@ -7,6 +7,7 @@ from typing import Any, Callable, Union from funix.app import app +from funix.decorator.encoder import FunixJsonEncoder from funix.decorator.lists import ( get_class_method_funix, get_function_detail_by_uuid, @@ -345,7 +346,7 @@ def generate_redirect_link_core( arguments = signature(_function).bind(*args, **kwargs) arguments.apply_defaults() dict_args = arguments.arguments - json_plain = json.dumps(dict_args) + json_plain = json.dumps(dict_args, cls=FunixJsonEncoder) web_safe_args = base64.urlsafe_b64encode(json_plain.encode()).decode() return f"/{result['path']}?args={web_safe_args}", result @@ -368,3 +369,68 @@ def generate_redirect_link( """ url, result = generate_redirect_link_core(function, *args, **kwargs) return f"{result['name']}" + +def generate_redirect_button( + function: Callable, + auto_direct: bool = True, + use_raw: bool = False, + raw_args: dict = {}, + button_text: str = "Redirect", + *args, + **kwargs, +) -> dict: + """ + Generate a redirect button. + """ + function_qualname = function.__qualname__ + _function = function + if "." in function_qualname: + class_function = get_class_method_funix( + app_name=app.name, method_qualname=function_qualname + ) + if class_function: + _function = class_function + else: + raise ValueError(f"Function {function_qualname} not found.") + jump_uuid = get_function_uuid_with_id(app_name=app.name, _id=id(_function)) + if jump_uuid == "": + raise ValueError(f"Function {function_qualname} not found.") + result = get_function_detail_by_uuid(app_name=app.name, uuid=jump_uuid) + if use_raw: + json_plain = json.dumps(raw_args, cls=FunixJsonEncoder) + else: + arguments = signature(_function).bind(*args, **kwargs) + arguments.apply_defaults() + dict_args = arguments.arguments + json_plain = json.dumps(dict_args, cls=FunixJsonEncoder) + return {"path": result["path"], "args": json_plain, "text": button_text, "auto_direct": auto_direct} + +def get_function_by_callable(function: Callable) -> Callable | None: + function_qualname = function.__qualname__ + _function = function + if "." in function_qualname: + class_function = get_class_method_funix( + app_name=app.name, method_qualname=function_qualname + ) + if class_function: + _function = class_function + else: + return None + return _function + + +def get_uuid_by_callable(function: Callable) -> str | None: + _function = get_function_by_callable(function) + if _function is None: + return None + jump_uuid = get_function_uuid_with_id(app_name=app.name, _id=id(_function)) + if jump_uuid == "": + return None + return jump_uuid + + +def get_path_by_callable(function: Callable) -> str | None: + _function = get_function_by_callable(function) + if _function is None: + return None + return get_function_detail_by_uuid(app_name=app.name, uuid=get_function_uuid_with_id(app_name=app.name, _id=id(_function)))["path"] diff --git a/backend/funix/hint/__init__.py b/backend/funix/hint/__init__.py index c84badd..e3f9091 100644 --- a/backend/funix/hint/__init__.py +++ b/backend/funix/hint/__init__.py @@ -320,6 +320,13 @@ class CodeConfig(TypedDict): Support Markdown like "**bold**" and "*italic*" """ +_NextTo = NewType("NextTo", type(Optional[dict])) +NextTo: TypeAlias = _NextTo # type: ignore +""" +NextTo type. +For output. +""" + _HTML = NewType("HTML", type(Optional[str])) HTML: TypeAlias = _HTML # type: ignore """ @@ -390,6 +397,13 @@ class CodeConfig(TypedDict): For output. """ +_RedirectButton = NewType("RedirectButton", type(Optional[dict])) +RedirectButton: TypeAlias = _RedirectButton # type: ignore +""" +RedirectButton type. +For output. +""" + # ---- Built-in Input Widgets ---- IntInputBox: TypeAlias = builtin.IntInputBox IntSlider = builtin.IntSlider @@ -651,8 +665,8 @@ class LimitSource(Enum): # --- Chem --- -@new_funix_type(widget={"name": "ketcher"}) -class Ketcher(dict): + +class KetcherBase(dict): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.smiles = self.get("smiles") @@ -662,3 +676,27 @@ def __init__(self, *args, **kwargs): self.smarts = self.get("smarts") self.ket = self.get("ket") + +@new_funix_type(widget={"name": "ketcher"}) +class InternalKetcher(KetcherBase): + pass + + +@new_funix_type(widget={"name": "ketcher_popup"}) +class InternalKetcherPopup(KetcherBase): + pass + + +class KetcherMeta(type): + def __getitem__(self, pop_up: Literal["popup", Any] = None) -> type: + if pop_up == "popup": + return InternalKetcherPopup + else: + return InternalKetcher + + def __new__(cls, *args, **kwargs) -> type: + return InternalKetcher + + +class Ketcher(metaclass=KetcherMeta): + pass diff --git a/backend/funix/requirements.txt b/backend/funix/requirements.txt index b16b200..22e4aba 100644 --- a/backend/funix/requirements.txt +++ b/backend/funix/requirements.txt @@ -10,3 +10,4 @@ matplotlib>=3.4.3 pandas>=2.0.3 tornado>=6.4.2 pydantic>=2.10.6 +GitPython>=3.1.31 diff --git a/examples/marvin-test.py b/examples/marvin-test.py new file mode 100644 index 0000000..c74ddb1 --- /dev/null +++ b/examples/marvin-test.py @@ -0,0 +1,5 @@ +import os +from funix.hint import ChemStr, KetcherPopup + +def ketcher_demo(ketcher: KetcherPopup) -> ChemStr: + return ketcher.inchi diff --git a/examples/pydantic_test.py b/examples/pydantic_test.py index 89655a7..961f423 100644 --- a/examples/pydantic_test.py +++ b/examples/pydantic_test.py @@ -1,7 +1,8 @@ from pydantic import BaseModel, Field from typing import Optional from datetime import datetime -from funix import funix, pydantic_ui +from funix import funix, pydantic_ui, generate_redirect_link, generate_redirect_button +from funix.hint import RedirectButton @pydantic_ui( @@ -58,3 +59,12 @@ def where_is_the_datetime( ) ) -> str: return f"User: {t.user}, Reason: {t.reason}, Time: {datetime.now()}" + + +def list_test(a: SingleModel) -> str: + return f"User: {a.user}, Reason: {a.reason}, Time: {datetime.now()}" + +@funix(next_to=list_test) +def test_generate_redirect_button_2(): + return {"a": {"links": [{"url": "https://www.google.com", "display": True}]}} + diff --git a/frontend/config-overrides.js b/frontend/config-overrides.js index 9b701fe..350d9eb 100644 --- a/frontend/config-overrides.js +++ b/frontend/config-overrides.js @@ -44,7 +44,7 @@ module.exports = function override(config) { config.plugins.push( new SaveRemoteFilePlugin([ { - url: "https://code.jquery.com/jquery-3.7.1.min.js", + url: "https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js", filepath: "static/js/jquery-3.7.1.min.js", hash: false, }, diff --git a/frontend/src/components/Common/MarkdownDiv.tsx b/frontend/src/components/Common/MarkdownDiv.tsx index bd64d33..86809e0 100644 --- a/frontend/src/components/Common/MarkdownDiv.tsx +++ b/frontend/src/components/Common/MarkdownDiv.tsx @@ -200,13 +200,14 @@ export default function MarkdownDiv(props: MarkdownDivProps) { useEffect(() => { const fetchContent = async () => { - const content = await MarkdownAsync({ + const _content = await MarkdownAsync({ remarkPlugins: [remarkGfm, remarkMath], rehypePlugins: [ rehypeRaw, [rehypeKatex, { output: "mathml" }], [rehypeMermaid], ], + urlTransform: (value: string) => value, components: { p: (props) => isRenderInline ? ( @@ -364,7 +365,7 @@ export default function MarkdownDiv(props: MarkdownDivProps) { }, children: props.markdown, }); - setContent(content); + setContent(_content); }; fetchContent(); }, [props.markdown]); diff --git a/frontend/src/components/FunixFunction/ChemEditor.tsx b/frontend/src/components/FunixFunction/ChemEditor.tsx index aee64a1..fe4b61a 100644 --- a/frontend/src/components/FunixFunction/ChemEditor.tsx +++ b/frontend/src/components/FunixFunction/ChemEditor.tsx @@ -1,100 +1,124 @@ import { WidgetProps } from "@rjsf/utils"; -import { Editor } from "ketcher-react"; -import { StandaloneStructServiceProvider } from "ketcher-standalone"; -import "miew/dist/miew.min.css"; -import "ketcher-react/dist/index.css"; -import { Ketcher } from "ketcher-core"; -import React, { useCallback, useMemo, useState } from "react"; -import { Box } from "@mui/material"; +import React, { useState } from "react"; +import { + Box, + Button, + Card, + CardMedia, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from "@mui/material"; +import { KetcherEditor } from "./components"; +import { ChemEditorValue } from "./hooks"; +import renderSvg from "../../shared/indigo-render"; interface ChemEditorProps { widget: WidgetProps; + popup?: boolean; } -type ChemEditorValue = { - smiles: string; - inchi: string | null; - inchiAuxInfo: string | null; - inchiKey: string | null; - smarts: string; - ket: string; +const SimpleRenderBox = (props: { data: string | null }) => { + return ( + + {props.data ? ( + + ) : ( + No data + )} + + ); }; const ChemEditor: React.FC = React.memo((props) => { - const [_, setValue] = useState( - props.widget.value ?? props.widget.formData ?? null, + const [popUpKet, setPopUpKet] = useState(null); + const [popUpOpen, setPopUpOpen] = useState(false); + const [popUpKetTemp, setPopUpKetTemp] = useState( + null, ); - const syncSetValue = useCallback( - (newValue: ChemEditorValue) => { - setValue(newValue); - props.widget.onChange(newValue); - }, - [props.widget], - ); + const initialValue = props.widget.value ?? props.widget.formData ?? null; - const structServiceProvider = useMemo( - () => new StandaloneStructServiceProvider(), - [], - ); + const handleChange = (newValue: ChemEditorValue) => { + props.widget.onChange(newValue); + setPopUpKet(newValue); + }; - return ( - - {}} - onInit={(ketcher: Ketcher) => { - if (window.ketcher) { - ketcher.editor.clear(); - ketcher.editor.clearHistory(); - } - window.ketcher = ketcher; - if (props.widget.value || props.widget.formData) { - const value = props.widget.value ?? props.widget.formData; - if (value) { - ketcher.setMolecule(value.ket); - } - } - ketcher.editor.subscribe("change", async () => { - const ket = await ketcher.getKet(); - if (ketcher.containsReaction()) { - const smiles = await ketcher.getSmiles(); - const smarts = await ketcher.getSmarts(); - syncSetValue({ - ket, - smiles, - inchi: null, - inchiAuxInfo: null, - inchiKey: null, - smarts, - }); - } else { - const smiles = await ketcher.getSmiles(); - const inchi = await ketcher.getInchi(); - const inchiAuxInfo = await ketcher.getInchi(true); - const inchiKey = await ketcher.getInChIKey(); - const smarts = await ketcher.getSmarts(); - syncSetValue({ - ket, - smiles, - inchi, - inchiAuxInfo, - inchiKey, - smarts, - }); - } - }); + if (props.popup) { + return ( + - - ); + > + + + setPopUpOpen(false)} + fullWidth + maxWidth="xl" + > + Editor + + setPopUpKetTemp(value)} + height="80vh" + /> + + + + + + + + ); + } + + return ; }); export default ChemEditor; diff --git a/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx b/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx index afd55d6..0375e42 100644 --- a/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx +++ b/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx @@ -208,6 +208,11 @@ const ObjectFieldExtendedTemplate = (props: ObjectFieldTemplateProps) => { type: "config", element: , }; + } else if (elementContent.props.schema.widget === "ketcher_popup") { + return { + type: "config", + element: , + }; } else { return { type: "config", diff --git a/frontend/src/components/FunixFunction/OutputComponents/OutputChem.tsx b/frontend/src/components/FunixFunction/OutputComponents/OutputChem.tsx index 05a78bc..f4b14fc 100644 --- a/frontend/src/components/FunixFunction/OutputComponents/OutputChem.tsx +++ b/frontend/src/components/FunixFunction/OutputComponents/OutputChem.tsx @@ -1,5 +1,6 @@ import { Card, CardMedia } from "@mui/material"; import React, { useEffect, useState } from "react"; +import renderSvg from "../../../shared/indigo-render"; declare global { interface Window { @@ -17,12 +18,8 @@ const OutputChem = (props: { data: string }) => { const loadAndRender = async () => { setLoading(true); try { - const options = new window.indigo.MapStringString(); - options.set("render-output-format", "svg"); - const rawRender = window.indigo.render(props.data, options); - const img = new Image(); - img.src = `data:image/svg+xml;base64,${rawRender}`; - setSrc(img.src); + const svg = renderSvg(props.data); + setSrc(svg); } catch (e) { console.error(e); } diff --git a/frontend/src/components/FunixFunction/OutputComponents/OutputKetcher.tsx b/frontend/src/components/FunixFunction/OutputComponents/OutputKetcher.tsx deleted file mode 100644 index 05832d1..0000000 --- a/frontend/src/components/FunixFunction/OutputComponents/OutputKetcher.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import Box from "@mui/material/Box/Box"; -import { Ketcher } from "ketcher-core"; -import { Editor } from "ketcher-react"; -import { StandaloneStructServiceProvider } from "ketcher-standalone"; -import React from "react"; -import { useState } from "react"; - -declare global { - interface Window { - ketcher: Ketcher; - } -} - -const svgToText = (svg: Blob) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(new Error("Failed to read SVG file")); - reader.readAsText(svg); - }); -}; - -const OutputKetcher = (props: { data: string }) => { - const structServiceProvider = new StandaloneStructServiceProvider(); - const [svg, setSvg] = useState(""); - - return ( - <> -
- - {}} - onInit={async (ketcher: Ketcher) => { - const svg = await ketcher - .generateImage(props.data, { - outputFormat: "svg", - }) - .then((svg) => svgToText(svg)); - setSvg(svg as string); - }} - /> - - - ); -}; - -export default React.memo(OutputKetcher); diff --git a/frontend/src/components/FunixFunction/OutputComponents/OutputNextTo.tsx b/frontend/src/components/FunixFunction/OutputComponents/OutputNextTo.tsx new file mode 100644 index 0000000..df18cbb --- /dev/null +++ b/frontend/src/components/FunixFunction/OutputComponents/OutputNextTo.tsx @@ -0,0 +1,39 @@ +import { useAtom } from "jotai"; +import { callableDefaultAtom } from "../../../store"; +import { useNavigate } from "react-router-dom"; +import { useCallback, useEffect } from "react"; + +interface OutputNextToProps { + response: string; +} + +type NextToResponse = { + args: any; + path: string; +}; + +const OutputNextTo = (props: OutputNextToProps) => { + const response = JSON.parse(props.response) as NextToResponse; + const [callableDefault, setCallableDefault] = useAtom(callableDefaultAtom); + const navigate = useNavigate(); + + const toPath = useCallback(() => { + const newCallableDefault = { ...callableDefault }; + newCallableDefault[response.path] = response.args; + setCallableDefault(newCallableDefault); + navigate(response.path); + }, [ + callableDefault, + response.args, + response.path, + setCallableDefault, + navigate, + ]); + + useEffect(() => { + toPath(); + }, [toPath]); + return <>; +}; + +export default OutputNextTo; diff --git a/frontend/src/components/FunixFunction/OutputComponents/OutputRedirectButton.tsx b/frontend/src/components/FunixFunction/OutputComponents/OutputRedirectButton.tsx new file mode 100644 index 0000000..7e2df90 --- /dev/null +++ b/frontend/src/components/FunixFunction/OutputComponents/OutputRedirectButton.tsx @@ -0,0 +1,58 @@ +import { useAtom } from "jotai"; +import { callableDefaultAtom } from "../../../store"; +import { useNavigate } from "react-router-dom"; +import Button from "@mui/material/Button"; +import { useCallback, useEffect, useMemo } from "react"; + +type RedirectButtonResponse = { + path: string; + args: string; + text: string; + auto_direct: boolean; +}; + +interface OutputRedirectButtonProps { + response: RedirectButtonResponse; +} + +const OutputRedirectButton = (props: OutputRedirectButtonProps) => { + const [callableDefault, setCallableDefault] = useAtom(callableDefaultAtom); + const navigate = useNavigate(); + const { response } = props; + + const parsedArgs = useMemo(() => { + try { + return JSON.parse(response.args); + } catch (error) { + console.error("Failed to parse args:", error); + return {}; + } + }, [response.args]); + + const toPath = useCallback(() => { + const newCallableDefault = { ...callableDefault }; + newCallableDefault[response.path] = parsedArgs; + setCallableDefault(newCallableDefault); + navigate(response.path); + }, [ + callableDefault, + parsedArgs, + response.path, + setCallableDefault, + navigate, + ]); + + useEffect(() => { + if (response.auto_direct) { + toPath(); + } + }, [response.auto_direct, toPath]); + + return ( + + ); +}; + +export default OutputRedirectButton; diff --git a/frontend/src/components/FunixFunction/OutputPanel.tsx b/frontend/src/components/FunixFunction/OutputPanel.tsx index e160b8d..8d27766 100644 --- a/frontend/src/components/FunixFunction/OutputPanel.tsx +++ b/frontend/src/components/FunixFunction/OutputPanel.tsx @@ -41,7 +41,9 @@ import InnerHTML from "dangerously-set-html-content"; import { useNavigate } from "react-router-dom"; import OutputPlotMedias from "./OutputComponents/OutputPlotImage"; import _ from "lodash"; -import OutputKetcher from "./OutputComponents/OutputChem"; +import OutputChem from "./OutputComponents/OutputChem"; +import OutputRedirectButton from "./OutputComponents/OutputRedirectButton"; +import OutputNextTo from "./OutputComponents/OutputNextTo"; const guessJSON = (response: string | null): object | false => { if (response === null) return false; @@ -361,7 +363,9 @@ const OutputPanel = (props: { ); case "ChemStr": - return ; + return ; + case "RedirectButton": + return ; default: return ( ); } else { + if (returnType === "NextTo") { + return ; + } if ( returnType !== undefined && (Array.isArray(returnType) || typeof returnType === "string") diff --git a/frontend/src/components/FunixFunction/PyDanticPanel.tsx b/frontend/src/components/FunixFunction/PyDanticPanel.tsx index 25b6010..8549817 100644 --- a/frontend/src/components/FunixFunction/PyDanticPanel.tsx +++ b/frontend/src/components/FunixFunction/PyDanticPanel.tsx @@ -27,6 +27,7 @@ import DateTimePickerWidget from "./DateTimePickerWidget"; import SwitchWidget from "./SwitchWidget"; import TextExtendedWidget from "./TextExtendedWidget"; import ChemEditor from "./ChemEditor"; +import _ from "lodash"; interface PyDanticPanelProps { rootContent: ReactElement; @@ -157,49 +158,101 @@ const PyDanticPanel = (props: PyDanticPanelProps) => { } }; - const [pydanticForm, setPydanticForm] = useState(() => { - if ( - rootContentProps.formData !== undefined && - rootContentProps.formData !== null - ) { - return rootContentProps.formData; + const smartMergeWithDefaults = ( + defaultData: any, + formData: any, + schema: any, + ): any => { + if (Array.isArray(formData)) { + return formData.length > 0 ? formData : defaultData; } - if (rootContentSchema.default !== undefined) { - return rootContentSchema.default; + + if (typeof formData === "object" && formData !== null) { + if (Object.keys(formData).length === 0) { + return defaultData; + } + + const safeDefaultData = defaultData || {}; + const result = { ...safeDefaultData }; + const properties = schema.items?.properties || schema.properties || {}; + + Object.keys(properties).forEach((key) => { + const fieldSchema = properties[key]; + const formValue = formData[key]; + const defaultValue = safeDefaultData[key]; + + if (formValue !== undefined && formValue !== null) { + if ( + typeof formValue === "object" && + !Array.isArray(formValue) && + typeof defaultValue === "object" && + !Array.isArray(defaultValue) + ) { + result[key] = smartMergeWithDefaults( + defaultValue, + formValue, + fieldSchema, + ); + } else { + result[key] = formValue; + } + } else if (defaultValue !== undefined) { + result[key] = defaultValue; + } else if (fieldSchema.default !== undefined) { + result[key] = fieldSchema.default; + } + }); + + return result; } - return createDefaultValue(rootContentSchema); + + return formData !== undefined && formData !== null ? formData : defaultData; + }; + + const [pydanticForm, setPydanticForm] = useState(() => { + const formData = rootContentProps.formData; + const defaultData = + rootContentSchema.default !== undefined + ? rootContentSchema.default + : createDefaultValue(rootContentSchema); + + return smartMergeWithDefaults(defaultData, formData, rootContentSchema); }); useEffect(() => { - const shouldInitialize = - rootContentProps.formData === undefined || - rootContentProps.formData === null || - (Array.isArray(rootContentProps.formData) && - rootContentProps.formData.length === 0) || - (typeof rootContentProps.formData === "object" && - Object.keys(rootContentProps.formData).length === 0); - - if (shouldInitialize) { - const defaultValue = - rootContentSchema.default !== undefined - ? rootContentSchema.default - : createDefaultValue(rootContentSchema); - - setPydanticForm(defaultValue); + const formData = rootContentProps.formData; + const defaultData = + rootContentSchema.default !== undefined + ? rootContentSchema.default + : createDefaultValue(rootContentSchema); + + const mergedData = smartMergeWithDefaults( + defaultData, + formData, + rootContentSchema, + ); + setPydanticForm(mergedData); - if (rootContentProps.onChange) { - rootContentProps.onChange(defaultValue); - } + if (rootContentProps.onChange) { + rootContentProps.onChange(mergedData); } }, []); useEffect(() => { - if ( - rootContentProps.formData !== undefined && - rootContentProps.formData !== null && - JSON.stringify(rootContentProps.formData) !== JSON.stringify(pydanticForm) - ) { - setPydanticForm(rootContentProps.formData); + const formData = rootContentProps.formData; + const defaultData = + rootContentSchema.default !== undefined + ? rootContentSchema.default + : createDefaultValue(rootContentSchema); + + const mergedData = smartMergeWithDefaults( + defaultData, + formData, + rootContentSchema, + ); + + if (!_.isEqual(mergedData, pydanticForm)) { + setPydanticForm(mergedData); } }, [rootContentProps.formData]); @@ -436,6 +489,8 @@ const PyDanticPanel = (props: PyDanticPanelProps) => { ); } else if (fieldSchema.widget === "ketcher") { return ; + } else if (fieldSchema.widget === "ketcher_popup") { + return ; } else if ( fieldSchema.type === "boolean" || fieldSchema.widget === "switch" diff --git a/frontend/src/components/FunixFunction/components/KetcherEditor.tsx b/frontend/src/components/FunixFunction/components/KetcherEditor.tsx new file mode 100644 index 0000000..99a6761 --- /dev/null +++ b/frontend/src/components/FunixFunction/components/KetcherEditor.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Editor } from "ketcher-react"; +import { Box, SxProps, Theme } from "@mui/material"; +import "miew/dist/miew.min.css"; +import "ketcher-react/dist/index.css"; +import { + useKetcherEditor, + UseKetcherEditorOptions, +} from "../hooks/useKetcherEditor"; + +declare global { + interface Window { + ketcher: any; + } +} + +export interface KetcherEditorProps extends UseKetcherEditorOptions { + width?: string | number; + height?: string | number; + sx?: SxProps; + staticResourcesUrl?: string; + errorHandler?: (error: any) => void; +} + +const KetcherEditor: React.FC = React.memo((props) => { + const { + width = "100%", + height = "700px", + sx, + staticResourcesUrl = "", + errorHandler = () => {}, + ...hookOptions + } = props; + + const { structServiceProvider, handleInit } = useKetcherEditor(hookOptions); + + return ( + + + + ); +}); + +KetcherEditor.displayName = "KetcherEditor"; + +export default KetcherEditor; diff --git a/frontend/src/components/FunixFunction/components/index.ts b/frontend/src/components/FunixFunction/components/index.ts new file mode 100644 index 0000000..915b0f8 --- /dev/null +++ b/frontend/src/components/FunixFunction/components/index.ts @@ -0,0 +1,2 @@ +export { default as KetcherEditor } from "./KetcherEditor"; +export type { KetcherEditorProps } from "./KetcherEditor"; diff --git a/frontend/src/components/FunixFunction/hooks/index.ts b/frontend/src/components/FunixFunction/hooks/index.ts new file mode 100644 index 0000000..2db0575 --- /dev/null +++ b/frontend/src/components/FunixFunction/hooks/index.ts @@ -0,0 +1,5 @@ +export { useKetcherEditor } from "./useKetcherEditor"; +export type { + ChemEditorValue, + UseKetcherEditorOptions, +} from "./useKetcherEditor"; diff --git a/frontend/src/components/FunixFunction/hooks/useKetcherEditor.ts b/frontend/src/components/FunixFunction/hooks/useKetcherEditor.ts new file mode 100644 index 0000000..b634f2c --- /dev/null +++ b/frontend/src/components/FunixFunction/hooks/useKetcherEditor.ts @@ -0,0 +1,90 @@ +import { useCallback, useMemo, useState } from "react"; +import { Ketcher } from "ketcher-core"; +import { StandaloneStructServiceProvider } from "ketcher-standalone"; + +export type ChemEditorValue = { + smiles: string; + inchi: string | null; + inchiAuxInfo: string | null; + inchiKey: string | null; + smarts: string; + ket: string; +}; + +export interface UseKetcherEditorOptions { + initialValue?: ChemEditorValue | null; + onChange?: (value: ChemEditorValue) => void; +} + +export const useKetcherEditor = (options: UseKetcherEditorOptions = {}) => { + const { initialValue, onChange } = options; + + const [value, setValue] = useState( + initialValue ?? null, + ); + + const syncSetValue = useCallback( + (newValue: ChemEditorValue) => { + setValue(newValue); + onChange?.(newValue); + }, + [onChange], + ); + + const structServiceProvider = useMemo( + () => new StandaloneStructServiceProvider(), + [], + ); + + const handleInit = useCallback( + (ketcher: Ketcher) => { + if (window.ketcher) { + ketcher.editor.clear(); + ketcher.editor.clearHistory(); + } + window.ketcher = ketcher; + + if (initialValue) { + ketcher.setMolecule(initialValue.ket); + } + + ketcher.editor.subscribe("change", async () => { + const ket = await ketcher.getKet(); + if (ketcher.containsReaction()) { + const smiles = await ketcher.getSmiles(); + const smarts = await ketcher.getSmarts(); + syncSetValue({ + ket, + smiles, + inchi: null, + inchiAuxInfo: null, + inchiKey: null, + smarts, + }); + } else { + const smiles = await ketcher.getSmiles(); + const inchi = await ketcher.getInchi(); + const inchiAuxInfo = await ketcher.getInchi(true); + const inchiKey = await ketcher.getInChIKey(); + const smarts = await ketcher.getSmarts(); + syncSetValue({ + ket, + smiles, + inchi, + inchiAuxInfo, + inchiKey, + smarts, + }); + } + }); + }, + [initialValue, syncSetValue], + ); + + return { + value, + setValue: syncSetValue, + structServiceProvider, + handleInit, + }; +}; diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts index 8af9833..f9d326f 100644 --- a/frontend/src/shared/index.ts +++ b/frontend/src/shared/index.ts @@ -30,6 +30,8 @@ export type ReturnType = | "Dict" | "Callable" | "ChemStr" + | "RedirectButton" + | "NextTo" | FunixType | null; diff --git a/frontend/src/shared/indigo-render.ts b/frontend/src/shared/indigo-render.ts new file mode 100644 index 0000000..5302665 --- /dev/null +++ b/frontend/src/shared/indigo-render.ts @@ -0,0 +1,10 @@ +const renderSvg = (data: string) => { + const options = new window.indigo.MapStringString(); + options.set("render-output-format", "svg"); + const rawRender = window.indigo.render(data, options); + const img = new Image(); + img.src = `data:image/svg+xml;base64,${rawRender}`; + return img.src; +}; + +export default renderSvg; diff --git a/pyproject.toml b/pyproject.toml index 23a6e34..ddd515c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "funix" -version = "0.6.3" +version = "0.6.4" authors = [ {name = "Textea Inc.", email = "forrestbao@gmail.com"} ] @@ -29,22 +29,19 @@ dependencies = [ "pandas>=2.0.3", "docstring_parser>=0.16", "tornado>=6.4.2", - "pydantic>=2.10.6" + "pydantic>=2.10.6", + "GitPython>=3.1.31", ] [project.optional-dependencies] pendera = [ "pandera>=0.17.2", ] -git = [ - "GitPython>=3.1.31", -] ipython = [ "IPython>=8.14.0", "ipywidgets>=8.0.7", ] all = [ - "GitPython>=3.1.31", "IPython>=8.14.0", "ipywidgets>=8.0.7", "pandera>=0.17.2",