33-- Atom <entry>), compara com o que ja foi visto, notifica o que for novo e
44-- publica a contagem de nao-lidos em noctalia.state para o widget consumir.
55
6- local seen = {} -- { [feedUrl] = { [itemId] = true, ... } }
6+ local seen = {} -- { [feedUrl] = { ids = { [itemId]=true, ... }, order = { id1, id2, ... } } }
77local unread = 0
88local recentItems = {} -- lista dos itens mais recentes, mais novo primeiro
99local dataPath = nil
1010
11- local MAX_RECENT_ITEMS = 50
11+ local MAX_RECENT_ITEMS = 50
12+ local MAX_SEEN_PER_FEED = 300 -- limite de ids "ja vistos" guardados por feed (evita crescer sem fim)
1213
1314-- Limites para manter o parsing rapido o suficiente para o orcamento de CPU
1415-- (muito apertado) do callback assincrono.
1516local MAX_BODY_BYTES = 8000 -- so olhamos os primeiros ~8KB do corpo do feed
1617local MAX_ITEM_BYTES = 400 -- corta cada bloco <item>/<entry> antes de extrair tags
1718local MAX_ITEMS = 8 -- para de processar itens depois desse tanto
1819
20+ local function isUtf8Continuation (b )
21+ return b ~= nil and b >= 0x80 and b < 0xC0
22+ end
23+
24+ -- Corta a string em ate n bytes, mas nunca no meio de um caractere UTF-8
25+ -- multibyte (acentos, aspas curvas, emoji, etc.) - cortar assim gera bytes
26+ -- invalidos que o renderizador de texto (Pango) rejeita, deixando labels
27+ -- (e por tabela o painel inteiro) sem aparecer.
1928local function truncate (s , n )
2029 if not s or # s <= n then
2130 return s
2231 end
23- return s :sub (1 , n )
32+ local cut = n
33+ -- recua enquanto o byte da posicao for byte de continuacao (0x80-0xBF)
34+ while cut > 0 and isUtf8Continuation (s :byte (cut )) do
35+ cut = cut - 1
36+ end
37+ -- se o byte que sobrou for o inicio de uma sequencia multibyte, so mantem
38+ -- se ela couber inteira dentro do limite n; senao descarta ele tambem
39+ local b = cut > 0 and s :byte (cut ) or nil
40+ if b and b >= 0xC0 then
41+ local seqLen = (b >= 0xF0 and 4 ) or (b >= 0xE0 and 3 ) or 2
42+ if cut + seqLen - 1 > n then
43+ cut = cut - 1
44+ end
45+ end
46+ return s :sub (1 , cut )
47+ end
48+
49+ -- Mesma logica de "nao corte no meio de UTF-8", mas trabalhando so com
50+ -- indices/bytes individuais (sem materializar substrings gigantes) - usado
51+ -- para limitar o tamanho de um bloco <item> sem copiar o conteudo inteiro
52+ -- antes de cortar.
53+ local function safeCutEnd (xml , endPos , minPos )
54+ local pos = endPos
55+ while pos > minPos do
56+ local b = xml :byte (pos )
57+ if not b then
58+ break
59+ end
60+ if b < 0x80 then
61+ break -- ascii, seguro
62+ elseif b >= 0xC0 then
63+ local seqLen = (b >= 0xF0 and 4 ) or (b >= 0xE0 and 3 ) or 2
64+ if pos + seqLen - 1 <= endPos then
65+ break -- sequencia cabe inteira ate endPos, seguro
66+ end
67+ pos = pos - 1 -- nao cabe inteira, descarta o lead byte tambem
68+ else
69+ pos = pos - 1 -- byte de continuacao, ainda no meio da sequencia
70+ end
71+ end
72+ return pos
73+ end
74+
75+ -- Testa se um item ja foi visto e, se nao, marca como visto - com limite de
76+ -- tamanho por feed (descarta o mais antigo quando passa do limite), para
77+ -- nunca deixar a estrutura "seen" crescer sem fim (e estourar o encode/save).
78+ local function markSeen (url , id )
79+ local bucket = seen [url ]
80+ if not bucket or not bucket .ids then
81+ bucket = { ids = {}, order = {} }
82+ seen [url ] = bucket
83+ end
84+ if bucket .ids [id ] then
85+ return false -- ja visto
86+ end
87+ bucket .ids [id ] = true
88+ table.insert (bucket .order , id )
89+ while # bucket .order > MAX_SEEN_PER_FEED do
90+ local oldest = table.remove (bucket .order , 1 )
91+ bucket .ids [oldest ] = nil
92+ end
93+ return true -- era novo
94+ end
95+
96+ local function isFirstRunForFeed (url )
97+ local bucket = seen [url ]
98+ return not bucket or not bucket .order or # bucket .order == 0
99+ end
100+
101+ -- Remove/descarta qualquer sequencia de bytes que NAO seja UTF-8 valido.
102+ -- Necessario porque alguns feeds vem com o charset errado no servidor (bytes
103+ -- Latin-1/CP-1252 marcados como UTF-8, por exemplo) - nesse caso nem um corte
104+ -- perfeito resolve, o defeito ja vem no texto original.
105+ local function sanitizeUtf8 (s )
106+ if not s then
107+ return s
108+ end
109+ local out = {}
110+ local i = 1
111+ local len = # s
112+ while i <= len do
113+ local b = s :byte (i )
114+ if b < 0x80 then
115+ out [# out + 1 ] = string.char (b )
116+ i = i + 1
117+ else
118+ local seqLen
119+ if b >= 0xF0 and b <= 0xF4 then
120+ seqLen = 4
121+ elseif b >= 0xE0 then
122+ seqLen = 3
123+ elseif b >= 0xC2 then
124+ seqLen = 2
125+ else
126+ seqLen = 0 -- lead byte invalido (0x80-0xC1)
127+ end
128+
129+ local valid = seqLen > 0 and (i + seqLen - 1 ) <= len
130+ if valid then
131+ for k = 1 , seqLen - 1 do
132+ local cb = s :byte (i + k )
133+ if not cb or cb < 0x80 or cb >= 0xC0 then
134+ valid = false
135+ break
136+ end
137+ end
138+ end
139+
140+ if valid then
141+ out [# out + 1 ] = s :sub (i , i + seqLen - 1 )
142+ i = i + seqLen
143+ else
144+ i = i + 1 -- byte invalido: descarta so ele e continua
145+ end
146+ end
147+ end
148+ return table.concat (out )
24149end
25150
26151-- ---------------------------------------------------------------------------
@@ -41,17 +166,57 @@ local function loadState()
41166 seen = decoded .seen or {}
42167 recentItems = decoded .recentItems or {}
43168 unread = decoded .unread or 0
169+
170+ -- migra formato antigo ({ [id]=true, ... } direto) para o novo
171+ -- ({ ids=..., order=... }), se necessario
172+ for url , bucket in pairs (seen ) do
173+ if type (bucket ) == "table" and not bucket .ids then
174+ local migrated = { ids = {}, order = {} }
175+ for id , v in pairs (bucket ) do
176+ if v == true then
177+ migrated .ids [id ] = true
178+ table.insert (migrated .order , id )
179+ end
180+ end
181+ seen [url ] = migrated
182+ end
183+ end
184+
185+ -- sanitiza itens ja persistidos (podem ter sido salvos com UTF-8
186+ -- quebrado por versoes anteriores deste plugin, antes deste fix)
187+ for _ , item in ipairs (recentItems ) do
188+ if item .title then
189+ item .title = sanitizeUtf8 (item .title )
190+ end
191+ if item .feedTitle then
192+ item .feedTitle = sanitizeUtf8 (item .feedTitle )
193+ end
194+ end
44195 end
45196 end
46197end
47198
199+ -- Nunca deixa uma falha de encode/escrita derrubar o entry (isso ja causou o
200+ -- service ser desativado apos varios erros seguidos no update()).
48201local function saveState ()
49- if dataPath then
50- noctalia .writeFile (dataPath , noctalia .json .encode ({
51- seen = seen ,
52- recentItems = recentItems ,
53- unread = unread ,
54- }))
202+ if not dataPath then
203+ return
204+ end
205+ local ok , encoded = pcall (noctalia .json .encode , {
206+ seen = seen ,
207+ recentItems = recentItems ,
208+ unread = unread ,
209+ })
210+ if ok and type (encoded ) == "string" then
211+ pcall (noctalia .writeFile , dataPath , encoded )
212+ end
213+ end
214+
215+ -- Idem para publicar em noctalia.state: nunca propaga erro pra cima.
216+ local function publishItems ()
217+ local ok , encoded = pcall (noctalia .json .encode , recentItems )
218+ if ok and type (encoded ) == "string" then
219+ noctalia .state .set ("items" , encoded )
55220 end
56221end
57222
@@ -70,7 +235,7 @@ local function decodeEntities(s)
70235 s = s :gsub (""" , '"' )
71236 s = s :gsub ("'" , "'" )
72237 s = s :gsub ("&" , "&" )
73- return noctalia .string .trim (s )
238+ return sanitizeUtf8 ( noctalia .string .trim (s ) )
74239end
75240
76241local function stripCdata (s )
@@ -154,6 +319,11 @@ local function findNextBlock(xml, tag, fromPos, maxItemBytes)
154319 return nil , fromPos
155320 end
156321 local contentEnd = math.min (closeStart - 1 , openEnd + maxItemBytes )
322+ if contentEnd < closeStart - 1 then
323+ -- so precisa corrigir a fronteira UTF-8 quando o corte foi mesmo pelo
324+ -- limite de bytes (nao pela tag de fechamento, que ja e uma fronteira segura)
325+ contentEnd = safeCutEnd (xml , contentEnd , openEnd )
326+ end
157327 local block = xml :sub (openEnd + 1 , contentEnd )
158328 return block , closeStart + # closeTag
159329end
@@ -185,15 +355,13 @@ end
185355-- extracao de itens) para manter cada callback pequeno.
186356local function finalizeFeed (f )
187357 local url = f .url
188- seen [url ] = seen [url ] or {}
189- local firstRun = next (seen [url ]) == nil
358+ local firstRun = isFirstRunForFeed (url )
190359 local notifyEnabled = noctalia .getConfig ("notify_new" )
191360 local newCount = 0
192361
193362 for _ , item in ipairs (f .items ) do
194363 local id = item .id or item .link or item .title
195- if id and not seen [url ][id ] then
196- seen [url ][id ] = true
364+ if id and markSeen (url , id ) then
197365 newCount = newCount + 1
198366
199367 if not firstRun then
@@ -218,7 +386,7 @@ local function finalizeFeed(f)
218386 if not firstRun and newCount > 0 then
219387 unread = unread + newCount
220388 noctalia .state .set ("unread" , unread )
221- noctalia . state . set ( "items" , noctalia . json . encode ( recentItems ) )
389+ publishItems ( )
222390 end
223391
224392 saveState ()
312480loadState ()
313481applyInterval ()
314482noctalia .state .set ("unread" , unread )
315- noctalia . state . set ( "items" , noctalia . json . encode ( recentItems ) )
483+ publishItems ( )
316484fetchAll () -- primeira leitura: so marca como "vistos", sem notificar (firstRun)
317485
318486function update ()
@@ -347,6 +515,11 @@ function onIpc(_event, payload)
347515 break
348516 end
349517 end
350- noctalia .state .set ("items" , noctalia .json .encode (recentItems ))
518+ publishItems ()
519+ saveState ()
520+ elseif _event == "clear-all" then
521+ recentItems = {}
522+ publishItems ()
523+ saveState ()
351524 end
352525end
0 commit comments