Files
chem-inventory/new_chem_inventory_html_js_sqlite_sql.html

456 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chem Inventory (SQLite + PubChem)</title>
<style>
:root { --bg:#0f1115; --panel:#171923; --text:#e6e6e6; --muted:#9aa0a6; --accent:#7aa2f7; }
* { box-sizing:border-box; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
body { margin:0; background:var(--bg); color:var(--text); }
header { padding:12px 16px; background:#11131a; border-bottom:1px solid #232634; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
header h1 { margin:0; font-size:18px; font-weight:650; letter-spacing:.3px; }
header .spacer { flex:1; }
main { display:grid; grid-template-columns:360px 1fr; gap:16px; padding:16px; }
@media (max-width: 900px) { main { grid-template-columns:1fr; } }
.card { background:var(--panel); border:1px solid #232634; border-radius:14px; padding:14px; }
.card h2 { margin:0 0 10px; font-size:15px; font-weight:650; color:#d2d5de; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.row3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; }
label { font-size:12px; color:var(--muted); margin-bottom:4px; display:block; }
input[type="text"], input[type="number"], select, textarea { width:100%; padding:10px; border-radius:10px; border:1px solid #2a2f3a; background:#11131a; color:var(--text); }
textarea { min-height:70px; }
button { padding:10px 12px; border-radius:10px; border:1px solid #2a2f3a; background:#11131a; color:var(--text); cursor:pointer; }
button.primary { background:var(--accent); border-color:#6486df; color:#0b1021; font-weight:650; }
button.ghost { opacity:.9; }
button:disabled { opacity:.5; cursor:not-allowed; }
.toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.muted { color:var(--muted); font-size:12px; }
table { width:100%; border-collapse:collapse; }
th, td { padding:8px 10px; border-bottom:1px solid #232634; font-size:13px; vertical-align:top; }
th { text-align:left; color:#c9ccd6; position:sticky; top:0; background:var(--panel); z-index:1; }
tr:hover td { background:#131726; }
.pill { padding:2px 8px; border-radius:999px; background:#1f2433; border:1px solid #2a2f3a; font-size:12px; }
.right { text-align:right; }
.status { font-size:12px; }
.ok { color:#a5d6a7; }
.bad { color:#ffcdd2; }
.warn { color:#ffe082; }
.kbd { background:#22273a; padding:2px 6px; border:1px solid #2a2f3a; border-radius:6px; font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; font-size:12px; }
</style>
<!-- sql.js: SQLite compiled to WASM. No SRI to avoid false mismatches. -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js"></script>
</head>
<body>
<header>
<h1>Chem Inventory</h1>
<span class="muted">Fast entry now, enrich later.</span>
<div class="spacer"></div>
<div class="toolbar">
<button id="bulkBtn" title="Fill missing CAS/Name for all items">Bulk Lookup Missing</button>
<button id="exportDbBtn" title="Download the SQLite .db file">Export DB</button>
<button id="exportCsvBtn" title="Download CSV export">Export CSV</button>
<label class="kbd" for="importDb">Import DB</label>
<input id="importDb" type="file" accept=".db,.sqlite,application/octet-stream" style="display:none" />
</div>
</header>
<main>
<section class="card" id="entryCard">
<h2>New entry</h2>
<div class="row">
<div>
<label for="cas">CAS</label>
<input id="cas" type="text" placeholder="e.g. 64-17-5" autocomplete="off" />
</div>
<div>
<label for="name">Name</label>
<input id="name" type="text" placeholder="e.g. Ethanol" autocomplete="off" />
</div>
</div>
<div class="row3" style="margin-top:8px">
<div>
<label for="qty">Quantity</label>
<input id="qty" type="number" step="0.01" placeholder="e.g. 500" />
</div>
<div>
<label for="unit">Unit</label>
<select id="unit">
<option value="g">g</option>
<option value="mg">mg</option>
<option value="kg">kg</option>
<option value="mL" selected>mL</option>
<option value="L">L</option>
</select>
</div>
<div>
<label for="container">Container</label>
<select id="container">
<option value="glass" selected>glass</option>
<option value="plastic">plastic</option>
<option value="metal">metal</option>
<option value="other">other</option>
</select>
</div>
</div>
<div style="margin-top:8px">
<label for="notes">Notes</label>
<textarea id="notes" placeholder="Label state, purity, supplier, hazards, storage, etc."></textarea>
</div>
<div class="toolbar" style="margin-top:10px">
<button id="addBtn" class="primary" title="Enter (Ctrl+Enter)">Add</button>
<button id="lookupBtn" class="ghost" title="On-demand lookup via PubChem">Lookup (PubChem)</button>
<label><input type="checkbox" id="autoLookup" /> Autolookup on blur</label>
<span id="status" class="status muted"></span>
</div>
<p class="muted" style="margin-top:10px">
Shortcuts: <span class="kbd">Enter</span> to add, <span class="kbd">Ctrl+Enter</span> to add and keep focus, <span class="kbd">/</span> focuses CAS, <span class="kbd">,</span> focuses Name.
</p>
</section>
<section class="card">
<h2>Inventory</h2>
<div class="toolbar" style="margin-bottom:8px">
<input type="text" id="search" placeholder="Filter by CAS or name" />
<button id="clearBtn">Clear</button>
</div>
<div style="overflow:auto; max-height: 70vh;">
<table id="table">
<thead><tr>
<th>#</th>
<th>CAS</th>
<th>Name</th>
<th class="right">Qty</th>
<th>Unit</th>
<th>Container</th>
<th>Notes</th>
<th></th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</section>
</main>
<script>
// Persistent DB in IndexedDB via sql.js
// Schema: items(id INTEGER PK, cas TEXT, name TEXT, qty REAL, unit TEXT, container TEXT, notes TEXT, created_at TEXT)
let SQL; // sql.js module
let db; // Database instance
const DB_KEY = 'chem_inventory_sqlite';
const $ = (id) => document.getElementById(id);
const status = (msg, cls = 'muted') => {
const s = $("status"); s.className = `status ${cls}`; s.textContent = msg;
if (msg) setTimeout(() => { if ($("status").textContent === msg) status(''); }, 4000);
};
// Small helpers
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const CAS_RE = /^\d{2,7}-\d{2}-\d$/;
const normCas = (x) => (x||'').replace(/\s+/g,'').trim();
const pickCAS = (arr) => (arr||[]).find(x => CAS_RE.test(x)) || (arr&&arr[0]) || '';
async function init() {
SQL = await initSqlJs({ locateFile: file => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}` });
const saved = localStorage.getItem(DB_KEY);
if (saved) {
const u8 = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
db = new SQL.Database(u8);
} else {
db = new SQL.Database();
db.run(`CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cas TEXT,
name TEXT,
qty REAL,
unit TEXT,
container TEXT,
notes TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_items_cas ON items(cas);
CREATE INDEX IF NOT EXISTS idx_items_name ON items(name);`);
persist();
}
render();
}
function persist() {
const data = db.export();
const b64 = btoa(Array.from(data).map(b => String.fromCharCode(b)).join(""));
localStorage.setItem(DB_KEY, b64);
}
function addItem(rec) {
const stmt = db.prepare(`INSERT INTO items (cas,name,qty,unit,container,notes,created_at) VALUES (?,?,?,?,?,?,?)`);
stmt.run([
rec.cas || null,
rec.name || null,
rec.qty != null && rec.qty !== '' ? Number(rec.qty) : null,
rec.unit || null,
rec.container || null,
rec.notes || null,
new Date().toISOString()
]);
stmt.free();
persist();
render();
}
function updateItem(id, fields) {
const keys = Object.keys(fields);
if (!keys.length) return;
const set = keys.map(k => `${k} = ?`).join(', ');
const params = keys.map(k => fields[k]);
params.push(id);
db.run(`UPDATE items SET ${set} WHERE id = ?`, params);
persist();
}
function deleteItem(id) {
db.run(`DELETE FROM items WHERE id = ?`, [id]);
persist();
render();
}
function queryAll(filter) {
let sql = `SELECT * FROM items`;
const params = [];
if (filter) {
sql += ` WHERE (cas LIKE ? OR name LIKE ? OR notes LIKE ?)`;
const f = `%${filter}%`;
params.push(f, f, f);
}
sql += ` ORDER BY id DESC`;
const res = db.exec(sql, params);
if (!res.length) return [];
const cols = res[0].columns; const rows = res[0].values;
return rows.map(r => Object.fromEntries(r.map((v,i)=>[cols[i], v])));
}
function render() {
const filter = $("search").value?.trim() || '';
const rows = queryAll(filter);
const tbody = document.querySelector('#table tbody');
tbody.innerHTML = '';
rows.forEach((r) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.id}</td>
<td><span class="pill">${r.cas ?? ''}</span></td>
<td>${r.name ?? ''}</td>
<td class="right">${r.qty ?? ''}</td>
<td>${r.unit ?? ''}</td>
<td>${r.container ?? ''}</td>
<td style="white-space: pre-wrap; max-width: 420px;">${r.notes ?? ''}</td>
<td class="right"><button data-del="${r.id}">Delete</button></td>
`;
tbody.appendChild(tr);
});
}
function resetForm(keepFocus=false) {
const fields = ['cas','name','qty','unit','container','notes'];
fields.forEach(id => { const el = $(id); if (el.tagName === 'SELECT') el.selectedIndex = 0; else el.value = ''; });
if (keepFocus) $('cas').focus();
}
function exportDb() {
const data = db.export();
const blob = new Blob([data], {type: 'application/octet-stream'});
const url = URL.createObjectURL(blob);
download(url, `chem_inventory_${new Date().toISOString().slice(0,10)}.sqlite`);
}
function exportCsv() {
const rows = queryAll('');
const header = ['id','cas','name','qty','unit','container','notes','created_at'];
const esc = s => (s==null?'' : (""+s).replaceAll('"','""'));
const csv = [header.join(',')].concat(rows.map(r => header.map(k => `"${esc(r[k])}"`).join(','))).join('\n');
const blob = new Blob([csv], {type: 'text/csv'});
const url = URL.createObjectURL(blob);
download(url, `chem_inventory_${new Date().toISOString().slice(0,10)}.csv`);
}
function download(url, filename) {
const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function importDb(file) {
const reader = new FileReader();
reader.onload = () => {
const u8 = new Uint8Array(reader.result);
db = new SQL.Database(u8);
persist();
render();
status('Database imported', 'ok');
};
reader.readAsArrayBuffer(file);
}
async function fetchJSON(url, {timeoutMs=8000, signal}={}) {
const controller = new AbortController();
const to = setTimeout(()=>controller.abort(), timeoutMs);
try {
const r = await fetch(url, { signal: signal || controller.signal });
if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
return await r.json();
} finally { clearTimeout(to); }
}
// Safer PubChem lookups
async function pubchemByName(name) {
const q = name.trim(); if (!q) return null;
try {
const cids = await fetchJSON(`https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${encodeURIComponent(q)}/cids/JSON`);
const cid = cids?.IdentifierList?.CID?.[0];
if (!cid) return null;
const prop = await fetchJSON(`https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${cid}/property/IUPACName/JSON`);
const iupac = prop?.PropertyTable?.Properties?.[0]?.IUPACName || q;
let rn = '';
try {
const rnJson = await fetchJSON(`https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${cid}/xrefs/RN/JSON`);
rn = pickCAS(rnJson?.InformationList?.Information?.[0]?.RN || []);
} catch (_) { /* no RN, leave blank */ }
return { name: iupac, cas: rn };
} catch (e) {
console.warn('pubchemByName failed:', e); return null;
}
}
async function pubchemByCas(cas) {
const rn = normCas(cas); if (!rn) return null;
try {
const cids = await fetchJSON(`https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/xref/RN/${encodeURIComponent(rn)}/cids/JSON`);
const cid = cids?.IdentifierList?.CID?.[0];
if (!cid) return null;
const prop = await fetchJSON(`https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${cid}/property/IUPACName/JSON`);
const iupac = prop?.PropertyTable?.Properties?.[0]?.IUPACName || '';
return { name: iupac, cas: rn };
} catch (e) {
console.warn('pubchemByCas failed:', e); return null;
}
}
async function onDemandLookup() {
const cas = $('cas').value; const name = $('name').value;
status('Querying PubChem…');
let res = null;
if (!cas && name) res = await pubchemByName(name);
else if (cas && !name) res = await pubchemByCas(cas);
else if (cas && name) { // both present: prefer to validate/normalize name
res = await pubchemByCas(cas) || await pubchemByName(name);
}
if (res) {
if (res.cas && !$('cas').value) $('cas').value = res.cas;
if (res.name && !$('name').value) $('name').value = res.name;
status('Filled from PubChem', 'ok');
} else {
status('No match', 'warn');
}
}
async function bulkLookupMissing() {
const btn = $('bulkBtn');
btn.disabled = true;
const res = db.exec(`SELECT id, cas, name FROM items WHERE (cas IS NULL OR TRIM(cas)='') OR (name IS NULL OR TRIM(name)='') ORDER BY id ASC`);
const rows = res.length ? res[0].values.map(v => ({id:v[0], cas:v[1]||'', name:v[2]||''})) : [];
if (!rows.length) { status('Nothing to fill'); btn.disabled=false; return; }
let done = 0; status('Bulk lookup started…');
for (const r of rows) {
btn.textContent = `Bulk Lookup: ${++done}/${rows.length}`;
try {
let info = null;
if (r.name && !r.cas) info = await pubchemByName(r.name);
else if (r.cas && !r.name) info = await pubchemByCas(r.cas);
if (info) {
const fields = {};
if (!r.cas && info.cas) fields.cas = info.cas;
if (!r.name && info.name) fields.name = info.name;
if (Object.keys(fields).length) updateItem(r.id, fields);
}
} catch (e) { console.warn('bulk item failed', r, e); }
await sleep(1100); // throttle to avoid rate limiting
}
btn.textContent = 'Bulk Lookup Missing';
btn.disabled = false;
render();
status('Bulk lookup complete', 'ok');
}
// Wire up events after DOM is ready
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('DOMContentLoaded', () => {
$('addBtn').addEventListener('click', () => {
const rec = {
cas: normCas($('cas').value),
name: $('name').value.trim(),
qty: $('qty').value,
unit: $('unit').value,
container: $('container').value,
notes: $('notes').value.trim()
};
if (!rec.cas && !rec.name) { status('Provide CAS or name', 'bad'); return; }
if (rec.cas && !CAS_RE.test(rec.cas)) { status('CAS format looks wrong', 'warn'); }
addItem(rec);
resetForm(true);
});
$('lookupBtn').addEventListener('click', onDemandLookup);
$('autoLookup').addEventListener('change', e => {
status(e.target.checked ? 'Autolookup enabled' : 'Autolookup disabled');
});
['cas','name'].forEach(id => {
$(id).addEventListener('blur', async () => {
if (!$('autoLookup').checked) return;
const cas = $('cas').value; const name = $('name').value;
if (!cas || !name) {
const res = cas ? await pubchemByCas(cas) : await pubchemByName(name);
if (res) {
if (res.cas && !cas) $('cas').value = res.cas;
if (res.name && !name) $('name').value = res.name;
}
}
});
});
$('search').addEventListener('input', render);
document.querySelector('#table').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-del]');
if (!btn) return;
const id = Number(btn.getAttribute('data-del'));
if (confirm('Delete entry #' + id + '?')) deleteItem(id);
});
$('clearBtn').addEventListener('click', () => { $('search').value=''; render(); });
$('exportDbBtn').addEventListener('click', exportDb);
$('exportCsvBtn').addEventListener('click', exportCsv);
$('importDb').addEventListener('change', (e) => {
const f = e.target.files?.[0]; if (f) importDb(f);
e.target.value = '';
});
$('bulkBtn').addEventListener('click', bulkLookupMissing);
// Keyboard shortcuts: Enter to add, Ctrl+Enter keep focus, '/' for CAS, ',' for Name
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (document.activeElement.tagName !== 'TEXTAREA')) {
e.preventDefault();
const keep = e.ctrlKey || e.metaKey;
$('addBtn').click();
if (keep) $('cas').focus();
} else if (e.key === '/') { e.preventDefault(); $('cas').focus(); }
else if (e.key === ',') { e.preventDefault(); $('name').focus(); }
});
});
</script>
</body>
</html>