Modulo:VociRecenti

Il modulo legge la cache generata dal bot e restituisce una lista di voci di Wikipedia recenti, filtrate secondo i parametri passati dal template chiamante.

Modulo:VociRecenti
Info Istruzioni per l'uso
Questo è un modulo scritto in Lua. Le istruzioni che seguono sono contenute nella sottopagina Modulo:VociRecenti/man (modifica · cronologia)
Sandbox: Modulo:VociRecenti/sandbox (modifica · cronologia) · Sottopagine: lista · Test: Modulo:VociRecenti/test

Modulo:VociRecenti — Documentazione

Il modulo legge la cache generata dal bot e restituisce una lista di voci di Wikipedia recenti, filtrate secondo i parametri passati dal template chiamante.

Struttura della cache

La cache è distribuita su più pagine wiki: Modulo:VociRecenti/Dati1, Dati2, ... DatiN.

Ogni file è un modulo Lua che restituisce una tabella con questa struttura (formato attuale, array posizionale):

return {
  u  = [[DD/MM/YYYY HH:MM]],  -- ultimo aggiornamento
  v  = [[8.30]],              -- versione bot
  p  = 1,                     -- numero parte
  tp = 3,                     -- totale parti
  n  = 1000,                  -- numero voci in questa parte
  d  = {
    {
      [[titolo]],
      [[timestamp_14chr]],          -- es. "20260303041500"
      {[[cat1]], [[cat2]], ...},    -- categorie
      {                             -- template
        {[[NomeTemplate]], {[[param1]], [[param2]]}},
        ...
      },
      [[preview_100chr]],           -- preview del testo (primi 100 caratteri)
      [[move_timestamp]],           -- opzionale: data spostamento da sandbox/bozze
    },
    ...
  }
}

Le stringhe usano long string Lua ([[...]], [=[...]=], ecc.) con livello di escape automatico per evitare problemi con caratteri speciali nel wikitesto. Il modulo riconosce anche il vecchio formato con keyword (titolo=..., timestamp='...', ecc.) per retrocompatibilità.

Funzioni del modulo

p.main(frame)

Funzione principale richiamata dal template. Legge i parametri, carica tutti i file cache (con memoizzazione: i file vengono letti una sola volta per pagina anche in presenza di più template), filtra le voci e restituisce l'output formattato.

Parametro Tipo Default Descrizione
num numero 10 Quante voci mostrare
OrCat stringa Categorie in OR (virgola = OR tra gruppi)
AndCat stringa Categorie in AND (virgola = AND tra gruppi)
NoCat stringa Esclusione categorie; * = esclude voci con almeno una categoria. Applicato sempre in AND anche con And=off
Title stringa Parola nel titolo; ; o | per OR, + per AND tra termini dentro un gruppo
Text stringa Parola nella preview; ; o | per OR
TextRegExp stringa Pattern regex (Lua, non PCRE) sulla preview e sulle categorie/template. Supporta espansione gruppi (a;b)
AndTemplate stringa Tutti i template elencati devono essere presenti (virgola = AND tra gruppi)
OrTemplate stringa Almeno un template deve essere presente
NoTemplate stringa Esclusione template; * = esclude voci senza alcun template. Applicato sempre in AND anche con And=off
Portali stringa AND sui portali (tutti devono essere presenti); virgola = AND tra portali
OrPortali stringa OR sui portali (basta almeno uno presente)
ExclIfNotCat stringa Veto finale: esclude la voce se ha la categoria X ma NON ha la categoria Y. Sintassi: X>Y; virgola = AND tra regole multiple. Applicato sempre indipendentemente da And=
CaseSensitive on/off off Rende i filtri Text e TextRegExp case-sensitive
DataFine gg/mm/aaaa Solo voci create dopo questa data. Applicato sempre in AND anche con And=off
And on/off off Se on, tutti i filtri positivi devono essere soddisfatti simultaneamente (logica AND); se off (default) basta che almeno uno sia soddisfatto (logica OR). I filtri negativi (NoCat, NoTemplate, DataFine, ExclIfNotCat) sono sempre applicati in AND
Order stringa data Criterio di ordinamento (vedi sotto)
Disp stringa s Formato output (vedi sotto)
DispScroll stringa Come Disp ma avvolge il risultato in uno scrollbox. Sintassi: v,300 (valore disp + altezza in px, default 200). Ha precedenza su Disp se valorizzato
Timestamp stringa cachedays Informazioni di aggiornamento mostrate sotto la lista (vedi sotto)

I valori del parametro Disp (e DispScroll):

Valore Formato
s Elenco numerato senza data (default)
v Elenco numerato con data
t Elenco puntato senza data
o Elenco puntato con data
h Elenco orizzontale, voci separate da · , senza data né numerazione

I valori del parametro Order:

Valore Ordinamento
data Data decrescente: dalla voce più recente alla più vecchia (default)
Dateold Data crescente: dalla voce più vecchia alla più recente
Alpha Alfabetico crescente per titolo (dalla A alla Z, accenti e diacritici normalizzati)

I valori del parametro Timestamp:

Valore Comportamento
cachedays Mostra data aggiornamento + numero file cache + ampiezza temporale della cache (default)
on Mostra data aggiornamento + numero file cache + ampiezza temporale delle voci filtrate
days Mostra data aggiornamento + ampiezza temporale delle voci filtrate
date Mostra solo la data di aggiornamento
off Non mostra alcuna informazione di aggiornamento

Tutti i parametri sono case-insensitive: |Disp=, |disp= e |DISP= sono equivalenti.

p.debugVoce(frame)

Funzione di debug: dato un titolo di voce, mostra le sue categorie, i suoi template (con parametri) e verifica se corrisponde a un filtro OrCat o Portali specificato. Esempio di utilizzo:

