Add query history and favorites, stored in IndexedDB

Reuses the shared mb-storage database so entries are automatically visible and editable on the Storage page's generic store editor.

author
Marijn Besseling <njirambem@gmail.com> · 2026-08-17 16:20 UTC
commit
117e3160b418d2f4734e1dc707daa0b390a7cc22
parent
e1e0ec09b9
tree
browse at this commit

2 files changed +209 -2

Blog/Components/Pages/Query.razor +25 -0

@@ -9,6 +9,7 @@
9 9 <option value="jsonql" selected>jsonql-js</option>
10 10 <option value="dactal">DACTAL</option>
11 11 </select>
12 + <button id="favoriteButton" type="button">Save as favorite</button>
12 13 </div>
13 14 <textarea id="input" class="editor" autofocus>SELECT *
14 15 FROM personen AS p
@@ -17,6 +18,30 @@ WHERE 'EenhoofdigOuderlijkGezag' IN p.gezag[].type</textarea>
17 18 </div>
18 19 <Log/>
19 20
21 + <dialog id="favoriteNameDialog">
22 + <Panel Legend="Save as favorite">
23 + <form method="dialog" id="favoriteNameForm">
24 + <p>
25 + <input id="favoriteNameInput" type="text" placeholder="Name"/>
26 + </p>
27 + <div class="flex-row">
28 + <button type="submit">Save</button>
29 + <button id="cancelFavoriteNameButton" type="reset">Cancel</button>
30 + </div>
31 + </form>
32 + </Panel>
33 + </dialog>
34 +
35 + <Panel Legend="Favorites">
36 + <div id="favoritesList" class="grid-stores">
37 + </div>
38 + </Panel>
39 +
40 + <Panel Legend="History">
41 + <div id="historyList" class="grid-stores">
42 + </div>
43 + </Panel>
44 +
20 45 <Panel Legend="Credits">
21 46 <div>
22 47 jsonql-js, a pure-JS SQL-like query language for JSON

Blog/Components/Pages/Query.razor.js +184 -2

