From 438775928b4c13439c5318f87e9403d458bcd7f3 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Wed, 9 Sep 2026 19:10:59 +0300 Subject: [PATCH] feat: add Timezone Hub plugin --- timezone-hub/README.md | 85 ++++ timezone-hub/bar.luau | 70 +++ timezone-hub/panel.luau | 765 ++++++++++++++++++++++++++++++ timezone-hub/plugin.toml | 121 +++++ timezone-hub/service.luau | 557 ++++++++++++++++++++++ timezone-hub/thumbnail.webp | Bin 0 -> 55714 bytes timezone-hub/translations/en.json | 84 ++++ 7 files changed, 1682 insertions(+) create mode 100644 timezone-hub/README.md create mode 100644 timezone-hub/bar.luau create mode 100644 timezone-hub/panel.luau create mode 100644 timezone-hub/plugin.toml create mode 100644 timezone-hub/service.luau create mode 100644 timezone-hub/thumbnail.webp create mode 100644 timezone-hub/translations/en.json diff --git a/timezone-hub/README.md b/timezone-hub/README.md new file mode 100644 index 000000000..4058a05bc --- /dev/null +++ b/timezone-hub/README.md @@ -0,0 +1,85 @@ +# Timezone Hub + +Timezone Hub is a multi-timezone clock for comparing cities, working hours, +dates, and daylight at a glance. It can also change the device timezone from +the Noctalia panel. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `ahmedhossamdev/timezone-hub` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `service` | + +## Requirements + +- Install `timedatectl` on `PATH` to detect, list, and change timezones. +- Install `pkexec` on `PATH` and run a polkit authentication agent to authorize + device-wide timezone changes. +- `/usr/share/zoneinfo/zone1970.tab` or `/usr/share/zoneinfo/zone.tab` is + optional and supplies representative coordinates for sunrise and sunset. + +## Usage + +Add the **Timezone Hub** bar widget in **Settings → Bar**. Click the widget to +open the comparison panel, or use: + +```sh +noctalia msg panel-toggle ahmedhossamdev/timezone-hub:panel +``` + +The panel always shows the device timezone first. Use **Add a timezone** to +search the IANA timezone database and add comparison cities. Drag the grip to +reorder a city, use its eye button to show or hide it in the bar widget, use +the pin button to make it the device timezone, and use the trash button to +remove it. Up to three timezones can appear in the bar. + +The pencil button changes the device timezone directly. This launches a polkit +authentication prompt because the setting applies to the whole system. The +settings button opens the plugin's settings page. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `time_format` | `select` | `24h` | Uses 24-hour or 12-hour times. | +| `show_date_in_bar` | `bool` | `true` | Shows each selected timezone's date in the bar widget. | +| `show_date_in_panel` | `bool` | `true` | Shows the local date in every panel row. | +| `show_relative_time` | `bool` | `true` | Shows ahead/behind, day, and working-hours descriptions. | +| `show_sun_times` | `bool` | `false` | Shows locally calculated sunrise and sunset times when timezone coordinate data is available. | +| `date_format` | `select` | `weekday` | Formats dates as weekday, short, or ISO. | +| `hours_before` | `int` | `2` | Number of hours before the selected time shown in each hour strip, from 0 to 6. | +| `hours_after` | `int` | `9` | Number of hours after the selected time shown in each hour strip, from 3 to 16. | +| `highlight_work_hours` | `bool` | `true` | Highlights the configured working-hour range. | +| `work_hour_start` | `int` | `9` | Working day start hour, from 0 to 23. | +| `work_hour_end` | `int` | `17` | Working day end hour, from 1 to 24. | + +## IPC + +Open the panel: + +```sh +noctalia msg panel-toggle ahmedhossamdev/timezone-hub:panel +``` + +Manage comparison timezones through the `service` entry: + +```sh +noctalia msg plugin ahmedhossamdev/timezone-hub:service all add "America/Los_Angeles" +noctalia msg plugin ahmedhossamdev/timezone-hub:service all remove "America/Los_Angeles" +noctalia msg plugin ahmedhossamdev/timezone-hub:service all list +``` + +`add` and `remove` accept one exact IANA timezone. `list` returns the saved +comparison timezones. + +## Notes + +- The plugin writes comparison timezones and bar selections to its Noctalia + plugin data directory so they survive shell and plugin restarts. +- It makes no network requests. +- Sunrise and sunset are calculated locally from representative coordinates in + the system timezone database. Times can vary within large timezones, and no + value is shown for polar dates on which the sun does not rise or set. +- Changing the device timezone spawns `pkexec timedatectl set-timezone` and may + show an authentication prompt. diff --git a/timezone-hub/bar.luau b/timezone-hub/bar.luau new file mode 100644 index 000000000..4cd26c3b5 --- /dev/null +++ b/timezone-hub/bar.luau @@ -0,0 +1,70 @@ +--!nonstrict +-- Timezone Hub bar widget — shows the device's local time (or, if chosen +-- in the panel, a comparison city's time instead) with its zone +-- abbreviation. Click opens the panel. + +local rows = {} +local DEVICE_BAR_KEY = "__device__" +local barTzs = { DEVICE_BAR_KEY } + +local function findRow(tz) + if tz == DEVICE_BAR_KEY then return rows[1] end + for _, row in ipairs(rows) do + if row.tz == tz then + return row + end + end + return nil +end + +local function render() + local selectedRows = {} + for _, zone in ipairs(barTzs) do + local row = findRow(zone) + if row ~= nil then table.insert(selectedRows, row) end + end + if #selectedRows == 0 and rows[1] ~= nil then + table.insert(selectedRows, rows[1]) + end + if #selectedRows == 0 then + barWidget.setGlyph("world") + barWidget.setText("--:--") + return + end + + barWidget.setGlyph("world") + local segments = {} + for _, row in ipairs(selectedRows) do + local label = row.tz and row.tz:match(".*/(.*)$") or nil + label = (label or row.label or "Time"):gsub("_", " ") + local segment = label + if noctalia.getConfig("show_date_in_bar") ~= false + and row.dateText and row.dateText ~= "" + then + segment = segment .. " " .. row.dateText + end + segment = segment .. " " .. (row.timeText or "--:--") + table.insert(segments, segment) + end + barWidget.setText(table.concat(segments, " · ")) +end + +function update() + render() +end + +function onClick() + noctalia.togglePanel("ahmedhossamdev/timezone-hub:panel") +end + +noctalia.state.watch("timezone_hub.rows", function(value) + rows = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.bar_tzs", function(value) + barTzs = type(value) == "table" and value or { DEVICE_BAR_KEY } + render() +end) + +render() diff --git a/timezone-hub/panel.luau b/timezone-hub/panel.luau new file mode 100644 index 000000000..71d205ff2 --- /dev/null +++ b/timezone-hub/panel.luau @@ -0,0 +1,765 @@ +--!nonstrict +-- Timezone Hub panel — device timezone pinned first, then every comparison +-- city: label, live time, UTC offset, delta vs device, and a compact +-- (non-scrolling) hour-offset strip with the current hour highlighted and +-- work hours shaded. A second view is a shared searchable IANA-zone picker, +-- used both to add a comparison city and to change the device timezone. + +local DRAG_TYPE = "timezone-hub-city" +local WEEKDAY_NAMES = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } + +local rows = {} +local comparisons = {} +local deviceTz = "" +local changingDeviceTz = false +local DEVICE_BAR_KEY = "__device__" +local barTzs = { DEVICE_BAR_KEY } +local allZones = {} +local allZonesLoaded = false + +local view = "list" -- "list" | "picker" +local pickerMode = "add" -- "add" | "device" +local query = "" +local queryKey = 0 +local pendingDelete = nil -- tz pending trash confirmation +local hourOffset = 0 -- hours the strip window is shifted from "now" (paging, since ui.scroll has no horizontal mode) + +-- --------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------- +local function rowFor(tz) + for _, row in ipairs(rows) do + if row.tz == tz then + return row + end + end + return nil +end + +local function shortLabel(tz) + local slash = tz:match(".*/(.*)$") + if slash ~= nil then + return (slash:gsub("_", " ")) + end + return tz +end + +local function roundHalf(x) + if x < 0 then + return -roundHalf(-x) + end + return math.floor(x * 2 + 0.5) / 2 +end + +local function diffLabel(offsetMinutes, deviceOffsetMinutes) + if offsetMinutes == nil or deviceOffsetMinutes == nil then + return "" + end + local diff = roundHalf((offsetMinutes - deviceOffsetMinutes) / 60) + if diff == 0 then + return noctalia.tr("row.same-time") + end + return (diff > 0 and "+" or "") .. tostring(diff) .. "h" +end + +local function barZoneSelected(key) + for _, selected in ipairs(barTzs) do + if selected == key then return true end + end + return false +end + +local function daysBeforeYear(year) + local previous = year - 1 + return 365 * previous + + math.floor(previous / 4) + - math.floor(previous / 100) + + math.floor(previous / 400) +end + +local function localDateInfo(zone) + local raw = noctalia.formatTime("%Y|%j|%H", nil, zone) + local year, day, hour = raw:match("(%d+)|(%d+)|(%d+)") + if year == nil then return nil end + return { + ordinal = daysBeforeYear(tonumber(year)) + tonumber(day), + hour = tonumber(hour), + } +end + +local function offsetDescription(offsetMinutes, deviceOffsetMinutes) + if offsetMinutes == nil or deviceOffsetMinutes == nil then return nil end + local difference = offsetMinutes - deviceOffsetMinutes + if difference == 0 then return noctalia.tr("relative.same-time") end + local absolute = math.abs(difference) + local hours = math.floor(absolute / 60) + local minutes = absolute % 60 + local amount + if hours == 0 then + amount = tostring(minutes) .. "m" + elseif minutes == 0 then + amount = tostring(hours) .. "h" + else + amount = tostring(hours) .. "h " .. tostring(minutes) .. "m" + end + return noctalia.tr(difference > 0 and "relative.ahead" or "relative.behind", { offset = amount }) +end + +local function relativeDescriptions(row, deviceRow, workStart, workEnd) + if noctalia.getConfig("show_relative_time") == false then + local fallback = row.isDevice and "" or diffLabel(row.offsetMinutes, deviceRow and deviceRow.offsetMinutes or nil) + return fallback ~= "" and { fallback } or {} + end + + local parts = {} + if not row.isDevice then + local offset = offsetDescription(row.offsetMinutes, deviceRow and deviceRow.offsetMinutes or nil) + if offset ~= nil then table.insert(parts, offset) end + end + + local here = localDateInfo(row.tz) + local device = deviceRow and localDateInfo(deviceRow.tz) or nil + if here ~= nil and device ~= nil then + local dayDifference = here.ordinal - device.ordinal + if dayDifference ~= 0 then + local day + if dayDifference == -1 then + day = noctalia.tr("relative.yesterday") + elseif dayDifference == 1 then + day = noctalia.tr("relative.tomorrow") + else + day = noctalia.tr("relative.day-offset", { days = (dayDifference > 0 and "+" or "") .. tostring(dayDifference) }) + end + local period + if here.hour < 5 then + period = noctalia.tr("relative.night") + elseif here.hour < 12 then + period = noctalia.tr("relative.morning") + elseif here.hour < 17 then + period = noctalia.tr("relative.afternoon") + elseif here.hour < 21 then + period = noctalia.tr("relative.evening") + else + period = noctalia.tr("relative.night") + end + table.insert(parts, day .. " " .. period) + end + table.insert(parts, noctalia.tr( + here.hour >= workStart and here.hour < workEnd + and "relative.inside-work-hours" + or "relative.outside-work-hours" + )) + end + return parts +end + +local function flooredHourEpoch() + return math.floor(math.floor(noctalia.nowMs() / 1000) / 3600) * 3600 + hourOffset * 3600 +end + +local function cellInfo(zone, colEpoch) + local raw = noctalia.formatTime("%w|%d|%H", colEpoch, zone) + local w, d, h = raw:match("(%d+)|(%d+)|(%d+)") + if w == nil then + return nil + end + return { weekday = tonumber(w), day = tonumber(d), hour = tonumber(h) } +end + +local function cellLabel(zone, colEpoch, use24h) + local info = cellInfo(zone, colEpoch) + if info == nil then + return "…" + end + if info.hour == 0 then + return WEEKDAY_NAMES[info.weekday + 1] .. " " .. info.day + end + if use24h then + return tostring(info.hour) + end + local h12 = info.hour % 12 + if h12 == 0 then + h12 = 12 + end + return tostring(h12) +end + +local function cellIsWork(zone, colEpoch, workStart, workEnd) + local info = cellInfo(zone, colEpoch) + return info ~= nil and info.hour >= workStart and info.hour < workEnd +end + +-- --------------------------------------------------------------------- +-- Drag & drop (comparison rows only) +-- --------------------------------------------------------------------- +local function insertionZone(index) + return ui.dropZone({ + key = "gap-" .. index, + accepts = { DRAG_TYPE }, + value = tostring(index), + onDrop = "onZoneDropped", + height = 3, + radius = 4, + expandOnDrag = true, + hitSlop = 20, + }) +end + +function onZoneDropped(payload, value) + local insertAt = tonumber(value) + if type(payload) ~= "string" or payload == "" or insertAt == nil then + return + end + noctalia.state.set("timezone_hub.cmd", { op = "reorder", tz = payload, index = insertAt }) +end + +-- --------------------------------------------------------------------- +-- Row rendering +-- --------------------------------------------------------------------- +-- The strip gets an explicit pixel budget rather than flexGrow: flexGrow +-- competing against fixed-width siblings (grip/info/actions) in the same +-- row was not reliably claiming its full share, leaving a dead gap before +-- the action buttons. An explicit width, divided evenly among however many +-- columns are configured, is deterministic instead. +local STRIP_WIDTH = 320 +local CELL_GAP = 1 + +local function hourStrip(zone, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd) + local floored = flooredHourEpoch() + local columnCount = hoursBefore + hoursAfter + 1 + local cellWidth = (STRIP_WIDTH - (columnCount - 1) * CELL_GAP) / columnCount + local cells = {} + for col = -hoursBefore, hoursAfter do + local isNow = col == -hourOffset + local cellProps = { + key = "cell-" .. tostring(col), + width = cellWidth, + align = "center", + justify = "center", + radius = 3, + } + if isNow then + cellProps.fill = "primary/0.18" + elseif highlightWork and cellIsWork(zone, floored + col * 3600, workStart, workEnd) then + cellProps.fill = "on_surface/0.05" + end + table.insert( + cells, + ui.column(cellProps, { + ui.label({ + text = cellLabel(zone, floored + col * 3600, use24h), + fontSize = isNow and 11 or 9, + fontWeight = isNow and "bold" or "normal", + color = isNow and "primary" or "on_surface_variant", + textAlign = "center", + }), + }) + ) + end + return ui.row({ width = STRIP_WIDTH, gap = CELL_GAP }, cells) +end + +local ACTION_BUTTON_SIZE = 28 + +local function rowActions(row, isDeviceRow) + if not isDeviceRow and pendingDelete == row.tz then + return { + ui.button({ + glyph = "check", + glyphSize = 14, + variant = "destructive", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.confirm-remove"), + onClick = function() + noctalia.state.set("timezone_hub.cmd", { op = "remove", tz = row.tz }) + pendingDelete = nil + end, + }), + ui.button({ + glyph = "close", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.cancel-remove"), + onClick = function() + pendingDelete = nil + render() + end, + }), + } + end + + local barKey = isDeviceRow and DEVICE_BAR_KEY or row.tz + local starSelected = barZoneSelected(barKey) + local actions = { + ui.button({ + glyph = starSelected and "eye" or "eye-off", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + selected = starSelected, + tooltip = noctalia.tr(starSelected and "row.hide-from-bar" or "row.show-in-bar"), + onClick = function() + noctalia.state.set("timezone_hub.cmd", { op = "toggle_bar_zone", tz = isDeviceRow and "" or row.tz }) + end, + }), + } + + if not isDeviceRow then + table.insert( + actions, + ui.button({ + glyph = "pin-filled", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.set-device"), + enabled = not changingDeviceTz, + onClick = function() + noctalia.state.set("timezone_hub.cmd", { op = "set_device", tz = row.tz }) + end, + }) + ) + + table.insert( + actions, + ui.button({ + glyph = "trash", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.remove"), + onClick = function() + pendingDelete = row.tz + render() + end, + }) + ) + end + + return actions +end + +-- Every row shares the same fixed-width leading (grip) and trailing +-- (actions) slots regardless of how many action buttons or whether a grip +-- is shown, so the strip's flexGrow space is identical across rows and the +-- hour columns line up between the device row and every comparison row. +local GRIP_SLOT_WIDTH = 20 +-- Three explicit 28px controls plus 8px inset on either side. Keeping the +-- hitboxes fixed prevents the final action from painting beyond the card. +local ACTIONS_SLOT_WIDTH = 100 +local SCROLLBAR_GUTTER = 16 + +local function cityRow(row, isDeviceRow, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd) + local gripSlot + if isDeviceRow then + gripSlot = ui.column({ width = GRIP_SLOT_WIDTH }, {}) + else + gripSlot = ui.dragSource({ + key = "grip-" .. row.tz, + dragType = DRAG_TYPE, + payload = row.tz, + previewAncestor = 1, + liftFromLayout = true, + width = GRIP_SLOT_WIDTH, + height = 20, + align = "center", + justify = "center", + tooltip = noctalia.tr("row.drag-tooltip"), + }, { + ui.glyph({ name = "menu-2", size = 12, color = "on_surface_variant" }), + }) + end + + local infoItems = { + ui.label({ text = row.label, fontWeight = "bold", fontSize = 12, maxLines = 1 }), + ui.label({ text = row.tz, fontSize = 9, color = "on_surface_variant", maxLines = 1 }), + ui.row({ gap = 4, align = "center", justify = "start" }, { + ui.label({ text = row.timeText or "--:--", fontWeight = "bold", fontSize = 14, color = isDeviceRow and "primary" or "on_surface" }), + ui.label({ text = row.abbrev or "", fontSize = 9, color = "on_surface_variant" }), + }), + } + if noctalia.getConfig("show_date_in_panel") ~= false + and row.dateText and row.dateText ~= "" + then + table.insert(infoItems, ui.label({ + text = row.dateText, + fontSize = 9, + color = "on_surface_variant", + maxLines = 1, + })) + end + if row.sunriseText and row.sunsetText then + table.insert(infoItems, ui.row({ gap = 3, align = "center", justify = "start" }, { + ui.glyph({ name = "sunrise", size = 10, color = "on_surface_variant" }), + ui.label({ text = row.sunriseText, fontSize = 9, color = "on_surface_variant" }), + ui.glyph({ name = "sunset", size = 10, color = "on_surface_variant" }), + ui.label({ text = row.sunsetText, fontSize = 9, color = "on_surface_variant" }), + })) + end + local relativeLines = relativeDescriptions(row, rows[1], workStart, workEnd) + for _, relative in ipairs(relativeLines) do + table.insert(infoItems, ui.label({ + text = relative, + fontSize = 9, + color = "primary", + maxLines = 1, + })) + end + local info = ui.column({ width = 132, gap = 1, justify = "start" }, infoItems) + + local strip = hourStrip(row.tz, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd) + + local actionsSlot = ui.row( + { + width = ACTIONS_SLOT_WIDTH, + gap = 0, + paddingH = 8, + align = "center", + justify = "end", + }, + rowActions(row, isDeviceRow) + ) + + return ui.row({ + key = "row-" .. row.key, + gap = 6, + align = "center", + justify = "start", + paddingV = 4, + paddingH = 6, + fill = "surface_variant/0.25", + radius = 6, + }, { gripSlot, info, strip, actionsSlot }) +end + +-- --------------------------------------------------------------------- +-- List view +-- --------------------------------------------------------------------- +local function listView() + local hoursBefore = noctalia.getConfig("hours_before") or 1 + local hoursAfter = noctalia.getConfig("hours_after") or 5 + local use24h = (noctalia.getConfig("time_format") or "24h") == "24h" + local highlightWork = noctalia.getConfig("highlight_work_hours") + if highlightWork == nil then + highlightWork = true + end + local workStart = noctalia.getConfig("work_hour_start") or 9 + local workEnd = noctalia.getConfig("work_hour_end") or 17 + + local body = {} + + table.insert( + body, + ui.row({ align = "center", justify = "space_between" }, { + ui.row({ gap = 8, align = "center", justify = "start", flexGrow = 1 }, { + ui.glyph({ name = "world", size = 20, color = "primary" }), + ui.column({ gap = 0, justify = "start" }, { + ui.label({ text = noctalia.tr("panel.title"), fontSize = 14, fontWeight = "bold" }), + ui.label({ + text = deviceTz ~= "" and (noctalia.tr("panel.subtitle") .. " " .. deviceTz) or noctalia.tr("panel.detecting"), + fontSize = 10, + color = "on_surface_variant", + }), + }), + }), + ui.row({ gap = 2, justify = "start" }, { + ui.button({ + glyph = "pencil", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("row.change-device"), + enabled = not changingDeviceTz, + onClick = function() + openPicker("device") + end, + }), + ui.button({ + glyph = "plus", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.add"), + enabled = #comparisons < 6, + onClick = function() + openPicker("add") + end, + }), + ui.button({ + glyph = "settings", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.open-settings"), + onClick = function() + noctalia.openSettings() + end, + }), + ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", onClick = function() + panel.close() + end }), + }), + }) + ) + + table.insert(body, ui.separator({})) + + local windowSpan = hoursBefore + hoursAfter + 1 + table.insert( + body, + ui.row({ gap = 4, align = "center", justify = "center" }, { + ui.button({ + glyph = "chevron-left", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.earlier"), + onClick = function() + hourOffset -= windowSpan + render() + end, + }), + ui.button({ + text = hourOffset == 0 and noctalia.tr("panel.now") or ((hourOffset > 0 and "+" or "") .. tostring(hourOffset) .. "h"), + variant = hourOffset == 0 and "ghost" or "outline", + controlSize = "sm", + enabled = hourOffset ~= 0, + tooltip = noctalia.tr("panel.jump-to-now"), + onClick = function() + hourOffset = 0 + render() + end, + }), + ui.button({ + glyph = "chevron-right", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.later"), + onClick = function() + hourOffset += windowSpan + render() + end, + }), + }) + ) + + if rows[1] ~= nil then + -- Comparison rows live in a scroll viewport, which reserves space for its + -- scrollbar. Give the fixed device row the same right gutter so both card + -- widths and every fixed-width child column line up exactly. + table.insert(body, ui.column({ + paddingRight = SCROLLBAR_GUTTER, + align = "stretch", + }, { + cityRow(rows[1], true, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd), + })) + end + + local list = {} + for i, entry in ipairs(comparisons) do + local row = rowFor(entry.tz) + if row ~= nil then + table.insert(list, insertionZone(i)) + table.insert(list, cityRow(row, false, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd)) + end + end + if #comparisons > 0 then + table.insert(list, insertionZone(#comparisons + 1)) + end + + if #list == 0 then + table.insert(body, ui.label({ + text = noctalia.tr("settings.no-comparisons"), + fontSize = 11, + color = "on_surface_variant", + })) + else + table.insert(body, ui.scroll({ + flexGrow = 1, + gap = 4, + padding = 0, + align = "stretch", + justify = "start", + }, list)) + end + + return ui.column({ flexGrow = 1, gap = 6, padding = 8, justify = "start" }, body) +end + +-- --------------------------------------------------------------------- +-- Picker view +-- --------------------------------------------------------------------- +local function filteredZones() + local q = noctalia.string.trim(query):lower() + local out = {} + for _, z in ipairs(allZones) do + if q == "" or z:lower():find(q, 1, true) ~= nil then + table.insert(out, z) + if #out >= 100 then + break + end + end + end + return out +end + +local function pick(tz) + if pickerMode == "device" then + noctalia.state.set("timezone_hub.cmd", { op = "set_device", tz = tz }) + else + noctalia.state.set("timezone_hub.cmd", { op = "add", tz = tz, label = shortLabel(tz) }) + end + view = "list" + render() +end + +function onPickerSubmit(_value) + local matches = filteredZones() + if #matches == 1 then + pick(matches[1]) + end +end + +local function pickerView() + local body = { + ui.row({ gap = 6, align = "center", justify = "start" }, { + ui.button({ + glyph = "arrow-left", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("picker.back"), + onClick = function() + view = "list" + render() + end, + }), + ui.label({ + text = pickerMode == "device" and noctalia.tr("picker.title-device") or noctalia.tr("picker.title-add"), + fontSize = 13, + fontWeight = "bold", + flexGrow = 1, + }), + }), + ui.input({ + key = "search-" .. tostring(queryKey), + value = query, + placeholder = noctalia.tr("picker.search-placeholder"), + submitOnEnter = true, + onChange = function(value) + query = value + render() + end, + onSubmit = "onPickerSubmit", + }), + } + + if not allZonesLoaded then + table.insert(body, ui.label({ text = noctalia.tr("picker.loading"), fontSize = 10, color = "on_surface_variant" })) + end + + local matches = filteredZones() + if allZonesLoaded and #matches == 0 then + table.insert(body, ui.label({ text = noctalia.tr("picker.empty"), fontSize = 11, color = "on_surface_variant" })) + else + local buttons = {} + for _, z in ipairs(matches) do + table.insert( + buttons, + ui.button({ + key = "zone-" .. z, + text = (z:gsub("_", " ")), + glyph = "pin-filled", + variant = "ghost", + contentAlign = "start", + onClick = function() + pick(z) + end, + }) + ) + end + table.insert(body, ui.scroll({ flexGrow = 1, gap = 2, align = "stretch", justify = "start" }, buttons)) + end + + return ui.column({ flexGrow = 1, gap = 6, padding = 8, justify = "start" }, body) +end + +-- --------------------------------------------------------------------- +-- Render dispatch +-- --------------------------------------------------------------------- +function render() + if view == "picker" then + panel.render(pickerView()) + else + panel.render(listView()) + end +end + +function openPicker(mode) + pickerMode = mode + view = "picker" + query = "" + queryKey += 1 + pendingDelete = nil + if not allZonesLoaded then + noctalia.state.set("timezone_hub.cmd", { op = "load_zones" }) + end + render() +end + +-- --------------------------------------------------------------------- +-- Lifecycle +-- --------------------------------------------------------------------- +function onOpen(_context) + view = "list" + pendingDelete = nil + hourOffset = 0 + panel.setWantsSecondTicks(true) + render() +end + +noctalia.state.watch("timezone_hub.rows", function(value) + rows = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.comparisons", function(value) + comparisons = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.device_tz", function(value) + deviceTz = type(value) == "string" and value or "" + render() +end) + +noctalia.state.watch("timezone_hub.changing_device_tz", function(value) + changingDeviceTz = value == true + render() +end) + +noctalia.state.watch("timezone_hub.bar_tzs", function(value) + barTzs = type(value) == "table" and value or { DEVICE_BAR_KEY } + render() +end) + +noctalia.state.watch("timezone_hub.all_zones", function(value) + allZones = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.all_zones_loaded", function(value) + allZonesLoaded = value == true + render() +end) diff --git a/timezone-hub/plugin.toml b/timezone-hub/plugin.toml new file mode 100644 index 000000000..6fe6a3259 --- /dev/null +++ b/timezone-hub/plugin.toml @@ -0,0 +1,121 @@ +# Timezone Hub — compare your device timezone against other cities and +# change your device timezone, from the bar and a panel. + +id = "ahmedhossamdev/timezone-hub" +name = "Timezone Hub" +version = "2.1.0" +plugin_api = 24 +author = "ahmedhossamdev" +license = "MIT" +icon = "world" +description = "Change your device timezone and compare it against other cities, with live times and an hour-offset strip for each." +tags = ["bar", "clock", "time", "utility"] +dependencies = ["timedatectl", "pkexec"] + +[[widget]] +id = "bar" +entry = "bar.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 660 +height = 560 +placement = "attached" +position = "auto" +open_near_click = true + +[[service]] +id = "service" +entry = "service.luau" + +[[setting]] +key = "time_format" +type = "select" +label_key = "settings.time-format" +default = "24h" +options = [ + { value = "24h", label_key = "settings.format-24h" }, + { value = "12h", label_key = "settings.format-12h" }, +] + +[[setting]] +key = "show_date_in_bar" +type = "bool" +label_key = "settings.show-date-in-bar" +description_key = "settings.show-date-in-bar-desc" +default = true + +[[setting]] +key = "show_date_in_panel" +type = "bool" +label_key = "settings.show-date-in-panel" +description_key = "settings.show-date-in-panel-desc" +default = true + +[[setting]] +key = "show_relative_time" +type = "bool" +label_key = "settings.show-relative-time" +description_key = "settings.show-relative-time-desc" +default = true + +[[setting]] +key = "show_sun_times" +type = "bool" +label_key = "settings.show-sun-times" +description_key = "settings.show-sun-times-desc" +default = false + +[[setting]] +key = "date_format" +type = "select" +label_key = "settings.date-format" +default = "weekday" +options = [ + { value = "weekday", label_key = "settings.date-format-weekday" }, + { value = "short", label_key = "settings.date-format-short" }, + { value = "iso", label_key = "settings.date-format-iso" }, +] + +[[setting]] +key = "hours_before" +type = "int" +label_key = "settings.hours-before" +default = 2 +min = 0 +max = 6 + +[[setting]] +key = "hours_after" +type = "int" +label_key = "settings.hours-after" +default = 9 +min = 3 +max = 16 + +[[setting]] +key = "highlight_work_hours" +type = "bool" +label_key = "settings.highlight-work-hours" +default = true + +[[setting]] +key = "work_hour_start" +type = "int" +label_key = "settings.work-hours-range" +description_key = "settings.work-hour-start-desc" +default = 9 +min = 0 +max = 23 +visible_when = { key = "highlight_work_hours", values = ["true"] } + +[[setting]] +key = "work_hour_end" +type = "int" +label_key = "settings.work-hours-range" +description_key = "settings.work-hour-end-desc" +default = 17 +min = 1 +max = 24 +visible_when = { key = "highlight_work_hours", values = ["true"] } diff --git a/timezone-hub/service.luau b/timezone-hub/service.luau new file mode 100644 index 000000000..08ff128fb --- /dev/null +++ b/timezone-hub/service.luau @@ -0,0 +1,557 @@ +--!nonstrict +-- Timezone Hub service — owns the device timezone, the comparison-city +-- list, and the bar-widget zone choices. Publishes computed rows every +-- second so the bar widget and panel stay thin clients of this state. +-- +-- Persistence: pluginDataDir()/data.json -> +-- { comparisons = {...}, barTzs = { "__device__", "Area/City", ... } } +-- State: +-- timezone_hub.device_tz -> string +-- timezone_hub.changing_device_tz -> bool +-- timezone_hub.rows -> { { key, label, tz, isDevice, index, +-- timeText, dateText, offsetMinutes, +-- abbrev }, ... } +-- timezone_hub.bar_tzs -> up to three zone keys; "__device__" follows the device zone +-- timezone_hub.all_zones -> { "Area/City", ... } +-- timezone_hub.all_zones_loaded -> bool +-- Commands (timezone_hub.cmd): +-- { op = "add"|"remove"|"reorder"|"set_device"|"toggle_bar_zone"|"load_zones", ... } + +noctalia.setUpdateInterval(1000) + +local FALLBACK_ZONES = { + "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Sao_Paulo", "America/Mexico_City", "America/Toronto", "America/Vancouver", + "America/Bogota", "America/Lima", "America/Argentina/Buenos_Aires", + "Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Madrid", "Europe/Rome", + "Europe/Amsterdam", "Europe/Zurich", "Europe/Stockholm", "Europe/Athens", + "Europe/Istanbul", "Europe/Moscow", "Europe/Warsaw", "Europe/Dublin", + "Africa/Cairo", "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi", + "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata", "Asia/Dhaka", "Asia/Bangkok", + "Asia/Jakarta", "Asia/Singapore", "Asia/Hong_Kong", "Asia/Shanghai", + "Asia/Manila", "Asia/Seoul", "Asia/Tokyo", + "Australia/Perth", "Australia/Brisbane", "Australia/Sydney", "Australia/Melbourne", + "Pacific/Auckland", "Pacific/Honolulu", "UTC", +} + +local deviceTz = "" +local changingDeviceTz = false +local comparisons = {} -- ordered { { tz, label }, ... } +local DEVICE_BAR_KEY = "__device__" +local MAX_BAR_ZONES = 3 +local barTzs = { DEVICE_BAR_KEY } +local allZones = {} +local allZonesLoaded = false +local allZonesLoading = false +local allZonesLoadStartedMs = nil +local ZONES_LOAD_TIMEOUT_MS = 8000 + +-- --------------------------------------------------------------------- +-- Persistence +-- --------------------------------------------------------------------- +local function dataPath() + local dir = noctalia.pluginDataDir() + if dir == nil then + return nil + end + return dir .. "/data.json" +end + +local function saveData() + local path = dataPath() + if path == nil then + return + end + local encoded = noctalia.json.encode({ comparisons = comparisons, barTzs = barTzs }) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +local function loadData() + comparisons = {} + barTzs = { DEVICE_BAR_KEY } + local path = dataPath() + if path ~= nil then + local raw = noctalia.readFile(path) + if raw ~= nil then + local decoded = noctalia.json.decode(raw) + if type(decoded) == "table" then + if type(decoded.comparisons) == "table" then + for _, entry in ipairs(decoded.comparisons) do + if type(entry) == "table" and type(entry.tz) == "string" and entry.tz ~= "" then + table.insert(comparisons, { tz = entry.tz, label = entry.label or entry.tz }) + end + end + end + if type(decoded.barTzs) == "table" then + local restored = {} + local seen = {} + for _, zone in ipairs(decoded.barTzs) do + if type(zone) == "string" and zone ~= "" and not seen[zone] and #restored < MAX_BAR_ZONES then + table.insert(restored, zone) + seen[zone] = true + end + end + if #restored > 0 then barTzs = restored end + elseif type(decoded.barTz) == "string" then + -- Migrate the v2.0 single-zone selection. An empty string used to + -- mean "follow the device timezone". + barTzs = { decoded.barTz == "" and DEVICE_BAR_KEY or decoded.barTz } + end + end + end + end +end + +-- --------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------- +local function shortLabel(zone) + local slash = zone:match(".*/(.*)$") + if slash ~= nil then + return (slash:gsub("_", " ")) + end + return zone +end + +local function indexOfComparison(zone) + for i, entry in ipairs(comparisons) do + if entry.tz == zone then + return i + end + end + return nil +end + +-- "+0200" -> 120, "-0930" -> -570 +local function parseOffsetMinutes(z) + if type(z) ~= "string" or #z < 5 then + return nil + end + local sign = z:sub(1, 1) == "-" and -1 or 1 + local hh = tonumber(z:sub(2, 3)) or 0 + local mm = tonumber(z:sub(4, 5)) or 0 + return sign * (hh * 60 + mm) +end + +local function timeText(zone) + local raw = noctalia.formatTime("%H|%M", nil, zone) + local hh, mm = raw:match("(%d+)|(%d+)") + if hh == nil then + return "--:--" + end + if noctalia.getConfig("time_format") == "12h" then + local h12 = tonumber(hh) % 12 + if h12 == 0 then + h12 = 12 + end + return string.format("%d:%s %s", h12, mm, tonumber(hh) < 12 and "AM" or "PM") + end + return hh .. ":" .. mm +end + +local function clockTextFromHours(hours) + local totalMinutes = math.floor(hours * 60 + 0.5) % (24 * 60) + local hh = math.floor(totalMinutes / 60) + local mm = totalMinutes % 60 + if noctalia.getConfig("time_format") == "12h" then + local h12 = hh % 12 + if h12 == 0 then h12 = 12 end + return string.format("%d:%02d %s", h12, mm, hh < 12 and "AM" or "PM") + end + return string.format("%02d:%02d", hh, mm) +end + +local function dateText(zone) + local style = noctalia.getConfig("date_format") or "weekday" + local format = "%a, %b %d" + if style == "short" then + format = "%b %d" + elseif style == "iso" then + format = "%Y-%m-%d" + end + return noctalia.formatTime(format, nil, zone) +end + +-- --------------------------------------------------------------------- +-- Sunrise/sunset (local calculation; no network) +-- --------------------------------------------------------------------- +local zoneCoordinates = {} + +local function decodeCoordinate(value, degreeDigits) + if type(value) ~= "string" or #value < degreeDigits + 3 then return nil end + local sign = value:sub(1, 1) == "-" and -1 or 1 + local digits = value:sub(2) + local degrees = tonumber(digits:sub(1, degreeDigits)) + local minutes = tonumber(digits:sub(degreeDigits + 1, degreeDigits + 2)) + local seconds = #digits >= degreeDigits + 4 and tonumber(digits:sub(degreeDigits + 3, degreeDigits + 4)) or 0 + if degrees == nil or minutes == nil or seconds == nil then return nil end + return sign * (degrees + minutes / 60 + seconds / 3600) +end + +local function loadZoneCoordinates(path) + local raw = noctalia.readFile(path) + if type(raw) ~= "string" then return end + for line in raw:gmatch("[^\r\n]+") do + if line:sub(1, 1) ~= "#" then + local coordinate, zone = line:match("^[^\t]+\t([^\t]+)\t([^\t]+)") + if coordinate ~= nil and zone ~= nil and zoneCoordinates[zone] == nil then + local latitudePart, longitudePart = coordinate:match("^([%+%-]%d+)([%+%-]%d+)$") + local latitude = decodeCoordinate(latitudePart, 2) + local longitude = decodeCoordinate(longitudePart, 3) + if latitude ~= nil and longitude ~= nil then + zoneCoordinates[zone] = { latitude = latitude, longitude = longitude } + end + end + end + end +end + +local function normalizeDegrees(value) + return ((value % 360) + 360) % 360 +end + +local function radians(value) + return value * math.pi / 180 +end + +local function degrees(value) + return value * 180 / math.pi +end + +-- NOAA's sunrise equation, returning UTC decimal hours. The standard +-- 90.833-degree zenith accounts for atmospheric refraction and the sun's +-- apparent radius. +local function sunUtcHour(dayOfYear, latitude, longitude, sunrise) + local longitudeHour = longitude / 15 + local approximate = dayOfYear + ((sunrise and 6 or 18) - longitudeHour) / 24 + local meanAnomaly = 0.9856 * approximate - 3.289 + local trueLongitude = normalizeDegrees( + meanAnomaly + + 1.916 * math.sin(radians(meanAnomaly)) + + 0.020 * math.sin(radians(2 * meanAnomaly)) + + 282.634 + ) + local rightAscension = normalizeDegrees(degrees(math.atan(0.91764 * math.tan(radians(trueLongitude))))) + local longitudeQuadrant = math.floor(trueLongitude / 90) * 90 + local ascensionQuadrant = math.floor(rightAscension / 90) * 90 + rightAscension = (rightAscension + longitudeQuadrant - ascensionQuadrant) / 15 + + local sinDeclination = 0.39782 * math.sin(radians(trueLongitude)) + local cosDeclination = math.cos(math.asin(sinDeclination)) + local cosHour = ( + math.cos(radians(90.833)) - sinDeclination * math.sin(radians(latitude)) + ) / (cosDeclination * math.cos(radians(latitude))) + if cosHour < -1 or cosHour > 1 then return nil end + + local hourAngle = degrees(math.acos(cosHour)) + if sunrise then hourAngle = 360 - hourAngle end + hourAngle /= 15 + local localMeanTime = hourAngle + rightAscension - 0.06571 * approximate - 6.622 + return normalizeDegrees((localMeanTime - longitudeHour) * 15) / 15 +end + +local function sunTimes(zone, offsetMinutes) + if noctalia.getConfig("show_sun_times") ~= true then return nil, nil end + local coordinate = zoneCoordinates[zone] + if coordinate == nil or offsetMinutes == nil then return nil, nil end + local dayOfYear = tonumber(noctalia.formatTime("%j", nil, zone)) + if dayOfYear == nil then return nil, nil end + local sunriseUtc = sunUtcHour(dayOfYear, coordinate.latitude, coordinate.longitude, true) + local sunsetUtc = sunUtcHour(dayOfYear, coordinate.latitude, coordinate.longitude, false) + if sunriseUtc == nil or sunsetUtc == nil then return nil, nil end + local offsetHours = offsetMinutes / 60 + return clockTextFromHours(sunriseUtc + offsetHours), clockTextFromHours(sunsetUtc + offsetHours) +end + +-- --------------------------------------------------------------------- +-- Row computation + publish +-- --------------------------------------------------------------------- +local function computeRows() + local rows = {} + local deviceOffset = deviceTz ~= "" and parseOffsetMinutes(noctalia.formatTime("%z", nil, deviceTz)) or nil + local deviceSunrise, deviceSunset = sunTimes(deviceTz, deviceOffset) + table.insert(rows, { + key = "__device__", + label = noctalia.tr("device.label"), + tz = deviceTz, + isDevice = true, + index = -1, + timeText = deviceTz ~= "" and timeText(deviceTz) or "--:--", + dateText = deviceTz ~= "" and dateText(deviceTz) or "", + offsetMinutes = deviceOffset, + abbrev = deviceTz ~= "" and noctalia.formatTime("%Z", nil, deviceTz) or "", + sunriseText = deviceSunrise, + sunsetText = deviceSunset, + }) + + for i, entry in ipairs(comparisons) do + if entry.tz ~= deviceTz then + local offset = parseOffsetMinutes(noctalia.formatTime("%z", nil, entry.tz)) + local sunrise, sunset = sunTimes(entry.tz, offset) + table.insert(rows, { + key = entry.tz .. "#" .. i, + label = entry.label or entry.tz, + tz = entry.tz, + isDevice = false, + index = i, + timeText = timeText(entry.tz), + dateText = dateText(entry.tz), + offsetMinutes = offset, + abbrev = noctalia.formatTime("%Z", nil, entry.tz), + sunriseText = sunrise, + sunsetText = sunset, + }) + end + end + return rows +end + +local function publish() + noctalia.state.set("timezone_hub.device_tz", deviceTz) + noctalia.state.set("timezone_hub.changing_device_tz", changingDeviceTz) + noctalia.state.set("timezone_hub.bar_tzs", barTzs) + noctalia.state.set("timezone_hub.rows", computeRows()) + -- Raw ordered comparison list (distinct from `rows`, which hides an + -- entry that currently matches the device zone) so the panel can index + -- drag-reorder positions and dedupe against the real underlying list. + noctalia.state.set("timezone_hub.comparisons", comparisons) +end + +-- --------------------------------------------------------------------- +-- Device timezone: detect + change +-- --------------------------------------------------------------------- +local function detectDeviceTimezone() + noctalia.runAsync({ "timedatectl", "show", "-p", "Timezone", "--value" }, function(result) + local tz = result.stdout and noctalia.string.trim(result.stdout) or "" + if tz == "" then + noctalia.runAsync( + { "sh", "-c", "readlink -f /etc/localtime | sed 's#.*/zoneinfo/##'" }, + function(fallback) + local fbTz = fallback.stdout and noctalia.string.trim(fallback.stdout) or "" + if fbTz ~= "" then + deviceTz = fbTz + publish() + else + noctalia.log("TimezoneHub: could not detect the device timezone") + end + end + ) + return + end + deviceTz = tz + publish() + end) +end + +local function setDeviceTimezone(tz) + if tz == nil or tz == "" or changingDeviceTz or tz == deviceTz then + return + end + changingDeviceTz = true + publish() + noctalia.runAsync({ "pkexec", "timedatectl", "set-timezone", tz }, function(result) + changingDeviceTz = false + if result.exitCode == 0 then + -- Swap, don't just overwrite: drop the newly-promoted zone out of the + -- comparison list (it's about to be the device row, not a comparison + -- row) and put the outgoing device zone back into the list in its + -- place, so switching devices never silently loses or duplicates a + -- zone. + local oldDeviceTz = deviceTz + local promotedIdx = indexOfComparison(tz) + if promotedIdx ~= nil then + table.remove(comparisons, promotedIdx) + if oldDeviceTz ~= "" then + table.insert(comparisons, promotedIdx, { tz = oldDeviceTz, label = shortLabel(oldDeviceTz) }) + end + elseif oldDeviceTz ~= "" and indexOfComparison(oldDeviceTz) == nil then + table.insert(comparisons, 1, { tz = oldDeviceTz, label = shortLabel(oldDeviceTz) }) + end + -- Keep both selected clocks when the device zone and a comparison zone + -- trade places. The special device key follows the promoted zone while + -- the old device zone becomes an ordinary comparison selection. + for i, selected in ipairs(barTzs) do + if selected == tz then + barTzs[i] = DEVICE_BAR_KEY + elseif selected == DEVICE_BAR_KEY and oldDeviceTz ~= "" then + barTzs[i] = oldDeviceTz + end + end + deviceTz = tz + saveData() + publish() + noctalia.notify(noctalia.tr("toast.title"), noctalia.tr("toast.device-changed") .. " " .. tz) + else + publish() + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("toast.device-change-failed")) + end + end) +end + +-- --------------------------------------------------------------------- +-- Full IANA zone list (lazy, for the picker) +-- --------------------------------------------------------------------- +local function loadAllZones() + if allZonesLoaded or allZonesLoading then + return + end + allZonesLoading = true + allZonesLoadStartedMs = noctalia.nowMs() + noctalia.runAsync({ "timedatectl", "list-timezones" }, function(result) + allZonesLoading = false + allZonesLoadStartedMs = nil + local list = {} + if result.exitCode == 0 and result.stdout then + for line in result.stdout:gmatch("[^\r\n]+") do + local trimmed = noctalia.string.trim(line) + if trimmed ~= "" then + table.insert(list, trimmed) + end + end + end + allZones = #list > 0 and list or FALLBACK_ZONES + allZonesLoaded = true + noctalia.state.set("timezone_hub.all_zones", allZones) + noctalia.state.set("timezone_hub.all_zones_loaded", true) + end) +end + +-- --------------------------------------------------------------------- +-- Comparison list mutations +-- --------------------------------------------------------------------- +local function addComparison(tz, label) + tz = noctalia.string.trim(tz or "") + if tz == "" or tz == deviceTz then + return false, "invalid" + end + if not noctalia.isValidTimezone(tz) then + return false, "invalid" + end + if indexOfComparison(tz) ~= nil then + return false, "duplicate" + end + table.insert(comparisons, { tz = tz, label = (label and label ~= "") and label or shortLabel(tz) }) + saveData() + publish() + return true +end + +local function removeComparison(tz) + local idx = indexOfComparison(tz) + if idx == nil then + return false + end + table.remove(comparisons, idx) + for i = #barTzs, 1, -1 do + if barTzs[i] == tz then table.remove(barTzs, i) end + end + if #barTzs == 0 then table.insert(barTzs, DEVICE_BAR_KEY) end + saveData() + publish() + return true +end + +-- Move `tz` so it ends up at 1-based `index` in the final list. +local function reorderComparison(tz, index) + local fromIdx = indexOfComparison(tz) + local insertAt = tonumber(index) + if fromIdx == nil or insertAt == nil then + return false + end + local entry = table.remove(comparisons, fromIdx) + if fromIdx < insertAt then + insertAt -= 1 + end + insertAt = math.max(1, math.min(insertAt, #comparisons + 1)) + table.insert(comparisons, insertAt, entry) + saveData() + publish() + return true +end + +local function toggleBarZone(tz) + local key = (tz == nil or tz == "") and DEVICE_BAR_KEY or tz + for i, selected in ipairs(barTzs) do + if selected == key then + if #barTzs > 1 then table.remove(barTzs, i) end + saveData() + publish() + return + end + end + if #barTzs >= MAX_BAR_ZONES then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("toast.max-bar-zones")) + return + end + table.insert(barTzs, key) + saveData() + publish() +end + +-- --------------------------------------------------------------------- +-- Commands from the panel +-- --------------------------------------------------------------------- +noctalia.state.watch("timezone_hub.cmd", function(cmd) + if type(cmd) ~= "table" then + return + end + local op = cmd.op + if op == "add" then + local ok, reason = addComparison(cmd.tz, cmd.label) + if not ok then + if reason == "invalid" then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("ipc.invalid-zone", { zone = cmd.tz or "" })) + elseif reason == "duplicate" then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("ipc.duplicate-zone", { zone = cmd.tz or "" })) + end + end + elseif op == "remove" then + removeComparison(cmd.tz) + elseif op == "reorder" then + reorderComparison(cmd.tz, cmd.index) + elseif op == "set_device" then + setDeviceTimezone(cmd.tz) + elseif op == "toggle_bar_zone" or op == "set_bar_zone" then + toggleBarZone(cmd.tz) + elseif op == "load_zones" then + loadAllZones() + end + noctalia.state.set("timezone_hub.cmd", nil) +end) + +function onIpc(event, payload) + if event == "add" then + addComparison(payload or "") + elseif event == "remove" then + removeComparison(payload or "") + elseif event == "list" then + local names = {} + for _, entry in ipairs(comparisons) do + table.insert(names, entry.tz) + end + local body = table.concat(names, "\n") + noctalia.notify(noctalia.tr("toast.title"), body ~= "" and body or noctalia.tr("ipc.empty-list")) + end +end + +function update() + -- Self-heal: if the zone-list fetch never came back (its runAsync + -- callback silently never fired), don't stay stuck forever - clear the + -- in-flight flag and retry so a picker left open on "Loading..." recovers + -- on its own instead of needing the plugin restarted. + if allZonesLoading and allZonesLoadStartedMs ~= nil and (noctalia.nowMs() - allZonesLoadStartedMs) > ZONES_LOAD_TIMEOUT_MS then + noctalia.log("TimezoneHub: zone list load timed out, retrying") + allZonesLoading = false + allZonesLoadStartedMs = nil + loadAllZones() + end + publish() +end + +loadZoneCoordinates("/usr/share/zoneinfo/zone1970.tab") +loadZoneCoordinates("/usr/share/zoneinfo/zone.tab") +loadData() +detectDeviceTimezone() +publish() diff --git a/timezone-hub/thumbnail.webp b/timezone-hub/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..7459542e09e730b9b37d007e0a1dadda35e78c87 GIT binary patch literal 55714 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 literal 0 HcmV?d00001 diff --git a/timezone-hub/translations/en.json b/timezone-hub/translations/en.json new file mode 100644 index 000000000..63c1db170 --- /dev/null +++ b/timezone-hub/translations/en.json @@ -0,0 +1,84 @@ +{ + "device": { + "label": "This Device" + }, + "panel": { + "title": "Timezone Hub", + "subtitle": "Device timezone:", + "detecting": "Detecting device timezone…", + "add": "Add a timezone", + "open-settings": "Open plugin settings", + "earlier": "Earlier hours", + "later": "Later hours", + "now": "Now", + "jump-to-now": "Jump back to now" + }, + "row": { + "change-device": "Change device timezone", + "set-device": "Set as device timezone", + "show-in-bar": "Show in bar widget", + "hide-from-bar": "Hide from bar widget", + "remove": "Remove", + "confirm-remove": "Confirm removal", + "cancel-remove": "Cancel removal", + "drag-tooltip": "Drag to reorder", + "same-time": "same time" + }, + "picker": { + "back": "Back", + "title-device": "Set device timezone", + "title-add": "Add a timezone", + "search-placeholder": "Search city or region…", + "loading": "Loading timezone database…", + "empty": "No matching timezones" + }, + "toast": { + "title": "Timezone Hub", + "device-changed": "Device timezone set to", + "device-change-failed": "Failed to change the device timezone. Authorization was denied or timedatectl is unavailable.", + "max-bar-zones": "You can show up to three timezones in the bar." + }, + "ipc": { + "invalid-zone": "\"{zone}\" is not a valid IANA timezone", + "duplicate-zone": "\"{zone}\" is already in the comparison list", + "empty-list": "No comparison timezones configured" + }, + "settings": { + "time-format": "Time format", + "format-24h": "24-hour", + "format-12h": "12-hour", + "show-date-in-bar": "Show date in bar", + "show-date-in-bar-desc": "Show the selected timezone's local date beside the bar clock.", + "show-date-in-panel": "Show dates in panel", + "show-date-in-panel-desc": "Show each timezone's local date in its comparison row.", + "show-relative-time": "Show relative descriptions", + "show-relative-time-desc": "Describe whether a city is ahead or behind, on another day, and inside working hours.", + "show-sun-times": "Show sunrise and sunset", + "show-sun-times-desc": "Calculate local sunrise and sunset from the system timezone database without network access.", + "date-format": "Date format", + "date-format-weekday": "Weekday · Tue, Sep 09", + "date-format-short": "Short · Sep 09", + "date-format-iso": "ISO · 2026-09-09", + "hours-before": "Hours shown before now", + "hours-after": "Hours shown after now", + "highlight-work-hours": "Highlight work hours", + "work-hours-range": "Work hours", + "work-hour-start-desc": "Hour the work day starts (0-23)", + "work-hour-end-desc": "Hour the work day ends (1-24)", + "no-comparisons": "No comparison timezones yet — add one above." + }, + "relative": { + "same-time": "Same time", + "ahead": "{offset} ahead", + "behind": "{offset} behind", + "yesterday": "Yesterday", + "tomorrow": "Tomorrow", + "day-offset": "{days} days", + "morning": "morning", + "afternoon": "afternoon", + "evening": "evening", + "night": "night", + "inside-work-hours": "Within working hours", + "outside-work-hours": "Outside working hours" + } +}