Compare commits
6
Commits
5bca04c5ac
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef6d53e74 | ||
|
|
460217d059 | ||
|
|
3d8bb1b1b3 | ||
|
|
e6a15ee3a6 | ||
|
|
8364fe2141 | ||
|
|
b5f4569233 |
@@ -214,6 +214,88 @@ function zonesToProbe(config) {
|
|||||||
return out
|
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<TAB>+0200" lines back into a lookup.
|
// Parse the probe's "zone<TAB>+0200" lines back into a lookup.
|
||||||
function parseOffsetReport(raw) {
|
function parseOffsetReport(raw) {
|
||||||
var out = {}
|
var out = {}
|
||||||
@@ -241,6 +323,7 @@ if (typeof module !== "undefined" && module.exports) {
|
|||||||
buildClock: buildClock,
|
buildClock: buildClock,
|
||||||
buildBar: buildBar,
|
buildBar: buildBar,
|
||||||
zonesToProbe: zonesToProbe,
|
zonesToProbe: zonesToProbe,
|
||||||
|
parseZoneCatalog: parseZoneCatalog,
|
||||||
parseOffsetReport: parseOffsetReport
|
parseOffsetReport: parseOffsetReport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -34,10 +34,15 @@ Item {
|
|||||||
readonly property color accent: Color.accent
|
readonly property color accent: Color.accent
|
||||||
readonly property color urgent: Color.urgent
|
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: {
|
readonly property var zoneOptions: {
|
||||||
var list = root.zones ? root.zones.allZones : []
|
var list = root.zones ? root.zones.allZones : []
|
||||||
var out = []
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +269,7 @@ Item {
|
|||||||
showLabel: false
|
showLabel: false
|
||||||
value: row.modelData.timezone
|
value: row.modelData.timezone
|
||||||
options: root.zoneOptions
|
options: root.zoneOptions
|
||||||
placeholderText: "Search timezones..."
|
placeholderText: "Search by country or zone..."
|
||||||
triggerLabel: row.modelData.timezone || "Choose a timezone"
|
triggerLabel: row.modelData.timezone || "Choose a timezone"
|
||||||
foreground: root.foreground
|
foreground: root.foreground
|
||||||
accent: root.accent
|
accent: root.accent
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ tiles windows above it exactly as it does for the top bar.
|
|||||||
On any Omarchy 4 machine:
|
On any Omarchy 4 machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
omarchy plugin add <git-url-of-this-repo> --enable --yes
|
omarchy plugin add https://git.nerdnation.co.za/NerdNation/OmarchyClockBar.git --enable --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin lands in `~/.config/omarchy/plugins/nerdnation.clockbar/`, and
|
The plugin lands in `~/.config/omarchy/plugins/nerdnation.clockbar/`, and
|
||||||
@@ -42,10 +42,17 @@ The plugin lands in `~/.config/omarchy/plugins/nerdnation.clockbar/`, and
|
|||||||
To update it later, and to remove it:
|
To update it later, and to remove it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
omarchy plugin update nerdnation.clockbar
|
omarchy plugin update nerdnation.clockbar && omarchy restart shell
|
||||||
omarchy plugin remove nerdnation.clockbar
|
omarchy plugin remove nerdnation.clockbar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**The `omarchy restart shell` is not optional.** `omarchy plugin update` pulls
|
||||||
|
the new files and calls `rescanPlugins`, but that does not reload a
|
||||||
|
`keepLoaded` panel plugin: the shell holds the compiled QML it started with, so
|
||||||
|
the files on disk change and the bar carries on running the old build. It looks
|
||||||
|
exactly like the update silently failing. Restarting the shell is what actually
|
||||||
|
applies it.
|
||||||
|
|
||||||
> Plugins run as unsandboxed QML inside `omarchy-shell`, so `omarchy plugin add`
|
> Plugins run as unsandboxed QML inside `omarchy-shell`, so `omarchy plugin add`
|
||||||
> clones disabled by default and wants you to read the code first. `--enable`
|
> clones disabled by default and wants you to read the code first. `--enable`
|
||||||
> skips that.
|
> skips that.
|
||||||
@@ -56,8 +63,15 @@ Two routes, one source of truth — both read and write
|
|||||||
`~/.config/omarchy/clockbar.json`, and the bar reloads from the file either way.
|
`~/.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,
|
**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
|
rename, retime and remove clocks, set seconds per clock, and set the
|
||||||
shows that zone's current offset so you can see the effect before saving.
|
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:
|
**Edit the file.** It hot-reloads on save, no restart:
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ QtObject {
|
|||||||
// rendered as unknown rather than being silently shown as UTC.
|
// rendered as unknown rather than being silently shown as UTC.
|
||||||
property var offsets: ({})
|
property var offsets: ({})
|
||||||
property string localOffset: ""
|
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 var allZones: []
|
||||||
property bool ready: false
|
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 {
|
property Process zoneListProc: Process {
|
||||||
command: ["sh", "-c",
|
command: ["sh", "-c", root.catalogScript]
|
||||||
// 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"]
|
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
waitForEnd: true
|
waitForEnd: true
|
||||||
onStreamFinished: {
|
onStreamFinished: root.allZones = Model.parseZoneCatalog(text)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,4 +249,75 @@ test("an unresolved clock keeps the width of the format it asked for", function
|
|||||||
assert.strictEqual(bar.right[1].time, "--:--:--")
|
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" : ""))
|
console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))
|
||||||
|
|||||||
Reference in New Issue
Block a user