{{#invoke:VociRecenti|debugVoce|titolo=Nome voce|OrCat=aviatori}}
{{#invoke:VociRecenti|debugVoce|titolo=Nome voce|Portali=sport|NoTemplate=stub}}

La funzione mostra anche informazioni diagnostiche sul bug mw.ustring.find relativo ai template portale (vedi sezione "Problemi noti").

Logica di filtraggio

I filtri vengono applicati in sequenza su ogni voce della cache. La modalità di combinazione dipende dal parametro And:

  • Modalità OR (default, And=off): basta che almeno uno dei filtri positivi sia soddisfatto. I filtri negativi (NoCat, NoTemplate, DataFine, ExclIfNotCat) sono sempre applicati come veto finale, indipendentemente da And=.
  • Modalità AND (And=on): tutti i filtri specificati devono essere soddisfatti simultaneamente.

Se sono presenti solo filtri negativi (es. solo NoCat=), in modalità OR il modulo include tutte le voci che superano il veto, senza richiedere che soddisfino un filtro positivo.

Separatori e operatori

Tutti i parametri filtro supportano i seguenti operatori:

Operatore Dove si usa Significato
, (virgola) OrCat, AndCat, NoCat, AndTemplate, OrTemplate, NoTemplate, Portali, OrPortali, ExclIfNotCat In OrCat/OrTemplate/OrPortali: OR tra gruppi. In AndCat/AndTemplate/Portali: AND tra gruppi. In NoCat/NoTemplate: AND tra gruppi di esclusione
; (punto e virgola) Tutti i parametri filtro OR tra alternative dentro ogni gruppo. In Title e Text equivale a |
+ (più) Title AND tra termini dentro un gruppo OR. Es: riserva+darwin;oasi → (riserva AND darwin) OR oasi
"termine" (doppi apici) Tutti i parametri filtro Match parola intera invece di sottostringa. Es: OrCat="nati" trova "Nati in Italia" ma non "Campionati"
not(termine) Tutti i parametri filtro Negazione del termine nel contesto del parametro. Es: AndCat=not(Nati a Parigi),Morti a Parigi → non nati a Parigi E morti a Parigi. Supporta doppi apici: not("morti"). Supporta OR interno con ; o , in tutti i parametri: not(russa;russi) = né "russa" né "russi" (De Morgan)

Ricerca nelle categorie

OrCat include la voce se almeno una categoria contiene almeno uno dei termini; AndCat richiede che tutti i gruppi siano soddisfatti; NoCat esclude le voci che hanno almeno uno dei termini. La ricerca è per sottostringa, case-insensitive, salvo uso dei doppi apici per match parola intera.

Ricerca nel testo

Text cerca nella preview (primi 100 caratteri della voce); TextRegExp cerca nella preview e in categorie/template concatenati. I pattern per TextRegExp sono in formato Lua (non PCRE); il parametro CaseSensitive=on rende la ricerca sensibile alle maiuscole. I gruppi alternativi nella sintassi (a;b;c) vengono espansi in pattern multipli.

ExclIfNotCat

Il parametro ExclIfNotCat agisce sempre come veto finale, indipendentemente dalla modalità And=. Sintassi: X>Y dove > (Shift+.) separa la categoria trigger dalla categoria eccezione. Più regole separate da virgola vengono applicate in AND. Esempio:

ExclIfNotCat=Morti a Parigi>Nati a Parigi

Esclude le voci che hanno la categoria "Morti a Parigi" ma non hanno "Nati a Parigi". Risolve il caso di voci collegate a un argomento solo tramite una categoria "negativa" (es. luogo di morte) senza altri legami positivi.

Logica di ordinamento

L'ordinamento viene applicato dopo il filtraggio e prima del troncamento al numero di voci richiesto (num). Con Order=Alpha i caratteri accentati e diacritici vengono normalizzati alla lettera base prima del confronto (à=a, è=e, ecc.), in modo che "Élite" e "elite" siano ordinati come equivalenti.

Comportamento con nessuna voce trovata

Se nessuna voce corrisponde ai criteri di filtro, il modulo mostra un messaggio di avviso. Se Timestamp≠off, viene comunque visualizzata la riga di aggiornamento sotto il messaggio. Se la cache non è inizializzata (nessun file Dati presente), viene mostrato un avviso specifico che invita ad eseguire il bot.

Stato sviluppo

Versione corrente: v8.33

Versione Modifica principale
v1–v5 Sviluppo iniziale, singolo file cache, filtri base
v6.0 Supporto multi-file cache (Dati1, Dati2, ...)
v7.0 Caricamento automatico file multipli, parametro And per combinazione filtri OR/AND
v8.0 Parametro Order (data decrescente, crescente, alfabetico); parametro Timestamp per mostrare/nascondere informazioni di aggiornamento
v8.7 Aggiunto parametro OrPortali; Portali mantiene logica AND
v8.10 Aggiunto parametro DispScroll (scrollbox verticale/orizzontale)
v8.13 Supporto ; come separatore OR in tutti i parametri filtro
v8.14 Title: supporto + come AND tra termini dentro ogni gruppo OR
v8.15 Aggiunto parametro NoTemplate (esclusione template, con * per voci senza template)
v8.16 Default di And= cambiato da on a off
v8.17 Memoizzazione di loadAllData(): i file cache vengono caricati una sola volta per pagina (fix OOM con più template sulla stessa pagina)
v8.18 Aggiunto valore h per Disp e DispScroll (visualizzazione orizzontale)
v8.19 formatTimestamp: aggiunta sigla "UTC" accanto all'ora
v8.20 Fix logica filtri solo negativi in modalità And=off; aggiunto valore * per NoCat e NoTemplate
v8.21 Match parola intera con doppi apici ("termine") in tutti i parametri filtro
v8.24–8.25 Fix matchesPortali/matchesOrPortali: confronto esatto via isPortaleTemplate(), workaround bug mw.ustring.find, supporto doppi apici
v8.26 Supporto not(termine) in tutti i parametri filtro
v8.27 Aggiunto parametro ExclIfNotCat (veto finale: ha X ma non ha Y)
v8.28–8.29 Fix separatore ExclIfNotCat: cambiato da | a ~ a > per compatibilità con MediaWiki e tastiere italiane
v8.30 Fix not() in Portali e OrPortali: primo supporto OR interno con ; dentro not() (es. not(radio;cinema))
v8.31 (versione intermedia su Wikipedia, nessuna modifica documentata)
v8.32 Fix not(X;Y) e not(X,Y) in tutti i parametri filtro: la funzione split() spezzava il token not(...) sui separatori interni. Nuova funzione splitParens() che ignora i separatori dentro parentesi tonde
v8.33 Fix not() con sotto-termini multipli in AndCat, OrCat, NoCat: l'inner estratto da parseNot veniva passato a contains() come stringa intera invece di essere espanso in sotto-termini. Nuova funzione expandNotInner() che espande l'inner su ; e , prima del match

Il modulo usa pcall per proteggere il require() di ogni file cache: se un file ha errori di sintassi Lua, viene saltato senza bloccare l'intera esecuzione. I long string Lua nei campi dati possono usare qualsiasi livello di escape — il modulo non deve gestirli esplicitamente perché Lua li interpreta nativamente.


-- Modulo:VociRecenti
-- Versione 8.46 - FIX sintassi /Nome/ (slash) non riconosciuta in Portali= e OrPortali=:
--                matchesPortali e matchesOrPortali confrontano i termini cercati
--                direttamente contro i parametri del template Portale/Portali tramite
--                contains(), che non conosce la sintassi slash. Il termine /storia/
--                veniva cercato letteralmente (con gli slash) nei parametri, che
--                contengono solo "storia", causando sempre un mancato match.
--                Fix: aggiunta funzione matchPortaleParam() che rileva la sintassi
--                /Nome/ (esatto), "Nome" (parola intera) e Nome (sottostringa),
--                usata al posto di contains() nei due punti di matchesPortali e
--                matchesOrPortali. Overhead nullo: il rilevamento slash/virgolette
--                avviene una volta per termine, fuori dal loop sui parametri.
-- Versione 8.45 - FIX falsi positivi nei filtri sui template (NoTemplate, AndTemplate,
--                OrTemplate, Portali, OrPortali): il match per sottostringa e il match
--                "parola intera" (tra virgolette, es. "Libro") matchavano anche template
--                il cui nome contiene la parola cercata come token separato, es.
--                NoTemplate=Libro o NoTemplate="Libro" escludevano erroneamente anche le
--                voci con {{cita libro}}, non solo quelle con {{Libro}}.
--                Aggiunta nuova sintassi /Nome/ (slash) per il match ESATTO sull'intero
--                nome del template: NoTemplate=/Libro/ esclude solo {{Libro}}, lasciando
--                {{cita libro}} non filtrato. Gli underscore nel nome tra slash vengono
--                normalizzati come spazi prima del confronto (es. /cita_libro/ combacia
--                con {{cita libro}}), coerentemente con la normalizzazione dei nomi
--                pagina/template di MediaWiki. La sintassi esistente tra virgolette
--                (match parola intera dentro il nome) non è stata modificata.
-- Versione 8.44 - Nuovo parametro |First=N: limita le voci caricate alle prime N
--                della cache (le più recenti, nell'ordine in cui il bot le ha scritte),
--                riducendo RAM e CPU proporzionalmente. Il caricamento si interrompe
--                appena raggiunte N voci, senza allocare il resto della cache.
--                Con First= valorizzato la memoizzazione è disabilitata per quell'istanza
--                (istanze diverse possono avere N diversi). Senza First= il comportamento
--                è identico alle versioni precedenti, memoizzazione inclusa: più template
--                senza First= sulla stessa pagina continuano a condividere la cache.
-- Versione 8.43 - Ottimizzazioni performance (nessuna modifica alla logica):
--                1) splitParens fast-path: se la stringa non contiene '(' usa split()
--                   diretto (caso più comune: nessun not(...) nel parametro),
--                   evitando l'intero loop di analisi parentesi.
--                2) getArgs: sostituito mw.ustring.lower con string.lower per le chiavi
--                   parametro template (sempre ASCII).
--                3) contains() refactoring: introdotti parseNeedle() e containsParsed()
--                   per separare il parsing del needle (una volta) dal match (per ogni
--                   haystack). matchesAndCat, matchesOrCat, matchesNoCat pre-lowercasano
--                   l'array categorie una sola volta fuori dal loop termini, eliminando
--                   N×M chiamate a mw.ustring.lower (il hot path più costoso).
--                4) matchesExclIfNotCat: pre-lowercase categorie + string.find per '>'
--                   (ASCII), eliminando mw.ustring.find/sub per il separatore.
--                5) matchesSingleTemplate: string.lower per nomi template (ASCII),
--                   rimosso doppio mw.ustring.lower ridondante nel ramo portale/portali.
--                6) buildSearchable guard nel ramo And=on: costruisce la stringa solo se
--                   regexPattern ~= ''. Early exit nel ramo And=off per i veto finali
--                   (noCat, hNoCat, noTemplate, exclIfNotCat): saltati se matches=false.
-- Versione 8.41 - FIX ordinamento Order=Data e Order=Dataold: entrambi usavano sempre
--                voce[2] (timestamp di creazione) per il confronto, ignorando voce[6]
--                (move_timestamp) per le voci spostate da altro namespace. Questo causava
--                un ordine disallineato rispetto alle date visualizzate (che già usavano
--                voce[6] tramite formatTimestampDisplay). Fix: entrambi i sort usano ora
--                lo stesso timestamp visualizzato: voce[6] se presente e non vuoto,
--                altrimenti voce[2]. Aggiunto inoltre sort esplicito per Order=Data
--                (in precedenza si affidava all'ordine implicito della cache, che poteva
--                non essere garantito dopo merge parziali dei file Dati).
-- Versione 8.40 - Categorie nascoste: il 7° campo del formato cache (voce[7])
--                contiene le categorie nascoste, distinte da quelle visibili (voce[3]).
--                Nuovi parametri HAndCat, HOrCat, HNoCat: operano su voce[7]
--                con la stessa logica di AndCat, OrCat, NoCat (incluso not()).
--                NoCat=* e tutti gli altri parametri esistenti operano solo sulle
--                categorie visibili (voce[3]), correggendo il caso in cui una voce
--                senza categorie visibili ma con categorie nascoste non veniva
--                segnalata da NoCat=*.
--                Retrocompatibilita': cache prodotte dal bot v9.1.x (senza voce[7])
--                restituiscono {} per voce[7] tramite il fallback nel loadAllData.
-- Versione 8.39 - FIX calcDaysRange: il conteggio dei giorni ora usa lo stesso timestamp
--                visualizzato dalla voce (move_timestamp se presente, altrimenti timestamp
--                di creazione), coerentemente con formatTimestampDisplay. In precedenza
--                veniva usato sempre e solo voce[2] (timestamp di creazione), ignorando
--                voce[6] (move_timestamp) per le voci spostate da altro namespace,
--                causando un calcolo dei giorni disallineato rispetto alle date mostrate.
-- Versione 8.38 - Visualizzazione corsivo per voci spostate da altro namespace.
-- Versione 8.37 - FIX loadAllData: nel ramo di normalizzazione formato "vecchio" (voce.titolo),
--                voce[4] (template) era sempre impostato a {} (tabella vuota), ignorando
--                completamente voce.template. Questo causava il mancato funzionamento di
--                NoTemplate=Categorizzare (e qualunque filtro su template: AndTemplate,
--                OrTemplate, NoTemplate, Portali, OrPortali) quando la cache è nel formato
--                con campi nominati (titolo/timestamp/categorie/template/contenuto).
--                Con voce[4]={} ogni voce risultava priva di template, quindi
--                matchesNoTemplate restituiva sempre true e le voci con {{Categorizzare}}
--                non venivano escluse. Corretto: voce[4] = voce.template or {}.
-- Versione 8.36 - FIX formatTimestamp: rimossa sigla "UTC" dall'orario delle voci
--                (introdotta in v8.19 quando i timestamp erano effettivamente UTC).
--                La correzione del fuso orario è avvenuta nel bot di aggiornamento
--                cache: i timestamp scritti in cache sono ora in ora locale, quindi
--                la sigla "UTC" era diventata scorretta. La rimozione era già stata
--                pianificata ma non era mai confluita in una versione pubblicata.
-- Versione 8.35 - FIX timestamp fuori dalle colonne: in v8.34 column-span:all era
--                applicato al tag <small> che è inline — i browser ignorano
--                column-span su elementi inline. Avvolto il <small> in un <div>
--                block-level, su cui column-span:all ha effetto. La modifica è
--                centralizzata in buildTimestampLine e copre automaticamente tutti
--                i casi (nessuna voce trovata, scrollbox, display h, display normale).
-- Versione 8.34 - FIX layout multicolonna: aggiunto column-span:all ai div di avviso
--                (cache non inizializzata, cache vuota, nessuna voce trovata) e alla
--                riga "Ultimo aggiornamento". Senza questo stile, quando il template
--                è avvolto in un <div style="column-count:N"> il contenuto veniva
--                distribuito nelle colonne spezzando i box di avviso.
--                column-span:all forza questi elementi a occupare l'intera larghezza
--                del contenitore, ignorando la suddivisione in colonne.
-- Versione 8.33 - FIX not() con sotto-termini multipli in AndCat, OrCat, NoCat:
--                il valore inner estratto da parseNot (es. '"russa";"russi";"russia"')
--                veniva passato direttamente a contains() come stringa intera invece
--                di essere espanso in sotto-termini separati da ";" o ",".
--                Questo causava il mancato match: contains cercava letteralmente
--                la stringa '"russa";"russi";"russia"' nelle categorie, non la trovava,
--                e il not() risultava erroneamente soddisfatto.
--                Soluzione: nei rami isNot di matchesAndCat, matchesOrCat, matchesNoCat
--                l'inner viene ora espanso con split(inner, ';') e split(inner, ',')
--                (OR tra sotto-termini), replicando la logica già presente in
--                matchesPortali e matchesOrPortali dalla v8.30.
--                Esempi ora funzionanti:
--                  AndCat=ucrain,not("russa";"russi";"russia")
--                    -> ha cat con "ucrain" E nessuna cat con parola intera
--                       "russa", "russi" o "russia"
--                  OrCat=not(calcio;tennis)
--                    -> match se la voce non ha né "calcio" né "tennis"
-- Versione 8.32 - FIX not() con separatori interni (";", ",") in tutti i parametri
--                filtro: AndCat, OrCat, NoCat, AndTemplate, OrTemplate, NoTemplate,
--                Title, Text. La funzione split() usata per espandere i termini dentro
--                ogni gruppo spezzava ingenuamente sul separatore, senza rispettare i
--                caratteri che si trovano dentro parentesi tonde. Questo causava il
--                malfunzionamento di not(X;Y) e not(X,Y) in tutti i parametri tranne
--                Portali/OrPortali (che avevano già un percorso separato).
--                Esempio del bug: AndCat=ucrain,not("russa";"russi") veniva splittato
--                in tre token: not("russa"  /  "russi"  /  "russia") perdendo le
--                parentesi e rendendo il not() inoperante.
--                Soluzione: nuova funzione splitParens(str, sep) che ignora i
--                separatori dentro parentesi tonde. Sostituisce split() per tutti
--                gli split su "," e ";" nelle funzioni di matching.
--                Esempi ora funzionanti:
--                  AndCat=ucrain,not("russa";"russi","russia")
--                    -> ha cat con "ucrain" E nessuna cat con "russa","russi","russia"
--                  NoCat=not(Nati a Roma),Morti a Roma
--                    -> stesso comportamento di prima (non rotto, ora più robusto)
-- Versione 8.31 - (versione intermedia su Wikipedia, nessuna modifica documentata)
-- Versione 8.30 - FIX not() in Portali= e OrPortali=: supporto OR interno con ";"
--                dentro not(). Sintassi: not(radio;cinema) esclude le voci che hanno
--                il portale "radio" OPPURE il portale "cinema" (equivalente per De Morgan
--                a: non radio AND non cinema). In precedenza "radio;cinema" veniva trattato
--                come stringa letterale e non matchava nulla. Ora il separatore ";" viene
--                espanso in più condizioni NOT, coerentemente con il comportamento degli
--                altri parametri (AndCat, OrCat, ecc.).
--                Esempi:
--                  Portali=televisione,not(radio;cinema)
--                    -> portale televisione presente; né radio né cinema presenti
--                  OrPortali=sport,not(calcio;tennis)
--                    -> portale sport presente, oppure né calcio né tennis tra i portali
-- Versione 8.29 - FIX ExclIfNotCat: cambiato separatore trigger>eccezione da "~" a ">"
--                per maggiore accessibilità su tastiere italiane standard (Shift+.).
--                Sintassi aggiornata: ExclIfNotCat=Morti a Parigi>Nati a Parigi
-- Versione 8.28 - FIX ExclIfNotCat: cambiato separatore trigger~eccezione da "|" a "~"
--                per evitare che MediaWiki interpreti il pipe come separatore di parametro
--                template, troncando il valore e rendendo la regola non funzionante.
--                Sintassi aggiornata: ExclIfNotCat=Morti a Parigi~Nati a Parigi
-- Versione 8.27 - Aggiunto parametro |ExclIfNotCat=: esclude una voce se ha la categoria X
--                ma NON ha la categoria Y. Risolve il caso in cui una voce è collegata a un
--                argomento solo tramite una categoria "negativa" (es. morte in un luogo) senza
--                avere altri legami positivi con quell'argomento.
--                Sintassi: ExclIfNotCat=X>Y
--                  X = categoria trigger (sottostringa, case-insensitive)
--                  Y = categoria eccezione (sottostringa, case-insensitive)
--                  > = separatore obbligatorio tra trigger ed eccezione
--                  , = AND tra regole multiple
--                Significato: "escludi la voce se ha X, a meno che non abbia anche Y"
--                Esempi:
--                  ExclIfNotCat=Morti a Parigi>Nati a Parigi
--                    -> esclude chi è morto a Parigi ma non nato a Parigi
--                  ExclIfNotCat=Morti a Roma>Nati a Roma,Morti a Milano>Nati a Milano
--                    -> applica la regola in AND per due città distinte
--                Applicato sempre come veto finale (come NoCat), in entrambe le
--                modalità And=on e And=off.
-- Versione 8.26 - Supporto not() in tutti i parametri filtro: AndCat, OrCat, NoCat,
--                AndTemplate, OrTemplate, NoTemplate, Title, Text, Portali, OrPortali.
--                Sintassi: not(termine) nega il match del singolo termine nel contesto
--                del parametro in cui appare. Il termine interno supporta le virgolette
--                per match parola intera (es. not("morti")).
--                Esempi:
--                  AndCat=not(Nati a Parigi),morti a Parigi
--                    -> non nati a Parigi E morti a Parigi
--                  NoCat=not(Nati a Parigi),morti a Parigi
--                    -> escludi chi non ha "Nati a Parigi" (richiede positivamente
--                       quella cat) E escludi chi ha "morti a Parigi"
--                  AndCat=not("morti"),"nati"
--                    -> ha cat "nati" (parola intera) e non ha cat "morti"
-- Versione 8.25 - FIX matchesPortali/matchesOrPortali: sostituito mw.ustring.find
--                diretto con contains(), portando il supporto ai doppi apici per
--                match esatto anche su Portali= e OrPortali=. In precedenza termini
--                come "Cina" facevano match parziale su "medicina" causando falsi
--                positivi. Ora "Cina" (senza virgolette) fa ancora match parziale,
--                mentre '"Cina"' (con virgolette) cerca solo il portale esatto.
-- Versione 8.24 - FIX matchesPortali/matchesOrPortali: sostituita ricerca sottostringa
--                con confronto esatto tramite isPortaleTemplate() che riconosce sia
--                "portale" che "portali". Necessario perche string.find e mw.ustring.find
--                non trovano "portale" in "portali" su MediaWiki Lua (comportamento anomalo).
-- Versione 8.23 - FIX matchesPortali/matchesOrPortali: sostituito mw.ustring.find
--                con string.find per il controllo del nome template. mw.ustring.find
--                con plain=true su MediaWiki restituisce nil per sottostringhe in
--                posizione finale (es. cerca portale in portali -> nil). Risolve il
--                mancato match di {{portali}} nei filtri Portali= e OrPortali=.
-- Versione 8.22 - FIX debugVoce: legge i parametri da frame.args invece di
--                frame:getParent().args, corretto per chiamate #invoke dirette.
-- Versione 8.21 - Match parola intera: se il termine è racchiuso tra doppi apici
--                (es. OrCat="nati") cerca la parola esatta invece della sottostringa.
--                Funziona in tutti i parametri: OrCat, AndCat, NoCat, Title, Text,
--                AndTemplate, OrTemplate, NoTemplate. Case-insensitive come il match
--                normale. Es: OrCat="nati" trova "Nati in Italia" ma non "Campionati".
--                FIX: i doppi apici ora funzionano anche nel nome template (AndTemplate,
--                OrTemplate, NoTemplate) tramite matchesSingleTemplate aggiornato.
-- Versione 8.20 - FIX logica filtraggio con soli filtri negativi (NoCat/NoTemplate):
--                in modalità And=off, specificare solo NoCat= o NoTemplate= ora
--                funziona correttamente (mostra tutto tranne le voci escluse).
--                Aggiunto valore speciale "*" per NoCat e NoTemplate: esclude voci
--                che hanno almeno una categoria (NoCat=*) o almeno un template
--                (NoTemplate=*). Utile per trovare voci completamente spoglie.
-- Versione 8.19 - formatTimestamp: aggiunta sigla "UTC" accanto all'ora per chiarire
--                che i timestamp delle voci sono in UTC (non nel fuso orario locale).
-- Versione 8.18 - Aggiunto valore 'h' per Disp e DispScroll: visualizzazione orizzontale
--                delle voci separate da ' · ', senza data né numerazione.
--                DispScroll=h,NNN avvolge la lista in uno scrollbox orizzontale.
-- Versione 8.17 - Memoizzazione di loadAllData(): i file cache vengono caricati
--                una sola volta per pagina. Più template VociRecenti sulla stessa
--                pagina riusano i dati già in memoria, dimezzando il consumo RAM
--                in caso di template multipli e risolvendo l'errore OOM con cache grandi.
-- Versione 8.16 - Default di |And= cambiato da 'on' a 'off': in assenza del parametro
--                i filtri AndCat, OrCat, Title, Text, TextRegExp, AndTemplate, OrTemplate,
--                Portali, OrPortali vengono combinati in logica OR anziché AND.
-- Versione 8.15 - Aggiunto parametro |NoTemplate=: esclude voci che contengono almeno uno
--                dei template elencati. Stessa logica di NoCat: virgola = AND tra gruppi di
--                esclusione, punto e virgola = OR dentro ogni gruppo. Applicato sempre in AND
--                anche con And=off. Match parziale, case-insensitive.
-- Versione 8.14 - Parametro Title: supporto "+" come AND tra termini dentro ogni gruppo OR.
--                Sintassi: "riserva+darwin;oasi" -> (riserva AND darwin) OR oasi.
--                Il separatore ";" (OR tra gruppi) e "|" (via {{!}}) restano invariati.
-- Versione 8.13 - Supporto ";" come separatore OR nei parametri Title, Text,
--                AndCat, OrCat, NoCat, AndTemplate, OrTemplate.
--                Per AndCat/NoCat/AndTemplate la virgola separa gruppi AND,
--                il punto e virgola separa alternative OR dentro ogni gruppo.
--                Es: AndCat=parte1;parte2,parte3 -> (parte1 OR parte2) AND parte3
-- Versione 8.12 - (saltata per allineamento versioni)
-- Versione 8.11 - (saltata per allineamento versioni)
-- Versione 8.10 - Aggiunto parametro |DispScroll=: stessa logica di |Disp= ma il risultato
--               viene avvolto in uno scrollbox verticale. Sintassi: |DispScroll=v,300 (valore
--               disp + altezza in px, default 200px). DispScroll ha precedenza su Disp.
-- Versione 8.9 - Parametri del template ora case-insensitive: |Disp, |disp, |DISP sono equivalenti;
--               vale per tutti i parametri (And, AndCat, OrCat, NoCat, Title, Text, TextRegExp,
--               AndTemplate, OrTemplate, Portali, OrPortali, DataFine, Disp, Timestamp, Order,
--               CaseSensitive, num)
-- Versione 8.8 - Fix normalizzazione parametri: stringa vuota e 'off' trattati come assenza di valore
--               in tutti i filtri stringa (AndCat, OrCat, NoCat, Title, Text, TextRegExp, AndTemplate,
--               OrTemplate, Portali, OrPortali, DataFine); And='' ora usa correttamente il default (era 'on', ora 'off' dalla v8.16)
-- Versione 8.7 - Aggiunto parametro |OrPortali: logica OR sui portali (basta almeno uno presente);
--               il parametro |Portali esistente mantiene la logica AND (tutti devono essere presenti)
-- Versione 8.5 - Fix Order=Dateold/Alpha: usava campi keyword (a.timestamp, a.titolo) invece di array posizionale (a[2], a[1])
-- Legge automaticamente Dati1, Dati2, Dati3, ... (quanti ce ne sono)

