Skip to content
Merged
4 changes: 2 additions & 2 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@
## CSV Import

- Command to import CSV files via browser file picker
- Auto-detects `smiles` column (case-insensitive)
- Creates one note per row with YAML frontmatter
- Creates one note per row with YAML frontmatter; notes are named `row_1`, `row_2`, etc.
- All CSV columns added as frontmatter properties (keys sanitized to lowercase with non-alphanumeric characters replaced by `_`)
- Generates a `.base` file scoped to the import folder
- Handles quoted fields and escaped double quotes per RFC 4180
- No RDKit dependency — SMILES are taken directly from the CSV
- Select the molecule property in the view options after import

## Rendering Settings

Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default tseslint.config(
},
},
{
files: ['src/__tests__/**/*.ts'],
files: ['src/__tests__/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"name": "mols2bases",
"version": "0.1.1",
"version": "0.1.2",
"description": "Molecule visualization for Bases",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",
"lint": "eslint src/",
"lint:fix": "eslint --fix src/",
"lint": "eslint src/ tests/",
"lint:fix": "eslint --fix src/ tests/",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"test": "vitest run",
Expand Down
33 changes: 4 additions & 29 deletions src/csv-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const BATCH_SIZE = 50;

export function parseCsv(text: string): { headers: string[]; rows: Record<string, string>[] } {
const lines = text.split(/\r?\n/);
if (lines.length === 0) return { headers: [], rows: [] };
if (lines.length === 0 || lines[0].trim() === '') return { headers: [], rows: [] };

const headers = parseCsvRow(lines[0]);
const rows: Record<string, string>[] = [];
Expand Down Expand Up @@ -84,19 +84,14 @@ export async function importCsv(plugin: Mols2BasesPlugin): Promise<void> {

try {
const content = await readFileAsText(file);
const { headers, rows } = parseCsv(content);
const { rows } = parseCsv(content);

if (rows.length === 0) {
notice.hide();
new Notice('No data rows found in the CSV file.');
return;
}

// Auto-detect SMILES column (case-insensitive)
const smilesHeader = headers.find(
(h) => h.toLowerCase() === plugin.settings.csvSmilesField.toLowerCase(),
);

notice.setMessage(`Importing ${rows.length} rows...`);

// Create folder based on filename
Expand All @@ -108,38 +103,19 @@ export async function importCsv(plugin: Mols2BasesPlugin): Promise<void> {
}

// Create .base file early so the view populates as notes arrive
const baseContent = buildBaseFile(
`${folderPath}.base`,
smilesHeader ? `note.${plugin.settings.csvSmilesField}` : undefined,
);
const baseContent = buildBaseFile(`${folderPath}.base`);
const basePath = normalizePath(`${folderPath}.base`);
const baseFile = await plugin.app.vault.create(basePath, baseContent);
await plugin.app.workspace.getLeaf(false).openFile(baseFile);

// Show warning if no SMILES column was found
if (!smilesHeader) {
new Notice(
`No "${plugin.settings.csvSmilesField}" column found — select the molecule property in the view options.`,
5000,
);
}

// Find first non-smiles header for note naming
const nameHeader = headers.find((h) => h !== smilesHeader);

for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const name = (nameHeader && row[nameHeader]?.trim()) || `row_${i + 1}`;
const name = `row_${i + 1}`;

// Build frontmatter
const frontmatter: Record<string, string> = {};
if (smilesHeader) {
frontmatter[smilesHeader] = row[smilesHeader];
}
frontmatter[INTERNAL_KEYS.LINK] = `[[${baseName}.base]]`;

for (const [key, value] of Object.entries(row)) {
if (smilesHeader && key === smilesHeader) continue;
if (!frontmatter[key]) {
frontmatter[key] = value;
}
Expand All @@ -150,7 +126,6 @@ export async function importCsv(plugin: Mols2BasesPlugin): Promise<void> {
const finalPath = await uniquePath(plugin.app, notePath);
await plugin.app.vault.create(finalPath, `---\n${yaml}---\n`);

// Yield to the event loop after each batch
if ((i + 1) % BATCH_SIZE === 0) {
notice.setMessage(`Importing rows... (${i + 1} / ${rows.length})`);
await sleep(0);
Expand Down
9 changes: 1 addition & 8 deletions src/sdf-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,7 @@ export async function importSdf(plugin: Mols2BasesPlugin): Promise<void> {
for (let i = 0; i < molecules.length; i++) {
const mol = molecules[i];
const molblockTitle = mol.molblock.split('\n', 1)[0].trim();
const name =
molblockTitle ||
mol.properties.Name ||
mol.properties.name ||
mol.properties.COMMON_NAME ||
mol.properties.ID ||
mol.properties.id ||
`molecule_${i + 1}`;
const name = molblockTitle || `molecule_${i + 1}`;

// Convert MOL block to SMILES via RDKit
let smiles = '';
Expand Down
12 changes: 0 additions & 12 deletions src/settings-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,5 @@ export class Mols2BasesSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}),
);

new Setting(containerEl)
.setName('CSV SMILES field name')
.setDesc('Name of the frontmatter property to use for SMILES when importing CSV files.')
.addText((text) =>
text.setValue(this.plugin.settings.csvSmilesField).onChange(async (value) => {
if (value.trim()) {
this.plugin.settings.csvSmilesField = value.trim();
await this.plugin.saveSettings();
}
}),
);
}
}
2 changes: 0 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export interface Mols2BasesSettings {
bondLineWidth: number;
transparentBg: boolean;
comicMode: boolean;
csvSmilesField: string;
}

export const DEFAULT_SETTINGS: Mols2BasesSettings = {
Expand All @@ -30,7 +29,6 @@ export const DEFAULT_SETTINGS: Mols2BasesSettings = {
bondLineWidth: 1.0,
transparentBg: false,
comicMode: false,
csvSmilesField: 'smiles',
};

export const CONFIG_KEYS = {
Expand Down
39 changes: 39 additions & 0 deletions tests/csv-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { parseCsv } from '../src/csv-import';

describe('parseCsv – basic parsing', () => {
it('returns headers and row values', () => {
const { headers, rows } = parseCsv('name,smiles\naspirin,CC(=O)Oc1ccccc1C(=O)O');
expect(headers).toEqual(['name', 'smiles']);
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual({ name: 'aspirin', smiles: 'CC(=O)Oc1ccccc1C(=O)O' });
});

it('skips blank lines between data rows', () => {
const { rows } = parseCsv('name,smiles\n\naspirin,CCO\n\n');
expect(rows).toHaveLength(1);
});

it('returns empty headers and rows for empty input', () => {
const { headers, rows } = parseCsv('');
expect(headers).toEqual([]);
expect(rows).toHaveLength(0);
});
});

describe('parseCsv – RFC 4180 quoting', () => {
it('treats a quoted field containing a comma as one field', () => {
const { rows } = parseCsv('name,note\naspirin,"painkiller, fever reducer"');
expect(rows[0]['note']).toBe('painkiller, fever reducer');
});

it('unescapes doubled quotes inside a quoted field', () => {
const { rows } = parseCsv('name,note\ntest,"say ""hello"""');
expect(rows[0]['note']).toBe('say "hello"');
});

it('empty trailing field is empty string', () => {
const { rows } = parseCsv('a,b,c\n1,2,');
expect(rows[0]['c']).toBe('');
});
});
36 changes: 36 additions & 0 deletions tests/data/dup_names.sdf
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
compound
-INDIGO-04142622472D

6 6 0 0 0 0 0 0 0 0999 V2000
10.1348 -5.5001 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.8652 -5.4996 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.0016 -5.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.8652 -6.5005 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
10.1348 -6.5050 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.0038 -7.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
3 1 2 0 0 0 0
1 5 1 0 0 0 0
5 6 2 0 0 0 0
6 4 1 0 0 0 0
4 2 2 0 0 0 0
2 3 1 0 0 0 0
M END
$$$$
compound
-INDIGO-04

6 6 0 0 0 0 0 0 0 0999 V2000
10.1348 -5.5001 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.8652 -5.4996 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.0016 -5.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.8652 -6.5005 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
10.1348 -6.5050 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.0038 -7.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
3 1 2 0 0 0 0
1 5 1 0 0 0 0
5 6 2 0 0 0 0
6 4 1 0 0 0 0
4 2 2 0 0 0 0
2 3 1 0 0 0 0
M END
$$$$
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildYaml } from '../import-utils';
import { buildYaml, uniquePath } from '../src/import-utils';

// Minimal molblock fragments used across tests
const HEADER_LINE = ' -OEChem-04032607062D';
Expand Down Expand Up @@ -78,3 +78,26 @@ describe('buildYaml – frontmatter round-trip for missing_names.sdf molecules',
expect(yaml).not.toContain('|2-');
});
});

describe('uniquePath – deduplication', () => {
const mockApp = (existing: string[]) =>
({ vault: { adapter: { exists: async (p: string) => existing.includes(p) } } }) as never;

it('returns the path unchanged when it does not exist', async () => {
const result = await uniquePath(mockApp([]), 'folder/mol.md');
expect(result).toBe('folder/mol.md');
});

it('appends _1 when the base path is taken', async () => {
const result = await uniquePath(mockApp(['folder/mol.md']), 'folder/mol.md');
expect(result).toBe('folder/mol_1.md');
});

it('increments past _1 when both base and _1 are taken', async () => {
const result = await uniquePath(
mockApp(['folder/mol.md', 'folder/mol_1.md']),
'folder/mol.md',
);
expect(result).toBe('folder/mol_2.md');
});
});
17 changes: 17 additions & 0 deletions tests/mocks/obsidian.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Mock obsidian module for testing
export const Notice = class {
constructor(message?: string, duration?: number) {
// No-op
}
hide() {
// No-op
}
setMessage(message: string) {
// No-op
return this;
}
};

export function normalizePath(path: string): string {
return path.replace(/\\/g, '/');
}
30 changes: 27 additions & 3 deletions src/__tests__/sdf-parser.test.ts → tests/sdf-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { describe, it, expect } from 'vitest';
import { parseSdf } from '../sdf-parser';
import { parseSdf } from '../src/sdf-parser';

const SDF_PATH = join(__dirname, '..', '..', 'missing_names.sdf');
const SDF_PATH = join(__dirname, 'data', 'missing_names.sdf');
const sdfContent = readFileSync(SDF_PATH, 'utf-8');

const NO_NAME_PATH = join(__dirname, '..', '..', 'no_name_at_all.sdf');
const NO_NAME_PATH = join(__dirname, 'data', 'no_name_at_all.sdf');
const noNameContent = readFileSync(NO_NAME_PATH, 'utf-8');

describe('parseSdf – missing_names.sdf', () => {
Expand Down Expand Up @@ -76,3 +76,27 @@ describe('parseSdf – no_name_at_all.sdf', () => {
}
});
});

const DUP_PATH = join(__dirname, 'data', 'dup_names.sdf');
const dupContent = readFileSync(DUP_PATH, 'utf-8');

describe('parseSdf – dup_names.sdf', () => {
const mols = parseSdf(dupContent);

it('parses exactly 2 molecules', () => {
expect(mols).toHaveLength(2);
});

it('both molecules have molblock title "compound"', () => {
for (const mol of mols) {
const firstLine = mol.molblock.split('\n')[0];
expect(firstLine).toBe('compound');
}
});

it('both molecules contain M END', () => {
for (const mol of mols) {
expect(mol.molblock).toContain('M END');
}
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@
"outDir": "./dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
12 changes: 12 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
},
resolve: {
alias: {
obsidian: new URL('./tests/mocks/obsidian.ts', import.meta.url).pathname,
},
},
});
Loading