From e1313ee7abb93a8a20f978ac152b462c9ef74a43 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 10:29:49 +0200 Subject: feat(user): location suggestions from OpenStreetMap There is no place database on this machine, and the user chose Nominatim over a tz city list for coverage. It is asked 1s after typing stops (its policy: one request a second, an identifying User-Agent, nothing personal sent), from three characters, in the desktop's language, and a reply to an older query is dropped. places() turns its JSON into 'place, region, country' labels and treats an error page as no suggestions; test_user.js covers it. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/user/User.js | 18 ++++++++++ desktop/modules/user/UserPage.qml | 74 +++++++++++++++++++++++++++++++++++++-- desktop/modules/user/test_user.js | 22 +++++++++++- 3 files changed, 110 insertions(+), 4 deletions(-) (limited to 'desktop/modules') diff --git a/desktop/modules/user/User.js b/desktop/modules/user/User.js index 8160b7f..5c3d5a2 100644 --- a/desktop/modules/user/User.js +++ b/desktop/modules/user/User.js @@ -44,3 +44,21 @@ function gitName(name) { if (w.length < 2) return w.join(" "); return w.slice(0, -1).join(" ") + " " + Array.from(w[w.length - 1])[0] + "."; } + +// Nominatim jsonv2 search results as location labels, "place, region, +// country", deduplicated. Anything that is not a JSON array (an error page, +// a rate-limit notice) is no suggestions rather than an error. +function places(text) { + let rows; + try { rows = JSON.parse(text); } catch (e) { return []; } + if (!Array.isArray(rows)) return []; + const out = []; + for (const r of rows) { + const a = r.address || {}; + const parts = [a.city || a.town || a.village || a.hamlet || a.municipality || r.name, + a.state, a.country].filter(p => p); + const label = parts.filter((p, i) => parts.indexOf(p) === i).join(", "); + if (label && !out.includes(label)) out.push(label); + } + return out; +} diff --git a/desktop/modules/user/UserPage.qml b/desktop/modules/user/UserPage.qml index 5e2e448..9fb81a6 100644 --- a/desktop/modules/user/UserPage.qml +++ b/desktop/modules/user/UserPage.qml @@ -33,7 +33,7 @@ Column { readonly property var fields: [ { key: "name", label: "Name", kind: "text" }, { key: "email", label: "Email", kind: "text" }, - { key: "location", label: "Location", kind: "text" }, + { key: "location", label: "Location", kind: "place" }, { key: "language", label: "Language", kind: "locale" }, { key: "gitname", label: "Name", kind: "derived", section: "Git" }, { key: "gitemail", label: "Email", kind: "derived", section: "Git" }, @@ -137,6 +137,35 @@ Column { }); } + // Location suggestions from OpenStreetMap's Nominatim, the user's choice + // over an offline city list. Its policy allows one request a second and + // asks for an identifying User-Agent: the timer fires 1s after typing + // stops, and a reply to an older query is dropped by its sequence number. + property var places: [] + property string placeQuery: "" + property int placeSeq: 0 + + function lookup(text) { + form.places = []; + form.placeQuery = text.trim(); + placeTimer.restart(); + } + + Timer { + id: placeTimer + interval: 1000 + onTriggered: { + if (form.placeQuery.length < 3) return; + const seq = ++form.placeSeq; + const lang = (Quickshell.env("LANG") || "en").slice(0, 2); + form.run(["curl", "-sf", "--max-time", "5", + "-A", "quickshell-drawer/1.0 (user details page)", + "https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=6" + + "&accept-language=" + lang + "&q=" + encodeURIComponent(form.placeQuery)], + out => { if (seq === form.placeSeq) form.places = U.places(out); }); + } + } + Component.onCompleted: { form.load({}); form.run([form.ctl, "keys"], out => form.keys = U.keys(out)); @@ -250,7 +279,7 @@ Column { // Read-only fields are dimmer, but still selectable for copying. Rectangle { - visible: field.modelData.kind === "text" || field.derived + visible: field.modelData.kind === "text" || field.modelData.kind === "place" || field.derived width: parent.width height: 34 radius: 8 @@ -265,7 +294,46 @@ Column { font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } text: field.derived ? form.derived(field.modelData.key) : form.draft[field.modelData.key] ?? "" - onTextEdited: form.set(field.modelData.key, text) + onTextEdited: { + form.set(field.modelData.key, text); + if (field.modelData.kind === "place") form.lookup(text); + } + onActiveFocusChanged: if (!activeFocus && field.modelData.kind === "place") form.places = [] + } + } + + // Suggestions under a place field; a click takes one. + Column { + visible: field.modelData.kind === "place" && form.places.length > 0 + width: parent.width + Repeater { + model: field.modelData.kind === "place" ? form.places : [] + delegate: Rectangle { + required property string modelData + width: parent.width + height: 30 + radius: 6 + color: placeArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.6) + Text { + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + text: parent.modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + MouseArea { + id: placeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + form.set(field.modelData.key, parent.modelData); + form.placeSeq++; + form.places = []; + } + } + } } } diff --git a/desktop/modules/user/test_user.js b/desktop/modules/user/test_user.js index 3ab22a1..3f1a73d 100644 --- a/desktop/modules/user/test_user.js +++ b/desktop/modules/user/test_user.js @@ -18,7 +18,7 @@ const assert = require("assert"); const src = fs.readFileSync(__dirname + "/User.js", "utf8") .replace(/^\.pragma library$/m, ""); -const U = new Function(src + "\nreturn { parse, keys, locales, gitName };")(); +const U = new Function(src + "\nreturn { parse, keys, locales, gitName, places };")(); // parse: key=value lines; a value may itself hold "=". assert.deepStrictEqual(U.parse("name=Jane Doèr\nlocation=a=b\n\n"), @@ -44,4 +44,24 @@ assert.strictEqual(U.gitName("Ève Àlba"), "Ève À."); assert.strictEqual(U.gitName(" Jane Doèr "), "Jane D."); assert.strictEqual(U.gitName(""), ""); +// places: Nominatim jsonv2 results as "place, region, country", the most +// specific settlement name first, duplicates and repeated parts dropped. +const nominatim = JSON.stringify([ + { name: "Roma", address: { city: "Roma", state: "Lazio", country: "Italia" } }, + { name: "Rome", address: { town: "Rome", county: "Floyd County", state: "Georgia", country: "United States" } }, + { name: "Roma", address: { city: "Roma", state: "Lazio", country: "Italia" } }, + { name: "Monaco", address: { city: "Monaco", country: "Monaco" } }, + { name: "Piazza", address: { village: "Piazza", country: "Italia" } }, + { name: "Somewhere" }, +]); +assert.deepStrictEqual(U.places(nominatim), [ + "Roma, Lazio, Italia", + "Rome, Georgia, United States", + "Monaco", + "Piazza, Italia", + "Somewhere", +]); +assert.deepStrictEqual(U.places(""), []); +assert.deepStrictEqual(U.places("rate limited"), []); + console.log("test_user: ok"); -- cgit v1.2.3