local p = {}

-- Funzione per ottenere gli argomenti
local function getArgs(frame)
    local args = {}
    for k, v in pairs(frame:getParent().args) do
        if v ~= '' then
            -- I nomi parametro template sono sempre ASCII: string.lower è sufficiente e più veloce
            local key = type(k) == 'string' and string.lower(k) or k
            args[key] = v
        end
    end
    return args
end

-- Funzione per dividere stringhe
local function split(str, sep)
    local result = {}
    for match in (str..sep):gmatch("(.-)"..sep) do
        local trimmed = mw.text.trim(match)
        if trimmed ~= '' then
            table.insert(result, trimmed)
        end
    end
    return result
end

-- Funzione per dividere stringhe rispettando le parentesi tonde.
-- I separatori che si trovano dentro parentesi tonde (es. dentro not(...))
-- vengono ignorati e non producono uno split.
-- Usata al posto di split() in tutte le funzioni di matching, per gestire
-- correttamente not(X;Y) e not(X,Y) senza spezzare il not() in token distinti.
local function splitParens(str, sep)
    -- Fast-path: se non ci sono parentesi, usa split() diretto (caso più comune)
    if not string.find(str, '(', 1, true) then
        return split(str, sep)
    end
    local result = {}
    local depth = 0
    local tokenStart = 1
    local len = #str
    local i = 1
    -- Cerca solo i caratteri che contano: '(', ')', sep
    -- string.find è implementato in C: molto più veloce di str:sub(i,i) in loop Lua
    while i <= len do
        local c = str:sub(i, i)
        if c == '(' then
            depth = depth + 1
        elseif c == ')' then
            depth = depth - 1
            if depth < 0 then depth = 0 end
        elseif c == sep and depth == 0 then
            local token = mw.text.trim(str:sub(tokenStart, i - 1))
            if token ~= '' then
                table.insert(result, token)
            end
            tokenStart = i + 1
        end
        -- Salta al prossimo carattere significativo: '(', ')', sep, o fine stringa
        local next = string.find(str, '[()' .. sep .. ']', i + 1, false)
        i = next or (len + 1)
    end
    local token = mw.text.trim(str:sub(tokenStart))
    if token ~= '' then
        table.insert(result, token)
    end
    return result
