diff --git a/rss-notifier/README.md b/rss-notifier/README.md new file mode 100644 index 0000000..f635e85 --- /dev/null +++ b/rss-notifier/README.md @@ -0,0 +1,53 @@ +# RSS/Atom Notifier + +Monitor RSS/Atom feeds and get notifications for new items. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `nilsonlinux/rss-notifier` | +| Entries | Bar widget: `indicator`; panel: `panel`| + +**Entries:** +- **Service:** `fetcher` - Background service that fetches and parses feeds +- **Widget:** `badge` - Shows unread count on the bar +- **Panel:** `list` - Displays feed items in a list + +**IPC Command:** + +noctalia msg panel-toggle nilsonlinux/rss-notifier:list +text + + +## Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `feed_urls` | string_list | `[]` | List of RSS/Atom feed URLs to monitor (one per line) | +| `refresh_minutes` | int | `30` | How often to check for new items (1-1440 minutes) | +| `notify_new` | bool | `true` | Display notifications when new items arrive | +| `max_notifications_per_cycle` | int | `5` | Maximum notifications shown per check (1-50) | + +## Installation + +Install via Noctalia Plugin Store. + +## Requirements + +- `xdg-open` - Required to open feed URLs in your default web browser. Usually pre-installed on most Linux distributions. + +## Usage + +1. Add feed URLs in the plugin settings +2. The widget will show a badge with unread count +3. Click the widget to open the panel +4. Click an item to open it in your default browser + +## Dependencies + +**xdg-open** + +## License + +MIT diff --git a/rss-notifier/panel.luau b/rss-notifier/panel.luau new file mode 100644 index 0000000..e4aaa7b --- /dev/null +++ b/rss-notifier/panel.luau @@ -0,0 +1,128 @@ +-- panel.luau +-- Janela pop-up mostrada ao clicar no widget da barra: lista os itens mais +-- recentes, ou um estado vazio com icone quando nao ha nada novo. + +local items = {} + +-- declaradas antes para permitir referencia cruzada entre elas +local render +local dismissItem +local itemRow +local emptyState + +local function openLink(link) + if not link or link == "" then + return + end + -- escapa aspas simples pra evitar quebrar o comando do shell + local safe = link:gsub("'", "'\\''") + noctalia.runAsync("xdg-open '" .. safe .. "'") +end + +dismissItem = function(item) + -- remove localmente (resposta imediata) e avisa o service pra nao + -- reaparecer nas proximas atualizacoes + for i, it in ipairs(items) do + if it == item then + table.remove(items, i) + break + end + end + render() + + local id = item.id or item.link or item.title + if id then + local safe = tostring(id):gsub("'", "'\\''") + noctalia.runAsync("noctalia msg plugin nilsonlinux/rss-notifier:fetcher all dismiss '" .. safe .. "'") + end +end + +itemRow = function(item) + return ui.row({ + key = item.id or item.link or item.title, + gap = 6, + padding = 10, + radius = 8, + fill = "surface_variant/0.4", + align = "center", + }, { + ui.column({ + gap = 2, + flexGrow = 1, + onClick = function() openLink(item.link) end, + }, { + ui.label({ text = item.title or "(sem titulo)", fontWeight = "bold", maxLines = 2 }), + ui.label({ text = item.feedTitle or "", fontSize = 12, color = "outline" }), + }), + ui.button({ + glyph = "close", + variant = "ghost", + controlSize = "sm", + tooltip = "Dispensar", + onClick = function() dismissItem(item) end, + }), + }) +end + +emptyState = function() + return ui.column({ gap = 10, align = "center", padding = 32, flexGrow = 1, justify = "center" }, { + ui.glyph({ name = "rss", size = 32, color = "outline" }), + ui.label({ text = "Nenhuma atualização", color = "outline" }), + }) +end + +render = function() + local body + + if #items == 0 then + body = emptyState() + else + local rows = {} + for _, item in ipairs(items) do + table.insert(rows, itemRow(item)) + end + body = ui.scroll({ gap = 6, flexGrow = 1 }, rows) + end + + local headerButtons = {} + if #items > 0 then + table.insert(headerButtons, ui.button({ glyph = "trash", variant = "ghost", controlSize = "sm", tooltip = "Limpar tudo", onClick = "onClearAllClicked" })) + end + table.insert(headerButtons, ui.button({ glyph = "refresh", variant = "ghost", controlSize = "sm", tooltip = "Atualizar agora", onClick = "onRefreshClicked" })) + table.insert(headerButtons, ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", onClick = "onCloseClicked" })) + + panel.render(ui.column({ gap = 10, padding = 12, fill = true }, { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = "RSS/Atom Notifier", fontSize = 15, fontWeight = "bold" }), + ui.row({ gap = 4 }, headerButtons), + }), + ui.separator({}), + body, + })) +end + +noctalia.state.watch("items", function(raw) + local ok, decoded = pcall(noctalia.json.decode, raw or "[]") + items = (ok and decoded) or {} + render() +end) + +function onOpen(_context) + render() + -- abrir o painel conta como "li as novidades": zera o badge no service + noctalia.runAsync("noctalia msg plugin nilsonlinux/rss-notifier:fetcher all mark-read") +end + +function onRefreshClicked() + noctalia.runAsync("noctalia msg plugin nilsonlinux/rss-notifier:fetcher all refresh") +end + +function onClearAllClicked() + items = {} + render() + noctalia.runAsync("noctalia msg plugin nilsonlinux/rss-notifier:fetcher all clear-all") +end + +function onCloseClicked() + panel.close() +end diff --git a/rss-notifier/plugin.toml b/rss-notifier/plugin.toml new file mode 100644 index 0000000..79a0e94 --- /dev/null +++ b/rss-notifier/plugin.toml @@ -0,0 +1,60 @@ +id = "nilsonlinux/rss-notifier" +name = "RSS/Atom Notifier" +version = "1.0.0" +plugin_api = 14 +author = "Nilsonlinux" +license = "MIT" +icon = "rss" +description = "Acompanha feeds RSS/Atom e notifica quando surgem novos itens." +tags = ["utility", "indicator"] +dependencies = ["xdg-open"] + +[[setting]] +key = "feed_urls" +type = "string_list" +label_key = "settings.feed_urls.label" +description_key = "settings.feed_urls.description" +default = [] + +[[setting]] +key = "refresh_minutes" +type = "int" +label_key = "settings.refresh_minutes.label" +description_key = "settings.refresh_minutes.description" +default = 30 +min = 1 +max = 1440 + +[[setting]] +key = "notify_new" +type = "bool" +label_key = "settings.notify_new.label" +description_key = "settings.notify_new.description" +default = true + +[[setting]] +key = "max_notifications_per_cycle" +type = "int" +label_key = "settings.max_notifications_per_cycle.label" +description_key = "settings.max_notifications_per_cycle.description" +default = 5 +min = 1 +max = 50 +advanced = true + +[[service]] +id = "fetcher" +entry = "service.luau" + +[[widget]] +id = "badge" +entry = "widget.luau" + +[[panel]] +id = "list" +entry = "panel.luau" +width = 360 +height = 440 +placement = "attached" +position = "auto" +open_near_click = true diff --git a/rss-notifier/service.luau b/rss-notifier/service.luau new file mode 100644 index 0000000..cd3369f --- /dev/null +++ b/rss-notifier/service.luau @@ -0,0 +1,525 @@ +-- service.luau +-- Servico headless: busca cada feed configurado, extrai itens (RSS ou +-- Atom ), compara com o que ja foi visto, notifica o que for novo e +-- publica a contagem de nao-lidos em noctalia.state para o widget consumir. + +local seen = {} -- { [feedUrl] = { ids = { [itemId]=true, ... }, order = { id1, id2, ... } } } +local unread = 0 +local recentItems = {} -- lista dos itens mais recentes, mais novo primeiro +local dataPath = nil + +local MAX_RECENT_ITEMS = 50 +local MAX_SEEN_PER_FEED = 300 -- limite de ids "ja vistos" guardados por feed (evita crescer sem fim) + +-- Limites para manter o parsing rapido o suficiente para o orcamento de CPU +-- (muito apertado) do callback assincrono. +local MAX_BODY_BYTES = 8000 -- so olhamos os primeiros ~8KB do corpo do feed +local MAX_ITEM_BYTES = 400 -- corta cada bloco / antes de extrair tags +local MAX_ITEMS = 8 -- para de processar itens depois desse tanto + +local function isUtf8Continuation(b) + return b ~= nil and b >= 0x80 and b < 0xC0 +end + +-- Corta a string em ate n bytes, mas nunca no meio de um caractere UTF-8 +-- multibyte (acentos, aspas curvas, emoji, etc.) - cortar assim gera bytes +-- invalidos que o renderizador de texto (Pango) rejeita, deixando labels +-- (e por tabela o painel inteiro) sem aparecer. +local function truncate(s, n) + if not s or #s <= n then + return s + end + local cut = n + -- recua enquanto o byte da posicao for byte de continuacao (0x80-0xBF) + while cut > 0 and isUtf8Continuation(s:byte(cut)) do + cut = cut - 1 + end + -- se o byte que sobrou for o inicio de uma sequencia multibyte, so mantem + -- se ela couber inteira dentro do limite n; senao descarta ele tambem + local b = cut > 0 and s:byte(cut) or nil + if b and b >= 0xC0 then + local seqLen = (b >= 0xF0 and 4) or (b >= 0xE0 and 3) or 2 + if cut + seqLen - 1 > n then + cut = cut - 1 + end + end + return s:sub(1, cut) +end + +-- Mesma logica de "nao corte no meio de UTF-8", mas trabalhando so com +-- indices/bytes individuais (sem materializar substrings gigantes) - usado +-- para limitar o tamanho de um bloco sem copiar o conteudo inteiro +-- antes de cortar. +local function safeCutEnd(xml, endPos, minPos) + local pos = endPos + while pos > minPos do + local b = xml:byte(pos) + if not b then + break + end + if b < 0x80 then + break -- ascii, seguro + elseif b >= 0xC0 then + local seqLen = (b >= 0xF0 and 4) or (b >= 0xE0 and 3) or 2 + if pos + seqLen - 1 <= endPos then + break -- sequencia cabe inteira ate endPos, seguro + end + pos = pos - 1 -- nao cabe inteira, descarta o lead byte tambem + else + pos = pos - 1 -- byte de continuacao, ainda no meio da sequencia + end + end + return pos +end + +-- Testa se um item ja foi visto e, se nao, marca como visto - com limite de +-- tamanho por feed (descarta o mais antigo quando passa do limite), para +-- nunca deixar a estrutura "seen" crescer sem fim (e estourar o encode/save). +local function markSeen(url, id) + local bucket = seen[url] + if not bucket or not bucket.ids then + bucket = { ids = {}, order = {} } + seen[url] = bucket + end + if bucket.ids[id] then + return false -- ja visto + end + bucket.ids[id] = true + table.insert(bucket.order, id) + while #bucket.order > MAX_SEEN_PER_FEED do + local oldest = table.remove(bucket.order, 1) + bucket.ids[oldest] = nil + end + return true -- era novo +end + +local function isFirstRunForFeed(url) + local bucket = seen[url] + return not bucket or not bucket.order or #bucket.order == 0 +end + +-- Remove/descarta qualquer sequencia de bytes que NAO seja UTF-8 valido. +-- Necessario porque alguns feeds vem com o charset errado no servidor (bytes +-- Latin-1/CP-1252 marcados como UTF-8, por exemplo) - nesse caso nem um corte +-- perfeito resolve, o defeito ja vem no texto original. +local function sanitizeUtf8(s) + if not s then + return s + end + local out = {} + local i = 1 + local len = #s + while i <= len do + local b = s:byte(i) + if b < 0x80 then + out[#out + 1] = string.char(b) + i = i + 1 + else + local seqLen + if b >= 0xF0 and b <= 0xF4 then + seqLen = 4 + elseif b >= 0xE0 then + seqLen = 3 + elseif b >= 0xC2 then + seqLen = 2 + else + seqLen = 0 -- lead byte invalido (0x80-0xC1) + end + + local valid = seqLen > 0 and (i + seqLen - 1) <= len + if valid then + for k = 1, seqLen - 1 do + local cb = s:byte(i + k) + if not cb or cb < 0x80 or cb >= 0xC0 then + valid = false + break + end + end + end + + if valid then + out[#out + 1] = s:sub(i, i + seqLen - 1) + i = i + seqLen + else + i = i + 1 -- byte invalido: descarta so ele e continua + end + end + end + return table.concat(out) +end + +-- --------------------------------------------------------------------------- +-- Persistencia (sobrevive a restarts do plugin/shell) +-- --------------------------------------------------------------------------- + +local function loadState() + local dir = noctalia.pluginDataDir() + if not dir then + return + end + dataPath = dir .. "/state.json" + + local raw = noctalia.readFile(dataPath) + if raw then + local ok, decoded = pcall(noctalia.json.decode, raw) + if ok and type(decoded) == "table" then + seen = decoded.seen or {} + recentItems = decoded.recentItems or {} + unread = decoded.unread or 0 + + -- migra formato antigo ({ [id]=true, ... } direto) para o novo + -- ({ ids=..., order=... }), se necessario + for url, bucket in pairs(seen) do + if type(bucket) == "table" and not bucket.ids then + local migrated = { ids = {}, order = {} } + for id, v in pairs(bucket) do + if v == true then + migrated.ids[id] = true + table.insert(migrated.order, id) + end + end + seen[url] = migrated + end + end + + -- sanitiza itens ja persistidos (podem ter sido salvos com UTF-8 + -- quebrado por versoes anteriores deste plugin, antes deste fix) + for _, item in ipairs(recentItems) do + if item.title then + item.title = sanitizeUtf8(item.title) + end + if item.feedTitle then + item.feedTitle = sanitizeUtf8(item.feedTitle) + end + end + end + end +end + +-- Nunca deixa uma falha de encode/escrita derrubar o entry (isso ja causou o +-- service ser desativado apos varios erros seguidos no update()). +local function saveState() + if not dataPath then + return + end + local ok, encoded = pcall(noctalia.json.encode, { + seen = seen, + recentItems = recentItems, + unread = unread, + }) + if ok and type(encoded) == "string" then + pcall(noctalia.writeFile, dataPath, encoded) + end +end + +-- Idem para publicar em noctalia.state: nunca propaga erro pra cima. +local function publishItems() + local ok, encoded = pcall(noctalia.json.encode, recentItems) + if ok and type(encoded) == "string" then + noctalia.state.set("items", encoded) + end +end + +-- --------------------------------------------------------------------------- +-- Parsing minimo de RSS 2.0 / Atom 1.0 usando SO busca literal (string.find +-- com plain=true) - evita o motor de padroes do Lua (".-", "[^>]", etc.), +-- que parece caro demais para o orcamento de CPU deste ambiente. +-- --------------------------------------------------------------------------- + +local function decodeEntities(s) + if not s then + return s + end + s = s:gsub("<", "<") + s = s:gsub(">", ">") + s = s:gsub(""", '"') + s = s:gsub("'", "'") + s = s:gsub("&", "&") + return sanitizeUtf8(noctalia.string.trim(s)) +end + +local function stripCdata(s) + if s:sub(1, 9) == "", 10, true) + if endPos then + return s:sub(10, endPos - 1) + end + end + return s +end + +-- Extrai o conteudo de ... usando so find() literal. +local function findTagContent(s, tag) + local openStart = s:find("<" .. tag, 1, true) + if not openStart then + return nil + end + local openEnd = s:find(">", openStart, true) + if not openEnd then + return nil + end + local closeStart = s:find("", openEnd, true) + if not closeStart then + return nil + end + return decodeEntities(stripCdata(s:sub(openEnd + 1, closeStart - 1))) +end + +-- Extrai um valor de atributo de dentro da PRIMEIRA ocorrencia de . +local function findAttr(s, tag, attr) + local openStart = s:find("<" .. tag, 1, true) + if not openStart then + return nil + end + local openEnd = s:find(">", openStart, true) + if not openEnd then + return nil + end + local tagSrc = s:sub(openStart, openEnd) + local attrStart = tagSrc:find(attr .. '="', 1, true) + if not attrStart then + return nil + end + local valueStart = attrStart + #attr + 2 + local valueEnd = tagSrc:find('"', valueStart, true) + if not valueEnd then + return nil + end + return tagSrc:sub(valueStart, valueEnd - 1) +end + +local function extractLink(block) + local rssLink = findTagContent(block, "link") -- RSS: URL + if rssLink and rssLink ~= "" then + return rssLink + end + return findAttr(block, "link", "href") -- Atom: +end + +local function extractId(block) + return findTagContent(block, "guid") or findTagContent(block, "id") or extractLink(block) +end + +-- Encontra o PROXIMO bloco ... a partir de fromPos, usando so +-- find() literal. Retorna o conteudo do bloco (ja cortado) e a posicao onde +-- parar na proxima chamada - permite processar um item por vez, em ticks +-- separados, em vez de tudo de uma vez dentro do callback http. +local function findNextBlock(xml, tag, fromPos, maxItemBytes) + local openStart = xml:find("<" .. tag, fromPos, true) + if not openStart then + return nil, fromPos + end + local openEnd = xml:find(">", openStart, true) + if not openEnd then + return nil, fromPos + end + local closeTag = "" + local closeStart = xml:find(closeTag, openEnd, true) + if not closeStart then + return nil, fromPos + end + local contentEnd = math.min(closeStart - 1, openEnd + maxItemBytes) + if contentEnd < closeStart - 1 then + -- so precisa corrigir a fronteira UTF-8 quando o corte foi mesmo pelo + -- limite de bytes (nao pela tag de fechamento, que ja e uma fronteira segura) + contentEnd = safeCutEnd(xml, contentEnd, openEnd) + end + local block = xml:sub(openEnd + 1, contentEnd) + return block, closeStart + #closeTag +end + +-- --------------------------------------------------------------------------- +-- Fila de processamento incremental: o callback http so guarda o corpo (ja +-- cortado) na fila; o parsing de verdade acontece aos poucos, um item por +-- tick de update(), porque o orcamento de CPU do callback assincrono e +-- pequeno demais para processar um feed inteiro de uma vez. +-- --------------------------------------------------------------------------- + +local queue = {} -- lista de { url=, body=, pos=, tag=, feedTitle=, items=, notifyBudget= } + +local function enqueueFeed(url, body, notifyBudget) + table.insert(queue, { + url = url, + body = truncate(body, MAX_BODY_BYTES), + pos = 1, + tag = "item", + triedEntry = false, + feedTitle = nil, + items = {}, + notifyBudget = notifyBudget, + }) +end + +-- Finaliza um feed da fila: compara com o que ja foi visto, notifica o que +-- for novo e publica o estado. E um passo isolado (nao mistura com a +-- extracao de itens) para manter cada callback pequeno. +local function finalizeFeed(f) + local url = f.url + local firstRun = isFirstRunForFeed(url) + local notifyEnabled = noctalia.getConfig("notify_new") + local newCount = 0 + + for _, item in ipairs(f.items) do + local id = item.id or item.link or item.title + if id and markSeen(url, id) then + newCount = newCount + 1 + + if not firstRun then + table.insert(recentItems, 1, { + id = id, + title = item.title, + link = item.link, + feedTitle = f.feedTitle, + }) + if notifyEnabled and f.notifyBudget.count < f.notifyBudget.max then + f.notifyBudget.count = f.notifyBudget.count + 1 + noctalia.notify(item.title, f.feedTitle) + end + end + end + end + + while #recentItems > MAX_RECENT_ITEMS do + table.remove(recentItems) + end + + if not firstRun and newCount > 0 then + unread = unread + newCount + noctalia.state.set("unread", unread) + publishItems() + end + + saveState() +end + +-- Processa UM pequeno passo da fila: extrai no maximo um item do feed que +-- esta na frente da fila. Chamado uma vez por tick de update(). +local function processQueueStep() + local f = queue[1] + if not f then + return + end + + if not f.feedTitle then + f.feedTitle = findTagContent(truncate(f.body, 300), "title") or f.url + return -- um passo por tick: so o titulo desta vez + end + + if #f.items >= MAX_ITEMS then + table.remove(queue, 1) + finalizeFeed(f) + return + end + + local block, nextPos = findNextBlock(f.body, f.tag, f.pos, MAX_ITEM_BYTES) + + if not block then + if f.tag == "item" and not f.triedEntry then + -- RSS nao encontrado: tenta Atom () a partir do comeco + f.tag = "entry" + f.pos = 1 + f.triedEntry = true + return + end + -- acabaram os itens (ou nao achou nenhum) - finaliza + table.remove(queue, 1) + finalizeFeed(f) + return + end + + f.pos = nextPos + table.insert(f.items, { + title = findTagContent(block, "title") or "(sem titulo)", + link = extractLink(block), + id = extractId(block), + }) +end + +-- --------------------------------------------------------------------------- +-- Ciclo de busca +-- --------------------------------------------------------------------------- + +local function checkFeed(url, notifyBudget) + -- o callback http faz o MINIMO possivel: so guarda o corpo na fila. + -- Todo o parsing de verdade acontece depois, aos poucos, em processQueueStep(). + noctalia.http({ url = url, headers = { "Accept: application/rss+xml, application/atom+xml, application/xml, text/xml" } }, function(res) + if not res.ok or not res.body or res.body == "" then + return + end + enqueueFeed(url, res.body, notifyBudget) + end) +end + +local function fetchAll() + local urls = noctalia.getConfig("feed_urls") or {} + local notifyBudget = { count = 0, max = noctalia.getConfig("max_notifications_per_cycle") or 5 } + + for _, url in ipairs(urls) do + if url and noctalia.string.trim(url) ~= "" then + checkFeed(url, notifyBudget) + end + end +end + +local REFRESH_TICK_MS = 1000 -- update() roda a cada segundo: drena a fila aos poucos +local ticksUntilFetch = 1 + +local function applyInterval() + local minutes = noctalia.getConfig("refresh_minutes") or 30 + if minutes < 1 then + minutes = 1 + end + noctalia.setUpdateInterval(REFRESH_TICK_MS) + ticksUntilFetch = math.floor((minutes * 60000) / REFRESH_TICK_MS) +end + +-- --------------------------------------------------------------------------- +-- Ciclo de vida +-- --------------------------------------------------------------------------- + +loadState() +applyInterval() +noctalia.state.set("unread", unread) +publishItems() +fetchAll() -- primeira leitura: so marca como "vistos", sem notificar (firstRun) + +function update() + processQueueStep() -- sempre drena um pedacinho da fila, se houver algo + + ticksUntilFetch = ticksUntilFetch - 1 + if ticksUntilFetch <= 0 then + fetchAll() + local minutes = noctalia.getConfig("refresh_minutes") or 30 + if minutes < 1 then + minutes = 1 + end + ticksUntilFetch = math.floor((minutes * 60000) / REFRESH_TICK_MS) + end +end + +function onConfigChanged() + applyInterval() + fetchAll() +end + +function onIpc(_event, payload) + if _event == "refresh" then + fetchAll() + elseif _event == "mark-read" then + unread = 0 + noctalia.state.set("unread", 0) + elseif _event == "dismiss" and payload then + for i, item in ipairs(recentItems) do + if item.id == payload then + table.remove(recentItems, i) + break + end + end + publishItems() + saveState() + elseif _event == "clear-all" then + recentItems = {} + publishItems() + saveState() + end +end diff --git a/rss-notifier/thumbnail.webp b/rss-notifier/thumbnail.webp new file mode 100644 index 0000000..df30766 Binary files /dev/null and b/rss-notifier/thumbnail.webp differ diff --git a/rss-notifier/translations/en.json b/rss-notifier/translations/en.json new file mode 100644 index 0000000..26038c1 --- /dev/null +++ b/rss-notifier/translations/en.json @@ -0,0 +1,20 @@ +{ + "settings": { + "feed_urls": { + "label": "Feed URLs", + "description": "List of RSS/Atom feed URLs to monitor (one per line)" + }, + "refresh_minutes": { + "label": "Refresh Interval (minutes)", + "description": "How often to check for new items" + }, + "notify_new": { + "label": "Show Notifications", + "description": "Display notifications when new items arrive" + }, + "max_notifications_per_cycle": { + "label": "Max Notifications per Cycle", + "description": "Maximum notifications to show in one check" + } + } +} diff --git a/rss-notifier/translations/pt-BR.json b/rss-notifier/translations/pt-BR.json new file mode 100644 index 0000000..ec18891 --- /dev/null +++ b/rss-notifier/translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "settings": { + "feed_urls": { + "label": "URLs dos Feeds", + "description": "Lista de URLs de feeds RSS/Atom para monitorar (por linha)." + }, + "refresh_minutes": { + "label": "Intervalo de atualização", + "description": "Com que frequência verificar novos itens (em minutos)." + }, + "notify_new": { + "label": "Notificar novos itens", + "description": "Mostrar notificação para cada novo item encontrado." + }, + "max_notifications_per_cycle": { + "label": "Máximo de notificações", + "description": "Limitar o número de notificações enviadas em um único ciclo." + } + } +} diff --git a/rss-notifier/widget.luau b/rss-notifier/widget.luau new file mode 100644 index 0000000..ab8af22 --- /dev/null +++ b/rss-notifier/widget.luau @@ -0,0 +1,62 @@ +-- widget.luau +-- Widget de barra: icone RSS com uma pilula colorida (badge) de itens nao +-- lidos encostada no lado esquerdo, publicada pelo service via noctalia.state. + +local unread = 0 + +local function render() + local children = {} + + if unread > 0 then + -- pilula: um container com fundo colorido e cantos arredondados, com o + -- numero dentro - o mais perto que a API declarativa chega de um "badge" + table.insert(children, ui.row({ + fill = "primary", + radius = 8, + paddingH = 5, + paddingV = 1, + align = "center", + justify = "center", + }, { + ui.label({ text = tostring(unread), fontSize = 10, fontWeight = "bold", color = "on_primary" }), + })) + end + + table.insert(children, ui.glyph({ name = "rss", size = 14 })) + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 4, align = "center" }, children)) + + barWidget.setTooltip(unread > 0 and (tostring(unread) .. " novo(s) item(ns) nos feeds") or "Nenhum item novo") +end + +noctalia.state.watch("unread", function(value) + unread = value or 0 + render() +end) + +render() + +function update() + noctalia.setUpdateInterval(5000) -- so mantem o widget "vivo"; os dados reais vem do state +end + +function onClick() + -- abre/fecha a janelinha com a lista de itens; o proprio painel marca + -- como lido (zera o badge) quando e aberto, via IPC pro service + noctalia.togglePanel("nilsonlinux/rss-notifier:list") +end + +function onRightClick() + -- clique direito: forca uma nova checagem imediata dos feeds, + -- enviando um evento IPC para a entry [[service]] (que e um singleton, alvo "all") + noctalia.runAsync("noctalia msg plugin nilsonlinux/rss-notifier:fetcher all refresh") + noctalia.notify("RSS/Atom Notifier", "Atualizando feeds...") +end + +function onIpc(event, payload) + if event == "set" then + unread = tonumber(payload) or 0 + render() + end +end