diff --git a/rss-notifier/README.md b/rss-notifier/README.md new file mode 100644 index 00000000..af6ae997 --- /dev/null +++ b/rss-notifier/README.md @@ -0,0 +1,50 @@ +# RSS/Atom Notifier + +Monitor RSS/Atom feeds and get notifications for new items. + +## Plugin + +**Id:** `nilsonlinux/rss-notifier` + +**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 00000000..3cafa3ac --- /dev/null +++ b/rss-notifier/panel.luau @@ -0,0 +1,118 @@ +-- 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 + + 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 }, { + ui.button({ glyph = "refresh", variant = "ghost", controlSize = "sm", tooltip = "Atualizar agora", onClick = "onRefreshClicked" }), + ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", onClick = "onCloseClicked" }), + }), + }), + 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 onCloseClicked() + panel.close() +end diff --git a/rss-notifier/plugin.toml b/rss-notifier/plugin.toml new file mode 100644 index 00000000..d83e1cef --- /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"] +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 00000000..1e657714 --- /dev/null +++ b/rss-notifier/service.luau @@ -0,0 +1,352 @@ +-- 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] = { [itemId] = true, ... } } +local unread = 0 +local recentItems = {} -- lista dos itens mais recentes, mais novo primeiro +local dataPath = nil + +local MAX_RECENT_ITEMS = 50 + +-- 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 truncate(s, n) + if not s or #s <= n then + return s + end + return s:sub(1, n) +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 + end + end +end + +local function saveState() + if dataPath then + noctalia.writeFile(dataPath, noctalia.json.encode({ + seen = seen, + recentItems = recentItems, + unread = unread, + })) + 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 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) + 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 + seen[url] = seen[url] or {} + local firstRun = next(seen[url]) == nil + 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 not seen[url][id] then + seen[url][id] = true + 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) + noctalia.state.set("items", noctalia.json.encode(recentItems)) + 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) +noctalia.state.set("items", noctalia.json.encode(recentItems)) +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 + noctalia.state.set("items", noctalia.json.encode(recentItems)) + end +end diff --git a/rss-notifier/thumbnail.webp b/rss-notifier/thumbnail.webp new file mode 100644 index 00000000..7d34bf04 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 00000000..26038c1b --- /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 00000000..ec18891e --- /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 00000000..ab8af223 --- /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