Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6944b02263 | ||
|
|
c1dc86d6cd | ||
|
|
e94ef9a38c | ||
|
|
56dcca57aa | ||
|
|
b06a0993d1 | ||
|
|
b957272eb8 | ||
|
|
a5d3431008 |
No files matched your search
@@ -483,11 +483,12 @@ const EmailCompose = (() => {
|
|||||||
placeholder: 'Scrivi il testo della email...',
|
placeholder: 'Scrivi il testo della email...',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
|
||||||
[{ 'header': [1, 2, 3, false] }],
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
['link', 'image'],
|
|
||||||
[{ 'color': [] }, { 'background': [] }],
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image', 'code-block'],
|
||||||
['clean']
|
['clean']
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,10 +155,12 @@ const NoteTemporaneeView = {
|
|||||||
placeholder: 'Scrivi il contenuto dettagliato della nota temporanea...',
|
placeholder: 'Scrivi il contenuto dettagliato della nota temporanea...',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
|
||||||
[{ 'color': [] }, { 'background': [] }],
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
['link', 'image'],
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image', 'code-block'],
|
||||||
['clean']
|
['clean']
|
||||||
],
|
],
|
||||||
keyboard: {
|
keyboard: {
|
||||||
|
|||||||
@@ -74,16 +74,16 @@ const ShortcutsView = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quick_edit_save',
|
id: 'quick_edit_save',
|
||||||
name: 'Salva Modifica Rapida',
|
name: 'Salva Modifiche / Invia Nota',
|
||||||
description: 'Salva le modifiche apportate nei campi di Modifica Rapida',
|
description: 'Salva le modifiche apportate nei campi di Modifica Rapida o invia la nota interna nel dettaglio ticket',
|
||||||
context: 'Pagina Dettaglio Ticket (Modifica Rapida)',
|
context: 'Pagina Dettaglio Ticket',
|
||||||
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pdf_save_edited',
|
id: 'pdf_save_edited',
|
||||||
name: 'Salva Allegato / PDF Modificato',
|
name: 'Salva Immagine / Allegato / PDF Modificato',
|
||||||
description: 'Scarica l\'allegato corrente o salva il PDF con le annotazioni apportate',
|
description: 'Scarica l\'immagine o il PDF con le correzioni e annotazioni apportate',
|
||||||
context: 'Anteprima Allegato / PDF',
|
context: 'Anteprima Allegato / PDF / Immagine',
|
||||||
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -230,10 +230,12 @@ const TicketCreateView = {
|
|||||||
placeholder: 'Descrivi il problema in dettaglio...',
|
placeholder: 'Descrivi il problema in dettaglio...',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
|
||||||
[{ 'header': [1, 2, 3, false] }],
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
['link', 'image'],
|
[{ 'align': [] }],
|
||||||
|
['link', 'image', 'code-block'],
|
||||||
['clean']
|
['clean']
|
||||||
],
|
],
|
||||||
keyboard: {
|
keyboard: {
|
||||||
@@ -486,8 +488,6 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ownerSearchInput.addEventListener('focus', () => {
|
ownerSearchInput.addEventListener('focus', () => {
|
||||||
ownerSearchInput.value = '';
|
|
||||||
ownerIdInput.value = '';
|
|
||||||
ownerSearchInput.dispatchEvent(new Event('input'));
|
ownerSearchInput.dispatchEvent(new Event('input'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -542,8 +542,6 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
responsibleSearchInput.addEventListener('focus', () => {
|
responsibleSearchInput.addEventListener('focus', () => {
|
||||||
responsibleSearchInput.value = '';
|
|
||||||
responsibleIdInput.value = '';
|
|
||||||
responsibleSearchInput.dispatchEvent(new Event('input'));
|
responsibleSearchInput.dispatchEvent(new Event('input'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -597,8 +595,6 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
queueSearchInput.addEventListener('focus', () => {
|
queueSearchInput.addEventListener('focus', () => {
|
||||||
queueSearchInput.value = '';
|
|
||||||
queueIdInput.value = '';
|
|
||||||
queueSearchInput.dispatchEvent(new Event('input'));
|
queueSearchInput.dispatchEvent(new Event('input'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -748,17 +744,21 @@ const TicketCreateView = {
|
|||||||
el.addEventListener('change', () => this.saveState());
|
el.addEventListener('change', () => this.saveState());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shortcut to submit ticket
|
// Shortcut to submit ticket (global keydown listener for ticket creation view)
|
||||||
const createFormEl = document.querySelector('.create-form');
|
const handleTicketCreateShortcut = (e) => {
|
||||||
if (createFormEl) {
|
// Check if we are still on ticket creation view
|
||||||
createFormEl.addEventListener('keydown', (e) => {
|
if (!document.getElementById('create-submit')) {
|
||||||
|
document.removeEventListener('keydown', handleTicketCreateShortcut, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (App.isShortcutTriggered(e, 'create_ticket_submit')) {
|
if (App.isShortcutTriggered(e, 'create_ticket_submit')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if (submitBtn && !submitBtn.disabled) {
|
if (submitBtn && !submitBtn.disabled) {
|
||||||
submitBtn.click();
|
submitBtn.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
document.addEventListener('keydown', handleTicketCreateShortcut, true);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+999
-354
File diff suppressed because it is too large.
Load diff
+28
-5
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { db, logAttivita } = require('../activityDb');
|
const { db, logAttivita } = require('../activityDb');
|
||||||
|
const { cleanAndDecodeText } = require('../utils/textDecoder');
|
||||||
|
|
||||||
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
|
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
|
||||||
function getLocalTimestamp() {
|
function getLocalTimestamp() {
|
||||||
@@ -292,6 +293,12 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.rows.forEach(t => {
|
||||||
|
if (t.title) t.title = cleanAndDecodeText(t.title);
|
||||||
|
if (t.customer_first) t.customer_first = cleanAndDecodeText(t.customer_first);
|
||||||
|
if (t.customer_last) t.customer_last = cleanAndDecodeText(t.customer_last);
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
tickets: result.rows,
|
tickets: result.rows,
|
||||||
total,
|
total,
|
||||||
@@ -443,14 +450,17 @@ router.get('/:id', async (req, res) => {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch HTML body attachment content for articles if present (e.g. file-1, file-1.html, text/html)
|
// Fetch HTML body attachment content for articles if present (e.g. file-1, file-2, text/html, html attachments)
|
||||||
const htmlAttachmentsRes = await pool.query(
|
const htmlAttachmentsRes = await pool.query(
|
||||||
`SELECT id, article_id, filename, content_type, content
|
`SELECT id, article_id, filename, content_type, content
|
||||||
FROM article_data_mime_attachment
|
FROM article_data_mime_attachment
|
||||||
WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1)
|
WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1)
|
||||||
AND (
|
AND (
|
||||||
filename LIKE 'file-%'
|
filename LIKE 'file-%'
|
||||||
|
OR filename LIKE '%.html'
|
||||||
|
OR filename LIKE '%.htm'
|
||||||
OR content_type LIKE '%html%'
|
OR content_type LIKE '%html%'
|
||||||
|
OR content_type LIKE '%text%'
|
||||||
OR disposition = 'inline'
|
OR disposition = 'inline'
|
||||||
)
|
)
|
||||||
ORDER BY id ASC`,
|
ORDER BY id ASC`,
|
||||||
@@ -522,10 +532,14 @@ router.get('/:id', async (req, res) => {
|
|||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (ticket.title) ticket.title = cleanAndDecodeText(ticket.title);
|
||||||
|
if (ticket.customer_first) ticket.customer_first = cleanAndDecodeText(ticket.customer_first);
|
||||||
|
if (ticket.customer_last) ticket.customer_last = cleanAndDecodeText(ticket.customer_last);
|
||||||
|
|
||||||
const htmlBodyMap = {};
|
const htmlBodyMap = {};
|
||||||
for (const row of htmlAttachmentsRes.rows) {
|
for (const row of htmlAttachmentsRes.rows) {
|
||||||
if (row.content) {
|
if (row.content) {
|
||||||
const contentStr = decodeTextContent(row.content, row.content_type);
|
const contentStr = cleanAndDecodeText(row.content, row.content_type);
|
||||||
if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) {
|
if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) {
|
||||||
const fname = (row.filename || '').toLowerCase();
|
const fname = (row.filename || '').toLowerCase();
|
||||||
if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) {
|
if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) {
|
||||||
@@ -536,12 +550,21 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
articlesResult.rows.forEach(a => {
|
articlesResult.rows.forEach(a => {
|
||||||
// Ensure a.a_body and subject are properly decoded if legacy encoded
|
// Decode article subjects, bodies, and sender/recipient headers
|
||||||
if (a.a_body) {
|
if (a.a_body) {
|
||||||
a.a_body = decodeTextContent(a.a_body, a.a_content_type);
|
a.a_body = cleanAndDecodeText(a.a_body, a.a_content_type);
|
||||||
}
|
}
|
||||||
if (a.a_subject) {
|
if (a.a_subject) {
|
||||||
a.a_subject = decodeTextContent(a.a_subject, 'text/plain; charset=utf-8');
|
a.a_subject = cleanAndDecodeText(a.a_subject);
|
||||||
|
}
|
||||||
|
if (a.a_from) {
|
||||||
|
a.a_from = cleanAndDecodeText(a.a_from);
|
||||||
|
}
|
||||||
|
if (a.a_to) {
|
||||||
|
a.a_to = cleanAndDecodeText(a.a_to);
|
||||||
|
}
|
||||||
|
if (a.a_cc) {
|
||||||
|
a.a_cc = cleanAndDecodeText(a.a_cc);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (htmlBodyMap[a.article_id]) {
|
if (htmlBodyMap[a.article_id]) {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
const iconv = require('iconv-lite');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes MIME encoded-words (e.g. =?UTF-8?B?...?= or =?ISO-8859-1?Q?...?=)
|
||||||
|
*/
|
||||||
|
function decodeMimeWords(str) {
|
||||||
|
if (!str || typeof str !== 'string') return str || '';
|
||||||
|
if (!str.includes('=?')) return str;
|
||||||
|
|
||||||
|
return str.replace(/=\?([^?]+)\?([BQbq])\?([^?]+)\?=/g, (fullMatch, charset, encoding, data) => {
|
||||||
|
try {
|
||||||
|
const cs = charset.toLowerCase();
|
||||||
|
const enc = encoding.toUpperCase();
|
||||||
|
|
||||||
|
let buf;
|
||||||
|
if (enc === 'B') {
|
||||||
|
buf = Buffer.from(data, 'base64');
|
||||||
|
} else if (enc === 'Q') {
|
||||||
|
const qDec = data.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||||
|
buf = Buffer.from(qDec, 'binary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf) {
|
||||||
|
if (iconv.encodingExists(cs)) {
|
||||||
|
return iconv.decode(buf, cs);
|
||||||
|
} else {
|
||||||
|
return buf.toString('utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
return fullMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes raw Quoted-Printable strings (e.g. =E0=E8=E9=F9 or =C3=A8)
|
||||||
|
*/
|
||||||
|
function decodeQuotedPrintable(str, charset = 'utf-8') {
|
||||||
|
if (!str || typeof str !== 'string') return str || '';
|
||||||
|
if (!/=(?:[0-9A-Fa-f]{2}|\r?\n)/.test(str)) return str;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cleaned = str.replace(/=\r?\n/g, '');
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i < cleaned.length; i++) {
|
||||||
|
if (cleaned[i] === '=' && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substring(i + 1, i + 3))) {
|
||||||
|
bytes.push(parseInt(cleaned.substring(i + 1, i + 3), 16));
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
bytes.push(cleaned.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(bytes);
|
||||||
|
if (iconv.encodingExists(charset)) {
|
||||||
|
return iconv.decode(buf, charset);
|
||||||
|
}
|
||||||
|
return buf.toString('utf-8');
|
||||||
|
} catch (_) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main text decoder & Mojibake repair utility
|
||||||
|
*/
|
||||||
|
function cleanAndDecodeText(rawInput, contentTypeHint = '') {
|
||||||
|
if (!rawInput && rawInput !== 0) return '';
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
// 1. Buffer / Base64 / String normalization
|
||||||
|
if (Buffer.isBuffer(rawInput)) {
|
||||||
|
let charset = 'utf-8';
|
||||||
|
const match = (contentTypeHint || '').match(/charset=["']?([^"';\s]+)/i);
|
||||||
|
if (match && match[1]) charset = match[1].toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (iconv.encodingExists(charset)) {
|
||||||
|
text = iconv.decode(rawInput, charset);
|
||||||
|
} else {
|
||||||
|
text = rawInput.toString('utf-8');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
text = rawInput.toString('utf-8');
|
||||||
|
}
|
||||||
|
} else if (typeof rawInput === 'string') {
|
||||||
|
const cleaned = rawInput.replace(/\s+/g, '');
|
||||||
|
if (cleaned.length > 20 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(cleaned, 'base64');
|
||||||
|
const utfStr = buf.toString('utf-8');
|
||||||
|
// Only accept base64 decode if result doesn't look like corrupted binary noise
|
||||||
|
if (utfStr && !utfStr.includes('\uFFFD')) {
|
||||||
|
text = utfStr;
|
||||||
|
} else {
|
||||||
|
text = rawInput;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
text = rawInput;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text = rawInput;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return String(rawInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decode MIME words (e.g. =?UTF-8?B?...?= or =?ISO-8859-1?Q?...?=)
|
||||||
|
if (text.includes('=?')) {
|
||||||
|
text = decodeMimeWords(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Decode Quoted-Printable if present (and not inside HTML tags)
|
||||||
|
if (/=(?:[0-9A-Fa-f]{2})/.test(text) && !text.includes('<html') && !text.includes('<div') && !text.includes('<p>')) {
|
||||||
|
text = decodeQuotedPrintable(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fix Mojibake / Double-UTF-8 / Win1252 / ISO-8859-1 mis-decoding
|
||||||
|
if (/(?:Ã[\s\xa0¡-ÿ]|â€[™œ]|–|Â[°©®])/.test(text)) {
|
||||||
|
try {
|
||||||
|
const reEncoded = iconv.encode(text, 'win1252');
|
||||||
|
const repaired = iconv.decode(reEncoded, 'utf-8');
|
||||||
|
if (repaired && !repaired.includes('\uFFFD')) {
|
||||||
|
text = repaired;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Clean stray Mojibake/control characters like ý, ÿ, or U+FFFD () replacement symbols
|
||||||
|
if (/[ýÿ\uFFFD]/.test(text)) {
|
||||||
|
text = text.replace(/ý{1,}/g, '')
|
||||||
|
.replace(/ÿ{1,}/g, '')
|
||||||
|
.replace(/\uFFFD{1,}/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cleanAndDecodeText,
|
||||||
|
decodeMimeWords,
|
||||||
|
decodeQuotedPrintable
|
||||||
|
};
|
||||||
Reference in new issue
Block a user