diff --git a/ClockModel.js b/ClockModel.js index 0765576..484c669 100644 --- a/ClockModel.js +++ b/ClockModel.js @@ -214,6 +214,88 @@ function zonesToProbe(config) { return out } +// ---------------------------------------------------------- zone catalogue +// +// Builds the picker's list: every valid zone, tagged with the countries it +// covers, so a search for "Ghana" finds Africa/Accra without the user knowing +// the zone is named after a city. +// +// The country data is tzdata's own. `zone1970.tab` lists one canonical zone +// per region and `zone.tab` the older per-country set; together they name +// about 420 of the ~600 zones. The rest are backward-compatibility aliases +// (America/Buenos_Aires, Africa/Asmera, US/Pacific), which tzdata ships as +// hard links to their canonical zone — so an alias is matched to its canonical +// name by inode and inherits its countries. Without that, searching a country +// would silently miss its legacy spellings. +function parseZoneCatalog(raw) { + var section = "" + var countryName = {} // "ZA" -> "South Africa" + var zoneCountries = {} // "Africa/Johannesburg" -> ["South Africa", ...] + var zoneInode = {} // zone -> inode + var inodeZones = {} // inode -> [zones] + var listed = [] + + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var line = lines[i].replace(/\s+$/, "") + if (!line) continue + if (line.charAt(0) === "@") { section = line; continue } + if (line.charAt(0) === "#") continue + var f = line.split("\t") + + if (section === "@ISO") { + if (f.length >= 2 && f[0]) countryName[f[0]] = f[1] + + } else if (section === "@ZONE") { + // codes \t coordinates \t zone [\t comment] + if (f.length < 3 || !f[2]) continue + var zone = f[2] + var codes = f[0].split(",") + var acc = zoneCountries[zone] || [] + for (var c = 0; c < codes.length; c++) { + var name = countryName[codes[c]] + // Union across both tab files, in first-seen order: a zone can appear + // in each with a different country set (Europe/Zurich is CH,DE,LI). + if (name && acc.indexOf(name) === -1) acc.push(name) + } + zoneCountries[zone] = acc + + } else if (section === "@INODE") { + if (f.length < 2 || !f[1]) continue + zoneInode[f[1]] = f[0] + if (!inodeZones[f[0]]) inodeZones[f[0]] = [] + inodeZones[f[0]].push(f[1]) + + } else if (section === "@LIST") { + listed.push(line) + } + } + + var out = [] + for (var j = 0; j < listed.length; j++) { + var z = listed[j] + var countries = zoneCountries[z] + if (!countries || !countries.length) { + // An alias: borrow from the zones sharing its inode. Union rather than + // first-match, because those siblings are the same clock under several + // countries' names and each tab file may name a different subset — + // taking only the first made Africa/Asmera searchable as Ethiopia but + // not as Eritrea. + var siblings = inodeZones[zoneInode[z]] || [] + var merged = [] + for (var k = 0; k < siblings.length; k++) { + var sc = zoneCountries[siblings[k]] || [] + for (var m = 0; m < sc.length; m++) if (merged.indexOf(sc[m]) === -1) merged.push(sc[m]) + } + countries = merged + } + out.push({ zone: z, countries: (countries || []).join(", ") }) + } + + out.sort(function (a, b) { return a.zone < b.zone ? -1 : (a.zone > b.zone ? 1 : 0) }) + return out +} + // Parse the probe's "zone+0200" lines back into a lookup. function parseOffsetReport(raw) { var out = {} @@ -241,6 +323,7 @@ if (typeof module !== "undefined" && module.exports) { buildClock: buildClock, buildBar: buildBar, zonesToProbe: zonesToProbe, + parseZoneCatalog: parseZoneCatalog, parseOffsetReport: parseOffsetReport } } diff --git a/Editor.qml b/Editor.qml index 7e1c105..3758e70 100644 --- a/Editor.qml +++ b/Editor.qml @@ -34,10 +34,15 @@ Item { readonly property color accent: Color.accent readonly property color urgent: Color.urgent + // The picker matches on label OR description, so putting the countries in + // the description makes a zone findable by country without knowing that, + // say, Ghana's zone is named after Accra. It shows there too, which is the + // other half of the problem — recognising the right zone once you see it. readonly property var zoneOptions: { var list = root.zones ? root.zones.allZones : [] var out = [] - for (var i = 0; i < list.length; i++) out.push({ value: list[i], label: list[i] }) + for (var i = 0; i < list.length; i++) + out.push({ value: list[i].zone, label: list[i].zone, description: list[i].countries }) return out } @@ -264,7 +269,7 @@ Item { showLabel: false value: row.modelData.timezone options: root.zoneOptions - placeholderText: "Search timezones..." + placeholderText: "Search by country or zone..." triggerLabel: row.modelData.timezone || "Choose a timezone" foreground: root.foreground accent: root.accent diff --git a/README.md b/README.md index 4f0029a..969e47f 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,15 @@ Two routes, one source of truth — both read and write `~/.config/omarchy/clockbar.json`, and the bar reloads from the file either way. **Click the bar.** Opens an editor with a searchable timezone picker: add, -rename, retime and remove clocks, and set the working-hours window. Each row -shows that zone's current offset so you can see the effect before saving. +rename, retime and remove clocks, set seconds per clock, and set the +working-hours window and bar height. Each row shows that zone's current offset +so you can see the effect before saving. + +The picker searches **country names as well as zone names**, so you can find a +zone without knowing it is named after a city — typing `Ghana` finds +`Africa/Accra`. Country data is tzdata's own (`zone1970.tab`, `zone.tab`, +`iso3166.tab`); the legacy alias zones tzdata ships as hard links inherit the +countries of the canonical zone they point at. **Edit the file.** It hot-reloads on save, no restart: diff --git a/Zones.qml b/Zones.qml index 98b675f..6aea204 100644 --- a/Zones.qml +++ b/Zones.qml @@ -20,6 +20,8 @@ QtObject { // rendered as unknown rather than being silently shown as UTC. property var offsets: ({}) property string localOffset: "" + // [{ zone, countries }] for the picker: the zone name plus the countries it + // covers, so it can be found by country as well as by name. property var allZones: [] property bool ready: false @@ -75,23 +77,29 @@ QtObject { } } + // Emits tzdata's own country tables alongside the zone list, tagged into + // sections. The shell only reads files; the joining is done in ClockModel so + // it can be tested. `right/` and `posix/` are pruned because they duplicate + // every zone under a different inode, which would double the output and + // break the alias-by-inode match. + readonly property string catalogScript: + 'printf "@ISO\n"\n' + + 'cat /usr/share/zoneinfo/iso3166.tab 2>/dev/null\n' + + 'printf "@ZONE\n"\n' + + 'cat /usr/share/zoneinfo/zone1970.tab /usr/share/zoneinfo/zone.tab 2>/dev/null\n' + + 'printf "@INODE\n"\n' + + 'find /usr/share/zoneinfo -type f -not -path "*/right/*" -not -path "*/posix/*" ' + + '-printf "%i\\t%P\\n" 2>/dev/null\n' + + 'printf "@LIST\n"\n' + + 'timedatectl list-timezones 2>/dev/null || ' + + 'find /usr/share/zoneinfo -type f -not -path "*/right/*" -not -path "*/posix/*" ' + + '-printf "%P\\n" 2>/dev/null | grep -E "^[A-Z][A-Za-z_+-]*/" | sort' + property Process zoneListProc: Process { - command: ["sh", "-c", - // timedatectl is the canonical list; the zoneinfo walk is the fallback - // for a system where systemd is not answering. - "timedatectl list-timezones 2>/dev/null || " + - "find /usr/share/zoneinfo -type f -printf '%P\\n' 2>/dev/null | grep -E '^[A-Z][A-Za-z_+-]*/' | sort"] + command: ["sh", "-c", root.catalogScript] stdout: StdioCollector { waitForEnd: true - onStreamFinished: { - var out = [] - var lines = String(text || "").split("\n") - for (var i = 0; i < lines.length; i++) { - var line = lines[i].replace(/^\s+|\s+$/g, "") - if (line) out.push(line) - } - root.allZones = out - } + onStreamFinished: root.allZones = Model.parseZoneCatalog(text) } } diff --git a/tests/model.test.js b/tests/model.test.js index d186b74..494a031 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -249,4 +249,75 @@ test("an unresolved clock keeps the width of the format it asked for", function assert.strictEqual(bar.right[1].time, "--:--:--") }) +// A miniature stand-in for the tzdata files, so these tests do not depend on +// the host's tzdata version. Africa/Asmera is a hard link to Africa/Nairobi +// (inode 100) and carries no country row of its own. +var CATALOG = [ + "@ISO", + "# a comment that must be skipped", + "ZA\tSouth Africa", + "LS\tLesotho", + "ER\tEritrea", + "ET\tEthiopia", + "CH\tSwitzerland", + "DE\tGermany", + "@ZONE", + "ZA,LS\t-2615+02800\tAfrica/Johannesburg", + "CH,DE\t+4723+00832\tEurope/Zurich\tBusingen", + "ER\t+1520+03853\tAfrica/Asmara", + "ET\t+0901+03842\tAfrica/Nairobi", + "@INODE", + "100\tAfrica/Nairobi", + "100\tAfrica/Asmara", + "100\tAfrica/Asmera", + "200\tAfrica/Johannesburg", + "300\tEtc/GMT+5", + "@LIST", + "Africa/Johannesburg", + "Europe/Zurich", + "Africa/Asmera", + "Etc/GMT+5" +].join("\n") + +test("the catalogue tags zones with their countries", function () { + var cat = M.parseZoneCatalog(CATALOG) + var by = {} + cat.forEach(function (c) { by[c.zone] = c.countries }) + assert.strictEqual(by["Africa/Johannesburg"], "South Africa, Lesotho", "all codes on the row") + assert.strictEqual(by["Europe/Zurich"], "Switzerland, Germany") +}) + +test("a legacy alias inherits countries from its hard-link siblings", function () { + var by = {} + M.parseZoneCatalog(CATALOG).forEach(function (c) { by[c.zone] = c.countries }) + // Asmera has no row of its own; it shares inode 100 with Asmara and Nairobi, + // so it must be findable by BOTH of their countries, not just the first. + assert.ok(by["Africa/Asmera"].indexOf("Eritrea") !== -1, "Eritrea, via Asmara") + assert.ok(by["Africa/Asmera"].indexOf("Ethiopia") !== -1, "Ethiopia, via Nairobi") +}) + +test("zones with no country are listed, not dropped", function () { + var cat = M.parseZoneCatalog(CATALOG) + var etc = cat.filter(function (c) { return c.zone === "Etc/GMT+5" })[0] + assert.ok(etc, "still offered in the picker") + assert.strictEqual(etc.countries, "", "just has nothing to match a country search") +}) + +test("the catalogue is sorted and only contains listed zones", function () { + var cat = M.parseZoneCatalog(CATALOG) + var names = cat.map(function (c) { return c.zone }) + assert.deepStrictEqual(names, names.slice().sort(), "sorted by zone") + assert.strictEqual(names.indexOf("Africa/Nairobi"), -1, + "Nairobi supplies country data but was not in @LIST, so it is not offered") + assert.strictEqual(names.length, 4) +}) + +test("a malformed or empty catalogue yields an empty list, not a crash", function () { + assert.deepStrictEqual(M.parseZoneCatalog(""), []) + assert.deepStrictEqual(M.parseZoneCatalog(null), []) + assert.deepStrictEqual(M.parseZoneCatalog("garbage\nwith no sections"), []) + assert.deepStrictEqual(M.parseZoneCatalog("@LIST\nAfrica/Cairo"), + [{ zone: "Africa/Cairo", countries: "" }], "a list with no country data still works") +}) + console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))