From 0b69b8d566ad1ec0087b88fa2254840fc979ae79 Mon Sep 17 00:00:00 2001 From: abite Date: Thu, 12 Jun 2025 22:29:29 -0500 Subject: [PATCH 01/66] Integrate Paperless-NGX API for document attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: Add Paperless NGX integration for document management 🔗 **Integration Features:** - Added Paperless NGX configuration in Settings → System → Integrations - Host URL, API token configuration with connection testing - Real-time connection status with document count display 📋 **Document Search & Attachment:** - New PaperlessManager class with modal search interface - Real-time document search with autocomplete and debouncing - "Search Paperless Documents" buttons in all attachment sections - Documents appear alongside regular file attachments with "P" badge 🔧 **Backend API Endpoints:** - `POST /api/paperless/test-connection` - Connection validation - `GET /api/paperless/search` - Document search with pagination - `GET /api/paperless/document/:id/download` - Authenticated document proxy - `GET /api/paperless/document/:id/info` - Document metadata retrieval 🎨 **UI/UX Enhancements:** - Paperless documents visually distinguished with blue "P" indicator - "From Paperless NGX" source labels on attached documents - Consistent styling with existing attachment system - Modal search interface with loading states and error handling ⚡ **Technical Implementation:** - Documents linked (not downloaded) - stay in Paperless storage - Authenticated proxy for seamless user access (no login required) - Web Streams API integration for efficient document streaming - Configuration stored in config.json with default settings - Full error handling and user feedback throughout 🔐 **Security & Authentication:** - API token-based authentication with Paperless - Server-side credential management (tokens not exposed to frontend) - Secure document proxy prevents direct Paperless exposure **Files Modified:** - `public/index.html` - Added integration settings UI and search buttons - `public/styles.css` - Paperless-specific styling and visual indicators - `public/managers/settings.js` - Integration configuration management - `public/managers/paperlessManager.js` - New document search and attachment logic - `public/managers/modalManager.js` - Paperless document attachment support - `public/script.js` - Event listener setup and manager initialization - `server.js` - API endpoints, configuration, and document proxy streaming **Usage:** 1. Configure Paperless in Settings → System → Integrations 2. Enter host URL and API token, test connection 3. Use "Search Paperless Documents" buttons when adding attachments 4. Search and attach documents directly from Paperless library 5. Click attached documents to download directly from Paperless Resolves integration requirements for external document management system. --- public/index.html | 111 ++++++++- public/managers/modalManager.js | 167 +++++++++++++ public/managers/paperlessManager.js | 372 ++++++++++++++++++++++++++++ public/managers/settings.js | 81 ++++++ public/script.js | 70 +++++- public/styles.css | 167 +++++++++++++ server.js | 217 ++++++++++++++++ 7 files changed, 1171 insertions(+), 14 deletions(-) create mode 100644 public/managers/paperlessManager.js diff --git a/public/index.html b/public/index.html index e397c9f..d5cf8e3 100644 --- a/public/index.html +++ b/public/index.html @@ -262,20 +262,29 @@

File Attachments

-
-
- -
- - - - - - Drag & drop or click to upload photos +
+
+ +
+ + + + + + Drag & drop or click to upload photos +
+
+
+
+
-
-
@@ -292,6 +301,15 @@

File Attachments

+
+ +
@@ -308,6 +326,15 @@

File Attachments

+
+ +
@@ -459,6 +486,15 @@

File Attachments

+
+ +
@@ -475,6 +511,15 @@

File Attachments

+
+ +
@@ -491,6 +536,15 @@

File Attachments

+
+ +
@@ -907,6 +961,39 @@ + +
+ Integrations +
+

Paperless NGX

