import {getById, writeError, writeDebug, h, t} from '/common.module.js' import {DACTAL} from "/dactal.js" import {query as jsonql} from "/jsonql-js/index.js" const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }); const defaultQueries = { jsonql: `SELECT * FROM personen AS p WHERE 'EenhoofdigOuderlijkGezag' IN p.gezag[].type`, dactal: "personen.gezag:type=EenhoofdigOuderlijkGezag", } const DB_NAME = "mb-storage" const HISTORY_STORE = "queryHistory" const FAVORITES_STORE = "queryFavorites" const HISTORY_LIMIT = 50 const input = getById("input") const lang = getById("lang") const favoriteButton = getById("favoriteButton") const dactal = new DACTAL() const db = await openDB() await renderHistory() await renderFavorites() fetch("/brp.json").then(async res => { const cdata = await res.json() dactal.load(cdata, "personen") writeDebug("Loaded brp data") await runQuery(input.value, lang.value) }) input.onkeydown = async (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() await runQuery(input.value, lang.value) // Only real keystrokes count as a run worth remembering, not the // synthetic Enter dispatched below to trigger the initial query. if (e.isTrusted) { await recordHistory(lang.value, input.value) } } } lang.onchange = async () => { input.value = defaultQueries[lang.value] await runQuery(input.value, lang.value) } favoriteButton.onclick = async () => { const name = await getFavoriteName() if (name) { await addFavorite(lang.value, input.value, name) } } getById("scrollTopButton").onclick = () => window.scrollTo({top: 0, behavior: "smooth"}) getById("scrollFavoritesButton").onclick = () => getById("favoritesList").closest(".panel").scrollIntoView({behavior: "smooth", block: "start"}) input.dispatchEvent(enterEvent) function openDB() { return new Promise((resolve, reject) => { const request = window.indexedDB.open(DB_NAME) request.onerror = () => reject(request.error) request.onsuccess = () => { const database = request.result const missingStores = [HISTORY_STORE, FAVORITES_STORE] .filter(name => !database.objectStoreNames.contains(name)) if (missingStores.length === 0) { resolve(database) return } const version = database.version + 1 database.close() const upgradeRequest = window.indexedDB.open(DB_NAME, version) upgradeRequest.onupgradeneeded = (event) => { const upgradeDb = event.target.result for (const storeName of missingStores) { if (!upgradeDb.objectStoreNames.contains(storeName)) { upgradeDb.createObjectStore(storeName, {autoIncrement: true}) } } } upgradeRequest.onblocked = () => writeError("Please close other tabs with this site open to enable query history.") upgradeRequest.onerror = () => reject(upgradeRequest.error) upgradeRequest.onsuccess = () => resolve(upgradeRequest.result) } }) } function loadEntries(storeName) { return new Promise((resolve, reject) => { const entries = [] const request = db.transaction(storeName).objectStore(storeName).openCursor() request.onerror = () => reject(request.error) request.onsuccess = (event) => { const cursor = event.target.result if (cursor) { entries.push({key: cursor.key, ...JSON.parse(cursor.value)}) cursor.continue() } else { resolve(entries) } } }) } function deleteEntry(storeName, key, rerender) { const transaction = db.transaction(storeName, "readwrite") transaction.oncomplete = () => rerender() transaction.objectStore(storeName).delete(key) } async function loadEntry(entry) { lang.value = entry.lang input.value = entry.query await runQuery(entry.query, entry.lang) } async function recordHistory(queryLang, query) { const entries = await loadEntries(HISTORY_STORE) const last = entries[entries.length - 1] if (last && last.lang === queryLang && last.query === query) { return } const transaction = db.transaction(HISTORY_STORE, "readwrite") transaction.objectStore(HISTORY_STORE).add(JSON.stringify({lang: queryLang, query})) transaction.oncomplete = async () => { await trimHistory() await renderHistory() } } async function trimHistory() { const entries = await loadEntries(HISTORY_STORE) const excess = entries.length - HISTORY_LIMIT if (excess <= 0) return const transaction = db.transaction(HISTORY_STORE, "readwrite") const store = transaction.objectStore(HISTORY_STORE) for (const entry of entries.slice(0, excess)) { store.delete(entry.key) } } async function addFavorite(favoriteLang, query, name) { const transaction = db.transaction(FAVORITES_STORE, "readwrite") transaction.objectStore(FAVORITES_STORE).add(JSON.stringify({lang: favoriteLang, query, name})) transaction.oncomplete = () => renderFavorites() } async function renderHistory() { const list = getById("historyList") list.replaceChildren() const entries = await loadEntries(HISTORY_STORE) for (const entry of entries.reverse()) { list.appendChild(h("span", `[${entry.lang}] ${previewQuery(entry.query)}`)) list.appendChild(h("button", {onClick: () => loadEntry(entry)}, "Load")) list.appendChild(h("button", {onClick: () => deleteEntry(HISTORY_STORE, entry.key, renderHistory)}, "Delete")) } } async function renderFavorites() { const list = getById("favoritesList") list.replaceChildren() const entries = await loadEntries(FAVORITES_STORE) for (const entry of entries) { list.appendChild(h("span", `${entry.name} [${entry.lang}]`)) list.appendChild(h("button", {onClick: () => loadEntry(entry)}, "Load")) list.appendChild(h("button", {onClick: () => deleteEntry(FAVORITES_STORE, entry.key, renderFavorites)}, "Delete")) } } function previewQuery(query) { const oneLine = query.replace(/\s+/g, " ").trim() return oneLine.length > 60 ? oneLine.slice(0, 60) + "…" : oneLine } function getFavoriteName() { return new Promise((resolve) => { const dialog = getById("favoriteNameDialog") const form = getById("favoriteNameForm") const nameInput = getById("favoriteNameInput") const cancelButton = getById("cancelFavoriteNameButton") dialog.showModal() nameInput.focus() const onCancel = () => { dialog.close("cancelFavoriteName") cancelButton.removeEventListener("click", onCancel) form.removeEventListener("submit", onSubmit) resolve(null) } const onSubmit = () => { const value = nameInput.value nameInput.value = "" form.removeEventListener("submit", onSubmit) cancelButton.removeEventListener("click", onCancel) resolve(value || null) } cancelButton.addEventListener("click", onCancel) form.addEventListener("submit", onSubmit) }) } async function runQuery(query, lang) { const results = getById("results") try { let result = lang === "jsonql" ? jsonql(query, {personen: dactal.data.personen ?? []}) : await dactal.query(query) results.replaceChildren(renderValue(result.slice(0, 100))) if (result.length > 100) { results.appendChild(h("div", [h("span", `Not showing ${result.length - 100} more results.`)])) } } catch (e) { writeError(e.message) results.replaceChildren() } } // How many children/entries a nested container reveals up front, and how // many more each "more..." click loads. Some queries (e.g. the dactal // grouping query personen/geslacht) produce a handful of top-level items // that each nest most of the dataset as children, so a click on "more" has // to pull in another page rather than the rest of the array in one go. const PAGE_SIZE = 10 function renderValue(obj, depth = 0) { if (Array.isArray(obj)) { return renderList(obj, depth) } else if (obj === null) { // Every branch has to return a node: aggregates (MIN/SUM over an // all-null group) and LEFT JOIN fills both put nulls in the results, // and returning undefined here makes appendChild throw. return h("span", {class: "json-null"}, "null") } else if (typeof obj === "object") { return renderContainer("ul", Object.entries(obj), ([name, item]) => renderEntry(name, item, depth)) } else if (typeof obj === "boolean") { return h("span", {class: obj ? "json-true" : "json-false"}, obj.toString()) } else if (typeof obj === "number") { return h("span", {class: "json-number"}, obj.toString()) } else if (typeof obj === "string") { return h("span", obj) } } function renderEntry(name, item, depth) { return h("li", {class: "dash"}, [ h("span", {class: "name"}, name), t(" "), renderValue(item, depth + 1) ]) } // Same idea as renderContainer, but for arrays: each page is its own //