From 719fc09c6622bd0b17863a206175873d78686d1e Mon Sep 17 00:00:00 2001 From: Gabriele Cimaschi Date: Wed, 9 Sep 2026 17:03:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ora=20=C3=A8=20possibile=20scorrere=20g?= =?UTF-8?q?li=20allegati=20e=20modificare=20i=20pdf,=20aggiunte=20personal?= =?UTF-8?q?izzazioni=20di=20immagini=20e=20forme=20per=20poterle=20richiam?= =?UTF-8?q?are=20pi=C3=B9=20rapidamente.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- activityDb.js | 24 + public/index.html | 12 + public/js/app.js | 7 +- public/js/views/envConfig.js | 10 +- public/js/views/ticketDetail.js | 1568 ++++++++++++++++++++++++++++++- public/js/views/userConfig.js | 294 ++++++ routes/config.js | 180 +++- routes/tickets.js | 144 ++- 8 files changed, 2168 insertions(+), 71 deletions(-) create mode 100644 public/js/views/userConfig.js diff --git a/activityDb.js b/activityDb.js index d825463..75d890b 100644 --- a/activityDb.js +++ b/activityDb.js @@ -78,6 +78,30 @@ db.exec(` ) `); +db.exec(` + CREATE TABLE IF NOT EXISTS user_signatures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL, + image_data TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS user_shapes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL, + shape_type TEXT NOT NULL DEFAULT 'rectangle', + fill_color TEXT NOT NULL DEFAULT '#3b82f6', + border_color TEXT NOT NULL DEFAULT '#1e3a8a', + border_width INTEGER NOT NULL DEFAULT 3, + is_favorite INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) +`); + db.exec(` CREATE TABLE IF NOT EXISTS ticket_draft_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/public/index.html b/public/index.html index 0e42366..22719a5 100644 --- a/public/index.html +++ b/public/index.html @@ -133,6 +133,15 @@ Gestione Mail +
  • + + + + + + Configurazioni Utente + +
  • @@ -249,6 +258,8 @@ + + @@ -264,6 +275,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 44a1374..4ca05a1 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -454,9 +454,14 @@ const App = { } else if (hash === '#/config-env') { document.getElementById('nav-config-env')?.classList.add('active'); - titleEl.textContent = 'Modifica .env'; + titleEl.textContent = 'Modifica file .env'; EnvConfigView.render(); + } else if (hash === '#/config-user') { + document.getElementById('nav-config-user')?.classList.add('active'); + titleEl.textContent = 'Configurazioni Utente'; + UserConfigView.render(); + } else if (hash === '#/time-accounting') { document.getElementById('nav-time-accounting')?.classList.add('active'); titleEl.textContent = 'Storico consuntivazione'; diff --git a/public/js/views/envConfig.js b/public/js/views/envConfig.js index 5f1e641..6323206 100644 --- a/public/js/views/envConfig.js +++ b/public/js/views/envConfig.js @@ -284,9 +284,13 @@ const EnvConfigView = { try { btn.disabled = true; - const res = await App.api(`/api/config/env/history/${id}`, { - method: 'DELETE' - }); + let res; + try { + res = await App.api(`/api/config/env/history/${id}`, { method: 'DELETE' }); + } catch (delErr) { + // Fallback to POST endpoint if DELETE route returned 404 + res = await App.api(`/api/config/env/history/${id}/delete`, { method: 'POST' }); + } Toast.success(res.message || `Versione #${id} eliminata dallo storico!`); this.history = res.history || []; diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js index a751b1a..af907cc 100644 --- a/public/js/views/ticketDetail.js +++ b/public/js/views/ticketDetail.js @@ -87,41 +87,1545 @@ const TicketDetailView = { }); }, - openImageLightbox(src) { - let overlay = document.getElementById('image-lightbox-overlay'); - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = 'image-lightbox-overlay'; - overlay.style.cssText = ` - position: fixed; - inset: 0; - z-index: 10000; - background: rgba(0, 0, 0, 0.85); - display: flex; - align-items: center; - justify-content: center; - cursor: zoom-out; - opacity: 0; - transition: opacity 0.2s ease; - `; - overlay.innerHTML = ` - - `; - overlay.addEventListener('click', () => { - overlay.style.opacity = '0'; - setTimeout(() => overlay.remove(), 200); - }); - overlay.querySelector('img').addEventListener('click', (e) => { - e.stopPropagation(); - }); - document.body.appendChild(overlay); + getFileIcon(filename) { + const ext = (filename || '').split('.').pop().toLowerCase(); + if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return 'πŸ“'; + if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return 'πŸ“Š'; + if (['ppt', 'pptx', 'odp'].includes(ext)) return 'πŸ“Š'; + if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'πŸ“¦'; + if (['pdf'].includes(ext)) return 'πŸ“„'; + if (['txt', 'log', 'json', 'xml', 'html', 'css', 'js', 'md'].includes(ext)) return 'πŸ“œ'; + if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) return '🎡'; + if (['mp4', 'webm', 'mov', 'avi'].includes(ext)) return '🎬'; + return 'πŸ“Ž'; + }, + + collectAllTicketAttachments() { + const items = []; + + // 1. Gather images from iframe email bodies + document.querySelectorAll('.articles-timeline iframe').forEach(iframe => { + try { + const doc = iframe.contentDocument || iframe.contentWindow.document; + if (doc) { + doc.querySelectorAll('img').forEach(img => { + if (img.src && !img.src.startsWith('data:image/svg')) { + items.push({ + type: 'image', + src: img.src, + filename: img.alt || img.title || 'Immagine articolo', + sizeText: '', + downloadUrl: img.src + }); + } + }); + } + } catch (_) {} + }); + + // 2. Gather all attachment badges (Images, PDFs, Word, Excel, etc.) + document.querySelectorAll('.attachment-badge').forEach(badge => { + if (badge.href) { + const filename = badge.querySelector('strong') ? badge.querySelector('strong').textContent : 'Allegato'; + const sizeText = badge.querySelector('.attachment-size') ? badge.querySelector('.attachment-size').textContent : ''; + const ext = (filename || '').split('.').pop().toLowerCase(); + + let type = 'file'; + if (badge.dataset.isImage === 'true' || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) { + type = 'image'; + } else if (ext === 'pdf') { + type = 'pdf'; + } else if (['txt', 'log', 'json', 'xml', 'csv', 'html', 'md'].includes(ext)) { + type = 'text'; + } + + items.push({ + type: type, + src: badge.href, + filename: filename, + sizeText: sizeText, + downloadUrl: badge.href + }); + } + }); + + // Deduplicate items by src while maintaining order + const uniqueItems = []; + const seen = new Set(); + items.forEach(item => { + if (!seen.has(item.src)) { + seen.add(item.src); + uniqueItems.push(item); + } + }); + + return uniqueItems; + }, + + openImageLightbox(initialSrc) { + const itemsList = this.collectAllTicketAttachments(); + if (itemsList.length === 0) { + itemsList.push({ type: 'file', src: initialSrc, filename: 'Allegato', downloadUrl: initialSrc }); } - const imgEl = overlay.querySelector('img'); - imgEl.src = src; + + let currentIndex = itemsList.findIndex(item => item.src === initialSrc); + if (currentIndex === -1) { + itemsList.unshift({ type: 'file', src: initialSrc, filename: 'Allegato', downloadUrl: initialSrc }); + currentIndex = 0; + } + + let overlay = document.getElementById('image-lightbox-overlay'); + if (overlay) { + overlay.remove(); + } + + overlay = document.createElement('div'); + overlay.id = 'image-lightbox-overlay'; + overlay.style.cssText = ` + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.88); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + user-select: none; + `; + + overlay.innerHTML = ` + + + + + + + + + + `; + + overlay.tabIndex = -1; + document.body.appendChild(overlay); + overlay.focus(); + + const wrapper = overlay.querySelector('#lightbox-img-wrapper'); + const prevBtn = overlay.querySelector('#lightbox-prev-btn'); + const nextBtn = overlay.querySelector('#lightbox-next-btn'); + const closeBtn = overlay.querySelector('#lightbox-close-btn'); + const titleEl = overlay.querySelector('#lightbox-title-text'); + const counterEl = overlay.querySelector('#lightbox-counter-text'); + const downloadBtn = overlay.querySelector('#lightbox-download-btn'); + + const updateDisplay = () => { + const current = itemsList[currentIndex]; + + let previewHtml = ''; + if (current.type === 'image') { + previewHtml = ``; + } else if (current.type === 'pdf') { + previewHtml = ` +
    +
    +
    + Pagina 1 / 1 + + +
    + + + + + + + + + + + + + +
    +
    + +
    + + πŸ“œ Scorrimento continuo | β€Ή β€Ί Allegati | Esc per chiudere + + +
    +
    +
    +
    +
    + Rendering documento PDF in corso... +
    +
    +
    + `; + } else if (current.type === 'text') { + previewHtml = ``; + } else { + const icon = this.getFileIcon(current.filename); + previewHtml = ` +
    +
    ${icon}
    +
    +
    + ${App.escapeHtml(current.filename)} +
    +
    + ${App.escapeHtml(current.sizeText || 'Allegato documento')} +
    +
    +

    + Per modificare o visualizzare questo file con l'applicazione predefinita del tuo sistema (Word, Excel, ecc.), usa il pulsante qui sotto. +

    + + + Apri nell'app / Scarica + +
    + `; + } + + wrapper.innerHTML = previewHtml; + this.currentFilename = current.filename || 'documento.pdf'; + titleEl.textContent = current.filename || 'Allegato'; + counterEl.textContent = `${currentIndex + 1} / ${itemsList.length}`; + downloadBtn.href = current.downloadUrl; + downloadBtn.download = current.filename || ''; + + if (itemsList.length <= 1) { + prevBtn.style.display = 'none'; + nextBtn.style.display = 'none'; + } else { + prevBtn.style.display = 'flex'; + nextBtn.style.display = 'flex'; + } + + if (current.type === 'pdf') { + this.initPdfJsViewer(current.src); + } + + overlay.focus(); + }; + + const showPrev = () => { + if (itemsList.length <= 1) return; + currentIndex = (currentIndex - 1 + itemsList.length) % itemsList.length; + updateDisplay(); + }; + + const showNext = () => { + if (itemsList.length <= 1) return; + currentIndex = (currentIndex + 1) % itemsList.length; + updateDisplay(); + }; + + const closeLightbox = () => { + window.currentPdfDoc = null; + document.removeEventListener('keydown', handleKeydown, true); + window.removeEventListener('keydown', handleKeydown, true); + window.removeEventListener('blur', handleWindowBlur); + overlay.style.opacity = '0'; + setTimeout(() => overlay.remove(), 200); + }; + + // Helper to generate filename with optional timestamp prefix if already downloaded in current session + const getOutputFilename = (originalName) => { + let base = originalName || 'documento.pdf'; + if (!this.downloadedFilenames) this.downloadedFilenames = new Set(); + + if (this.downloadedFilenames.has(base)) { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + const hh = String(now.getHours()).padStart(2, '0'); + const min = String(now.getMinutes()).padStart(2, '0'); + const ss = String(now.getSeconds()).padStart(2, '0'); + const timestamp = `${yyyy}${mm}${dd}_${hh}${min}${ss}`; + return `${timestamp}_${base}`; + } else { + this.downloadedFilenames.add(base); + return base; + } + }; + + const savePdfDocument = async () => { + if (!window.PDFLib) { + Toast.error('Libreria salvataggio PDF-Lib non caricata'); + return; + } + + const saveBtn = document.getElementById('pdf-save-edited'); + try { + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = 'Generazione PDF...'; + } + + const pdfDocLib = await window.PDFLib.PDFDocument.load(this.currentRawPdfBytes); + const libPages = pdfDocLib.getPages(); + const totalPages = libPages.length; + + for (let num = 1; num <= totalPages; num++) { + const baseCanvas = document.getElementById(`pdf-canvas-page-${num}`); + const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`); + if (!baseCanvas || !drawCanvas) continue; + + // Check if drawCanvas has any annotations + const drawCtx = drawCanvas.getContext('2d'); + const imgData = drawCtx.getImageData(0, 0, drawCanvas.width, drawCanvas.height); + const hasDrawing = imgData.data.some(channel => channel !== 0); + + if (hasDrawing && libPages[num - 1]) { + // Merge base page and drawings onto a temporary canvas + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = baseCanvas.width; + tempCanvas.height = baseCanvas.height; + const tempCtx = tempCanvas.getContext('2d'); + + tempCtx.drawImage(baseCanvas, 0, 0); + tempCtx.globalCompositeOperation = 'multiply'; + tempCtx.drawImage(drawCanvas, 0, 0); + tempCtx.globalCompositeOperation = 'source-over'; + + const dataUrl = tempCanvas.toDataURL('image/jpeg', 0.92); + const jpgImage = await pdfDocLib.embedJpg(dataUrl); + + const targetPage = libPages[num - 1]; + const { width, height } = targetPage.getSize(); + + targetPage.drawImage(jpgImage, { + x: 0, + y: 0, + width: width, + height: height, + }); + } + } + + const modifiedBytes = await pdfDocLib.save(); + const blob = new Blob([modifiedBytes], { type: 'application/pdf' }); + const downloadUrl = URL.createObjectURL(blob); + + const outName = getOutputFilename(this.currentFilename); + + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = outName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); + + Toast.success(`PDF modificato scaricato: ${outName}`); + } catch (saveErr) { + console.error('Error saving edited PDF', saveErr); + Toast.error('Errore durante il salvataggio: ' + saveErr.message); + } finally { + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = 'πŸ’Ύ Scarica PDF Modificato'; + } + } + }; + + const handleKeydown = (e) => { + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + if (isCtrlOrCmd && e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + savePdfDocument(); + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeLightbox(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + e.stopPropagation(); + showPrev(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + e.stopPropagation(); + showNext(); + } else if (e.key === 'ArrowUp' && window.currentPdfDoc) { + e.preventDefault(); + const prevPageBtn = document.getElementById('pdf-prev-page'); + if (prevPageBtn) prevPageBtn.click(); + } else if (e.key === 'ArrowDown' && window.currentPdfDoc) { + e.preventDefault(); + const nextPageBtn = document.getElementById('pdf-next-page'); + if (nextPageBtn) nextPageBtn.click(); + } + }; + + // When clicking anywhere on the lightbox overlay or pressing ESC, ensure focus returns to main window + const handleWindowBlur = () => { + window.focus(); + }; + + window.addEventListener('blur', handleWindowBlur); + document.addEventListener('keydown', handleKeydown, true); + window.addEventListener('keydown', handleKeydown, true); + + prevBtn.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); }); + nextBtn.addEventListener('click', (e) => { e.stopPropagation(); showNext(); }); + closeBtn.addEventListener('click', (e) => { e.stopPropagation(); closeLightbox(); }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay || e.target.id === 'lightbox-img-wrapper') { + closeLightbox(); + } + }); + + updateDisplay(); overlay.offsetHeight; overlay.style.opacity = '1'; }, + async initPdfJsViewer(url) { + if (!window.pdfjsLib) { + console.warn('PDF.js library not loaded'); + return; + } + + try { + window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; + + const response = await fetch(url); + const pdfBytes = await response.arrayBuffer(); + this.currentRawPdfBytes = pdfBytes.slice(0); // copy buffer for pdf-lib export + + const loadingTask = window.pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }); + const pdfDoc = await loadingTask.promise; + window.currentPdfDoc = pdfDoc; + + const totalPages = pdfDoc.numPages; + const pageNumEl = document.getElementById('pdf-page-num'); + const loadingEl = document.getElementById('pdf-loading'); + const wrapper = document.getElementById('pdf-canvas-wrapper'); + if (!wrapper) return; + + if (pageNumEl) pageNumEl.textContent = `Pagina 1 / ${totalPages}`; + + let currentTool = 'draw'; // 'draw' | 'text' | 'highlight' + let currentColor = '#ef4444'; + + const toolDrawBtn = document.getElementById('pdf-tool-draw'); + const toolTextBtn = document.getElementById('pdf-tool-text'); + const toolHighlightBtn = document.getElementById('pdf-tool-highlight'); + const toolShapeBtn = document.getElementById('pdf-tool-shape'); + const shapeControls = document.getElementById('pdf-shape-controls'); + const colorPicker = document.getElementById('pdf-color-picker'); + const toolClearBtn = document.getElementById('pdf-tool-clear'); + const saveEditedBtn = document.getElementById('pdf-save-edited'); + + const setActiveTool = (tool, activeBtn) => { + currentTool = tool; + const allToolBtns = [toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, document.getElementById('pdf-tool-select')]; + allToolBtns.forEach(b => { + if (b) { + b.style.background = 'transparent'; + b.classList.remove('active'); + } + }); + if (activeBtn) { + activeBtn.style.background = 'var(--accent-primary)'; + activeBtn.classList.add('active'); + } + if (shapeControls) { + shapeControls.style.display = tool === 'shape' ? 'flex' : 'none'; + } + }; + + if (toolDrawBtn) toolDrawBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('draw', toolDrawBtn); }; + if (toolTextBtn) toolTextBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('text', toolTextBtn); }; + if (toolHighlightBtn) toolHighlightBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('highlight', toolHighlightBtn); }; + if (toolShapeBtn) { + toolShapeBtn.onclick = async (e) => { + e.stopPropagation(); + setActiveTool('shape', toolShapeBtn); + + let shapePopup = document.getElementById('pdf-shape-picker-popup'); + if (shapePopup) { + shapePopup.remove(); + return; + } + + let allShapes = []; + try { + allShapes = await App.api('/api/config/user-shapes'); + } catch (_) {} + + const btnRect = toolShapeBtn.getBoundingClientRect(); + shapePopup = document.createElement('div'); + shapePopup.id = 'pdf-shape-picker-popup'; + shapePopup.style.cssText = ` + position: fixed; + left: ${btnRect.left}px; + top: ${btnRect.bottom + 6}px; + z-index: 20000; + background: #0f172a; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 10px; + box-shadow: 0 10px 30px rgba(0,0,0,0.8); + color: #ffffff; + font-size: 0.78rem; + min-width: 220px; + max-height: 280px; + overflow-y: auto; + user-select: none; + `; + + shapePopup.innerHTML = ` +
    + πŸ“ Seleziona Forma + βœ• +
    + +
    + +
    + + +
    +
    + +
    + + ${allShapes.length > 0 ? ` +
    + ${allShapes.map(sh => ` + + `).join('')} +
    + ` : ` +
    Nessuna forma salvata. Puoi crearne su 'Configurazioni Utente'.
    + `} +
    + `; + + document.body.appendChild(shapePopup); + + const closePopup = () => { if (shapePopup) shapePopup.remove(); }; + shapePopup.querySelector('#shape-popup-close').onclick = closePopup; + + shapePopup.querySelectorAll('.base-shape-btn').forEach(btn => { + btn.onclick = (eBtn) => { + eBtn.stopPropagation(); + const typeSelect = document.getElementById('pdf-shape-type'); + if (typeSelect) typeSelect.value = btn.dataset.type; + closePopup(); + }; + }); + + shapePopup.querySelectorAll('.preset-shape-btn').forEach(btn => { + btn.onclick = (eBtn) => { + eBtn.stopPropagation(); + const sh = allShapes.find(s => s.id == btn.dataset.id); + if (sh) { + const typeSelect = document.getElementById('pdf-shape-type'); + const fillPicker = document.getElementById('pdf-shape-fill-color'); + const borderPicker = document.getElementById('pdf-shape-border-color'); + const borderWidth = document.getElementById('pdf-shape-border-width'); + + if (typeSelect) typeSelect.value = sh.shape_type; + if (fillPicker && sh.fill_color !== 'transparent') fillPicker.value = sh.fill_color; + if (borderPicker) borderPicker.value = sh.border_color; + if (borderWidth) borderWidth.value = sh.border_width; + + Toast.success(`Forma "${sh.name}" selezionata! Clicca sul PDF per posizionarla.`); + } + closePopup(); + }; + }); + + const closeOnOutsideClick = (evt) => { + if (!shapePopup.contains(evt.target) && evt.target !== toolShapeBtn) { + closePopup(); + document.removeEventListener('mousedown', closeOnOutsideClick, true); + } + }; + setTimeout(() => document.addEventListener('mousedown', closeOnOutsideClick, true), 50); + }; + } + if (colorPicker) colorPicker.onchange = (e) => { currentColor = e.target.value; }; + + // Create page containers, base canvases, and annotation overlay canvases + for (let num = 1; num <= totalPages; num++) { + const pageContainer = document.createElement('div'); + pageContainer.className = 'pdf-page-container'; + pageContainer.dataset.pageNum = num; + pageContainer.style.cssText = 'position: relative; margin-bottom: 16px; display: flex; flex-direction: column; align-items: center; user-select: none;'; + + const baseCanvas = document.createElement('canvas'); + baseCanvas.id = `pdf-canvas-page-${num}`; + baseCanvas.style.cssText = 'box-shadow: 0 4px 20px rgba(0,0,0,0.4); border-radius: 4px; background: #ffffff; display: block;'; + + const drawCanvas = document.createElement('canvas'); + drawCanvas.id = `pdf-draw-canvas-page-${num}`; + drawCanvas.className = 'pdf-annotation-canvas'; + drawCanvas.dataset.pageNum = num; + drawCanvas.style.cssText = 'position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-radius: 4px; cursor: crosshair; z-index: 10; touch-action: none;'; + + pageContainer.appendChild(baseCanvas); + pageContainer.appendChild(drawCanvas); + wrapper.appendChild(pageContainer); + } + + if (loadingEl) loadingEl.style.display = 'none'; + + // Render pages & bind drawing events on overlay canvases + for (let num = 1; num <= totalPages; num++) { + try { + const page = await pdfDoc.getPage(num); + const viewport = page.getViewport({ scale: 1.25 }); + const baseCanvas = document.getElementById(`pdf-canvas-page-${num}`); + const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`); + if (!baseCanvas || !drawCanvas) continue; + + baseCanvas.height = viewport.height; + baseCanvas.width = viewport.width; + + drawCanvas.height = viewport.height; + drawCanvas.width = viewport.width; + + const baseCtx = baseCanvas.getContext('2d'); + const renderContext = { + canvasContext: baseCtx, + viewport: viewport + }; + + await page.render(renderContext).promise; + + // Setup Object-Based Interactive Annotation System for each PDF page + const drawCtx = drawCanvas.getContext('2d'); + + // Page elements array: [{ id, type: 'stroke'|'text'|'image', x, y, width, height, color, size, text, img, strokePoints }] + const pageObjects = []; + drawCanvas.pageObjects = pageObjects; + + let selectedObject = null; + let isDraggingObj = false; + let isResizingObj = false; + let resizeHandleIndex = -1; // 0..7 + let dragOffsetX = 0; + let dragOffsetY = 0; + + let isDrawingStroke = false; + let currentStrokePoints = []; + let currentBrushType = 'pencil'; // 'pencil' | 'brush' | 'highlight' + let currentBrushSize = 3; + + const redrawPageObjects = () => { + drawCtx.clearRect(0, 0, drawCanvas.width, drawCanvas.height); + + pageObjects.forEach(obj => { + drawCtx.save(); + if (obj.type === 'stroke') { + if (obj.points && obj.points.length > 0) { + drawCtx.beginPath(); + drawCtx.moveTo(obj.points[0].x, obj.points[0].y); + obj.points.forEach(p => drawCtx.lineTo(p.x, p.y)); + drawCtx.strokeStyle = obj.color; + drawCtx.lineWidth = obj.size; + + if (obj.brushType === 'highlight') { + drawCtx.globalCompositeOperation = 'multiply'; + drawCtx.globalAlpha = 0.55; + drawCtx.lineCap = 'square'; + } else { + drawCtx.globalCompositeOperation = 'source-over'; + drawCtx.globalAlpha = obj.opacity || 1.0; + drawCtx.lineCap = 'round'; + } + drawCtx.lineJoin = 'round'; + drawCtx.stroke(); + } + } else if (obj.type === 'text') { + drawCtx.font = `bold ${obj.size || 16}px Inter, sans-serif`; + drawCtx.fillStyle = obj.color; + drawCtx.globalAlpha = 1.0; + drawCtx.fillText(obj.text, obj.x, obj.y + (obj.size || 16)); + } else if (obj.type === 'image') { + drawCtx.globalAlpha = 1.0; + if (obj.img) { + drawCtx.drawImage(obj.img, obj.x, obj.y, obj.width, obj.height); + } + } else if (obj.type === 'shape') { + drawCtx.globalAlpha = 1.0; + drawCtx.beginPath(); + if (obj.shapeType === 'circle') { + const rx = obj.width / 2; + const ry = obj.height / 2; + const cx = obj.x + rx; + const cy = obj.y + ry; + drawCtx.ellipse(cx, cy, Math.abs(rx), Math.abs(ry), 0, 0, Math.PI * 2); + } else { + drawCtx.rect(obj.x, obj.y, obj.width, obj.height); + } + if (obj.fillColor && obj.fillColor !== 'transparent') { + drawCtx.fillStyle = obj.fillColor; + drawCtx.fill(); + } + if (obj.borderWidth > 0 && obj.borderColor) { + drawCtx.lineWidth = obj.borderWidth; + drawCtx.strokeStyle = obj.borderColor; + drawCtx.stroke(); + } + } + drawCtx.restore(); + + // Draw selection box & resize handles if selected + if (selectedObject === obj) { + drawCtx.save(); + drawCtx.strokeStyle = '#3b82f6'; + drawCtx.lineWidth = 1.5; + drawCtx.setLineDash([4, 4]); + drawCtx.strokeRect(obj.x - 4, obj.y - 4, obj.width + 8, obj.height + 8); + drawCtx.setLineDash([]); + + // Handles at corners and midpoints + const handles = getObjectHandles(obj); + handles.forEach(h => { + drawCtx.fillStyle = '#ffffff'; + drawCtx.strokeStyle = '#2563eb'; + drawCtx.lineWidth = 1.5; + drawCtx.beginPath(); + drawCtx.arc(h.x, h.y, 4.5, 0, Math.PI * 2); + drawCtx.fill(); + drawCtx.stroke(); + }); + drawCtx.restore(); + } + }); + }; + + const getObjectHandles = (obj) => { + const x = obj.x; + const y = obj.y; + const w = obj.width; + const h = obj.height; + return [ + { x: x, y: y }, // 0: Top-Left + { x: x + w / 2, y: y }, // 1: Top-Center + { x: x + w, y: y }, // 2: Top-Right + { x: x + w, y: y + h / 2 }, // 3: Middle-Right + { x: x + w, y: y + h }, // 4: Bottom-Right + { x: x + w / 2, y: y + h }, // 5: Bottom-Center + { x: x, y: y + h }, // 6: Bottom-Left + { x: x, y: y + h / 2 } // 7: Middle-Left + ]; + }; + + const hitTestObject = (x, y) => { + for (let i = pageObjects.length - 1; i >= 0; i--) { + const obj = pageObjects[i]; + if (x >= obj.x - 6 && x <= obj.x + obj.width + 6 && y >= obj.y - 6 && y <= obj.y + obj.height + 6) { + return obj; + } + } + return null; + }; + + const hitTestHandle = (obj, x, y) => { + if (!obj) return -1; + const handles = getObjectHandles(obj); + for (let i = 0; i < handles.length; i++) { + const h = handles[i]; + const dist = Math.hypot(x - h.x, y - h.y); + if (dist <= 7) return i; + } + return -1; + }; + + // Delete selected object on Delete or Backspace key + const deleteSelectedObject = () => { + if (selectedObject) { + const idx = pageObjects.indexOf(selectedObject); + if (idx !== -1) { + pageObjects.splice(idx, 1); + selectedObject = null; + redrawPageObjects(); + Toast.info('Elemento rimosso'); + } + } + }; + + document.addEventListener('keydown', (e) => { + if (selectedObject && (e.key === 'Delete' || e.key === 'Backspace')) { + if (document.activeElement && ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; + e.preventDefault(); + deleteSelectedObject(); + } + }); + + // Ctrl + Right Click Context Menu Handler + drawCanvas.oncontextmenu = async (e) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + + const rect = drawCanvas.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + // Fetch signatures and shapes from SQLite + let signatures = []; + let favoriteShapes = []; + try { + const [sigRes, shapeRes] = await Promise.all([ + App.api('/api/config/user-signatures'), + App.api('/api/config/user-shapes') + ]); + signatures = sigRes || []; + favoriteShapes = (shapeRes || []).filter(s => s.is_favorite); + } catch (_) {} + + let menuOverlay = document.getElementById('pdf-context-menu-popup'); + if (menuOverlay) menuOverlay.remove(); + + menuOverlay = document.createElement('div'); + menuOverlay.id = 'pdf-context-menu-popup'; + menuOverlay.style.cssText = ` + position: fixed; + left: ${e.clientX}px; + top: ${e.clientY}px; + z-index: 20000; + background: #0f172a; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.8); + color: #ffffff; + font-size: 0.8rem; + min-width: 260px; + user-select: none; + `; + + menuOverlay.innerHTML = ` +
    + βš™οΈ Strumenti Rapidi PDF + βœ• +
    + +
    + +
    + + + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + ${favoriteShapes.length > 0 ? ` +
    + ${favoriteShapes.map(sh => ` + + `).join('')} +
    + ` : ` +
    Nessuna forma preferita salvata.
    + `} +
    + +
    + + ${signatures.length > 0 ? ` +
    + ${signatures.map(s => ` + + `).join('')} +
    + ` : ` +
    Nessuna firma salvata in SQLite.
    + `} +
    + `; + + document.body.appendChild(menuOverlay); + + // Menu Event Listeners + const closeCtx = () => { if (menuOverlay) menuOverlay.remove(); }; + menuOverlay.querySelector('#ctx-close').onclick = closeCtx; + + menuOverlay.querySelectorAll('.ctx-btn').forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const type = btn.dataset.type; + menuOverlay.querySelectorAll('.ctx-btn').forEach(b => { + b.style.background = 'rgba(255,255,255,0.1)'; + b.classList.remove('active'); + }); + btn.style.background = 'var(--accent-primary)'; + btn.classList.add('active'); + + if (type === 'pencil') { + currentBrushType = 'pencil'; + currentBrushSize = 3; + setActiveTool('draw', toolDrawBtn); + } else if (type === 'brush') { + currentBrushType = 'brush'; + currentBrushSize = 8; + setActiveTool('draw', toolDrawBtn); + } else if (type === 'highlight') { + currentBrushType = 'highlight'; + currentBrushSize = 20; + setActiveTool('highlight', toolHighlightBtn); + } else if (type === 'shape') { + setActiveTool('shape', toolShapeBtn); + } + if (sizeInput) sizeInput.value = currentBrushSize; + }; + }); + + const sizeInput = menuOverlay.querySelector('#ctx-brush-size'); + if (sizeInput) { + sizeInput.oninput = (eVal) => { + eVal.stopPropagation(); + currentBrushSize = parseInt(eVal.target.value, 10); + }; + } + + const ctxColorPicker = menuOverlay.querySelector('#ctx-color'); + if (ctxColorPicker) { + ctxColorPicker.onchange = (eVal) => { + eVal.stopPropagation(); + currentColor = eVal.target.value; + if (colorPicker) colorPicker.value = currentColor; + }; + } + + menuOverlay.querySelectorAll('.ctx-shape-btn').forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const shId = btn.dataset.id; + const sh = favoriteShapes.find(s => s.id == shId); + if (sh) { + const defaultW = 130; + const defaultH = 85; + const newObj = { + id: Date.now(), + type: 'shape', + shapeType: sh.shape_type, + x: clickX - defaultW / 2, + y: clickY - defaultH / 2, + width: defaultW, + height: defaultH, + fillColor: sh.fill_color, + borderColor: sh.border_color, + borderWidth: sh.border_width + }; + pageObjects.push(newObj); + selectedObject = newObj; + redrawPageObjects(); + Toast.success(`Forma "${sh.name}" inserita!`); + } + closeCtx(); + }; + }); + + menuOverlay.querySelectorAll('.ctx-sig-btn').forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const sigId = btn.dataset.id; + const sig = signatures.find(s => s.id == sigId); + if (sig) { + const img = new Image(); + img.onload = () => { + const targetWidth = Math.min(220, img.width || 180); + const targetHeight = (img.height / img.width) * targetWidth || 80; + const newObj = { + id: Date.now(), + type: 'image', + img: img, + x: clickX - targetWidth / 2, + y: clickY - targetHeight / 2, + width: targetWidth, + height: targetHeight + }; + pageObjects.push(newObj); + selectedObject = newObj; + redrawPageObjects(); + Toast.success(`Firma "${sig.name}" aggiunta al PDF! Usa 'Seleziona' per trascinare o ridimensionare.`); + }; + img.src = sig.image_data; + } + closeCtx(); + }; + }); + + const closeOnOutside = (evt) => { + if (!menuOverlay.contains(evt.target)) { + closeCtx(); + document.removeEventListener('mousedown', closeOnOutside, true); + } + }; + setTimeout(() => document.addEventListener('mousedown', closeOnOutside, true), 50); + } + }; + + // Tool Select Button Handler + const toolSelectBtn = document.getElementById('pdf-tool-select'); + if (toolSelectBtn) { + toolSelectBtn.onclick = (e) => { + e.stopPropagation(); + setActiveTool('select', toolSelectBtn); + drawCanvas.style.cursor = 'default'; + }; + } + + drawCanvas.onmousedown = (e) => { + if (e.button !== 0) return; // Only primary click + e.preventDefault(); + e.stopPropagation(); + const rect = drawCanvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 1. Check if clicking on resize handles of currently selected object (any tool mode) + if (selectedObject) { + const hIdx = hitTestHandle(selectedObject, x, y); + if (hIdx !== -1) { + isResizingObj = true; + resizeHandleIndex = hIdx; + return; + } + } + + // 2. Check if clicking directly on an existing object border/body (any tool mode) + const hitObj = hitTestObject(x, y); + if (hitObj) { + selectedObject = hitObj; + isDraggingObj = true; + dragOffsetX = x - hitObj.x; + dragOffsetY = y - hitObj.y; + redrawPageObjects(); + return; + } + + // If clicked empty space, deselect current object + if (selectedObject) { + selectedObject = null; + redrawPageObjects(); + } + + if (currentTool === 'select') { + return; + } + + const openInlineTextEditor = (clientX, clientY, initialX, initialY, existingTextObj = null) => { + let inlineInput = document.getElementById('pdf-inline-text-input'); + if (inlineInput) inlineInput.remove(); + + const initialText = existingTextObj ? existingTextObj.text : ''; + const textColor = existingTextObj ? existingTextObj.color : currentColor; + const textSize = existingTextObj ? existingTextObj.size : (currentBrushSize > 8 ? currentBrushSize : 16); + + inlineInput = document.createElement('input'); + inlineInput.id = 'pdf-inline-text-input'; + inlineInput.type = 'text'; + inlineInput.value = initialText; + inlineInput.placeholder = 'Scrivi testo...'; + inlineInput.style.cssText = ` + position: absolute; + left: ${clientX}px; + top: ${clientY}px; + z-index: 20000; + background: var(--bg-card); + color: ${textColor}; + border: 2px solid var(--accent-primary); + border-radius: 4px; + padding: 2px 6px; + font-family: Inter, sans-serif; + font-weight: 700; + font-size: ${textSize}px; + box-shadow: 0 4px 14px rgba(0,0,0,0.5); + outline: none; + min-width: 140px; + `; + + document.body.appendChild(inlineInput); + inlineInput.focus(); + if (existingTextObj) inlineInput.select(); + + let isCommitted = false; + const commitInlineText = () => { + if (isCommitted) return; + isCommitted = true; + const text = inlineInput ? inlineInput.value.trim() : ''; + if (inlineInput) inlineInput.remove(); + + if (existingTextObj) { + if (text) { + existingTextObj.text = text; + drawCtx.font = `bold ${existingTextObj.size}px Inter, sans-serif`; + existingTextObj.width = drawCtx.measureText(text).width; + existingTextObj.height = existingTextObj.size * 1.2; + selectedObject = existingTextObj; + } else { + // Remove if empty + const idx = pageObjects.indexOf(existingTextObj); + if (idx !== -1) pageObjects.splice(idx, 1); + selectedObject = null; + } + redrawPageObjects(); + } else if (text) { + drawCtx.font = `bold ${textSize}px Inter, sans-serif`; + const textWidth = drawCtx.measureText(text).width; + const newObj = { + id: Date.now(), + type: 'text', + text: text, + x: initialX, + y: initialY, + width: textWidth, + height: textSize * 1.2, + color: currentColor, + size: textSize + }; + pageObjects.push(newObj); + selectedObject = newObj; + redrawPageObjects(); + } + }; + + inlineInput.onkeydown = (eInput) => { + if (eInput.key === 'Enter') { + eInput.preventDefault(); + commitInlineText(); + } else if (eInput.key === 'Escape') { + eInput.preventDefault(); + isCommitted = true; + if (inlineInput) inlineInput.remove(); + redrawPageObjects(); + } + }; + + inlineInput.onblur = () => { + setTimeout(commitInlineText, 100); + }; + }; + + // Bind double click to re-edit existing text objects + drawCanvas.ondblclick = (eDbl) => { + eDbl.preventDefault(); + eDbl.stopPropagation(); + const rect = drawCanvas.getBoundingClientRect(); + const dblX = eDbl.clientX - rect.left; + const dblY = eDbl.clientY - rect.top; + + const hitObj = hitTestObject(dblX, dblY); + if (hitObj && hitObj.type === 'text') { + openInlineTextEditor(eDbl.clientX, eDbl.clientY, dblX, dblY, hitObj); + } + }; + + if (currentTool === 'text') { + openInlineTextEditor(e.clientX, e.clientY, x, y); + return; + } + + if (currentTool === 'shape') { + const shapeTypeEl = document.getElementById('pdf-shape-type'); + const fillColorEl = document.getElementById('pdf-shape-fill-color'); + const borderColorEl = document.getElementById('pdf-shape-border-color'); + const borderWidthEl = document.getElementById('pdf-shape-border-width'); + + const shapeType = shapeTypeEl ? shapeTypeEl.value : 'rectangle'; + const fillColor = fillColorEl ? fillColorEl.value : '#3b82f6'; + const borderColor = borderColorEl ? borderColorEl.value : '#1e3a8a'; + const borderWidth = borderWidthEl ? parseInt(borderWidthEl.value, 10) : 3; + + const defaultW = 120; + const defaultH = 80; + + const newShapeObj = { + id: Date.now(), + type: 'shape', + shapeType: shapeType, + x: x - defaultW / 2, + y: y - defaultH / 2, + width: defaultW, + height: defaultH, + fillColor: fillColor, + borderColor: borderColor, + borderWidth: borderWidth + }; + + pageObjects.push(newShapeObj); + selectedObject = newShapeObj; + redrawPageObjects(); + return; + } + + // Drawing mode (pencil / brush / highlight) + isDrawingStroke = true; + currentStrokePoints = [{ x, y }]; + const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight') ? 0.15 : 1.0; + const size = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3)); + + const newStrokeObj = { + id: Date.now(), + type: 'stroke', + points: currentStrokePoints, + x: x, + y: y, + width: size, + height: size, + color: currentColor, + size: size, + opacity: opacity, + brushType: currentBrushType + }; + pageObjects.push(newStrokeObj); + // Keep selectedObject null during active drawing so no blue bounding box displays while drawing + selectedObject = null; + }; + + drawCanvas.onmousemove = (e) => { + const rect = drawCanvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (isResizingObj && selectedObject) { + e.preventDefault(); + const obj = selectedObject; + const oldX = obj.x; + const oldY = obj.y; + const oldW = obj.width || 1; + const oldH = obj.height || 1; + + if (resizeHandleIndex === 0) { // Top-Left + const oldRight = obj.x + obj.width; + const oldBottom = obj.y + obj.height; + obj.x = Math.min(x, oldRight - 10); + obj.y = Math.min(y, oldBottom - 10); + obj.width = oldRight - obj.x; + obj.height = oldBottom - obj.y; + } else if (resizeHandleIndex === 2) { // Top-Right + const oldLeft = obj.x; + const oldBottom = obj.y + obj.height; + obj.y = Math.min(y, oldBottom - 10); + obj.width = Math.max(10, x - oldLeft); + obj.height = oldBottom - obj.y; + } else if (resizeHandleIndex === 4) { // Bottom-Right + obj.width = Math.max(10, x - obj.x); + obj.height = Math.max(10, y - obj.y); + } else if (resizeHandleIndex === 6) { // Bottom-Left + const oldRight = obj.x + obj.width; + obj.x = Math.min(x, oldRight - 10); + obj.width = oldRight - obj.x; + obj.height = Math.max(10, y - obj.y); + } else if (resizeHandleIndex === 1) { // Top-Center + const oldBottom = obj.y + obj.height; + obj.y = Math.min(y, oldBottom - 10); + obj.height = oldBottom - obj.y; + } else if (resizeHandleIndex === 3) { // Middle-Right + obj.width = Math.max(10, x - obj.x); + } else if (resizeHandleIndex === 5) { // Bottom-Center + obj.height = Math.max(10, y - obj.y); + } else if (resizeHandleIndex === 7) { // Middle-Left + const oldRight = obj.x + obj.width; + obj.x = Math.min(x, oldRight - 10); + obj.width = oldRight - obj.x; + } + + if (obj.type === 'stroke' && obj.points) { + const scaleX = obj.width / oldW; + const scaleY = obj.height / oldH; + obj.points.forEach(p => { + p.x = obj.x + (p.x - oldX) * scaleX; + p.y = obj.y + (p.y - oldY) * scaleY; + }); + } else if (obj.type === 'text') { + const scaleY = obj.height / oldH; + obj.size = Math.max(10, Math.round((obj.size || 16) * scaleY)); + drawCtx.font = `bold ${obj.size}px Inter, sans-serif`; + obj.width = drawCtx.measureText(obj.text).width; + obj.height = obj.size * 1.2; + } + + redrawPageObjects(); + return; + } + + if (isDraggingObj && selectedObject) { + e.preventDefault(); + const newX = x - dragOffsetX; + const newY = y - dragOffsetY; + const dx = newX - selectedObject.x; + const dy = newY - selectedObject.y; + + selectedObject.x = newX; + selectedObject.y = newY; + + if (selectedObject.type === 'stroke' && selectedObject.points) { + selectedObject.points.forEach(p => { + p.x += dx; + p.y += dy; + }); + } + redrawPageObjects(); + return; + } + + // Update cursor on hover over object or handle + if (selectedObject) { + const hIdx = hitTestHandle(selectedObject, x, y); + if (hIdx !== -1) { + drawCanvas.style.cursor = 'nwse-resize'; + return; + } + } + const hoverObj = hitTestObject(x, y); + drawCanvas.style.cursor = hoverObj ? 'move' : (currentTool === 'select' ? 'default' : 'crosshair'); + if (!isDrawingStroke) return; + e.preventDefault(); + e.stopPropagation(); + + currentStrokePoints.push({ x, y }); + + // Update stroke bounding box + if (isDrawingStroke && pageObjects.length > 0) { + const activeStrokeObj = pageObjects[pageObjects.length - 1]; + if (activeStrokeObj && activeStrokeObj.type === 'stroke') { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + currentStrokePoints.forEach(p => { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + }); + activeStrokeObj.x = minX; + activeStrokeObj.y = minY; + activeStrokeObj.width = maxX - minX; + activeStrokeObj.height = maxY - minY; + } + } + + redrawPageObjects(); + }; + + const stopDrawing = (e) => { + if (isDrawingStroke && pageObjects.length > 0) { + const activeObj = pageObjects[pageObjects.length - 1]; + if (activeObj && activeObj.type === 'stroke') { + if (!activeObj.points || activeObj.points.length <= 1) { + // Single click without dragging: discard stroke + pageObjects.pop(); + redrawPageObjects(); + } + } + } + isDrawingStroke = false; + isDraggingObj = false; + isResizingObj = false; + resizeHandleIndex = -1; + }; + + drawCanvas.onmouseup = stopDrawing; + drawCanvas.onmouseleave = stopDrawing; + + } catch (pErr) { + console.warn(`Failed rendering PDF page ${num}`, pErr); + } + } + + // Clear button handler for current page + if (toolClearBtn) { + toolClearBtn.onclick = (e) => { + e.stopPropagation(); + const pageContainers = wrapper.querySelectorAll('.pdf-page-container'); + let activePageNum = 1; + const wrapperTop = wrapper.scrollTop; + pageContainers.forEach((container) => { + if (container.offsetTop - wrapper.offsetTop <= wrapperTop + 150) { + activePageNum = parseInt(container.dataset.pageNum, 10); + } + }); + + const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${activePageNum}`); + if (drawCanvas) { + if (drawCanvas.pageObjects) { + drawCanvas.pageObjects.length = 0; + } + const ctx = drawCanvas.getContext('2d'); + ctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height); + Toast.info(`Pagina ${activePageNum} pulita.`); + } + }; + } + + // Save Edited PDF using pdf-lib and html canvas flattening + if (saveEditedBtn) { + saveEditedBtn.onclick = (e) => { + e.stopPropagation(); + savePdfDocument(); + }; + } + + // Scroll listener to update page counter in toolbar + wrapper.addEventListener('scroll', () => { + const pageContainers = wrapper.querySelectorAll('.pdf-page-container'); + let currentVisiblePage = 1; + const wrapperTop = wrapper.scrollTop; + + pageContainers.forEach((container) => { + if (container.offsetTop - wrapper.offsetTop <= wrapperTop + 150) { + currentVisiblePage = parseInt(container.dataset.pageNum, 10); + } + }); + + if (pageNumEl) { + pageNumEl.textContent = `Pagina ${currentVisiblePage} / ${totalPages}`; + } + }); + + } catch (err) { + console.error('Failed to load PDF via PDF.js', err); + const loadingEl = document.getElementById('pdf-loading'); + if (loadingEl) { + loadingEl.innerHTML = `Errore caricamento PDF: ${App.escapeHtml(err.message)}`; + } + } + }, + + showPdfTextModal(onSubmitCallback) { + let modal = document.getElementById('pdf-text-modal-overlay'); + if (modal) modal.remove(); + + modal = document.createElement('div'); + modal.id = 'pdf-text-modal-overlay'; + modal.style.cssText = ` + position: fixed; + inset: 0; + z-index: 25000; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + backdrop-filter: blur(2px); + `; + + modal.innerHTML = ` +
    +
    + πŸ”€ Inserisci Testo sul PDF + +
    +
    + +
    +
    + + +
    +
    + `; + + document.body.appendChild(modal); + + const inputEl = modal.querySelector('#pdf-modal-text-input'); + const confirmBtn = modal.querySelector('#pdf-modal-confirm-btn'); + const cancelBtn = modal.querySelector('#pdf-modal-cancel-btn'); + const closeBtn = modal.querySelector('#pdf-modal-close-btn'); + + if (inputEl) { + inputEl.focus(); + inputEl.select(); + } + + const closeModal = () => { + if (modal) modal.remove(); + }; + + const submitText = () => { + const val = inputEl ? inputEl.value : ''; + closeModal(); + if (onSubmitCallback) onSubmitCallback(val); + }; + + confirmBtn.onclick = submitText; + cancelBtn.onclick = closeModal; + closeBtn.onclick = closeModal; + + inputEl.onkeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitText(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closeModal(); + } + }; + + modal.onclick = (e) => { + if (e.target === modal) closeModal(); + }; + }, + async render(id) { this.ticketId = id; this.noteAttachments = []; @@ -1548,8 +3052,8 @@ const TicketDetailView = { } catch (_) { } }); - document.querySelectorAll('.attachment-badge[data-is-image="true"]').forEach(badge => { - badge.style.cursor = 'zoom-in'; + document.querySelectorAll('.attachment-badge').forEach(badge => { + badge.style.cursor = 'pointer'; badge.addEventListener('click', (e) => { e.preventDefault(); this.openImageLightbox(badge.href); diff --git a/public/js/views/userConfig.js b/public/js/views/userConfig.js new file mode 100644 index 0000000..cb1c272 --- /dev/null +++ b/public/js/views/userConfig.js @@ -0,0 +1,294 @@ +/** + * userConfig.js + * User Configurations View: Upload, preview and manage custom signature images stored in SQLite. + */ +const UserConfigView = { + async render() { + const container = document.getElementById('view-container'); + container.innerHTML = '

    Caricamento configurazioni utente...

    '; + + const titleEl = document.getElementById('page-title'); + if (titleEl) { + titleEl.textContent = 'Configurazioni Utente'; + } + + try { + const [signatures, shapes] = await Promise.all([ + App.api('/api/config/user-signatures'), + App.api('/api/config/user-shapes') + ]); + this.renderUI(container, signatures, shapes); + } catch (err) { + container.innerHTML = ` +
    +
    ⚠️
    +
    Errore caricamento configurazioni utente
    +
    ${App.escapeHtml(err.message)}
    +
    + `; + } + }, + + renderUI(container, signatures = [], shapes = []) { + container.innerHTML = ` +
    + +
    +
    + πŸ“ Forme Personalizzate & Preferiti + Configura forme salvate per l'editor PDF +
    + +
    +

    Crea Nuova Forma Personalizzata

    +
    +
    + + +
    + +
    + + +
    + +
    + +
    + + +
    +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +
    + + +
    + ${shapes.length > 0 ? shapes.map(sh => ` +
    +
    + + +
    + +
    +
    +
    + +
    + ${App.escapeHtml(sh.name)} +
    +
    + ${sh.shape_type === 'circle' ? 'Cerchio' : 'Rettangolo'} β€’ Bordo ${sh.border_width}px +
    +
    + `).join('') : ` +
    + Nessuna forma personalizzata salvata. +
    + `} +
    +
    + + +
    +
    + ✍️ Immagini e Firme Utente + Saranno disponibili per l'apposizione rapida sui PDF +
    + +
    +

    Aggiungi nuova immagine / firma

    +
    +
    + + +
    +
    + + +
    + +
    +
    + + +
    + ${signatures.length > 0 ? signatures.map(sig => ` +
    + +
    + ${App.escapeHtml(sig.name)} +
    +
    + ${App.escapeHtml(sig.name)} +
    +
    + ${App.formatDateTime(sig.created_at)} +
    +
    + `).join('') : ` +
    + Nessuna firma salvata. Carica la prima immagine con il modulo qui sopra. +
    + `} +
    +
    +
    + `; + + this.bindEvents(container); + }, + + bindEvents(container) { + // Shapes handlers + const btnSaveShape = container.querySelector('#btn-save-shape'); + if (btnSaveShape) { + btnSaveShape.addEventListener('click', async () => { + const name = container.querySelector('#shape-name').value.trim(); + const shapeType = container.querySelector('#shape-type').value; + const isTransparent = container.querySelector('#shape-transparent-fill').checked; + const fillColor = isTransparent ? 'transparent' : container.querySelector('#shape-fill-color').value; + const borderColor = container.querySelector('#shape-border-color').value; + const borderWidth = parseInt(container.querySelector('#shape-border-width').value || 3, 10); + + if (!name) { + Toast.warning('Inserisci un nome per la forma.'); + return; + } + + try { + btnSaveShape.disabled = true; + await App.api('/api/config/user-shapes', { + method: 'POST', + body: JSON.stringify({ + name, + shape_type: shapeType, + fill_color: fillColor, + border_color: borderColor, + border_width: borderWidth, + is_favorite: 1 + }) + }); + Toast.success('Forma salvata!'); + this.render(); + } catch (err) { + Toast.error('Errore salvataggio forma: ' + err.message); + btnSaveShape.disabled = false; + } + }); + } + + container.querySelectorAll('.btn-fav-shape').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + await App.api(`/api/config/user-shapes/${btn.dataset.id}/toggle-favorite`, { method: 'PUT' }); + this.render(); + } catch (err) { + Toast.error('Errore aggiornamento preferiti: ' + err.message); + } + }); + }); + + container.querySelectorAll('.btn-delete-shape').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const ok = await App.confirm('Elimina Forma', 'Eliminare questa forma personalizzata?', { confirmText: 'Elimina', cancelText: 'Annulla' }); + if (ok) { + try { + await App.api(`/api/config/user-shapes/${btn.dataset.id}`, { method: 'DELETE' }); + Toast.success('Forma eliminata!'); + this.render(); + } catch (err) { + Toast.error('Errore eliminazione: ' + err.message); + } + } + }); + }); + + // Signatures handlers + const btnSave = container.querySelector('#btn-save-user-sig'); + const inputName = container.querySelector('#user-sig-name'); + const inputFile = container.querySelector('#user-sig-file'); + + if (btnSave && inputFile) { + btnSave.addEventListener('click', async () => { + const name = inputName.value.trim(); + const file = inputFile.files[0]; + + if (!name) { + Toast.warning('Inserisci un nome descrittivo per la firma/immagine.'); + return; + } + if (!file) { + Toast.warning('Seleziona un file immagine.'); + return; + } + + const reader = new FileReader(); + reader.onload = async () => { + const base64Data = reader.result; + try { + btnSave.disabled = true; + btnSave.textContent = 'Salvataggio...'; + + const res = await App.api('/api/config/user-signatures', { + method: 'POST', + body: JSON.stringify({ + name, + image_data: base64Data + }) + }); + + Toast.success(res.message || 'Firma salvata!'); + this.render(); + } catch (err) { + Toast.error('Errore durante il salvataggio: ' + err.message); + btnSave.disabled = false; + btnSave.textContent = 'βž• Salva in SQLite'; + } + }; + reader.readAsDataURL(file); + }); + } + + container.querySelectorAll('.btn-delete-sig').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = btn.dataset.id; + const ok = await App.confirm('Elimina Firma', 'Sei sicuro di voler eliminare questa firma dal database?', { confirmText: 'Elimina', cancelText: 'Annulla' }); + if (ok) { + try { + await App.api(`/api/config/user-signatures/${id}`, { method: 'DELETE' }); + Toast.success('Firma eliminata!'); + this.render(); + } catch (err) { + Toast.error('Errore eliminazione: ' + err.message); + } + } + }); + }); + } +}; diff --git a/routes/config.js b/routes/config.js index 49f88b7..214fb7b 100644 --- a/routes/config.js +++ b/routes/config.js @@ -146,8 +146,8 @@ router.post('/env/restore/:history_id', async (req, res) => { } }); -// DELETE /api/config/env/history/:id β€” Delete a specific .env history entry -router.delete('/env/history/:id', async (req, res) => { +// Helper handler for deleting a specific .env history entry +async function handleDeleteHistory(req, res) { try { const { id } = req.params; const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); @@ -181,6 +181,182 @@ router.delete('/env/history/:id', async (req, res) => { console.error('Error deleting .env history entry:', err); res.status(500).json({ error: 'Errore durante l\'eliminazione della versione dello storico', message: err.message }); } +} + +// DELETE & POST /api/config/env/history/:id β€” Delete a specific .env history entry +router.delete('/env/history/:id', handleDeleteHistory); +router.post('/env/history/:id/delete', handleDeleteHistory); + +// GET /api/config/user-signatures β€” Fetch all user signature images +router.get('/user-signatures', (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); + const signatures = db.prepare(` + SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY id DESC + `).all(operatorId); + res.json(signatures); + } catch (err) { + console.error('Error fetching user signatures:', err); + res.status(500).json({ error: 'Errore durante il recupero delle firme utente', message: err.message }); + } +}); + +// POST /api/config/user-signatures β€” Add new user signature image +router.post('/user-signatures', (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); + const { name, image_data } = req.body; + + if (!name || !image_data) { + return res.status(400).json({ error: 'Nome e dati immagine sono obbligatori' }); + } + + const info = db.prepare(` + INSERT INTO user_signatures (agent_id, name, image_data) + VALUES (?, ?, ?) + `).run(operatorId, name.trim(), image_data); + + const signatures = db.prepare(` + SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY id DESC + `).all(operatorId); + + res.json({ + success: true, + message: 'Immagine/Firma salvata nel database con successo!', + id: info.lastInsertRowid, + signatures + }); + } catch (err) { + console.error('Error saving user signature:', err); + res.status(500).json({ error: 'Errore durante il salvataggio della firma', message: err.message }); + } +}); + +// DELETE /api/config/user-signatures/:id β€” Delete user signature image +router.delete('/user-signatures/:id', (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); + const { id } = req.params; + + db.prepare(`DELETE FROM user_signatures WHERE id = ? AND agent_id = ?`).run(id, operatorId); + + const signatures = db.prepare(` + SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY id DESC + `).all(operatorId); + + res.json({ + success: true, + message: 'Firma eliminata con successo!', + signatures + }); + } catch (err) { + console.error('Error deleting user signature:', err); + res.status(500).json({ error: 'Errore durante l\'eliminazione della firma', message: err.message }); + } +}); + +// GET /api/config/user-shapes β€” List custom user shapes +router.get('/user-shapes', (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); + const shapes = db.prepare(` + SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC + `).all(operatorId); + res.json(shapes); + } catch (err) { + console.error('Error fetching user shapes:', err); + res.status(500).json({ error: 'Errore durante il recupero delle forme utente', message: err.message }); + } +}); + +// POST /api/config/user-shapes β€” Create a new custom shape +router.post('/user-shapes', (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); + const { name, shape_type, fill_color, border_color, border_width, is_favorite } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Il nome della forma Γ¨ obbligatorio' }); + } + + const info = db.prepare(` + INSERT INTO user_shapes (agent_id, name, shape_type, fill_color, border_color, border_width, is_favorite) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + operatorId, + name.trim(), + shape_type || 'rectangle', + fill_color || '#3b82f6', + border_color || '#1e3a8a', + parseInt(border_width || 3, 10), + is_favorite !== undefined ? (is_favorite ? 1 : 0) : 1 + ); + + const shapes = db.prepare(` + SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC + `).all(operatorId); + + res.json({ + success: true, + message: 'Forma salvata nei preferiti con successo!', + id: info.lastInsertRowid, + shapes + }); + } catch (err) { + console.error('Error saving user shape:', err); + res.status(500).json({ error: 'Errore durante il salvataggio della forma', message: err.message }); + } +}); + +// PUT /api/config/user-shapes/:id/toggle-favorite β€” Toggle favorite status +router.put('/user-shapes/:id/toggle-favorite', (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); + const { id } = req.params; + + const shape = db.prepare(`SELECT * FROM user_shapes WHERE id = ? AND agent_id = ?`).get(id, operatorId); + if (!shape) { + return res.status(404).json({ error: 'Forma non trovata' }); + } + + const newFav = shape.is_favorite ? 0 : 1; + db.prepare(`UPDATE user_shapes SET is_favorite = ? WHERE id = ? AND agent_id = ?`).run(newFav, id, operatorId); + + const shapes = db.prepare(` + SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC + `).all(operatorId); + + res.json({ + success: true, + shapes + }); + } catch (err) { + console.error('Error toggling shape favorite status:', err); + res.status(500).json({ error: 'Errore durante l\'aggiornamento dei preferiti', message: err.message }); + } +}); + +// DELETE /api/config/user-shapes/:id β€” Delete a custom shape +router.delete('/user-shapes/:id', (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10); + const { id } = req.params; + + db.prepare(`DELETE FROM user_shapes WHERE id = ? AND agent_id = ?`).run(id, operatorId); + + const shapes = db.prepare(` + SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC + `).all(operatorId); + + res.json({ + success: true, + message: 'Forma eliminata con successo!', + shapes + }); + } catch (err) { + console.error('Error deleting user shape:', err); + res.status(500).json({ error: 'Errore durante l\'eliminazione della forma', message: err.message }); + } }); module.exports = router; diff --git a/routes/tickets.js b/routes/tickets.js index e5c04ba..106bfcf 100644 --- a/routes/tickets.js +++ b/routes/tickets.js @@ -457,27 +457,75 @@ router.get('/:id', async (req, res) => { [id] ); + const iconv = require('iconv-lite'); + + const decodeTextContent = (rawContent, contentTypeStr = '') => { + if (!rawContent) return ''; + let rawBuf = null; + + if (Buffer.isBuffer(rawContent)) { + rawBuf = rawContent; + } else if (typeof rawContent === 'string') { + const cleaned = rawContent.replace(/\s+/g, ''); + if (cleaned.length > 20 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) { + try { + rawBuf = Buffer.from(cleaned, 'base64'); + } catch (_) { + rawBuf = Buffer.from(rawContent, 'utf-8'); + } + } else { + rawBuf = Buffer.from(rawContent, 'utf-8'); + } + } else { + return String(rawContent); + } + + // Detect charset from content-type header + let charset = 'utf-8'; + const charsetMatch = (contentTypeStr || '').match(/charset=["']?([^"';\s]+)/i); + if (charsetMatch && charsetMatch[1]) { + charset = charsetMatch[1].toLowerCase(); + } + + let text = ''; + try { + if (iconv.encodingExists(charset)) { + text = iconv.decode(rawBuf, charset); + } else { + text = rawBuf.toString('utf-8'); + } + } catch (_) { + text = rawBuf.toString('utf-8'); + } + + // If UTF-8 decoding contains replacement character U+FFFD (), retry decoding as win1252/latin1 + if (text.includes('\uFFFD') || text.includes('')) { + try { + const latin1Text = iconv.decode(rawBuf, 'windows-1252'); + if (latin1Text && !latin1Text.includes('\uFFFD')) { + text = latin1Text; + } + } catch (_) {} + } + + // Repair Double-UTF-8 / Mojibake (e.g., Ò€ℒ -> ’, ù -> ΓΉ, è -> Γ¨, Γƒ -> Γ , Γƒ -> È) + 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 (_) {} + } + + return text; + }; + const htmlBodyMap = {}; for (const row of htmlAttachmentsRes.rows) { if (row.content) { - let contentStr = ''; - if (Buffer.isBuffer(row.content)) { - contentStr = row.content.toString('utf-8'); - } else if (typeof row.content === 'string') { - contentStr = row.content; - } - - const cleaned = contentStr.replace(/\s+/g, ''); - const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; - if (cleaned.length > 20 && cleaned.length % 4 === 0 && base64Regex.test(cleaned)) { - try { - const decoded = Buffer.from(cleaned, 'base64').toString('utf-8'); - if (decoded && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(decoded)) { - contentStr = decoded; - } - } catch (e) {} - } - + const contentStr = decodeTextContent(row.content, row.content_type); if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) { const fname = (row.filename || '').toLowerCase(); if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) { @@ -488,6 +536,14 @@ router.get('/:id', async (req, res) => { } articlesResult.rows.forEach(a => { + // Ensure a.a_body and subject are properly decoded if legacy encoded + if (a.a_body) { + a.a_body = decodeTextContent(a.a_body, a.a_content_type); + } + if (a.a_subject) { + a.a_subject = decodeTextContent(a.a_subject, 'text/plain; charset=utf-8'); + } + if (htmlBodyMap[a.article_id]) { a.html_body = htmlBodyMap[a.article_id]; a.has_html = true; @@ -1821,34 +1877,56 @@ router.get('/attachments/:id', async (req, res) => { } const attachment = result.rows[0]; - // Decode base64 if necessary let contentBuffer; if (attachment.content) { - let contentStr = ''; if (Buffer.isBuffer(attachment.content)) { - contentStr = attachment.content.toString('utf-8'); - } else if (typeof attachment.content === 'string') { - contentStr = attachment.content; - } + // Check for raw binary signatures (%PDF, JPEG, PNG, Zip) + const isRawBinary = attachment.content.length > 4 && ( + (attachment.content[0] === 0x25 && attachment.content[1] === 0x50 && attachment.content[2] === 0x44 && attachment.content[3] === 0x46) || // %PDF + (attachment.content[0] === 0xFF && attachment.content[1] === 0xD8) || // JPEG + (attachment.content[0] === 0x89 && attachment.content[1] === 0x50) || // PNG + (attachment.content[0] === 0x50 && attachment.content[1] === 0x4B) // ZIP/DOCX/XLSX + ); - const cleaned = contentStr.replace(/\s+/g, ''); - const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; - if (cleaned.length % 4 === 0 && base64Regex.test(cleaned)) { - try { - contentBuffer = Buffer.from(cleaned, 'base64'); - } catch (e) { - contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content); + if (isRawBinary) { + contentBuffer = attachment.content; + } else { + const str = attachment.content.toString('utf-8'); + const cleaned = str.replace(/\s+/g, ''); + if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) { + try { + contentBuffer = Buffer.from(cleaned, 'base64'); + } catch (_) { + contentBuffer = attachment.content; + } + } else { + contentBuffer = attachment.content; + } + } + } else if (typeof attachment.content === 'string') { + const cleaned = attachment.content.replace(/\s+/g, ''); + if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) { + try { + contentBuffer = Buffer.from(cleaned, 'base64'); + } catch (_) { + contentBuffer = Buffer.from(attachment.content, 'utf-8'); + } + } else { + contentBuffer = Buffer.from(attachment.content, 'utf-8'); } } else { - contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content); + contentBuffer = Buffer.from(attachment.content); } } else { contentBuffer = Buffer.alloc(0); } + const isDownload = req.query.download === '1' || req.query.disposition === 'attachment'; + const disposition = isDownload ? 'attachment' : 'inline'; const safeFilename = attachment.filename || 'attachment'; + res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream'); - res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`); + res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`); res.send(contentBuffer); } catch (err) { console.error('Errore download allegato:', err);