Found old differing version on laptop
This commit is contained in:
455
new_chem_inventory_html_js_sqlite_sql.html
Normal file
455
new_chem_inventory_html_js_sqlite_sql.html
Normal file
@ -0,0 +1,455 @@
|
||||
<!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" /> Auto‑lookup 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 ? 'Auto‑lookup enabled' : 'Auto‑lookup 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>
|
||||
Reference in New Issue
Block a user