diff --git a/github-activity/README.md b/github-activity/README.md new file mode 100644 index 00000000..274393b3 --- /dev/null +++ b/github-activity/README.md @@ -0,0 +1,82 @@ +# GitHub Activity + +A native GitHub contribution calendar for the Noctalia v5 bar. It displays +today's contributions in a compact widget and opens an adaptive, theme-aware +annual heatmap on click. + +![GitHub Activity panel](thumbnail.webp) + +## Plugin + +| Field | Value | +| --- | --- | +| Plugin ID | `alexmnrs/github-activity` | +| Entries | Bar widget: `activity`; panel: `calendar`; service: `sync` | +| Minimum Noctalia plugin API | `24` (Noctalia v5.0.0-beta.9) | + +## Requirements + +- [GitHub CLI](https://cli.github.com/) (`gh`), authenticated with the account + whose activity you want to display. +- `xdg-open` (`xdg-utils` on Arch) to open the profile button. + +Authenticate once before enabling the plugin: + +```bash +gh auth login -h github.com +``` + +## Usage + +1. Enable **GitHub Activity** in Noctalia's plugin manager. +2. Add the `activity` widget to a bar from the widget picker. +3. Left-click the widget to open the annual calendar. +4. Right-click the widget or use the panel's refresh button to refresh now. + +## Settings + +| Setting | Options | Default | +| --- | --- | --- | +| Automatic refresh interval | 15, 30, or 60 minutes | 30 minutes | + +Changing the interval applies it immediately and requests a refresh unless a +request is already in progress. The widget tooltip and calendar panel show the +last update time and selected automatic refresh interval. + +The panel can also be toggled through IPC: + +```bash +noctalia msg panel-toggle alexmnrs/github-activity:calendar +``` + +Request a refresh externally with: + +```bash +noctalia msg plugin alexmnrs/github-activity:sync all refresh +``` + +## Data and privacy + +The plugin runs `gh api graphql` to request the authenticated account's +`contributionCalendar`. It never reads, writes, or displays a GitHub token. +Authentication remains entirely inside GitHub CLI. + +The latest successful normalized calendar is cached in Noctalia's per-plugin +data directory. The cache contains the public contribution dates, counts, +levels, username, and fetch time; it contains no credentials. It lets the +widget continue to show the last known activity when offline. + +## Troubleshooting + +- **GitHub CLI is required:** install `github-cli` (or your distribution's + `gh` package). +- **GitHub CLI needs authentication:** run `gh auth login -h github.com`. +- **No data after a refresh:** run `gh auth status` in a terminal, then retry. +- **The profile button does nothing:** install `xdg-utils` so `xdg-open` is + available. + +## Compatibility + +Noctalia v5's plugin API is beta and can change before the stable release. This +plugin targets API 24 and uses Noctalia's declarative panel and shared-state +APIs only. diff --git a/github-activity/lib/activity.luau b/github-activity/lib/activity.luau new file mode 100644 index 00000000..c95a7910 --- /dev/null +++ b/github-activity/lib/activity.luau @@ -0,0 +1,223 @@ +--!nonstrict + +local Activity = {} + +local levelByName = { + NONE = 0, + FIRST_QUARTILE = 1, + SECOND_QUARTILE = 2, + THIRD_QUARTILE = 3, + FOURTH_QUARTILE = 4, +} + +local colorByLevel = { + "surface_variant", + "primary/0.25", + "primary/0.45", + "primary/0.70", + "primary", +} + +function Activity.level(value) + if type(value) == "number" then + return math.clamp(math.floor(value), 0, 4) + end + + return levelByName[value] or 0 +end + +function Activity.color(level) + return colorByLevel[Activity.level(level) + 1] +end + +function Activity.symbol(level) + local symbols = { "·", "░", "▒", "▓", "█" } + return symbols[Activity.level(level) + 1] +end + +function Activity.rowLevels(row) + local levels = {} + local byCodepoint = { + [0x00B7] = 0, + [0x2591] = 1, + [0x2592] = 2, + [0x2593] = 3, + [0x2588] = 4, + } + for _, codepoint in utf8.codes(row or "") do + table.insert(levels, byCodepoint[codepoint] or 0) + end + return levels +end + +function Activity.flatten(weeks) + local days = {} + + for _, week in ipairs(weeks or {}) do + for _, day in ipairs(week.days or {}) do + table.insert(days, day) + end + end + + return days +end + +function Activity.streaks(days, todayDate) + local best = 0 + local running = 0 + + for _, day in ipairs(days or {}) do + if (day.count or 0) > 0 then + running = running + 1 + best = math.max(best, running) + else + running = 0 + end + end + + local endIndex = nil + for index = #days, 1, -1 do + if days[index].date <= todayDate then + endIndex = index + break + end + end + + if endIndex == nil then + return 0, best + end + + -- A day that has not happened yet is irrelevant. If today is empty, retain + -- yesterday's active streak rather than resetting it early in the morning. + if days[endIndex].date == todayDate and (days[endIndex].count or 0) == 0 then + endIndex = endIndex - 1 + end + + local current = 0 + for index = endIndex, 1, -1 do + if (days[index].count or 0) == 0 then + break + end + current = current + 1 + end + + return current, best +end + +function Activity.normalize(payload, todayDate) + local viewer = payload + and payload.data + and payload.data.viewer + local calendar = viewer + and viewer.contributionsCollection + and viewer.contributionsCollection.contributionCalendar + + if type(viewer) ~= "table" or type(calendar) ~= "table" then + return nil, "missing contribution calendar" + end + + if type(viewer.login) ~= "string" + or type(calendar.weeks) ~= "table" + or type(calendar.totalContributions) ~= "number" then + return nil, "invalid contribution calendar" + end + + local weeks = {} + for _, sourceWeek in ipairs(calendar.weeks) do + if type(sourceWeek.contributionDays) ~= "table" then + return nil, "invalid contribution week" + end + + local week = { days = {} } + for _, sourceDay in ipairs(sourceWeek.contributionDays) do + if type(sourceDay.date) ~= "string" or type(sourceDay.contributionCount) ~= "number" then + return nil, "invalid contribution day" + end + + table.insert(week.days, { + date = sourceDay.date, + count = math.max(0, math.floor(sourceDay.contributionCount)), + level = Activity.level(sourceDay.contributionLevel), + }) + end + table.insert(weeks, week) + end + + local days = Activity.flatten(weeks) + local today = 0 + for _, day in ipairs(days) do + if day.date == todayDate then + today = day.count + break + end + end + + local currentStreak, bestStreak = Activity.streaks(days, todayDate) + return { + login = viewer.login, + total = math.max(0, math.floor(calendar.totalContributions)), + weeks = weeks, + today = today, + currentStreak = currentStreak, + bestStreak = bestStreak, + fetchedAt = os.time(), + } +end + +-- Convert caches written by the first MVP build (which stored all 371 days) +-- into the compact representation used by the CPU-safe panel. +function Activity.compact(data, todayDate) + if type(data) ~= "table" then + return nil + end + + if type(data.rows) == "table" then + return data + end + + if type(data.weeks) ~= "table" + or type(data.login) ~= "string" + or type(data.total) ~= "number" then + return nil + end + + local rows = { "", "", "", "", "", "", "" } + for _, week in ipairs(data.weeks) do + if type(week) ~= "table" or type(week.days) ~= "table" then + return nil + end + + for index = 1, 7 do + local day = week.days[index] + if day ~= nil then + rows[index] = rows[index] .. Activity.symbol(day.level) + end + end + end + + return { + login = data.login, + total = data.total, + rows = rows, + today = data.today or 0, + todayDate = data.todayDate or todayDate or os.date("%Y-%m-%d"), + currentStreak = data.currentStreak or 0, + bestStreak = data.bestStreak or 0, + fetchedAt = data.fetchedAt or os.time(), + } +end + +function Activity.isValidCache(data) + return type(data) == "table" + and type(data.login) == "string" + and type(data.total) == "number" + and type(data.rows) == "table" + and (data.details == nil or type(data.details) == "table") + and type(data.today) == "number" + and type(data.todayDate) == "string" + and type(data.currentStreak) == "number" + and type(data.bestStreak) == "number" + and type(data.fetchedAt) == "number" +end + +return Activity diff --git a/github-activity/lib/settings.luau b/github-activity/lib/settings.luau new file mode 100644 index 00000000..27bf811c --- /dev/null +++ b/github-activity/lib/settings.luau @@ -0,0 +1,21 @@ +--!nonstrict + +local Settings = {} + +local DEFAULT_REFRESH_INTERVAL_MINUTES = 30 +local refreshIntervalMinutes = { + ["15"] = 15, + ["30"] = 30, + ["60"] = 60, +} + +function Settings.refreshIntervalMinutes(value) + return type(value) == "string" and refreshIntervalMinutes[value] + or DEFAULT_REFRESH_INTERVAL_MINUTES +end + +function Settings.refreshIntervalMs(value) + return Settings.refreshIntervalMinutes(value) * 60 * 1000 +end + +return Settings diff --git a/github-activity/panel.luau b/github-activity/panel.luau new file mode 100644 index 00000000..b31f7864 --- /dev/null +++ b/github-activity/panel.luau @@ -0,0 +1,399 @@ +--!nonstrict + +local Activity = require("./lib/activity.luau") +local Settings = require("./lib/settings.luau") + +local data = noctalia.state.get("data") +local status = noctalia.state.get("status") or "loading" +local stale = noctalia.state.get("stale") == true +local detail = noctalia.state.get("error") or "" +local selectedDate = nil +local selectedByKey = {} +local cachedHeatmap = nil +local cachedCalendar = nil +local gridColumns = {} +local gridLevels = {} +local gridDetails = {} +local gridWeek = 1 +local gridWeekCount = 0 +local gridPreparingDay = 1 +local gridReady = false +local open = false +local tr = noctalia.tr +local monthNames = { + tr("panel.months.jan"), + tr("panel.months.feb"), + tr("panel.months.mar"), + tr("panel.months.apr"), + tr("panel.months.may"), + tr("panel.months.jun"), + tr("panel.months.jul"), + tr("panel.months.aug"), + tr("panel.months.sep"), + tr("panel.months.oct"), + tr("panel.months.nov"), + tr("panel.months.dec"), +} + +local function requestRefresh() + noctalia.state.set("refresh_request", noctalia.nowMs()) +end + +local function splitTabs(value) + local fields = {} + for field in string.gmatch((value or "") .. "\t", "(.-)\t") do + table.insert(fields, field) + end + return fields +end + +local function resetGrid(source) + cachedHeatmap = nil + cachedCalendar = nil + gridColumns = {} + gridLevels = {} + gridDetails = {} + selectedByKey = {} + selectedDate = nil + gridWeek = 1 + gridPreparingDay = 1 + gridReady = source ~= nil and type(source.rows) == "table" + gridWeekCount = 0 +end + +local function monthForWeek(week) + local preferredDays = { 4, 1, 2, 3, 5, 6, 7 } + for _, day in ipairs(preferredDays) do + local rawDetail = (gridDetails[day] or {})[week] or "" + local date = string.match(rawDetail, "^([^|]+)|") + if date ~= nil then + return tonumber(string.sub(date, 6, 7)) + end + end + return nil +end + +local function buildMonthHeader() + local segments = {} + local segmentMonth = nil + local segmentWeeks = 0 + + local function appendSegment() + if segmentMonth == nil or segmentWeeks == 0 then + return + end + table.insert(segments, ui.column({ + width = segmentWeeks * 11 - 1, + height = 12, + align = "center", + justify = "center", + }, { + ui.label({ + text = segmentWeeks >= 2 and (monthNames[segmentMonth] or "") or "", + fontSize = 10, + color = "on_surface_variant", + textAlign = "center", + }), + })) + end + + for week = 1, gridWeekCount do + local month = monthForWeek(week) + if segmentMonth ~= nil and month ~= segmentMonth then + appendSegment() + segmentWeeks = 0 + end + segmentMonth = month or segmentMonth + segmentWeeks = segmentWeeks + 1 + end + appendSegment() + + return ui.row({ gap = 1, align = "center" }, segments) +end + +local function buildWeekdayLabels() + local names = { "", tr("panel.weekdays.mon"), "", tr("panel.weekdays.wed"), "", tr("panel.weekdays.fri"), "" } + local labels = {} + for day = 1, 7 do + table.insert(labels, ui.column({ + width = 30, + height = 12, + align = "end", + justify = "center", + }, { + ui.label({ + text = names[day], + fontSize = 11, + color = "on_surface_variant", + textAlign = "end", + }), + })) + end + return ui.column({ gap = 3, align = "end" }, labels) +end + +local function prepareGridDay() + if not gridReady or data == nil or gridPreparingDay > 7 then + return + end + + local day = gridPreparingDay + gridLevels[day] = Activity.rowLevels(data.rows[day]) + gridDetails[day] = splitTabs((data.details or {})[day]) + gridWeekCount = math.max(gridWeekCount, #gridLevels[day]) + gridPreparingDay = gridPreparingDay + 1 +end + +local function prepareGridColumns() + if not gridReady or gridPreparingDay <= 7 then + return + end + + -- One native week per tick keeps every callback well below Noctalia's Luau + -- budget while preserving individual hover targets for every day. + local lastWeek = math.min(gridWeek, gridWeekCount) + while gridWeek <= lastWeek do + local cells = {} + for day = 1, 7 do + local rawDetail = gridDetails[day][gridWeek] or "" + local date, countText = string.match(rawDetail, "^([^|]+)|(%d+)$") + if date ~= nil then + local key = "day-" .. date + selectedByKey[key] = { date = date, count = tonumber(countText) or 0 } + table.insert(cells, ui.box({ + key = key, + fill = Activity.color(gridLevels[day][gridWeek]), + radius = 2, + width = 10, + height = 12, + onHover = "onDayHover", + })) + end + end + table.insert(gridColumns, ui.column({ key = "week-" .. tostring(gridWeek), gap = 3, align = "center" }, cells)) + gridWeek = gridWeek + 1 + end + + if gridWeek > gridWeekCount then + cachedHeatmap = ui.row({ gap = 1, align = "start" }, gridColumns) + cachedCalendar = ui.row({ gap = 10, align = "end", justify = "center" }, { + buildWeekdayLabels(), + ui.column({ gap = 6, align = "start" }, { + buildMonthHeader(), + cachedHeatmap, + }), + }) + end +end + +resetGrid(data) + +local function selectedDay() + if selectedDate == nil then + return nil + end + return selectedByKey["day-" .. selectedDate] +end + +local function activityBlocks(filled, total) + local blocks = {} + for index = 1, total do + table.insert(blocks, ui.box({ + width = 9, + height = 9, + radius = 2, + fill = index <= filled and "primary" or "surface_variant", + })) + end + return ui.row({ gap = 3, align = "center", justify = "center" }, blocks) +end + +local function metric(label, value, visual) + return ui.column({ gap = 6, align = "center", flexGrow = 1 }, { + ui.label({ text = label, fontSize = 12, color = "on_surface_variant" }), + ui.label({ text = tostring(value), fontSize = 25, fontWeight = "bold" }), + visual, + }) +end + +local function statusText() + if status == "loading" then + return data ~= nil and tr("status.refreshing") or tr("status.loading") + elseif status == "stale" then + return tr("status.stale") + elseif status == "missing_gh" then + return tr("status.missing_gh") + elseif status == "auth_error" then + return tr("status.auth_error") + elseif status == "request_error" then + return tr("status.request_error") + elseif status == "invalid_response" then + return tr("status.invalid_response") + end + + return stale and tr("status.stale") or "" +end + +local function refreshSchedule() + return tr("status.refresh_schedule", { + time = os.date("%H:%M", math.floor(data.fetchedAt)), + interval = Settings.refreshIntervalMinutes(noctalia.getConfig("refresh_interval_minutes")), + }) +end + +function render() + local root = { + ui.row({ align = "center", gap = 12, paddingH = 18, paddingV = 10 }, { + ui.glyph({ name = "brand-github", size = 25, color = "on_surface" }), + ui.label({ text = tr("panel.title"), fontSize = 19, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("action.refresh"), onClick = "onRefreshClicked" }), + }), + ui.separator({ thickness = 1, color = "outline/0.35" }), + } + local body = {} + + local message = statusText() + if message ~= "" then + table.insert(body, ui.label({ text = message, fontSize = 11, color = status == "ready" and "on_surface_variant" or "primary", maxLines = 2 })) + end + + if data == nil or cachedCalendar == nil then + local emptyText = data ~= nil and tr("panel.preparing") + or (detail ~= "" and statusText() or tr("status.no_data")) + table.insert(body, ui.spacer({ flexGrow = 1 })) + table.insert(body, ui.column({ gap = 12, align = "center" }, { + ui.glyph({ name = "brand-github", size = 36, color = "on_surface_variant" }), + ui.label({ text = emptyText, maxWidth = 520, textAlign = "center" }), + ui.button({ text = tr("action.try_again"), glyph = "refresh", variant = "primary", onClick = "onRefreshClicked" }), + })) + else + table.insert(body, cachedCalendar) + + local day = selectedDay() + local selectedText = day ~= nil + and tr("panel.day_total", { date = day.date, count = day.count }) + or tr("panel.year_total", { login = data.login, count = data.total }) + table.insert(body, ui.label({ text = selectedText, height = 14, fontSize = 11, color = "on_surface_variant", textAlign = "center" })) + table.insert(body, ui.label({ text = refreshSchedule(), height = 12, fontSize = 10, color = "on_surface_variant", textAlign = "center" })) + table.insert(body, ui.separator({ thickness = 1, color = "outline/0.35" })) + table.insert(body, ui.row({ gap = 14, align = "stretch" }, { + metric(tr("metric.today"), data.today, activityBlocks(data.today > 0 and 1 or 0, 1)), + ui.separator({ orientation = "vertical", thickness = 1, color = "outline/0.35" }), + metric( + tr("metric.current_streak"), + data.currentStreak, + activityBlocks(math.min(data.currentStreak, 12), math.max(1, math.min(data.currentStreak, 12))) + ), + ui.separator({ orientation = "vertical", thickness = 1, color = "outline/0.35" }), + metric(tr("metric.best_streak"), data.bestStreak, activityBlocks(math.min(data.bestStreak, 12), 12)), + })) + end + + table.insert(root, ui.column({ gap = 12, paddingH = 18, paddingV = 14, fill = "surface", flexGrow = 1 }, body)) + if data ~= nil and cachedCalendar ~= nil then + table.insert(root, ui.separator({ thickness = 1, color = "outline/0.35" })) + table.insert(root, ui.row({ justify = "end", paddingH = 14, paddingV = 10 }, { + ui.button({ text = tr("action.open_profile"), glyph = "external-link", variant = "outline", onClick = "onOpenProfile" }), + })) + end + + panel.render(ui.column({ gap = 0, fill = "surface", flexGrow = 1, align = "stretch" }, root)) +end + +function onDayHover(hovered, key) + if hovered == "true" then + local day = selectedByKey[key] + if day ~= nil then + selectedDate = day.date + render() + end + elseif selectedDate ~= nil and key == "day-" .. selectedDate then + selectedDate = nil + render() + end +end + +function onOpen(_context) + open = true + if data ~= nil and cachedHeatmap == nil then + -- Panels only receive fast ticks after explicitly opting into frame ticks. + -- This lets us build the original native, hoverable cell tree safely. + panel.setWantsSecondTicks(true) + panel.setNeedsFrameTick(true) + end + render() +end + +function onClose() + open = false +end + +function onRefreshClicked() + requestRefresh() +end + +function onOpenProfile() + if data == nil then + return + end + if not noctalia.commandExists("xdg-open") then + noctalia.notifyError(tr("panel.title"), tr("status.missing_xdg_open")) + return + end + noctalia.runAsync({ "xdg-open", "https://github.com/" .. data.login }) +end + +local function rerender(valueName, value) + if valueName == "data" then + data = value + resetGrid(data) + if open and data ~= nil then + panel.setWantsSecondTicks(true) + panel.setNeedsFrameTick(true) + end + elseif valueName == "status" then + status = value or "loading" + elseif valueName == "stale" then + stale = value == true + elseif valueName == "error" then + detail = value or "" + end + if open then + render() + end +end + +local function advanceGrid() + if not gridReady or cachedHeatmap ~= nil then + return + end + + if gridPreparingDay <= 7 then + prepareGridDay() + return + end + + prepareGridColumns() + if cachedHeatmap ~= nil then + panel.setWantsSecondTicks(false) + panel.setNeedsFrameTick(false) + if open then + render() + end + end +end + +function onFrameTick(_deltaMs) + advanceGrid() +end + +function update() + -- Fallback for a compositor that coalesces frame callbacks while a panel is + -- attached. Frame ticks normally complete this in about one second. + advanceGrid() +end + +noctalia.state.watch("data", function(value) rerender("data", value) end) +noctalia.state.watch("status", function(value) rerender("status", value) end) +noctalia.state.watch("stale", function(value) rerender("stale", value) end) +noctalia.state.watch("error", function(value) rerender("error", value) end) diff --git a/github-activity/plugin.toml b/github-activity/plugin.toml new file mode 100644 index 00000000..47b8b534 --- /dev/null +++ b/github-activity/plugin.toml @@ -0,0 +1,40 @@ +id = "alexmnrs/github-activity" +name = "GitHub Activity" +version = "1.2.1" +plugin_api = 24 +author = "AlexMnrs" +license = "MIT" +icon = "brand-github" +description = "A native GitHub contribution calendar for your Noctalia bar." +tags = ["bar", "panel", "service", "development", "productivity", "utility", "network", "hyprland", "arch"] +dependencies = ["gh", "xdg-open"] + +[[setting]] +key = "refresh_interval_minutes" +type = "select" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = "30" +options = [ + { value = "15", label_key = "settings.refresh_interval.options.fifteen" }, + { value = "30", label_key = "settings.refresh_interval.options.thirty" }, + { value = "60", label_key = "settings.refresh_interval.options.sixty" }, +] + +[[widget]] +id = "activity" +entry = "widget.luau" + +[[panel]] +id = "calendar" +entry = "panel.luau" +width = 680 +height = 430 +placement = "attached" +position = "auto" +open_near_click = true +dismiss_on_outside_click = true + +[[service]] +id = "sync" +entry = "sync.luau" diff --git a/github-activity/sync.luau b/github-activity/sync.luau new file mode 100644 index 00000000..0a89ca35 --- /dev/null +++ b/github-activity/sync.luau @@ -0,0 +1,221 @@ +--!nonstrict + +local Activity = require("./lib/activity.luau") +local Settings = require("./lib/settings.luau") + +local QUERY = [[ +query { + viewer { + login + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + contributionCount + contributionLevel + } + } + } + } + } +} +]] + +local JQ_QUERY = [=[ +def sym: + if . == "NONE" then "·" + elif . == "FIRST_QUARTILE" then "░" + elif . == "SECOND_QUARTILE" then "▒" + elif . == "THIRD_QUARTILE" then "▓" + else "█" + end; +def maxrun: + reduce .[] as $value + ({ current: 0, maximum: 0 }; + if $value > 0 + then .current += 1 | .maximum = ([.maximum, .current] | max) + else .current = 0 + end + ) | .maximum; +def suffixrun: + reduce .[] as $value + ({ running: 0, stopped: false }; + if .stopped + then . + elif $value > 0 + then .running += 1 + else .stopped = true + end + ) | .running; + +(.data.viewer.contributionsCollection.contributionCalendar) as $calendar +| $calendar.weeks as $weeks +| [$weeks[] | .contributionDays[]] as $days +| (now | strftime("%Y-%m-%d")) as $today +| ([$days[] | .date] | index($today)) as $todayIndex +| (if $todayIndex == null + then null + elif $days[$todayIndex].contributionCount == 0 + then $todayIndex - 1 + else $todayIndex + end) as $streakEnd +| (if $streakEnd == null or $streakEnd < 0 + then 0 + else [range(0; $streakEnd + 1) as $offset + | $days[$streakEnd - $offset].contributionCount] | suffixrun + end) as $currentStreak +| ([$days[] | .contributionCount] | maxrun) as $bestStreak +| ([range(0; 7) as $day + | [$weeks[] + | ((.contributionDays | .[$day] | .contributionLevel // "NONE") | sym)] + | join("")]) as $rows +| ([range(0; 7) as $day + | [$weeks[] + | (.contributionDays | .[$day] // null) + | if . == null then "" else "\(.date)|\(.contributionCount)" end] + | join("\t")]) as $details +| { + login: .data.viewer.login, + total: $calendar.totalContributions, + rows: $rows, + details: $details, + today: (if $todayIndex == null then 0 else $days[$todayIndex].contributionCount end), + todayDate: $today, + currentStreak: $currentStreak, + bestStreak: $bestStreak, + fetchedAt: now + } +]=] + +local cachePath = nil +local inFlight = false + +local function todayDate() + return os.date("%Y-%m-%d") +end + +local function publish(status, detail) + noctalia.state.set("status", status) + noctalia.state.set("error", detail or "") +end + +local function initialiseCache() + local dataDir, error = noctalia.pluginDataDir() + if dataDir == nil then + noctalia.log("GitHub Activity could not create its data directory: " .. tostring(error)) + return + end + + cachePath = dataDir .. "/activity.json" + local contents, readError = noctalia.readFile(cachePath) + if contents == nil then + if readError ~= nil then + noctalia.log("GitHub Activity cache is unavailable: " .. tostring(readError)) + end + return + end + + local cached, decodeError = noctalia.json.decode(contents) + cached = Activity.compact(cached, todayDate()) + if cached == nil or not Activity.isValidCache(cached) then + noctalia.log("GitHub Activity ignored an invalid cache: " .. tostring(decodeError or "unexpected data")) + return + end + + noctalia.state.set("data", cached) + noctalia.state.set("stale", true) + publish("stale") +end + +local function persist(data) + if cachePath == nil then + return + end + + local encoded, encodeError = noctalia.json.encode(data) + if encoded == nil then + noctalia.log("GitHub Activity could not encode its cache: " .. tostring(encodeError)) + return + end + + local ok, writeError = noctalia.writeFile(cachePath, encoded) + if not ok then + noctalia.log("GitHub Activity could not write its cache: " .. tostring(writeError)) + end +end + +local function failure(status, detail) + inFlight = false + noctalia.state.set("stale", noctalia.state.get("data") ~= nil) + publish(status, detail) +end + +local function refresh() + if inFlight then + return + end + + if not noctalia.commandExists("gh") then + failure("missing_gh", "GitHub CLI (gh) is not installed.") + return + end + + inFlight = true + publish("loading") + noctalia.runAsync({ "gh", "api", "graphql", "--jq", JQ_QUERY, "-f", "query=" .. QUERY }, function(result) + if result.exitCode ~= 0 then + local message = string.lower(result.stderr or "") + if string.find(message, "auth", 1, true) or string.find(message, "token", 1, true) then + failure("auth_error", "Run gh auth login -h github.com to authenticate GitHub CLI.") + else + failure("request_error", "GitHub CLI could not fetch contribution data.") + end + return + end + + local data, decodeError = noctalia.json.decode(result.stdout or "") + if data == nil or not Activity.isValidCache(data) then + failure("invalid_response", "GitHub returned an unexpected contribution calendar: " .. tostring(decodeError or "invalid normalized data")) + return + end + + inFlight = false + persist(data) + noctalia.state.set("data", data) + noctalia.state.set("stale", false) + publish("ready") + end) +end + +local function configureRefreshInterval() + noctalia.setUpdateInterval(Settings.refreshIntervalMs(noctalia.getConfig("refresh_interval_minutes"))) +end + +configureRefreshInterval() +initialiseCache() +refresh() + +noctalia.state.watch("refresh_request", function(_value) + refresh() +end) + +function update() + refresh() +end + +function onEnable() + refresh() +end + +function onConfigChanged() + configureRefreshInterval() + refresh() +end + +function onIpc(event, _payload) + if event == "refresh" then + refresh() + end +end diff --git a/github-activity/thumbnail.webp b/github-activity/thumbnail.webp new file mode 100644 index 00000000..6970b141 Binary files /dev/null and b/github-activity/thumbnail.webp differ diff --git a/github-activity/translations/en.json b/github-activity/translations/en.json new file mode 100644 index 00000000..0932edbc --- /dev/null +++ b/github-activity/translations/en.json @@ -0,0 +1,70 @@ +{ + "plugin": { + "name": "GitHub Activity", + "description": "A native GitHub contribution calendar for your Noctalia bar." + }, + "settings": { + "refresh_interval": { + "label": "Refresh interval", + "description": "How often GitHub contribution data refreshes automatically.", + "options": { + "fifteen": "Every 15 minutes", + "thirty": "Every 30 minutes", + "sixty": "Every 60 minutes" + } + } + }, + "widget": { + "tooltip": "{login}\nToday: {today} contributions\nCurrent streak: {streak} days\n{freshness}\n{refresh}", + "cached": "Cached data", + "up_to_date": "Up to date" + }, + "panel": { + "title": "GitHub Activity", + "year_total": "{login} · {count} contributions in the last year", + "day_total": "{date} · {count} contributions", + "preparing": "Preparing interactive calendar…", + "months": { + "jan": "Jan", + "feb": "Feb", + "mar": "Mar", + "apr": "Apr", + "may": "May", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Oct", + "nov": "Nov", + "dec": "Dec" + }, + "weekdays": { + "mon": "Mon", + "wed": "Wed", + "fri": "Fri" + } + }, + "metric": { + "today": "Today", + "current_streak": "Current streak", + "best_streak": "Best streak" + }, + "action": { + "refresh": "Refresh", + "try_again": "Try again", + "open_profile": "Open profile" + }, + "status": { + "loading": "Loading contribution data…", + "refreshing": "Refreshing contribution data…", + "stale": "Showing cached contribution data.", + "missing_gh": "GitHub CLI (gh) is required to load activity.", + "auth_error": "GitHub CLI needs authentication: gh auth login -h github.com", + "request_error": "GitHub Activity could not reach GitHub.", + "invalid_response": "GitHub returned an unexpected contribution calendar.", + "unavailable": "GitHub activity is unavailable.", + "no_data": "No contribution data is available yet.", + "missing_xdg_open": "xdg-open is required to open your profile.", + "refresh_schedule": "Last updated at {time} · Auto-refresh every {interval} min" + } +} diff --git a/github-activity/widget.luau b/github-activity/widget.luau new file mode 100644 index 00000000..96ba2720 --- /dev/null +++ b/github-activity/widget.luau @@ -0,0 +1,67 @@ +--!nonstrict + +local Settings = require("./lib/settings.luau") + +local data = noctalia.state.get("data") +local status = noctalia.state.get("status") or "loading" +local stale = noctalia.state.get("stale") == true +local tr = noctalia.tr + +local function tooltip() + if data ~= nil then + local freshness = stale and tr("widget.cached") or tr("widget.up_to_date") + local refreshSchedule = tr("status.refresh_schedule", { + time = os.date("%H:%M", math.floor(data.fetchedAt)), + interval = Settings.refreshIntervalMinutes(noctalia.getConfig("refresh_interval_minutes")), + }) + return tr("widget.tooltip", { + login = data.login, + today = data.today, + streak = data.currentStreak, + freshness = freshness, + refresh = refreshSchedule, + }) + end + + if status == "missing_gh" then + return tr("status.missing_gh") + elseif status == "auth_error" then + return tr("status.auth_error") + elseif status == "request_error" or status == "invalid_response" then + return tr("status.unavailable") + end + + return tr("status.loading") +end + +local function render() + barWidget.setGlyph("brand-github") + barWidget.setText(data ~= nil and tostring(data.today) or "—") + barWidget.setTooltip(tooltip()) + barWidget.setVisible(true) +end + +noctalia.state.watch("data", function(value) + data = value + render() +end) + +noctalia.state.watch("status", function(value) + status = value or "loading" + render() +end) + +noctalia.state.watch("stale", function(value) + stale = value == true + render() +end) + +function onClick() + noctalia.togglePanel("alexmnrs/github-activity:calendar") +end + +function onRightClick() + noctalia.state.set("refresh_request", noctalia.nowMs()) +end + +render()