end

-- Mappa caratteri accentati/diacritici -> base ASCII (per ordinamento alfabetico)
local diacriticMap = {
    ['à'] = 'a', ['á'] = 'a', ['â'] = 'a', ['ã'] = 'a', ['ä'] = 'a', ['å'] = 'a', ['ā'] = 'a', ['ă'] = 'a', ['ą'] = 'a',
    ['ç'] = 'c', ['ć'] = 'c', ['č'] = 'c',
    ['ď'] = 'd', ['đ'] = 'd',
    ['è'] = 'e', ['é'] = 'e', ['ê'] = 'e', ['ë'] = 'e', ['ē'] = 'e', ['ĕ'] = 'e', ['ę'] = 'e', ['ě'] = 'e',
    ['ğ'] = 'g',
    ['ì'] = 'i', ['í'] = 'i', ['î'] = 'i', ['ï'] = 'i', ['ī'] = 'i', ['ĭ'] = 'i', ['į'] = 'i',
    ['ł'] = 'l', ['ĺ'] = 'l', ['ļ'] = 'l', ['ľ'] = 'l',
    ['ñ'] = 'n', ['ń'] = 'n', ['ņ'] = 'n', ['ň'] = 'n',
    ['ò'] = 'o', ['ó'] = 'o', ['ô'] = 'o', ['õ'] = 'o', ['ö'] = 'o', ['ø'] = 'o', ['ō'] = 'o', ['ő'] = 'o',
    ['ř'] = 'r', ['ŕ'] = 'r',
    ['š'] = 's', ['ś'] = 's', ['ş'] = 's', ['ș'] = 's',
    ['ť'] = 't', ['ţ'] = 't', ['ț'] = 't',
    ['ù'] = 'u', ['ú'] = 'u', ['û'] = 'u', ['ü'] = 'u', ['ū'] = 'u', ['ŭ'] = 'u', ['ů'] = 'u', ['ű'] = 'u', ['ų'] = 'u',
    ['ý'] = 'y', ['ÿ'] = 'y',
    ['ž'] = 'z', ['ź'] = 'z', ['ż'] = 'z',
}

-- Normalizza una stringa per l'ordinamento: lowercase + rimozione diacritici
local function normalizeForSort(str)
    if not str then return '' end
    str = mw.ustring.lower(str)
    str = mw.ustring.gsub(str, '[^%z\1-\127]', function(c)
        return diacriticMap[c] or c
    end)
    return str
end

-- Funzione contains (case-insensitive, con supporto match parola intera tramite "...")
-- Se needle è racchiuso tra doppi apici (es. "nati"), cerca la parola intera:
-- il termine deve essere preceduto e seguito da un carattere non alfanumerico.
local function isWordChar(s, pos)
    -- Restituisce true se il carattere in posizione pos (1-based) è alfanumerico
    -- Gestisce sia ASCII che caratteri Unicode/accentati
    if pos < 1 or pos > mw.ustring.len(s) then return false end
    local c = mw.ustring.sub(s, pos, pos)
    -- Alfanumerico ASCII
    if mw.ustring.find(c, '^[%w]$') then return true end
    -- Lettere accentate e Unicode (code point > 127)
    local b = string.byte(c)
    if b and b > 127 then return true end
    return false
end

local function containsWholeWord(haystack, needle)
    -- Cerca needle come parola intera in haystack (già lowercased entrambi)
    local hlen = mw.ustring.len(haystack)
    local nlen = mw.ustring.len(needle)
    local start = 1
    while true do
        local s, e = mw.ustring.find(haystack, needle, start, true)
        if not s then return false end
        -- Verifica boundary: carattere prima e dopo non deve essere alfanumerico
        local before_ok = not isWordChar(haystack, s - 1)
        local after_ok  = not isWordChar(haystack, e + 1)
        if before_ok and after_ok then return true end
        start = s + 1
    end
end

-- Analizza il needle una sola volta: estrae il termine e il flag wholeWord.
-- Restituisce: needle normalizzato (lowercase, senza virgolette), wholeWord (bool).
-- Usato per pre-processare il needle fuori dai loop categorie.
local function parseNeedle(needle)
    if not needle then return nil, false end
    local trimmed = mw.text.trim(needle)
    local wholeWord = false
    if trimmed:sub(1,1) == '"' and trimmed:sub(-1) == '"' and #trimmed > 2 then
        trimmed = trimmed:sub(2, -2)
        wholeWord = true
    end
    trimmed = mw.ustring.lower(trimmed)
    if trimmed == '' then return nil, false end
    return trimmed, wholeWord
end

-- Match needle (già parseato con parseNeedle) contro haystack (già lowercased).
-- Versione interna: non ri-lowercasa né ri-parsa, usa dati già pronti.
local function containsParsed(haystackLow, needleLow, wholeWord)
    if not haystackLow or not needleLow then return false end
    if wholeWord then
        return containsWholeWord(haystackLow, needleLow)
    end
    return mw.ustring.find(haystackLow, needleLow, 1, true) ~= nil
end

local function contains(haystack, needle)
    if not haystack or not needle then return false end
    -- Rileva match parola intera: needle racchiuso tra doppi apici
    local wholeWord = false
    local trimmed = mw.text.trim(needle)
    if trimmed:sub(1,1) == '"' and trimmed:sub(-1) == '"' and #trimmed > 2 then
        needle = trimmed:sub(2, -2)
        wholeWord = true
    end
    haystack = mw.ustring.lower(haystack)
    needle = mw.ustring.lower(needle)
    if needle == '' then return false end
    if wholeWord then
        return containsWholeWord(haystack, needle)
    end
    return mw.ustring.find(haystack, needle, 1, true) ~= nil
end

-- Rileva la sintassi not(...) in un termine.
-- Restituisce isNot=true e il termine interno se il termine è "not(...)",
-- altrimenti restituisce isNot=false e il termine invariato.
-- Supporta spazi opzionali: "not( foo )" -> inner = "foo" (trimmed).
-- Le virgolette per match parola intera sono supportate nell'inner:
-- not("morti") -> isNot=true, inner='"morti"'
local function parseNot(term)
    term = mw.text.trim(term)
    local inner = term:match('^[Nn][Oo][Tt]%s*%((.-)%)%s*$')
    if inner ~= nil then
        return true, mw.text.trim(inner)
    end
    return false, term
end

-- Espande l'inner di un not() in sotto-termini separati da ";" o ",".
-- Restituisce una lista di termini che devono essere trattati in OR tra loro
-- (il not() è soddisfatto se NESSUNO dei sotto-termini matcha).
-- Es: inner='"russa";"russi";"russia"' -> {'"russa"', '"russi"', '"russia"'}
-- Es: inner='calcio,tennis'            -> {'calcio', 'tennis'}
-- Es: inner='sport'                    -> {'sport'}
local function expandNotInner(inner)
    inner = mw.text.trim(inner)
    -- Prima prova a splittare su ";", poi su ","
    local terms = split(inner, ';')
    if #terms <= 1 then
        terms = split(inner, ',')
    end
    if #terms == 0 then terms = {inner} end
    return terms
end

local function matchesAndCat(categories, andCat)
    if not andCat or andCat == '' then return true end
    -- Pre-lowercase categorie una volta sola (hot path: N categorie × M termini)
    local catsLow = {}
    for i, cat in ipairs(categories) do
        catsLow[i] = mw.ustring.lower(cat)
    end
    -- Virgola = AND tra gruppi; punto e virgola = OR dentro ogni gruppo
    local groups = splitParens(andCat, ',')
    for _, group in ipairs(groups) do
        local found = false
        local terms = splitParens(group, ';')
        if #terms == 0 then terms = {group} end
        for _, term in ipairs(terms) do
            term = mw.text.trim(term)
            if term ~= '' then
                local isNot, inner = parseNot(term)
                if isNot then
                    local subTerms = expandNotInner(inner)
                    local anyMatch = false
                    for _, subTerm in ipairs(subTerms) do
                        local needleLow, wholeWord = parseNeedle(subTerm)
                        if needleLow then
                            for _, catLow in ipairs(catsLow) do
                                if containsParsed(catLow, needleLow, wholeWord) then anyMatch = true; break end
                            end
                        end
                        if anyMatch then break end
                    end
                    if not anyMatch then found = true; break end
                else
                    local needleLow, wholeWord = parseNeedle(term)
                    if needleLow then
                        for _, catLow in ipairs(catsLow) do
                            if containsParsed(catLow, needleLow, wholeWord) then found = true; break end
                        end
                    end
                    if found then break end
                end
            end
        end
        if not found then return false end
    end
    return true
end

local function matchesOrCat(categories, orCat)
    if not orCat or orCat == '' then return true end
    -- Pre-lowercase categorie una volta sola
    local catsLow = {}
    for i, cat in ipairs(categories) do
        catsLow[i] = mw.ustring.lower(cat)
    end
    -- Virgola = OR tra gruppi; punto e virgola = OR dentro ogni gruppo
    local groups = splitParens(orCat, ',')
    for _, group in ipairs(groups) do
        local terms = splitParens(group, ';')
        if #terms == 0 then terms = {group} end
        for _, term in ipairs(terms) do
            term = mw.text.trim(term)
            if term ~= '' then
                local isNot, inner = parseNot(term)
                if isNot then
                    local subTerms = expandNotInner(inner)
                    local anyMatch = false
                    for _, subTerm in ipairs(subTerms) do
                        local needleLow, wholeWord = parseNeedle(subTerm)
                        if needleLow then
                            for _, catLow in ipairs(catsLow) do
                                if containsParsed(catLow, needleLow, wholeWord) then anyMatch = true; break end
                            end
                        end
                        if anyMatch then break end
                    end
                    if not anyMatch then return true end
                else
                    local needleLow, wholeWord = parseNeedle(term)
                    if needleLow then
                        for _, catLow in ipairs(catsLow) do
                            if containsParsed(catLow, needleLow, wholeWord) then return true end
                        end
                    end
                end
            end
        end
    end
    return false
end

local function matchesNoCat(categories, noCat)
    if not noCat or noCat == '' then return true end
    if mw.text.trim(noCat) == '*' then
        return #categories == 0
    end
    -- Pre-lowercase categorie una volta sola
    local catsLow = {}
    for i, cat in ipairs(categories) do
        catsLow[i] = mw.ustring.lower(cat)
    end
    -- Virgola = AND tra gruppi di esclusione; punto e virgola = OR dentro ogni gruppo
    local groups = splitParens(noCat, ',')
    for _, group in ipairs(groups) do
        local terms = splitParens(group, ';')
        if #terms == 0 then terms = {group} end
        for _, term in ipairs(terms) do
            term = mw.text.trim(term)
            if term ~= '' then
                local isNot, inner = parseNot(term)
                if isNot then
                    local subTerms = expandNotInner(inner)
                    local hasAny = false
                    for _, subTerm in ipairs(subTerms) do
                        local needleLow, wholeWord = parseNeedle(subTerm)
                        if needleLow then
                            for _, catLow in ipairs(catsLow) do
                                if containsParsed(catLow, needleLow, wholeWord) then hasAny = true; break end
                            end
                        end
                        if hasAny then break end
                    end
                    if not hasAny then return false end
                else
                    local needleLow, wholeWord = parseNeedle(term)
                    if needleLow then
                        for _, catLow in ipairs(catsLow) do
                            if containsParsed(catLow, needleLow, wholeWord) then return false end
                        end
                    end
                end
            end
        end
    end
    return true
