pragma Singleton // Turning a place name into coordinates. // // The weather card needs latitude and longitude, but nobody knows their own // coordinates, and a settings page that demands them is a settings page nobody // changes. Open-Meteo publishes a geocoding endpoint that needs no API key and // no account, which is the same reason the forecast itself uses them. // // Fetched with curl rather than XMLHttpRequest for the same reason as // services/Weather.qml: curl is guaranteed present, and a search that fails // must leave the page usable rather than producing an error popup. // // Only the query is sent. The stored location label never leaves the machine. import Quickshell import Quickshell.Io import QtQuick import qs.config Singleton { id: root // [{ name, admin, country, latitude, longitude, label }] property var results: [] property bool searching: false property string lastError: "" property string lastQuery: "" readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search" Process { id: fetch stdout: StdioCollector { onStreamFinished: root.parse(this.text) } onExited: (exitCode, exitStatus) => { root.searching = false; if (exitCode !== 0) root.lastError = "Could not reach the location service."; } } // Debounced: typing "Denver" should not fire six searches. Timer { id: debounce interval: 350 onTriggered: root.run() } property string pending: "" function search(query: string): void { const trimmed = String(query).trim(); root.pending = trimmed; if (trimmed.length < 2) { root.results = []; root.lastError = ""; debounce.stop(); return; } debounce.restart(); } function run(): void { if (fetch.running || root.pending.length < 2) return; root.searching = true; root.lastError = ""; root.lastQuery = root.pending; // --get with --data-urlencode makes curl do the escaping, so a place // name with spaces or an ampersand cannot alter the request. fetch.exec(["curl", "-s", "--max-time", "10", "--get", "--data-urlencode", `name=${root.pending}`, "--data-urlencode", "count=8", "--data-urlencode", "format=json", root.endpoint]); } function parse(text: string): void { try { const parsed = JSON.parse(text); const out = []; for (const item of (parsed.results ?? [])) { if (typeof item.latitude !== "number" || typeof item.longitude !== "number") continue; const admin = item.admin1 ?? ""; const country = item.country ?? ""; out.push({ name: item.name ?? "", admin: admin, country: country, latitude: item.latitude, longitude: item.longitude, // What the user will see stored as their location label. label: [item.name, admin, country].filter(part => !!part).join(", ") }); } root.results = out; root.lastError = out.length === 0 ? "No places match that name." : ""; } catch (error) { root.results = []; root.lastError = "The location service returned something unreadable."; } } // Stores a chosen place. Coordinates are rounded to four decimals -- roughly // ten metres, far finer than a weather reading resolves, and it keeps a // precise home location out of the settings file. function choose(place: var): bool { const latitude = Math.round(place.latitude * 10000) / 10000; const longitude = Math.round(place.longitude * 10000) / 10000; const label = String(place.label).slice(0, 64); const ok = DesktopPreferences.set("weatherLatitude", latitude) && DesktopPreferences.set("weatherLongitude", longitude) && DesktopPreferences.set("weatherLocation", label); if (!ok) { root.lastError = "That location could not be saved."; return false; } root.results = []; root.lastError = ""; return true; } }