diff --git a/package-lock.json b/package-lock.json index 54dae962f..1cfa89524 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "d3-zoom": "^3.0.0", "dlv": "^1.1.3", "eventemitter3": "^5.0.4", + "expand-chemical-groups": "^1.0.0", "fifo-logger": "^2.0.1", "file-collection": "^6.7.0", "file-saver": "^2.0.5", @@ -6803,6 +6804,20 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-chemical-groups": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/expand-chemical-groups/-/expand-chemical-groups-1.0.0.tgz", + "integrity": "sha512-jStn7mgdSVlnRFtDtpXN1dlzkK9+z6Wa0QiNTJybqrBt3+PfvIzsqnLFrPGlBpvbAqMmwGQ5NYcZ6gKwfbdk3w==", + "license": "MIT", + "workspaces": [ + "dev" + ], + "dependencies": { + "chemical-groups": "^2.2.4", + "mf-parser": "^3.9.3", + "openchemlib": "^9.25.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", diff --git a/package.json b/package.json index 4b5f48d5a..242145478 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "d3-zoom": "^3.0.0", "dlv": "^1.1.3", "eventemitter3": "^5.0.4", + "expand-chemical-groups": "^1.0.0", "fifo-logger": "^2.0.1", "file-collection": "^6.7.0", "file-saver": "^2.0.5", diff --git a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx index 56a896283..dea5fde3d 100644 --- a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx +++ b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx @@ -251,7 +251,7 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { } /** - * The moleecule pasted could be SMILES, molfile or SDF + * The molecule pasted could be SMILES, molfile, SDF or chemical groups * @param text - text from clipboard * @returns */ @@ -430,9 +430,10 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { /> )} } onClick={handlePasteMoleculeAction} + data-testid="paste-molecule-action" /> {renderSource === 'moleculePanel' && ( ); diff --git a/src/data/molecules/Molecule.ts b/src/data/molecules/Molecule.ts index 5bd7e5fbe..83aa3454f 100644 --- a/src/data/molecules/Molecule.ts +++ b/src/data/molecules/Molecule.ts @@ -1,4 +1,5 @@ import type { MoleculeView, StateMolecule } from '@zakodium/nmrium-core'; +import { expandChemicalGroups } from 'expand-chemical-groups'; import { Molecule } from 'openchemlib'; import getAtomsFromMF from '../utilities/getAtomsFromMF.js'; @@ -36,17 +37,37 @@ type InitializeMoleculeOptions = { text: string } & Omit< 'molfile' >; +export const parseErrorMessage = + 'Failed to parse SMILES, molfile or chemical groups. Please paste a valid format'; + +function parseMoleculeText(text: string): Molecule { + const moleculeText = text.trim(); + if (!moleculeText) { + throw new Error(parseErrorMessage); + } + + if (/v[23]000/i.test(text)) { + return Molecule.fromMolfile(text); + } + + try { + return Molecule.fromSmiles(moleculeText); + } catch { + // fallback to expanding chemical groups if SMILES parsing fails + // for example HAlaGlyOH, BocAlaOH ...etc. + // TODO a way to check if the text is a valid chemical group instead of fallback if can not read as SMILES + return expandChemicalGroups(moleculeText); + } +} + export function initMolecule( options: InitializeMoleculeOptions, ): StateMoleculeExtended { const { id = crypto.randomUUID(), label = 'p#', text, selector } = options; + const molecule = parseMoleculeText(text); - const molecule = Molecule.fromText(text); - - if (!molecule) { - throw new Error( - 'Failed to parse SMILES or molfile. Please paste a valid format', - ); + if (molecule.getAllAtoms() === 0) { + throw new Error(parseErrorMessage); } const molfile = molecule.toMolfileV3(); const mfInfo = molecule.getMolecularFormula(); diff --git a/src/data/molecules/MoleculeManager.ts b/src/data/molecules/MoleculeManager.ts index e40a9aef1..a32ffcb03 100644 --- a/src/data/molecules/MoleculeManager.ts +++ b/src/data/molecules/MoleculeManager.ts @@ -3,7 +3,9 @@ import { readSDF } from '@zakodium/nmrium-core-plugins'; import { Molecule } from 'openchemlib'; import type { StateMoleculeExtended } from './Molecule.js'; -import { initMolecule } from './Molecule.js'; +import { initMolecule, parseErrorMessage } from './Molecule.js'; + +export { parseErrorMessage } from './Molecule.js'; //TODO: Move molecule initialization to nmrium-core and log an error if the molecule cannot be parsed. export function fromJSON( @@ -105,9 +107,6 @@ function getLabelNumber(reserveNumbers: number[]): number { return 1; } -export const parseErrorMessage = - 'Failed to parse SMILES or molfile. Please paste a valid format'; - function validateMolecules(molecules: StateMolecule[]) { if (!molecules?.length) { throw new Error(parseErrorMessage); @@ -132,10 +131,8 @@ function validateMolecules(molecules: StateMolecule[]) { } /** - * We will extract the molecule(s) from the text. - * Because a molfile is actually a SDF we can just call 'readSDF' method - * @param text - text containing one or several molecules in SMILES, molfile or SDF format - * @returns + * Read SDF records and validate their structures. + * @param text - SDF content */ function parseSDF(text: string) { @@ -148,12 +145,17 @@ function parseSDF(text: string) { } } +/** + * Parse SDF records or a single molfile, SMILES or chemical-group. + * @param text - Pasted structure text + */ export function getMolecules(text?: string) { if (!text?.trim()) { throw new Error(parseErrorMessage); } - const isSDF = /v[23]000/i.test(text); + // identifies an SDF file by checking for the presence of the '$$$$' separator + const isSDF = text.includes('$$$$'); return isSDF ? parseSDF(text) : [initMolecule({ text })]; } diff --git a/test-e2e/panels/structures.test.ts b/test-e2e/panels/structures.test.ts index 72590935b..7ebba47ae 100644 --- a/test-e2e/panels/structures.test.ts +++ b/test-e2e/panels/structures.test.ts @@ -408,9 +408,9 @@ test('molecules 1H spectrum', async ({ page, browserName }) => { if (browserName !== 'webkit') { await test.step('Check copy as molfile V3 or V2 and paste', async () => { await clickExportMenuOption(nmrium, 'text=Copy as molfile V3'); - await nmrium.clickToolByTitle('Paste SMILES or molfile'); + await nmrium.page.getByTestId('paste-molecule-action').click(); await clickExportMenuOption(nmrium, 'text=Copy as molfile V2'); - await nmrium.clickToolByTitle('Paste SMILES or molfile'); + await nmrium.page.getByTestId('paste-molecule-action').click(); await expect( nmrium.page.locator('_react=MoleculePanel >> text=5 / 5'),