end

-- ExclIfNotCat: esclude la voce se ha la categoria X ma NON ha la categoria Y.
-- Sintassi: ExclIfNotCat=X>Y  oppure  X>Y,A>B  (virgola = AND tra regole distinte)
-- Significato: "escludi la voce se ha X, a meno che non abbia anche Y"
-- Il separatore è ">" (maggiore) e non "|" per evitare conflitti con il parser MediaWiki.
-- Es: ExclIfNotCat=Morti a Parigi>Nati a Parigi
--   -> passa solo se: non ha "Morti a Parigi", oppure ha sia "Morti a Parigi" che "Nati a Parigi"
-- Applicato sempre come veto finale indipendentemente da And=on/off.
local function matchesExclIfNotCat(categories, param)
    if not param or param == '' then return true end
    -- Pre-lowercase categorie una volta sola
    local catsLow = {}
    for i, cat in ipairs(categories) do
        catsLow[i] = mw.ustring.lower(cat)
    end
    local rules = split(param, ',')
    for _, rule in ipairs(rules) do
        rule = mw.text.trim(rule)
        if rule ~= '' then
            -- '>' è ASCII: string.find è sufficiente
            local gt = string.find(rule, '>', 1, true)
            if gt then
                local triggerStr   = mw.text.trim(rule:sub(1, gt - 1))
                local exceptionStr = mw.text.trim(rule:sub(gt + 1))
                local trigNeedleLow, trigWholeWord = parseNeedle(triggerStr)
                local hasTrigger = false
                if trigNeedleLow then
                    for _, catLow in ipairs(catsLow) do
                        if containsParsed(catLow, trigNeedleLow, trigWholeWord) then hasTrigger = true; break end
                    end
                end
                if hasTrigger then
                    local excNeedleLow, excWholeWord = parseNeedle(exceptionStr)
                    local hasException = false
                    if excNeedleLow then
                        for _, catLow in ipairs(catsLow) do
                            if containsParsed(catLow, excNeedleLow, excWholeWord) then hasException = true; break end
                        end
                    end
                    if not hasException then return false end
                end
            end
        end
    end
    return true
end

local function matchesTitle(title, titleSearch)
    if not titleSearch or titleSearch == '' then return true end
    -- Normalizza | (via {{!}}) in ;
    local normalized = titleSearch:gsub('|', ';')
    -- ; = OR tra gruppi; + = AND tra termini dentro ogni gruppo
    -- Es: "riserva+darwin;oasi" -> (riserva AND darwin) OR oasi
    -- not(X) in un termine: il termine è soddisfatto se il titolo NON contiene X
    -- splitParens: rispetta i separatori dentro not(...), es. not(X;Y) o not(X,Y)
    local groups = splitParens(normalized, ';')
    if #groups == 0 then groups = {normalized} end
    for _, group in ipairs(groups) do
        group = mw.text.trim(group)
        if group ~= '' then
            -- Verifica AND tra tutti i termini del gruppo
            local andTerms = splitParens(group, '+')
            if #andTerms == 0 then andTerms = {group} end
            local allMatch = true
            for _, term in ipairs(andTerms) do
                term = mw.text.trim(term)
                if term ~= '' then
                    local isNot, inner = parseNot(term)
                    if isNot then
                        if contains(title, inner) then allMatch = false; break end
                    else
                        if not contains(title, term) then allMatch = false; break end
                    end
                end
            end
            if allMatch then return true end
        end
    end
    return false
end

local function matchesText(content, text)
    if not text or text == '' then return true end
    if not content then return false end
    -- Supporta ; o | (via {{!}}) per OR: "gotico;romanico;barocco"
    -- not(X) in un termine: il termine è soddisfatto se il contenuto NON contiene X
    -- splitParens: rispetta i separatori dentro not(...), es. not(X;Y)
    if mw.ustring.find(text, '|', 1, true) or mw.ustring.find(text, ';', 1, true) then
        local normalized = text:gsub('|', ';')
        local terms = splitParens(normalized, ';')
        for _, term in ipairs(terms) do
            local isNot, inner = parseNot(term)
            if isNot then
                if not contains(content, inner) then return true end
            else
                if contains(content, term) then return true end
            end
        end
        return false
    end
    local isNot, inner = parseNot(text)
    if isNot then
        return not contains(content, inner)
    end
    return contains(content, text)
end

-- Espande un pattern con gruppi (a;b;c) o (a|b|c) in una lista di pattern alternativi.
local function expandPatternAlternatives(pattern)
    local pre, group, post = pattern:match('^(.-)%(([^()]+)%)(.*)$')
    if not pre then
        return {pattern}
    end
    local groupNorm = group:gsub(';', '|')
    local results = {}
    for alt in (groupNorm .. '|'):gmatch('([^|]*)|') do
        local subPattern = pre .. alt .. post
        for _, expanded in ipairs(expandPatternAlternatives(subPattern)) do
            table.insert(results, expanded)
        end
    end
    return results
end

local function matchesRegex(content, pattern, caseSensitive)
    if not pattern or pattern == '' then return true end
    if not content then return false end
    local variants = expandPatternAlternatives(pattern)
    for _, variant in ipairs(variants) do
        local searchPattern = variant
        if not caseSensitive then
            searchPattern = mw.ustring.lower(variant)
        end
        for line in (content .. '\n'):gmatch('([^\n]*)\n') do
            local searchLine = caseSensitive and line or mw.ustring.lower(line)
            local success, result = pcall(function()
                return mw.ustring.find(searchLine, searchPattern) ~= nil
            end)
            if success and result then
                return true
            end
        end
    end
    return false
end

local function isAfterDate(timestamp, dateLimit)
    if not dateLimit or dateLimit == '' then return true end
    local day, month, year = dateLimit:match('(%d+)/(%d+)/(%d+)')
    if not day then return true end
    local limitStr = string.format('%04d%02d%02d', tonumber(year), tonumber(month), tonumber(day))
    local dateStr = timestamp:sub(1, 8)
    return dateStr >= limitStr
end

local function formatTimestamp(timestamp)
    if not timestamp or #timestamp < 14 then return '' end
    local year = timestamp:sub(1, 4)
    local month = timestamp:sub(5, 6)
    local day = timestamp:sub(7, 8)
    local hour = timestamp:sub(9, 10)
    local min = timestamp:sub(11, 12)
    return string.format('%s/%s/%s %s:%s', day, month, year, hour, min)
end

-- Restituisce la data formattata per la visualizzazione nella lista.
-- Se voce[6] (move_timestamp) e' presente e non vuoto, usa quella data
-- avvolta in corsivo wikitext (voce spostata da altro namespace).
-- Altrimenti usa voce[2] (timestamp di prima creazione) in testo normale.
local function formatTimestampDisplay(voce)
    local movets = voce[6]
    if movets and movets ~= '' then
        return "''" .. formatTimestamp(movets) .. "''"
    end
    return formatTimestamp(voce[2])
end

-- Confronta un nome di template (con eventuali parametri richiesti) contro l'elenco
-- dei template presenti nella voce. Tre modalità di match sul nome, mutuamente esclusive:
--   Nome      -> substring match (default): "libro" matcha sia {{Libro}} che {{cita libro}}
--   "Nome"    -> parola intera dentro il nome: "libro" matcha ancora {{cita libro}}
--                (perché "libro" è un token separato dentro "cita libro"), ma non
--                matcherebbe un ipotetico {{librone}}
--   /Nome/    -> match ESATTO sull'intero nome del template: /libro/ matcha solo {{Libro}},
--                non {{cita libro}}. Underscore normalizzati come spazi prima del confronto.
local function matchesSingleTemplate(templates, tmplName, requiredParams)
    tmplName = mw.text.trim(tmplName)
    -- Rileva match esatto sull'intero nome: nome template racchiuso tra slash (/Nome/)
    local exactTmpl = false
    if tmplName:sub(1,1) == '/' and tmplName:sub(-1) == '/' and #tmplName > 2 then
        tmplName = tmplName:sub(2, -2)
        exactTmpl = true
    end
    -- Rileva match parola intera: nome template racchiuso tra doppi apici
    local wholeWordTmpl = false
    if not exactTmpl and tmplName:sub(1,1) == '"' and tmplName:sub(-1) == '"' and #tmplName > 2 then
        tmplName = tmplName:sub(2, -2)
        wholeWordTmpl = true
    end
    -- Nomi template sono ASCII: string.lower è sufficiente e più veloce
    tmplName = string.lower(tmplName)
    if exactTmpl then
        -- Normalizza underscore come spazi, come fa MediaWiki nei nomi pagina/template
        tmplName = tmplName:gsub('_', ' ')
    end
    for _, tmpl in ipairs(templates) do
        local nome = string.lower(tmpl[1] or '')
        local nameMatch
        if exactTmpl then
            -- Match esatto sull'intero nome del template (non una sua parola/sottostringa)
            nameMatch = (nome:gsub('_', ' ') == tmplName)
        elseif wholeWordTmpl then
            -- wholeWord può coinvolgere caratteri Unicode: usa mw.ustring
            nameMatch = containsWholeWord(nome, tmplName)
        else
            nameMatch = string.find(nome, tmplName, 1, true) ~= nil
            -- Workaround bug MediaWiki Lua: mw.ustring.find non trova "portale" in "portali".
            -- Se la ricerca fallisce, verifica se tmplName e nome sono alias portale/portali.
            if not nameMatch then
                if (tmplName == 'portale' and nome == 'portali') or
                   (tmplName == 'portali' and nome == 'portale') then
                    nameMatch = true
                end
            end
        end
        if nameMatch then
            local allParamsFound = true
            for _, reqParam in ipairs(requiredParams) do
                -- Parametri portale possono contenere caratteri accentati: mw.ustring necessario
                local rp = mw.ustring.lower(mw.text.trim(reqParam))
                local found = false
                for _, p in ipairs(tmpl[2] or {}) do
                    if mw.ustring.find(mw.ustring.lower(p), rp, 1, true) then
                        found = true
                        break
                    end
                end
                if not found then
                    allParamsFound = false
                    break
                end
            end
            if allParamsFound then return true end
        end
    end
    return false
end

local function parseTemplateSpec(spec)
    spec = mw.text.trim(spec)
    local nome, paramStr = spec:match('^(.-)%s*%((.-)%)%s*$')
    if not nome then
        return spec, {}
    end
    local params = {}
    for p in (paramStr .. ';'):gmatch('([^;]*);') do
        p = mw.text.trim(p)
        if p ~= '' then table.insert(params, p) end
    end
    return nome, params
end

