'
+ ''
+ '
EM-Database
'
+ '
A curated, citable collection of electron microscopy datasets — '
+ 'a couple of lines of Python from your analysis. Search below, then copy the '
+ 'snippet to load one with em_database.data.<Name>().
'
+ '
'
+ ''
+ ''
+ )
+ return _app_page("EM-Database", body, active="",
+ extra_css=_BROWSER_OVERRIDES,
+ scripts=_browser_script(payload, tabs))
+
+
+def generate_all_data_html() -> str:
+ """The All Data page: the full widget-style dataset browser with nav."""
+ payload, tabs = _catalogue_payload()
+ body = (
+ ''
+ ''
+ '
All Data
'
+ '
Every dataset in the collection. Search across names, techniques, '
+ 'authors, detectors and tags; filter by technique with the tabs.
'
+ '
'
+ ''
+ ''
+ )
+ return _app_page("All Data · EM-Database", body, active="All Data",
+ extra_css=_BROWSER_OVERRIDES + "\n.emdb-body { height: 620px; }\n",
+ scripts=_browser_script(payload, tabs))
+
+
+# -- Add Dataset page --------------------------------------------------------
+
+_MANUFACTURERS = ("Gatan", "Thermo Fisher Scientific", "Direct Electron",
+ "Dectris", "Quantum Detectors", "TVIPS", "Other")
+_VENDORS = ("Thermo Fisher Scientific", "JEOL", "Hitachi", "Zeiss", "Other")
+_TECHNIQUES = ("4D-STEM", "EELS", "EDS", "EBSD", "STEM", "In-situ TEM", "Cryo-EM", "Other")
+
+# Owner/repo the prefilled "create new file" PR link targets.
+_REPO = "CSSFrancis/em_data"
+_BRANCH = "main"
+
+
+def _text_field(fid, label, required=False, placeholder="", hint="", full=False):
+ req = ' '
+ '
Technique, sample, size, and anything notable.
'
+ '
'
+ '
'
+ + _text_field("f-source", "Source URL", required=True,
+ placeholder="https://zenodo.org/records/15490547/files",
+ hint="Direct download base (no file name).")
+ + _text_field("f-file", "File", required=True,
+ placeholder="smallPtychography.hspy",
+ hint="The file name at that source.")
+ + _text_field("f-checksum", "Checksum",
+ placeholder="md5:df9376d5c020a23f0f7f51cfe79f303f",
+ hint="md5:<32 hex chars>")
+ + _text_field("f-data_size", "Data Size", placeholder="1.4 GB")
+ + _select_field("f-detector_manufacturer", "Detector Manufacturer", _MANUFACTURERS)
+ + _text_field("f-detector", "Detector", placeholder="CeleritasXS")
+ + _select_field("f-microscope_vendor", "Microscope Vendor", _VENDORS)
+ + _text_field("f-microscope_model", "Microscope Model", placeholder="Gen 1 Titan")
+ + _text_field("f-camera_length", "Camera Length", placeholder="e.g. 100 mm")
+ + _text_field("f-voltage", "Voltage", placeholder="200 kV", hint="e.g. 200 kV")
+ + _select_field("f-technique", "Technique", _TECHNIQUES)
+ + _text_field("f-license", "License", placeholder="CC-BY-4.0")
+ + _text_field("f-doi", "DOI", placeholder="10.5281/zenodo.15490547")
+ + _text_field("f-tags", "Tags", placeholder="Orientation Mapping, Nanocrystals",
+ hint="Comma-separated.", full=True)
+ )
+
+ issue_url = "https://github.com/" + _REPO + "/issues/new?template=new_dataset.yaml"
+
+ body = (
+ ''
+ ''
+ '
Add a Dataset
'
+ '
Fill in the metadata; the YAML builds live on the right. '
+ '“Open a Pull Request” sends you to GitHub with the new file '
+ 'pre-filled — commit it to a branch there and GitHub opens the PR.
'
+ '
'
+ ''
+ ''
+ )
+
+ js = _ADD_DATASET_JS.replace("__REPO__", _REPO).replace("__BRANCH__", _BRANCH)
+ scripts = ""
+ return _app_page("Add Dataset · EM-Database", body, active="Add Dataset",
+ extra_css=_FORM_CSS, scripts=scripts)
+
- # Usage
- datasets = parse_datasets('datasets')
- print(datasets)
- html_output = generate_html_table(datasets)
+_ADD_DATASET_JS = r"""
+(function () {
+ var REPO = "__REPO__", BRANCH = "__BRANCH__";
+ var form = document.getElementById("ds-form");
+ var preview = document.querySelector("#yaml-preview code");
+ var submitPr = document.getElementById("submit-pr");
+ var copyBtn = document.getElementById("copy-yaml");
+ var addAuthor = document.getElementById("add-author");
+ var authorsBox = document.getElementById("authors");
+
+ function val(id) { var e = document.getElementById(id); return e ? e.value.trim() : ""; }
+ function sanitizeName(s) { return String(s).replace(/[^A-Za-z0-9]+/g, ""); }
+
+ // Emit a YAML scalar: plain when safe, double-quoted (with escapes) otherwise.
+ function yamlStr(v) {
+ v = String(v);
+ if (v === "") return '""';
+ if (/\n/.test(v)) {
+ return '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+ .replace(/\n/g, "\\n").replace(/\t/g, "\\t") + '"';
+ }
+ var risky = /^\s|\s$/.test(v)
+ || /^[-?:,\[\]{}#&*!|>'"%@`]/.test(v)
+ || /:(\s|$)/.test(v)
+ || /\s#/.test(v)
+ || /^(true|false|null|yes|no|on|off|~)$/i.test(v)
+ || /^[-+]?(\d[\d_]*\.?\d*([eE][-+]?\d+)?)$/.test(v);
+ if (risky) return '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
+ return v;
+ }
+
+ function authors() {
+ var out = [];
+ form.querySelectorAll(".author-row").forEach(function (r) {
+ var name = r.querySelector(".a-name").value.trim();
+ var aff = r.querySelector(".a-aff").value.trim();
+ var orcid = r.querySelector(".a-orcid").value.trim();
+ if (name) out.push({ name: name, aff: aff, orcid: orcid });
+ });
+ return out;
+ }
+
+ function tags() {
+ return val("f-tags").split(",").map(function (t) { return t.trim(); }).filter(Boolean);
+ }
+
+ function buildYaml() {
+ var name = sanitizeName(val("f-name")) || "DatasetName";
+ var lines = ["# $schema: ../json-schema.json", name + ":"];
+ function add(k, v) { if (v !== "" && v != null) lines.push(" " + k + ": " + yamlStr(v)); }
+ add("description", val("f-description"));
+ add("source", val("f-source"));
+ add("checksum", val("f-checksum"));
+ add("file", val("f-file"));
+ add("data_size", val("f-data_size"));
+ add("detector_manufacturer", val("f-detector_manufacturer"));
+ add("detector", val("f-detector"));
+ add("microscope_vendor", val("f-microscope_vendor"));
+ add("microscope_model", val("f-microscope_model"));
+ add("camera_length", val("f-camera_length"));
+ add("voltage", val("f-voltage"));
+ add("license", val("f-license"));
+ add("technique", val("f-technique"));
+ add("doi", val("f-doi"));
+ var tg = tags();
+ if (tg.length) {
+ lines.push(" tags:");
+ tg.forEach(function (t) { lines.push(" - " + yamlStr(t)); });
+ }
+ var au = authors();
+ if (au.length) {
+ lines.push(" authors:");
+ au.forEach(function (a) {
+ lines.push(" " + yamlStr(a.name) + ":");
+ lines.push(" affiliation: " + yamlStr(a.aff));
+ if (a.orcid) lines.push(" orcid: " + yamlStr(a.orcid));
+ });
+ }
+ return lines.join("\n") + "\n";
+ }
+
+ function setError(id, msg) {
+ var e = document.getElementById("err-" + id);
+ if (e) e.textContent = msg || "";
+ var field = document.getElementById(id);
+ if (field) field.classList.toggle("invalid", !!msg);
+ }
+
+ function validate() {
+ var ok = true;
+ if (!sanitizeName(val("f-name"))) { setError("f-name", "Required"); ok = false; }
+ else setError("f-name", "");
+ if (!val("f-description")) { setError("f-description", "Required"); ok = false; }
+ else setError("f-description", "");
+ var src = val("f-source");
+ if (!src) { setError("f-source", "Required"); ok = false; }
+ else if (!/^https?:\/\/\S+$/i.test(src)) { setError("f-source", "Must be an http(s) URL"); ok = false; }
+ else setError("f-source", "");
+ if (!val("f-file")) { setError("f-file", "Required"); ok = false; }
+ else setError("f-file", "");
+ var cs = val("f-checksum");
+ if (cs && !/^md5:[0-9a-fA-F]{32}$/.test(cs)) { setError("f-checksum", "Must match md5:<32 hex>"); ok = false; }
+ else setError("f-checksum", "");
+ var volt = val("f-voltage");
+ if (volt && !/^[0-9]+\s?kV$/.test(volt)) { setError("f-voltage", "e.g. 200 kV"); ok = false; }
+ else setError("f-voltage", "");
+ form.querySelectorAll(".author-row").forEach(function (r) {
+ var n = r.querySelector(".a-name"), a = r.querySelector(".a-aff"), o = r.querySelector(".a-orcid");
+ var ae = r.querySelector(".a-aff-err"), oe = r.querySelector(".a-orcid-err");
+ if (n.value.trim() && !a.value.trim()) { if (ae) ae.textContent = "Affiliation required"; a.classList.add("invalid"); ok = false; }
+ else { if (ae) ae.textContent = ""; a.classList.remove("invalid"); }
+ if (o.value.trim() && !/^\d{4}-\d{4}-\d{4}-\d{4}$/.test(o.value.trim())) { if (oe) oe.textContent = "0000-0000-0000-0000"; o.classList.add("invalid"); ok = false; }
+ else { if (oe) oe.textContent = ""; o.classList.remove("invalid"); }
+ });
+ return ok;
+ }
+
+ function refresh() {
+ preview.textContent = buildYaml();
+ submitPr.disabled = !validate();
+ }
- output_path = Path('docs/datasets_db.html')
- output_path.parent.mkdir(parents=True, exist_ok=True)
- with output_path.open('w', encoding='utf-8') as f:
- f.write(html_output)
\ No newline at end of file
+ function copyText(text, btn) {
+ var done = function () {
+ var old = btn.textContent; btn.textContent = "Copied!"; btn.classList.add("copied");
+ setTimeout(function () { btn.textContent = old; btn.classList.remove("copied"); }, 1100);
+ };
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(done).catch(function () { fallbackCopy(text, done); });
+ } else { fallbackCopy(text, done); }
+ }
+ function fallbackCopy(text, done) {
+ var ta = document.createElement("textarea");
+ ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
+ document.body.appendChild(ta); ta.select();
+ try { document.execCommand("copy"); done(); } catch (e) {}
+ ta.remove();
+ }
+
+ form.addEventListener("input", refresh);
+ form.addEventListener("change", refresh);
+
+ addAuthor.addEventListener("click", function () {
+ var tmp = document.createElement("div");
+ tmp.innerHTML = authorsBox.querySelector(".author-row").outerHTML;
+ var row = tmp.firstChild;
+ row.querySelectorAll("input").forEach(function (i) { i.value = ""; i.classList.remove("invalid"); });
+ row.querySelectorAll(".field-err").forEach(function (e) { e.textContent = ""; });
+ authorsBox.appendChild(row);
+ refresh();
+ });
+
+ copyBtn.addEventListener("click", function () { copyText(buildYaml(), copyBtn); });
+
+ submitPr.addEventListener("click", function () {
+ if (!validate()) { refresh(); return; }
+ var name = sanitizeName(val("f-name")) || "Dataset";
+ var url = "https://github.com/" + REPO + "/new/" + BRANCH
+ + "?filename=" + encodeURIComponent("em_database/datasets/" + name + ".yaml")
+ + "&value=" + encodeURIComponent(buildYaml());
+ window.open(url, "_blank", "noopener");
+ });
+
+ refresh();
+})();
+"""
+
+
+if __name__ == "__main__":
+ # Manual smoke test: write every generated page to ./_docs_preview so they
+ # can be opened in a browser without a full Sphinx build.
+ out = Path("_docs_preview")
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "index.html").write_text(generate_landing_html(), encoding="utf-8")
+ (out / "all_data.html").write_text(generate_all_data_html(), encoding="utf-8")
+ (out / "add_dataset.html").write_text(generate_add_dataset_html(), encoding="utf-8")
+ (out / "datasets_browser.html").write_text(generate_browser_html(), encoding="utf-8")
+ print("Wrote preview pages to", out.resolve())
\ No newline at end of file
diff --git a/em_database/config.py b/em_database/config.py
new file mode 100644
index 0000000..1fa7bb3
--- /dev/null
+++ b/em_database/config.py
@@ -0,0 +1,213 @@
+"""Runtime settings for em_database, in the style of matplotlib's ``rcParams``.
+
+``em_database.settings`` is a live dict, seeded at import from a YAML file (or
+the built-in defaults). Change it in memory::
+
+ import em_database
+ em_database.settings["data_dir"] = "/big/disk/em_data"
+
+and it takes effect immediately. To remember the choice across sessions, persist
+it::
+
+ em_database.settings.save()
+
+Settings live in a ``~/.em_database`` folder (``~/.em_database/settings.yaml``),
+or wherever ``EM_DATABASE_CONFIG`` points. No environment variable is *needed* to
+configure em_database - the settings object is the source of truth; the old
+``EM_DATABASE_DATA_DIR`` is still honored as a seed for backward compatibility.
+
+Data can also be installed **system-wide** and shared by every user. When
+resolving where a dataset lives, em_database looks in the shared/system
+locations first, then the user's own data directory, and only downloads (into
+the user directory) if it is nowhere to be found. Shared locations come from a
+system config file (see :func:`system_config_path`), the ``EM_DATABASE_SHARED_DIR``
+environment variable (an ``os.pathsep``-separated list), or a ``shared_data_dirs``
+list in the user's settings.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import yaml
+
+
+def _default_data_dir() -> str:
+ return os.path.join(os.path.expanduser("~"), "em_database")
+
+
+#: Built-in defaults - the fallback when nothing is configured.
+DEFAULTS: dict = {
+ "data_dir": _default_data_dir(),
+}
+
+
+def config_dir() -> Path:
+ """The ``~/.em_database`` folder that holds the settings file."""
+ return Path(os.path.expanduser("~")) / ".em_database"
+
+
+def config_path() -> Path:
+ """Path to the YAML settings file.
+
+ ``EM_DATABASE_CONFIG`` overrides the location; otherwise it is
+ ``~/.em_database/settings.yaml``.
+ """
+ override = os.environ.get("EM_DATABASE_CONFIG")
+ if override:
+ return Path(override)
+ return config_dir() / "settings.yaml"
+
+
+def system_config_path() -> Path | None:
+ """Path to the machine-wide settings file (for system-wide installs).
+
+ ``EM_DATABASE_SYSTEM_CONFIG`` overrides it; otherwise it is
+ ``/etc/em_database/settings.yaml`` (POSIX) or
+ ``%PROGRAMDATA%\\em_database\\settings.yaml`` (Windows).
+ """
+ override = os.environ.get("EM_DATABASE_SYSTEM_CONFIG")
+ if override:
+ return Path(override)
+ if os.name == "nt":
+ base = os.environ.get("PROGRAMDATA")
+ return Path(base) / "em_database" / "settings.yaml" if base else None
+ return Path("/etc/em_database/settings.yaml")
+
+
+def _read_yaml(path: Path | None) -> dict:
+ """Parse a YAML file into a dict, or ``{}`` if missing/unreadable/malformed."""
+ if path is None:
+ return {}
+ try:
+ with open(path, encoding="utf-8") as handle:
+ data = yaml.safe_load(handle)
+ return data if isinstance(data, dict) else {}
+ except FileNotFoundError:
+ return {}
+ except Exception:
+ # A malformed settings file must not break importing the package.
+ return {}
+
+
+def _read_file() -> dict:
+ """The user settings stored on disk, or ``{}``."""
+ return _read_yaml(config_path())
+
+
+def _read_system() -> dict:
+ """The machine-wide settings, or ``{}``."""
+ return _read_yaml(system_config_path())
+
+
+def _write_file(data: dict) -> None:
+ path = config_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "w", encoding="utf-8") as handle:
+ yaml.safe_dump(dict(data), handle, default_flow_style=False, sort_keys=True)
+
+
+def _seed(settings: "Settings") -> None:
+ """(Re)seed a settings object: defaults < file < legacy env var."""
+ settings.clear()
+ settings.update(DEFAULTS)
+ settings.update(_read_file()) # the file overrides the defaults
+ # Backward compatibility: seed from the old env var if it is set.
+ env_dir = os.environ.get("EM_DATABASE_DATA_DIR")
+ if env_dir:
+ settings["data_dir"] = env_dir
+
+
+class Settings(dict):
+ """A live settings mapping that can persist itself to the YAML file."""
+
+ def save(self) -> None:
+ """Persist the settings to the YAML file.
+
+ Only values that DIFFER from the built-in defaults are written, so a
+ default (like the data directory) is never frozen into the file: it stays
+ dynamic and keeps being obeyed across sessions, machines, or a change to
+ the default itself. When nothing differs, any stale file is removed.
+ """
+ delta = {key: value for key, value in self.items() if DEFAULTS.get(key) != value}
+ path = config_path()
+ if delta:
+ _write_file(delta)
+ elif path.exists():
+ path.unlink()
+
+ def reload(self) -> None:
+ """Re-seed from the built-in defaults and the on-disk file, discarding
+ any in-memory changes."""
+ _seed(self)
+
+ def reset(self, key: str | None = None) -> None:
+ """Reset a key (or everything) to its default, in memory and on disk."""
+ if key is None:
+ self.clear()
+ self.update(DEFAULTS)
+ elif key in DEFAULTS:
+ self[key] = DEFAULTS[key]
+ else:
+ self.pop(key, None)
+ self.save() # delta-save drops the now-default key from the file
+
+ def widget(self):
+ """Return an interactive widget for editing the settings (Jupyter)."""
+ from em_database.widget import settings_widget
+ return settings_widget()
+
+ def _repr_mimebundle_(self, **kwargs):
+ """Render as an editable settings widget in Jupyter (falls back to the
+ plain dict repr if anywidget is not installed)."""
+ try:
+ from em_database.widget import settings_widget
+ widget = settings_widget()
+ except Exception:
+ return {"text/plain": repr(dict(self))}
+ return widget._repr_mimebundle_(**kwargs)
+
+
+#: The live settings object (seeded once at import).
+settings = Settings()
+_seed(settings)
+
+
+def data_dir() -> str:
+ """The user's data directory - where downloads are written."""
+ return str(settings.get("data_dir") or _default_data_dir())
+
+
+def shared_data_dirs() -> list[str]:
+ """System-wide / shared data locations, checked before the user's dir.
+
+ Order: ``EM_DATABASE_SHARED_DIR`` (an ``os.pathsep`` list), then the user's
+ ``shared_data_dirs`` setting, then the system config file's ``data_dir`` and
+ ``shared_data_dirs``. Duplicates are removed, order preserved.
+ """
+ dirs: list[str] = []
+ env = os.environ.get("EM_DATABASE_SHARED_DIR")
+ if env:
+ dirs += [d for d in env.split(os.pathsep) if d]
+ dirs += [str(d) for d in (settings.get("shared_data_dirs") or [])]
+ system = _read_system()
+ if system.get("data_dir"):
+ dirs.append(str(system["data_dir"]))
+ dirs += [str(d) for d in (system.get("shared_data_dirs") or [])]
+ seen: set[str] = set()
+ unique: list[str] = []
+ for d in dirs:
+ if d not in seen:
+ seen.add(d)
+ unique.append(d)
+ return unique
+
+
+def data_search_dirs() -> list[str]:
+ """Everywhere to look for an existing dataset: shared/system dirs first,
+ then the user's data directory."""
+ dirs = shared_data_dirs()
+ user = data_dir()
+ if user not in dirs:
+ dirs.append(user)
+ return dirs
diff --git a/em_database/downloadable_dataset.py b/em_database/downloadable_dataset.py
index e07c9f0..ad7cfbe 100644
--- a/em_database/downloadable_dataset.py
+++ b/em_database/downloadable_dataset.py
@@ -129,8 +129,8 @@ def _repr_mimebundle_(self, **kwargs):
def _resolve_destination(destination: str | None) -> str:
"""Return the directory the dataset should live in."""
if destination is None:
- return os.environ.get("EM_DATABASE_DATA_DIR",
- os.path.join(os.path.expanduser("~"), "em_database"))
+ from em_database import config
+ return config.data_dir()
return destination
def download(self,
@@ -176,7 +176,12 @@ def download(self,
"""
if not background:
return self._retrieve(destination, progressbar, chunk_size)
- target = os.path.join(self._resolve_destination(destination), self.file)
+ # Resolve where the file will end up: an existing shared/user copy if it
+ # is already present, otherwise the user's download location.
+ if destination is not None:
+ target = os.path.join(destination, self.file)
+ else:
+ target = self.filepath() or os.path.join(self._resolve_destination(None), self.file)
# In Jupyter (with the widget installed) a background download pops a
# cancelable toast; the toast's monitor replaces the plain progress bar.
monitor = finish = None
@@ -198,14 +203,25 @@ def _retrieve(self,
destination: str | None = None,
progressbar: bool = True,
chunk_size: int = 4096) -> str:
- """Fetch the file with pooch and return its local path (blocking)."""
+ """Fetch the file and return its local path (blocking).
+
+ With no explicit destination, an existing system-wide/shared copy is used
+ as-is (never re-downloaded); otherwise pooch downloads into the user's
+ data directory.
+ """
if progressbar:
try:
import tqdm # noqa: F401
except ImportError:
print("`tqdm` is not installed, progress bar will be disabled.")
progressbar = False
- destination = self._resolve_destination(destination)
+ if destination is None:
+ shared = self._find_shared()
+ if shared is not None:
+ return shared
+ destination = self._resolve_destination(None)
+ else:
+ destination = self._resolve_destination(destination)
# Instantiate an Http downloader with a custom user agent
headers = {"User-Agent": "em_database (https://github.com/CSSFrancis/em_data)"}
downloader = pooch.HTTPDownloader(progressbar=progressbar,
@@ -220,17 +236,26 @@ def _retrieve(self,
)
return filepath
+ def _find_shared(self) -> str | None:
+ """Path to an existing copy in a shared/system data dir, or None."""
+ from em_database import config
+ for directory in config.shared_data_dirs():
+ candidate = os.path.join(directory, self.file)
+ if os.path.exists(candidate):
+ return candidate
+ return None
+
def filepath(self) -> str:
- """ Return the local file path of the dataset if downloaded.
-
- If not downloaded return None. """
- destination = os.environ.get("EM_DATABASE_DATA_DIR",
- os.path.join(os.path.expanduser("~"), "em_database"))
- filepath = os.path.join(destination, self.file)
- if os.path.exists(filepath):
- return filepath
- else:
- return None
+ """ Return the local file path of the dataset if present.
+
+ Looks in the shared/system data locations first, then the user's data
+ directory. Returns None if the dataset is not downloaded anywhere. """
+ from em_database import config
+ for directory in config.data_search_dirs():
+ candidate = os.path.join(directory, self.file)
+ if os.path.exists(candidate):
+ return candidate
+ return None
def delete(self, destination: str | None = None) -> bool:
""" Delete the downloaded file if it is present.
diff --git a/em_database/static/browser.css b/em_database/static/browser.css
index b4eb66d..3ba9959 100644
--- a/em_database/static/browser.css
+++ b/em_database/static/browser.css
@@ -196,6 +196,39 @@
.emdb-inline-toast.cancelling { opacity: 0.65; }
.emdb-inline-toast.error { border-color: var(--emdb-red); }
+/* Settings panel (display(em_database.settings)) ------------------- */
+.emdb-settings-host { max-width: 620px; }
+.emdb-settings { padding: 16px 18px; }
+.emdb-field { margin-top: 12px; }
+.emdb-field-label {
+ font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;
+ text-transform: uppercase; color: var(--emdb-muted); margin: 12px 0 6px;
+}
+.emdb-field-row { display: flex; gap: 8px; align-items: stretch; }
+.emdb-field-row .emdb-search { flex: 1; min-width: 0; margin-top: 0; }
+.emdb-browse {
+ flex: 0 0 auto; cursor: pointer; font-family: var(--emdb-font);
+ font-size: 12px; font-weight: 600; color: var(--emdb-text);
+ background: var(--emdb-surface0); border: 1px solid var(--emdb-surface1);
+ border-radius: 7px; padding: 0 14px;
+}
+.emdb-browse:hover { border-color: var(--emdb-blue); color: var(--emdb-blue); }
+.emdb-field-actions { display: flex; gap: 8px; margin-top: 8px; }
+.emdb-hint { margin-top: 6px; font-size: 11px; color: var(--emdb-subtext); }
+.emdb-status { margin-top: 12px; font-size: 12px; color: var(--emdb-green); font-weight: 600; }
+.emdb-settings-info { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--emdb-surface0); }
+.emdb-searchlist { display: flex; flex-direction: column; gap: 5px; }
+.emdb-searchdir {
+ font-family: var(--emdb-mono); font-size: 11.5px; color: var(--emdb-text);
+ word-break: break-all;
+}
+.emdb-tag {
+ font-family: var(--emdb-font); font-size: 10px; font-weight: 600;
+ color: var(--emdb-crust); background: var(--emdb-green);
+ border-radius: 4px; padding: 1px 6px; white-space: nowrap;
+}
+.emdb-tag.shared { background: var(--emdb-blue); }
+
/* Toasts: pinned to the bottom-right of the viewport ----------------- */
.emdb-toast-root {
position: fixed; right: 16px; bottom: 16px; z-index: 99999;
diff --git a/em_database/static/settings.js b/em_database/static/settings.js
new file mode 100644
index 0000000..41bacd0
--- /dev/null
+++ b/em_database/static/settings.js
@@ -0,0 +1,134 @@
+// settings.js - an editable settings panel (what display(em_database.settings) shows).
+//
+// Edit the data directory directly: type a path and Save (persist), Use for
+// session (in-memory), or Reset to the default. Also shows the settings-file
+// location and the shared-then-user search order.
+
+function esc(value) {
+ return String(value).replace(/[&<>"']/g, function (c) {
+ return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
+ });
+}
+
+function el(tag, cls, html) {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (html != null) n.innerHTML = html;
+ return n;
+}
+
+function render({ model, el: root }) {
+ root.classList.add("emdb", "emdb-settings-host");
+
+ let nonce = 0;
+ function cmd(action, extra) {
+ model.set("_command", Object.assign({ action, nonce: nonce++ }, extra || {}));
+ model.save_changes();
+ }
+
+ function draw() {
+ root.innerHTML = "";
+ const wrap = el("div", "emdb-settings");
+ wrap.appendChild(el("div", "emdb-brand", '