+
+
+ Enable Paperless Integration + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
diff --git a/public/managers/modalManager.js b/public/managers/modalManager.js index b6142a9..6199b05 100644 --- a/public/managers/modalManager.js +++ b/public/managers/modalManager.js @@ -1035,4 +1035,171 @@ export class ModalManager { this.duplicationManager.openDuplicateModal(type, itemId); } } + + /** + * Attach a Paperless document to the current asset/sub-asset + * @param {Object} attachment - The attachment object from PaperlessManager + * @param {string} attachmentType - Type of attachment ('photo', 'receipt', 'manual') + * @param {boolean} isSubAsset - Whether this is for a sub-asset + */ + async attachPaperlessDocument(attachment, attachmentType, isSubAsset) { + try { + // Generate preview for the Paperless document + const previewId = isSubAsset ? + `sub${attachmentType.charAt(0).toUpperCase() + attachmentType.slice(1)}Preview` : + `${attachmentType}Preview`; + + const previewContainer = document.getElementById(previewId); + if (!previewContainer) { + throw new Error(`Preview container ${previewId} not found`); + } + + // Create a preview element for the Paperless document + const previewElement = this._createPaperlessPreview(attachment, attachmentType); + previewContainer.appendChild(previewElement); + + // Store the attachment data for saving + const targetAsset = isSubAsset ? this.currentSubAsset : this.currentAsset; + if (!targetAsset) { + throw new Error('No asset currently being edited'); + } + + // Initialize arrays if they don't exist + const pathsKey = `${attachmentType}Paths`; + const infoKey = `${attachmentType}Info`; + + if (!targetAsset[pathsKey]) targetAsset[pathsKey] = []; + if (!targetAsset[infoKey]) targetAsset[infoKey] = []; + + // Add the Paperless document as a "file" + targetAsset[pathsKey].push(attachment.downloadUrl); + targetAsset[infoKey].push({ + originalName: attachment.title, + size: attachment.fileSize, + isPaperlessDocument: true, + paperlessId: attachment.paperlessId, + mimeType: attachment.mimeType, + attachedAt: attachment.attachedAt + }); + + console.log(`Attached Paperless document to ${isSubAsset ? 'sub-asset' : 'asset'}:`, { + type: attachmentType, + title: attachment.title, + paperlessId: attachment.paperlessId + }); + + } catch (error) { + globalThis.logError('Failed to attach Paperless document:', error.message); + throw error; + } + } + + /** + * Create a preview element for a Paperless document + * @param {Object} attachment - The attachment object + * @param {string} type - The attachment type + * @returns {HTMLElement} - The preview element + */ + _createPaperlessPreview(attachment, type) { + const previewItem = document.createElement('div'); + previewItem.className = 'file-preview-item paperless-document'; + + // Determine file type icon + let icon = ` + + + + + `; + + if (attachment.mimeType && attachment.mimeType.startsWith('image/')) { + icon = ` + + + + + + `; + } + + previewItem.innerHTML = ` +
${icon}
+
+ ${this._escapeHtml(attachment.title)} + ${attachment.fileSize ? `${this.formatFileSize(attachment.fileSize)}` : ''} + From Paperless NGX +
+
+ + +
+ `; + + // Add event listeners + const previewBtn = previewItem.querySelector('.preview-btn'); + if (previewBtn) { + previewBtn.addEventListener('click', () => { + // Use DumbAssets proxy for authenticated download + window.open(attachment.downloadUrl, '_blank'); + }); + } + + const deleteBtn = previewItem.querySelector('.delete-file-btn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + this._removePaperlessAttachment(previewItem, attachment, type); + }); + } + + return previewItem; + } + + /** + * Remove a Paperless document attachment + * @param {HTMLElement} previewElement - The preview element to remove + * @param {Object} attachment - The attachment object + * @param {string} type - The attachment type + */ + _removePaperlessAttachment(previewElement, attachment, type) { + const targetAsset = this.currentSubAsset || this.currentAsset; + if (!targetAsset) return; + + const pathsKey = `${type}Paths`; + const infoKey = `${type}Info`; + + if (targetAsset[pathsKey] && targetAsset[infoKey]) { + // Find and remove the attachment + const index = targetAsset[pathsKey].indexOf(attachment.downloadUrl); + if (index > -1) { + targetAsset[pathsKey].splice(index, 1); + targetAsset[infoKey].splice(index, 1); + } + } + + // Remove the preview element + previewElement.remove(); + + globalThis.toaster.show(`Removed "${attachment.title}" from attachments`, 'success'); + } + + /** + * Escape HTML to prevent XSS + * @param {string} text - Text to escape + * @returns {string} - Escaped text + */ + _escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } } \ No newline at end of file diff --git a/public/managers/paperlessManager.js b/public/managers/paperlessManager.js new file mode 100644 index 0000000..b481590 --- /dev/null +++ b/public/managers/paperlessManager.js @@ -0,0 +1,372 @@ +/** + * PaperlessManager handles all Paperless NGX integration functionality + * including document search, attachment, and configuration management + */ +export class PaperlessManager { + constructor() { + this.searchModal = null; + this.currentSearchQuery = ''; + this.searchTimeout = null; + this.DEBUG = false; + this._createSearchModal(); + } + + /** + * Check if Paperless integration is enabled and configured + */ + async isEnabled() { + try { + const response = await fetch('/api/settings', { credentials: 'include' }); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) return false; + + const settings = await response.json(); + const paperlessConfig = settings.integrationSettings?.paperless; + return paperlessConfig?.enabled && paperlessConfig?.hostUrl && paperlessConfig?.apiToken; + } catch (error) { + if (this.DEBUG) console.error('Failed to check Paperless config:', error); + return false; + } + } + + /** + * Open the Paperless document search modal + */ + async openSearchModal(onAttach) { + const enabled = await this.isEnabled(); + if (!enabled) { + globalThis.toaster.show('Paperless integration is not configured. Please check your settings.', 'error'); + return; + } + + this.onAttachCallback = onAttach; + this.searchModal.style.display = 'flex'; + this._clearSearch(); + this._focusSearchInput(); + this._loadRecentDocuments(); + } + + /** + * Close the search modal + */ + closeSearchModal() { + if (this.searchModal) { + this.searchModal.style.display = 'none'; + this._clearSearch(); + } + } + + /** + * Search for Paperless documents + */ + async searchDocuments(query, page = 1) { + try { + const searchUrl = `/api/paperless/search?q=${encodeURIComponent(query)}&page=${page}&page_size=20`; + const response = await fetch(searchUrl, { credentials: 'include' }); + + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage); + + const data = await response.json(); + return data; + } catch (error) { + globalThis.logError('Failed to search Paperless documents:', error.message); + throw error; + } + } + + /** + * Attach a Paperless document to the current asset/sub-asset + */ + async attachDocument(document) { + try { + if (!this.onAttachCallback) { + throw new Error('No attachment callback configured'); + } + + // Create a standardized attachment object + const attachment = { + id: `paperless_${document.id}`, + type: 'paperless_document', + paperlessId: document.id, + title: document.title, + originalName: document.original_file_name || document.title, + mimeType: document.mime_type || 'application/pdf', + fileSize: document.size || null, + downloadUrl: `${globalThis.getApiBaseUrl()}/api/paperless/document/${document.id}/download`, + isPaperlessDocument: true, + attachedAt: new Date().toISOString(), + paperlessUrl: document.download_url + }; + + // Call the attachment callback + await this.onAttachCallback(attachment); + + this.closeSearchModal(); + globalThis.toaster.show(`Attached "${document.title}" from Paperless`, 'success'); + } catch (error) { + globalThis.logError('Failed to attach Paperless document:', error.message); + } + } + + /** + * Create the search modal HTML structure + */ + _createSearchModal() { + // Remove existing modal if it exists + const existingModal = document.getElementById('paperlessSearchModal'); + if (existingModal) { + existingModal.remove(); + } + + const modalHTML = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalHTML); + this.searchModal = document.getElementById('paperlessSearchModal'); + + // Bind events + this._bindSearchModalEvents(); + } + + /** + * Bind event listeners for the search modal + */ + _bindSearchModalEvents() { + const closeBtn = document.getElementById('paperlessSearchClose'); + const searchInput = document.getElementById('paperlessSearchInput'); + + // Close button + closeBtn.addEventListener('click', () => this.closeSearchModal()); + + // Click outside to close + this.searchModal.addEventListener('click', (e) => { + if (e.target === this.searchModal) { + this.closeSearchModal(); + } + }); + + // Escape key to close + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.searchModal.style.display === 'flex') { + this.closeSearchModal(); + } + }); + + // Search input with debouncing + searchInput.addEventListener('input', (e) => { + const query = e.target.value.trim(); + this.currentSearchQuery = query; + + // Clear previous timeout + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + // Debounce search + this.searchTimeout = setTimeout(() => { + if (query.length === 0) { + this._loadRecentDocuments(); + } else if (query.length >= 2) { + this._performSearch(query); + } + }, 300); + }); + + // Enter key to search + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const query = e.target.value.trim(); + if (query.length >= 2) { + this._performSearch(query); + } + } + }); + } + + /** + * Focus the search input + */ + _focusSearchInput() { + const searchInput = document.getElementById('paperlessSearchInput'); + if (searchInput) { + setTimeout(() => searchInput.focus(), 100); + } + } + + /** + * Clear search results and input + */ + _clearSearch() { + const searchInput = document.getElementById('paperlessSearchInput'); + const resultsDiv = document.getElementById('paperlessSearchResults'); + + if (searchInput) searchInput.value = ''; + if (resultsDiv) { + resultsDiv.innerHTML = ` +
+ Start typing to search documents... +
+ `; + } + + this.currentSearchQuery = ''; + } + + /** + * Load recent documents when no search query + */ + async _loadRecentDocuments() { + try { + const results = await this.searchDocuments('', 1); + this._renderSearchResults(results, 'Recent Documents'); + } catch (error) { + this._renderError('Failed to load recent documents'); + } + } + + /** + * Perform search with the given query + */ + async _performSearch(query) { + const resultsDiv = document.getElementById('paperlessSearchResults'); + + // Show loading state + resultsDiv.innerHTML = ` +
+
+ Searching... +
+ `; + + try { + const results = await this.searchDocuments(query); + this._renderSearchResults(results, `Search Results for "${query}"`); + } catch (error) { + this._renderError(`Search failed: ${error.message}`); + } + } + + /** + * Render search results in the modal + */ + _renderSearchResults(data, title) { + const resultsDiv = document.getElementById('paperlessSearchResults'); + + if (!data.results || data.results.length === 0) { + resultsDiv.innerHTML = ` +
+ No documents found +
+ `; + return; + } + + const resultsHTML = ` +

${title} (${data.count})

+ ${data.results.map(doc => this._renderDocumentItem(doc)).join('')} + ${data.count > data.results.length ? ` +
+ Showing ${data.results.length} of ${data.count} documents +
+ ` : ''} + `; + + resultsDiv.innerHTML = resultsHTML; + + // Bind attach buttons + resultsDiv.querySelectorAll('.paperless-attach-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const docId = e.target.dataset.docId; + const document = data.results.find(doc => doc.id.toString() === docId); + if (document) { + this.attachDocument(document); + } + }); + }); + } + + /** + * Render a single document item + */ + _renderDocumentItem(document) { + const createdDate = new Date(document.created).toLocaleDateString(); + const fileSize = document.size ? this._formatFileSize(document.size) : ''; + const tags = document.tags && document.tags.length > 0 + ? document.tags.slice(0, 3).join(', ') + (document.tags.length > 3 ? '...' : '') + : ''; + + return ` +
+
+
${this._escapeHtml(document.title)}
+
+ Created: ${createdDate} + ${fileSize ? ` • Size: ${fileSize}` : ''} + ${tags ? ` • Tags: ${this._escapeHtml(tags)}` : ''} +
+
+ +
+ `; + } + + /** + * Render error message + */ + _renderError(message) { + const resultsDiv = document.getElementById('paperlessSearchResults'); + resultsDiv.innerHTML = ` +
+ ${this._escapeHtml(message)} +
+ `; + } + + /** + * Format file size for display + */ + _formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + /** + * Escape HTML to prevent XSS + */ + _escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} \ No newline at end of file diff --git a/public/managers/settings.js b/public/managers/settings.js index c2856f2..740a952 100644 --- a/public/managers/settings.js +++ b/public/managers/settings.js @@ -83,6 +83,17 @@ export class SettingsManager { exportSimpleDataBtn.addEventListener('click', () => this._exportSimpleData()); } + // Paperless integration + const testPaperlessConnection = document.getElementById('testPaperlessConnection'); + if (testPaperlessConnection) { + testPaperlessConnection.addEventListener('click', () => this._testPaperlessConnection()); + } + + const paperlessEnabled = document.getElementById('paperlessEnabled'); + if (paperlessEnabled) { + paperlessEnabled.addEventListener('change', (e) => this._togglePaperlessConfig(e.target.checked)); + } + document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { const tabId = btn.getAttribute('data-tab'); @@ -207,6 +218,17 @@ export class SettingsManager { document.getElementById('toggleWarranties').checked = finalVisibility.warranties; document.getElementById('toggleAnalytics').checked = finalVisibility.analytics; document.getElementById('toggleEvents').checked = finalVisibility.events; + + // Paperless integration settings + const integrationSettings = settings.integrationSettings || {}; + const paperlessSettings = integrationSettings.paperless || {}; + + document.getElementById('paperlessEnabled').checked = paperlessSettings.enabled || false; + document.getElementById('paperlessHost').value = paperlessSettings.hostUrl || ''; + document.getElementById('paperlessToken').value = paperlessSettings.apiToken || ''; + + this._togglePaperlessConfig(paperlessSettings.enabled || false); + // Card visibility toggles if (typeof window.renderCardVisibilityToggles === 'function') { window.renderCardVisibilityToggles(settings); @@ -259,6 +281,13 @@ export class SettingsManager { expired: document.getElementById('toggleCardWarrantiesExpired')?.checked !== false, active: document.getElementById('toggleCardWarrantiesActive')?.checked !== false } + }, + integrationSettings: { + paperless: { + enabled: document.getElementById('paperlessEnabled')?.checked || false, + hostUrl: document.getElementById('paperlessHost')?.value || '', + apiToken: document.getElementById('paperlessToken')?.value || '' + } } }; const dashboardSections = document.querySelectorAll('#dashboardSections .sortable-item'); @@ -818,4 +847,56 @@ export class SettingsManager { // Convert to CSV string return rows.map(row => row.join(',')).join('\n'); } + + _togglePaperlessConfig(enabled) { + const configDiv = document.getElementById('paperlessConfig'); + if (configDiv) { + configDiv.style.display = enabled ? 'block' : 'none'; + } + } + + async _testPaperlessConnection() { + const testBtn = document.getElementById('testPaperlessConnection'); + const statusSpan = document.getElementById('paperlessConnectionStatus'); + const hostUrl = document.getElementById('paperlessHost').value; + const apiToken = document.getElementById('paperlessToken').value; + + if (!hostUrl || !apiToken) { + statusSpan.textContent = 'Please enter both host URL and API token'; + statusSpan.className = 'connection-status error'; + return; + } + + this.setButtonLoading(testBtn, true); + statusSpan.textContent = 'Testing connection...'; + statusSpan.className = 'connection-status testing'; + + try { + const response = await fetch('/api/paperless/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostUrl, apiToken }), + credentials: 'include' + }); + + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage); + + const result = await response.json(); + + if (result.success) { + statusSpan.textContent = `✓ Connected successfully (${result.documentsCount} documents available)`; + statusSpan.className = 'connection-status success'; + globalThis.toaster.show('Paperless connection successful!', 'success'); + } else { + throw new Error(result.error || 'Connection failed'); + } + } catch (error) { + statusSpan.textContent = `✗ Connection failed: ${error.message}`; + statusSpan.className = 'connection-status error'; + globalThis.logError('Paperless connection test failed:', error.message); + } finally { + this.setButtonLoading(testBtn, false); + } + } } diff --git a/public/script.js b/public/script.js index 7f5d8e0..082e112 100644 --- a/public/script.js +++ b/public/script.js @@ -120,7 +120,7 @@ document.addEventListener('DOMContentLoaded', () => { // Acts as constructor for the app // will be called at the very end of the file - function initialize() { + async function initialize() { // Display demo banner if in demo mode if (window.appConfig?.demoMode) { document.getElementById('demo-banner').style.display = 'block'; @@ -293,7 +293,10 @@ document.addEventListener('DOMContentLoaded', () => { // Global state getAssets: () => assets, - getSubAssets: () => subAssets + getSubAssets: () => subAssets, + + // Paperless integration + paperlessManager }); // Initialize SettingsManager after DashboardManager is ready @@ -327,6 +330,7 @@ document.addEventListener('DOMContentLoaded', () => { addElementEventListeners(); setupDragIcons(); addShortcutEventListeners(); + setupPaperlessEventListeners(paperlessManager); registerServiceWorker(); } @@ -1920,4 +1924,66 @@ document.addEventListener('DOMContentLoaded', () => { siteTitleElem.addEventListener('click', () => goHome()); } } + + function setupPaperlessEventListeners(paperlessManager) { + // Asset modal search buttons + const searchPaperlessPhotos = document.getElementById('searchPaperlessPhotos'); + const searchPaperlessReceipts = document.getElementById('searchPaperlessReceipts'); + const searchPaperlessManuals = document.getElementById('searchPaperlessManuals'); + + // Sub-asset modal search buttons + const searchPaperlessSubPhotos = document.getElementById('searchPaperlessSubPhotos'); + const searchPaperlessSubReceipts = document.getElementById('searchPaperlessSubReceipts'); + const searchPaperlessSubManuals = document.getElementById('searchPaperlessSubManuals'); + + // Asset modal handlers + if (searchPaperlessPhotos) { + searchPaperlessPhotos.addEventListener('click', () => { + paperlessManager.openSearchModal((attachment) => { + return modalManager.attachPaperlessDocument(attachment, 'photo', false); + }); + }); + } + + if (searchPaperlessReceipts) { + searchPaperlessReceipts.addEventListener('click', () => { + paperlessManager.openSearchModal((attachment) => { + return modalManager.attachPaperlessDocument(attachment, 'receipt', false); + }); + }); + } + + if (searchPaperlessManuals) { + searchPaperlessManuals.addEventListener('click', () => { + paperlessManager.openSearchModal((attachment) => { + return modalManager.attachPaperlessDocument(attachment, 'manual', false); + }); + }); + } + + // Sub-asset modal handlers + if (searchPaperlessSubPhotos) { + searchPaperlessSubPhotos.addEventListener('click', () => { + paperlessManager.openSearchModal((attachment) => { + return modalManager.attachPaperlessDocument(attachment, 'photo', true); + }); + }); + } + + if (searchPaperlessSubReceipts) { + searchPaperlessSubReceipts.addEventListener('click', () => { + paperlessManager.openSearchModal((attachment) => { + return modalManager.attachPaperlessDocument(attachment, 'receipt', true); + }); + }); + } + + if (searchPaperlessSubManuals) { + searchPaperlessSubManuals.addEventListener('click', () => { + paperlessManager.openSearchModal((attachment) => { + return modalManager.attachPaperlessDocument(attachment, 'manual', true); + }); + }); + } + } }); \ No newline at end of file diff --git a/public/styles.css b/public/styles.css index 53321fa..45bb9fc 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1577,6 +1577,173 @@ input[type="date"][data-has-value="true"] { display: block; } +/* Integration Settings Styles */ +.integrations-section { + margin-top: 0.5rem; +} + +.integration-config { + padding: 1rem; + background: var(--file-label-color); + border-radius: var(--app-border-radius); + border: 1px solid var(--border-color); +} + +.connection-status { + font-size: 0.875rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: var(--app-border-radius); +} + +.connection-status.success { + color: var(--success-color); + background: rgba(16, 185, 129, 0.1); +} + +.connection-status.error { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} + +.connection-status.testing { + color: var(--primary-color); + background: rgba(37, 99, 235, 0.1); +} + +/* Paperless Document Styles */ +.paperless-document { + position: relative; +} + +.paperless-document::before { + content: ''; + position: absolute; + top: 4px; + right: 4px; + width: 16px; + height: 16px; + background: var(--primary-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: white; + content: 'P'; + font-weight: bold; +} + +.paperless-search-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; +} + +.paperless-search-content { + background: var(--background-color); + border-radius: var(--app-border-radius); + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.paperless-search-header { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.paperless-search-body { + padding: 1rem; + flex: 1; + overflow-y: auto; +} + +.paperless-search-input { + width: 100%; + padding: 0.75rem; + border: var(--app-border); + border-radius: var(--app-border-radius); + background: var(--file-label-color); + color: var(--text-color); + margin-bottom: 1rem; +} + +.paperless-results { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.paperless-document-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: var(--app-border-radius); + background: var(--file-label-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.paperless-document-item:hover { + border-color: var(--primary-color); + background: rgba(37, 99, 235, 0.05); +} + +.paperless-doc-info { + flex: 1; +} + +.paperless-doc-title { + font-weight: 500; + margin-bottom: 0.25rem; + color: var(--text-color); +} + +.paperless-doc-meta { + font-size: 0.875rem; + color: var(--text-light); +} + +.paperless-attach-btn { + padding: 0.5rem 1rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: var(--app-border-radius); + cursor: pointer; + font-size: 0.875rem; + transition: background 0.2s ease; +} + +.paperless-attach-btn:hover { + background: #1d4ed8; +} + +/* File source styling */ +.file-source { + font-size: 0.75rem; + color: var(--primary-color); + font-style: italic; + margin-top: 0.25rem; + display: block; +} + .toast-container { position: fixed; top: 3rem; diff --git a/server.js b/server.js index 437117c..35e54aa 100644 --- a/server.js +++ b/server.js @@ -61,6 +61,13 @@ const DEFAULT_SETTINGS = { active: true } }, + integrationSettings: { + paperless: { + enabled: false, + hostUrl: '', + apiToken: '' + } + } }; // Currency configuration from environment variables @@ -2102,6 +2109,216 @@ app.post('/api/settings', (req, res) => { } }); +// --- PAPERLESS NGX INTEGRATION --- + +// Test Paperless connection +app.post('/api/paperless/test-connection', async (req, res) => { + try { + const { hostUrl, apiToken } = req.body; + + if (!hostUrl || !apiToken) { + return res.status(400).json({ error: 'Host URL and API token are required' }); + } + + // Normalize the URL + const normalizedUrl = hostUrl.endsWith('/') ? hostUrl.slice(0, -1) : hostUrl; + + // Test connection by fetching documents count + const testResponse = await fetch(`${normalizedUrl}/api/documents/?page_size=1`, { + headers: { + 'Authorization': `Token ${apiToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!testResponse.ok) { + return res.status(400).json({ + success: false, + error: `Connection failed: ${testResponse.status} ${testResponse.statusText}` + }); + } + + const data = await testResponse.json(); + + res.json({ + success: true, + documentsCount: data.count || 0 + }); + } catch (error) { + debugLog('Paperless connection test error:', error); + res.status(500).json({ + success: false, + error: error.message || 'Connection test failed' + }); + } +}); + +// Search Paperless documents +app.get('/api/paperless/search', async (req, res) => { + try { + const config = getAppSettings(); + const paperlessConfig = config.integrationSettings?.paperless; + + if (!paperlessConfig?.enabled || !paperlessConfig?.hostUrl || !paperlessConfig?.apiToken) { + return res.status(400).json({ error: 'Paperless integration not configured' }); + } + + const query = req.query.q || ''; + const page = req.query.page || 1; + const pageSize = req.query.page_size || 25; + + const normalizedUrl = paperlessConfig.hostUrl.endsWith('/') + ? paperlessConfig.hostUrl.slice(0, -1) + : paperlessConfig.hostUrl; + + let searchUrl = `${normalizedUrl}/api/documents/?page=${page}&page_size=${pageSize}`; + if (query) { + searchUrl += `&query=${encodeURIComponent(query)}`; + } + + const paperlessResponse = await fetch(searchUrl, { + headers: { + 'Authorization': `Token ${paperlessConfig.apiToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!paperlessResponse.ok) { + return res.status(paperlessResponse.status).json({ + error: 'Failed to search Paperless documents' + }); + } + + const data = await paperlessResponse.json(); + res.json(data); + } catch (error) { + debugLog('Paperless search error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get Paperless document info +app.get('/api/paperless/document/:id/info', async (req, res) => { + try { + const config = getAppSettings(); + const paperlessConfig = config.integrationSettings?.paperless; + + if (!paperlessConfig?.enabled || !paperlessConfig?.hostUrl || !paperlessConfig?.apiToken) { + return res.status(400).json({ error: 'Paperless integration not configured' }); + } + + const normalizedUrl = paperlessConfig.hostUrl.endsWith('/') + ? paperlessConfig.hostUrl.slice(0, -1) + : paperlessConfig.hostUrl; + + const paperlessResponse = await fetch(`${normalizedUrl}/api/documents/${req.params.id}/`, { + headers: { + 'Authorization': `Token ${paperlessConfig.apiToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!paperlessResponse.ok) { + return res.status(paperlessResponse.status).json({ + error: 'Failed to fetch document info' + }); + } + + const data = await paperlessResponse.json(); + res.json(data); + } catch (error) { + debugLog('Paperless document info error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Test endpoint for debugging +app.get('/api/paperless/test', (req, res) => { + res.json({ message: 'Paperless routes are working!' }); +}); + +// Proxy Paperless document download +app.get('/api/paperless/document/:id/download', async (req, res) => { + console.log('🔍 PAPERLESS DOWNLOAD ROUTE HIT'); + console.log('🔍 Document ID:', req.params.id); + console.log('🔍 Full URL:', req.originalUrl); + + try { + console.log('🔍 Step 1: Getting config...'); + debugLog('Paperless download request received for document ID:', req.params.id); + const config = getAppSettings(); + console.log('🔍 Step 2: Config retrieved'); + + const paperlessConfig = config.integrationSettings?.paperless; + console.log('🔍 Step 3: Paperless config:', { + enabled: paperlessConfig?.enabled, + hasHost: !!paperlessConfig?.hostUrl, + hasToken: !!paperlessConfig?.apiToken + }); + + if (!paperlessConfig?.enabled || !paperlessConfig?.hostUrl || !paperlessConfig?.apiToken) { + console.log('🔍 ERROR: Paperless not configured properly'); + return res.status(400).json({ error: 'Paperless integration not configured' }); + } + + const normalizedUrl = paperlessConfig.hostUrl.endsWith('/') + ? paperlessConfig.hostUrl.slice(0, -1) + : paperlessConfig.hostUrl; + + const fetchUrl = `${normalizedUrl}/api/documents/${req.params.id}/download/`; + console.log('🔍 Step 4: About to fetch from:', fetchUrl); + + const paperlessResponse = await fetch(fetchUrl, { + headers: { + 'Authorization': `Token ${paperlessConfig.apiToken}` + } + }); + + console.log('🔍 Step 5: Paperless response status:', paperlessResponse.status); + + if (!paperlessResponse.ok) { + console.log('🔍 ERROR: Paperless response not OK:', paperlessResponse.status, paperlessResponse.statusText); + return res.status(paperlessResponse.status).json({ + error: 'Failed to download document' + }); + } + + console.log('🔍 Step 6: Setting headers...'); + // Forward the content type and other relevant headers + const contentType = paperlessResponse.headers.get('content-type'); + const contentLength = paperlessResponse.headers.get('content-length'); + const contentDisposition = paperlessResponse.headers.get('content-disposition'); + + if (contentType) res.setHeader('Content-Type', contentType); + if (contentLength) res.setHeader('Content-Length', contentLength); + if (contentDisposition) res.setHeader('Content-Disposition', contentDisposition); + + console.log('🔍 Step 7: Starting to stream response...'); + // Stream the response directly to the client using Web Streams API + const reader = paperlessResponse.body.getReader(); + + const pump = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + res.end(); + } catch (error) { + res.destroy(error); + } + }; + + await pump(); + console.log('🔍 Step 8: Response streamed successfully'); + } catch (error) { + console.log('🔍 ERROR in catch block:', error.message); + debugLog('Paperless document download error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Test notification endpoint app.post('/api/notification-test', async (req, res) => { if (DEBUG) { From beb2fe8f01e9e1a75385d1cd6004542ab6d3bdef Mon Sep 17 00:00:00 2001 From: abite Date: Fri, 13 Jun 2025 10:14:00 -0500 Subject: [PATCH 02/66] resolve Paperless NGX compressed PDF download issue fix: resolve Paperless NGX compressed PDF download issue - Remove Content-Encoding header forwarding in document proxy endpoint - Skip Content-Length header when content is compressed (br/gzip) - Let fetch API handle decompression automatically instead of double-decompression - Add debug logging for response headers sent to browser - Fixes issue where text PDFs (compressed) failed to download while scanned PDFs (uncompressed) worked - Browser now receives properly decompressed content without compression headers The root cause was forwarding Content-Encoding: br header while streaming already-decompressed content, causing browser to attempt decompression again and corrupting the download. Resolves: Paperless NGX integration document download for compressed content --- server.js | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 35e54aa..a95c73e 100644 --- a/server.js +++ b/server.js @@ -2275,6 +2275,12 @@ app.get('/api/paperless/document/:id/download', async (req, res) => { }); console.log('🔍 Step 5: Paperless response status:', paperlessResponse.status); + + // Debug all response headers + console.log('🔍 Response Headers:'); + for (const [key, value] of paperlessResponse.headers) { + console.log(` ${key}: ${value}`); + } if (!paperlessResponse.ok) { console.log('🔍 ERROR: Paperless response not OK:', paperlessResponse.status, paperlessResponse.statusText); @@ -2288,24 +2294,51 @@ app.get('/api/paperless/document/:id/download', async (req, res) => { const contentType = paperlessResponse.headers.get('content-type'); const contentLength = paperlessResponse.headers.get('content-length'); const contentDisposition = paperlessResponse.headers.get('content-disposition'); + const contentEncoding = paperlessResponse.headers.get('content-encoding'); + + console.log('🔍 Key headers:', { contentType, contentLength, contentDisposition, contentEncoding }); if (contentType) res.setHeader('Content-Type', contentType); - if (contentLength) res.setHeader('Content-Length', contentLength); if (contentDisposition) res.setHeader('Content-Disposition', contentDisposition); + + // Don't forward Content-Length or Content-Encoding when compressed + // Let the browser handle the decompressed content length + if (contentLength && !contentEncoding) { + res.setHeader('Content-Length', contentLength); + console.log('🔍 Setting Content-Length:', contentLength); + } else if (contentEncoding) { + console.log('🔍 Skipping Content-Length and Content-Encoding due to compression:', contentEncoding); + } + + // Debug: Log all headers we're sending to the browser + console.log('🔍 Headers being sent to browser:'); + const responseHeaders = res.getHeaders(); + for (const [key, value] of Object.entries(responseHeaders)) { + console.log(` ${key}: ${value}`); + } console.log('🔍 Step 7: Starting to stream response...'); // Stream the response directly to the client using Web Streams API const reader = paperlessResponse.body.getReader(); + let totalBytes = 0; + let chunkCount = 0; const pump = async () => { try { while (true) { const { done, value } = await reader.read(); - if (done) break; + if (done) { + console.log(`🔍 Streaming complete: ${chunkCount} chunks, ${totalBytes} bytes total`); + break; + } + chunkCount++; + totalBytes += value.length; + console.log(`🔍 Chunk ${chunkCount}: ${value.length} bytes (total: ${totalBytes})`); res.write(value); } res.end(); } catch (error) { + console.log('🔍 Streaming error:', error.message); res.destroy(error); } }; From 438026cdafe621a17167fc1f7ef5513ecd1d0040 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:08:59 -0700 Subject: [PATCH 03/66] Paperless integration reorg --- public/script.js | 73 ++----------------- server.js | 8 +- .../integrations/paperless.js | 69 +++++++++++++++++- 3 files changed, 80 insertions(+), 70 deletions(-) rename public/managers/paperlessManager.js => src/integrations/paperless.js (82%) diff --git a/public/script.js b/public/script.js index 082e112..9970365 100644 --- a/public/script.js +++ b/public/script.js @@ -13,6 +13,10 @@ new GlobalHandlers(); // Import file upload module import { initializeFileUploads, handleFileUploads } from '/src/services/fileUpload/index.js'; import { formatFileSize } from '/src/services/fileUpload/utils.js'; + +// Import Integrations +import { PaperlessIntegration } from '/src/integrations/paperless.js'; + // Import asset renderer module import { initRenderer, @@ -294,9 +298,6 @@ document.addEventListener('DOMContentLoaded', () => { // Global state getAssets: () => assets, getSubAssets: () => subAssets, - - // Paperless integration - paperlessManager }); // Initialize SettingsManager after DashboardManager is ready @@ -327,10 +328,12 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Initialize Paperless integration + paperlessIntegration = new PaperlessIntegration(modalManager); + addElementEventListeners(); setupDragIcons(); addShortcutEventListeners(); - setupPaperlessEventListeners(paperlessManager); registerServiceWorker(); } @@ -1924,66 +1927,4 @@ document.addEventListener('DOMContentLoaded', () => { siteTitleElem.addEventListener('click', () => goHome()); } } - - function setupPaperlessEventListeners(paperlessManager) { - // Asset modal search buttons - const searchPaperlessPhotos = document.getElementById('searchPaperlessPhotos'); - const searchPaperlessReceipts = document.getElementById('searchPaperlessReceipts'); - const searchPaperlessManuals = document.getElementById('searchPaperlessManuals'); - - // Sub-asset modal search buttons - const searchPaperlessSubPhotos = document.getElementById('searchPaperlessSubPhotos'); - const searchPaperlessSubReceipts = document.getElementById('searchPaperlessSubReceipts'); - const searchPaperlessSubManuals = document.getElementById('searchPaperlessSubManuals'); - - // Asset modal handlers - if (searchPaperlessPhotos) { - searchPaperlessPhotos.addEventListener('click', () => { - paperlessManager.openSearchModal((attachment) => { - return modalManager.attachPaperlessDocument(attachment, 'photo', false); - }); - }); - } - - if (searchPaperlessReceipts) { - searchPaperlessReceipts.addEventListener('click', () => { - paperlessManager.openSearchModal((attachment) => { - return modalManager.attachPaperlessDocument(attachment, 'receipt', false); - }); - }); - } - - if (searchPaperlessManuals) { - searchPaperlessManuals.addEventListener('click', () => { - paperlessManager.openSearchModal((attachment) => { - return modalManager.attachPaperlessDocument(attachment, 'manual', false); - }); - }); - } - - // Sub-asset modal handlers - if (searchPaperlessSubPhotos) { - searchPaperlessSubPhotos.addEventListener('click', () => { - paperlessManager.openSearchModal((attachment) => { - return modalManager.attachPaperlessDocument(attachment, 'photo', true); - }); - }); - } - - if (searchPaperlessSubReceipts) { - searchPaperlessSubReceipts.addEventListener('click', () => { - paperlessManager.openSearchModal((attachment) => { - return modalManager.attachPaperlessDocument(attachment, 'receipt', true); - }); - }); - } - - if (searchPaperlessSubManuals) { - searchPaperlessSubManuals.addEventListener('click', () => { - paperlessManager.openSearchModal((attachment) => { - return modalManager.attachPaperlessDocument(attachment, 'manual', true); - }); - }); - } - } }); \ No newline at end of file diff --git a/server.js b/server.js index a95c73e..94d635a 100644 --- a/server.js +++ b/server.js @@ -442,8 +442,9 @@ app.use(BASE_PATH + '/styles.css', express.static('public/styles.css')); app.use(BASE_PATH + '/script.js', express.static('public/script.js')); // Module files (need to be accessible for imports) -app.use(BASE_PATH + '/src/services/fileUpload', express.static('src/services/fileUpload')); -app.use(BASE_PATH + '/src/services/render', express.static('src/services/render')); +app.use(BASE_PATH + '/src/services', express.static('src/services')); +// app.use(BASE_PATH + '/src/services/fileUpload', express.static('src/services/fileUpload')); +// app.use(BASE_PATH + '/src/services/render', express.static('src/services/render')); // Serve Chart.js from node_modules app.use(BASE_PATH + '/js/chart.js', express.static('node_modules/chart.js/dist/chart.umd.js')); @@ -453,6 +454,9 @@ app.use(BASE_PATH + '/Images', express.static('data/Images')); app.use(BASE_PATH + '/Receipts', express.static('data/Receipts')); app.use(BASE_PATH + '/Manuals', express.static('data/Manuals')); +// INTEGRATIONS +app.use(BASE_PATH + '/src/integrations', express.static('src/integrations')); + // Protected API routes app.use('/api', (req, res, next) => { console.log(`API Request: ${req.method} ${req.path}`); diff --git a/public/managers/paperlessManager.js b/src/integrations/paperless.js similarity index 82% rename from public/managers/paperlessManager.js rename to src/integrations/paperless.js index b481590..d836d2f 100644 --- a/public/managers/paperlessManager.js +++ b/src/integrations/paperless.js @@ -2,13 +2,26 @@ * PaperlessManager handles all Paperless NGX integration functionality * including document search, attachment, and configuration management */ -export class PaperlessManager { - constructor() { +export class PaperlessIntegration { + constructor(modalManager) { this.searchModal = null; this.currentSearchQuery = ''; this.searchTimeout = null; this.DEBUG = false; this._createSearchModal(); + + this.modalManager = modalManager; + + // Asset modal search buttons + this.searchPaperlessPhotos = document.getElementById('searchPaperlessPhotos'); + this.searchPaperlessReceipts = document.getElementById('searchPaperlessReceipts'); + this.searchPaperlessManuals = document.getElementById('searchPaperlessManuals'); + + // Sub-asset modal search buttons + this.searchPaperlessSubPhotos = document.getElementById('searchPaperlessSubPhotos'); + this.searchPaperlessSubReceipts = document.getElementById('searchPaperlessSubReceipts'); + this.searchPaperlessSubManuals = document.getElementById('searchPaperlessSubManuals'); + this.setupPaperlessEventListeners(); } /** @@ -272,6 +285,58 @@ export class PaperlessManager { } } + setupPaperlessEventListeners() { + // Asset modal handlers + if (searchPaperlessPhotos) { + searchPaperlessPhotos.addEventListener('click', () => { + this.openSearchModal((attachment) => { + return this.modalManager.attachPaperlessDocument(attachment, 'photo', false); + }); + }); + } + + if (searchPaperlessReceipts) { + searchPaperlessReceipts.addEventListener('click', () => { + this.openSearchModal((attachment) => { + return this.modalManager.attachPaperlessDocument(attachment, 'receipt', false); + }); + }); + } + + if (searchPaperlessManuals) { + searchPaperlessManuals.addEventListener('click', () => { + this.openSearchModal((attachment) => { + return this.modalManager.attachPaperlessDocument(attachment, 'manual', false); + }); + }); + } + + // Sub-asset modal handlers + if (searchPaperlessSubPhotos) { + searchPaperlessSubPhotos.addEventListener('click', () => { + this.openSearchModal((attachment) => { + return this.modalManager.attachPaperlessDocument(attachment, 'photo', true); + }); + }); + } + + if (searchPaperlessSubReceipts) { + searchPaperlessSubReceipts.addEventListener('click', () => { + this.openSearchModal((attachment) => { + return this.modalManager.attachPaperlessDocument(attachment, 'receipt', true); + }); + }); + } + + if (searchPaperlessSubManuals) { + searchPaperlessSubManuals.addEventListener('click', () => { + this.openSearchModal((attachment) => { + return this.modalManager.attachPaperlessDocument(attachment, 'manual', true); + }); + }); + } + } + /** * Render search results in the modal */ From a1e9697a2fff2332883f5da8361a9b8a6e5cb17a Mon Sep 17 00:00:00 2001 From: abite Date: Fri, 13 Jun 2025 13:07:03 -0500 Subject: [PATCH 04/66] fix(security): prevent API token exposure to frontend in settings endpoint fix(security): prevent API token exposure to frontend in settings endpoint CRITICAL SECURITY FIX: Paperless NGX API tokens were being exposed to the frontend through the /api/settings endpoint, creating a serious security vulnerability. Changes: - Backend: Sanitize GET /api/settings to replace real API tokens with placeholder - Backend: Enhanced test connection endpoint to handle both saved and new tokens - Backend: Smart settings save preserves existing tokens when placeholder received - Frontend: Show placeholder for saved tokens instead of exposing real values - Frontend: Improved UX with dynamic placeholders and token field handlers - Integration: Updated isEnabled() method to recognize placeholder tokens Security Impact: - BEFORE: Real API tokens sent to frontend (HIGH RISK) - AFTER: Only placeholder (*********************) sent to frontend (SECURE) Features: - Users can test connections without re-entering saved tokens - Clear feedback when tokens are saved vs need to be entered - Backward compatible with existing configurations - Maintains full Paperless integration functionality Files modified: - server.js: API endpoints sanitization and token handling - public/managers/settings.js: Frontend token management and UX - src/integrations/paperless.js: Integration compatibility fix --- public/managers/settings.js | 68 ++++++++++++++++++++++++++++++++--- server.js | 50 ++++++++++++++++++++++---- src/integrations/paperless.js | 7 +++- 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/public/managers/settings.js b/public/managers/settings.js index 740a952..56959c0 100644 --- a/public/managers/settings.js +++ b/public/managers/settings.js @@ -94,6 +94,13 @@ export class SettingsManager { paperlessEnabled.addEventListener('change', (e) => this._togglePaperlessConfig(e.target.checked)); } + // Handle token field changes + const paperlessToken = document.getElementById('paperlessToken'); + if (paperlessToken) { + paperlessToken.addEventListener('input', (e) => this._handleTokenFieldChange(e.target)); + paperlessToken.addEventListener('focus', (e) => this._handleTokenFieldFocus(e.target)); + } + document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { const tabId = btn.getAttribute('data-tab'); @@ -225,7 +232,26 @@ export class SettingsManager { document.getElementById('paperlessEnabled').checked = paperlessSettings.enabled || false; document.getElementById('paperlessHost').value = paperlessSettings.hostUrl || ''; - document.getElementById('paperlessToken').value = paperlessSettings.apiToken || ''; + + // Handle API token - show placeholder if token exists, empty if not + const tokenField = document.getElementById('paperlessToken'); + if (paperlessSettings.apiToken) { + if (paperlessSettings.apiToken === '*********************') { + // Already a placeholder, keep it + tokenField.value = paperlessSettings.apiToken; + tokenField.setAttribute('data-has-saved-token', 'true'); + tokenField.placeholder = 'Saved token hidden - focus to enter new token'; + } else { + // This shouldn't happen with the backend fix, but handle it for safety + tokenField.value = '*********************'; + tokenField.setAttribute('data-has-saved-token', 'true'); + tokenField.placeholder = 'Saved token hidden - focus to enter new token'; + } + } else { + tokenField.value = ''; + tokenField.removeAttribute('data-has-saved-token'); + tokenField.placeholder = 'Enter your Paperless API token'; + } this._togglePaperlessConfig(paperlessSettings.enabled || false); @@ -855,14 +881,45 @@ export class SettingsManager { } } + _handleTokenFieldFocus(tokenField) { + // Clear placeholder when user focuses to enter new token + if (tokenField.value === '*********************') { + tokenField.value = ''; + tokenField.placeholder = 'Enter new API token to replace existing one'; + } + } + + _handleTokenFieldChange(tokenField) { + // Reset connection status when token changes + const statusSpan = document.getElementById('paperlessConnectionStatus'); + if (statusSpan) { + statusSpan.textContent = ''; + statusSpan.className = 'connection-status'; + } + + // Update data attribute based on whether we have content + if (tokenField.value && tokenField.value !== '*********************') { + tokenField.removeAttribute('data-has-saved-token'); + } + } + async _testPaperlessConnection() { const testBtn = document.getElementById('testPaperlessConnection'); const statusSpan = document.getElementById('paperlessConnectionStatus'); const hostUrl = document.getElementById('paperlessHost').value; - const apiToken = document.getElementById('paperlessToken').value; + const tokenField = document.getElementById('paperlessToken'); + const apiToken = tokenField.value; + const hasSavedToken = tokenField.hasAttribute('data-has-saved-token'); + + if (!hostUrl) { + statusSpan.textContent = 'Please enter host URL'; + statusSpan.className = 'connection-status error'; + return; + } - if (!hostUrl || !apiToken) { - statusSpan.textContent = 'Please enter both host URL and API token'; + // Check if we need a new token + if (!apiToken || (apiToken === '*********************' && !hasSavedToken)) { + statusSpan.textContent = 'Please enter API token'; statusSpan.className = 'connection-status error'; return; } @@ -872,6 +929,7 @@ export class SettingsManager { statusSpan.className = 'connection-status testing'; try { + // Send the token even if it's the placeholder - backend will handle it const response = await fetch('/api/paperless/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -885,7 +943,7 @@ export class SettingsManager { const result = await response.json(); if (result.success) { - statusSpan.textContent = `✓ Connected successfully (${result.documentsCount} documents available)`; + statusSpan.textContent = `✓ ${result.message || `Connected successfully (${result.documentsCount} documents available)`}`; statusSpan.className = 'connection-status success'; globalThis.toaster.show('Paperless connection successful!', 'success'); } else { diff --git a/server.js b/server.js index 94d635a..2cf15f9 100644 --- a/server.js +++ b/server.js @@ -2088,11 +2088,20 @@ app.post('/api/import-assets', upload.single('file'), (req, res) => { } }); -// Get all settings +// Get all settings (sanitized for frontend) app.get('/api/settings', (req, res) => { try { const appSettings = getAppSettings(); - res.json(appSettings); + + // Sanitize sensitive data before sending to frontend + const sanitizedSettings = { ...appSettings }; + + // Replace API tokens with placeholder if they exist + if (sanitizedSettings.integrationSettings?.paperless?.apiToken) { + sanitizedSettings.integrationSettings.paperless.apiToken = '*********************'; + } + + res.json(sanitizedSettings); } catch (err) { res.status(500).json({ error: 'Failed to load settings' }); } @@ -2105,6 +2114,17 @@ app.post('/api/settings', (req, res) => { // Update settings with the new values const updatedConfig = { ...config, ...req.body }; + // Handle sensitive data preservation + // If the API token is the placeholder, keep the existing token + if (updatedConfig.integrationSettings?.paperless?.apiToken === '*********************') { + if (config.integrationSettings?.paperless?.apiToken) { + updatedConfig.integrationSettings.paperless.apiToken = config.integrationSettings.paperless.apiToken; + } else { + // If there's no existing token, remove the placeholder + updatedConfig.integrationSettings.paperless.apiToken = ''; + } + } + const configPath = path.join(DATA_DIR, 'config.json'); fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); res.json({ success: true }); @@ -2120,8 +2140,25 @@ app.post('/api/paperless/test-connection', async (req, res) => { try { const { hostUrl, apiToken } = req.body; - if (!hostUrl || !apiToken) { - return res.status(400).json({ error: 'Host URL and API token are required' }); + if (!hostUrl) { + return res.status(400).json({ error: 'Host URL is required' }); + } + + let tokenToUse = apiToken; + + // If no token provided or it's the placeholder, try to use saved token + if (!apiToken || apiToken === '*********************') { + const config = getAppSettings(); + const savedToken = config.integrationSettings?.paperless?.apiToken; + + if (!savedToken) { + return res.status(400).json({ error: 'No API token available. Please enter a new token.' }); + } + + tokenToUse = savedToken; + debugLog('Using saved API token for connection test'); + } else { + debugLog('Using provided API token for connection test'); } // Normalize the URL @@ -2130,7 +2167,7 @@ app.post('/api/paperless/test-connection', async (req, res) => { // Test connection by fetching documents count const testResponse = await fetch(`${normalizedUrl}/api/documents/?page_size=1`, { headers: { - 'Authorization': `Token ${apiToken}`, + 'Authorization': `Token ${tokenToUse}`, 'Content-Type': 'application/json' } }); @@ -2146,7 +2183,8 @@ app.post('/api/paperless/test-connection', async (req, res) => { res.json({ success: true, - documentsCount: data.count || 0 + documentsCount: data.count || 0, + message: `Connection successful! Found ${data.count || 0} documents.` }); } catch (error) { debugLog('Paperless connection test error:', error); diff --git a/src/integrations/paperless.js b/src/integrations/paperless.js index d836d2f..ab11f30 100644 --- a/src/integrations/paperless.js +++ b/src/integrations/paperless.js @@ -35,7 +35,12 @@ export class PaperlessIntegration { const settings = await response.json(); const paperlessConfig = settings.integrationSettings?.paperless; - return paperlessConfig?.enabled && paperlessConfig?.hostUrl && paperlessConfig?.apiToken; + + // Check if enabled and has host URL + // For API token, accept both actual tokens and the placeholder (indicates saved token exists) + return paperlessConfig?.enabled && + paperlessConfig?.hostUrl && + (paperlessConfig?.apiToken && paperlessConfig.apiToken.length > 0); } catch (error) { if (this.DEBUG) console.error('Failed to check Paperless config:', error); return false; From c6804eaf0ae4ba9ff15d9ba0ae72025901e00c0b Mon Sep 17 00:00:00 2001 From: abite Date: Fri, 13 Jun 2025 13:44:03 -0500 Subject: [PATCH 05/66] feat: implement Paperless NGX document indication with visual badges - Add Paperless NGX icon badges to identify documents from Paperless integration - Implement badge detection using isPaperlessDocument flag in file metadata - Update setupExistingFilePreview to accept and process file info parameters - Modify createPhotoPreview and createDocumentPreview to support badge rendering - Pass file metadata through all modal manager setupExistingFilePreview calls - Position badges in top-left corner (14px top, 8px left) of file preview items - Style badges as clean 20x20px icons with drop-shadow for contrast - Ensure badge persistence when reopening edit modals for existing assets - Support badges in both asset and sub-asset file previews across all types - Maintain consistent badge appearance in modal and asset detail views Paperless documents are now clearly identified with the Paperless NGX logo badge, providing immediate visual recognition of document source across all file preview contexts in the application. Files modified: - public/styles.css: Badge styling and positioning - src/services/render/previewRenderer.js: Badge rendering logic - public/managers/modalManager.js: File info parameter passing --- public/assets/paperless-ngx.png | Bin 0 -> 9704 bytes public/managers/modalManager.js | 126 +++++++++++++++---------- public/styles.css | 37 +++++--- src/services/render/assetRenderer.js | 36 +++++-- src/services/render/previewRenderer.js | 20 +++- 5 files changed, 147 insertions(+), 72 deletions(-) create mode 100644 public/assets/paperless-ngx.png diff --git a/public/assets/paperless-ngx.png b/public/assets/paperless-ngx.png new file mode 100644 index 0000000000000000000000000000000000000000..a9ac9cb956f43e612a5f03f6a9af9077ea0356af GIT binary patch literal 9704 zcmX9^c|6q5|DQeFTkHBbO6|)1N#%-E*pMq%s3;;g3CVr!Dy4FlB65`@sf6&!xsixU zg;2DP+;@(}9`E1y{{Gms70(D6X`=?@ z_#T_&)3A$AC9Z9&}1ed4Du`~Xmc@gd0NlW8a{087+YLu5;RsXR#6 z81&YKPU8k3k2ErfSR~i0q5u${3h26Ult7VY3%@zP$Hexmzfa()MKeAKzV%Agi8{eAV02|2yHkq*8!1fQR49^{_ypIBT zy!o?8_#b-a{OSMkJyJbO&hlXZC=203IHo3S?iL5Y=yX93l8FOAdM4<7Qq*>j*^$2? zyO!NP!q+lsPHoDSYAW#YKtXZIn?GYT<%~uhczETZR15@|ChtCkNJ7+~u9W+f?dxL^ zIw}M#;XGxL&ULqf6?XE+|8EPx_JC0h^UMi2xUQbbo|x)Lhk|-kpQJR|bVv&TrK;A7 zhMfPV7q{7&DyXp0pN7DP?3Gf=BhfJ`Kjo2Hu-EN*nZUgNxlfpSk_}XmxlDP0mwKj zTgi%t*r=|G>}hDHN_TigkV@=DJwIs0`u`-gHTY&u^xQXxdZ&0ck0RAxnZ-E)=hFbV z?Og72A)Z|83V_&<^ME#AJaVTvOBQO)pvtz|Lj+lc79wUQwzN{$suKlQH7K{OPJduQ z@0?K~LMcmprsP^w)Z&bwb2ue!j-TQIloPteNJKn22V#}*Dfj7W`2u;S%fT1qvQzvO z5B;^YTgNXPfGM*|M@>jIS2$3193nmbuNvc}BHK*>fY;U;a~?1JVj#qRZ)ggcMVlYH z74Ad)WzXn+C*UewJ&ajN0EX}THzN2o_EC8husK-3EPK3I77u;GS#q3wVBC=)11Kg} zB$g{T&KYrnJY9n=sQqW{&I8qE-SyS8hCCooaD>;9PG7l$1Edk%bfmB(+6TroVbgS^ z;P=F;^8)~Q3p;b$+F_q;0kh5lxt4pN4(gm&(2DJljtJ%MPd<$X^6#AoF5a?Zi;&mv zp+LL+kzcezrbX1lRT&IODNn3qiI8{hgO8FL8#2>fk1xJ}HlP~s79nEA_GWp7<#xxP zL4$T9&sE0IXjeH@hl32w7q9sGLFeO7b^%QkAo1 z-x-6y-#`=94`%fS;P~?lf?0nI0PvTtiw;mk{kK!hDev`clXvFCO%L0e!B=Qs2;|mP zN1eAq&?u-;ET83rZE=5Vuop2Tk-rBStN2U3XX^mdr?O+Y1d97Ow9P}haW$>E z4iz{6DXO{a*sp$&XErVOi319kdN35~R{Mf1)qoQW$G28Q!8f(g5;1|Eu+%q7OI&8P zjCO$mGn9`h8tX0kVdJ9RhWNj23Y0`aV7Wh?u5|C0J)r>FLsDmi*y)FcU^fa_3Z>Ke zW1w=}4lYUxq5$cx{cki)QUV>=AAlQr_0by?4uI z`(Xje8)A~J@C80NG}=lqm<54z+2K*%5QA|4+Wq`F3@8Q^yOOp0PX{f4nhK!W&ux9Q z{gDn{PNRTq@ehlM3JXL7_NUElDd?}ufnDOj>EB?Mus$bnBGg6mrmY>OUWU=?TgHgR z9g`!duTVS6@cCUu6UNC5fP{`$xy}Eivi;OhXqWSfze$UVFbg zdN@;-s=Wq{KG(`}c-(`xJNpg!Gr zgjg3#*ILXMO(LPBEX!LfwD47C@j7#7?YEi)0I8vQfQ^`Jwp?s2MBwyi$!R(grktQX z3;xgP8EXJvK|qozU|veu2lXxp(kwa%Nmf39hyfgYH=^LcfBSZC8(LbY6XD3u`JvC= zXJ%Y%(3Iwkar{#Sbrvim@)Sq5e!u#Syf@@C+9!nq6^h=A&yX4Y zSNWZ%cA7l`Eg+@Fa&gg$U94>)cII0hV!#9b{xic`Jiz9^2~$&YxQ;%|Cf^0W(2m2=T z2SN5K8Ah4LTZS*WfME5vIgjNyFPn0PUm74fTv|px8)EN2f5L{~iDodWy&S;1OJaa& zmszw@>1Z3WEtGwMTP<_X2Ur@JKyt_KY#1psx`NEQek+qQ9=SxP#Cj(oFFyim0?h26 zM)AhaiWp>QTBe~OEQedo`#kkxqrabLWSbXmtLSqe}>71=5E&krJD zMgR!dG9~$Xix8-tgsxHiz_b5p!^vKR+-}f2jllReR=%Nk_PIHtfd*o6t%M5epl#mN zWymDkcb)wIL~T5x49MwF?;;y=!im0GWhEPO50q#FPY67L!IZpk<|3;u5e&z`moS~- zNPP@rm-}*@&lwu4Nfz|KhMCu_+g^I*x{*0l$(Aee6g@;^|1?4ZOI5yf^1bhT`}RPm zin3+0j-kUU7&Ic_)-|KQ*{tZi@tLdx(deM4oG7YjRu!^18Lcc`d}_D$S~;nD+rt*4 zA|1Iw;~YEJhuQSSJyKIz7PqCj_LlVHRb-^25MYj9nrspTxv|4v^9b#^s|co-3tR|B zvV)7#^>mv{_}f@S@)x$>=_v5pjZO>OmD%{r#uFQ4&)wlfZ#vnFq9zR?nJT10-KCJ1 zV}!f@vvWvrl?T|HwN)(e`eNTvcD`6Ja?EBon1jZErX0O_`fJx^@=i;B6m{oe53v53 z88TO@l0UsT`7;+I{JsH6ex?Foa#+!-Lq6*ma=^;Cca^KPoI%!qDNbn=Wy@oF#;S^R zAu-JZT)d(k3&6yOWGf(qUKd!E~sJyTzsbc8`wm;)4sw&U1l) z-)h|@cP&_2NoVG|rTTaS%A^atKhhW?$(NFBiB?(S>ge6U4x3%kvY$qg5{9#l)h>AB# zrAdJIdub7cqC~Ina4i>dvW+X?*JL#ni4srmLh^X8Go>D6qB8!~TGS^wH&2elqdMH_ zI@hf*loH>?`y57Ht=IvA)5Ysdfk;@`w9V1p7p1Tfmr+Ls-ul^uXfU!r8?k)jOJBVB zP#`S>mGShLqn!7yg9q4_$Lo+(*kk)S4b3iIdA7pil8e;*pyMCBW)M!F7N^{)MM6?J zGSVGTXg&yhJ ziUpTOqK>X~9y!gM@o-4T=G}tM^?NG7I-5nCJei@_r8dW#7s<{sw6p>#-=9Rh6y|pf z{Sb^;R)+*5SGmDxsI%^{^l=yY+##~`$xo;ZuPsM{&fw6C-*OwZ6&^DLoBUs_{Urug zCY;1x@`-|%qhVglaibsq!mYvW(ySfCsDI)PamxSNFW&PX7A&(QOoS6$VI@7Z!PQzB zc2|)Bh>EXDimzw$ilqoY+aW-%JMhz>^fra$-cIV z?~tM9S}urFB(oP)9Cup2(t!Ky8g1J2pS|GEdF!S?wI!wez`w_f`*0HPKX^K; zfR!Ux)s~!~EDpA@-BTh!%F5N(#Ywdi`Y^fHt~ne@_Q6net)>o%bpB|rHuPMN%v_Df z-U^uWlhyP9CMEff(_H zgm0mOQ`m9NUd*gVf~2FX7#}$netcS%{g&Y7ZpR5E9WIXQ|3_QJM8cwKynMiz^h%s^ zPt=&sfgREOZxat){e6=fH~8K?bR}>9`XOJ7*owidjr1gY4CQ-BdvUy5skST&Ul=z1 ziF`C*2k`C}XG{1tl|I6Ow3bK6`*u(;U;Cqoy3}~Bc1FB^{^FQp_%@lQ=*5*3VjWq& ztxy%2{&)~@-?-%co#R;FgN8|%Lt3Y-5YB8F`8FKs3W_utcGD~d=HA>Max)feJ}XjC z>OoFPtYWKDw=6OM@lcf-N9HZ=&HGyxkN>}Ag6RD&V5<^AyTuN(7!{DBQG($9MDBL_ zTw6rFXMOn|MM~!-_FwBeeO!kgzFgJ$b81FMN(F?~{rg(Ma*OcS?FZSQ zf)`dzjKursGQ0>N#H^mx25*m*7Q~hmx z49&gyU(laJa9cY5anqFo@PDtqTFBKvksj3=hiev%r-c&nGi(likXCw`x4wOD=p!I) z^|1Kzd0zV|lgvl8Z<8GpGy)}5mtiWekpn(OkXY@zqpVZ7=r zVGqdL>EPXvW*kFZy*M=VA~H_c0HicBlsp)=U80@p<90((dPR7cT7wWY5BMG;c#t73 z#l4|9Qbf`MDLE0C+721et|uCmn=FjA&w7hr4J?{2vqw{oKaMR`?=s*Tnd}*;yUuCnft;If z1_!uLP{}Gy+G1jTabNxPIUtkGv|9WLs@SoOnmenQ$FfVi^4y1RjTRO)g>!?yD}m)c zI)#(Ei3{mcw0FftRNh>vrK)RYvY_bupsUWkk>q6$x9z;a+4>oMnZ zSll_vWtmZ+&-ZfE182D?;Tg-0nkdrmgX$r3u-A8)z1AL5KmZ-|cM0SGJ`ngUdj8MI zX==)sZakO^ARJ}yp_khGqA27`O+n@30R$GHs?$fV6L$2Xrsqnk%X~w96%#Q#(kFzhX5WZXyG4;4!v;K1-40 zuc@mr>~W4u@9f2!b-GTa5*QE#k{Lx_eg#&fF&p4I_CSF=Ck5oYIK4}UJ7QeySFer#bjo<# zn){RobkwNw@f*bMyICz11#~4^9`(D+i zt@*b2=RpYRh7x>geDJXI0NX=^?A)OazH%RFZyef`x1&gf^F|gexh>P_G0y_N0JU8x z{5rFIEL_SRRsGwb{{{|J&2~6-DY8u^>3}r3F;diyo~uvv$2*4i%<1)SX5D!wjRk9A zY9K?CkBA|?tVQW`HQ)Bl&|g4N_Kc0}y~2t6;CVcJb!tT29c8J2iOx9r14X+1aL=A& zgN=$*+ip}`8Y-zFpFhBvim&{rh4~tmO}*Uyd1dePW~spM?v%Hn;%HAEUm!A_LAds> zBuG);>+UF$$M?e5a6>C@!50hCe23;H>*DR)2w>xsnr&|OrkEY2nCrJd9wzf%!?h2& zHfbrSse0q#!+$2clJ`fpnON+#;#_JqDycO`RI}LyPtpKM#3{qDdu1fvJ3%VuP&jt? zWi&8ZeQHj&vNi#E0{$6MZK$l&4q(O z=4!25+&n+t^O>>*g6H=qIJy->rX)eWF+qYTD$Ina?ZSQ zas>AeR9q_c&!5C_owo`H0Dl->)lHlvLix@N2&p_8(wI9a{11@F;C>Y2mL`qdD2>*r z!{~dpYsZmOxx$+N9>9EUN8^5YT-qze_m!?j0hF|$cm9`!H8q96YxOe(QnzZqC7O5! zdHA;&+sXm*V|)_o<}R-U(*Aij!snOZ@vB$>mdx2kpK3eFFQzk;N(EIxq9WDF`Yj%OZ7|k4^jYo9Oh|=$&mxAN`E-Ts@}RnS1Y_-5@sm zxoky?S(R#tGQYx1qv{v6t?ut(%wBgt zpmf&zq7?DY27eO$(UjpCSg@;A0Iu0zTuL7fmJHFkR#E(q9CUi0%Rt9F^2z4@);cGM zIq37jz0YC|?hGV&B=n_-g|#)aaZNqYwr>B$tDfN*{`Re9Hx?vZih4z(qk1oU^8npF zn`h96WJE2(fAjhKTxmSV)T>=#BFS#3=?M!%k5Zpe_2*x)yK0{Fu_$ zQvWMuj1$8#-e_P)xltIbZy`?63p9&H)LIC^r3%}MlPIv@bgDS?Ltv4Z=aXd?ecd2b z{|t(vNIfCzg`LyHd76n++Umdk;bQeZM0vWR!jBbb>-=-_C&Nc&t)-2=C%iw_#^pXQYR zv42OjQuYjnGOVjNY_7}zNeWH!m^7D$rKX-?u5JO~$vr6N4*WkIv;Zkh3q~}&NOVRV zye~>LSa^(_{Uk^ouEVCGD%O3+jA-BJ#-PO$&HUNj(*2Kv z=fbtectIlOwPb;$gNR!8Q<3W_Rp07T8$a)9Y9$Ctf>7cok#oEo&a@-LF76x&r|G?X zDX)UA<=!L+N`wA|!t8O6ZwIlvN|rlQGFGQ%;&9xc+LCw5queK%XQ#o(irDP&9#O&N zmur8t`E*=$!D~Hvjwu!LpfZY@F+}SCD{qIy1|qyh;v$c5Ed7_BZ$58B4Q_*5*&nfW zPV>bAI|m|P)jm~uK%zT*AQ8ux8h---HIi1amkNa?M#*4}r7qjzysQvNJcvEa=qZSmLh z_3W>}>AB6d`Jg!lzmEf~Wj{!~#R=;J|Ja4jpmLwOtM8D{>t=KGUT0XJ4~-(O)ULPL z{*+H&*|CGh%a{fpkkk`3aPZ-LvROCt;o9zjOQ+6lbjIj--y0&gcRV&_f`>7{FCKq zz8X>F{Eh3007xh+5JzR1MTlZ7j@4&95z@h0_lCYBu&0VPtEBC-0h=(H44~md+8B{ zrG#gXdh5=^OG%ET?Qa19QF8YQYF)y`;G`1dSN{n<(hL^dnXaAEQynT3uOD=j6$^_H z7%6Ic28VV&MjQ1z4>aPW!X_m`>Q1F^kwsQSW3$+7vCTFEf;BD$BTk9EIYF&dx~?vh z`{j$pQuqgR)@3G!8#3inSogTI#$o^7HxJ(HcXEfwdcuJ;pdu*Jn%}pQG=4^-RcYeT z?X<1MUC7T=vMj0E6%HQlLo~j>n{+a5=6tD zg^4udA-V|Br1+!UqYHY-lLT)BINnFK+x}-b9LH0{#XkD&6H}}NQTQN?aWw=fZxg3u z$~n1$YM3X>7=|V++AU39a6AudnWChMgunk7=HNc!bG?@Qq6bO7^big3?Lb&styXKd z!fqwwZbyEX)M$@ou6{FMnc<&KJ$d9{=qKzuy3nUhM-7#v>@7c2CMNVT$g`Ak#WW_f zTw%)DL1!k<_X@w`BYnXFj$YR3)HkaK=CDI9h(|Gvfv00T*M!KMq$>aLOo$e`KqyWt)Tcaq)dgB zsS~L8Cai53=xqvt+J@bz7Bh1BD_vgghFzH3M;2<7;T(H7C#eJIH%@oelPRe@U_*ND6@3TxD9?RX_1gqE z>FONbDH09TcVu$$e_^)mqW}8LNDeCC&pW@}skKu~RjX_nPlSN7wy?lLqYWLWiWH~j zJPAWUDmUow2`Oi#ad5|wUZ`%XKwlaRSAO*j`89Uu1p_$eZ1!z%@u$M;ukbxwxMI{r z{qXQ@Dp@d`ukA*W6F9{1f(CNi0#Nm$Abs|x21JcUgGQB=(b8qS(>^q)4%6tieyale zcfrP^vAG7gw2&Zp{aFGXANe9?CQ~*f8kILbVmZ`S2Pr_|bFR zQYoC%4U73sVK)lnb_fqx;zofw)AIcgpOO%KeE$VR!t$+ELJrmkSTOfD>W|!0$iM(H zxMP<^c7wvM$AUk4J_BUt*{dD}vIqKk7FsDQ|7+iHhNvIggdbhCBXHMlpSzd?N`VyH#&M)6G_bSUp3 zcp>M}|o zWo{c#@bvcBgqYK~?g|Y#8On<^!jiO?9f`7%!H^OvHwlVt;3Wk3$9_7NkSXtZK - - - - `; - - if (attachment.mimeType && attachment.mimeType.startsWith('image/')) { - icon = ` - - - - - + // Determine if this is an image or document + const isImage = attachment.mimeType && attachment.mimeType.startsWith('image/'); + + // For images, show actual preview if possible, otherwise show document icon + let previewContent; + if (isImage) { + // Try to show image preview, fall back to document icon + previewContent = ` + Paperless Document Preview + `; + } else { + // Show document icon + previewContent = ` +
+ + + + + + + +
`; } previewItem.innerHTML = ` -
${icon}
-
+
+ ${previewContent} +
+ Paperless NGX +
+
+ +
${this._escapeHtml(attachment.title)} ${attachment.fileSize ? `${this.formatFileSize(attachment.fileSize)}` : ''} - From Paperless NGX -
-
- -
`; - // Add event listeners - const previewBtn = previewItem.querySelector('.preview-btn'); - if (previewBtn) { - previewBtn.addEventListener('click', () => { - // Use DumbAssets proxy for authenticated download + // Add click handler for preview/download + const filePreview = previewItem.querySelector('.file-preview'); + if (filePreview) { + filePreview.addEventListener('click', () => { window.open(attachment.downloadUrl, '_blank'); }); + filePreview.style.cursor = 'pointer'; } - const deleteBtn = previewItem.querySelector('.delete-file-btn'); + // Add delete button handler + const deleteBtn = previewItem.querySelector('.delete-preview-btn'); if (deleteBtn) { deleteBtn.addEventListener('click', () => { this._removePaperlessAttachment(previewItem, attachment, type); diff --git a/public/styles.css b/public/styles.css index 45bb9fc..33a6f3f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1616,24 +1616,39 @@ input[type="date"][data-has-value="true"] { position: relative; } -.paperless-document::before { - content: ''; +.paperless-badge { position: absolute; top: 4px; - right: 4px; - width: 16px; - height: 16px; - background: var(--primary-color); - border-radius: 50%; + left: 4px; + width: 20px; + height: 20px; display: flex; align-items: center; justify-content: center; - font-size: 10px; - color: white; - content: 'P'; - font-weight: bold; + z-index: 10; +} + +/* Position badge relative to file-preview-item in modals */ +.file-preview-item .paperless-badge { + top: 14px; + left: 8px; +} + +/* Position badge relative to file-item in asset details */ +.file-item .paperless-badge { + top: 8px; + left: 8px; +} + +.paperless-badge img { + width: 20px; + height: 20px; + object-fit: contain; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); } + + .paperless-search-modal { position: fixed; top: 0; diff --git a/src/services/render/assetRenderer.js b/src/services/render/assetRenderer.js index 8434a63..892b791 100644 --- a/src/services/render/assetRenderer.js +++ b/src/services/render/assetRenderer.js @@ -264,10 +264,14 @@ function generateFileGridHTML(asset) { asset.photoPaths.forEach((photoPath, index) => { const photoInfo = asset.photoInfo?.[index] || {}; const fileName = photoInfo.originalName || photoPath.split('/').pop(); + const paperlessClass = photoInfo.isPaperlessDocument ? ' paperless-document' : ''; + const paperlessBadge = photoInfo.isPaperlessDocument ? + '
Paperless NGX
' : ''; html += ` -
+ @@ -277,10 +281,14 @@ function generateFileGridHTML(asset) { // Backward compatibility for single photo const photoInfo = asset.photoInfo?.[0] || {}; const fileName = photoInfo.originalName || asset.photoPath.split('/').pop(); + const paperlessClass = photoInfo.isPaperlessDocument ? ' paperless-document' : ''; + const paperlessBadge = photoInfo.isPaperlessDocument ? + '
Paperless NGX
' : ''; html += ` -
+ @@ -292,13 +300,17 @@ function generateFileGridHTML(asset) { asset.receiptPaths.forEach((receiptPath, index) => { const receiptInfo = asset.receiptInfo?.[index] || {}; const fileName = receiptInfo.originalName || receiptPath.split('/').pop(); + const paperlessClass = receiptInfo.isPaperlessDocument ? ' paperless-document' : ''; + const paperlessBadge = receiptInfo.isPaperlessDocument ? + '
Paperless NGX
' : ''; html += ` -
+ @@ -308,13 +320,17 @@ function generateFileGridHTML(asset) { // Backward compatibility for single receipt const receiptInfo = asset.receiptInfo?.[0] || {}; const fileName = receiptInfo.originalName || asset.receiptPath.split('/').pop(); + const paperlessClass = receiptInfo.isPaperlessDocument ? ' paperless-document' : ''; + const paperlessBadge = receiptInfo.isPaperlessDocument ? + '
Paperless NGX
' : ''; html += ` -
+ @@ -326,8 +342,11 @@ function generateFileGridHTML(asset) { asset.manualPaths.forEach((manualPath, index) => { const manualInfo = asset.manualInfo?.[index] || {}; const fileName = manualInfo.originalName || manualPath.split('/').pop(); + const paperlessClass = manualInfo.isPaperlessDocument ? ' paperless-document' : ''; + const paperlessBadge = manualInfo.isPaperlessDocument ? + '
Paperless NGX
' : ''; html += ` -
+ @@ -345,8 +365,11 @@ function generateFileGridHTML(asset) { // Backward compatibility for single manual const manualInfo = asset.manualInfo?.[0] || {}; const fileName = manualInfo.originalName || asset.manualPath.split('/').pop(); + const paperlessClass = manualInfo.isPaperlessDocument ? ' paperless-document' : ''; + const paperlessBadge = manualInfo.isPaperlessDocument ? + '
Paperless NGX
' : ''; html += ` -
+ diff --git a/src/services/render/previewRenderer.js b/src/services/render/previewRenderer.js index 1b037df..2296be9 100644 --- a/src/services/render/previewRenderer.js +++ b/src/services/render/previewRenderer.js @@ -10,7 +10,7 @@ * @param {Function} onDeleteCallback - Callback function when delete button is clicked * @return {HTMLElement} The created preview element */ -export function createPhotoPreview(filePath, onDeleteCallback, fileName = null, fileSize = null) { +export function createPhotoPreview(filePath, onDeleteCallback, fileName = null, fileSize = null, isPaperlessDocument = false) { const previewItem = document.createElement('div'); previewItem.className = 'file-preview-item'; @@ -19,12 +19,16 @@ export function createPhotoPreview(filePath, onDeleteCallback, fileName = null, fileName = filePath.split('/').pop(); } + const paperlessBadge = isPaperlessDocument ? + '
Paperless NGX
' : ''; + previewItem.innerHTML = `
Photo Preview
+ ${paperlessBadge} - -
+
diff --git a/public/managers/settings.js b/public/managers/settings.js index 563b89d..fb5d6f1 100644 --- a/public/managers/settings.js +++ b/public/managers/settings.js @@ -1,5 +1,5 @@ // SettingsManager handles all settings modal logic, loading, saving, and dashboard order drag/drop -import { TOKENMASK } from '../../src/constants.js'; +import { TOKENMASK } from '../src/constants.js'; export class SettingsManager { constructor({ @@ -85,23 +85,9 @@ export class SettingsManager { exportSimpleDataBtn.addEventListener('click', () => this._exportSimpleData()); } - // Paperless integration - const testPaperlessConnection = document.getElementById('testPaperlessConnection'); - if (testPaperlessConnection) { - testPaperlessConnection.addEventListener('click', () => this._testPaperlessConnection()); - } - - const paperlessEnabled = document.getElementById('paperlessEnabled'); - if (paperlessEnabled) { - paperlessEnabled.addEventListener('change', (e) => this._togglePaperlessConfig(e.target.checked)); - } + // Integrations will be bound dynamically when loaded - // Handle token field changes - const paperlessToken = document.getElementById('paperlessToken'); - if (paperlessToken) { - paperlessToken.addEventListener('input', (e) => this._handleTokenFieldChange(e.target)); - paperlessToken.addEventListener('focus', (e) => this._handleTokenFieldFocus(e.target)); - } + // Handle token field changes will be bound dynamically per integration document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -228,34 +214,11 @@ export class SettingsManager { document.getElementById('toggleAnalytics').checked = finalVisibility.analytics; document.getElementById('toggleEvents').checked = finalVisibility.events; - // Paperless integration settings - const integrationSettings = settings.integrationSettings || {}; - const paperlessSettings = integrationSettings.paperless || {}; - - document.getElementById('paperlessEnabled').checked = paperlessSettings.enabled || false; - document.getElementById('paperlessHost').value = paperlessSettings.hostUrl || ''; - - // Handle API token - show placeholder if token exists, empty if not - const tokenField = document.getElementById('paperlessToken'); - if (paperlessSettings.apiToken) { - if (paperlessSettings.apiToken === TOKENMASK) { - // Already a placeholder, keep it - tokenField.value = paperlessSettings.apiToken; - tokenField.setAttribute('data-has-saved-token', 'true'); - tokenField.placeholder = 'Saved token hidden - focus to enter new token'; - } else { - // This shouldn't happen with the backend fix, but handle it for safety - tokenField.value = TOKENMASK; - tokenField.setAttribute('data-has-saved-token', 'true'); - tokenField.placeholder = 'Saved token hidden - focus to enter new token'; - } - } else { - tokenField.value = ''; - tokenField.removeAttribute('data-has-saved-token'); - tokenField.placeholder = 'Enter your Paperless API token'; - } + // Load integrations dynamically + await this.loadIntegrations(); - this._togglePaperlessConfig(paperlessSettings.enabled || false); + // Apply current integration settings to the dynamically loaded form + this.applyIntegrationSettingsToForm(settings.integrationSettings || {}); // Card visibility toggles if (typeof window.renderCardVisibilityToggles === 'function') { @@ -310,13 +273,7 @@ export class SettingsManager { active: document.getElementById('toggleCardWarrantiesActive')?.checked !== false } }, - integrationSettings: { - paperless: { - enabled: document.getElementById('paperlessEnabled')?.checked || false, - hostUrl: document.getElementById('paperlessHost')?.value || '', - apiToken: document.getElementById('paperlessToken')?.value || '' - } - } + integrationSettings: this.collectAllIntegrationSettings() }; const dashboardSections = document.querySelectorAll('#dashboardSections .sortable-item'); dashboardSections.forEach(section => { @@ -876,87 +833,465 @@ export class SettingsManager { return rows.map(row => row.join(',')).join('\n'); } - _togglePaperlessConfig(enabled) { - const configDiv = document.getElementById('paperlessConfig'); - if (configDiv) { - configDiv.style.display = enabled ? 'block' : 'none'; + /** + * Load and render integrations dynamically + */ + async loadIntegrations() { + const integrationsContainer = document.getElementById('integrationsContainer'); + const loadingElement = document.getElementById('integrationsLoading'); + const errorElement = document.getElementById('integrationsError'); + + try { + loadingElement.style.display = 'block'; + errorElement.style.display = 'none'; + + const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations`); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) { + throw new Error(responseValidation.errorMessage); + } + + const integrations = await response.json(); + + // Clear loading state + loadingElement.style.display = 'none'; + + // Clear existing content except loading/error elements + const existingIntegrations = integrationsContainer.querySelectorAll('.integration-section'); + existingIntegrations.forEach(el => el.remove()); + + if (integrations.length === 0) { + const noIntegrationsMsg = document.createElement('div'); + noIntegrationsMsg.style.cssText = 'text-align: center; padding: 2rem; color: var(--text-color-secondary);'; + noIntegrationsMsg.textContent = 'No integrations available'; + integrationsContainer.appendChild(noIntegrationsMsg); + return; + } + + // Group integrations by category + const categories = this.groupIntegrationsByCategory(integrations); + + // Render each category + for (const [category, categoryIntegrations] of Object.entries(categories)) { + const categorySection = this.renderIntegrationCategory(category, categoryIntegrations); + integrationsContainer.appendChild(categorySection); + } + + // Bind events for all rendered integrations + this.bindIntegrationEvents(); + + } catch (error) { + console.error('Failed to load integrations:', error); + loadingElement.style.display = 'none'; + errorElement.style.display = 'block'; + errorElement.textContent = `Failed to load integrations: ${error.message}`; } } - _handleTokenFieldFocus(tokenField) { - // Clear placeholder when user focuses to enter new token - if (tokenField.value === TOKENMASK) { - tokenField.value = ''; - tokenField.placeholder = 'Enter new API token to replace existing one'; - } + /** + * Group integrations by category + */ + groupIntegrationsByCategory(integrations) { + const categories = {}; + + integrations.forEach(integration => { + const category = integration.category || 'general'; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(integration); + }); + + return categories; } - _handleTokenFieldChange(tokenField) { - // Reset connection status when token changes - const statusSpan = document.getElementById('paperlessConnectionStatus'); - if (statusSpan) { - statusSpan.textContent = ''; - statusSpan.className = 'connection-status'; - } + /** + * Render an integration category section + */ + renderIntegrationCategory(category, integrations) { + const categorySection = document.createElement('div'); + categorySection.className = 'integration-category'; - // Update data attribute based on whether we have content - if (tokenField.value && tokenField.value !== TOKENMASK) { - tokenField.removeAttribute('data-has-saved-token'); + const categoryTitle = this.getCategoryDisplayName(category); + + integrations.forEach(integration => { + const integrationElement = this.renderIntegration(integration); + categorySection.appendChild(integrationElement); + }); + + return categorySection; + } + + /** + * Get display name for category + */ + getCategoryDisplayName(category) { + const categoryNames = { + 'document-management': 'Document Management', + 'communication': 'Communication', + 'monitoring': 'Monitoring', + 'backup': 'Backup & Sync', + 'general': 'General' + }; + + return categoryNames[category] || category.charAt(0).toUpperCase() + category.slice(1); + } + + /** + * Render a single integration + */ + renderIntegration(integration) { + const section = document.createElement('div'); + section.className = 'integration-section'; + section.dataset.integrationId = integration.id; + + const header = document.createElement('div'); + header.className = 'collapsible-header'; + header.innerHTML = ` +
+
+

${integration.name}

+

${integration.description || ''}

+
+
+ +
+
+ + `; + + const content = document.createElement('div'); + content.className = 'collapsible-content'; + content.style.display = 'none'; + + const fieldsContainer = document.createElement('div'); + fieldsContainer.className = 'integration-fields'; + + // Render fields based on schema + const fields = this.renderIntegrationFields(integration); + fieldsContainer.appendChild(fields); + + // Add test connection button if integration supports it + if (this.integrationSupportsTestConnection(integration)) { + const testButton = document.createElement('button'); + testButton.type = 'button'; + testButton.className = 'action-button test-integration-btn'; + testButton.dataset.integrationId = integration.id; + testButton.style.cssText = 'background: var(--success-color); margin-top: 1rem;'; + testButton.innerHTML = 'Test Connection
'; + fieldsContainer.appendChild(testButton); } + + content.appendChild(fieldsContainer); + section.appendChild(header); + section.appendChild(content); + + // Make it collapsible + this.makeCollapsible(header, content); + + return section; + } + + /** + * Render integration fields based on schema + */ + renderIntegrationFields(integration) { + const container = document.createElement('div'); + container.className = 'integration-fields-container'; + + const schema = integration.configSchema || {}; + + Object.entries(schema).forEach(([fieldName, fieldConfig]) => { + // Skip the enabled field as it's handled by the toggle + if (fieldName === 'enabled') return; + + const fieldElement = this.renderIntegrationField(integration.id, fieldName, fieldConfig); + container.appendChild(fieldElement); + }); + + return container; } - async _testPaperlessConnection() { - const testBtn = document.getElementById('testPaperlessConnection'); - const statusSpan = document.getElementById('paperlessConnectionStatus'); - const hostUrl = document.getElementById('paperlessHost').value; - const tokenField = document.getElementById('paperlessToken'); - const apiToken = tokenField.value; - const hasSavedToken = tokenField.hasAttribute('data-has-saved-token'); - - if (!hostUrl) { - statusSpan.textContent = 'Please enter host URL'; - statusSpan.className = 'connection-status error'; - return; + /** + * Render a single integration field + */ + renderIntegrationField(integrationId, fieldName, fieldConfig) { + const fieldContainer = document.createElement('div'); + fieldContainer.className = 'form-group integration-field'; + fieldContainer.dataset.fieldName = fieldName; + + // Add dependency class if this field depends on another + if (fieldConfig.dependsOn) { + fieldContainer.classList.add('depends-on-' + fieldConfig.dependsOn); } - // Check if we need a new token - if (!apiToken || (apiToken === TOKENMASK && !hasSavedToken)) { - statusSpan.textContent = 'Please enter API token'; - statusSpan.className = 'connection-status error'; - return; + const label = document.createElement('label'); + label.textContent = fieldConfig.label || fieldName; + label.htmlFor = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; + + const fieldId = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; + let input; + + switch (fieldConfig.type) { + case 'password': + input = document.createElement('input'); + input.type = 'password'; + input.id = fieldId; + input.name = fieldName; + input.placeholder = fieldConfig.placeholder || ''; + if (fieldConfig.sensitive) { + input.dataset.sensitive = 'true'; + } + break; + + case 'url': + input = document.createElement('input'); + input.type = 'url'; + input.id = fieldId; + input.name = fieldName; + input.placeholder = fieldConfig.placeholder || ''; + break; + + case 'boolean': + // For boolean fields other than 'enabled', render as checkbox + input = document.createElement('div'); + input.className = 'checkbox-container'; + input.innerHTML = ` + + `; + break; + + default: + input = document.createElement('input'); + input.type = 'text'; + input.id = fieldId; + input.name = fieldName; + input.placeholder = fieldConfig.placeholder || ''; } - this.setButtonLoading(testBtn, true); - statusSpan.textContent = 'Testing connection...'; - statusSpan.className = 'connection-status testing'; + fieldContainer.appendChild(label); + fieldContainer.appendChild(input); + // Add description if provided + if (fieldConfig.description) { + const description = document.createElement('small'); + description.className = 'field-description'; + description.textContent = fieldConfig.description; + fieldContainer.appendChild(description); + } + + return fieldContainer; + } + + /** + * Check if integration supports test connection + */ + integrationSupportsTestConnection(integration) { + return integration.endpoints && integration.endpoints.some(endpoint => + endpoint.includes('test-connection') || endpoint.includes('/test') + ); + } + + /** + * Make a section collapsible + */ + makeCollapsible(header, content) { + header.addEventListener('click', () => { + const isOpen = content.style.display !== 'none'; + content.style.display = isOpen ? 'none' : 'block'; + const arrow = header.querySelector('.collapsible-arrow'); + if (arrow) { + arrow.textContent = isOpen ? '▼' : '▲'; + } + }); + } + + /** + * Bind events for integration controls + */ + bindIntegrationEvents() { + // Enable/disable toggle events + document.querySelectorAll('.integration-section input[id$="Enabled"]').forEach(toggle => { + toggle.addEventListener('change', (e) => { + const integrationId = e.target.id.replace('Enabled', ''); + this.toggleIntegrationFields(integrationId, e.target.checked); + }); + + // Set initial state + const integrationId = toggle.id.replace('Enabled', ''); + this.toggleIntegrationFields(integrationId, toggle.checked); + }); + + // Sensitive field focus events for password fields + document.querySelectorAll('.integration-section input[data-sensitive="true"]').forEach(field => { + field.addEventListener('focus', () => { + if (field.value === TOKENMASK) { + field.value = ''; + field.placeholder = 'Enter new value...'; + } + }); + }); + + // Test connection button events + document.querySelectorAll('.test-integration-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const integrationId = e.target.dataset.integrationId; + this.testIntegrationConnection(integrationId, btn); + }); + }); + } + + /** + * Toggle integration fields visibility based on enabled state + */ + toggleIntegrationFields(integrationId, enabled) { + const section = document.querySelector(`[data-integration-id="${integrationId}"]`); + if (!section) return; + + // Toggle fields that depend on enabled state + const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); + dependentFields.forEach(field => { + field.style.display = enabled ? 'block' : 'none'; + }); + + // Toggle test button + const testBtn = section.querySelector('.test-integration-btn'); + if (testBtn) { + testBtn.style.display = enabled ? 'block' : 'none'; + } + } + + /** + * Test integration connection + */ + async testIntegrationConnection(integrationId, button) { try { - // Send the token even if it's the placeholder - backend will handle it - const response = await fetch('/api/paperless/test-connection', { + this.setButtonLoading(button, true); + + // Collect current integration settings + const integrationConfig = this.collectIntegrationSettings(integrationId); + + const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations/${integrationId}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostUrl, apiToken }), + body: JSON.stringify(integrationConfig), credentials: 'include' }); const responseValidation = await globalThis.validateResponse(response); - if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage); + if (responseValidation.errorMessage) { + throw new Error(responseValidation.errorMessage); + } const result = await response.json(); - if (result.success) { - statusSpan.textContent = `✓ ${result.message || `Connected successfully (${result.documentsCount} documents available)`}`; - statusSpan.className = 'connection-status success'; - globalThis.toaster.show('Paperless connection successful!', 'success'); + if (result.status === 'success') { + globalThis.toaster.show(`${integrationId} connection test successful!`, 'success'); } else { - throw new Error(result.error || 'Connection failed'); + throw new Error(result.message || 'Connection test failed'); } + } catch (error) { - statusSpan.textContent = `✗ Connection failed: ${error.message}`; - statusSpan.className = 'connection-status error'; - globalThis.logError('Paperless connection test failed:', error.message); + globalThis.logError(`${integrationId} connection test failed:`, error); } finally { - this.setButtonLoading(testBtn, false); + this.setButtonLoading(button, false); } } + + /** + * Collect integration settings from form fields + */ + collectIntegrationSettings(integrationId) { + const section = document.querySelector(`[data-integration-id="${integrationId}"]`); + if (!section) return {}; + + const settings = {}; + + // Get enabled state + const enabledToggle = section.querySelector(`#${integrationId}Enabled`); + if (enabledToggle) { + settings.enabled = enabledToggle.checked; + } + + // Get all other fields + const fields = section.querySelectorAll('.integration-field input, .integration-field select, .integration-field textarea'); + fields.forEach(field => { + const fieldName = field.name || field.id.replace(integrationId, '').toLowerCase(); + + if (field.type === 'checkbox') { + settings[fieldName] = field.checked; + } else { + settings[fieldName] = field.value; + } + }); + + return settings; + } + + /** + * Collect all integration settings from the form + */ + collectAllIntegrationSettings() { + const integrationSettings = {}; + + // Find all integration sections + const integrationSections = document.querySelectorAll('.integration-section[data-integration-id]'); + + integrationSections.forEach(section => { + const integrationId = section.dataset.integrationId; + const settings = this.collectIntegrationSettings(integrationId); + + if (Object.keys(settings).length > 0) { + integrationSettings[integrationId] = settings; + } + }); + + return integrationSettings; + } + + /** + * Apply integration settings to the dynamically loaded form + */ + applyIntegrationSettingsToForm(integrationSettings) { + Object.entries(integrationSettings).forEach(([integrationId, config]) => { + const section = document.querySelector(`[data-integration-id="${integrationId}"]`); + if (!section) return; + + // Set enabled state + const enabledToggle = section.querySelector(`#${integrationId}Enabled`); + if (enabledToggle) { + enabledToggle.checked = config.enabled || false; + this.toggleIntegrationFields(integrationId, enabledToggle.checked); + } + + // Set field values + Object.entries(config).forEach(([fieldName, value]) => { + if (fieldName === 'enabled') return; // Already handled above + + const fieldId = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; + const field = section.querySelector(`#${fieldId}`); + + if (field) { + if (field.type === 'checkbox') { + field.checked = !!value; + } else { + // Handle sensitive fields + if (field.dataset.sensitive === 'true' && value) { + field.value = TOKENMASK; + field.setAttribute('data-has-saved-token', 'true'); + field.placeholder = 'Saved token hidden - focus to enter new token'; + } else { + field.value = value || ''; + } + } + } + }); + }); + } } diff --git a/public/styles.css b/public/styles.css index 33a6f3f..b5e5b63 100644 --- a/public/styles.css +++ b/public/styles.css @@ -12,6 +12,7 @@ --secondary-color: #64748b; --background-color: #ffffff; --text-color: #1a1a1a; + --text-color-secondary: #64748b; --border-color: #e2e8f0; --hover-color: #f8fafc; --modal-overlay: rgba(0, 0, 0, 0.5); @@ -35,6 +36,7 @@ --secondary-color: #94a3b8; --background-color: #2d2d2d; --text-color: #ffffff; + --text-color-secondary: #94a3b8; --border-color: #334155; --hover-color: #1e293b; --link-color: #60a5fa; @@ -729,7 +731,6 @@ input[type="date"][data-has-value="true"] { justify-content: flex-end; } -/* Edit and Delete Icon Buttons */ .asset-actions button, .sub-asset-actions button { padding: 0.3rem 0.5rem; @@ -1578,185 +1579,186 @@ input[type="date"][data-has-value="true"] { } /* Integration Settings Styles */ -.integrations-section { - margin-top: 0.5rem; +/* Integration Settings Styles */ +.integration-section { + margin-bottom: 1rem; + border: 1px solid var(--border-color); + border-radius: var(--app-border-radius); + background: var(--container); } -.integration-config { - padding: 1rem; - background: var(--file-label-color); - border-radius: var(--app-border-radius); - border: 1px solid var(--border-color); +.integration-header-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 1rem; } -.connection-status { - font-size: 0.875rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: var(--app-border-radius); +.integration-info h4 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-color); } -.connection-status.success { - color: var(--success-color); - background: rgba(16, 185, 129, 0.1); +.integration-description { + margin: 0; + font-size: 0.875rem; + color: var(--text-color-secondary); + opacity: 0.8; } -.connection-status.error { - color: #ef4444; - background: rgba(239, 68, 68, 0.1); +.integration-toggle { + margin-left: 1rem; } -.connection-status.testing { - color: var(--primary-color); - background: rgba(37, 99, 235, 0.1); +.collapsible-header { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 0; + background: transparent; + border: none; + width: 100%; + border-bottom: 1px solid var(--border-color); } -/* Paperless Document Styles */ -.paperless-document { - position: relative; +.collapsible-header:hover { + background: var(--hover-color); } -.paperless-badge { - position: absolute; - top: 4px; - left: 4px; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; +.collapsible-arrow { + font-size: 0.875rem; + color: var(--text-color-secondary); + margin-right: 1rem; + transition: transform 0.2s ease; } -/* Position badge relative to file-preview-item in modals */ -.file-preview-item .paperless-badge { - top: 14px; - left: 8px; +.collapsible-content { + padding: 1rem; } -/* Position badge relative to file-item in asset details */ -.file-item .paperless-badge { - top: 8px; - left: 8px; +.integration-fields-container { + display: flex; + flex-direction: column; + gap: 1rem; } -.paperless-badge img { - width: 20px; - height: 20px; - object-fit: contain; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); +.integration-field { + display: flex; + flex-direction: column; + gap: 0.5rem; } +.integration-field label { + font-weight: 500; + color: var(--text-color); + font-size: 0.875rem; +} +.integration-field input[type="text"], +.integration-field input[type="url"], +.integration-field input[type="password"] { + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--background-color); + color: var(--text-color); + font-size: 0.875rem; +} -.paperless-search-modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1001; -} - -.paperless-search-content { - background: var(--background-color); - border-radius: var(--app-border-radius); - width: 90%; - max-width: 600px; - max-height: 80vh; - overflow: hidden; - display: flex; - flex-direction: column; +.integration-field input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); } -.paperless-search-header { - padding: 1rem; - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: space-between; +.field-description { + font-size: 0.75rem; + color: var(--text-color-secondary); + opacity: 0.8; } -.paperless-search-body { - padding: 1rem; - flex: 1; - overflow-y: auto; +.test-integration-btn { + margin-top: 1rem; + align-self: flex-start; } -.paperless-search-input { - width: 100%; - padding: 0.75rem; - border: var(--app-border); - border-radius: var(--app-border-radius); - background: var(--file-label-color); - color: var(--text-color); - margin-bottom: 1rem; +/* Integration field visibility based on dependencies */ +.integration-field.depends-on-enabled { + transition: opacity 0.2s ease, max-height 0.3s ease; } -.paperless-results { - display: flex; - flex-direction: column; - gap: 0.75rem; +.integration-field.depends-on-enabled[style*="display: none"] { + opacity: 0; + max-height: 0; + overflow: hidden; } -.paperless-document-item { - display: flex; - align-items: center; - gap: 1rem; - padding: 0.75rem; - border: 1px solid var(--border-color); - border-radius: var(--app-border-radius); - background: var(--file-label-color); - cursor: pointer; - transition: all 0.2s ease; +/* Integration category styles */ +.integration-category { + margin-bottom: 1.5rem; } -.paperless-document-item:hover { - border-color: var(--primary-color); - background: rgba(37, 99, 235, 0.05); +.integration-category:last-child { + margin-bottom: 0; } -.paperless-doc-info { - flex: 1; +/* Switch styles for integration toggles */ +.switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; } -.paperless-doc-title { - font-weight: 500; - margin-bottom: 0.25rem; - color: var(--text-color); +.switch input { + opacity: 0; + width: 0; + height: 0; } -.paperless-doc-meta { - font-size: 0.875rem; - color: var(--text-light); +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: 0.4s; + border-radius: 24px; } -.paperless-attach-btn { - padding: 0.5rem 1rem; - background: var(--primary-color); - color: white; - border: none; - border-radius: var(--app-border-radius); - cursor: pointer; - font-size: 0.875rem; - transition: background 0.2s ease; +.slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.4s; + border-radius: 50%; } -.paperless-attach-btn:hover { - background: #1d4ed8; +input:checked + .slider { + background-color: var(--primary-color); +} + +input:checked + .slider:before { + transform: translateX(26px); } /* File source styling */ .file-source { - font-size: 0.75rem; - color: var(--primary-color); - font-style: italic; - margin-top: 0.25rem; - display: block; + font-size: 0.75rem; + color: var(--primary-color); + font-style: italic; + margin-top: 0.25rem; + display: block; } .toast-container { @@ -3520,27 +3522,19 @@ a:hover { /* font-size: 12px; */ } -.events-date-input.show { - display: block; -} - -/* DEMO MODE STYLES */ -.header-top { - display: flex; - justify-content: center; - align-items: center; - position: absolute; - left: 1rem; - right: 1rem; +/* Paperless Search Modal Styles */ +.paperless-search-modal { + display: none; + position: fixed; top: 0; - background: none; - border: none; - cursor: pointer; - /* padding: 0.5rem; */ - color: var(--text-color); - border-radius: 8px; - transition: var(--input-element-transition); - font-size: 1.5rem; + left: 0; + width: 100%; + height: 100%; + background-color: var(--modal-overlay); + z-index: 1000; + overflow-y: auto; + align-items: center; + justify-content: center; } .demo-banner { background: linear-gradient(135deg, #ff6b6b 0%, #ff8383 100%); @@ -3561,6 +3555,115 @@ a:hover { /* border: 1px solid rgba(255, 255, 255, 0.1); */ } +.paperless-search-header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + padding: 1rem 1.5rem; + background-color: var(--container); +} + +.paperless-search-header .close-btn { + font-size: 1.5rem; + font-weight: 800; + display: flex; + align-items: center; + cursor: pointer; + background: none; + border: none; + color: var(--text-color); + padding: 0.25rem; + border-radius: 0.25rem; + transition: background-color 0.2s ease; +} + +.paperless-search-header .close-btn:hover { + background-color: var(--hover-color); +} + +.paperless-search-body { + padding: 1.5rem; + background-color: var(--background-color); +} + +.paperless-search-input { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--container); + color: var(--text-color); + font-size: 1rem; + margin-bottom: 1rem; +} + +.paperless-search-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.paperless-results { + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--container); +} + +.paperless-document-item { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s ease; +} + +.paperless-document-item:last-child { + border-bottom: none; +} + +.paperless-document-item:hover { + background-color: var(--hover-color); +} + +.paperless-document-info { + flex: 1; + margin-right: 1rem; +} + +.paperless-document-title { + font-weight: 600; + color: var(--text-color); + margin-bottom: 0.25rem; +} + +.paperless-document-meta { + font-size: 0.875rem; + color: var(--text-color-secondary); + opacity: 0.8; +} + +.paperless-attach-btn { + background: var(--primary-color); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: background-color 0.2s ease; +} + +.paperless-attach-btn:hover { + background: var(--primary-hover); +} + +/* Paperless Document Badge Styles */ .demo-banner span { display: flex; align-items: center; diff --git a/server.js b/server.js index bc0a269..65987ca 100644 --- a/server.js +++ b/server.js @@ -25,6 +25,8 @@ const { demoModeMiddleware } = require('./middleware/demo'); const { sanitizeFileName } = require('./src/services/fileUpload/utils'); const packageJson = require('./package.json'); const { TOKENMASK } = require('./src/constants'); +const { integrationManager } = require('./src/integrations/integrationManager'); +const PaperlessEndpoints = require('./src/integrations/paperlessEndpoints'); const app = express(); const PORT = process.env.PORT || 3000; @@ -2013,44 +2015,22 @@ function getAppSettings() { // Use before sending settings to frontend function stripIntegrationTokens(appSettings) { const sanitizedSettings = { ...appSettings }; - - // Replace API tokens with placeholder if they exist - if (sanitizedSettings.integrationSettings?.paperless?.apiToken) { - sanitizedSettings.integrationSettings.paperless.apiToken = TOKENMASK; + + // Use integration manager to sanitize all integration settings + if (sanitizedSettings.integrationSettings) { + Object.keys(sanitizedSettings.integrationSettings).forEach(integrationId => { + const integrationConfig = sanitizedSettings.integrationSettings[integrationId]; + sanitizedSettings.integrationSettings[integrationId] = + integrationManager.sanitizeConfigForFrontend(integrationId, integrationConfig); + }); } - // Add more integrations here as needed - return sanitizedSettings; } -// Validate and Handle sensitive data preservation +// Use integration manager for validation and sensitive data handling function applyIntegrationSettings(serverConfig, updatedConfig) { - if (updatedConfig.integrationSettings?.paperless) { - const requestHostUrl = updatedConfig.integrationSettings.paperless.hostUrl.trim() || ''; - const requestToken = updatedConfig.integrationSettings.paperless.apiToken.trim() || ''; - if (requestToken === TOKENMASK) { - if (serverConfig.integrationSettings?.paperless?.apiToken) { - // If the API token is the placeholder, keep the existing token - updatedConfig.integrationSettings.paperless.apiToken = serverConfig.integrationSettings.paperless.apiToken; - } else { - // If there's no existing token, remove the placeholder - updatedConfig.integrationSettings.paperless.apiToken = ''; - } - } - if (requestHostUrl) { - if (!/^https?:\/\//i.test(requestHostUrl)) { // ensure host URL is a valid url - throw new Error('Invalid Paperless Host URL'); - } - updatedConfig.integrationSettings.paperless.hostUrl = requestHostUrl.endsWith('/') - ? requestHostUrl.slice(0, -1) - : requestHostUrl; - } - - } - // Add more validation for other integrations here - - return updatedConfig; + return integrationManager.applyIntegrationSettings(serverConfig, updatedConfig); } // Import assets route @@ -2138,15 +2118,54 @@ app.get('/api/settings', (req, res) => { try { const appSettings = getAppSettings(); - // Sanitize sensitive data before sending to frontend - const sanitizedSettings = stripIntegrationTokens(appSettings); + // Sanitize integration settings for frontend using integration manager + if (appSettings.integrationSettings) { + for (const [integrationId, config] of Object.entries(appSettings.integrationSettings)) { + appSettings.integrationSettings[integrationId] = integrationManager.sanitizeConfigForFrontend(integrationId, config); + } + } - res.json(sanitizedSettings); + res.json(appSettings); } catch (err) { res.status(500).json({ error: 'Failed to load settings' }); } }); +// Get available integrations for settings UI +app.get('/api/integrations', (req, res) => { + try { + const integrations = integrationManager.getIntegrationsForSettings(); + res.json(integrations); + } catch (error) { + console.error('Failed to get integrations:', error); + res.status(500).json({ error: 'Failed to get integrations' }); + } +}); + +// Test integration connection +app.post('/api/integrations/:id/test', async (req, res) => { + try { + const integrationId = req.params.id; + const testConfig = req.body; + + // Prepare config by handling masked tokens + const preparedConfig = await integrationManager.prepareConfigForTesting( + integrationId, + testConfig, + getAppSettings + ); + + const result = await integrationManager.checkIntegrationStatus(integrationId, preparedConfig); + res.json(result); + } catch (error) { + console.error(`Failed to test integration ${req.params.id}:`, error); + res.status(400).json({ + status: 'error', + message: error.message + }); + } +}); + // Save all settings app.post('/api/settings', (req, res) => { try { @@ -2169,266 +2188,9 @@ app.post('/api/settings', (req, res) => { } }); -// --- PAPERLESS NGX INTEGRATION --- - -// Test Paperless connection -app.post('/api/paperless/test-connection', async (req, res) => { - try { - const { hostUrl, apiToken } = req.body; - - if (!hostUrl) { - return res.status(400).json({ error: 'Host URL is required' }); - } - - let tokenToUse = apiToken; - - // If no token provided or it's the placeholder, try to use saved token - if (!apiToken || apiToken === TOKENMASK) { - const config = getAppSettings(); - const savedToken = config.integrationSettings?.paperless?.apiToken; - - if (!savedToken) { - return res.status(400).json({ error: 'No API token available. Please enter a new token.' }); - } - - tokenToUse = savedToken; - debugLog('Using saved API token for connection test'); - } else { - debugLog('Using provided API token for connection test'); - } - - // Normalize the URL - const normalizedUrl = hostUrl.endsWith('/') ? hostUrl.slice(0, -1) : hostUrl; - - // Test connection by fetching documents count - const testResponse = await fetch(`${normalizedUrl}/api/documents/?page_size=1`, { - headers: { - 'Authorization': `Token ${tokenToUse}`, - 'Content-Type': 'application/json' - } - }); - - if (!testResponse.ok) { - return res.status(400).json({ - success: false, - error: `Connection failed: ${testResponse.status} ${testResponse.statusText}` - }); - } - - const data = await testResponse.json(); - - res.json({ - success: true, - documentsCount: data.count || 0, - message: `Connection successful! Found ${data.count || 0} documents.` - }); - } catch (error) { - debugLog('Paperless connection test error:', error); - res.status(500).json({ - success: false, - error: error.message || 'Connection test failed' - }); - } -}); - -// Search Paperless documents -app.get('/api/paperless/search', async (req, res) => { - try { - const config = getAppSettings(); - const paperlessConfig = config.integrationSettings?.paperless; - - if (!paperlessConfig?.enabled || !paperlessConfig?.hostUrl || !paperlessConfig?.apiToken) { - return res.status(400).json({ error: 'Paperless integration not configured' }); - } - - const query = req.query.q || ''; - const page = req.query.page || 1; - const pageSize = req.query.page_size || 25; - - const normalizedUrl = paperlessConfig.hostUrl.endsWith('/') - ? paperlessConfig.hostUrl.slice(0, -1) - : paperlessConfig.hostUrl; - - let searchUrl = `${normalizedUrl}/api/documents/?page=${page}&page_size=${pageSize}`; - if (query) { - searchUrl += `&query=${encodeURIComponent(query)}`; - } - - const paperlessResponse = await fetch(searchUrl, { - headers: { - 'Authorization': `Token ${paperlessConfig.apiToken}`, - 'Content-Type': 'application/json' - } - }); - - if (!paperlessResponse.ok) { - return res.status(paperlessResponse.status).json({ - error: 'Failed to search Paperless documents' - }); - } - - const data = await paperlessResponse.json(); - res.json(data); - } catch (error) { - debugLog('Paperless search error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// Get Paperless document info -app.get('/api/paperless/document/:id/info', async (req, res) => { - try { - const config = getAppSettings(); - const paperlessConfig = config.integrationSettings?.paperless; - - if (!paperlessConfig?.enabled || !paperlessConfig?.hostUrl || !paperlessConfig?.apiToken) { - return res.status(400).json({ error: 'Paperless integration not configured' }); - } - - const normalizedUrl = paperlessConfig.hostUrl.endsWith('/') - ? paperlessConfig.hostUrl.slice(0, -1) - : paperlessConfig.hostUrl; - - const paperlessResponse = await fetch(`${normalizedUrl}/api/documents/${req.params.id}/`, { - headers: { - 'Authorization': `Token ${paperlessConfig.apiToken}`, - 'Content-Type': 'application/json' - } - }); - - if (!paperlessResponse.ok) { - return res.status(paperlessResponse.status).json({ - error: 'Failed to fetch document info' - }); - } - - const data = await paperlessResponse.json(); - res.json(data); - } catch (error) { - debugLog('Paperless document info error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// Test endpoint for debugging -app.get('/api/paperless/test', (req, res) => { - res.json({ message: 'Paperless routes are working!' }); -}); - -// Proxy Paperless document download -app.get('/api/paperless/document/:id/download', async (req, res) => { - console.log('🔍 PAPERLESS DOWNLOAD ROUTE HIT'); - console.log('🔍 Document ID:', req.params.id); - console.log('🔍 Full URL:', req.originalUrl); - - try { - console.log('🔍 Step 1: Getting config...'); - debugLog('Paperless download request received for document ID:', req.params.id); - const config = getAppSettings(); - console.log('🔍 Step 2: Config retrieved'); - - const paperlessConfig = config.integrationSettings?.paperless; - console.log('🔍 Step 3: Paperless config:', { - enabled: paperlessConfig?.enabled, - hasHost: !!paperlessConfig?.hostUrl, - hasToken: !!paperlessConfig?.apiToken - }); - - if (!paperlessConfig?.enabled || !paperlessConfig?.hostUrl || !paperlessConfig?.apiToken) { - console.log('🔍 ERROR: Paperless not configured properly'); - return res.status(400).json({ error: 'Paperless integration not configured' }); - } - - const normalizedUrl = paperlessConfig.hostUrl.endsWith('/') - ? paperlessConfig.hostUrl.slice(0, -1) - : paperlessConfig.hostUrl; - - const fetchUrl = `${normalizedUrl}/api/documents/${req.params.id}/download/`; - console.log('🔍 Step 4: About to fetch from:', fetchUrl); - - const paperlessResponse = await fetch(fetchUrl, { - headers: { - 'Authorization': `Token ${paperlessConfig.apiToken}` - } - }); - - console.log('🔍 Step 5: Paperless response status:', paperlessResponse.status); - - // Debug all response headers - console.log('🔍 Response Headers:'); - for (const [key, value] of paperlessResponse.headers) { - console.log(` ${key}: ${value}`); - } - - if (!paperlessResponse.ok) { - console.log('🔍 ERROR: Paperless response not OK:', paperlessResponse.status, paperlessResponse.statusText); - return res.status(paperlessResponse.status).json({ - error: 'Failed to download document' - }); - } - - console.log('🔍 Step 6: Setting headers...'); - // Forward the content type and other relevant headers - const contentType = paperlessResponse.headers.get('content-type'); - const contentLength = paperlessResponse.headers.get('content-length'); - const contentDisposition = paperlessResponse.headers.get('content-disposition'); - const contentEncoding = paperlessResponse.headers.get('content-encoding'); - - console.log('🔍 Key headers:', { contentType, contentLength, contentDisposition, contentEncoding }); - - if (contentType) res.setHeader('Content-Type', contentType); - if (contentDisposition) res.setHeader('Content-Disposition', contentDisposition); - - // Don't forward Content-Length or Content-Encoding when compressed - // Let the browser handle the decompressed content length - if (contentLength && !contentEncoding) { - res.setHeader('Content-Length', contentLength); - console.log('🔍 Setting Content-Length:', contentLength); - } else if (contentEncoding) { - console.log('🔍 Skipping Content-Length and Content-Encoding due to compression:', contentEncoding); - } - - // Debug: Log all headers we're sending to the browser - console.log('🔍 Headers being sent to browser:'); - const responseHeaders = res.getHeaders(); - for (const [key, value] of Object.entries(responseHeaders)) { - console.log(` ${key}: ${value}`); - } - - console.log('🔍 Step 7: Starting to stream response...'); - // Stream the response directly to the client using Web Streams API - const reader = paperlessResponse.body.getReader(); - let totalBytes = 0; - let chunkCount = 0; - - const pump = async () => { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - console.log(`🔍 Streaming complete: ${chunkCount} chunks, ${totalBytes} bytes total`); - break; - } - chunkCount++; - totalBytes += value.length; - console.log(`🔍 Chunk ${chunkCount}: ${value.length} bytes (total: ${totalBytes})`); - res.write(value); - } - res.end(); - } catch (error) { - console.log('🔍 Streaming error:', error.message); - res.destroy(error); - } - }; - - await pump(); - console.log('🔍 Step 8: Response streamed successfully'); - } catch (error) { - console.log('🔍 ERROR in catch block:', error.message); - debugLog('Paperless document download error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); +// --- INTEGRATION SYSTEM --- +// Initialize and register integration routes +PaperlessEndpoints.registerRoutes(app, getAppSettings); // Test notification endpoint app.post('/api/notification-test', async (req, res) => { diff --git a/src/constants.js b/src/constants.js index c5cdc50..8d3df00 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1 +1,31 @@ -export const TOKENMASK = '*********************'; \ No newline at end of file +// Token masking for security +export const TOKENMASK = '*********************'; + +// Integration constants +export const INTEGRATION_CATEGORIES = { + DOCUMENT_MANAGEMENT: 'document-management', + COMMUNICATION: 'communication', + MONITORING: 'monitoring', + BACKUP: 'backup', + GENERAL: 'general' +}; + +export const INTEGRATION_STATUS = { + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + ERROR: 'error', + DISABLED: 'disabled', + MISCONFIGURED: 'misconfigured', + UNKNOWN: 'unknown' +}; + +export const FIELD_TYPES = { + TEXT: 'text', + PASSWORD: 'password', + URL: 'url', + EMAIL: 'email', + NUMBER: 'number', + BOOLEAN: 'boolean', + SELECT: 'select', + TEXTAREA: 'textarea' +}; \ No newline at end of file diff --git a/src/integrations/integrationManager.js b/src/integrations/integrationManager.js new file mode 100644 index 0000000..bcbf1b2 --- /dev/null +++ b/src/integrations/integrationManager.js @@ -0,0 +1,373 @@ +/** + * Integration Manager - Server-side integration registry and manager + * Handles registration, configuration, and endpoint management for all integrations + */ + +const { TOKENMASK } = require('../constants'); + +class IntegrationManager { + constructor() { + this.integrations = new Map(); + this.registerBuiltInIntegrations(); + } + + /** + * Register a new integration + * @param {string} id - Unique integration identifier + * @param {Object} config - Integration configuration + */ + registerIntegration(id, config) { + const integration = { + id, + name: config.name, + description: config.description || '', + version: config.version || '1.0.0', + enabled: config.enabled || false, + icon: config.icon || 'gear', + category: config.category || 'general', + + // Configuration schema for settings UI + configSchema: config.configSchema || {}, + + // Default configuration values + defaultConfig: config.defaultConfig || {}, + + // API endpoints this integration provides + endpoints: config.endpoints || [], + + // Middleware functions + middleware: config.middleware || {}, + + // Validation functions + validators: config.validators || {}, + + // Status check function + statusCheck: config.statusCheck || null, + + // Integration-specific metadata + metadata: config.metadata || {} + }; + + this.integrations.set(id, integration); + console.log(`✅ Registered integration: ${integration.name} (${id})`); + return integration; + } + + /** + * Get all registered integrations + */ + getAllIntegrations() { + return Array.from(this.integrations.values()); + } + + /** + * Get a specific integration by ID + */ + getIntegration(id) { + return this.integrations.get(id); + } + + /** + * Get enabled integrations only + */ + getEnabledIntegrations() { + return Array.from(this.integrations.values()).filter(integration => integration.enabled); + } + + /** + * Get integrations by category + */ + getIntegrationsByCategory(category) { + return Array.from(this.integrations.values()).filter(integration => integration.category === category); + } + + /** + * Update integration configuration + */ + updateIntegrationConfig(id, config) { + const integration = this.integrations.get(id); + if (!integration) { + throw new Error(`Integration not found: ${id}`); + } + + // Merge with existing config + integration.config = { ...integration.defaultConfig, ...config }; + integration.enabled = config.enabled || false; + + console.log(`🔄 Updated integration config: ${integration.name}`); + return integration; + } + + /** + * Validate integration configuration + */ + validateConfig(id, config) { + const integration = this.integrations.get(id); + if (!integration) { + throw new Error(`Integration not found: ${id}`); + } + + const validator = integration.validators.configValidator; + if (validator) { + return validator(config); + } + + return { valid: true }; + } + + /** + * Check integration status/health + */ + async checkIntegrationStatus(id, config) { + const integration = this.integrations.get(id); + if (!integration || !integration.statusCheck) { + return { status: 'unknown', message: 'No status check available' }; + } + + try { + return await integration.statusCheck(config); + } catch (error) { + return { status: 'error', message: error.message }; + } + } + + /** + * Sanitize configuration for frontend (mask sensitive fields) + */ + sanitizeConfigForFrontend(id, config) { + const integration = this.integrations.get(id); + if (!integration) return config; + + const sanitized = { ...config }; + const schema = integration.configSchema; + + // Mask sensitive fields + for (const [fieldName, fieldConfig] of Object.entries(schema)) { + if (fieldConfig.sensitive && sanitized[fieldName]) { + sanitized[fieldName] = TOKENMASK; + } + } + + return sanitized; + } + + /** + * Register built-in integrations + */ + registerBuiltInIntegrations() { + // Register Paperless NGX integration + this.registerIntegration('paperless', { + name: 'Paperless NGX', + description: 'Document management system integration for attaching documents to assets', + version: '1.0.0', + icon: 'document', + category: 'document-management', + + configSchema: { + enabled: { + type: 'boolean', + label: 'Enable Paperless Integration', + description: 'Enable integration with Paperless NGX document management system', + default: false, + required: false + }, + hostUrl: { + type: 'url', + label: 'Paperless Host URL', + description: 'The base URL of your Paperless NGX instance (e.g., https://paperless.example.com)', + placeholder: 'https://paperless.example.com', + required: true, + dependsOn: 'enabled' + }, + apiToken: { + type: 'password', + label: 'API Token', + description: 'Your Paperless NGX API token (found in your user settings)', + placeholder: 'Enter your API token', + required: true, + sensitive: true, + dependsOn: 'enabled' + } + }, + + defaultConfig: { + enabled: false, + hostUrl: '', + apiToken: '' + }, + + endpoints: [ + 'GET /api/paperless/test-connection', + 'GET /api/paperless/search', + 'GET /api/paperless/document/:id/info', + 'GET /api/paperless/document/:id/download', + 'GET /api/paperless/test' + ], + + validators: { + configValidator: (config) => { + const errors = []; + + if (config.enabled) { + if (!config.hostUrl) { + errors.push('Host URL is required when Paperless integration is enabled'); + } else { + try { + new URL(config.hostUrl); + } catch { + errors.push('Host URL must be a valid URL'); + } + } + + if (!config.apiToken) { + errors.push('API Token is required when Paperless integration is enabled'); + } + } + + return { + valid: errors.length === 0, + errors + }; + } + }, + + statusCheck: async (config) => { + if (!config.enabled) { + return { status: 'disabled', message: 'Integration is disabled' }; + } + + if (!config.hostUrl || !config.apiToken) { + return { status: 'misconfigured', message: 'Missing required configuration' }; + } + + try { + // This would be implemented by the specific integration + const PaperlessIntegration = require('./paperlessEndpoints'); + return await PaperlessIntegration.testConnection(config); + } catch (error) { + return { status: 'error', message: error.message }; + } + }, + + metadata: { + documentationUrl: 'https://paperless-ngx.readthedocs.io/en/latest/api/', + supportLevel: 'community', + tags: ['documents', 'pdf', 'scanning', 'ocr'] + } + }); + + // Future integrations can be added here + // this.registerIntegration('nextcloud', { ... }); + // this.registerIntegration('sharepoint', { ... }); + } + + /** + * Get integration configuration for frontend settings + */ + getIntegrationsForSettings() { + return Array.from(this.integrations.values()).map(integration => ({ + id: integration.id, + name: integration.name, + description: integration.description, + icon: integration.icon, + category: integration.category, + configSchema: integration.configSchema, + defaultConfig: integration.defaultConfig, + endpoints: integration.endpoints, + metadata: integration.metadata + })); + } + + /** + * Apply integration settings updates, handling sensitive data preservation and validation + */ + applyIntegrationSettings(serverConfig, updatedConfig) { + if (!updatedConfig.integrationSettings) { + return updatedConfig; + } + + for (const [integrationId, newConfig] of Object.entries(updatedConfig.integrationSettings)) { + const integration = this.getIntegration(integrationId); + if (!integration) { + console.warn(`Unknown integration in settings: ${integrationId}`); + continue; + } + + const serverIntegrationConfig = serverConfig.integrationSettings?.[integrationId] || {}; + const schema = integration.configSchema; + + // Handle sensitive field preservation and validation + for (const [fieldName, fieldConfig] of Object.entries(schema)) { + const newValue = newConfig[fieldName]; + + if (fieldConfig.sensitive && newValue === TOKENMASK) { + // Preserve existing sensitive value if token mask is present + if (serverIntegrationConfig[fieldName]) { + newConfig[fieldName] = serverIntegrationConfig[fieldName]; + } else { + newConfig[fieldName] = ''; + } + } + + // Validate and sanitize URL fields + if (fieldConfig.type === 'url' && newValue && newValue.trim()) { + const trimmedUrl = newValue.trim(); + if (!/^https?:\/\//i.test(trimmedUrl)) { + throw new Error(`Invalid ${integration.name} ${fieldConfig.label}: URL must start with http:// or https://`); + } + // Remove trailing slash for consistency + newConfig[fieldName] = trimmedUrl.endsWith('/') + ? trimmedUrl.slice(0, -1) + : trimmedUrl; + } + } + + // Run integration-specific validation + const validation = this.validateConfig(integrationId, newConfig); + if (!validation.valid) { + throw new Error(`${integration.name} configuration error: ${validation.errors.join(', ')}`); + } + } + + return updatedConfig; + } + + /** + * Prepare configuration for testing, handling masked sensitive fields + */ + async prepareConfigForTesting(integrationId, testConfig, getSettings) { + const integration = this.getIntegration(integrationId); + if (!integration) { + throw new Error(`Integration not found: ${integrationId}`); + } + + const preparedConfig = { ...testConfig }; + const schema = integration.configSchema; + + // Handle masked sensitive fields + for (const [fieldName, fieldConfig] of Object.entries(schema)) { + if (fieldConfig.sensitive && preparedConfig[fieldName] === TOKENMASK) { + try { + // Get the actual stored value + const settings = await getSettings(); + const storedConfig = settings.integrationSettings?.[integrationId]; + + if (storedConfig?.[fieldName] && storedConfig[fieldName] !== TOKENMASK) { + preparedConfig[fieldName] = storedConfig[fieldName]; + } else { + throw new Error(`No stored ${fieldConfig.label || fieldName} found. Please enter a new value.`); + } + } catch (error) { + throw new Error(`Failed to retrieve stored ${fieldConfig.label || fieldName}: ${error.message}`); + } + } + } + + return preparedConfig; + } +} + +// Singleton instance +const integrationManager = new IntegrationManager(); + +module.exports = { IntegrationManager, integrationManager }; diff --git a/src/integrations/paperless.js b/src/integrations/paperless.js index ab11f30..f0f624f 100644 --- a/src/integrations/paperless.js +++ b/src/integrations/paperless.js @@ -59,6 +59,8 @@ export class PaperlessIntegration { this.onAttachCallback = onAttach; this.searchModal.style.display = 'flex'; + this.searchModal.style.alignItems = 'center'; + this.searchModal.style.justifyContent = 'center'; this._clearSearch(); this._focusSearchInput(); this._loadRecentDocuments(); @@ -155,7 +157,7 @@ export class PaperlessIntegration { autocomplete="off" >
-
+
Start typing to search documents...
@@ -247,7 +249,7 @@ export class PaperlessIntegration { if (searchInput) searchInput.value = ''; if (resultsDiv) { resultsDiv.innerHTML = ` -
+
Start typing to search documents...
`; @@ -276,7 +278,7 @@ export class PaperlessIntegration { // Show loading state resultsDiv.innerHTML = ` -
+
Searching...
@@ -350,7 +352,7 @@ export class PaperlessIntegration { if (!data.results || data.results.length === 0) { resultsDiv.innerHTML = ` -
+
No documents found
`; @@ -361,7 +363,7 @@ export class PaperlessIntegration {

${title} (${data.count})

${data.results.map(doc => this._renderDocumentItem(doc)).join('')} ${data.count > data.results.length ? ` -
+
Showing ${data.results.length} of ${data.count} documents
` : ''} diff --git a/src/integrations/paperlessEndpoints.js b/src/integrations/paperlessEndpoints.js new file mode 100644 index 0000000..d27cddb --- /dev/null +++ b/src/integrations/paperlessEndpoints.js @@ -0,0 +1,319 @@ +/** + * Paperless NGX Integration Endpoints + * Handles all server-side functionality for Paperless NGX integration + */ + +const { TOKENMASK } = require('../constants.js'); + +class PaperlessEndpoints { + + /** + * Test connection to Paperless instance + */ + static async testConnection(config) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Host URL and API Token are required'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/?page_size=1`, { + headers: { + 'Authorization': `Token ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Invalid API token'); + } else if (response.status === 404) { + throw new Error('Paperless API not found - check your host URL'); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } + + const data = await response.json(); + return { + status: 'connected', + message: `Successfully connected to Paperless NGX (${data.count} documents available)`, + documentCount: data.count + }; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Connection timeout - Paperless instance may be slow or unreachable'); + } else if (error.code === 'ECONNREFUSED') { + throw new Error('Connection refused - check your host URL and network connectivity'); + } else if (error.code === 'ENOTFOUND') { + throw new Error('Host not found - check your host URL'); + } else { + throw error; + } + } + } + + /** + * Search Paperless documents + */ + static async searchDocuments(config, query, page = 1, pageSize = 20) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Paperless integration not configured'); + } + + try { + const url = new URL(`${config.hostUrl.replace(/\/$/, '')}/api/documents/`); + + if (query) { + url.searchParams.append('query', query); + } + url.searchParams.append('page', page.toString()); + url.searchParams.append('page_size', pageSize.toString()); + url.searchParams.append('ordering', '-created'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + const response = await fetch(url.toString(), { + headers: { + 'Authorization': `Token ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Search failed: HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Search timeout - Paperless instance may be slow or unreachable'); + } + console.error('Paperless search error:', error); + throw error; + } + } + + /** + * Get document information + */ + static async getDocumentInfo(config, documentId) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Paperless integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/`, { + headers: { + 'Authorization': `Token ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Document not found'); + } + throw new Error(`Failed to get document info: HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Request timeout - Paperless instance may be slow or unreachable'); + } + console.error('Failed to get document info:', error); + throw error; + } + } + + /** + * Proxy document download + */ + static async downloadDocument(config, documentId) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Paperless integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/download/`, { + headers: { + 'Authorization': `Token ${config.apiToken}` + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Document not found'); + } + throw new Error(`Download failed: HTTP ${response.status}`); + } + + return response; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Download timeout - Paperless instance may be slow or unreachable'); + } + console.error('Failed to download document:', error); + throw error; + } + } + + /** + * Register Paperless API routes with Express app + */ + static registerRoutes(app, getSettings) { + const BASE_PATH = process.env.BASE_PATH || ''; + + // Helper to get Paperless config + const getPaperlessConfig = async () => { + try { + const settings = await getSettings(); + const paperlessConfig = settings.integrationSettings?.paperless; + + if (!paperlessConfig?.enabled) { + throw new Error('Paperless integration is disabled'); + } + + return paperlessConfig; + } catch (error) { + throw new Error('Failed to get Paperless configuration'); + } + }; + + // Test Paperless connection + app.post(BASE_PATH + '/api/paperless/test-connection', async (req, res) => { + try { + let { hostUrl, apiToken } = req.body; + + if (!hostUrl || !apiToken) { + return res.status(400).json({ + success: false, + error: 'Host URL and API Token are required' + }); + } + + // If the token is masked, get the real token from stored settings + if (apiToken === TOKENMASK) { + try { + const settings = await getSettings(); + const paperlessConfig = settings.integrationSettings?.paperless; + + if (paperlessConfig?.apiToken && paperlessConfig.apiToken !== TOKENMASK) { + apiToken = paperlessConfig.apiToken; + } else { + return res.status(400).json({ + success: false, + error: 'No stored API token found. Please enter a new token.' + }); + } + } catch (error) { + return res.status(400).json({ + success: false, + error: 'Failed to retrieve stored API token' + }); + } + } + + const result = await PaperlessEndpoints.testConnection({ hostUrl, apiToken }); + res.json({ success: true, ...result }); + } catch (error) { + console.error('Paperless connection test failed:', error); + res.status(400).json({ + success: false, + error: error.message + }); + } + }); + + // Search Paperless documents + app.get(BASE_PATH + '/api/paperless/search', async (req, res) => { + try { + const config = await getPaperlessConfig(); + const { q: query, page = 1, page_size: pageSize = 20 } = req.query; + + const results = await PaperlessEndpoints.searchDocuments( + config, + query, + parseInt(page), + parseInt(pageSize) + ); + + res.json(results); + } catch (error) { + console.error('Paperless search failed:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Get Paperless document info + app.get(BASE_PATH + '/api/paperless/document/:id/info', async (req, res) => { + try { + const config = await getPaperlessConfig(); + const documentId = req.params.id; + + const info = await PaperlessEndpoints.getDocumentInfo(config, documentId); + res.json(info); + } catch (error) { + console.error('Failed to get document info:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Proxy Paperless document download + app.get(BASE_PATH + '/api/paperless/document/:id/download', async (req, res) => { + try { + const config = await getPaperlessConfig(); + const documentId = req.params.id; + + const response = await PaperlessEndpoints.downloadDocument(config, documentId); + + // Copy headers from Paperless response + response.headers.forEach((value, key) => { + if (key.toLowerCase() !== 'transfer-encoding') { + res.setHeader(key, value); + } + }); + + // Pipe the response + response.body.pipe(res); + } catch (error) { + console.error('Failed to download document:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Test endpoint for debugging + app.get(BASE_PATH + '/api/paperless/test', (req, res) => { + res.json({ + message: 'Paperless integration endpoints are working', + timestamp: new Date().toISOString() + }); + }); + + console.log('📄 Paperless NGX endpoints registered'); + } +} + +module.exports = PaperlessEndpoints; From ff6124b35ba7c9102bc895713d0260ae61918e58 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:10:08 -0700 Subject: [PATCH 08/66] reorg integrations to root --- {src/integrations => integrations/api}/paperlessEndpoints.js | 2 +- {src/integrations => integrations}/integrationManager.js | 4 ++-- server.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename {src/integrations => integrations/api}/paperlessEndpoints.js (99%) rename {src/integrations => integrations}/integrationManager.js (98%) diff --git a/src/integrations/paperlessEndpoints.js b/integrations/api/paperlessEndpoints.js similarity index 99% rename from src/integrations/paperlessEndpoints.js rename to integrations/api/paperlessEndpoints.js index d27cddb..27de13e 100644 --- a/src/integrations/paperlessEndpoints.js +++ b/integrations/api/paperlessEndpoints.js @@ -3,7 +3,7 @@ * Handles all server-side functionality for Paperless NGX integration */ -const { TOKENMASK } = require('../constants.js'); +const { TOKENMASK } = require('../../src/constants.js'); class PaperlessEndpoints { diff --git a/src/integrations/integrationManager.js b/integrations/integrationManager.js similarity index 98% rename from src/integrations/integrationManager.js rename to integrations/integrationManager.js index bcbf1b2..af1c7c6 100644 --- a/src/integrations/integrationManager.js +++ b/integrations/integrationManager.js @@ -3,7 +3,7 @@ * Handles registration, configuration, and endpoint management for all integrations */ -const { TOKENMASK } = require('../constants'); +const { TOKENMASK } = require('../src/constants'); class IntegrationManager { constructor() { @@ -242,7 +242,7 @@ class IntegrationManager { try { // This would be implemented by the specific integration - const PaperlessIntegration = require('./paperlessEndpoints'); + const PaperlessIntegration = require('./api/paperlessEndpoints'); return await PaperlessIntegration.testConnection(config); } catch (error) { return { status: 'error', message: error.message }; diff --git a/server.js b/server.js index 65987ca..99aad14 100644 --- a/server.js +++ b/server.js @@ -25,8 +25,8 @@ const { demoModeMiddleware } = require('./middleware/demo'); const { sanitizeFileName } = require('./src/services/fileUpload/utils'); const packageJson = require('./package.json'); const { TOKENMASK } = require('./src/constants'); -const { integrationManager } = require('./src/integrations/integrationManager'); -const PaperlessEndpoints = require('./src/integrations/paperlessEndpoints'); +const { integrationManager } = require('./integrations/integrationManager'); +const PaperlessEndpoints = require('./integrations/api/paperlessEndpoints'); const app = express(); const PORT = process.env.PORT || 3000; From 895fd59a60db464dcaac69d571db8f34cda13d96 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:13:13 -0700 Subject: [PATCH 09/66] reorg paperless icon to public/assets/logos --- public/assets/{ => logos}/paperless-ngx.png | Bin public/managers/modalManager.js | 2 +- src/services/render/assetRenderer.js | 12 ++++++------ src/services/render/previewRenderer.js | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) rename public/assets/{ => logos}/paperless-ngx.png (100%) diff --git a/public/assets/paperless-ngx.png b/public/assets/logos/paperless-ngx.png similarity index 100% rename from public/assets/paperless-ngx.png rename to public/assets/logos/paperless-ngx.png diff --git a/public/managers/modalManager.js b/public/managers/modalManager.js index b2ef591..b7c96c8 100644 --- a/public/managers/modalManager.js +++ b/public/managers/modalManager.js @@ -1153,7 +1153,7 @@ export class ModalManager {
${previewContent}
- Paperless NGX + Paperless NGX
-
-
- -
-
- Start typing to search documents... -
-
-
-
-
- `; - - document.body.insertAdjacentHTML('beforeend', modalHTML); - this.searchModal = document.getElementById('paperlessSearchModal'); - - // Bind events - this._bindSearchModalEvents(); - } - - /** - * Bind event listeners for the search modal - */ - _bindSearchModalEvents() { - const closeBtn = document.getElementById('paperlessSearchClose'); - const searchInput = document.getElementById('paperlessSearchInput'); - - // Close button - closeBtn.addEventListener('click', () => this.closeSearchModal()); - - // Click outside to close - this.searchModal.addEventListener('click', (e) => { - if (e.target === this.searchModal) { - this.closeSearchModal(); - } - }); - - // Escape key to close - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && this.searchModal.style.display === 'flex') { - this.closeSearchModal(); - } - }); - - // Search input with debouncing - searchInput.addEventListener('input', (e) => { - const query = e.target.value.trim(); - this.currentSearchQuery = query; - - // Clear previous timeout - if (this.searchTimeout) { - clearTimeout(this.searchTimeout); - } - - // Debounce search - this.searchTimeout = setTimeout(() => { - if (query.length === 0) { - this._loadRecentDocuments(); - } else if (query.length >= 2) { - this._performSearch(query); - } - }, 300); - }); - - // Enter key to search - searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const query = e.target.value.trim(); - if (query.length >= 2) { - this._performSearch(query); - } - } - }); - } - - /** - * Focus the search input - */ - _focusSearchInput() { - const searchInput = document.getElementById('paperlessSearchInput'); - if (searchInput) { - setTimeout(() => searchInput.focus(), 100); - } - } - - /** - * Clear search results and input - */ - _clearSearch() { - const searchInput = document.getElementById('paperlessSearchInput'); - const resultsDiv = document.getElementById('paperlessSearchResults'); - - if (searchInput) searchInput.value = ''; - if (resultsDiv) { - resultsDiv.innerHTML = ` -
- Start typing to search documents... -
- `; - } - - this.currentSearchQuery = ''; - } - - /** - * Load recent documents when no search query - */ - async _loadRecentDocuments() { - try { - const results = await this.searchDocuments('', 1); - this._renderSearchResults(results, 'Recent Documents'); - } catch (error) { - this._renderError('Failed to load recent documents'); - } - } - - /** - * Perform search with the given query - */ - async _performSearch(query) { - const resultsDiv = document.getElementById('paperlessSearchResults'); - - // Show loading state - resultsDiv.innerHTML = ` -
-
- Searching... -
- `; - - try { - const results = await this.searchDocuments(query); - this._renderSearchResults(results, `Search Results for "${query}"`); - } catch (error) { - this._renderError(`Search failed: ${error.message}`); - } - } - - setupPaperlessEventListeners() { - // Asset modal handlers - if (searchPaperlessPhotos) { - searchPaperlessPhotos.addEventListener('click', () => { - this.openSearchModal((attachment) => { - return this.modalManager.attachPaperlessDocument(attachment, 'photo', false); - }); - }); - } - - if (searchPaperlessReceipts) { - searchPaperlessReceipts.addEventListener('click', () => { - this.openSearchModal((attachment) => { - return this.modalManager.attachPaperlessDocument(attachment, 'receipt', false); - }); - }); - } - - if (searchPaperlessManuals) { - searchPaperlessManuals.addEventListener('click', () => { - this.openSearchModal((attachment) => { - return this.modalManager.attachPaperlessDocument(attachment, 'manual', false); - }); - }); - } - - // Sub-asset modal handlers - if (searchPaperlessSubPhotos) { - searchPaperlessSubPhotos.addEventListener('click', () => { - this.openSearchModal((attachment) => { - return this.modalManager.attachPaperlessDocument(attachment, 'photo', true); - }); - }); - } - - if (searchPaperlessSubReceipts) { - searchPaperlessSubReceipts.addEventListener('click', () => { - this.openSearchModal((attachment) => { - return this.modalManager.attachPaperlessDocument(attachment, 'receipt', true); - }); - }); - } - - if (searchPaperlessSubManuals) { - searchPaperlessSubManuals.addEventListener('click', () => { - this.openSearchModal((attachment) => { - return this.modalManager.attachPaperlessDocument(attachment, 'manual', true); - }); - }); - } - } - - /** - * Render search results in the modal - */ - _renderSearchResults(data, title) { - const resultsDiv = document.getElementById('paperlessSearchResults'); - - if (!data.results || data.results.length === 0) { - resultsDiv.innerHTML = ` -
- No documents found -
- `; - return; - } - - const resultsHTML = ` -

${title} (${data.count})

- ${data.results.map(doc => this._renderDocumentItem(doc)).join('')} - ${data.count > data.results.length ? ` -
- Showing ${data.results.length} of ${data.count} documents -
- ` : ''} - `; - - resultsDiv.innerHTML = resultsHTML; - - // Bind attach buttons - resultsDiv.querySelectorAll('.paperless-attach-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const docId = e.target.dataset.docId; - const document = data.results.find(doc => doc.id.toString() === docId); - if (document) { - this.attachDocument(document); - } - }); - }); - } - - /** - * Render a single document item - */ - _renderDocumentItem(document) { - const createdDate = new Date(document.created).toLocaleDateString(); - const fileSize = document.size ? this._formatFileSize(document.size) : ''; - const tags = document.tags && document.tags.length > 0 - ? document.tags.slice(0, 3).join(', ') + (document.tags.length > 3 ? '...' : '') - : ''; - - return ` -
-
-
${this._escapeHtml(document.title)}
-
- Created: ${createdDate} - ${fileSize ? ` • Size: ${fileSize}` : ''} - ${tags ? ` • Tags: ${this._escapeHtml(tags)}` : ''} -
-
- -
- `; - } - - /** - * Render error message - */ - _renderError(message) { - const resultsDiv = document.getElementById('paperlessSearchResults'); - resultsDiv.innerHTML = ` -
- ${this._escapeHtml(message)} -
- `; - } - - /** - * Format file size for display - */ - _formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - - /** - * Escape HTML to prevent XSS - */ - _escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -} \ No newline at end of file From 203b221d889a1cdc3394bb91d1e2737ca6d2fa1f Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:06:17 -0700 Subject: [PATCH 11/66] refactor frontend integrations code out of settings and into its own manager class, backend paperless integration code to its own integrations file which contains everything it needs (schema, functions, endpoints) --- integrations/api/paperlessEndpoints.js | 319 ---------------- integrations/integrationManager.js | 101 +---- integrations/paperless.js | 428 ++++++++++++++++++++++ public/managers/integrations.js | 488 +++++++++++++++++++++++++ public/managers/settings.js | 479 +----------------------- server.js | 4 +- 6 files changed, 930 insertions(+), 889 deletions(-) delete mode 100644 integrations/api/paperlessEndpoints.js create mode 100644 integrations/paperless.js create mode 100644 public/managers/integrations.js diff --git a/integrations/api/paperlessEndpoints.js b/integrations/api/paperlessEndpoints.js deleted file mode 100644 index 27de13e..0000000 --- a/integrations/api/paperlessEndpoints.js +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Paperless NGX Integration Endpoints - * Handles all server-side functionality for Paperless NGX integration - */ - -const { TOKENMASK } = require('../../src/constants.js'); - -class PaperlessEndpoints { - - /** - * Test connection to Paperless instance - */ - static async testConnection(config) { - if (!config.hostUrl || !config.apiToken) { - throw new Error('Host URL and API Token are required'); - } - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/?page_size=1`, { - headers: { - 'Authorization': `Token ${config.apiToken}`, - 'Content-Type': 'application/json' - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Invalid API token'); - } else if (response.status === 404) { - throw new Error('Paperless API not found - check your host URL'); - } else { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - } - - const data = await response.json(); - return { - status: 'connected', - message: `Successfully connected to Paperless NGX (${data.count} documents available)`, - documentCount: data.count - }; - } catch (error) { - if (error.name === 'AbortError') { - throw new Error('Connection timeout - Paperless instance may be slow or unreachable'); - } else if (error.code === 'ECONNREFUSED') { - throw new Error('Connection refused - check your host URL and network connectivity'); - } else if (error.code === 'ENOTFOUND') { - throw new Error('Host not found - check your host URL'); - } else { - throw error; - } - } - } - - /** - * Search Paperless documents - */ - static async searchDocuments(config, query, page = 1, pageSize = 20) { - if (!config.hostUrl || !config.apiToken) { - throw new Error('Paperless integration not configured'); - } - - try { - const url = new URL(`${config.hostUrl.replace(/\/$/, '')}/api/documents/`); - - if (query) { - url.searchParams.append('query', query); - } - url.searchParams.append('page', page.toString()); - url.searchParams.append('page_size', pageSize.toString()); - url.searchParams.append('ordering', '-created'); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); - - const response = await fetch(url.toString(), { - headers: { - 'Authorization': `Token ${config.apiToken}`, - 'Content-Type': 'application/json' - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`Search failed: HTTP ${response.status}`); - } - - return await response.json(); - } catch (error) { - if (error.name === 'AbortError') { - throw new Error('Search timeout - Paperless instance may be slow or unreachable'); - } - console.error('Paperless search error:', error); - throw error; - } - } - - /** - * Get document information - */ - static async getDocumentInfo(config, documentId) { - if (!config.hostUrl || !config.apiToken) { - throw new Error('Paperless integration not configured'); - } - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/`, { - headers: { - 'Authorization': `Token ${config.apiToken}`, - 'Content-Type': 'application/json' - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Document not found'); - } - throw new Error(`Failed to get document info: HTTP ${response.status}`); - } - - return await response.json(); - } catch (error) { - if (error.name === 'AbortError') { - throw new Error('Request timeout - Paperless instance may be slow or unreachable'); - } - console.error('Failed to get document info:', error); - throw error; - } - } - - /** - * Proxy document download - */ - static async downloadDocument(config, documentId) { - if (!config.hostUrl || !config.apiToken) { - throw new Error('Paperless integration not configured'); - } - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); - - const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/download/`, { - headers: { - 'Authorization': `Token ${config.apiToken}` - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Document not found'); - } - throw new Error(`Download failed: HTTP ${response.status}`); - } - - return response; - } catch (error) { - if (error.name === 'AbortError') { - throw new Error('Download timeout - Paperless instance may be slow or unreachable'); - } - console.error('Failed to download document:', error); - throw error; - } - } - - /** - * Register Paperless API routes with Express app - */ - static registerRoutes(app, getSettings) { - const BASE_PATH = process.env.BASE_PATH || ''; - - // Helper to get Paperless config - const getPaperlessConfig = async () => { - try { - const settings = await getSettings(); - const paperlessConfig = settings.integrationSettings?.paperless; - - if (!paperlessConfig?.enabled) { - throw new Error('Paperless integration is disabled'); - } - - return paperlessConfig; - } catch (error) { - throw new Error('Failed to get Paperless configuration'); - } - }; - - // Test Paperless connection - app.post(BASE_PATH + '/api/paperless/test-connection', async (req, res) => { - try { - let { hostUrl, apiToken } = req.body; - - if (!hostUrl || !apiToken) { - return res.status(400).json({ - success: false, - error: 'Host URL and API Token are required' - }); - } - - // If the token is masked, get the real token from stored settings - if (apiToken === TOKENMASK) { - try { - const settings = await getSettings(); - const paperlessConfig = settings.integrationSettings?.paperless; - - if (paperlessConfig?.apiToken && paperlessConfig.apiToken !== TOKENMASK) { - apiToken = paperlessConfig.apiToken; - } else { - return res.status(400).json({ - success: false, - error: 'No stored API token found. Please enter a new token.' - }); - } - } catch (error) { - return res.status(400).json({ - success: false, - error: 'Failed to retrieve stored API token' - }); - } - } - - const result = await PaperlessEndpoints.testConnection({ hostUrl, apiToken }); - res.json({ success: true, ...result }); - } catch (error) { - console.error('Paperless connection test failed:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - }); - - // Search Paperless documents - app.get(BASE_PATH + '/api/paperless/search', async (req, res) => { - try { - const config = await getPaperlessConfig(); - const { q: query, page = 1, page_size: pageSize = 20 } = req.query; - - const results = await PaperlessEndpoints.searchDocuments( - config, - query, - parseInt(page), - parseInt(pageSize) - ); - - res.json(results); - } catch (error) { - console.error('Paperless search failed:', error); - res.status(500).json({ error: error.message }); - } - }); - - // Get Paperless document info - app.get(BASE_PATH + '/api/paperless/document/:id/info', async (req, res) => { - try { - const config = await getPaperlessConfig(); - const documentId = req.params.id; - - const info = await PaperlessEndpoints.getDocumentInfo(config, documentId); - res.json(info); - } catch (error) { - console.error('Failed to get document info:', error); - res.status(500).json({ error: error.message }); - } - }); - - // Proxy Paperless document download - app.get(BASE_PATH + '/api/paperless/document/:id/download', async (req, res) => { - try { - const config = await getPaperlessConfig(); - const documentId = req.params.id; - - const response = await PaperlessEndpoints.downloadDocument(config, documentId); - - // Copy headers from Paperless response - response.headers.forEach((value, key) => { - if (key.toLowerCase() !== 'transfer-encoding') { - res.setHeader(key, value); - } - }); - - // Pipe the response - response.body.pipe(res); - } catch (error) { - console.error('Failed to download document:', error); - res.status(500).json({ error: error.message }); - } - }); - - // Test endpoint for debugging - app.get(BASE_PATH + '/api/paperless/test', (req, res) => { - res.json({ - message: 'Paperless integration endpoints are working', - timestamp: new Date().toISOString() - }); - }); - - console.log('📄 Paperless NGX endpoints registered'); - } -} - -module.exports = PaperlessEndpoints; diff --git a/integrations/integrationManager.js b/integrations/integrationManager.js index af1c7c6..8523a7f 100644 --- a/integrations/integrationManager.js +++ b/integrations/integrationManager.js @@ -4,6 +4,7 @@ */ const { TOKENMASK } = require('../src/constants'); +const PaperlessIntegration = require('./paperless'); // Import Paperless schema class IntegrationManager { constructor() { @@ -156,105 +157,7 @@ class IntegrationManager { */ registerBuiltInIntegrations() { // Register Paperless NGX integration - this.registerIntegration('paperless', { - name: 'Paperless NGX', - description: 'Document management system integration for attaching documents to assets', - version: '1.0.0', - icon: 'document', - category: 'document-management', - - configSchema: { - enabled: { - type: 'boolean', - label: 'Enable Paperless Integration', - description: 'Enable integration with Paperless NGX document management system', - default: false, - required: false - }, - hostUrl: { - type: 'url', - label: 'Paperless Host URL', - description: 'The base URL of your Paperless NGX instance (e.g., https://paperless.example.com)', - placeholder: 'https://paperless.example.com', - required: true, - dependsOn: 'enabled' - }, - apiToken: { - type: 'password', - label: 'API Token', - description: 'Your Paperless NGX API token (found in your user settings)', - placeholder: 'Enter your API token', - required: true, - sensitive: true, - dependsOn: 'enabled' - } - }, - - defaultConfig: { - enabled: false, - hostUrl: '', - apiToken: '' - }, - - endpoints: [ - 'GET /api/paperless/test-connection', - 'GET /api/paperless/search', - 'GET /api/paperless/document/:id/info', - 'GET /api/paperless/document/:id/download', - 'GET /api/paperless/test' - ], - - validators: { - configValidator: (config) => { - const errors = []; - - if (config.enabled) { - if (!config.hostUrl) { - errors.push('Host URL is required when Paperless integration is enabled'); - } else { - try { - new URL(config.hostUrl); - } catch { - errors.push('Host URL must be a valid URL'); - } - } - - if (!config.apiToken) { - errors.push('API Token is required when Paperless integration is enabled'); - } - } - - return { - valid: errors.length === 0, - errors - }; - } - }, - - statusCheck: async (config) => { - if (!config.enabled) { - return { status: 'disabled', message: 'Integration is disabled' }; - } - - if (!config.hostUrl || !config.apiToken) { - return { status: 'misconfigured', message: 'Missing required configuration' }; - } - - try { - // This would be implemented by the specific integration - const PaperlessIntegration = require('./api/paperlessEndpoints'); - return await PaperlessIntegration.testConnection(config); - } catch (error) { - return { status: 'error', message: error.message }; - } - }, - - metadata: { - documentationUrl: 'https://paperless-ngx.readthedocs.io/en/latest/api/', - supportLevel: 'community', - tags: ['documents', 'pdf', 'scanning', 'ocr'] - } - }); + this.registerIntegration('paperless', PaperlessIntegration.SCHEMA); // Future integrations can be added here // this.registerIntegration('nextcloud', { ... }); diff --git a/integrations/paperless.js b/integrations/paperless.js new file mode 100644 index 0000000..d490632 --- /dev/null +++ b/integrations/paperless.js @@ -0,0 +1,428 @@ +/** + * Paperless NGX Integration + * Combined integration schema and endpoint functionality for Paperless NGX document management system + */ + +const { TOKENMASK } = require('../src/constants.js'); + +class PaperlessIntegration { + /** + * Integration schema for Paperless NGX + * This schema defines the configuration options, endpoints, and validation for the Paperless NGX integration. + * * It includes: + * - Configuration schema for enabling/disabling the integration + * - Host URL and API token fields + * - Default configuration values + * - API endpoints for testing connection, searching documents, getting document info, and downloading documents + * - Validation functions for configuration + * - Status check function to verify connection and configuration + * - Metadata for documentation and support + * * This schema is used to register the integration with the integration manager and provide a consistent interface for interacting with Paperless NGX. + **/ + static SCHEMA = { + name: 'Paperless NGX', + description: 'Document management system integration for attaching documents to assets', + version: '1.0.0', + icon: 'document', + category: 'document-management', + + configSchema: { + enabled: { + type: 'boolean', + label: 'Enable Paperless Integration', + description: 'Enable integration with Paperless NGX document management system', + default: false, + required: false + }, + hostUrl: { + type: 'url', + label: 'Paperless Host URL', + description: 'The base URL of your Paperless NGX instance (e.g., https://paperless.example.com)', + placeholder: 'https://paperless.example.com', + required: true, + dependsOn: 'enabled' + }, + apiToken: { + type: 'password', + label: 'API Token', + description: 'Your Paperless NGX API token (found in your user settings)', + placeholder: 'Enter your API token', + required: true, + sensitive: true, + dependsOn: 'enabled' + } + }, + + defaultConfig: { + enabled: false, + hostUrl: '', + apiToken: '' + }, + + endpoints: [ + 'GET /api/paperless/test-connection', + 'GET /api/paperless/search', + 'GET /api/paperless/document/:id/info', + 'GET /api/paperless/document/:id/download', + 'GET /api/paperless/test' + ], + + validators: { + configValidator: (config) => { + const errors = []; + + if (config.enabled) { + if (!config.hostUrl) { + errors.push('Host URL is required when Paperless integration is enabled'); + } else { + try { + new URL(config.hostUrl); + } catch { + errors.push('Host URL must be a valid URL'); + } + } + + if (!config.apiToken) { + errors.push('API Token is required when Paperless integration is enabled'); + } + } + + return { + valid: errors.length === 0, + errors + }; + } + }, + + statusCheck: async (config) => { + if (!config.enabled) { + return { status: 'disabled', message: 'Integration is disabled' }; + } + + if (!config.hostUrl || !config.apiToken) { + return { status: 'misconfigured', message: 'Missing required configuration' }; + } + + try { + return await this.testConnection(config); + } catch (error) { + return { status: 'error', message: error.message }; + } + }, + + metadata: { + documentationUrl: 'https://paperless-ngx.readthedocs.io/en/latest/api/', + supportLevel: 'community', + tags: ['documents', 'pdf', 'scanning', 'ocr'] + } + }; + + /** + * Test connection to Paperless instance + */ + static async testConnection(config) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Host URL and API Token are required'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/?page_size=1`, { + headers: { + 'Authorization': `Token ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Invalid API token'); + } else if (response.status === 404) { + throw new Error('Paperless API not found - check your host URL'); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } + + const data = await response.json(); + return { + status: 'connected', + message: `Successfully connected to Paperless NGX (${data.count} documents available)`, + documentCount: data.count + }; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Connection timeout - Paperless instance may be slow or unreachable'); + } else if (error.code === 'ECONNREFUSED') { + throw new Error('Connection refused - check your host URL and network connectivity'); + } else if (error.code === 'ENOTFOUND') { + throw new Error('Host not found - check your host URL'); + } else { + throw error; + } + } + } + + /** + * Search Paperless documents + */ + static async searchDocuments(config, query, page = 1, pageSize = 20) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Paperless integration not configured'); + } + + try { + const url = new URL(`${config.hostUrl.replace(/\/$/, '')}/api/documents/`); + + if (query) { + url.searchParams.append('query', query); + } + url.searchParams.append('page', page.toString()); + url.searchParams.append('page_size', pageSize.toString()); + url.searchParams.append('ordering', '-created'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + const response = await fetch(url.toString(), { + headers: { + 'Authorization': `Token ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Search failed: HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Search timeout - Paperless instance may be slow or unreachable'); + } + console.error('Paperless search error:', error); + throw error; + } + } + + /** + * Get document information + */ + static async getDocumentInfo(config, documentId) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Paperless integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/`, { + headers: { + 'Authorization': `Token ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Document not found'); + } + throw new Error(`Failed to get document info: HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Request timeout - Paperless instance may be slow or unreachable'); + } + console.error('Failed to get document info:', error); + throw error; + } + } + + /** + * Proxy document download + */ + static async downloadDocument(config, documentId) { + if (!config.hostUrl || !config.apiToken) { + throw new Error('Paperless integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/download/`, { + headers: { + 'Authorization': `Token ${config.apiToken}` + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Document not found'); + } + throw new Error(`Download failed: HTTP ${response.status}`); + } + + return response; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Download timeout - Paperless instance may be slow or unreachable'); + } + console.error('Failed to download document:', error); + throw error; + } + } + + /** + * Register Paperless API routes with Express app + */ + static registerRoutes(app, getSettings) { + const BASE_PATH = process.env.BASE_PATH || ''; + + // Helper to get Paperless config + const getPaperlessConfig = async () => { + try { + const settings = await getSettings(); + const paperlessConfig = settings.integrationSettings?.paperless; + + if (!paperlessConfig?.enabled) { + throw new Error('Paperless integration is disabled'); + } + + return paperlessConfig; + } catch (error) { + throw new Error('Failed to get Paperless configuration'); + } + }; + + // Test Paperless connection + app.post(BASE_PATH + '/api/paperless/test-connection', async (req, res) => { + try { + let { hostUrl, apiToken } = req.body; + + if (!hostUrl || !apiToken) { + return res.status(400).json({ + success: false, + error: 'Host URL and API Token are required' + }); + } + + // If the token is masked, get the real token from stored settings + if (apiToken === TOKENMASK) { + try { + const settings = await getSettings(); + const paperlessConfig = settings.integrationSettings?.paperless; + if (paperlessConfig?.apiToken && paperlessConfig.apiToken !== TOKENMASK) { + apiToken = paperlessConfig.apiToken; + } else { + return res.status(400).json({ + success: false, + error: 'No saved API token found. Please enter a new token.' + }); + } + } catch (error) { + return res.status(400).json({ + success: false, + error: 'Failed to retrieve saved token: ' + error.message + }); + } + } + + const result = await this.testConnection({ hostUrl, apiToken }); + res.json({ success: true, ...result }); + } catch (error) { + console.error('Paperless connection test failed:', error); + res.status(400).json({ + success: false, + error: error.message + }); + } + }); + + // Search Paperless documents + app.get(BASE_PATH + '/api/paperless/search', async (req, res) => { + try { + const config = await getPaperlessConfig(); + const { q: query, page = 1, page_size: pageSize = 20 } = req.query; + + const results = await this.searchDocuments( + config, + query, + parseInt(page), + parseInt(pageSize) + ); + + res.json(results); + } catch (error) { + console.error('Paperless search failed:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Get Paperless document info + app.get(BASE_PATH + '/api/paperless/document/:id/info', async (req, res) => { + try { + const config = await getPaperlessConfig(); + const documentId = req.params.id; + + const info = await this.getDocumentInfo(config, documentId); + res.json(info); + } catch (error) { + console.error('Failed to get document info:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Proxy Paperless document download + app.get(BASE_PATH + '/api/paperless/document/:id/download', async (req, res) => { + try { + const config = await getPaperlessConfig(); + const documentId = req.params.id; + + const response = await this.downloadDocument(config, documentId); + + // Copy headers from Paperless response + response.headers.forEach((value, key) => { + if (key.toLowerCase() !== 'transfer-encoding') { + res.setHeader(key, value); + } + }); + + // Pipe the response + response.body.pipe(res); + } catch (error) { + console.error('Failed to download document:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Test endpoint for debugging + app.get(BASE_PATH + '/api/paperless/test', (req, res) => { + res.json({ + message: 'Paperless integration endpoints are working', + timestamp: new Date().toISOString() + }); + }); + + console.log('📄 Paperless NGX endpoints registered'); + } +} + +module.exports = PaperlessIntegration; \ No newline at end of file diff --git a/public/managers/integrations.js b/public/managers/integrations.js new file mode 100644 index 0000000..5c5ecf9 --- /dev/null +++ b/public/managers/integrations.js @@ -0,0 +1,488 @@ +// IntegrationsManager handles all integration-related functionality including loading, +// rendering, configuration, and testing of integrations in the settings modal +import { TOKENMASK } from '../src/constants.js'; + +export class IntegrationsManager { + constructor({ + setButtonLoading + }) { + this.setButtonLoading = setButtonLoading; + this.DEBUG = false; + } + + /** + * Initialize integrations - load and render them dynamically + */ + async initialize() { + await this.loadIntegrations(); + } + + /** + * Load and render integrations dynamically + */ + async loadIntegrations() { + const integrationsContainer = document.getElementById('integrationsContainer'); + const loadingElement = document.getElementById('integrationsLoading'); + const errorElement = document.getElementById('integrationsError'); + + if (!integrationsContainer) { + console.error('Integrations container not found'); + return; + } + + try { + if (loadingElement) loadingElement.style.display = 'block'; + if (errorElement) errorElement.style.display = 'none'; + + const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations`); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) { + throw new Error(responseValidation.errorMessage); + } + + const integrations = await response.json(); + + // Clear loading state + if (loadingElement) loadingElement.style.display = 'none'; + + // Clear existing content except loading/error elements + const existingIntegrations = integrationsContainer.querySelectorAll('.integration-section'); + existingIntegrations.forEach(el => el.remove()); + + if (integrations.length === 0) { + const noIntegrationsMsg = document.createElement('div'); + noIntegrationsMsg.style.cssText = 'text-align: center; padding: 2rem; color: var(--text-color-secondary);'; + noIntegrationsMsg.textContent = 'No integrations available'; + integrationsContainer.appendChild(noIntegrationsMsg); + return; + } + + // Group integrations by category + const categories = this.groupIntegrationsByCategory(integrations); + + // Render each category + for (const [category, categoryIntegrations] of Object.entries(categories)) { + const categorySection = this.renderIntegrationCategory(category, categoryIntegrations); + integrationsContainer.appendChild(categorySection); + } + + // Bind events for all rendered integrations + this.bindIntegrationEvents(); + + } catch (error) { + console.error('Failed to load integrations:', error); + if (loadingElement) loadingElement.style.display = 'none'; + if (errorElement) { + errorElement.style.display = 'block'; + errorElement.textContent = `Failed to load integrations: ${error.message}`; + } + } + } + + /** + * Group integrations by category + */ + groupIntegrationsByCategory(integrations) { + const categories = {}; + + integrations.forEach(integration => { + const category = integration.category || 'general'; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(integration); + }); + + return categories; + } + + /** + * Render an integration category section + */ + renderIntegrationCategory(category, integrations) { + const categorySection = document.createElement('div'); + categorySection.className = 'integration-category'; + + const categoryTitle = this.getCategoryDisplayName(category); + + integrations.forEach(integration => { + const integrationElement = this.renderIntegration(integration); + categorySection.appendChild(integrationElement); + }); + + return categorySection; + } + + /** + * Get display name for category + */ + getCategoryDisplayName(category) { + const categoryNames = { + 'document-management': 'Document Management', + 'communication': 'Communication', + 'monitoring': 'Monitoring', + 'backup': 'Backup & Sync', + 'general': 'General' + }; + + return categoryNames[category] || category.charAt(0).toUpperCase() + category.slice(1); + } + + /** + * Render a single integration + */ + renderIntegration(integration) { + const section = document.createElement('div'); + section.className = 'integration-section'; + section.dataset.integrationId = integration.id; + + const header = document.createElement('div'); + header.className = 'collapsible-header'; + header.innerHTML = ` +
+
+

${integration.name}

+

${integration.description || ''}

+
+
+ +
+
+ + `; + + const content = document.createElement('div'); + content.className = 'collapsible-content'; + content.style.display = 'none'; + + const fieldsContainer = document.createElement('div'); + fieldsContainer.className = 'integration-fields'; + + // Render fields based on schema + const fields = this.renderIntegrationFields(integration); + fieldsContainer.appendChild(fields); + + // Add test connection button if integration supports it + if (this.integrationSupportsTestConnection(integration)) { + const testButton = document.createElement('button'); + testButton.type = 'button'; + testButton.className = 'action-button test-integration-btn'; + testButton.dataset.integrationId = integration.id; + testButton.style.cssText = 'background: var(--success-color); margin-top: 1rem;'; + testButton.innerHTML = 'Test Connection
'; + fieldsContainer.appendChild(testButton); + } + + content.appendChild(fieldsContainer); + section.appendChild(header); + section.appendChild(content); + + // Make it collapsible + this.makeCollapsible(header, content); + + return section; + } + + /** + * Render integration fields based on schema + */ + renderIntegrationFields(integration) { + const container = document.createElement('div'); + container.className = 'integration-fields-container'; + + const schema = integration.configSchema || {}; + + Object.entries(schema).forEach(([fieldName, fieldConfig]) => { + // Skip the enabled field as it's handled by the toggle + if (fieldName === 'enabled') return; + + const fieldElement = this.renderIntegrationField(integration.id, fieldName, fieldConfig); + container.appendChild(fieldElement); + }); + + return container; + } + + /** + * Render a single integration field + */ + renderIntegrationField(integrationId, fieldName, fieldConfig) { + const fieldContainer = document.createElement('div'); + fieldContainer.className = 'form-group integration-field'; + fieldContainer.dataset.fieldName = fieldName; + + // Add dependency class if this field depends on another + if (fieldConfig.dependsOn) { + fieldContainer.classList.add('depends-on-' + fieldConfig.dependsOn); + } + + const label = document.createElement('label'); + label.textContent = fieldConfig.label || fieldName; + label.htmlFor = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; + + const fieldId = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; + let input; + + switch (fieldConfig.type) { + case 'password': + input = document.createElement('input'); + input.type = 'password'; + input.id = fieldId; + input.name = fieldName; + input.placeholder = fieldConfig.placeholder || ''; + if (fieldConfig.sensitive) { + input.dataset.sensitive = 'true'; + } + break; + + case 'url': + input = document.createElement('input'); + input.type = 'url'; + input.id = fieldId; + input.name = fieldName; + input.placeholder = fieldConfig.placeholder || ''; + break; + + case 'boolean': + // For boolean fields other than 'enabled', render as checkbox + input = document.createElement('div'); + input.className = 'checkbox-container'; + input.innerHTML = ` + + `; + break; + + default: + input = document.createElement('input'); + input.type = 'text'; + input.id = fieldId; + input.name = fieldName; + input.placeholder = fieldConfig.placeholder || ''; + } + + fieldContainer.appendChild(label); + fieldContainer.appendChild(input); + + // Add description if provided + if (fieldConfig.description) { + const description = document.createElement('small'); + description.className = 'field-description'; + description.textContent = fieldConfig.description; + fieldContainer.appendChild(description); + } + + return fieldContainer; + } + + /** + * Check if integration supports test connection + */ + integrationSupportsTestConnection(integration) { + return integration.endpoints && integration.endpoints.some(endpoint => + endpoint.includes('test-connection') || endpoint.includes('/test') + ); + } + + /** + * Make a section collapsible + */ + makeCollapsible(header, content) { + header.addEventListener('click', () => { + const isOpen = content.style.display !== 'none'; + content.style.display = isOpen ? 'none' : 'block'; + const arrow = header.querySelector('.collapsible-arrow'); + if (arrow) { + arrow.textContent = isOpen ? '▼' : '▲'; + } + }); + } + + /** + * Bind events for integration controls + */ + bindIntegrationEvents() { + // Enable/disable toggle events + document.querySelectorAll('.integration-section input[id$="Enabled"]').forEach(toggle => { + toggle.addEventListener('change', (e) => { + const integrationId = e.target.id.replace('Enabled', ''); + this.toggleIntegrationFields(integrationId, e.target.checked); + }); + + // Set initial state + const integrationId = toggle.id.replace('Enabled', ''); + this.toggleIntegrationFields(integrationId, toggle.checked); + }); + + // Sensitive field focus events for password fields + document.querySelectorAll('.integration-section input[data-sensitive="true"]').forEach(field => { + field.addEventListener('focus', () => { + if (field.value === TOKENMASK) { + field.value = ''; + field.placeholder = 'Enter new value...'; + } + }); + }); + + // Test connection button events + document.querySelectorAll('.test-integration-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const integrationId = e.target.dataset.integrationId; + this.testIntegrationConnection(integrationId, btn); + }); + }); + } + + /** + * Toggle integration fields visibility based on enabled state + */ + toggleIntegrationFields(integrationId, enabled) { + const section = document.querySelector(`[data-integration-id="${integrationId}"]`); + if (!section) return; + + // Toggle fields that depend on enabled state + const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); + dependentFields.forEach(field => { + field.style.display = enabled ? 'block' : 'none'; + }); + + // Toggle test button + const testBtn = section.querySelector('.test-integration-btn'); + if (testBtn) { + testBtn.style.display = enabled ? 'block' : 'none'; + } + } + + /** + * Test integration connection + */ + async testIntegrationConnection(integrationId, button) { + try { + this.setButtonLoading(button, true); + + // Collect current integration settings + const integrationConfig = this.collectIntegrationSettings(integrationId); + + const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations/${integrationId}/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(integrationConfig), + credentials: 'include' + }); + + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) { + throw new Error(responseValidation.errorMessage); + } + + const result = await response.json(); + + if (result.status === 'success') { + globalThis.toaster.show(`${integrationId} connection test successful!`, 'success'); + } else { + throw new Error(result.message || 'Connection test failed'); + } + + } catch (error) { + globalThis.logError(`${integrationId} connection test failed:`, error); + } finally { + this.setButtonLoading(button, false); + } + } + + /** + * Collect integration settings from form fields + */ + collectIntegrationSettings(integrationId) { + const section = document.querySelector(`[data-integration-id="${integrationId}"]`); + if (!section) return {}; + + const settings = {}; + + // Get enabled state + const enabledToggle = section.querySelector(`#${integrationId}Enabled`); + if (enabledToggle) { + settings.enabled = enabledToggle.checked; + } + + // Get all other fields + const fields = section.querySelectorAll('.integration-field input, .integration-field select, .integration-field textarea'); + fields.forEach(field => { + const fieldName = field.name || field.id.replace(integrationId, '').toLowerCase(); + + if (field.type === 'checkbox') { + settings[fieldName] = field.checked; + } else { + settings[fieldName] = field.value; + } + }); + + return settings; + } + + /** + * Collect all integration settings from the form + */ + collectAllIntegrationSettings() { + const integrationSettings = {}; + + // Find all integration sections + const integrationSections = document.querySelectorAll('.integration-section[data-integration-id]'); + + integrationSections.forEach(section => { + const integrationId = section.dataset.integrationId; + const settings = this.collectIntegrationSettings(integrationId); + + if (Object.keys(settings).length > 0) { + integrationSettings[integrationId] = settings; + } + }); + + return integrationSettings; + } + + /** + * Apply integration settings to the dynamically loaded form + */ + applyIntegrationSettingsToForm(integrationSettings) { + Object.entries(integrationSettings).forEach(([integrationId, config]) => { + const section = document.querySelector(`[data-integration-id="${integrationId}"]`); + if (!section) return; + + // Set enabled state + const enabledToggle = section.querySelector(`#${integrationId}Enabled`); + if (enabledToggle) { + enabledToggle.checked = config.enabled || false; + this.toggleIntegrationFields(integrationId, enabledToggle.checked); + } + + // Set field values + Object.entries(config).forEach(([fieldName, value]) => { + if (fieldName === 'enabled') return; // Already handled above + + const fieldId = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; + const field = section.querySelector(`#${fieldId}`); + + if (field) { + if (field.type === 'checkbox') { + field.checked = !!value; + } else { + // Handle sensitive fields + if (field.dataset.sensitive === 'true' && value) { + field.value = TOKENMASK; + field.setAttribute('data-has-saved-token', 'true'); + field.placeholder = 'Saved token hidden - focus to enter new token'; + } else { + field.value = value || ''; + } + } + } + }); + }); + } +} diff --git a/public/managers/settings.js b/public/managers/settings.js index fb5d6f1..b20e119 100644 --- a/public/managers/settings.js +++ b/public/managers/settings.js @@ -1,5 +1,6 @@ // SettingsManager handles all settings modal logic, loading, saving, and dashboard order drag/drop import { TOKENMASK } from '../src/constants.js'; +import { IntegrationsManager } from './integrations.js'; export class SettingsManager { constructor({ @@ -26,6 +27,12 @@ export class SettingsManager { this.renderDashboard = renderDashboard; this.selectedAssetId = null; this.DEBUG = false; + + // Initialize integrations manager + this.integrationsManager = new IntegrationsManager({ + setButtonLoading: this.setButtonLoading + }); + this._bindEvents(); this.defaultSettings = window.appConfig?.defaultSettings || { notificationSettings: { @@ -85,10 +92,6 @@ export class SettingsManager { exportSimpleDataBtn.addEventListener('click', () => this._exportSimpleData()); } - // Integrations will be bound dynamically when loaded - - // Handle token field changes will be bound dynamically per integration - document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { const tabId = btn.getAttribute('data-tab'); @@ -215,10 +218,10 @@ export class SettingsManager { document.getElementById('toggleEvents').checked = finalVisibility.events; // Load integrations dynamically - await this.loadIntegrations(); + await this.integrationsManager.loadIntegrations(); // Apply current integration settings to the dynamically loaded form - this.applyIntegrationSettingsToForm(settings.integrationSettings || {}); + this.integrationsManager.applyIntegrationSettingsToForm(settings.integrationSettings || {}); // Card visibility toggles if (typeof window.renderCardVisibilityToggles === 'function') { @@ -273,7 +276,7 @@ export class SettingsManager { active: document.getElementById('toggleCardWarrantiesActive')?.checked !== false } }, - integrationSettings: this.collectAllIntegrationSettings() + integrationSettings: this.integrationsManager.collectAllIntegrationSettings() }; const dashboardSections = document.querySelectorAll('#dashboardSections .sortable-item'); dashboardSections.forEach(section => { @@ -832,466 +835,4 @@ export class SettingsManager { // Convert to CSV string return rows.map(row => row.join(',')).join('\n'); } - - /** - * Load and render integrations dynamically - */ - async loadIntegrations() { - const integrationsContainer = document.getElementById('integrationsContainer'); - const loadingElement = document.getElementById('integrationsLoading'); - const errorElement = document.getElementById('integrationsError'); - - try { - loadingElement.style.display = 'block'; - errorElement.style.display = 'none'; - - const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations`); - const responseValidation = await globalThis.validateResponse(response); - if (responseValidation.errorMessage) { - throw new Error(responseValidation.errorMessage); - } - - const integrations = await response.json(); - - // Clear loading state - loadingElement.style.display = 'none'; - - // Clear existing content except loading/error elements - const existingIntegrations = integrationsContainer.querySelectorAll('.integration-section'); - existingIntegrations.forEach(el => el.remove()); - - if (integrations.length === 0) { - const noIntegrationsMsg = document.createElement('div'); - noIntegrationsMsg.style.cssText = 'text-align: center; padding: 2rem; color: var(--text-color-secondary);'; - noIntegrationsMsg.textContent = 'No integrations available'; - integrationsContainer.appendChild(noIntegrationsMsg); - return; - } - - // Group integrations by category - const categories = this.groupIntegrationsByCategory(integrations); - - // Render each category - for (const [category, categoryIntegrations] of Object.entries(categories)) { - const categorySection = this.renderIntegrationCategory(category, categoryIntegrations); - integrationsContainer.appendChild(categorySection); - } - - // Bind events for all rendered integrations - this.bindIntegrationEvents(); - - } catch (error) { - console.error('Failed to load integrations:', error); - loadingElement.style.display = 'none'; - errorElement.style.display = 'block'; - errorElement.textContent = `Failed to load integrations: ${error.message}`; - } - } - - /** - * Group integrations by category - */ - groupIntegrationsByCategory(integrations) { - const categories = {}; - - integrations.forEach(integration => { - const category = integration.category || 'general'; - if (!categories[category]) { - categories[category] = []; - } - categories[category].push(integration); - }); - - return categories; - } - - /** - * Render an integration category section - */ - renderIntegrationCategory(category, integrations) { - const categorySection = document.createElement('div'); - categorySection.className = 'integration-category'; - - const categoryTitle = this.getCategoryDisplayName(category); - - integrations.forEach(integration => { - const integrationElement = this.renderIntegration(integration); - categorySection.appendChild(integrationElement); - }); - - return categorySection; - } - - /** - * Get display name for category - */ - getCategoryDisplayName(category) { - const categoryNames = { - 'document-management': 'Document Management', - 'communication': 'Communication', - 'monitoring': 'Monitoring', - 'backup': 'Backup & Sync', - 'general': 'General' - }; - - return categoryNames[category] || category.charAt(0).toUpperCase() + category.slice(1); - } - - /** - * Render a single integration - */ - renderIntegration(integration) { - const section = document.createElement('div'); - section.className = 'integration-section'; - section.dataset.integrationId = integration.id; - - const header = document.createElement('div'); - header.className = 'collapsible-header'; - header.innerHTML = ` -
-
-

${integration.name}

-

${integration.description || ''}

-
-
- -
-
- - `; - - const content = document.createElement('div'); - content.className = 'collapsible-content'; - content.style.display = 'none'; - - const fieldsContainer = document.createElement('div'); - fieldsContainer.className = 'integration-fields'; - - // Render fields based on schema - const fields = this.renderIntegrationFields(integration); - fieldsContainer.appendChild(fields); - - // Add test connection button if integration supports it - if (this.integrationSupportsTestConnection(integration)) { - const testButton = document.createElement('button'); - testButton.type = 'button'; - testButton.className = 'action-button test-integration-btn'; - testButton.dataset.integrationId = integration.id; - testButton.style.cssText = 'background: var(--success-color); margin-top: 1rem;'; - testButton.innerHTML = 'Test Connection
'; - fieldsContainer.appendChild(testButton); - } - - content.appendChild(fieldsContainer); - section.appendChild(header); - section.appendChild(content); - - // Make it collapsible - this.makeCollapsible(header, content); - - return section; - } - - /** - * Render integration fields based on schema - */ - renderIntegrationFields(integration) { - const container = document.createElement('div'); - container.className = 'integration-fields-container'; - - const schema = integration.configSchema || {}; - - Object.entries(schema).forEach(([fieldName, fieldConfig]) => { - // Skip the enabled field as it's handled by the toggle - if (fieldName === 'enabled') return; - - const fieldElement = this.renderIntegrationField(integration.id, fieldName, fieldConfig); - container.appendChild(fieldElement); - }); - - return container; - } - - /** - * Render a single integration field - */ - renderIntegrationField(integrationId, fieldName, fieldConfig) { - const fieldContainer = document.createElement('div'); - fieldContainer.className = 'form-group integration-field'; - fieldContainer.dataset.fieldName = fieldName; - - // Add dependency class if this field depends on another - if (fieldConfig.dependsOn) { - fieldContainer.classList.add('depends-on-' + fieldConfig.dependsOn); - } - - const label = document.createElement('label'); - label.textContent = fieldConfig.label || fieldName; - label.htmlFor = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; - - const fieldId = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; - let input; - - switch (fieldConfig.type) { - case 'password': - input = document.createElement('input'); - input.type = 'password'; - input.id = fieldId; - input.name = fieldName; - input.placeholder = fieldConfig.placeholder || ''; - if (fieldConfig.sensitive) { - input.dataset.sensitive = 'true'; - } - break; - - case 'url': - input = document.createElement('input'); - input.type = 'url'; - input.id = fieldId; - input.name = fieldName; - input.placeholder = fieldConfig.placeholder || ''; - break; - - case 'boolean': - // For boolean fields other than 'enabled', render as checkbox - input = document.createElement('div'); - input.className = 'checkbox-container'; - input.innerHTML = ` - - `; - break; - - default: - input = document.createElement('input'); - input.type = 'text'; - input.id = fieldId; - input.name = fieldName; - input.placeholder = fieldConfig.placeholder || ''; - } - - fieldContainer.appendChild(label); - fieldContainer.appendChild(input); - - // Add description if provided - if (fieldConfig.description) { - const description = document.createElement('small'); - description.className = 'field-description'; - description.textContent = fieldConfig.description; - fieldContainer.appendChild(description); - } - - return fieldContainer; - } - - /** - * Check if integration supports test connection - */ - integrationSupportsTestConnection(integration) { - return integration.endpoints && integration.endpoints.some(endpoint => - endpoint.includes('test-connection') || endpoint.includes('/test') - ); - } - - /** - * Make a section collapsible - */ - makeCollapsible(header, content) { - header.addEventListener('click', () => { - const isOpen = content.style.display !== 'none'; - content.style.display = isOpen ? 'none' : 'block'; - const arrow = header.querySelector('.collapsible-arrow'); - if (arrow) { - arrow.textContent = isOpen ? '▼' : '▲'; - } - }); - } - - /** - * Bind events for integration controls - */ - bindIntegrationEvents() { - // Enable/disable toggle events - document.querySelectorAll('.integration-section input[id$="Enabled"]').forEach(toggle => { - toggle.addEventListener('change', (e) => { - const integrationId = e.target.id.replace('Enabled', ''); - this.toggleIntegrationFields(integrationId, e.target.checked); - }); - - // Set initial state - const integrationId = toggle.id.replace('Enabled', ''); - this.toggleIntegrationFields(integrationId, toggle.checked); - }); - - // Sensitive field focus events for password fields - document.querySelectorAll('.integration-section input[data-sensitive="true"]').forEach(field => { - field.addEventListener('focus', () => { - if (field.value === TOKENMASK) { - field.value = ''; - field.placeholder = 'Enter new value...'; - } - }); - }); - - // Test connection button events - document.querySelectorAll('.test-integration-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const integrationId = e.target.dataset.integrationId; - this.testIntegrationConnection(integrationId, btn); - }); - }); - } - - /** - * Toggle integration fields visibility based on enabled state - */ - toggleIntegrationFields(integrationId, enabled) { - const section = document.querySelector(`[data-integration-id="${integrationId}"]`); - if (!section) return; - - // Toggle fields that depend on enabled state - const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); - dependentFields.forEach(field => { - field.style.display = enabled ? 'block' : 'none'; - }); - - // Toggle test button - const testBtn = section.querySelector('.test-integration-btn'); - if (testBtn) { - testBtn.style.display = enabled ? 'block' : 'none'; - } - } - - /** - * Test integration connection - */ - async testIntegrationConnection(integrationId, button) { - try { - this.setButtonLoading(button, true); - - // Collect current integration settings - const integrationConfig = this.collectIntegrationSettings(integrationId); - - const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations/${integrationId}/test`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(integrationConfig), - credentials: 'include' - }); - - const responseValidation = await globalThis.validateResponse(response); - if (responseValidation.errorMessage) { - throw new Error(responseValidation.errorMessage); - } - - const result = await response.json(); - - if (result.status === 'success') { - globalThis.toaster.show(`${integrationId} connection test successful!`, 'success'); - } else { - throw new Error(result.message || 'Connection test failed'); - } - - } catch (error) { - globalThis.logError(`${integrationId} connection test failed:`, error); - } finally { - this.setButtonLoading(button, false); - } - } - - /** - * Collect integration settings from form fields - */ - collectIntegrationSettings(integrationId) { - const section = document.querySelector(`[data-integration-id="${integrationId}"]`); - if (!section) return {}; - - const settings = {}; - - // Get enabled state - const enabledToggle = section.querySelector(`#${integrationId}Enabled`); - if (enabledToggle) { - settings.enabled = enabledToggle.checked; - } - - // Get all other fields - const fields = section.querySelectorAll('.integration-field input, .integration-field select, .integration-field textarea'); - fields.forEach(field => { - const fieldName = field.name || field.id.replace(integrationId, '').toLowerCase(); - - if (field.type === 'checkbox') { - settings[fieldName] = field.checked; - } else { - settings[fieldName] = field.value; - } - }); - - return settings; - } - - /** - * Collect all integration settings from the form - */ - collectAllIntegrationSettings() { - const integrationSettings = {}; - - // Find all integration sections - const integrationSections = document.querySelectorAll('.integration-section[data-integration-id]'); - - integrationSections.forEach(section => { - const integrationId = section.dataset.integrationId; - const settings = this.collectIntegrationSettings(integrationId); - - if (Object.keys(settings).length > 0) { - integrationSettings[integrationId] = settings; - } - }); - - return integrationSettings; - } - - /** - * Apply integration settings to the dynamically loaded form - */ - applyIntegrationSettingsToForm(integrationSettings) { - Object.entries(integrationSettings).forEach(([integrationId, config]) => { - const section = document.querySelector(`[data-integration-id="${integrationId}"]`); - if (!section) return; - - // Set enabled state - const enabledToggle = section.querySelector(`#${integrationId}Enabled`); - if (enabledToggle) { - enabledToggle.checked = config.enabled || false; - this.toggleIntegrationFields(integrationId, enabledToggle.checked); - } - - // Set field values - Object.entries(config).forEach(([fieldName, value]) => { - if (fieldName === 'enabled') return; // Already handled above - - const fieldId = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; - const field = section.querySelector(`#${fieldId}`); - - if (field) { - if (field.type === 'checkbox') { - field.checked = !!value; - } else { - // Handle sensitive fields - if (field.dataset.sensitive === 'true' && value) { - field.value = TOKENMASK; - field.setAttribute('data-has-saved-token', 'true'); - field.placeholder = 'Saved token hidden - focus to enter new token'; - } else { - field.value = value || ''; - } - } - } - }); - }); - } } diff --git a/server.js b/server.js index 99aad14..5f031fb 100644 --- a/server.js +++ b/server.js @@ -26,7 +26,7 @@ const { sanitizeFileName } = require('./src/services/fileUpload/utils'); const packageJson = require('./package.json'); const { TOKENMASK } = require('./src/constants'); const { integrationManager } = require('./integrations/integrationManager'); -const PaperlessEndpoints = require('./integrations/api/paperlessEndpoints'); +const PaperlessIntegration = require('./integrations/paperless'); const app = express(); const PORT = process.env.PORT || 3000; @@ -2190,7 +2190,7 @@ app.post('/api/settings', (req, res) => { // --- INTEGRATION SYSTEM --- // Initialize and register integration routes -PaperlessEndpoints.registerRoutes(app, getAppSettings); +PaperlessIntegration.registerRoutes(app, getAppSettings); // Test notification endpoint app.post('/api/notification-test', async (req, res) => { From dc515fc8ca9824eef677d8a09e0a7cd42811e5df Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:18:06 -0700 Subject: [PATCH 12/66] fix test endpoint --- integrations/paperless.js | 4 ++-- public/managers/integrations.js | 6 +++--- server.js | 2 +- src/constants.js | 4 +++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/integrations/paperless.js b/integrations/paperless.js index d490632..c2694df 100644 --- a/integrations/paperless.js +++ b/integrations/paperless.js @@ -3,7 +3,7 @@ * Combined integration schema and endpoint functionality for Paperless NGX document management system */ -const { TOKENMASK } = require('../src/constants.js'); +const { API_TEST_SUCCESS, TOKENMASK } = require('../src/constants.js'); class PaperlessIntegration { /** @@ -151,7 +151,7 @@ class PaperlessIntegration { const data = await response.json(); return { - status: 'connected', + status: API_TEST_SUCCESS, message: `Successfully connected to Paperless NGX (${data.count} documents available)`, documentCount: data.count }; diff --git a/public/managers/integrations.js b/public/managers/integrations.js index 5c5ecf9..1aaf6f2 100644 --- a/public/managers/integrations.js +++ b/public/managers/integrations.js @@ -1,6 +1,6 @@ // IntegrationsManager handles all integration-related functionality including loading, // rendering, configuration, and testing of integrations in the settings modal -import { TOKENMASK } from '../src/constants.js'; +import { API_TEST_SUCCESS, TOKENMASK } from '../src/constants.js'; export class IntegrationsManager { constructor({ @@ -382,8 +382,8 @@ export class IntegrationsManager { const result = await response.json(); - if (result.status === 'success') { - globalThis.toaster.show(`${integrationId} connection test successful!`, 'success'); + if (result.status === API_TEST_SUCCESS) { + globalThis.toaster.show(result.message || `${integrationId} connection test successful!`); } else { throw new Error(result.message || 'Connection test failed'); } diff --git a/server.js b/server.js index 5f031fb..3daed8b 100644 --- a/server.js +++ b/server.js @@ -2156,7 +2156,7 @@ app.post('/api/integrations/:id/test', async (req, res) => { ); const result = await integrationManager.checkIntegrationStatus(integrationId, preparedConfig); - res.json(result); + res.status(200).json(result); } catch (error) { console.error(`Failed to test integration ${req.params.id}:`, error); res.status(400).json({ diff --git a/src/constants.js b/src/constants.js index 8d3df00..4e72ebc 100644 --- a/src/constants.js +++ b/src/constants.js @@ -28,4 +28,6 @@ export const FIELD_TYPES = { BOOLEAN: 'boolean', SELECT: 'select', TEXTAREA: 'textarea' -}; \ No newline at end of file +}; + +export const API_TEST_SUCCESS = 'connected'; From d403632106ef4e822f260bd413ea558019e9000c Mon Sep 17 00:00:00 2001 From: abite Date: Sat, 14 Jun 2025 21:49:15 -0500 Subject: [PATCH 13/66] feat: transform Paperless search into multi-integration external document linking system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGES: - Replace "Search Paperless Documents" buttons with "Link External Docs" across all modals - Modal now supports multi-document linking without closing NEW FEATURES: - Multi-integration external document search modal with extensible architecture - Immediate document population on modal open with pagination (15 docs/page) - Support for linking multiple documents in single session - Integration filter system (All Sources, Paperless NGX, etc.) - Real-time search with 300ms debouncing - Visual feedback for successfully linked documents BACKEND CHANGES: - Add /api/integrations/enabled endpoint to return active integrations - Enhance integration system to support frontend filtering FRONTEND ARCHITECTURE: - Create ExternalDocManager class for centralized document search/linking - Enhance ModalManager with attachExternalDocument() method for forward compatibility - Maintain backward compatibility with existing attachPaperlessDocument() UI/UX IMPROVEMENTS: - Compact, centered modal design (60vh max-height, 700px max-width) - Immediate document loading instead of empty search state - Pagination controls with document count display - Button state management (Link → Linked with checkmark and green styling) - Modal subtitle explaining multi-document linking capability - Success toast notifications with shortened display time - Loading states and error handling throughout TECHNICAL DETAILS: - Extensible integration architecture for future additions (Nextcloud, SharePoint, etc.) - CSS custom properties for consistent theming - Responsive design with proper viewport centering - Debounced search to prevent excessive API calls - Proper state management for pagination and search queries FILES MODIFIED: - public/index.html: Update button text, add external doc modal structure - public/styles.css: Add complete styling for external doc system - public/script.js: Implement ExternalDocManager class with pagination - public/managers/modalManager.js: Add attachExternalDocument method - server.js: Add /api/integrations/enabled endpoint This change transforms a Paperless-specific feature into a generic, extensible multi-integration document linking system while maintaining full backward compatibility and significantly improving user experience. --- package-lock.json | 4 +- public/index.html | 95 +++-- public/managers/externalDocManager.js | 535 ++++++++++++++++++++++++++ public/managers/modalManager.js | 11 + public/script.js | 10 + public/styles.css | 341 ++++++++++++++++ server.js | 24 ++ 7 files changed, 991 insertions(+), 29 deletions(-) create mode 100644 public/managers/externalDocManager.js diff --git a/package-lock.json b/package-lock.json index 95e0728..1a792ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dumbassets", - "version": "1.0.0", + "version": "1.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dumbassets", - "version": "1.0.0", + "version": "1.0.11", "dependencies": { "chart.js": "^4.4.9", "cookie-parser": "^1.4.7", diff --git a/public/index.html b/public/index.html index c768d95..ccea5f5 100644 --- a/public/index.html +++ b/public/index.html @@ -277,13 +277,13 @@

File Attachments

- +
@@ -302,12 +302,12 @@

File Attachments

-
@@ -327,12 +327,12 @@

File Attachments

-
@@ -487,12 +487,12 @@

File Attachments

-
@@ -512,12 +512,12 @@

File Attachments

-
@@ -537,12 +537,12 @@

File Attachments

-
@@ -570,6 +570,47 @@

File Attachments

+ + +
- +
+ + +
@@ -489,18 +502,19 @@

File Attachments

-
-
+
+
+ +
-
-
-
@@ -514,18 +528,19 @@

File Attachments

-
-
+
+
+ +
-
-
-
@@ -539,15 +554,6 @@

File Attachments

-
- -
@@ -574,7 +580,7 @@

File Attachments

-
- ${isSub ? `` \ No newline at end of file + ${isSub ? `` : ''} + + + + +
+ +
+ ${generateAssetInfoHTML(asset)} + ${maintenanceScheduleHtml} +
+ ${generateFileGridHTML(asset)} + ${asset.maintenanceEvents && asset.maintenanceEvents.length > 0 ? generateMaintenanceEventsHTML(asset.maintenanceEvents) : ''} + + `; + + // Set up event listeners for asset actions + const copyLinkBtn = assetDetails.querySelector('.copy-link-btn'); + const editBtn = assetDetails.querySelector('.edit-asset-btn'); + const duplicateBtn = assetDetails.querySelector('.duplicate-asset-btn'); + const deleteBtn = assetDetails.querySelector('.delete-asset-btn'); + const backBtn = assetDetails.querySelector('.back-to-parent-btn'); + + if (copyLinkBtn) { + copyLinkBtn.addEventListener('click', () => { + const url = new URL(window.location); + url.searchParams.set('asset', asset.id); + if (isSub) url.searchParams.set('sub', 'true'); + navigator.clipboard.writeText(url.toString()).then(() => { + globalThis.toaster?.show('Link copied to clipboard', 'success'); + }).catch(() => { + globalThis.toaster?.show('Failed to copy link', 'error'); + }); + }); + } + + if (editBtn) { + editBtn.addEventListener('click', () => { + if (isSub) { + openSubAssetModal(asset); + } else { + openAssetModal(asset); + } + }); + } + + if (duplicateBtn) { + duplicateBtn.addEventListener('click', () => { + const type = isSub ? 'subAsset' : 'asset'; + openDuplicateModal(type, asset.id); + }); + } + + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + if (isSub) { + deleteSubAsset(asset.id); + } else { + deleteAsset(asset.id); + } + }); + } + + if (backBtn && isSub) { + backBtn.addEventListener('click', () => { + // Navigate back to parent asset + renderAssetDetails(asset.parentId, false); + }); + } + + // Show sub-assets container if this is a main asset + if (!isSub) { + renderSubAssets(assetId); + } +} \ No newline at end of file From 19062c93c33471f5be23ce3af6f2d78a2f6355bc Mon Sep 17 00:00:00 2001 From: abiteman <30483819+abiteman@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:04:08 -0500 Subject: [PATCH 55/66] fix: resolve CSS integration style conflicts by moving and scoping selectors - Move integration CSS from styles.css to dedicated integration-styles.css - Scope generic .switch and .slider selectors with .integration-section parent - Prevent style leakage to other components using similar class names - Maintain proper CSS loading order with integration styles loading last - Consolidate all integration-related styles in single dedicated file --- public/assets/css/integration-styles.css | 163 +++++++++++++++++++++++ public/assets/css/styles.css | 161 ---------------------- 2 files changed, 163 insertions(+), 161 deletions(-) diff --git a/public/assets/css/integration-styles.css b/public/assets/css/integration-styles.css index c9f302c..591ae7e 100644 --- a/public/assets/css/integration-styles.css +++ b/public/assets/css/integration-styles.css @@ -385,3 +385,166 @@ /* object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); */ } + +/* Integration Settings Styles */ +.integration-section { + /* Remove conflicting margin and border styles - let collapsible-section handle them */ +} + +.integration-header-content { + display: flex; + text-align: left; + align-items: center; + gap: 1rem; +} + +.integration-logo { + width: 18px; + height: 18px; + /* border-radius: 50%; */ + /* background-color: var(--primary-transparent); */ + display: flex; + align-items: center; + justify-content: center; +} + +.integration-info h4 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-color); +} + +.integration-description { + margin: 0; + font-size: 0.8rem; + color: var(--text-color-secondary); + opacity: 0.8; +} + +.integration-fields-container { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 0.5rem; +} + +.integration-field { + display: flex; + align-items: center; + gap: 0.75rem; + justify-content: space-between; + /* flex-direction: column; */ +} + +.integration-field label { + font-weight: 500; + color: var(--text-color); + font-size: 0.875rem; +} + +.integration-field input[type="text"], +.integration-field input[type="url"], +.integration-field input[type="password"] { + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--background-color); + color: var(--text-color); + font-size: 0.875rem; +} + +.integration-field input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.field-description { + font-size: 0.75rem; + color: var(--text-color-secondary); + opacity: 0.8; + text-align: left; +} + +.test-integration-btn { + /* margin: 1rem 0; */ + /* float: right; */ + max-width: 140px; + background: var(--success-color); +} + +/* Integration field visibility based on dependencies */ +.integration-field.depends-on-enabled { + transition: opacity 0.2s ease, max-height 0.3s ease; +} + +.integration-field.depends-on-enabled[style*="display: none"] { + opacity: 0; + max-height: 0; + overflow: hidden; +} + +/* Integration category styles */ +.integration-category { + margin-bottom: 1.5rem; +} + +.integration-category:last-child { + margin-bottom: 0; +} + +/* Switch styles for integration toggles - scoped to integration section */ +.integration-section .switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; +} + +.integration-section .switch input { + opacity: 0; + width: 0; + height: 0; +} + +.integration-section .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: 0.4s; + border-radius: 24px; +} + +.integration-section .slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.4s; + border-radius: 50%; +} + +.integration-section input:checked + .slider { + background-color: var(--primary-color); +} + +.integration-section input:checked + .slider:before { + transform: translateX(26px); +} + +/* File source styling */ +.file-source { + font-size: 0.75rem; + color: var(--primary-color); + font-style: italic; + margin-top: 0.25rem; + display: block; +} diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css index 351e142..111cd65 100644 --- a/public/assets/css/styles.css +++ b/public/assets/css/styles.css @@ -1594,168 +1594,7 @@ input[type="date"][data-has-value="true"] { display: block; } -/* Integration Settings Styles */ -.integration-section { - /* Remove conflicting margin and border styles - let collapsible-section handle them */ -} - -.integration-header-content { - display: flex; - text-align: left; - align-items: center; - gap: 1rem; -} - -.integration-logo { - width: 18px; - height: 18px; - /* border-radius: 50%; */ - /* background-color: var(--primary-transparent); */ - display: flex; - align-items: center; - justify-content: center; -} - -.integration-info h4 { - margin: 0 0 0.25rem 0; - font-size: 1rem; - font-weight: 600; - color: var(--text-color); -} - -.integration-description { - margin: 0; - font-size: 0.8rem; - color: var(--text-color-secondary); - opacity: 0.8; -} - -.integration-fields-container { - display: flex; - flex-direction: column; - gap: 1rem; - margin: 0.5rem; -} - -.integration-field { - display: flex; - align-items: center; - gap: 0.75rem; - justify-content: space-between; - /* flex-direction: column; */ -} - -.integration-field label { - font-weight: 500; - color: var(--text-color); - font-size: 0.875rem; -} - -.integration-field input[type="text"], -.integration-field input[type="url"], -.integration-field input[type="password"] { - padding: 0.5rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - background: var(--background-color); - color: var(--text-color); - font-size: 0.875rem; -} - -.integration-field input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); -} - -.field-description { - font-size: 0.75rem; - color: var(--text-color-secondary); - opacity: 0.8; - text-align: left; -} - -.test-integration-btn { - /* margin: 1rem 0; */ - /* float: right; */ - max-width: 140px; - background: var(--success-color); -} - -/* Integration field visibility based on dependencies */ -.integration-field.depends-on-enabled { - transition: opacity 0.2s ease, max-height 0.3s ease; -} - -.integration-field.depends-on-enabled[style*="display: none"] { - opacity: 0; - max-height: 0; - overflow: hidden; -} - -/* Integration category styles */ -.integration-category { - margin-bottom: 1.5rem; -} - -.integration-category:last-child { - margin-bottom: 0; -} - -/* Switch styles for integration toggles */ -.switch { - position: relative; - display: inline-block; - width: 50px; - height: 24px; -} -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--border-color); - transition: 0.4s; - border-radius: 24px; -} - -.slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - transition: 0.4s; - border-radius: 50%; -} - -input:checked + .slider { - background-color: var(--primary-color); -} - -input:checked + .slider:before { - transform: translateX(26px); -} - -/* File source styling */ -.file-source { - font-size: 0.75rem; - color: var(--primary-color); - font-style: italic; - margin-top: 0.25rem; - display: block; -} .toast-container { position: fixed; From 04228214e04ddafdfb20d5d5ba1a518a7a904192 Mon Sep 17 00:00:00 2001 From: abiteman <30483819+abiteman@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:12:53 -0500 Subject: [PATCH 56/66] feat: disable Home Assistant integration with Coming Soon message - Add comingSoon flag to Home Assistant integration schema - Replace enable toggle with animated 'Coming Soon!' badge for disabled integrations - Hide all dependent fields and test buttons for coming soon integrations - Add gradient text styling with pulse animation for coming soon message - Ensure proper handling in settings load/save for disabled integrations - Feature ready for future activation by removing comingSoon flag --- integrations/homeassistant.js | 1 + public/assets/css/integration-styles.css | 33 +++++++++++++ public/managers/integrations.js | 60 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/integrations/homeassistant.js b/integrations/homeassistant.js index a8370fe..e82136d 100644 --- a/integrations/homeassistant.js +++ b/integrations/homeassistant.js @@ -27,6 +27,7 @@ class HomeAssistantIntegration { colorScheme: '#41BDF5', category: 'monitoring', apiEndpoint: API_HOMEASSISTANT_ENDPOINT, + comingSoon: true, configSchema: { enabled: { diff --git a/public/assets/css/integration-styles.css b/public/assets/css/integration-styles.css index 591ae7e..c393f6a 100644 --- a/public/assets/css/integration-styles.css +++ b/public/assets/css/integration-styles.css @@ -548,3 +548,36 @@ margin-top: 0.25rem; display: block; } + +/* Coming Soon message styling */ +.coming-soon-message { + display: flex; + align-items: center; + justify-content: center; +} + +.coming-soon-message span { + background: linear-gradient(45deg, #ff6b6b, #4ecdc4) !important; + -webkit-background-clip: text !important; + -webkit-text-fill-color: transparent !important; + background-clip: text !important; + font-weight: 600 !important; + font-size: 0.875rem !important; + padding: 0.25rem 0.5rem !important; + border-radius: 0.25rem !important; + background-color: rgba(79, 172, 254, 0.1) !important; + border: 1px solid rgba(79, 172, 254, 0.3) !important; + display: inline-block !important; + animation: pulse-glow 2s ease-in-out infinite alternate; +} + +@keyframes pulse-glow { + from { + box-shadow: 0 0 5px rgba(79, 172, 254, 0.2); + transform: scale(1); + } + to { + box-shadow: 0 0 10px rgba(79, 172, 254, 0.4); + transform: scale(1.02); + } +} diff --git a/public/managers/integrations.js b/public/managers/integrations.js index 84f7d8f..1b38b79 100644 --- a/public/managers/integrations.js +++ b/public/managers/integrations.js @@ -397,6 +397,32 @@ export class IntegrationsManager { break; case 'boolean': + // Check if this is the enable field for a "coming soon" integration + if (fieldName === 'enabled') { + const integration = this.integrations.get(integrationId); + if (integration && integration.comingSoon) { + // Show "Coming Soon!" message instead of toggle + input = document.createElement('div'); + input.className = 'coming-soon-message'; + input.innerHTML = ` + Coming Soon! + `; + break; + } + } + // For boolean fields other than 'enabled', render as checkbox input = document.createElement('div'); input.className = 'checkbox-container'; @@ -465,6 +491,13 @@ export class IntegrationsManager { this.toggleIntegrationFields(integrationId, toggle.checked); }); + // For coming soon integrations, ensure fields are always hidden + this.integrations.forEach((integration, integrationId) => { + if (integration.comingSoon) { + this.toggleIntegrationFields(integrationId, false); + } + }); + // Sensitive field focus events for password fields document.querySelectorAll('.integration-section input[data-sensitive="true"]').forEach(field => { field.addEventListener('focus', () => { @@ -494,6 +527,25 @@ export class IntegrationsManager { const section = document.querySelector(`[data-integration-id="${integrationId}"]`); if (!section) return; + // Check if this is a "coming soon" integration + const integration = this.integrations.get(integrationId); + const isComingSoon = integration && integration.comingSoon; + + // For coming soon integrations, always hide dependent fields and test button + if (isComingSoon) { + const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); + dependentFields.forEach(field => { + field.style.display = 'none'; + }); + + const testBtn = section.querySelector('.test-integration-btn'); + if (testBtn) { + testBtn.style.display = 'none'; + } + return; + } + + // Normal behavior for other integrations // Toggle fields that depend on enabled state const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); dependentFields.forEach(field => { @@ -603,6 +655,14 @@ export class IntegrationsManager { const section = document.querySelector(`[data-integration-id="${integrationId}"]`); if (!section) return; + // Check if this is a coming soon integration + const integration = this.integrations.get(integrationId); + if (integration && integration.comingSoon) { + // For coming soon integrations, just ensure fields are hidden + this.toggleIntegrationFields(integrationId, false); + return; + } + // Set enabled state const enabledToggle = section.querySelector(`#${integrationId}Enabled`); if (enabledToggle) { From 8aed97d802218a964d1eba6f521973b4b79ae67d Mon Sep 17 00:00:00 2001 From: abiteman <30483819+abiteman@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:25:16 -0500 Subject: [PATCH 57/66] fix: improve Coming Soon integration logic and add cache busting - Add cache-busting timestamp to integrations API call to prevent stale data - Simplify toggleIntegrationFields logic for Coming Soon integrations - Use display: flex instead of block for better field alignment - Ensure integrationSupportsTestConnection returns false for Coming Soon integrations - Reset loadingPromise to allow re-fetching when needed - Improve robustness of Coming Soon feature display --- public/managers/integrations.js | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/public/managers/integrations.js b/public/managers/integrations.js index 1b38b79..401b728 100644 --- a/public/managers/integrations.js +++ b/public/managers/integrations.js @@ -47,7 +47,7 @@ export class IntegrationsManager { if (loadingElement) loadingElement.style.display = 'block'; if (errorElement) errorElement.style.display = 'none'; - const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations`); + const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations?t=${new Date().getTime()}`); const responseValidation = await globalThis.validateResponse(response); if (responseValidation.errorMessage) { throw new Error(responseValidation.errorMessage); @@ -63,6 +63,7 @@ export class IntegrationsManager { console.log('Loaded integrations:', Array.from(this.integrations.keys())); + // Now that data is loaded, proceed with UI rendering // Clear loading state if (loadingElement) loadingElement.style.display = 'none'; @@ -78,6 +79,8 @@ export class IntegrationsManager { errorElement.style.display = 'block'; errorElement.textContent = `Failed to load integrations: ${error.message}`; } + } finally { + this.loadingPromise = null; // Reset promise to allow re-fetching if needed } } @@ -460,6 +463,7 @@ export class IntegrationsManager { * Check if integration supports test connection */ integrationSupportsTestConnection(integration) { + if (integration.comingSoon) return false; return integration.endpoints && integration.endpoints.some(endpoint => endpoint.includes('test-connection') || endpoint.includes('/test') ); @@ -527,35 +531,24 @@ export class IntegrationsManager { const section = document.querySelector(`[data-integration-id="${integrationId}"]`); if (!section) return; - // Check if this is a "coming soon" integration const integration = this.integrations.get(integrationId); const isComingSoon = integration && integration.comingSoon; // For coming soon integrations, always hide dependent fields and test button if (isComingSoon) { - const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); - dependentFields.forEach(field => { - field.style.display = 'none'; - }); - - const testBtn = section.querySelector('.test-integration-btn'); - if (testBtn) { - testBtn.style.display = 'none'; - } - return; + enabled = false; } - // Normal behavior for other integrations // Toggle fields that depend on enabled state const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); dependentFields.forEach(field => { - field.style.display = enabled ? 'block' : 'none'; + field.style.display = enabled ? 'flex' : 'none'; // Use flex for proper alignment }); // Toggle test button const testBtn = section.querySelector('.test-integration-btn'); if (testBtn) { - testBtn.style.display = enabled ? 'block' : 'none'; + testBtn.style.display = enabled ? 'flex' : 'none'; // Use flex for proper alignment } } @@ -655,10 +648,9 @@ export class IntegrationsManager { const section = document.querySelector(`[data-integration-id="${integrationId}"]`); if (!section) return; - // Check if this is a coming soon integration const integration = this.integrations.get(integrationId); if (integration && integration.comingSoon) { - // For coming soon integrations, just ensure fields are hidden + // For coming soon integrations, just ensure fields are hidden and do not save any settings this.toggleIntegrationFields(integrationId, false); return; } From 5112c47c1a0de605832ca331d56ce115b10017bb Mon Sep 17 00:00:00 2001 From: abiteman <30483819+abiteman@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:34:46 -0500 Subject: [PATCH 58/66] debug: add schema logging to /api/integrations endpoint - Log the loaded Home Assistant integration schema for debugging - Help diagnose why comingSoon flag isn't appearing in API response --- server.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server.js b/server.js index f790a88..798c919 100644 --- a/server.js +++ b/server.js @@ -2189,13 +2189,17 @@ app.get('/api/settings', (req, res) => { // Get available integrations for settings UI app.get('/api/integrations', (req, res) => { - try { - const integrations = integrationManager.getIntegrationsForSettings(); - res.json(integrations); - } catch (error) { - console.error('Failed to get integrations:', error); - res.status(500).json({ error: 'Failed to get integrations' }); + const integrations = integrationManager.getAllIntegrations(); + + // Debug: Log Home Assistant schema + const haIntegration = integrations.find(i => i.id === 'homeassistant'); + if (haIntegration) { + console.log('Loaded Home Assistant Schema:', JSON.stringify(haIntegration, null, 2)); + } else { + console.log('Home Assistant integration not found in loaded schemas'); } + + res.json(integrations); }); // Get enabled integrations for external document search From 39dced14cc7bcb3ad594c494d1813c52bafc236e Mon Sep 17 00:00:00 2001 From: abiteman <30483819+abiteman@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:55:00 -0500 Subject: [PATCH 59/66] fix: Add comingSoon flag to integration registration and implement proper module cache clearing - Fixed integration schema registration to include comingSoon flag in final integration object - Implemented proper module cache clearing for fresh integration loading on restart - Store integration classes as instance variables for proper route registration - This resolves the issue where comingSoon flag was present in schema files but not appearing in API responses --- integrations/integrationManager.js | 37 ++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/integrations/integrationManager.js b/integrations/integrationManager.js index e853811..163408f 100644 --- a/integrations/integrationManager.js +++ b/integrations/integrationManager.js @@ -4,13 +4,20 @@ */ const { TOKENMASK } = require('../src/constants'); -const PaperlessIntegration = require('./paperless'); // Import Paperless schema -const PapraIntegration = require('./papra'); // Import Papra schema -const HomeAssistantIntegration = require('./homeassistant'); // Import Home Assistant schema + +function clearModuleCache(modulePath) { + try { + const fullPath = require.resolve(modulePath); + delete require.cache[fullPath]; + } catch (err) { + console.warn(`Failed to clear cache for ${modulePath}:`, err.message); + } +} class IntegrationManager { constructor() { this.integrations = new Map(); + this.integrationClasses = {}; // Store integration classes for route registration this.registerBuiltInIntegrations(); } @@ -18,14 +25,25 @@ class IntegrationManager { * Register built-in integrations */ registerBuiltInIntegrations() { + // Clear module cache for all integrations to ensure fresh loading + clearModuleCache('./paperless'); + clearModuleCache('./papra'); + clearModuleCache('./homeassistant'); + + // Import integrations fresh after cache clearing + this.integrationClasses.PaperlessIntegration = require('./paperless'); + this.integrationClasses.PapraIntegration = require('./papra'); + this.integrationClasses.HomeAssistantIntegration = require('./homeassistant'); + // Register Paperless NGX integration - this.registerIntegration('paperless', PaperlessIntegration.SCHEMA); + this.registerIntegration('paperless', this.integrationClasses.PaperlessIntegration.SCHEMA); // Register Papra integration - this.registerIntegration('papra', PapraIntegration.SCHEMA); + this.registerIntegration('papra', this.integrationClasses.PapraIntegration.SCHEMA); // Register Home Assistant integration - this.registerIntegration('homeassistant', HomeAssistantIntegration.SCHEMA); + console.log('🔍 Home Assistant SCHEMA before registration:', JSON.stringify(this.integrationClasses.HomeAssistantIntegration.SCHEMA, null, 2)); + this.registerIntegration('homeassistant', this.integrationClasses.HomeAssistantIntegration.SCHEMA); // Future integrations can be added here // this.registerIntegration('nextcloud', { ... }); @@ -41,9 +59,9 @@ class IntegrationManager { * It allows each integration to define its own API endpoints */ registerRoutes(app, getSettings) { - PaperlessIntegration.registerRoutes(app, getSettings); - PapraIntegration.registerRoutes(app, getSettings); - HomeAssistantIntegration.registerRoutes(app, getSettings); + this.integrationClasses.PaperlessIntegration.registerRoutes(app, getSettings); + this.integrationClasses.PapraIntegration.registerRoutes(app, getSettings); + this.integrationClasses.HomeAssistantIntegration.registerRoutes(app, getSettings); // Future integrations can register their routes here } @@ -63,6 +81,7 @@ class IntegrationManager { logoHref: config.logoHref || null, // Optional logo URL for frontend display colorScheme: config.colorScheme || 'default', // Default color scheme for UI category: config.category || 'general', + comingSoon: config.comingSoon || false, // Coming soon flag for integrations under development // Configuration schema for settings UI configSchema: config.configSchema || {}, From a295aae0bee412c6b9c82bc0a21d380dca18ecb6 Mon Sep 17 00:00:00 2001 From: abiteman <30483819+abiteman@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:59:37 -0500 Subject: [PATCH 60/66] fix: Add missing exports to assetRenderer.js and clean up debug logging - Export formatFilePath, initRenderer, updateState, updateSelectedIds, and renderAssetDetails functions - Remove debug console.log from integrationManager.js - Resolves 'does not provide an export named formatFilePath' JavaScript module error - Frontend should now load properly with Home Assistant 'Coming Soon!' message displaying correctly --- integrations/integrationManager.js | 1 - src/services/render/assetRenderer.js | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/integrations/integrationManager.js b/integrations/integrationManager.js index 163408f..baf86a5 100644 --- a/integrations/integrationManager.js +++ b/integrations/integrationManager.js @@ -42,7 +42,6 @@ class IntegrationManager { this.registerIntegration('papra', this.integrationClasses.PapraIntegration.SCHEMA); // Register Home Assistant integration - console.log('🔍 Home Assistant SCHEMA before registration:', JSON.stringify(this.integrationClasses.HomeAssistantIntegration.SCHEMA, null, 2)); this.registerIntegration('homeassistant', this.integrationClasses.HomeAssistantIntegration.SCHEMA); // Future integrations can be added here diff --git a/src/services/render/assetRenderer.js b/src/services/render/assetRenderer.js index 151e707..48e7f8f 100644 --- a/src/services/render/assetRenderer.js +++ b/src/services/render/assetRenderer.js @@ -578,4 +578,13 @@ function renderAssetDetails(assetId, isSubAsset = false) { if (!isSub) { renderSubAssets(assetId); } -} \ No newline at end of file +} + +// Export functions for use by other modules +export { + initRenderer, + updateState, + updateSelectedIds, + renderAssetDetails, + formatFilePath +}; \ No newline at end of file From 79b0f36d40a411fcb1ba1b478a38615591853977 Mon Sep 17 00:00:00 2001 From: abiteman <30483819+abiteman@users.noreply.github.com> Date: Thu, 17 Jul 2025 00:12:24 -0500 Subject: [PATCH 61/66] style: Update Coming Soon message styling with blue-green theme and smoother animation - Changed gradient colors from red/orange to blue-green (#14b8a6, #06b6d4) - Updated background and border colors to match teal theme - Smoothed animation: increased duration from 2s to 3s - Reduced scaling from 1.02 to 1.01 for more subtle effect - Improved keyframe animation for gentler pulse glow - Enhanced overall visual polish for Coming Soon integrations --- public/assets/css/integration-styles.css | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/public/assets/css/integration-styles.css b/public/assets/css/integration-styles.css index c393f6a..7088bb0 100644 --- a/public/assets/css/integration-styles.css +++ b/public/assets/css/integration-styles.css @@ -557,7 +557,7 @@ } .coming-soon-message span { - background: linear-gradient(45deg, #ff6b6b, #4ecdc4) !important; + background: linear-gradient(45deg, #14b8a6, #06b6d4) !important; -webkit-background-clip: text !important; -webkit-text-fill-color: transparent !important; background-clip: text !important; @@ -565,19 +565,19 @@ font-size: 0.875rem !important; padding: 0.25rem 0.5rem !important; border-radius: 0.25rem !important; - background-color: rgba(79, 172, 254, 0.1) !important; - border: 1px solid rgba(79, 172, 254, 0.3) !important; + background-color: rgba(20, 184, 166, 0.1) !important; + border: 1px solid rgba(20, 184, 166, 0.3) !important; display: inline-block !important; - animation: pulse-glow 2s ease-in-out infinite alternate; + animation: smooth-pulse 3s ease-in-out infinite; } -@keyframes pulse-glow { - from { - box-shadow: 0 0 5px rgba(79, 172, 254, 0.2); +@keyframes smooth-pulse { + 0%, 100% { + box-shadow: 0 0 8px rgba(20, 184, 166, 0.3); transform: scale(1); } - to { - box-shadow: 0 0 10px rgba(79, 172, 254, 0.4); - transform: scale(1.02); + 50% { + box-shadow: 0 0 12px rgba(20, 184, 166, 0.5); + transform: scale(1.01); } } From 2ff6c6c99c0ff311dd00f16209c5cafbdc1812b3 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Tue, 22 Jul 2025 15:31:18 -0700 Subject: [PATCH 62/66] fix save settings bug from not loading all integration settings after saving settings --- public/managers/settings.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/public/managers/settings.js b/public/managers/settings.js index 839e63b..a3c95b7 100644 --- a/public/managers/settings.js +++ b/public/managers/settings.js @@ -295,14 +295,16 @@ export class SettingsManager { const settingsCopy = { ...settings }; localStorage.setItem(this.localSettingsStorageKey, JSON.stringify(settingsCopy)); - // this.closeSettingsModal(); - - globalThis.toaster.show('Settings saved'); if (!this.selectedAssetId && typeof this.renderDashboard === 'function') { this.renderDashboard(); } - this.loadActiveIntegrations(); // reload integrations + + // this.closeSettingsModal(); // Don't close modal automatically, let user decide + // Reload settings to ensure everything is up-to-date + await this.loadSettings(); + + globalThis.toaster.show('Settings saved'); } catch (error) { globalThis.logError('Failed to save settings:', error.message); } finally { From 4621efd5e9d6917c152488e01a68f02c2ba79cff Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Tue, 22 Jul 2025 21:40:55 -0700 Subject: [PATCH 63/66] readjust styles on file preview grid on asset/subassets view - update to put each attachment type in their own div/row - remove set height on file-preview so less space is taken up - adjust file-info /filename pill so it stays contained within their own respective containers --- public/assets/css/styles.css | 13 +++++++------ src/services/render/assetRenderer.js | 20 +++++++++++++++++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css index 111cd65..bd680fa 100644 --- a/public/assets/css/styles.css +++ b/public/assets/css/styles.css @@ -1806,6 +1806,7 @@ a:hover { gap: 0.5rem; width: 100%; margin-bottom: 1rem; + max-width: 100%; /* Ensure grid doesn't overflow */ } .preview-grid .file-preview-item { position: relative; @@ -1951,7 +1952,7 @@ a:hover { flex-direction: column; align-items: center; transition: all 0.2s ease; - height: 250px; + /* height: 250px; */ overflow: hidden; } @@ -1981,12 +1982,12 @@ a:hover { stroke: var(--primary-color); margin: auto; transition: transform 0.2s ease; - flex: 1; + /* flex: 1; */ } .file-item.photo .asset-image { width: 100%; - height: 150px; + height: 100%; object-fit: contain; border-radius: var(--app-border-radius); border: none; @@ -1996,9 +1997,9 @@ a:hover { .file-label { font-size: 0.8rem; text-align: center; - padding: 0.4rem 0.8rem; - min-width: 60%; - max-width: 85%; + padding: 0.4rem 0.5rem; + /* min-width: 60%; */ + max-width: 100%; background-color: var(--primary-transparent); border-radius: 1rem; color: var(--primary-color); diff --git a/src/services/render/assetRenderer.js b/src/services/render/assetRenderer.js index 48e7f8f..6e0849e 100644 --- a/src/services/render/assetRenderer.js +++ b/src/services/render/assetRenderer.js @@ -242,10 +242,10 @@ function generateAssetInfoHTML(asset) { /** * Format filename for display with truncation if needed * @param {string} fileName - The original filename - * @param {number} maxLength - Maximum length (default 15) + * @param {number} maxLength - Maximum length (default 30) * @returns {string} Formatted filename */ -function formatDisplayFileName(fileName, maxLength = 15) { +function formatDisplayFileName(fileName, maxLength = 30) { if (!fileName || fileName.length <= maxLength) { return fileName || 'Unknown File'; } @@ -273,9 +273,11 @@ function formatDisplayFileName(fileName, maxLength = 15) { function generateFileGridHTML(asset) { let html = ''; - + // create a div and add compact-files-grid class to it + // Handle multiple photos if (asset.photoPaths && Array.isArray(asset.photoPaths) && asset.photoPaths.length > 0) { + html += `
`; asset.photoPaths.forEach((photoPath, index) => { const photoInfo = asset.photoInfo?.[index] || {}; const fileName = photoInfo.originalName || photoPath.split('/').pop(); @@ -298,7 +300,9 @@ function generateFileGridHTML(asset) {
`; }); + html += ``; } else if (asset.photoPath) { + html += `
`; // Backward compatibility for single photo const photoInfo = asset.photoInfo?.[0] || {}; const fileName = photoInfo.originalName || asset.photoPath.split('/').pop(); @@ -313,10 +317,12 @@ function generateFileGridHTML(asset) {
`; + html += ``; } // Handle multiple receipts if (asset.receiptPaths && Array.isArray(asset.receiptPaths) && asset.receiptPaths.length > 0) { + html += `
`; asset.receiptPaths.forEach((receiptPath, index) => { const receiptInfo = asset.receiptInfo?.[index] || {}; const fileName = receiptInfo.originalName || receiptPath.split('/').pop(); @@ -335,7 +341,9 @@ function generateFileGridHTML(asset) {
`; }); + html += ``; } else if (asset.receiptPath) { + html += `
`; // Backward compatibility for single receipt const receiptInfo = asset.receiptInfo?.[0] || {}; const fileName = receiptInfo.originalName || asset.receiptPath.split('/').pop(); @@ -353,10 +361,12 @@ function generateFileGridHTML(asset) {
`; + html += ``; } // Handle multiple manuals if (asset.manualPaths && Array.isArray(asset.manualPaths) && asset.manualPaths.length > 0) { + html += `
`; asset.manualPaths.forEach((manualPath, index) => { const manualInfo = asset.manualInfo?.[index] || {}; const fileName = manualInfo.originalName || manualPath.split('/').pop(); @@ -378,12 +388,14 @@ function generateFileGridHTML(asset) {
`; }); + html += ``; } else if (asset.manualPath) { // Backward compatibility for single manual const manualInfo = asset.manualInfo?.[0] || {}; const fileName = manualInfo.originalName || asset.manualPath.split('/').pop(); const integrationClass = manualInfo.integrationId ? ` ${manualInfo.integrationId}-document` : ''; const integrationBadge = manualInfo.integrationId ? getIntegrationBadge(manualInfo.integrationId) : ''; + html += `
`; html += ` `; + html += `
`; } + html += `
`; // Close the compact-files-grid div return html || ''; } From ea44564beb57f5c5e775b2c90049699a29619913 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Tue, 22 Jul 2025 23:41:16 -0700 Subject: [PATCH 64/66] revert unused changes --- src/services/render/assetRenderer.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/render/assetRenderer.js b/src/services/render/assetRenderer.js index 6e0849e..4da858e 100644 --- a/src/services/render/assetRenderer.js +++ b/src/services/render/assetRenderer.js @@ -273,7 +273,6 @@ function formatDisplayFileName(fileName, maxLength = 30) { function generateFileGridHTML(asset) { let html = ''; - // create a div and add compact-files-grid class to it // Handle multiple photos if (asset.photoPaths && Array.isArray(asset.photoPaths) && asset.photoPaths.length > 0) { @@ -413,8 +412,7 @@ function generateFileGridHTML(asset) { `; html += `
`; } - - html += `
`; // Close the compact-files-grid div + return html || ''; } From b55538f371e0512014a9b285141fdb5986c1f46e Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:30:45 -0700 Subject: [PATCH 65/66] Fix for keeping file attachment collapsible sections open when linking external doc --- public/js/collapsible.js | 23 ++++++++++++++++------- public/managers/settings.js | 7 +++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/public/js/collapsible.js b/public/js/collapsible.js index 874d440..4e77010 100644 --- a/public/js/collapsible.js +++ b/public/js/collapsible.js @@ -64,16 +64,25 @@ function setupCollapsible(section) { header.addEventListener('click', header._clickHandler); - // Set initial state based on data attribute - const startCollapsed = section.getAttribute('data-collapsed') === 'true'; + // Preserve current expanded state if section is already expanded + const isCurrentlyExpanded = !section.classList.contains('collapsed') && content.style.height !== '0px' && content.style.height !== ''; - if (startCollapsed) { - section.classList.add('collapsed'); - content.style.height = '0px'; - } else { + if (isCurrentlyExpanded) { + // Section is already expanded, just recalculate height to ensure proper sizing section.classList.remove('collapsed'); - // Make sure the content has rendered before calculating height calculateCollapsibleContentHeight(content); + } else { + // Set initial state based on data attribute for new or collapsed sections + const startCollapsed = section.getAttribute('data-collapsed') === 'true'; + + if (startCollapsed) { + section.classList.add('collapsed'); + content.style.height = '0px'; + } else { + section.classList.remove('collapsed'); + // Make sure the content has rendered before calculating height + calculateCollapsibleContentHeight(content); + } } } diff --git a/public/managers/settings.js b/public/managers/settings.js index a3c95b7..0dbae0b 100644 --- a/public/managers/settings.js +++ b/public/managers/settings.js @@ -295,15 +295,14 @@ export class SettingsManager { const settingsCopy = { ...settings }; localStorage.setItem(this.localSettingsStorageKey, JSON.stringify(settingsCopy)); + // Reload settings to ensure everything is up-to-date + await this.loadSettings(); + // this.closeSettingsModal(); // Don't close modal automatically, let user decide if (!this.selectedAssetId && typeof this.renderDashboard === 'function') { this.renderDashboard(); } - // this.closeSettingsModal(); // Don't close modal automatically, let user decide - // Reload settings to ensure everything is up-to-date - await this.loadSettings(); - globalThis.toaster.show('Settings saved'); } catch (error) { globalThis.logError('Failed to save settings:', error.message); From fa7adb550292b273341cc48c2e52527c29e8bec2 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:03:44 -0700 Subject: [PATCH 66/66] update paperless endpoints - preview should now pull from by id/thumbs/ in paperless - download: append query param for original so it always downloads the original file to handle any file type --- integrations/paperless.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/integrations/paperless.js b/integrations/paperless.js index de45227..b259445 100644 --- a/integrations/paperless.js +++ b/integrations/paperless.js @@ -271,7 +271,7 @@ class PaperlessIntegration { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); - const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/download/`, { + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/download/?original=true`, { headers: { 'Authorization': `Token ${config.apiToken}` }, @@ -422,21 +422,27 @@ class PaperlessIntegration { } }); - // Proxy Paperless document preview for images (for UI display) + // Proxy Paperless document preview/thumbnail for images (optimized for UI display) app.get(BASE_PATH + `/${API_PAPERLESS_ENDPOINT}/document/:id/preview`, async (req, res) => { try { const config = await getPaperlessConfig(); const documentId = req.params.id; - const response = await this.downloadDocument(config, documentId); + // Call the Paperless thumbnail API endpoint for optimized image previews + const thumbnailUrl = `${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/thumb/`; - // Only allow image content types for preview - const contentType = response.headers.get('content-type'); - if (!contentType || !contentType.startsWith('image/')) { - return res.status(400).json({ error: 'Document is not an image' }); + const response = await fetch(thumbnailUrl, { + headers: { + 'Authorization': `Token ${config.apiToken}` + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch thumbnail: ${response.status} ${response.statusText}`); } // Set appropriate headers for image display + const contentType = response.headers.get('content-type') || 'image/webp'; res.setHeader('content-type', contentType); res.setHeader('cache-control', 'private, max-age=3600'); // Cache for 1 hour