-- NoTemplate: esclude la voce se contiene almeno uno dei template elencati.
-- Virgola = AND tra gruppi di esclusione; punto e virgola = OR dentro ogni gruppo.
-- Es: NoTemplate=Stub;Abbozzo,Redirect -> esclude se ha (Stub OR Abbozzo) oppure (Redirect)
-- Usare /Nome/ per match esatto sull'intero nome template (es. NoTemplate=/Libro/ esclude
-- solo {{Libro}}, non {{cita libro}}); vedi commento su matchesSingleTemplate.
-- not(X) inverte: not(X) in un gruppo NOTemplate richiede positivamente X
-- (se la voce NON ha X, viene esclusa - utile per filtrare voci prive di un certo template)
local function matchesNoTemplate(templates, noTmpl)
    if not noTmpl or noTmpl == '' then return true end
    -- Valore speciale '*': esclude voci che hanno almeno un template
    if mw.text.trim(noTmpl) == '*' then
        return #templates == 0
    end
    -- splitParens: rispetta i separatori dentro not(...), es. not(X;Y) o not(X,Y)
    local groups = splitParens(noTmpl, ',')
    for _, group in ipairs(groups) do
        -- Raccoglie gli spec del gruppo, separati da ";"
        -- Per ogni spec: se è not(X) -> la voce viene esclusa se NON ha X
        --                se è normale -> la voce viene esclusa se ha X
        local anyMatchPositive = false  -- almeno uno spec positivo ha trovato match
        local anyNotFailed = false      -- almeno uno spec not(X) non è soddisfatto
        local hasPositive = false
        local hasNot = false
        local specs = splitParens(group, ';')
        for _, spec in ipairs(specs) do
            spec = mw.text.trim(spec)
            if spec ~= '' then
                local isNot, inner = parseNot(spec)
                if isNot then
                    hasNot = true
                    local nome, params = parseTemplateSpec(inner)
                    -- not(X) in NoCat/NoTemplate: richiede positivamente X
                    -- se la voce NON ha il template X -> escludila (return false)
                    if not matchesSingleTemplate(templates, nome, params) then
                        anyNotFailed = true
                    end
                else
                    hasPositive = true
                    local nome, params = parseTemplateSpec(spec)
                    if matchesSingleTemplate(templates, nome, params) then
                        anyMatchPositive = true
                    end
                end
            end
        end
        -- Logica combinata per il gruppo (OR tra spec dello stesso gruppo):
        -- - uno spec positivo che matcha -> esclude la voce
        -- - uno spec not(X) non soddisfatto (voce priva di X) -> esclude la voce
        if anyMatchPositive then return false end
        if anyNotFailed then return false end
    end
    return true
end

-- AndTemplate: tutti i template elencati devono essere presenti
-- Virgola = AND tra gruppi; punto e virgola = OR dentro ogni gruppo
-- Es: "Bio;Wrestler,Portale" -> (Bio OR Wrestler) AND Portale
-- not(X) in un gruppo: il gruppo è soddisfatto se la voce NON ha X
-- Usare /Nome/ per match esatto sull'intero nome template (vedi matchesSingleTemplate)
-- splitParens: rispetta i separatori dentro not(...), es. not(X;Y) o not(X,Y)
local function matchesAndTemplate(templates, andTmpl)
    if not andTmpl or andTmpl == '' then return true end
    local groups = splitParens(andTmpl, ',')
    for _, group in ipairs(groups) do
        group = mw.text.trim(group)
        if group ~= '' then
            local found = false
            local specs = splitParens(group, ';')
            for _, spec in ipairs(specs) do
                spec = mw.text.trim(spec)
                if spec ~= '' then
                    local isNot, inner = parseNot(spec)
                    if isNot then
                        local nome, params = parseTemplateSpec(inner)
                        if not matchesSingleTemplate(templates, nome, params) then
                            found = true
                            break
                        end
                    else
                        local nome, params = parseTemplateSpec(spec)
                        if matchesSingleTemplate(templates, nome, params) then
                            found = true
                            break
                        end
                    end
                end
            end
            if not found then return false end
        end
    end
    return true
end

-- OrTemplate: almeno un template elencato deve essere presente
-- Virgola e punto e virgola sono entrambi OR
-- not(X): contribuisce come match se la voce NON ha X
-- Usare /Nome/ per match esatto sull'intero nome template (vedi matchesSingleTemplate)
-- splitParens: rispetta i separatori dentro not(...), es. not(X;Y) o not(X,Y)
local function matchesOrTemplate(templates, orTmpl)
    if not orTmpl or orTmpl == '' then return true end
    local groups = splitParens(orTmpl, ',')
    for _, group in ipairs(groups) do
        group = mw.text.trim(group)
        if group ~= '' then
            local specs = splitParens(group, ';')
            for _, spec in ipairs(specs) do
                spec = mw.text.trim(spec)
                if spec ~= '' then
                    local isNot, inner = parseNot(spec)
                    if isNot then
                        local nome, params = parseTemplateSpec(inner)
                        if not matchesSingleTemplate(templates, nome, params) then
                            return true
                        end
                    else
                        local nome, params = parseTemplateSpec(spec)
                        if matchesSingleTemplate(templates, nome, params) then
                            return true
                        end
                    end
                end
            end
        end
    end
    return false
end

-- Confronta un singolo parametro portale contro un needle con supporto alle tre sintassi:
--   /Nome/ -> match esatto (underscore normalizzati come spazi, case-insensitive)
--   "Nome" -> match parola intera (case-insensitive)
--   Nome   -> match sottostringa (case-insensitive, comportamento originale)
-- Il rilevamento della sintassi avviene una volta per needle, fuori dal loop sui parametri.
local function matchPortaleParam(param, needle)
    needle = mw.text.trim(needle)
    -- Sintassi /Nome/: match esatto sull'intero valore del parametro
    if needle:sub(1,1) == '/' and needle:sub(-1) == '/' and #needle > 2 then
        local name = mw.ustring.lower(needle:sub(2, -2):gsub('_', ' '))
        return mw.ustring.lower(param:gsub('_', ' ')) == name
    end
    -- Sintassi "Nome" e sottostringa: delega a contains() che già le gestisce
    return contains(param, needle)
end

-- Portali=p1,p2: AND su tutti i portali (tutti devono essere presenti)
-- Verifica se un nome template è di tipo Portale (gestisce sia "Portale" che "Portali")
local function isPortaleTemplate(nome)
    nome = string.lower(mw.text.trim(nome))
    return nome == 'portale' or nome == 'portali'
end

local function matchesPortali(templates, portali)
    if not portali or portali == '' then return true end
    local allPortaleParams = {}
    for _, tmpl in ipairs(templates) do
        local nome = string.lower(tmpl[1] or '')
        if isPortaleTemplate(nome) then
            for _, p in ipairs(tmpl[2] or {}) do
                table.insert(allPortaleParams, p)
            end
        end
    end
    -- Virgola = AND tra portali richiesti
    -- not(X): richiede che X NON sia tra i portali presenti.
    -- not(X;Y): ";" dentro not() = OR tra i termini da negare.
    --   Equivale a: non X AND non Y (per De Morgan).
    --   Es: not(radio;cinema) -> né radio né cinema devono essere presenti.
    for spec in (portali .. ','):gmatch('([^,]*),') do
        local portale = mw.text.trim(spec)
        if portale ~= '' then
            local isNot, inner = parseNot(portale)
            if isNot then
                -- not(X) oppure not(X;Y;...): espandi i sotto-termini separati da ";"
                -- e applica AND di negazioni (tutti devono essere assenti).
                local subTerms = split(inner, ';')
                if #subTerms == 0 then subTerms = {inner} end
                for _, subTerm in ipairs(subTerms) do
                    subTerm = mw.text.trim(subTerm)
                    if subTerm ~= '' then
                        -- Se anche uno solo di questi portali è presente, la condizione fallisce
                        for _, p in ipairs(allPortaleParams) do
                            if contains(p, subTerm) then return false end
                        end
                    end
                end
            else
                local found = false
                for _, p in ipairs(allPortaleParams) do
                    if matchPortaleParam(p, portale) then
                        found = true
                        break
                    end
                end
                if not found then return false end
            end
        end
    end
    return true
end

-- OrPortali=p1,p2: OR sui portali (basta almeno uno presente)
-- not(X): contribuisce come match se la voce NON ha il portale X
local function matchesOrPortali(templates, portali)
    if not portali or portali == '' then return true end
    local allPortaleParams = {}
    for _, tmpl in ipairs(templates) do
        local nome = string.lower(tmpl[1] or '')
        if isPortaleTemplate(nome) then
            for _, p in ipairs(tmpl[2] or {}) do
                table.insert(allPortaleParams, p)
            end
        end
    end
    -- Virgola e ";" sono entrambi OR tra portali
    -- not(X): match se la voce NON ha il portale X.
    -- not(X;Y): ";" dentro not() = OR tra i termini da negare.
    --   Match se la voce non ha X E non ha Y (AND di negazioni, De Morgan).
    --   Es: not(calcio;tennis) -> match se né calcio né tennis sono tra i portali.
    for spec in (portali .. ','):gmatch('([^,]*),') do
        local portale = mw.text.trim(spec)
        if portale ~= '' then
            local isNot, inner = parseNot(portale)
            if isNot then
                -- not(X) oppure not(X;Y;...): tutti i sotto-termini devono essere assenti
                local subTerms = split(inner, ';')
                if #subTerms == 0 then subTerms = {inner} end
                local allAbsent = true
                for _, subTerm in ipairs(subTerms) do
                    subTerm = mw.text.trim(subTerm)
                    if subTerm ~= '' then
                        for _, p in ipairs(allPortaleParams) do
                            if contains(p, subTerm) then allAbsent = false; break end
                        end
                        if not allAbsent then break end
                    end
                end
                if allAbsent then return true end
            else
                for _, p in ipairs(allPortaleParams) do
                    if matchPortaleParam(p, portale) then
                        return true
                    end
                end
            end
        end
    end
    return false
end

-- Memoizzazione: usata solo quando non è specificato firstLimit.
-- Più template senza First= sulla stessa pagina condividono questo risultato,
-- evitando di ricaricare i file Dati da zero per ogni istanza.
local _cachedData = nil

-- Carica automaticamente TUTTI i file Dati (Dati1, Dati2, ...)
-- firstLimit: se > 0, interrompe il caricamento dopo aver accumulato firstLimit voci,
-- senza allocare il resto della cache. In questo caso non viene usata la memoizzazione,
-- perché istanze diverse possono avere firstLimit diversi.
-- Se firstLimit è nil, usa la memoizzazione globale (comportamento precedente).
local function loadAllData(firstLimit)
    firstLimit = (firstLimit and firstLimit > 0) and firstLimit or nil
    if not firstLimit and _cachedData then return _cachedData end
    local allVoci = {}
    local ultimoAggiornamento = nil
    local filesLoaded = 0
    local errors = {}
    local i = 1
    local done = false
    while i <= 100 and not done do
        local pageName = 'Modulo:VociRecenti/Dati' .. i
        local dataPage = mw.title.new(pageName)
        if dataPage and dataPage.exists then
            local success, data = pcall(function()
                return require(pageName)
            end)
            if success then
                if type(data) == 'table' then
                    local vociList = data.d or data.voci
                    if vociList and type(vociList) == 'table' and #vociList > 0 then
                        for _, voce in ipairs(vociList) do
                            if voce.titolo ~= nil then
                                voce = {
                                    voce.titolo,
                                    voce.timestamp or '',
                                    voce.categorie or {},
                                    voce.template or {},
                                    (voce.contenuto or ''):sub(1, 100),
                                    voce.move_timestamp or '',
                                    voce.categorie_nascoste or {}
                                }
                            end
                            table.insert(allVoci, voce)
                            if firstLimit and #allVoci >= firstLimit then
                                done = true
                                break
                            end
                        end
                        if i == 1 then
                            ultimoAggiornamento = data.u or data.ultimo_aggiornamento
                        end
                        filesLoaded = filesLoaded + 1
                    end
                else
                    table.insert(errors, pageName .. ': formato non valido')
                end
            else
                table.insert(errors, pageName .. ': errore caricamento')
            end
        else
            break
        end
        i = i + 1
    end
    local result = {
        voci = allVoci,
        ultimo_aggiornamento = ultimoAggiornamento,
        num_files = filesLoaded,
        errors = errors
    }
    if not firstLimit then _cachedData = result end
    return result
end

