diff --git a/db.js b/db.js
index 988b26a..73d1e1a 100644
--- a/db.js
+++ b/db.js
@@ -40,6 +40,10 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
// 2. ILIKE -> LIKE (MySQL LIKE is case-insensitive by default)
translatedSql = translatedSql.replace(/\bILIKE\b/gi, 'LIKE');
+ // 2b. PostgreSQL regex ~* / ~ -> REGEXP
+ translatedSql = translatedSql.replace(/~\*/g, 'REGEXP');
+ translatedSql = translatedSql.replace(/~/g, 'REGEXP');
+
// 3. PostgreSQL string concatenation '||' -> CONCAT(...) in ticket number generator
translatedSql = translatedSql.replace(/md5\(random\(\)::text\s*\|\|\s*clock_timestamp\(\)::text\)/gi, 'MD5(CONCAT(RAND(), NOW()))');
diff --git a/public/css/style.css b/public/css/style.css
index 5b1972c..353a582 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -3062,4 +3062,32 @@ body {
line-height: 1.2;
margin: 0;
white-space: pre;
+}
+
+/* Ricerca Spudorata - Info Modal / Popover & Regex */
+#spudorata-info-btn:hover {
+ background: var(--accent-primary) !important;
+ color: #ffffff !important;
+ box-shadow: 0 0 10px var(--accent-primary-glow);
+ transform: scale(1.08);
+}
+
+.spudorata-rules-popover {
+ animation: popoverFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@keyframes popoverFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+#spudorata-regex-toggle:hover {
+ border-color: var(--accent-primary) !important;
+ background: var(--bg-card-hover) !important;
}
\ No newline at end of file
diff --git a/public/js/views/ricercaSpudorata.js b/public/js/views/ricercaSpudorata.js
index 3f9740f..e131402 100644
--- a/public/js/views/ricercaSpudorata.js
+++ b/public/js/views/ricercaSpudorata.js
@@ -4,6 +4,7 @@
*/
const RicercaSpudorataView = {
searchTerm: '',
+ isRegex: false,
cachedTickets: null,
currentPage: 1,
perPage: 50,
@@ -29,6 +30,10 @@ const RicercaSpudorataView = {
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
const qParam = urlParams.get('q');
+ const regexParam = urlParams.get('regex');
+ if (regexParam !== null) {
+ this.isRegex = regexParam === '1' || regexParam === 'true';
+ }
if (qParam && qParam !== this.searchTerm) {
this.renderContainer(container);
@@ -62,12 +67,54 @@ const RicercaSpudorataView = {
renderContainer(container) {
container.innerHTML = `
-
+
π
-
Ricerca Testuale Spudorata
+
+
Ricerca Testuale Spudorata
+
+
+
+
+
+
+
+
+
+
+
+
+
+ π‘
+ Guida Ricerca & Regole Avanzate (Regex)
+
+
Regole avanzate
+
+
+
+ Puoi cercare normalmente per parole chiave o attivare l'opzione Regex per ricerche con espressioni regolari (POSIX / JavaScript) sia sul database che sui filtri in cache.
+
+
+
+
Esempi di espressioni regolari:
+
+ vpn|firewall β Trova ticket contenenti vpn oppure firewall
+ \b(errore|fatal)\b β Trova parole esatte "errore" o "fatal"
+ ^INC-[0-9]+ β Ticket o codici che iniziano per "INC-" seguito da cifre
+ host(name)?.*down β Trova "host down" o "hostname down" anche con parole in mezzo
+ \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} β Trova indirizzi IP nel corpo o nell'oggetto
+
+
+
+
+
β‘ Case-insensitive: Le ricerche con Regex non distinguono automaticamente tra maiuscole e minuscole.
+
β‘ Cache locale: Anche il campo "Cerca in Cache" rispetta la modalitΓ Regex o il formato /pattern/.
+
+
+
+
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano istantaneamente in cache locale senza fare nuove richieste al server.
@@ -78,11 +125,19 @@ const RicercaSpudorataView = {
-
+
β
+
+
+
+
+ .*
+ Regex
+
+
Cerca
@@ -194,6 +249,15 @@ const RicercaSpudorataView = {
return;
}
+ if (this.isRegex) {
+ try {
+ new RegExp(query);
+ } catch (e) {
+ Toast.error('Espressione regolare non valida: ' + e.message);
+ return;
+ }
+ }
+
this.searchTerm = query;
this.isFetching = true;
@@ -202,13 +266,14 @@ const RicercaSpudorataView = {
resultsArea.innerHTML = `
-
Ricerca testuale nel server in corso per "${App.escapeHtml(query)}"...
+
Ricerca testuale ${this.isRegex ? 'con Regex ' : ''}nel server in corso per "${App.escapeHtml(query)}"...
`;
}
try {
- const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}&per_page=2000`);
+ const regexParam = this.isRegex ? '&is_regex=1' : '';
+ const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}${regexParam}&per_page=2000`);
this.cachedTickets = data.tickets || [];
this.otrsBaseUrl = data.otrsBaseUrl || '';
this.hasSearched = true;
@@ -216,7 +281,7 @@ const RicercaSpudorataView = {
this.selectedIds.clear();
this.selectedOrder = [];
- Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"`);
+ Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"${this.isRegex ? ' (Regex)' : ''}`);
this.renderContainer(document.getElementById('view-container'));
this.bindEvents();
this.renderLocalResults();
@@ -361,8 +426,22 @@ const RicercaSpudorataView = {
const s = Filters.state;
if (s.sub_search) {
- const q = String(s.sub_search).toLowerCase().trim();
- if (q) {
+ const rawQ = String(s.sub_search).trim();
+ if (rawQ) {
+ let regexMatcher = null;
+ if (this.isRegex) {
+ try {
+ regexMatcher = new RegExp(rawQ, 'i');
+ } catch (e) {
+ // If invalid while typing, fallback or ignore
+ }
+ } else if (rawQ.startsWith('/') && rawQ.length > 2 && rawQ.endsWith('/')) {
+ try {
+ regexMatcher = new RegExp(rawQ.slice(1, -1), 'i');
+ } catch (e) {}
+ }
+
+ const qLower = rawQ.toLowerCase();
list = list.filter(t => {
const fields = [
t.tn,
@@ -379,7 +458,10 @@ const RicercaSpudorataView = {
t.customer_id,
t.customer_user_id
];
- return fields.some(f => f && String(f).toLowerCase().includes(q));
+ if (regexMatcher) {
+ return fields.some(f => f && regexMatcher.test(String(f)));
+ }
+ return fields.some(f => f && String(f).toLowerCase().includes(qLower));
});
}
}
@@ -529,6 +611,78 @@ const RicercaSpudorataView = {
});
}
+ const regexCheckbox = document.getElementById('spudorata-regex-checkbox');
+ if (regexCheckbox) {
+ regexCheckbox.addEventListener('change', () => {
+ this.isRegex = regexCheckbox.checked;
+ const toggleLabel = document.getElementById('spudorata-regex-toggle');
+ if (toggleLabel) {
+ toggleLabel.style.borderColor = this.isRegex ? 'var(--accent-primary)' : 'var(--border-light)';
+ toggleLabel.style.color = this.isRegex ? 'var(--accent-primary)' : 'var(--text-secondary)';
+ }
+ if (searchInput) {
+ searchInput.placeholder = this.isRegex
+ ? 'Cerca con espressione regolare (es. vpn|firewall, ^INC-\\d+)...'
+ : 'Cerca nel testo (oggetto, corpo, numero ticket, cliente)...';
+ searchInput.focus();
+ }
+ // If we already have cached tickets, update the local filtered view immediately
+ if (this.hasSearched && this.cachedTickets) {
+ this.currentPage = 1;
+ this.renderLocalResults();
+ }
+ });
+ }
+
+ // Info button & hover modal
+ const infoBtn = document.getElementById('spudorata-info-btn');
+ const infoModal = document.getElementById('spudorata-info-modal');
+ if (infoBtn && infoModal) {
+ let closeTimeout = null;
+
+ const openModal = () => {
+ if (closeTimeout) {
+ clearTimeout(closeTimeout);
+ closeTimeout = null;
+ }
+ infoModal.style.display = 'block';
+ };
+
+ const closeModalWithDelay = () => {
+ if (closeTimeout) clearTimeout(closeTimeout);
+ closeTimeout = setTimeout(() => {
+ infoModal.style.display = 'none';
+ }, 250);
+ };
+
+ infoBtn.addEventListener('mouseenter', openModal);
+ infoBtn.addEventListener('mouseleave', closeModalWithDelay);
+
+ infoModal.addEventListener('mouseenter', () => {
+ if (closeTimeout) {
+ clearTimeout(closeTimeout);
+ closeTimeout = null;
+ }
+ });
+ infoModal.addEventListener('mouseleave', closeModalWithDelay);
+
+ // Also toggle on click for touch/mobile devices or explicit clicks
+ infoBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (infoModal.style.display === 'block') {
+ infoModal.style.display = 'none';
+ } else {
+ openModal();
+ }
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!infoBtn.contains(e.target) && !infoModal.contains(e.target)) {
+ infoModal.style.display = 'none';
+ }
+ });
+ }
+
if (resetCacheBtn) {
resetCacheBtn.addEventListener('click', () => {
this.invalidateCache();
diff --git a/routes/tickets.js b/routes/tickets.js
index da7304d..048b5e6 100644
--- a/routes/tickets.js
+++ b/routes/tickets.js
@@ -212,20 +212,45 @@ router.get('/', async (req, res) => {
}
const searchTerm = search || sub_search;
if (searchTerm) {
- conditions.push(`(
- t.title ILIKE $${paramIdx} OR
- t.tn ILIKE $${paramIdx} OR
- EXISTS (
- SELECT 1 FROM article art
- LEFT JOIN article_data_mime adm ON art.id = adm.article_id
- WHERE art.ticket_id = t.id AND (
- adm.a_subject ILIKE $${paramIdx} OR
- adm.a_body ILIKE $${paramIdx}
+ const isRegex = req.query.is_regex === 'true' || req.query.is_regex === '1';
+ if (isRegex) {
+ // Validate regex pattern to prevent DB syntax error
+ try {
+ new RegExp(searchTerm);
+ } catch (regexErr) {
+ return res.status(400).json({ error: `Espressione regolare non valida: ${regexErr.message}` });
+ }
+
+ conditions.push(`(
+ t.title ~* $${paramIdx} OR
+ t.tn ~* $${paramIdx} OR
+ EXISTS (
+ SELECT 1 FROM article art
+ LEFT JOIN article_data_mime adm ON art.id = adm.article_id
+ WHERE art.ticket_id = t.id AND (
+ adm.a_subject ~* $${paramIdx} OR
+ adm.a_body ~* $${paramIdx}
+ )
)
- )
- )`);
- params.push(`%${searchTerm}%`);
- paramIdx++;
+ )`);
+ params.push(searchTerm);
+ paramIdx++;
+ } else {
+ conditions.push(`(
+ t.title ILIKE $${paramIdx} OR
+ t.tn ILIKE $${paramIdx} OR
+ EXISTS (
+ SELECT 1 FROM article art
+ LEFT JOIN article_data_mime adm ON art.id = adm.article_id
+ WHERE art.ticket_id = t.id AND (
+ adm.a_subject ILIKE $${paramIdx} OR
+ adm.a_body ILIKE $${paramIdx}
+ )
+ )
+ )`);
+ params.push(`%${searchTerm}%`);
+ paramIdx++;
+ }
}
const whereClause = conditions.length > 0