@@ -18,13 +18,26 @@ WHERE 'EenhoofdigOuderlijkGezag' IN p.gezag[].type`,
18 18 dactal: "personen.gezag:type=EenhoofdigOuderlijkGezag",
19 19 }
20 20
21 +const DB_NAME = "mb-storage"
22 +const HISTORY_STORE = "queryHistory"
23 +const FAVORITES_STORE = "queryFavorites"
24 +const HISTORY_LIMIT = 50
25 +
21 26 let dactal
27 +let db
28 +let input
29 +let lang
22 30
23 31 export async function onLoad() {
24 - const input = getById("input")
25 - const lang = getById("lang")
32 + input = getById("input")
33 + lang = getById("lang")
34 + const favoriteButton = getById("favoriteButton")
26 35 dactal = new DACTAL()
27 36
37 + db = await openDB()
38 + await renderHistory()
39 + await renderFavorites()
40 +
28 41 fetch("/brp.json").then(async res => {
29 42 const cdata = await res.json()
30 43 dactal.load(cdata, "personen")
@@ -36,6 +49,11 @@ export async function onLoad() {
36 49 if (e.key === "Enter" && !e.shiftKey) {
37 50 e.preventDefault()
38 51 await runQuery(input.value, lang.value)
52 + // Only real keystrokes count as a run worth remembering, not the
53 + // synthetic Enter dispatched below to trigger the initial query.
54 + if (e.isTrusted) {
55 + await recordHistory(lang.value, input.value)
56 + }
39 57 }
40 58 }
41 59
@@ -44,9 +62,173 @@ export async function onLoad() {
44 62 await runQuery(input.value, lang.value)
45 63 }
46 64
65 + favoriteButton.onclick = async () => {
66 + const name = await getFavoriteName()
67 + if (name) {
68 + await addFavorite(lang.value, input.value, name)
69 + }
70 + }
71 +
47 72 input.dispatchEvent(enterEvent)
48 73 }
49 74
75 +function openDB() {
76 + return new Promise((resolve, reject) => {
77 + const request = window.indexedDB.open(DB_NAME)
78 +
79 + request.onerror = () => reject(request.error)
80 +
81 + request.onsuccess = () => {
82 + const database = request.result
83 + const missingStores = [HISTORY_STORE, FAVORITES_STORE]
84 + .filter(name => !database.objectStoreNames.contains(name))
85 +
86 + if (missingStores.length === 0) {
87 + resolve(database)
88 + return
89 + }
90 +
91 + const version = database.version + 1
92 + database.close()
93 +
94 + const upgradeRequest = window.indexedDB.open(DB_NAME, version)
95 + upgradeRequest.onupgradeneeded = (event) => {
96 + const upgradeDb = event.target.result
97 + for (const storeName of missingStores) {
98 + if (!upgradeDb.objectStoreNames.contains(storeName)) {
99 + upgradeDb.createObjectStore(storeName, {autoIncrement: true})
100 + }
101 + }
102 + }
103 + upgradeRequest.onblocked = () => writeError("Please close other tabs with this site open to enable query history.")
104 + upgradeRequest.onerror = () => reject(upgradeRequest.error)
105 + upgradeRequest.onsuccess = () => resolve(upgradeRequest.result)
106 + }
107 + })
108 +}
109 +
110 +function loadEntries(storeName) {
111 + return new Promise((resolve, reject) => {
112 + const entries = []
113 + const request = db.transaction(storeName).objectStore(storeName).openCursor()
114 +
115 + request.onerror = () => reject(request.error)
116 + request.onsuccess = (event) => {
117 + const cursor = event.target.result
118 + if (cursor) {
119 + entries.push({key: cursor.key, ...JSON.parse(cursor.value)})
120 + cursor.continue()
121 + } else {
122 + resolve(entries)
123 + }
124 + }
125 + })
126 +}
127 +
128 +function deleteEntry(storeName, key, rerender) {
129 + const transaction = db.transaction(storeName, "readwrite")
130 + transaction.oncomplete = () => rerender()
131 + transaction.objectStore(storeName).delete(key)
132 +}
133 +
134 +async function loadEntry(entry) {
135 + lang.value = entry.lang
136 + input.value = entry.query
137 + await runQuery(entry.query, entry.lang)
138 +}
139 +
140 +async function recordHistory(queryLang, query) {
141 + const entries = await loadEntries(HISTORY_STORE)
142 + const last = entries[entries.length - 1]
143 + if (last && last.lang === queryLang && last.query === query) {
144 + return
145 + }
146 +
147 + const transaction = db.transaction(HISTORY_STORE, "readwrite")
148 + transaction.objectStore(HISTORY_STORE).add(JSON.stringify({lang: queryLang, query}))
149 + transaction.oncomplete = async () => {
150 + await trimHistory()
151 + await renderHistory()
152 + }
153 +}
154 +
155 +async function trimHistory() {
156 + const entries = await loadEntries(HISTORY_STORE)
157 + const excess = entries.length - HISTORY_LIMIT
158 + if (excess <= 0) return
159 +
160 + const transaction = db.transaction(HISTORY_STORE, "readwrite")
161 + const store = transaction.objectStore(HISTORY_STORE)
162 + for (const entry of entries.slice(0, excess)) {
163 + store.delete(entry.key)
164 + }
165 +}
166 +
167 +async function addFavorite(favoriteLang, query, name) {
168 + const transaction = db.transaction(FAVORITES_STORE, "readwrite")
169 + transaction.objectStore(FAVORITES_STORE).add(JSON.stringify({lang: favoriteLang, query, name}))
170 + transaction.oncomplete = () => renderFavorites()
171 +}
172 +
173 +async function renderHistory() {
174 + const list = getById("historyList")
175 + list.replaceChildren()
176 +
177 + const entries = await loadEntries(HISTORY_STORE)
178 + for (const entry of entries.reverse()) {
179 + list.appendChild(h("span", `[${entry.lang}] ${previewQuery(entry.query)}`))
180 + list.appendChild(h("button", {onClick: () => loadEntry(entry)}, "Load"))
181 + list.appendChild(h("button", {onClick: () => deleteEntry(HISTORY_STORE, entry.key, renderHistory)}, "Delete"))
182 + }
183 +}
184 +
185 +async function renderFavorites() {
186 + const list = getById("favoritesList")
187 + list.replaceChildren()
188 +
189 + const entries = await loadEntries(FAVORITES_STORE)
190 + for (const entry of entries) {
191 + list.appendChild(h("span", `${entry.name} [${entry.lang}]`))
192 + list.appendChild(h("button", {onClick: () => loadEntry(entry)}, "Load"))
193 + list.appendChild(h("button", {onClick: () => deleteEntry(FAVORITES_STORE, entry.key, renderFavorites)}, "Delete"))
194 + }
195 +}
196 +
197 +function previewQuery(query) {
198 + const oneLine = query.replace(/\s+/g, " ").trim()
199 + return oneLine.length > 60 ? oneLine.slice(0, 60) + "…" : oneLine
200 +}
201 +
202 +function getFavoriteName() {
203 + return new Promise((resolve) => {
204 + const dialog = getById("favoriteNameDialog")
205 + const form = getById("favoriteNameForm")
206 + const nameInput = getById("favoriteNameInput")
207 + const cancelButton = getById("cancelFavoriteNameButton")
208 +
209 + dialog.showModal()
210 + nameInput.focus()
211 +
212 + const onCancel = () => {
213 + dialog.close("cancelFavoriteName")
214 + cancelButton.removeEventListener("click", onCancel)
215 + form.removeEventListener("submit", onSubmit)
216 + resolve(null)
217 + }
218 +
219 + const onSubmit = () => {
220 + const value = nameInput.value
221 + nameInput.value = ""
222 + form.removeEventListener("submit", onSubmit)
223 + cancelButton.removeEventListener("click", onCancel)
224 + resolve(value || null)
225 + }
226 +
227 + cancelButton.addEventListener("click", onCancel)
228 + form.addEventListener("submit", onSubmit)
229 + })
230 +}
231 +
50 232 async function runQuery(query, lang) {
51 233 const results = getById("results")
52 234