local function calcDaysRange(voci)
    if not voci or #voci == 0 then return nil end
    local function toDays(ts)
        if not ts or #ts < 8 then return nil end
        local y = tonumber(ts:sub(1,4))
        local m = tonumber(ts:sub(5,6))
        local d = tonumber(ts:sub(7,8))
        if not y or not m or not d then return nil end
        local a = math.floor((14 - m) / 12)
        local yy = y + 4800 - a
        local mm = m + 12 * a - 3
        return d + math.floor((153 * mm + 2) / 5) + 365 * yy
            + math.floor(yy / 4) - math.floor(yy / 100) + math.floor(yy / 400) - 32045
    end
    local tsMin, tsMax = nil, nil
    for _, voce in ipairs(voci) do
        -- Usa lo stesso timestamp visualizzato: move_timestamp (voce[6]) se presente,
        -- altrimenti il timestamp di creazione (voce[2]), coerentemente con formatTimestampDisplay.
        local movets = voce[6]
        local ts = (movets and movets ~= '') and movets or (voce[2] or '')
        if ts ~= '' then
            if not tsMin or ts < tsMin then tsMin = ts end
            if not tsMax or ts > tsMax then tsMax = ts end
        end
    end
    local dMin = toDays(tsMin)
    local dMax = toDays(tsMax)
    if not dMin or not dMax then return nil end
    return (dMax - dMin) + 1
end

local function buildTimestampLine(timestampParam, ultimoAggiornamento, numFiles, voci, cacheVoci)
    local line = 'Ultimo aggiornamento: ' .. ultimoAggiornamento
    if (timestampParam == 'on' or timestampParam == 'cachedays') and numFiles then
        line = line .. ' (' .. numFiles .. ' file cache)'
    end
    if (timestampParam == 'on' or timestampParam == 'days') and voci and #voci > 0 then
        local days = calcDaysRange(voci)
        if days then
            local daysLabel = days == 1 and '1 giorno nelle voci filtrate' or (days .. ' giorni nelle voci filtrate')
            line = line .. ', ' .. daysLabel
        end
    end
    if timestampParam == 'cachedays' and cacheVoci and #cacheVoci > 0 then
        local days = calcDaysRange(cacheVoci)
        if days then
            local daysLabel = days == 1 and '1 giorno nella cache' or (days .. ' giorni nella cache')
            line = line .. ', ' .. daysLabel
        end
    end
    return '<div style="column-span:all;"><small>' .. line .. '</small></div>'
end

local function buildSearchable(voce)
    local parts = {}
    for _, cat in ipairs(voce[3] or {}) do
        table.insert(parts, cat)
    end
    for _, tmpl in ipairs(voce[4] or {}) do
        table.insert(parts, tmpl[1] or '')
        for _, p in ipairs(tmpl[2] or {}) do
            table.insert(parts, p)
        end
    end
    if voce[5] and voce[5] ~= '' then
        table.insert(parts, voce[5])
    end
    return table.concat(parts, '\n')
end

