import {getById, writeError, writeDebug, writeInfo, h } from "/common.module.js"; /** @member {IDBDatabase} db */ let db; /** * The stores open on the page, by name, each with what closes it again. * @type {Map void>} */ const opened = new Map(); // The store named in the fragment is opened once the store list is known. Only then: showStores // runs again whenever the database changes, and must not reopen a store someone just closed. let openFromHash = true; function InitDB(upgradeCallback) { writeDebug(`db = ${db?.version}`) if (db) { writeDebug("Closing database"); db.close(); } writeDebug(`db = ${db?.version}`) const DBOpenRequest = db === null || db === undefined ? window.indexedDB.open('mb-storage') : window.indexedDB.open('mb-storage', db.version + 1); DBOpenRequest.onupgradeneeded = (e) => { writeDebug(`Upgrading from ${e.oldVersion} to ${e.newVersion}`); if (upgradeCallback) upgradeCallback(e); }; DBOpenRequest.onerror = () => { writeError('Error loading database.'); }; DBOpenRequest.onsuccess = () => { db = DBOpenRequest.result; writeDebug(`db = ${db.version}`) writeDebug("Database opened successfully."); db.onversionchange = () => { // Another tab is making or deleting a store, such as Note saving its first note. Let // it, and open whatever version it leaves behind. writeInfo(`Version changed in another tab, reopening database.`); db.close(); db = null; InitDB(null); } showStores(); }; DBOpenRequest.onblocked = () => { writeError(`Please close all other tabs with this site open!`); } } try { db?.close(); db = null; InitDB(null); // A link to another store from this page changes only the fragment, which reloads nothing. window.addEventListener('hashchange', () => { openFromHash = true; if (db) showStores(); }); const newStoreButton = getById('newStoreButton'); newStoreButton.addEventListener('click', async () => { const newStoreName = await getInput("New store", "Store name"); if (newStoreName) { writeDebug("New store submitted."); InitDB((event) => { writeDebug(`Creating new store: ${newStoreName}, version: ${event.newVersion}`); event.target.result.createObjectStore(newStoreName, {autoIncrement: true}); writeDebug("New store created: " + newStoreName); }); } }); } catch (e) { writeError(e.message); throw e; } function showStores() { const storeList = getById("storeList"); storeList.replaceChildren(); let storeNames = db.objectStoreNames; if (openFromHash) { openFromHash = false; const storeName = decodeURIComponent(window.location.hash.substring(1)); if (storeNames.contains(storeName) && !opened.has(storeName)) { openStore(storeName); } } for (const storeName of storeNames) { const deleteButton = h("button", { onClick: async () => { if (await askForConfirmation(`Are you sure you want to delete store '${storeName}'?`)) { opened.get(storeName)?.(); InitDB((event) => { writeDebug(`Deleting store: ${storeName}, version: ${event.newVersion}`); event.target.result.deleteObjectStore(storeName); writeDebug("Deleted store: " + storeName); }); } } }, "Delete"); const openButton = h("button", { onClick: () => { if (opened.has(storeName)) { opened.get(storeName)(); openButton.textContent = "Open"; } else { openStore(storeName); openButton.textContent = "Close"; } } }, opened.has(storeName) ? "Close" : "Open"); const storeElement = h("span", storeName); storeList.appendChild(storeElement); storeList.appendChild(openButton); storeList.appendChild(deleteButton); } } function openStore(storeName) { const openedStores = getById("openedStores"); const addButton = h("button", "Add"); const storeElement = h("fieldset", {class: "panel"}, [h("legend", [storeName + " ", addButton])]); opened.set(storeName, () => { openedStores.removeChild(storeElement); opened.delete(storeName); }); openedStores.appendChild(storeElement); const elementList = h("div", {class:"grid-stores"}); storeElement.appendChild(elementList); function showItems() { /** @member {Node[]} newChildren*/ const newChildren = []; const store = db.transaction(storeName).objectStore(storeName); store.openCursor().onsuccess = (event) => { /** @member {IDBCursorWithValue} cursor*/ const cursor = event.target.result; if (!cursor) { elementList.replaceChildren(...newChildren); return; } const value = cursor.value; const key = cursor.key; const editItemButton = h("button", { onClick: async () => { const value = await getInput("Edit value", "new value"); if (value) { const transaction = db.transaction(storeName, 'readwrite'); transaction.oncomplete = () => { showItems(); } const store = transaction.objectStore(storeName); store.put(value, key); } } }, "Edit"); const deleteItemButton = h("button", { onClick: () => { const transaction = db.transaction(storeName, 'readwrite'); transaction.oncomplete = () => { showItems(); } const store = transaction.objectStore(storeName); store.delete(key); } }, "Delete"); newChildren.push(showValue(value)); newChildren.push(editItemButton); newChildren.push(deleteItemButton); cursor.continue(); } } showItems(); storeElement.scrollIntoView({block: "nearest"}); addButton.addEventListener('click', async () => { const value = await getInput("New value", "value"); if (value) { const transaction = db.transaction(storeName, "readwrite"); transaction.oncomplete = () => { showItems(); } const store = transaction.objectStore(storeName); store.add(value); } }) } /** * A web address is a link to it, and `{title, url}` (what Note saves) is a link labelled with * its title. Anything else is its text. Only http(s): a value can be anything that was typed * into it, and a javascript: link would run it. * @param {unknown} value * @returns {HTMLElement} */ function showValue(value) { const isLabelled = typeof value?.url === "string" && typeof value?.title === "string"; const text = isLabelled ? value.url : String(value); const url = URL.canParse(text) ? new URL(text) : null; if (url && (url.protocol === "https:" || url.protocol === "http:")) { return h("a", {href: url.href, class: "store-link"}, isLabelled ? value.title : text); } return h("span", text); } /** * @param {string} title * @param {string} placeholder * @return {Promise} */ function getInput(title, placeholder) { return new Promise((resolve) => { const inputDialog = getById("inputDialog"); const cancelInputButton = getById('cancelInputButton'); const inputDialogInput = getById('inputDialogInput'); const inputForm = getById('inputForm'); inputDialogInput.placeholder = placeholder; getById("inputTitle").textContent = title; inputDialog.showModal(); const cancelCallback = () => { inputDialog.close("cancelInput"); cancelInputButton.removeEventListener("click", cancelCallback); inputForm.removeEventListener("submit", submitCallback); resolve(null); }; cancelInputButton.addEventListener('click', cancelCallback); const submitCallback = () => { const inputValue = inputDialogInput.value; inputDialogInput.value = ''; inputForm.removeEventListener("submit", submitCallback); cancelInputButton.removeEventListener("click", cancelCallback); resolve(inputValue); }; inputForm.addEventListener('submit', submitCallback); }) } function askForConfirmation(question) { return new Promise((resolve) => { const confirmDialog = getById("confirmDialog"); getById("confirmMessage").textContent = question; confirmDialog.showModal(); const cancelConfirmButton = getById("cancelConfirmButton"); const closeCancelled = () => { confirmDialog.close("cancelConfirmation"); resolve(false) }; cancelConfirmButton.addEventListener('click', closeCancelled); const confirmForm = getById("confirmForm"); const submitConfirmation = () => { cancelConfirmButton.removeEventListener('click', closeCancelled); confirmForm.removeEventListener('submit', submitConfirmation); resolve(true); }; confirmForm.addEventListener('submit', submitConfirmation); }) }