Found old differing version on laptop

This commit is contained in:
2026-05-11 14:51:33 +02:00
parent d5428ad3e2
commit 47e9c9817c

View 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" /> 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>