-- Parsers for the sampler caches. -- -- Every function takes the file *contents* as a string rather than reading the -- file itself, so the parsers are testable against fixtures without mocking the -- filesystem. local M = {} -- Read a whole file, returning nil if it cannot be read, so a missing cache -- degrades to nil instead of raising. function M.slurp(path) local f = io.open(path, 'r') if not f then return nil end local s = f:read('*a') f:close() return s end -- Human-readable size to bytes: '1.1G' -> 1181116006. -- -- Needed because du -sh is what the sampler runs (its output is also what the -- card displays), and 'biggest' has to be decided numerically: sorted as -- strings, '245M' beats '1.1G'. local SUFFIX = { K = 1024, M = 1024^2, G = 1024^3, T = 1024^4, P = 1024^5 } function M.human_bytes(s) if type(s) ~= 'string' then return nil end local n, suf = s:match('^%s*([%d%.]+)%s*([KMGTP]?)') n = tonumber(n) if not n then return nil end return n * (SUFFIX[suf] or 1) end -- Parse `du -sh` output: the first line is the total, the rest are children -- already sorted largest-first by the sampler. Returns nil when there is -- nothing usable, so the widget can draw its "no data" state. function M.du_parse(text) if type(text) ~= 'string' or text == '' then return nil end local lines = {} for line in text:gmatch('[^\n]+') do lines[#lines + 1] = line end if #lines == 0 then return nil end local function entry(line) local label, path = line:match('^(%S+)%s+(.+)$') if not label then return nil end return { label = label, bytes = M.human_bytes(label) or 0, name = path:match('([^/]+)/?$') or path, path = path } end local total = entry(lines[1]) if not total then return nil end local items = {} for i = 2, #lines do local e = entry(lines[i]) if e then items[#items + 1] = e end end return { total = total, items = items } end -- Parse 'key value' lines into a table of strings. -- -- Values stay strings and are converted by the caller. The sampler writes -- epochs as integers and the widget decides whether an age reads as hours or -- days; a parser that guessed would have to know that. -- -- Only the first space separates, so a value keeps its own spaces: -- 'version Slackware 15.0+' yields 'Slackware 15.0+', not 'Slackware'. function M.kv_parse(text) local out = {} if type(text) ~= 'string' then return out end for line in text:gmatch('[^\n]+') do local k, v = line:match('^(%S+)%s+(.*)$') if k and v ~= '' then out[k] = v end end return out end -- The calendar cache written by bin/calendar-sample.sh. -- -- Returns { today = { y, m, d }, colors = { = }, -- events = { { y, m, d, start, finish, calendar, title }, ... } }, or nil when -- the cache is absent or carries no 'generated' key. nil means "the sampler -- never ran", which the card names; an empty events list means "nothing in the -- next week", which is a different and equally valid state. -- -- Dates arrive in khal's own `longdateformat`, whatever the user set it to, so -- the three numbers are pulled out by position and assigned by magnitude: the -- 4-digit group is the year, and of the remaining two the one that cannot be a -- month is the day. An unambiguous pair (03.04.2026) is read as day-first, -- matching khal's default and the European formats its config ships. This is a -- heuristic, and it is the honest one available: khal will not emit ISO. function M.calendar_parse(text) if type(text) ~= 'string' then return nil end local out = { colors = {}, events = {} } local generated = false for line in text:gmatch('[^\n]+') do local kind, rest = line:match('^(%S+)%s+(.*)$') if kind == 'generated' then generated = true elseif kind == 'today' then local y, m, d = rest:match('^(%d+)-(%d+)-(%d+)$') if y then out.today = { tonumber(y), tonumber(m), tonumber(d) } end elseif kind == 'color' then local name, colour = rest:match('^(%S+)%s+(.*)$') if name and colour ~= '' then out.colors[name] = colour end elseif kind == 'event' then -- Split on the first four pipes only: a title may contain one. local date, st, en, cal, title = rest:match('^([^|]*)|([^|]*)|([^|]*)|([^|]*)|(.*)$') local ev = date and M.calendar_date(date) if ev then out.events[#out.events + 1] = { y = ev[1], m = ev[2], d = ev[3], start = st ~= '' and st or nil, finish = en ~= '' and en or nil, calendar = cal ~= '' and cal or nil, title = title, } end end end if not generated then return nil end return out end -- Three numbers out of a formatted date, as { year, month, day }. -- See calendar_parse for why this is positional rather than a format string. function M.calendar_date(s) if type(s) ~= 'string' then return nil end local nums = {} for n in s:gmatch('%d+') do nums[#nums + 1] = n end if #nums < 3 then return nil end -- The year is the 4-digit group. A 2-digit year is not handled: khal's -- shipped formats all use %Y, and guessing a century from two digits would -- be a second heuristic stacked on the first. local yi for i = 1, 3 do if #nums[i] == 4 then yi = i break end end if not yi then return nil end local rest = {} for i = 1, 3 do if i ~= yi then rest[#rest + 1] = tonumber(nums[i]) end end local a, b = rest[1], rest[2] local day, month if a > 12 then day, month = a, b elseif b > 12 then month, day = a, b else day, month = a, b end -- ambiguous: day-first, as khal's defaults are if month < 1 or month > 12 or day < 1 or day > 31 then return nil end return { tonumber(nums[yi]), month, day } end -- Which days of a given month carry an event, and whose calendar owns each. -- Returns { [day] = }. First event of a day wins, so a day -- with two calendars takes the earlier one rather than blending into a colour -- that means neither. function M.calendar_month_days(events, year, month) local out = {} if type(events) ~= 'table' then return out end for _, e in ipairs(events) do if e.y == year and e.m == month and not out[e.d] then out[e.d] = e.calendar or true end end return out end -- Days in a month, and the weekday its 1st falls on. -- -- os.time/os.date rather than a leap-year rule: the C library already knows, -- and a hand-rolled rule is one more thing to get wrong in a century year. -- Normalised to Monday = 1 .. Sunday = 7, because khal's firstweekday = 0 -- means Monday while os.date's wday means Sunday = 1. function M.month_shape(year, month) local first = os.date('*t', os.time({ year = year, month = month, day = 1, hour = 12 })) -- Day 0 of the next month is the last day of this one, which os.time -- normalises for us across the December boundary. local last = os.date('*t', os.time({ year = year, month = month + 1, day = 0, hour = 12 })) return last.day, (first.wday + 5) % 7 + 1 end -- The breaktimer cache written by bin/breaktimer-sample.sh. -- -- Returns a table describing the daemon, or nil when the cache is absent or -- carries no 'generated' key (the sampler never ran). The display strings are -- derived here rather than in the widget so the phase-to-word and -- phase-to-colour decisions can be tested without Cairo: -- -- { running, state, phase, frozen, remain, -- countdown = '24:31' or '--', -- phase_label = 'working' | 'micro-pausa' | 'pausa lunga' | 'in pausa' -- | 'outside working hours' | 'fermo', -- next = 'pausa' | 'lavoro' | '--', -- role = 'ok' | 'heading' | 'highlight' | 'warning' | 'label' } -- -- `frozen` is the sampler's read of whether the clock is ticking: the phase is -- 'working' but the remain file has stopped being rewritten. Outside the work -- window that is normal and reads as such; a hung daemon looks the same, and -- should. -- -- The countdown leads to the next phase, but the daemon keeps the cycle count -- in memory and never writes it, so whether the NEXT break is a micro or a long -- one is not knowable here. 'pausa' is the honest answer; the phase word carries -- the rest. function M.breaktimer_parse(text) if type(text) ~= 'string' then return nil end local kv = M.kv_parse(text) if not kv.generated then return nil end local running = kv.running == 'yes' local state = kv.state or 'stopped' local phase = kv.phase or 'stopped' local remain = tonumber(kv.remain) or 0 if remain < 0 then remain = 0 end local out = { running = running, state = state, phase = phase, frozen = kv.frozen == 'yes', remain = remain, } -- Not running and stopped are the same thing on the card. The sampler's -- liveness check is what turns a dead PID into this reading: the state file -- can still say 'running' after a crash or a kill -9. if not running or state == 'stopped' then out.countdown = '--' out.phase_label = 'fermo' out.next = '--' out.role = 'label' return out end out.countdown = string.format('%d:%02d', math.floor(remain / 60), remain % 60) if state == 'paused' then out.phase_label = 'in pausa' out.role = 'warning' elseif out.frozen then out.phase_label = 'outside working hours' out.role = 'label' else local labels = { working = 'working', breaking = 'micro-pausa', longbreak = 'pausa lunga' } local roles = { working = 'ok', breaking = 'heading', longbreak = 'highlight' } out.phase_label = labels[phase] or phase out.role = roles[phase] or 'value' end out.next = (phase == 'working') and 'pausa' or 'lavoro' return out end -- The Immich memories index written by bin/immich-fetch.sh. -- -- Returns a list of { id, year, taken = { y, m, d, H, M }, place }, plus -- .url, the server's address for a click-through, and .week, true when the -- photos are the past week's fallback rather than today's, or nil when -- the index is absent or carries no 'generated' key. nil means "the fetch never -- ran"; an empty list means "no memories today", which the card names -- differently. `place` is nil when the key lacks asset.read or the photo has no -- location, so the card can print '--'. function M.immich_parse(text) if type(text) ~= 'string' then return nil end local out, generated = {}, false for line in text:gmatch('[^\n]+') do local kind, rest = line:match('^(%S+)%s+(.*)$') if kind == 'generated' then generated = true elseif kind == 'url' then out.url = rest elseif kind == 'scope' then out.week = rest == 'week' elseif kind == 'photo' then -- The place is last and takes the rest of the line, so a pipe in a -- place name cannot shift a field. local id, year, taken, place = rest:match('^([^|]+)|(%d*)|([^|]*)|(.*)$') if id then local y, m, d, H, Mi = taken:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+)') out[#out + 1] = { id = id, year = tonumber(year), taken = y and { tonumber(y), tonumber(m), tonumber(d), tonumber(H), tonumber(Mi) } or nil, place = place ~= '' and place or nil, } end end end if not generated then return nil end return out end -- nvtable's plain table (`nvtable -C -E`), as bin/updates-sample.sh caches it: -- -- [personal] foo-bin 1.14.9 -> 1.14.9 !! ~ 1.14.10 -- -> [~] [upstream] -- -- followed by a summary line with the counts. The marker is '!!' (installed -- behind upstream), 'ok' or '-' (not installed); '~' flags the shipped .info -- as behind. Upstream appears only when shipped does not already show it. -- -- Returns the rows as { repo, name, installed, shipped, upstream, behind, -- bump } with '--' turned into nil, plus .upgrade/.bump/.current/.missing -- counts, or nil when the text has neither rows nor a summary. function M.nvtable_parse(text) if type(text) ~= 'string' then return nil end local out, seen = {}, false local function v(s) return s ~= '--' and s or nil end for line in text:gmatch('[^\n]+') do local repo, name, inst, ship, rest = line:match('^%s*%[(.-)%]%s+(%S+)%s+(%S+)%s+%->%s+(%S+)%s*(.*)$') if repo then local mark, tail = rest:match('^(%S*)%s*(.*)$') local bump = tail:match('^~') ~= nil if bump then tail = tail:gsub('^~%s*', '') end out[#out + 1] = { repo = v(repo), name = name, installed = v(inst), shipped = v(ship), upstream = tail:match('^(%S+)'), behind = mark == '!!', bump = bump, } seen = true elseif line:match('to upgrade') then out.upgrade = tonumber(line:match('(%d+) to upgrade')) out.bump = tonumber(line:match('(%d+) repos? to bump')) out.current = tonumber(line:match('(%d+) current')) out.missing = tonumber(line:match('(%d+) not installed')) or 0 seen = true end end return seen and out or nil end return M