-- Funzione principale
function p.main(frame)
    local args = getArgs(frame)

    local num = tonumber(args.num) or 10
    if num < 1 then num = 10 end

    local function normFilter(v)
        if not v then return '' end
        local t = mw.text.trim(v)
        if t == '' or mw.ustring.lower(t) == 'off' then return '' end
        return t
    end

    local andCat = normFilter(args.andcat)
    local orCat = normFilter(args.orcat)
    local noCat = normFilter(args.nocat)
    local hAndCat = normFilter(args.handcat)
    local hOrCat = normFilter(args.horcat)
    local hNoCat = normFilter(args.hnocat)
    local titleSearch = normFilter(args.title)
    local searchText = normFilter(args.text)
    local regexPattern = normFilter(args.textregexp)
    local andTemplate = normFilter(args.andtemplate)
    local orTemplate = normFilter(args.ortemplate)
    local portali = normFilter(args.portali)
    local orPortali = normFilter(args.orportali)
    local dataFine = normFilter(args.datafine)
    local noTemplate = normFilter(args.notemplate)
    local exclIfNotCat = normFilter(args.exclifnotcat)

    -- Parametro DispScroll (ha precedenza su Disp se valorizzato)
    -- Sintassi: |DispScroll=v,300 -> disp=v, scrollbox altezza 300px
    local dispScroll = args.dispscroll or ''
    dispScroll = mw.text.trim(mw.ustring.lower(dispScroll))
    local scrollHeight = nil
    local disp
    if dispScroll ~= '' then
        local comma = dispScroll:find(',')
        local dispScrollVal, heightVal
        if comma then
            dispScrollVal = mw.text.trim(dispScroll:sub(1, comma - 1))
            heightVal = mw.text.trim(dispScroll:sub(comma + 1))
        else
            dispScrollVal = dispScroll
            heightVal = '200'
        end
        if not heightVal:match('^%d+$') then heightVal = '200' end
        scrollHeight = heightVal .. 'px'
        disp = dispScrollVal
    else
        disp = args.disp or 's'
    end

    local caseSensitive = args.casesensitive or 'off'
    caseSensitive = mw.text.trim(mw.ustring.lower(caseSensitive))
    local useCaseSensitive = (caseSensitive == 'on' or caseSensitive == 'true' or caseSensitive == '1' or caseSensitive == 'yes')

    local andMode = args['and'] or ''
    andMode = mw.text.trim(mw.ustring.lower(andMode))
    if andMode == '' then andMode = 'off' end
    local useAndLogic = (andMode == 'on' or andMode == 'true' or andMode == '1' or andMode == 'yes')

    local order = args.order or 'data'
    order = mw.text.trim(mw.ustring.lower(order))
    if order ~= 'data' and order ~= 'dataold' and order ~= 'alpha' then
        order = 'data'
    end

    local timestampParam = args.timestamp or 'cachedays'
    timestampParam = mw.text.trim(mw.ustring.lower(timestampParam))
    if timestampParam ~= 'off' and timestampParam ~= 'on' and timestampParam ~= 'date'
            and timestampParam ~= 'days' and timestampParam ~= 'cachedays' then
        timestampParam = 'cachedays'
    end

    disp = mw.text.trim(mw.ustring.lower(disp))
    if disp ~= 's' and disp ~= 'v' and disp ~= 'o' and disp ~= 't' and disp ~= 'h' then
        disp = 's'
    end

    local firstParam = tonumber(args.first)
    if firstParam and firstParam <= 0 then firstParam = nil end

    local data = loadAllData(firstParam)
    local vociSlice = data.voci

    if not data or not vociSlice or #vociSlice == 0 then
        local msg = '<div class="noprint" style="padding:1em; border:2px solid #fc3; background:#ffc; column-span:all;">'
        if not data or data.num_files == 0 then
            msg = msg .. "'''⚠️ Cache non inizializzata'''<br/>Esegui il bot per creare la cache iniziale."
        else
            msg = msg .. "'''⚠️ Cache vuota'''<br/>Il bot sta rigenerando la cache. Riprova tra qualche minuto."
            if data.errors and #data.errors > 0 then
                msg = msg .. "<br/><small>Errori: " .. table.concat(data.errors, ", ") .. "</small>"
            end
        end
        msg = msg .. '</div>'
        return msg
    end

    local filtered = {}

    for _, voce in ipairs(vociSlice) do
        local matches = true

        if useAndLogic then
            -- Early exit: ogni check viene eseguito solo se i precedenti sono passati.
            -- In AND puro basta un singolo fallimento per scartare la voce.
            if andCat ~= '' and not matchesAndCat(voce[3] or {}, andCat) then
                matches = false
            end
            if matches and orCat ~= '' and not matchesOrCat(voce[3] or {}, orCat) then
                matches = false
            end
            if matches and hAndCat ~= '' and not matchesAndCat(voce[7] or {}, hAndCat) then
                matches = false
            end
            if matches and hOrCat ~= '' and not matchesOrCat(voce[7] or {}, hOrCat) then
                matches = false
            end
            if matches and titleSearch ~= '' and not matchesTitle(voce[1], titleSearch) then
                matches = false
            end
            if matches and searchText ~= '' and not matchesText(voce[5], searchText) then
                matches = false
            end
            if matches and dataFine ~= '' and not isAfterDate(voce[2], dataFine) then
                matches = false
            end
            if matches and regexPattern ~= '' then
                if not matchesRegex(buildSearchable(voce), regexPattern, useCaseSensitive) then
                    matches = false
                end
            end
            if matches and andTemplate ~= '' and not matchesAndTemplate(voce[4] or {}, andTemplate) then
                matches = false
            end
            if matches and orTemplate ~= '' and not matchesOrTemplate(voce[4] or {}, orTemplate) then
                matches = false
            end
            if matches and portali ~= '' and not matchesPortali(voce[4] or {}, portali) then
                matches = false
            end
            if matches and orPortali ~= '' and not matchesOrPortali(voce[4] or {}, orPortali) then
                matches = false
            end
            if matches and noCat ~= '' and not matchesNoCat(voce[3] or {}, noCat) then
                matches = false
            end
            if matches and hNoCat ~= '' and not matchesNoCat(voce[7] or {}, hNoCat) then
                matches = false
            end
            if matches and noTemplate ~= '' and not matchesNoTemplate(voce[4] or {}, noTemplate) then
                matches = false
            end
            if matches and exclIfNotCat ~= '' and not matchesExclIfNotCat(voce[3] or {}, exclIfNotCat) then
                matches = false
            end
        else
            matches = false
            local hasPositiveFilters = (andCat ~= '' or orCat ~= '' or hAndCat ~= '' or hOrCat ~= '' or titleSearch ~= '' or searchText ~= '' or regexPattern ~= '' or andTemplate ~= '' or orTemplate ~= '' or portali ~= '' or orPortali ~= '')
            local hasNegativeFilters = (noCat ~= '' or hNoCat ~= '' or noTemplate ~= '')
            local hasFilters = hasPositiveFilters or hasNegativeFilters
            if not hasFilters then
                matches = true
            elseif not hasPositiveFilters then
                -- Solo filtri negativi: includi tutto solo se supera i filtri negativi
                local passNoCat  = (noCat  == '' or matchesNoCat(voce[3] or {}, noCat))
                local passHNoCat = (hNoCat == '' or matchesNoCat(voce[7] or {}, hNoCat))
                local passNoTmpl = (noTemplate == '' or matchesNoTemplate(voce[4] or {}, noTemplate))
                matches = passNoCat and passHNoCat and passNoTmpl
            else
                if andCat ~= '' and matchesAndCat(voce[3] or {}, andCat) then matches = true end
                if orCat ~= '' and matchesOrCat(voce[3] or {}, orCat) then matches = true end
                if hAndCat ~= '' and matchesAndCat(voce[7] or {}, hAndCat) then matches = true end
                if hOrCat ~= '' and matchesOrCat(voce[7] or {}, hOrCat) then matches = true end
                if titleSearch ~= '' and matchesTitle(voce[1], titleSearch) then matches = true end
                if searchText ~= '' and matchesText(voce[5], searchText) then matches = true end
                if regexPattern ~= '' and matchesRegex(buildSearchable(voce), regexPattern, useCaseSensitive) then matches = true end
                if andTemplate ~= '' and matchesAndTemplate(voce[4] or {}, andTemplate) then matches = true end
                if orTemplate ~= '' and matchesOrTemplate(voce[4] or {}, orTemplate) then matches = true end
                if portali ~= '' and matchesPortali(voce[4] or {}, portali) then matches = true end
                if orPortali ~= '' and matchesOrPortali(voce[4] or {}, orPortali) then matches = true end
            end
            if dataFine ~= '' and not isAfterDate(voce[2], dataFine) then matches = false end
            if matches and noCat ~= '' and not matchesNoCat(voce[3] or {}, noCat) then matches = false end
            if matches and hNoCat ~= '' and not matchesNoCat(voce[7] or {}, hNoCat) then matches = false end
            if matches and noTemplate ~= '' and not matchesNoTemplate(voce[4] or {}, noTemplate) then matches = false end
            if matches and exclIfNotCat ~= '' and not matchesExclIfNotCat(voce[3] or {}, exclIfNotCat) then matches = false end
        end

        if matches then
            table.insert(filtered, voce)
        end
    end

    if order == 'dataold' then
        table.sort(filtered, function(a, b)
            local ta = ((a[6] and a[6] ~= '') and a[6] or (a[2] or ''))
            local tb = ((b[6] and b[6] ~= '') and b[6] or (b[2] or ''))
            return ta < tb
        end)
    elseif order == 'data' then
        table.sort(filtered, function(a, b)
            local ta = ((a[6] and a[6] ~= '') and a[6] or (a[2] or ''))
            local tb = ((b[6] and b[6] ~= '') and b[6] or (b[2] or ''))
            return ta > tb
        end)
    elseif order == 'alpha' then
        table.sort(filtered, function(a, b)
            return normalizeForSort(a[1] or '') < normalizeForSort(b[1] or '')
        end)
    end

    -- Nessuna copia della tabella: il limite viene passato direttamente a formatOutput.
    local displayNum = math.min(#filtered, num)

    if displayNum == 0 then
        local msg = [=[<div class="noprint" style="padding:1em; border:1px solid #a2a9b1; background:#f8f9fa; column-span:all;">''Nessuna voce trovata con i criteri specificati.''</div>]=]
        if timestampParam ~= 'off' and data.ultimo_aggiornamento then
            msg = msg .. '\n' .. buildTimestampLine(timestampParam, data.ultimo_aggiornamento, data.num_files, nil, vociSlice)
        end
        return msg
    end

    local result
    if scrollHeight then
        local listWiki = p.formatOutput(filtered, disp, nil, nil, 'off', nil, displayNum)
        local listHtml
        if disp == 'h' then
            -- Orizzontale: non serve preprocess (è HTML puro, non wikitext lista)
            listHtml = listWiki
            result = '<div style="overflow-x:auto; height:' .. scrollHeight
                     .. '; border:1px solid #ccc; padding:0.3em;">'
                     .. listHtml .. '</div>'
        else
            listHtml = frame:preprocess('\n' .. listWiki)
            result = '<div style="overflow-y:auto; height:' .. scrollHeight
                     .. '; border:1px solid #ccc; padding:0.3em;">'
                     .. listHtml .. '</div>'
        end
        if timestampParam ~= 'off' and data.ultimo_aggiornamento then
            result = result .. '\n' .. buildTimestampLine(timestampParam,
                     data.ultimo_aggiornamento, data.num_files, filtered, vociSlice)
        end
    else
        result = p.formatOutput(filtered, disp, data.ultimo_aggiornamento, data.num_files, timestampParam, vociSlice, displayNum)
    end
    return result
end

-- Formattazione output wikitext
-- maxItems: numero massimo di voci da renderizzare (opzionale, default = #voci).
-- Evita di creare una tabella troncata: itera semplicemente fino a math.min(#voci, maxItems).
function p.formatOutput(voci, disp, ultimoAggiornamento, numFiles, timestampParam, cacheVoci, maxItems)
    local output = {}
    if timestampParam == nil then timestampParam = 'cachedays' end
    local limit = maxItems and math.min(#voci, maxItems) or #voci

    if disp == 'h' then
        local items = {}
        for i = 1, limit do
            table.insert(items, '[[' .. voci[i][1] .. ']]')
        end
        local listHtml = table.concat(items, ' · ')
        if timestampParam ~= 'off' and ultimoAggiornamento then
            return listHtml .. '<br/>' .. buildTimestampLine(timestampParam, ultimoAggiornamento, numFiles, voci, cacheVoci)
        end
        return listHtml
    elseif disp == 't' then
        for i = 1, limit do
            table.insert(output, '* [[' .. voci[i][1] .. ']]')
        end
    elseif disp == 'o' then
        for i = 1, limit do
            local dateStr = formatTimestampDisplay(voci[i])
            table.insert(output, '* [[' .. voci[i][1] .. ']] <small>(' .. dateStr .. ')</small>')
        end
    elseif disp == 'v' then
        for i = 1, limit do
            local dateStr = formatTimestampDisplay(voci[i])
            table.insert(output, '# [[' .. voci[i][1] .. ']] <small>(' .. dateStr .. ')</small>')
        end
    else
        for i = 1, limit do
            table.insert(output, '# [[' .. voci[i][1] .. ']]')
        end
    end

    if timestampParam ~= 'off' and ultimoAggiornamento then
        table.insert(output, '')
        table.insert(output, buildTimestampLine(timestampParam, ultimoAggiornamento, numFiles, voci, cacheVoci))
    end

    return table.concat(output, '\n')
end

-- Istruzioni setup
function p.showSetupInstructions(args, numFiles)
    local output = {}
    table.insert(output, '<div style="border:2px solid #fc3; background:#ffc; padding:1.5em; margin:1em 0;">')
    table.insert(output, "=== ⚠️ Configurazione necessaria ===\n")
    if numFiles == 0 then
        table.insert(output, "Nessun file cache trovato. Esegui il bot v3.0 per creare '''Modulo:VociRecenti/Dati1''', '''Dati2''', ecc.")
    else
        table.insert(output, "Cache caricata da " .. numFiles .. " file ma nessuna voce corrisponde ai filtri.")
    end
    table.insert(output, '</div>')
    return table.concat(output, '')
end

-- Funzione di debug per testare una voce specifica
function p.debugVoce(frame)
    -- debugVoce viene chiamata con #invoke diretto: i parametri sono in frame.args
    -- non in frame:getParent().args come per il template normale
    local args = {}
    for k, v in pairs(frame.args) do
        if v ~= '' then
            local key = type(k) == 'string' and string.lower(k) or k
            args[key] = v
        end
    end
    local titoloTest = args.titolo or args[1] or ''
    local orCatTest = args.orcat or ''

    if titoloTest == '' then
        return "Specifica un titolo: {{#invoke:VociRecenti|debugVoce|titolo=Rudolf Frank|OrCat=aviatori}}"
    end

    local data = loadAllData()
    if not data.voci or #data.voci == 0 then return "ERRORE: Cache vuota" end

    local voceTest = nil
    for _, voce in ipairs(data.voci) do
        if voce[1] == titoloTest then voceTest = voce break end
    end

    if not voceTest then
        return "ERRORE: Voce '" .. titoloTest .. "' non trovata nella cache"
    end

    local output = {}
    table.insert(output, "=== DEBUG VOCE ===\n")
    table.insert(output, "'''Titolo:''' " .. (voceTest[1] or '') .. "\n")
    table.insert(output, "'''Timestamp:''' " .. (voceTest[2] or '') .. "\n")
    table.insert(output, "'''Numero categorie:''' " .. #(voceTest[3] or {}) .. "\n\n")
    table.insert(output, "'''Categorie:'''\n")
    for i, cat in ipairs(voceTest[3] or {}) do
        table.insert(output, "* " .. cat .. "\n")
    end

    if orCatTest ~= '' then
        table.insert(output, "\n'''Test OrCat = '" .. orCatTest .. "':'''\n")
        local match = matchesOrCat(voceTest[3] or {}, orCatTest)
        table.insert(output, "* Risultato: " .. (match and "MATCH" or "NO MATCH") .. "\n")
        table.insert(output, "\n'''Dettaglio match:'''\n")
        local requiredCats = split(orCatTest, ',')
        for _, reqCat in ipairs(requiredCats) do
            table.insert(output, "* Cerca '" .. reqCat .. "':\n")
            for _, cat in ipairs(voceTest[3] or {}) do
                local found = mw.ustring.find(mw.ustring.lower(cat), mw.ustring.lower(reqCat), 1, true) ~= nil
                if found then
                    table.insert(output, "  ** TROVATO in: " .. cat .. "\n")
                end
            end
        end
    end

    -- Template
    local templates = voceTest[4] or {}
    table.insert(output, "\n'''Numero template:''' " .. #templates .. "\n")
    table.insert(output, "\n'''Template:'''\n")
    for i, tmpl in ipairs(templates) do
        local nome = tmpl[1] or "?"
        local params = tmpl[2] or {}
        local pstr = ""
        for j, p in ipairs(params) do
            pstr = pstr .. (j > 1 and ", " or "") .. p
        end
        table.insert(output, "* {{" .. nome .. "}} -> [" .. pstr .. "]\n")
    end

    -- Debug struttura template raw
    table.insert(output, "\n'''Debug struttura template raw:'''\n")
    -- Test diretto find
    local teststr = "portale"
    local testfind = mw.ustring.find(teststr, "portale", 1, true)
    table.insert(output, "* mw.ustring.find portale in portale: " .. tostring(testfind) .. "\n")
    local teststr2 = "portali"
    local testfind2 = mw.ustring.find(teststr2, "portale", 1, true)
    table.insert(output, "* mw.ustring.find portale in portali: " .. tostring(testfind2) .. "\n")
    local testfind3 = string.find(teststr2, "portale", 1, true)
    table.insert(output, "* string.find portale in portali: " .. tostring(testfind3) .. "\n")
    -- Verifica versione modulo
    table.insert(output, "* Versione modulo: 8.44\n")
    for i, tmpl in ipairs(templates) do
        local t1 = tmpl[1]
        local t2 = tmpl[2]
        local t1type = type(t1)
        local t1val = tostring(t1)
        local t1len = t1type == "string" and tostring(#t1) or "N/A"
        local has_portale = t1type == "string" and (mw.ustring.find(mw.ustring.lower(t1), "portale", 1, true) ~= nil)
        table.insert(output, "* tmpl[" .. i .. "]: type=" .. t1type .. " val=" .. t1val .. " len=" .. t1len .. " has_portale=" .. tostring(has_portale) .. "\n")
    end

    -- Test struttura voce raw
    table.insert(output, "\n'''Debug struttura voce:'''\n")
    table.insert(output, "* voceTest[4] type: " .. type(voceTest[4]) .. "\n")
    if type(voceTest[4]) == "table" then
        table.insert(output, "* voceTest[4] length: " .. #voceTest[4] .. "\n")
    end
    local tpl_field = voceTest[4] or {}
    table.insert(output, "* tpl_field length (or {}): " .. #tpl_field .. "\n")

    -- Test NoTemplate=*
    local noTmplTest = args.notemplate or ""
    if noTmplTest ~= "" then
        table.insert(output, "\n'''Test NoTemplate = '" .. noTmplTest .. "':'''\n")
        local trimmed = mw.text.trim(noTmplTest)
        table.insert(output, "* trimmed='" .. trimmed .. "' eq*=" .. tostring(trimmed == "*") .. "\n")
        table.insert(output, "* #templates=" .. #templates .. "\n")
        local result = (trimmed == "*") and (#templates == 0) or true
        table.insert(output, "* matchesNoTemplate result: " .. tostring(trimmed == "*" and #templates == 0) .. "\n")
    end

    -- Test Portali
    local portaliTest = args.portali or ""
    if portaliTest ~= "" then
        table.insert(output, "\n'''Test Portali = '" .. portaliTest .. "':'''\n")
        local match = matchesPortali(templates, portaliTest)
        table.insert(output, "* Risultato: " .. (match and "MATCH" or "NO MATCH") .. "\n")
        table.insert(output, "\n'''Params portale trovati:'''\n")
        for _, tmpl in ipairs(templates) do
            local nome = mw.ustring.lower(tmpl[1] or "")
            if isPortaleTemplate(nome) then
                for _, p in ipairs(tmpl[2] or {}) do
                    table.insert(output, "* [" .. nome .. "] param: " .. p .. "\n")
                end
            end
        end
    end

    return table.concat(output, "")
end

return p

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.