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'),