From 485b3a7501ed85239172b021ce21c6ac48fb06b0 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Mon, 10 Aug 2026 16:12:19 +0200 Subject: [PATCH 1/2] ENH: add attribution to molecular drawings --- .../src/components/DrawingAttribution.tsx | 33 +++++++++++++++++++ .../client/src/components/MotifHoverCard.tsx | 6 +++- .../components/workspace/DialogViewItem.tsx | 2 ++ .../components/workspace/WorkspaceRules.tsx | 15 ++++++--- 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 gui/src/client/src/components/DrawingAttribution.tsx diff --git a/gui/src/client/src/components/DrawingAttribution.tsx b/gui/src/client/src/components/DrawingAttribution.tsx new file mode 100644 index 0000000..860b30a --- /dev/null +++ b/gui/src/client/src/components/DrawingAttribution.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import Typography from "@mui/material/Typography"; +import type { SxProps, Theme } from "@mui/material/styles"; + +// Neither version is importable at runtime -- smiles-drawer's package.json is blocked +// by its own "exports" field, and RDKit drawings are produced server-side (see +// gui/src/server/routes/rules.py). Both are kept in sync by hand with the resolved +// version in package-lock.json and pyproject.toml's `rdkit==` pin, respectively. +const RDKIT_VERSION = "2025.9.1"; +const SMILES_DRAWER_VERSION = "2.4.1"; + +type DrawingLibrary = "smiles-drawer" | "rdkit"; + +const LIBRARY_LABEL: Record = { + "smiles-drawer": `SmilesDrawer v${SMILES_DRAWER_VERSION}`, + rdkit: `RDKit v${RDKIT_VERSION}`, +}; + +// A single caption attributing one or more structure/reaction drawings above it to the +// library that rendered them. Place once per diagram -- if a diagram contains multiple +// drawings from the same library (e.g. a compound plus its reconstructions), attribute +// the whole group once rather than repeating this per drawing. +export const DrawingAttribution: React.FC<{ library: DrawingLibrary; sx?: SxProps }> = ({ library, sx }) => { + return ( + + Drawn with {LIBRARY_LABEL[library]} + + ); +}; diff --git a/gui/src/client/src/components/MotifHoverCard.tsx b/gui/src/client/src/components/MotifHoverCard.tsx index 9306fef..7c63b0d 100644 --- a/gui/src/client/src/components/MotifHoverCard.tsx +++ b/gui/src/client/src/components/MotifHoverCard.tsx @@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query"; import { fetchMotifStructures } from "../features/motifs/api"; import { MotifName } from "./MotifName"; import SmilesDrawerContainer from "./SmilesDrawerContainer.js"; +import { DrawingAttribution } from "./DrawingAttribution"; const DRAWING_SIZE = 100; @@ -38,7 +39,10 @@ function MotifHoverContent({ name, hint }: { name: string; hint?: string }) { return ( {smiles ? ( - + <> + + + ) : ( = ({ )} + {hasReconstructions && ( - + + + + @@ -195,6 +199,7 @@ function ReactionRuleRow({ rule }: { rule: ReactionRule }) { + From 9331620ac7ce96796b7af744f7de59b0f59b6cb7 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Mon, 10 Aug 2026 16:53:55 +0200 Subject: [PATCH 2/2] UPD: auto-update RDKit version for attribution; fail tests if SmilesDrawer attribution is out of sync --- .github/workflows/gui-tests.yml | 3 ++ gui/src/client/package.json | 3 +- .../scripts/checkSmilesDrawerVersion.js | 40 +++++++++++++++++++ .../src/components/DrawingAttribution.tsx | 27 ++++++------- .../components/workspace/ReactionScheme.tsx | 16 +++++--- .../components/workspace/WorkspaceRules.tsx | 1 - gui/src/client/src/features/rules/api.ts | 10 ++--- gui/src/client/src/features/rules/types.ts | 10 +++++ gui/src/server/routes/rules.py | 3 +- 9 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 gui/src/client/scripts/checkSmilesDrawerVersion.js diff --git a/.github/workflows/gui-tests.yml b/.github/workflows/gui-tests.yml index ffa6aef..0487d6f 100644 --- a/.github/workflows/gui-tests.yml +++ b/.github/workflows/gui-tests.yml @@ -31,6 +31,9 @@ jobs: - name: Lint run: npx eslint src --ext .ts,.tsx,.js + - name: Check SmilesDrawer version attribution + run: npm run check:smiles-drawer-version + - name: Test run: npm test -- --watchAll=false env: diff --git a/gui/src/client/package.json b/gui/src/client/package.json index 6a933ee..11ce0ad 100644 --- a/gui/src/client/package.json +++ b/gui/src/client/package.json @@ -38,7 +38,8 @@ "scripts": { "start": "craco start", "build": "craco build", - "test": "craco test" + "test": "craco test", + "check:smiles-drawer-version": "node scripts/checkSmilesDrawerVersion.js" }, "eslintConfig": { "extends": [ diff --git a/gui/src/client/scripts/checkSmilesDrawerVersion.js b/gui/src/client/scripts/checkSmilesDrawerVersion.js new file mode 100644 index 0000000..c8680df --- /dev/null +++ b/gui/src/client/scripts/checkSmilesDrawerVersion.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// Fails if DrawingAttribution.tsx's hardcoded SMILES_DRAWER_VERSION drifts from the +// version npm actually resolved in package-lock.json. Needed because smiles-drawer's +// package.json is blocked from import (its own "exports" field), so the "Drawn with +// SmilesDrawer vX" caption can't read the version at runtime the way ReactionScheme +// does for RDKit -- see DrawingAttribution.tsx for that comparison. +const fs = require("fs"); +const path = require("path"); + +const root = path.join(__dirname, ".."); + +const lockfile = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8")); +const resolvedVersion = lockfile.packages?.["node_modules/smiles-drawer"]?.version; + +if (!resolvedVersion) { + console.error("checkSmilesDrawerVersion: could not find node_modules/smiles-drawer in package-lock.json"); + process.exit(1); +} + +const attributionPath = path.join(root, "src/components/DrawingAttribution.tsx"); +const attributionSource = fs.readFileSync(attributionPath, "utf8"); +const match = attributionSource.match(/SMILES_DRAWER_VERSION\s*=\s*"([^"]+)"/); + +if (!match) { + console.error(`checkSmilesDrawerVersion: could not find SMILES_DRAWER_VERSION in ${attributionPath}`); + process.exit(1); +} + +const hardcodedVersion = match[1]; + +if (hardcodedVersion !== resolvedVersion) { + console.error( + `checkSmilesDrawerVersion: DrawingAttribution.tsx says smiles-drawer v${hardcodedVersion}, ` + + `but package-lock.json resolves it to v${resolvedVersion}. ` + + `Update SMILES_DRAWER_VERSION in src/components/DrawingAttribution.tsx to match.` + ); + process.exit(1); +} + +console.log(`checkSmilesDrawerVersion: OK (v${resolvedVersion})`); diff --git a/gui/src/client/src/components/DrawingAttribution.tsx b/gui/src/client/src/components/DrawingAttribution.tsx index 860b30a..ec95f21 100644 --- a/gui/src/client/src/components/DrawingAttribution.tsx +++ b/gui/src/client/src/components/DrawingAttribution.tsx @@ -2,32 +2,31 @@ import React from "react"; import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; -// Neither version is importable at runtime -- smiles-drawer's package.json is blocked -// by its own "exports" field, and RDKit drawings are produced server-side (see -// gui/src/server/routes/rules.py). Both are kept in sync by hand with the resolved -// version in package-lock.json and pyproject.toml's `rdkit==` pin, respectively. -const RDKIT_VERSION = "2025.9.1"; +// smiles-drawer's package.json is blocked from import by its own "exports" field, so +// this is kept in sync by hand with the resolved version in package-lock.json -- +// scripts/checkSmilesDrawerVersion.js fails CI/lint if the two drift apart. const SMILES_DRAWER_VERSION = "2.4.1"; -type DrawingLibrary = "smiles-drawer" | "rdkit"; - -const LIBRARY_LABEL: Record = { - "smiles-drawer": `SmilesDrawer v${SMILES_DRAWER_VERSION}`, - rdkit: `RDKit v${RDKIT_VERSION}`, -}; +type DrawingAttributionProps = + | { library: "smiles-drawer"; sx?: SxProps } + // RDKit drawings are produced server-side (see gui/src/server/routes/rules.py), so + // there's no local constant to hardcode -- the version is only known once the + // caller has actually fetched a drawing and the server reported what rendered it. + | { library: "rdkit"; version: string; sx?: SxProps }; // A single caption attributing one or more structure/reaction drawings above it to the // library that rendered them. Place once per diagram -- if a diagram contains multiple // drawings from the same library (e.g. a compound plus its reconstructions), attribute // the whole group once rather than repeating this per drawing. -export const DrawingAttribution: React.FC<{ library: DrawingLibrary; sx?: SxProps }> = ({ library, sx }) => { +export const DrawingAttribution: React.FC = (props) => { + const label = props.library === "smiles-drawer" ? `SmilesDrawer v${SMILES_DRAWER_VERSION}` : `RDKit v${props.version}`; return ( - Drawn with {LIBRARY_LABEL[library]} + Drawn with {label} ); }; diff --git a/gui/src/client/src/components/workspace/ReactionScheme.tsx b/gui/src/client/src/components/workspace/ReactionScheme.tsx index 56783cf..8a0cc47 100644 --- a/gui/src/client/src/components/workspace/ReactionScheme.tsx +++ b/gui/src/client/src/components/workspace/ReactionScheme.tsx @@ -16,6 +16,7 @@ import DOMPurify from "dompurify"; import { useColorScheme } from "@mui/material/styles"; import { useQuery } from "@tanstack/react-query"; import { fetchReactionSchemeSvg } from "../../features/rules/api"; +import { DrawingAttribution } from "../DrawingAttribution"; export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) { const { mode, systemMode } = useColorScheme(); @@ -31,7 +32,7 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) { // Server-rendered SVG markup gets injected raw via dangerouslySetInnerHTML, so it // must be sanitized first -- same rationale/profile as SvgViewer. const sanitizedSvg = React.useMemo( - () => (svgQuery.data ? DOMPurify.sanitize(svgQuery.data, { USE_PROFILES: { svg: true, svgFilters: true } }) : null), + () => (svgQuery.data ? DOMPurify.sanitize(svgQuery.data.svg, { USE_PROFILES: { svg: true, svgFilters: true } }) : null), [svgQuery.data] ); @@ -43,7 +44,7 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) { ); } - if (svgQuery.error || !sanitizedSvg) { + if (svgQuery.error || !sanitizedSvg || !svgQuery.data) { return ( Could not render this reaction ({smarts}). @@ -52,9 +53,12 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) { } return ( - + <> + + + ); } diff --git a/gui/src/client/src/components/workspace/WorkspaceRules.tsx b/gui/src/client/src/components/workspace/WorkspaceRules.tsx index 4843ae9..4e281af 100644 --- a/gui/src/client/src/components/workspace/WorkspaceRules.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceRules.tsx @@ -199,7 +199,6 @@ function ReactionRuleRow({ rule }: { rule: ReactionRule }) { - diff --git a/gui/src/client/src/features/rules/api.ts b/gui/src/client/src/features/rules/api.ts index 55fcdd7..fd59779 100644 --- a/gui/src/client/src/features/rules/api.ts +++ b/gui/src/client/src/features/rules/api.ts @@ -1,6 +1,5 @@ import { getJson } from "../http"; -import { RuleSetRespSchema, type RuleSetResp } from "./types"; -import { ItemDrawingResultSchema } from "../drawing/types"; +import { RuleSetRespSchema, ReactionSchemeSvgRespSchema, type RuleSetResp, type ReactionSchemeSvgResp } from "./types"; // The whole default rule set, fetched once and cached by the caller (see // WorkspaceRules) -- it's small (a few hundred rules total) and effectively static @@ -15,11 +14,10 @@ export async function fetchReactionSchemeSvg( ruleId: string, theme: "light" | "dark", signal?: AbortSignal -): Promise { - const data = await getJson( +): Promise { + return getJson( `/api/reactionSchemeSvg/${encodeURIComponent(ruleId)}?theme=${theme}`, - ItemDrawingResultSchema, + ReactionSchemeSvgRespSchema, signal ); - return data.svg; } diff --git a/gui/src/client/src/features/rules/types.ts b/gui/src/client/src/features/rules/types.ts index 2e13730..9ee71c8 100644 --- a/gui/src/client/src/features/rules/types.ts +++ b/gui/src/client/src/features/rules/types.ts @@ -29,3 +29,13 @@ export const RuleSetRespSchema = z.object({ reactionRules: z.array(ReactionRuleSchema), }); export type RuleSetResp = z.output; + +export const ReactionSchemeSvgRespSchema = z.object({ + svg: z.string(), + // The RDKit version that rendered `svg` -- reported by the server (see + // routes/rules.py's reaction_scheme_svg) rather than pinned client-side, so the + // "Drawn with RDKit vX" attribution in ReactionScheme can never drift from what + // actually rendered it. + rdkitVersion: z.string(), +}); +export type ReactionSchemeSvgResp = z.output; diff --git a/gui/src/server/routes/rules.py b/gui/src/server/routes/rules.py index b0cee18..6917d86 100644 --- a/gui/src/server/routes/rules.py +++ b/gui/src/server/routes/rules.py @@ -2,6 +2,7 @@ import threading +import rdkit from flask import Blueprint, Response, jsonify, request from rdkit.Chem.Draw import rdMolDraw2D @@ -154,4 +155,4 @@ def reaction_scheme_svg(rule_id: str) -> tuple[Response, int]: return jsonify({"error": "Unknown reaction rule id"}), 404 svg = _get_reaction_svg(rule, theme) - return jsonify({"svg": svg}), 200 + return jsonify({"svg": svg, "rdkitVersion": rdkit.__version__}), 200