diff --git a/.cursorrules b/.cursorrules index 9ff06c8..f93bff1 100644 --- a/.cursorrules +++ b/.cursorrules @@ -323,4 +323,58 @@ try { - Apprise integration for external notifications - Queue management to prevent notification spam - Timezone-aware scheduling with Luxon -- Sanitized message formatting for security \ No newline at end of file +- Sanitized message formatting for security + +## Integration System + +### Adding New Integrations +DumbAssets uses a schema-driven integration system that automatically generates UI and handles configuration management. The integration system is now managed centrally by the Integration Manager, which is responsible for importing, registering, and routing all integrations. Follow these steps to add a new integration: + +#### 1. Backend Integration Setup + +- **Create Integration File** (`/integrations/your-integration.js`): + - Export a class with a static `SCHEMA` property and a static `registerRoutes(app, getSettings)` method. + - The `SCHEMA` defines config fields, validation, endpoints, and metadata. + - The `registerRoutes` method should register all Express routes for the integration. + +- **Register Integration in Integration Manager** (`/integrations/integrationManager.js`): + - Import your integration at the top of the file. + - In `registerBuiltInIntegrations()`, call `this.registerIntegration('your-integration-id', YourIntegration.SCHEMA);` + - The Integration Manager is now responsible for calling each integration's `registerRoutes` method via its own `registerRoutes(app, getSettings)` function. This means only the Integration Manager imports integration files, and all route registration is centralized. + +- **Register All Integration Routes in server.js**: + - In `server.js`, after initializing the app and before starting the server, call `integrationManager.registerRoutes(app, getSettings)`. + - You do NOT need to import individual integrations in `server.js`. + +#### 2. Frontend Integration (Automatic) +- The frontend dynamically loads all integrations and their schemas from the `/api/integrations` endpoint. +- The UI is generated based on the schema, including validation, field types, and test connection functionality. +- Add optional integration-specific styles in `/public/assets/css/your-integration-styles.css` if needed. + +#### 3. Integration Schema Reference +- See the Paperless NGX integration for a full example of a schema and route registration. +- Supported field types: text, password, url, number, select, checkbox, textarea, boolean. +- Mark sensitive fields with `sensitive: true` (these are masked in the UI and handled securely). + +#### 4. Error Handling and Testing +- Use consistent error response format: `{ success: false, message: 'Error description' }` +- Implement proper timeout handling (default: 10000ms) +- Provide meaningful error messages for users +- Log detailed errors server-side for debugging +- Implement a `testConnection` static method for connectivity validation + +#### 5. Integration Development Checklist +- [ ] Create integration class in `/integrations/` with SCHEMA and registerRoutes +- [ ] Register integration in `integrationManager.js` (import and add to `registerBuiltInIntegrations`) +- [ ] Integration Manager will call your integration's `registerRoutes` automatically +- [ ] Call `integrationManager.registerRoutes(app, getSettings)` in `server.js` +- [ ] Test UI generation, validation, and connection from the frontend +- [ ] Add custom styles if needed +- [ ] Document any special configuration requirements + +#### 6. Key Conventions +- All backend integration logic and route registration is managed by the Integration Manager +- Only the Integration Manager imports integration files +- All integrations must provide a static `registerRoutes(app, getSettings)` method +- The frontend is schema-driven and requires no manual code changes for new integrations +- Use the Paperless NGX integration as a reference for best practices \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 92c4113..f499613 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,12 +1,14 @@ # Copilot Instructions for DumbAssets Project -- This document provides guidelines for using Copilot effectively in the DumbAssets project. -- It covers project conventions, architecture, and best practices to follow when writing code. -- The goal is to maintain a consistent codebase that is easy to read, understand, and maintain. -- Copilot should assist in generating code that adheres to these conventions and patterns. + +- This document provides guidelines for using Copilot effectively in the DumbAssets project. +- It covers project conventions, architecture, and best practices to follow when writing code. +- The goal is to maintain a consistent codebase that is easy to read, understand, and maintain. +- Copilot should assist in generating code that adheres to these conventions and patterns. # DumbAssets Architecture & Conventions ## Project Philosophy + - Keep code simple, smart, and follow best practices - Don't over-engineer for the sake of engineering - Use standard conventions and patterns @@ -17,6 +19,7 @@ - Overcommented code is better than undercommented code ## Commit Conventions + - Use Conventional Commits format: - feat: new features - fix: bug fixes @@ -31,6 +34,7 @@ ## Project Structure ### Root Directory + - Keep root directory clean with only essential files - Production configuration files in root: - docker-compose.yml @@ -41,6 +45,7 @@ - nodemon.json (development configuration) ### Backend Structure + - server.js: Main Express server with all API routes - middleware/: Custom middleware modules - cors.js: CORS configuration @@ -51,6 +56,7 @@ - Images/, Manuals/, Receipts/: File uploads ### Frontend Structure (/public) + - All client-side code in /public directory - **Manager Pattern**: Feature-specific classes in `/public/managers/` - globalHandlers.js: Global utilities (toaster, error logging, API calls) @@ -63,6 +69,7 @@ - toaster.js: Toast notification system ### Services Architecture (/src/services) + - **fileUpload/**: Modular file upload system - index.js: Main export interface - fileUploader.js: Core upload logic @@ -82,15 +89,18 @@ - index.js: Service exports ### Helper Modules (/public/helpers) + - utils.js: General utility functions (generateId, formatDate, formatCurrency) - paths.js: Path management utilities - serviceWorkerHelper.js: PWA service worker management ### UI Enhancement (/public/js) + - collapsible.js: Collapsible section functionality - datepicker-enhancement.js: Enhanced date input UX # Documentation + - Main README.md in root focuses on production deployment - Each service module has its own README.md with usage examples - Code must be self-documenting with clear naming @@ -99,6 +109,7 @@ - File headers must explain module purpose and key functionality # Module System & ES6 + - Use ES6 modules with import/export syntax - Each manager class should be in its own file - Services should be modular and reusable @@ -107,6 +118,7 @@ - Dynamic imports only when necessary for performance # Manager Pattern (/public/managers) + - Each major feature has its own manager class - Manager classes handle feature-specific logic and DOM manipulation - Managers should not directly manipulate other managers' DOM elements @@ -115,6 +127,7 @@ - Each manager should have clear initialization and cleanup methods # Service Architecture (/src/services) + - Services are backend utilities that can be used across the application - Each service directory should have: - index.js: Main export interface @@ -124,6 +137,7 @@ - Use consistent error handling across services # Global Handlers Pattern + - globalHandlers.js centralizes common frontend functionality - Exposes utilities to globalThis for app-wide access: - globalThis.validateResponse: API response validation @@ -134,6 +148,7 @@ - All async API calls should use validateResponse pattern # File Upload System + - Modular file upload service in /src/services/fileUpload/ - Supports drag-and-drop, previews, and validation - Consistent API across different file types (images, receipts, manuals) @@ -142,6 +157,7 @@ - Preview generation for images and documents # PWA & Service Worker + - Service worker for offline functionality and caching - Manifest generation for PWA capabilities - Version management for cache invalidation @@ -149,6 +165,7 @@ - Service worker helper for registration and updates # Notification System + - Apprise-based notification system for external alerts - Queue management to prevent notification spam - Cron-based warranty expiration notifications @@ -156,12 +173,14 @@ - Sanitized message formatting # Chart Integration + - Chart.js wrapper in managers/charts.js - Centralized chart creation and updates - Theme-aware chart styling - Responsive chart configuration # Theme System + - CSS custom properties for theme variables - data-theme attribute on html element - Theme persistence in localStorage @@ -172,6 +191,7 @@ - Theme toggle on all pages # Security & Authentication + - PIN-based authentication system - Session management with express-session - Helmet security middleware @@ -187,6 +207,7 @@ - Secure cookie configuration # Data Management + - JSON file-based storage (Assets.json, SubAssets.json) - File uploads organized by type (Images/, Manuals/, Receipts/) - Import/export functionality for data migration @@ -194,6 +215,7 @@ - State synchronization between components # API Patterns + - RESTful API endpoints in server.js - Consistent error response format - File upload handling with multer @@ -201,6 +223,7 @@ - Environment-aware base URL handling # UI Enhancement + - Collapsible sections with consistent API - Enhanced date picker with clear functionality - Drag-and-drop file uploads @@ -208,6 +231,7 @@ - Loading states and user feedback # Error Handling + - Global error logging with globalThis.logError - Toast notifications for user feedback - Console logging in debug mode @@ -215,6 +239,7 @@ - Validation at both client and server levels # Development Workflow + - nodemon for development server - Docker configuration for production - Environment variable support @@ -222,6 +247,7 @@ - Maintenance notification testing scripts # Code Style + - Use meaningful variable and function names - Keep functions small and focused (under 50 lines when possible) - Maximum line length: 100 characters @@ -234,6 +260,7 @@ # Frontend Architecture Patterns ## Global Handlers Implementation + - globalHandlers class instantiated at the very top of script.js - Provides 4 key global utilities: 1. `globalThis.validateResponse` - API response validation @@ -250,6 +277,7 @@ - Ensures correct base URL for all API calls ## API Call Pattern + ```javascript try { const response = await fetch(`${globalThis.getApiBaseUrl()}/api/endpoint`); @@ -264,6 +292,7 @@ try { ``` ## Manager Class Structure + - Constructor accepts configuration object with dependencies - Each manager handles specific feature domain - Managers should not manipulate other managers' DOM elements @@ -272,6 +301,7 @@ try { - Include cleanup methods for proper teardown ## File Upload Patterns + - Use `initializeFileUploads()` for standard setup - Each file type (images, receipts, manuals) has consistent API - Drag-and-drop with validation built-in @@ -279,12 +309,14 @@ try { - Global delete flags for file removal state ## State Management + - State synchronization through syncHelper.js - Use updateState functions for cross-module updates - Maintain single source of truth for asset data - Sync selected IDs and filter states across components ## Component Initialization + - DOM-ready event listener in main script.js - Initialize global handlers first - Load configuration and check authentication @@ -293,6 +325,7 @@ try { - Initialize managers in dependency order ## CSS and Theming + - Use CSS custom properties (--variable-name) - data-theme attribute on html element - Theme values stored in localStorage @@ -300,6 +333,7 @@ try { - Dark/light theme toggle with system preference detection ## PWA Implementation + - Service worker with versioned caching - Manifest generation via scripts/pwa-manifest-generator.js - Cache invalidation on version updates @@ -307,8 +341,71 @@ try { - Version checking via service worker messaging ## Maintenance & Notifications + - Cron-based warranty expiration checking - Apprise integration for external notifications - Queue management to prevent notification spam - Timezone-aware scheduling with Luxon -- Sanitized message formatting for security \ No newline at end of file +- Sanitized message formatting for security + +## Integration System + +### Adding New Integrations + +DumbAssets uses a schema-driven integration system that automatically generates UI and handles configuration management. The integration system is now managed centrally by the Integration Manager, which is responsible for importing, registering, and routing all integrations. Follow these steps to add a new integration: + +#### 1. Backend Integration Setup + +- **Create Integration File** (`/integrations/your-integration.js`): + + - Export a class with a static `SCHEMA` property and a static `registerRoutes(app, getSettings)` method. + - The `SCHEMA` defines config fields, validation, endpoints, and metadata. + - The `registerRoutes` method should register all Express routes for the integration. + +- **Register Integration in Integration Manager** (`/integrations/integrationManager.js`): + + - Import your integration at the top of the file. + - In `registerBuiltInIntegrations()`, call `this.registerIntegration('your-integration-id', YourIntegration.SCHEMA);` + - The Integration Manager is now responsible for calling each integration's `registerRoutes` method via its own `registerRoutes(app, getSettings)` function. This means only the Integration Manager imports integration files, and all route registration is centralized. + +- **Register All Integration Routes in server.js**: + - In `server.js`, after initializing the app and before starting the server, call `integrationManager.registerRoutes(app, getSettings)`. + - You do NOT need to import individual integrations in `server.js`. + +#### 2. Frontend Integration (Automatic) + +- The frontend dynamically loads all integrations and their schemas from the `/api/integrations` endpoint. +- The UI is generated based on the schema, including validation, field types, and test connection functionality. +- Add optional integration-specific styles in `/public/assets/css/your-integration-styles.css` if needed. + +#### 3. Integration Schema Reference + +- See the Paperless NGX integration for a full example of a schema and route registration. +- Supported field types: text, password, url, number, select, checkbox, textarea, boolean. +- Mark sensitive fields with `sensitive: true` (these are masked in the UI and handled securely). + +#### 4. Error Handling and Testing + +- Use consistent error response format: `{ success: false, message: 'Error description' }` +- Implement proper timeout handling (default: 10000ms) +- Provide meaningful error messages for users +- Log detailed errors server-side for debugging +- Implement a `testConnection` static method for connectivity validation + +#### 5. Integration Development Checklist + +- [ ] Create integration class in `/integrations/` with SCHEMA and registerRoutes +- [ ] Register integration in `integrationManager.js` (import and add to `registerBuiltInIntegrations`) +- [ ] Integration Manager will call your integration's `registerRoutes` automatically +- [ ] Call `integrationManager.registerRoutes(app, getSettings)` in `server.js` +- [ ] Test UI generation, validation, and connection from the frontend +- [ ] Add custom styles if needed +- [ ] Document any special configuration requirements + +#### 6. Key Conventions + +- All backend integration logic and route registration is managed by the Integration Manager +- Only the Integration Manager imports integration files +- All integrations must provide a static `registerRoutes(app, getSettings)` method +- The frontend is schema-driven and requires no manual code changes for new integrations +- Use the Paperless NGX integration as a reference for best practices diff --git a/integrations/INTEGRATION.md b/integrations/INTEGRATION.md new file mode 100644 index 0000000..e97ee4e --- /dev/null +++ b/integrations/INTEGRATION.md @@ -0,0 +1,333 @@ +# DumbAssets Integration Guide + +This guide explains how to add new third-party integrations to DumbAssets using our schema-driven architecture. + +## Overview + +DumbAssets uses a centralized integration system where: + +- Integration classes define schemas and API routes +- UI is automatically generated from schemas +- Integration Manager handles registration and routing +- External Document Manager provides modular search + +## Files That Need Updates + +When adding a new integration, you need to modify these files: + +1. **Create**: `/integrations/your-integration.js` - Main integration class +2. **Update**: `/src/constants.js` - Add API endpoint constant +3. **Update**: `/integrations/integrationManager.js` - Register integration +4. **Update**: `/public/managers/externalDocManager.js` - Add search support +5. **Create**: `/public/assets/integrations/your-integration/` - Assets folder +6. **Optional**: `/public/assets/css/your-integration-styles.css` - Custom styles + +## Step 1: Create Integration Class + +Create `/integrations/your-integration.js`: + +```javascript +const { + API_TEST_SUCCESS, + API_YOUR_INTEGRATION_ENDPOINT, +} = require("../src/constants.js"); + +class YourIntegration { + static SCHEMA = { + id: "your-integration", + name: "Your Integration Name", + description: "Brief description", + category: "document-management", + icon: "/assets/integrations/your-integration/icon.png", + logoHref: "/assets/integrations/your-integration/icon.png", + colorScheme: "#your-brand-color", + + config: { + enabled: { + type: "boolean", + label: "Enable Integration", + default: false, + required: true, + }, + hostUrl: { + type: "url", + label: "Server URL", + required: true, + }, + apiToken: { + type: "password", + label: "API Token", + required: true, + sensitive: true, + }, + }, + + endpoints: { + testConnection: "/api/integrations/your-integration/test", + search: "/api/integrations/your-integration/search", + download: "/api/integrations/your-integration/document/:id/download", + }, + + capabilities: { + search: true, + download: true, + }, + }; + + static registerRoutes(app, getSettings) { + console.log("📄 Your Integration endpoints registered"); + + app.get("/api/integrations/your-integration/test", async (req, res) => { + try { + const result = await YourIntegration.testConnection(getSettings); + res.json(result); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + }); + + app.get("/api/integrations/your-integration/search", async (req, res) => { + try { + const { searchQuery, pageIndex = 0, pageSize = 25 } = req.query; + const result = await YourIntegration.searchDocuments( + getSettings, + searchQuery, + parseInt(pageIndex), + parseInt(pageSize) + ); + res.json(result); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + }); + + app.get( + "/api/integrations/your-integration/document/:id/download", + async (req, res) => { + try { + const result = await YourIntegration.downloadDocument( + getSettings, + req.params.id + ); + res.setHeader("Content-Type", result.contentType); + res.setHeader( + "Content-Disposition", + `attachment; filename="${result.filename}"` + ); + result.stream.pipe(res); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + } + ); + } + + static async testConnection(getSettings) { + const settings = getSettings(); + const config = settings.integrations?.["your-integration"]; + + if (!config?.enabled) { + throw new Error("Integration not enabled"); + } + + // Test your API connection here + return { success: true, message: "Connected successfully" }; + } + + static async searchDocuments(getSettings, searchQuery, pageIndex, pageSize) { + const settings = getSettings(); + const config = settings.integrations?.["your-integration"]; + + if (!config?.enabled) { + throw new Error("Integration not enabled"); + } + + // Implement search logic + return { + documents: [], + documentsCount: 0, + }; + } + + static async downloadDocument(getSettings, documentId) { + const settings = getSettings(); + const config = settings.integrations?.["your-integration"]; + + if (!config?.enabled) { + throw new Error("Integration not enabled"); + } + + // Implement download logic + return { + stream: null, + filename: "document.pdf", + contentType: "application/pdf", + }; + } +} + +module.exports = YourIntegration; +``` + +## Step 2: Add Constants + +In `/src/constants.js`, add: + +```javascript +export const API_YOUR_INTEGRATION_ENDPOINT = + API_INTEGRATIONS_ENPOINT + "/your-integration"; +``` + +## Step 3: Register in Integration Manager + +In `/integrations/integrationManager.js`: + +```javascript +// Add import +const YourIntegration = require("./your-integration"); + +// In registerBuiltInIntegrations() +this.registerIntegration("your-integration", YourIntegration.SCHEMA); + +// In registerRoutes() +YourIntegration.registerRoutes(app, getSettings); +``` + +## Step 4: Add Search Support + +In `/public/managers/externalDocManager.js`: + +```javascript +// Add import +import { API_YOUR_INTEGRATION_ENDPOINT } from '../../src/constants.js'; + +// Add case in searchAllIntegrations() +case 'your-integration': + return await this.searchYourIntegration(query, page); + +// Add search method +async searchYourIntegration(query, page = 0) { + const pageSize = this.currentAttachmentType ? Math.max(this.pageSize * 3, 50) : this.pageSize; + + const params = new URLSearchParams({ + pageIndex: page.toString(), + pageSize: pageSize.toString() + }); + + if (query && query.trim()) { + params.append('searchQuery', query.trim()); + } + + const searchEndpoint = `${globalThis.getApiBaseUrl()}/${API_YOUR_INTEGRATION_ENDPOINT}/search?${params.toString()}`; + const response = await fetch(searchEndpoint); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage); + + const data = await response.json(); + + return { + results: (data.documents || []).map(doc => ({ + id: doc.id, + title: doc.name, + source: 'your-integration', + downloadUrl: `${globalThis.getApiBaseUrl()}/${API_YOUR_INTEGRATION_ENDPOINT}/document/${doc.id}/download`, + mimeType: doc.contentType || 'application/octet-stream', + fileSize: doc.size, + modified: doc.updatedAt, + originalFileName: doc.name, + attachedAt: new Date().toISOString() + })), + count: data.documentsCount || 0, + next: null, + previous: null + }; +} + +// Update getSourceDisplayName() +getSourceDisplayName(sourceId) { + const sourceNames = { + 'paperless': 'Paperless NGX', + 'papra': 'Papra', + 'your-integration': 'Your Integration Name' + }; + return sourceNames[sourceId] || sourceId.charAt(0).toUpperCase() + sourceId.slice(1); +} +``` + +## Step 5: Add Assets + +Create directory structure: + +``` +/public/assets/integrations/your-integration/ +├── icon.png +``` + +## Step 6: Optional CSS + +Create `/public/assets/css/your-integration-styles.css`: + +```css +.your-integration-document { + border-left: 3px solid #your-brand-color; +} + +.your-integration-badge { + background: #your-brand-color; + color: white; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 500; +} +``` + +Include in `/public/index.html`: + +```html + +``` + +## Schema Field Types + +- `text` - Single line text +- `password` - Masked input +- `url` - URL with validation +- `number` - Numeric input +- `boolean` - Checkbox +- `select` - Dropdown +- `textarea` - Multi-line text + +## Field Properties + +- `type` - Input type +- `label` - Display name +- `description` - Help text +- `required` - Required field +- `sensitive` - Mask in UI +- `default` - Default value +- `validation` - Rules object + +## Testing Checklist + +- [ ] Test connection endpoint +- [ ] Test search functionality +- [ ] Test document download +- [ ] Test UI generation +- [ ] Test form validation +- [ ] Test error handling + +## Example Integrations + +Reference existing integrations: + +- `paperless.js` - Paperless NGX +- `papra.js` - Papra + +## Architecture Benefits + +- Schema-driven UI generation +- Centralized integration management +- Consistent patterns across integrations +- Modular search system +- Easy extensibility diff --git a/integrations/homeassistant.js b/integrations/homeassistant.js new file mode 100644 index 0000000..e82136d --- /dev/null +++ b/integrations/homeassistant.js @@ -0,0 +1,551 @@ +/** + * Home Assistant Integration + * Combined integration schema and endpoint functionality for Home Assistant home automation system + */ + +const { API_TEST_SUCCESS, API_HOMEASSISTANT_ENDPOINT, TOKENMASK } = require('../src/constants.js'); + +class HomeAssistantIntegration { + /** + * Integration schema for Home Assistant + * This schema defines the configuration options, endpoints, and validation for the Home Assistant integration. + * It includes: + * - Configuration schema for enabling/disabling the integration + * - Host URL and API token fields + * - Default configuration values + * - API endpoints for testing connection, importing devices, and getting device info + * - Validation functions for configuration + * - Status check function to verify connection and configuration + * - Metadata for documentation and support + */ + static SCHEMA = { + name: 'Home Assistant', + description: 'Integration for importing devices and sensors from Home Assistant into your asset inventory.', + version: '1.0.0', + icon: 'home', + logoHref: 'assets/integrations/homeassistant/homeassistant.png', + colorScheme: '#41BDF5', + category: 'monitoring', + apiEndpoint: API_HOMEASSISTANT_ENDPOINT, + comingSoon: true, + + configSchema: { + enabled: { + type: 'boolean', + label: 'Enable Home Assistant Integration', + // description: 'Enable integration with Home Assistant home automation system', + default: false, + required: false + }, + hostUrl: { + type: 'url', + label: 'Home Assistant URL', + description: 'The base URL of your Home Assistant instance (including port if not 8123)', + placeholder: 'http://homeassistant.local:8123', + required: true, + dependsOn: 'enabled' + }, + accessToken: { + type: 'password', + label: 'Long-Lived Access Token', + description: 'Your Home Assistant Long-Lived Access Token (create in Profile settings)', + placeholder: 'Enter your access token', + required: true, + sensitive: true, + dependsOn: 'enabled' + }, + importFilters: { + type: 'textarea', + label: 'Device Import Filters (Optional)', + description: 'Comma-separated list of entity domains to import (e.g., sensor,light,switch). Leave empty to import all.', + placeholder: 'sensor,light,switch,climate,lock', + required: false, + dependsOn: 'enabled' + } + }, + + defaultConfig: { + enabled: false, + hostUrl: '', + accessToken: '', + importFilters: '' + }, + + endpoints: [ + `GET /${API_HOMEASSISTANT_ENDPOINT}/test-connection`, + `GET /${API_HOMEASSISTANT_ENDPOINT}/devices`, + `GET /${API_HOMEASSISTANT_ENDPOINT}/device/:entity_id/info`, + `POST /${API_HOMEASSISTANT_ENDPOINT}/import-devices`, + `GET /${API_HOMEASSISTANT_ENDPOINT}/config`, + `GET /${API_HOMEASSISTANT_ENDPOINT}/test` + ], + + validators: { + configValidator: (config) => { + const errors = []; + + if (config.enabled) { + if (!config.hostUrl) { + errors.push('Home Assistant URL is required when integration is enabled'); + } else { + try { + new URL(config.hostUrl); + } catch { + errors.push('Home Assistant URL must be a valid URL'); + } + } + + if (!config.accessToken) { + errors.push('Access Token is required when Home Assistant 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.accessToken) { + return { status: 'misconfigured', message: 'Missing required configuration' }; + } + + try { + return await this.testConnection(config); + } catch (error) { + return { status: 'error', message: error.message }; + } + }, + + metadata: { + documentationUrl: 'https://developers.home-assistant.io/docs/api/rest/', + supportLevel: 'community', + author: 'DumbAssets Team', + tags: ['home-automation', 'iot', 'monitoring', 'sensors'] + } + }; + + /** + * Test connection to Home Assistant + */ + static async testConnection(config) { + if (!config.hostUrl || !config.accessToken) { + throw new Error('Home Assistant URL and Access Token are required'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + // Test the API endpoint + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/`, { + headers: { + 'Authorization': `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Invalid access token - please check your Home Assistant token'); + } + throw new Error(`Connection failed: HTTP ${response.status}`); + } + + const data = await response.json(); + + if (data.message === 'API running.') { + return { + status: API_TEST_SUCCESS, + message: 'Successfully connected to Home Assistant', + version: data.version || 'Unknown' + }; + } else { + throw new Error('Unexpected response from Home Assistant API'); + } + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Connection timeout - Home Assistant instance may be unreachable'); + } + throw error; + } + } + + /** + * Get all Home Assistant devices/entities + */ + static async getDevices(config, filters = []) { + if (!config.hostUrl || !config.accessToken) { + throw new Error('Home Assistant integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/states`, { + headers: { + 'Authorization': `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Failed to fetch devices: HTTP ${response.status}`); + } + + let entities = await response.json(); + + // Apply domain filters if specified + if (filters && filters.length > 0) { + entities = entities.filter(entity => { + const domain = entity.entity_id.split('.')[0]; + return filters.includes(domain); + }); + } + + // Transform entities into a more useful format for asset management + return entities.map(entity => ({ + entity_id: entity.entity_id, + name: entity.attributes.friendly_name || entity.entity_id, + domain: entity.entity_id.split('.')[0], + state: entity.state, + unit_of_measurement: entity.attributes.unit_of_measurement || null, + device_class: entity.attributes.device_class || null, + area: entity.attributes.area_id || null, + last_changed: entity.last_changed, + last_updated: entity.last_updated, + attributes: entity.attributes, + raw_entity: entity // Keep original for reference + })); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Request timeout - Home Assistant instance may be slow or unreachable'); + } + console.error('Home Assistant devices fetch error:', error); + throw error; + } + } + + /** + * Get Home Assistant configuration info + */ + static async getConfig(config) { + if (!config.hostUrl || !config.accessToken) { + throw new Error('Home Assistant integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/config`, { + headers: { + 'Authorization': `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Failed to fetch config: HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Request timeout - Home Assistant instance may be slow or unreachable'); + } + throw error; + } + } + + /** + * Get specific device/entity information + */ + static async getDeviceInfo(config, entityId) { + if (!config.hostUrl || !config.accessToken) { + throw new Error('Home Assistant integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/states/${entityId}`, { + headers: { + 'Authorization': `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Device/entity not found'); + } + throw new Error(`Failed to get device info: HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Request timeout - Home Assistant instance may be slow or unreachable'); + } + throw error; + } + } + + /** + * Convert Home Assistant entities to DumbAssets format + */ + static convertToAssets(entities, config) { + return entities.map(entity => { + const domain = entity.entity_id.split('.')[0]; + const deviceName = entity.attributes.friendly_name || entity.entity_id; + + // Determine asset category based on domain + let category = 'Electronics'; + switch (domain) { + case 'light': + category = 'Lighting'; + break; + case 'sensor': + category = 'Sensors'; + break; + case 'switch': + category = 'Electrical'; + break; + case 'climate': + category = 'HVAC'; + break; + case 'lock': + category = 'Security'; + break; + case 'camera': + category = 'Security'; + break; + case 'media_player': + category = 'Entertainment'; + break; + default: + category = 'Electronics'; + } + + return { + name: deviceName, + category: category, + serialNumber: entity.entity_id, + notes: `Imported from Home Assistant\nDomain: ${domain}\nCurrent State: ${entity.state}${entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}`, + location: entity.area || 'Unknown', + tags: [domain, 'home-assistant', 'iot'], + customFields: { + ha_entity_id: entity.entity_id, + ha_domain: domain, + ha_device_class: entity.device_class, + ha_last_updated: entity.last_updated, + ha_current_state: entity.state, + ha_unit: entity.unit_of_measurement + }, + source: 'home-assistant', + importDate: new Date().toISOString() + }; + }); + } + + /** + * Register Home Assistant API routes with Express app + */ + static registerRoutes(app, getSettings) { + const BASE_PATH = process.env.BASE_PATH || ''; + + // Helper to get Home Assistant config + const getHomeAssistantConfig = async () => { + try { + const settings = await getSettings(); + const haConfig = settings.integrationSettings?.homeassistant; + + if (!haConfig?.enabled) { + throw new Error('Home Assistant integration is disabled'); + } + + return haConfig; + } catch (error) { + throw new Error('Failed to get Home Assistant configuration'); + } + }; + + // Test Home Assistant connection + app.post(BASE_PATH + `/${API_HOMEASSISTANT_ENDPOINT}/test-connection`, async (req, res) => { + try { + let { hostUrl, accessToken } = req.body; + + if (!hostUrl || !accessToken) { + return res.status(400).json({ + success: false, + error: 'Host URL and Access Token are required' + }); + } + + // If the token is masked, get the real token from stored settings + if (accessToken === TOKENMASK) { + try { + const haConfig = await getHomeAssistantConfig(); + if (haConfig?.accessToken && haConfig.accessToken !== TOKENMASK) { + accessToken = haConfig.accessToken; + } else { + return res.status(400).json({ + success: false, + error: 'No saved access 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, accessToken }); + res.json({ success: true, ...result }); + } catch (error) { + console.error('Home Assistant connection test failed:', error); + res.status(400).json({ + success: false, + error: error.message + }); + } + }); + + // Get all Home Assistant devices + app.get(BASE_PATH + `/${API_HOMEASSISTANT_ENDPOINT}/devices`, async (req, res) => { + try { + const config = await getHomeAssistantConfig(); + + // Parse filters from config + let filters = []; + if (config.importFilters) { + filters = config.importFilters.split(',').map(f => f.trim()).filter(f => f); + } + + const devices = await this.getDevices(config, filters); + res.json({ + success: true, + devices, + total: devices.length, + filters: filters + }); + } catch (error) { + console.error('Home Assistant devices fetch failed:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } + }); + + // Get Home Assistant configuration + app.get(BASE_PATH + `/${API_HOMEASSISTANT_ENDPOINT}/config`, async (req, res) => { + try { + const config = await getHomeAssistantConfig(); + const haConfig = await this.getConfig(config); + res.json({ success: true, config: haConfig }); + } catch (error) { + console.error('Home Assistant config fetch failed:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } + }); + + // Get specific device info + app.get(BASE_PATH + `/${API_HOMEASSISTANT_ENDPOINT}/device/:entity_id/info`, async (req, res) => { + try { + const config = await getHomeAssistantConfig(); + const entityId = decodeURIComponent(req.params.entity_id); + + const info = await this.getDeviceInfo(config, entityId); + res.json({ success: true, device: info }); + } catch (error) { + console.error('Failed to get device info:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } + }); + + // Import devices as assets + app.post(BASE_PATH + `/${API_HOMEASSISTANT_ENDPOINT}/import-devices`, async (req, res) => { + try { + const config = await getHomeAssistantConfig(); + const { selectedEntities } = req.body; + + if (!selectedEntities || !Array.isArray(selectedEntities)) { + return res.status(400).json({ + success: false, + error: 'selectedEntities array is required' + }); + } + + // Get full device info for selected entities + const devicePromises = selectedEntities.map(entityId => + this.getDeviceInfo(config, entityId) + ); + + const entities = await Promise.all(devicePromises); + + // Convert to asset format + const assets = this.convertToAssets(entities.map(entity => ({ + entity_id: entity.entity_id, + state: entity.state, + attributes: entity.attributes, + last_changed: entity.last_changed, + last_updated: entity.last_updated, + area: entity.attributes.area_id + })), config); + + res.json({ + success: true, + assets, + imported: assets.length, + message: `Successfully converted ${assets.length} Home Assistant entities to assets` + }); + } catch (error) { + console.error('Home Assistant import failed:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } + }); + + // Test endpoint for debugging + app.get(BASE_PATH + `/${API_HOMEASSISTANT_ENDPOINT}/test`, (req, res) => { + res.json({ + message: 'Home Assistant integration endpoints are working', + timestamp: new Date().toISOString() + }); + }); + } +} + +module.exports = HomeAssistantIntegration; \ No newline at end of file diff --git a/integrations/integrationManager.js b/integrations/integrationManager.js new file mode 100644 index 0000000..baf86a5 --- /dev/null +++ b/integrations/integrationManager.js @@ -0,0 +1,321 @@ +/** + * Integration Manager - Server-side integration registry and manager + * Handles registration, configuration, and endpoint management for all integrations + */ + +const { TOKENMASK } = require('../src/constants'); + +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(); + } + + /** + * 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', this.integrationClasses.PaperlessIntegration.SCHEMA); + + // Register Papra integration + this.registerIntegration('papra', this.integrationClasses.PapraIntegration.SCHEMA); + + // Register Home Assistant integration + this.registerIntegration('homeassistant', this.integrationClasses.HomeAssistantIntegration.SCHEMA); + + // Future integrations can be added here + // this.registerIntegration('nextcloud', { ... }); + // this.registerIntegration('sharepoint', { ... }); + } + + /** + * Register routes for all integrations + * @param {Object} app - Express application instance + * @param {Function} getSettings - Function to retrieve application settings + * This method registers all integration-specific routes + * by calling their static `registerRoutes` methods. which is ultimately called from server.js. + * It allows each integration to define its own API endpoints + */ + 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 + } + + /** + * 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', + 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 || {}, + + // 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; + } + + /** + * 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, + logoHref: integration.logoHref, + colorScheme: integration.colorScheme || 'default', + 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/integrations/paperless.js b/integrations/paperless.js new file mode 100644 index 0000000..b259445 --- /dev/null +++ b/integrations/paperless.js @@ -0,0 +1,470 @@ +/** + * Paperless NGX Integration + * Combined integration schema and endpoint functionality for Paperless NGX document management system + */ + +const { API_TEST_SUCCESS, API_PAPERLESS_ENDPOINT, 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: 'Integration for accessing and attaching paperless documents to assets.', + version: '1.0.0', + icon: 'document', + logoHref: 'assets/integrations/paperless/paperless-ngx.png', + colorScheme: '#17541F', + category: 'document-management', + apiEndpoint: API_PAPERLESS_ENDPOINT, + + 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', + 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_ENDPOINT}/test-connection`, + `GET /${API_PAPERLESS_ENDPOINT}/search`, + `GET /${API_PAPERLESS_ENDPOINT}/document/:id/info`, + `GET /${API_PAPERLESS_ENDPOINT}/document/:id/download`, + `GET /${API_PAPERLESS_ENDPOINT}/document/:id/preview`, + `GET /${API_PAPERLESS_ENDPOINT}/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 PaperlessIntegration.testConnection(config); + } catch (error) { + return { status: 'error', message: error.message }; + } + }, + + metadata: { + documentationUrl: 'https://docs.paperless-ngx.com/api/', + supportLevel: 'community', + tags: ['documents', 'pdf', 'scanning', 'ocr'] + } + }; + + /** + * Test connection to Paperless instance + */ + static async testConnection(config) { + if (!config.enabled) { + throw new Error('Integration is disabled'); + } + 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: API_TEST_SUCCESS, + 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/?original=true`, { + 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_ENDPOINT}/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 paperlessConfig = await getPaperlessConfig(); + 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_ENDPOINT}/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_ENDPOINT}/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_ENDPOINT}/document/:id/download`, async (req, res) => { + try { + const config = await getPaperlessConfig(); + const documentId = req.params.id; + + const response = await this.downloadDocument(config, documentId); + + // Copy relevant headers from Paperless response + const contentType = response.headers.get('content-type'); + const contentLength = response.headers.get('content-length'); + const contentDisposition = response.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); + + // Get the response as a buffer and send it + const buffer = await response.arrayBuffer(); + res.send(Buffer.from(buffer)); + } catch (error) { + console.error('Failed to download document:', error); + res.status(500).json({ error: error.message }); + } + }); + + // 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; + + // Call the Paperless thumbnail API endpoint for optimized image previews + const thumbnailUrl = `${config.hostUrl.replace(/\/$/, '')}/api/documents/${documentId}/thumb/`; + + 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 + + // Get the response as a buffer and send it + const buffer = await response.arrayBuffer(); + res.send(Buffer.from(buffer)); + } catch (error) { + console.error('Failed to preview document:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Test endpoint for debugging + app.get(BASE_PATH + `/${API_PAPERLESS_ENDPOINT}/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/integrations/papra.js b/integrations/papra.js new file mode 100644 index 0000000..a9762d7 --- /dev/null +++ b/integrations/papra.js @@ -0,0 +1,496 @@ +/** + * Papra Integration + * Combined integration schema and endpoint functionality for Papra document management system + */ + +const { API_TEST_SUCCESS, API_PAPRA_ENDPOINT, TOKENMASK } = require('../src/constants.js'); + +class PapraIntegration { + /** + * Integration schema for Papra + * This schema defines the configuration options, endpoints, and validation for the Papra integration. + * It includes: + * - Configuration schema for enabling/disabling the integration + * - Host URL, organization ID, 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 Papra. + **/ + static SCHEMA = { + name: 'Papra', + description: 'Integration for accessing and attaching Papra documents to assets.', + version: '1.0.0', + icon: 'document', + logoHref: 'assets/integrations/papra/papra.png', + colorScheme: '#B3D167', + category: 'document-management', + apiEndpoint: API_PAPRA_ENDPOINT, + + configSchema: { + enabled: { + type: 'boolean', + label: 'Enable Papra Integration', + // description: 'Enable integration with Papra document management system', + default: false, + required: false + }, + hostUrl: { + type: 'url', + label: 'Papra Host URL', + description: 'The base URL of your Papra instance', + placeholder: 'https://papra.example.com', + required: true, + dependsOn: 'enabled' + }, + organizationId: { + type: 'text', + label: 'Organization ID', + description: 'Your Papra organization ID', + placeholder: 'Enter your organization ID', + required: true, + dependsOn: 'enabled' + }, + apiToken: { + type: 'password', + label: 'API Token', + description: 'Your Papra API token for authentication', + placeholder: 'Enter your API token', + required: true, + sensitive: true, + dependsOn: 'enabled' + } + }, + + defaultConfig: { + enabled: false, + hostUrl: '', + organizationId: '', + apiToken: '' + }, + + endpoints: [ + `GET /${API_PAPRA_ENDPOINT}/test-connection`, + `GET /${API_PAPRA_ENDPOINT}/search`, + `GET /${API_PAPRA_ENDPOINT}/document/:id/info`, + `GET /${API_PAPRA_ENDPOINT}/document/:id/download`, + `GET /${API_PAPRA_ENDPOINT}/document/:id/preview`, + `GET /${API_PAPRA_ENDPOINT}/test` + ], + + validators: { + configValidator: (config) => { + const errors = []; + + if (config.enabled) { + if (!config.hostUrl) { + errors.push('Host URL is required when Papra integration is enabled'); + } else { + try { + new URL(config.hostUrl); + } catch { + errors.push('Host URL must be a valid URL'); + } + } + + if (!config.organizationId) { + errors.push('Organization ID is required when Papra integration is enabled'); + } + + if (!config.apiToken) { + errors.push('API Token is required when Papra 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.organizationId || !config.apiToken) { + return { status: 'misconfigured', message: 'Missing required configuration' }; + } + + try { + return await PapraIntegration.testConnection(config); + } catch (error) { + return { status: 'error', message: error.message }; + } + }, + + metadata: { + documentationUrl: 'https://github.com/papra-hq/papra/issues/363#issuecomment-2981636832', + supportLevel: 'community', + tags: ['documents', 'pdf', 'management', 'organization'] + } + }; + + /** + * Test connection to Papra instance + */ + static async testConnection(config) { + if (!config.enabled) { + throw new Error('Integration is disabled'); + } + if (!config.hostUrl || !config.organizationId || !config.apiToken) { + throw new Error('Host URL, Organization ID, and API Token are required'); + } + + try { + const data = await this.getDocuments(config, 0, 1); + return { + status: API_TEST_SUCCESS, + message: `Successfully connected to Papra (${data.documentsCount || 0} documents available)`, + documentCount: data.documentsCount || 0 + }; + } catch (error) { + console.error('Papra connection test failed:', error); + throw error; + } + } + + /** Load Papra Documents */ + static async getDocuments(config, page = 0, pageSize = 50) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + const url = new URL(`${config.hostUrl.replace(/\/$/, '')}/api/organizations/${config.organizationId}/documents`); + url.searchParams.append('pageIndex', page.toString()); + url.searchParams.append('pageSize', pageSize.toString()); + + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Invalid API token or unauthorized access'); + } else if (response.status === 404) { + throw new Error('Papra API not found or invalid Organization ID - check your host URL and organization ID'); + } else if (response.status === 403) { + throw new Error('Access forbidden - check your permissions and organization membership'); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } + + const data = await response.json(); + return data; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Connection timeout - Papra 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 Papra documents + */ + static async searchDocuments(config, query, page = 0, pageSize = 50) { + if (!config.hostUrl || !config.organizationId || !config.apiToken) { + throw new Error('Papra integration not configured'); + } + + try { + if (query && query.trim().length > 0) { + const url = new URL(`${config.hostUrl.replace(/\/$/, '')}/api/organizations/${config.organizationId}/documents/search`); + + const searchQuery = !query || !query.trim() ? '' : query; + url.searchParams.append('searchQuery', searchQuery); + url.searchParams.append('pageIndex', page.toString()); + url.searchParams.append('pageSize', pageSize.toString()); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + const response = await fetch(url.toString(), { + headers: { + 'Authorization': `Bearer ${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(); + } else { + const data = await this.getDocuments(config, page, pageSize); + return data; + } + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Search timeout - Papra instance may be slow or unreachable'); + } + console.error('Papra search error:', error); + throw error; + } + } + + /** + * Get document information + */ + static async getDocumentInfo(config, documentId) { + if (!config.hostUrl || !config.organizationId || !config.apiToken) { + throw new Error('Papra integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/organizations/${config.organizationId}/documents/${documentId}`, { + headers: { + 'Authorization': `Bearer ${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 - Papra 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.organizationId || !config.apiToken) { + throw new Error('Papra integration not configured'); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + const response = await fetch(`${config.hostUrl.replace(/\/$/, '')}/api/organizations/${config.organizationId}/documents/${documentId}/file`, { + headers: { + 'Authorization': `Bearer ${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 - Papra instance may be slow or unreachable'); + } + console.error('Failed to download document:', error); + throw error; + } + } + + /** + * Register Papra API routes with Express app + */ + static registerRoutes(app, getSettings) { + const BASE_PATH = process.env.BASE_PATH || ''; + + // Helper to get Papra config + const getPapraConfig = async () => { + try { + const settings = await getSettings(); + const papraConfig = settings.integrationSettings?.papra; + + if (!papraConfig?.enabled) { + throw new Error('Papra integration is disabled'); + } + + return papraConfig; + } catch (error) { + throw new Error('Failed to get Papra configuration'); + } + }; + + // Test Papra connection + app.post(BASE_PATH + `/${API_PAPRA_ENDPOINT}/test-connection`, async (req, res) => { + try { + let { hostUrl, organizationId, apiToken } = req.body; + + if (!hostUrl || !organizationId || !apiToken) { + return res.status(400).json({ + success: false, + error: 'Host URL, Organization ID, and API Token are required' + }); + }; + + // If the token is masked, get the real token from stored settings + if (apiToken === TOKENMASK) { + try { + const papraConfig = await getPapraConfig(); + if (papraConfig?.apiToken && papraConfig.apiToken !== TOKENMASK) { + apiToken = papraConfig.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, organizationId, apiToken }); + res.json({ success: true, ...result }); + } catch (error) { + console.error('Papra connection test failed:', error); + res.status(400).json({ + success: false, + error: error.message + }); + } + }); + + // Search Papra documents + app.get(BASE_PATH + `/${API_PAPRA_ENDPOINT}/search`, async (req, res) => { + try { + const config = await getPapraConfig(); + const { q: query, pageIndex = 0, pageSize = 20 } = req.query; + + const results = await this.searchDocuments( + config, + query, + parseInt(pageIndex), + parseInt(pageSize) + ); + + res.json(results); + } catch (error) { + console.error('Papra search failed:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Get Papra document info + app.get(BASE_PATH + `/${API_PAPRA_ENDPOINT}/document/:id/info`, async (req, res) => { + try { + const config = await getPapraConfig(); + 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 Papra document download + app.get(BASE_PATH + `/${API_PAPRA_ENDPOINT}/document/:id/download`, async (req, res) => { + try { + const config = await getPapraConfig(); + const documentId = req.params.id; + + const response = await this.downloadDocument(config, documentId); + + // Copy relevant headers from Papra response + const contentType = response.headers.get('content-type'); + const contentLength = response.headers.get('content-length'); + const contentDisposition = response.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); + + // Get the response as a buffer and send it + const buffer = await response.arrayBuffer(); + res.send(Buffer.from(buffer)); + } catch (error) { + console.error('Failed to download document:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Proxy Papra document preview for images (for UI display) + app.get(BASE_PATH + `/${API_PAPRA_ENDPOINT}/document/:id/preview`, async (req, res) => { + try { + const config = await getPapraConfig(); + const documentId = req.params.id; + + const response = await this.downloadDocument(config, documentId); + + // 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' }); + } + + // Set appropriate headers for image display + res.setHeader('content-type', contentType); + res.setHeader('cache-control', 'private, max-age=3600'); // Cache for 1 hour + + // Get the response as a buffer and send it + const buffer = await response.arrayBuffer(); + res.send(Buffer.from(buffer)); + } catch (error) { + console.error('Failed to preview document:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Test endpoint for debugging + app.get(BASE_PATH + `/${API_PAPRA_ENDPOINT}/test`, (req, res) => { + res.json({ + message: 'Papra integration endpoints are working', + timestamp: new Date().toISOString() + }); + }); + + console.log('📄 Papra endpoints registered'); + } +} + +module.exports = PapraIntegration; \ No newline at end of file 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/assets/css/demo-styles.css b/public/assets/css/demo-styles.css new file mode 100644 index 0000000..c46d85c --- /dev/null +++ b/public/assets/css/demo-styles.css @@ -0,0 +1,56 @@ +/* DEMO MODE STYLES */ +.header-top { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + left: 1rem; + right: 1rem; + 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; +} +.demo-banner { + background: linear-gradient(135deg, #ff6b6b 0%, #ff8383 100%); + color: white; + text-align: center; + padding: 0.5rem 1rem; + margin: 0 auto 1rem auto; + font-weight: 500; + font-size: 0.9rem; + animation: pulse 2s infinite; + box-shadow: 0 2px 8px rgba(255, 107, 107, 0.2); + border-radius: 0 0 8px 8px; + position: relative; + z-index: 5; + width: fit-content; + max-width: 90%; + backdrop-filter: blur(8px); + /* border: 1px solid rgba(255, 255, 255, 0.1); */ +} + +.demo-banner span { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + white-space: nowrap; +} + +@keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.7; + } + 100% { + opacity: 1; + } +} diff --git a/public/assets/css/homeassistant-styles.css b/public/assets/css/homeassistant-styles.css new file mode 100644 index 0000000..e3c7b9e --- /dev/null +++ b/public/assets/css/homeassistant-styles.css @@ -0,0 +1,213 @@ +/** + * Home Assistant Integration Styles + * Styles specific to the Home Assistant integration UI + */ + +/* Home Assistant brand colors */ +:root { + --homeassistant-primary: #41BDF5; + --homeassistant-primary-dark: #2B9FE3; + --homeassistant-secondary: #03A9F4; + --homeassistant-accent: #FFC107; +} + +/* Integration card styling */ +.integration-card[data-integration="homeassistant"] { + border-left: 4px solid var(--homeassistant-primary); +} + +.integration-card[data-integration="homeassistant"] .integration-icon { + color: var(--homeassistant-primary); +} + +/* Test connection button styling for Home Assistant */ +.integration-card[data-integration="homeassistant"] .test-connection-btn { + background-color: var(--homeassistant-primary); + border-color: var(--homeassistant-primary); +} + +.integration-card[data-integration="homeassistant"] .test-connection-btn:hover { + background-color: var(--homeassistant-primary-dark); + border-color: var(--homeassistant-primary-dark); +} + +/* Device import specific styles */ +.homeassistant-device-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + background-color: var(--bg-color); +} + +.homeassistant-device-item { + display: flex; + align-items: center; + padding: 0.75rem; + border-bottom: 1px solid var(--border-color); + transition: background-color 0.2s ease; +} + +.homeassistant-device-item:hover { + background-color: var(--hover-bg-color); +} + +.homeassistant-device-item:last-child { + border-bottom: none; +} + +.homeassistant-device-checkbox { + margin-right: 1rem; +} + +.homeassistant-device-info { + flex: 1; +} + +.homeassistant-device-name { + font-weight: 600; + color: var(--text-color); + margin-bottom: 0.25rem; +} + +.homeassistant-device-entity { + font-size: 0.875rem; + color: var(--text-muted); + font-family: monospace; +} + +.homeassistant-device-details { + font-size: 0.875rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.homeassistant-device-domain { + display: inline-block; + background-color: var(--homeassistant-primary); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + margin-right: 0.5rem; +} + +.homeassistant-device-state { + color: var(--text-color); + font-weight: 500; +} + +/* Filter controls */ +.homeassistant-filters { + margin-bottom: 1rem; + padding: 1rem; + background-color: var(--card-bg-color); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.homeassistant-filter-group { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.homeassistant-filter-tag { + display: inline-block; + background-color: var(--homeassistant-secondary); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 16px; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.homeassistant-filter-tag:hover { + background-color: var(--homeassistant-primary-dark); + transform: translateY(-1px); +} + +.homeassistant-filter-tag.active { + background-color: var(--homeassistant-accent); + color: var(--text-color); +} + +/* Import summary */ +.homeassistant-import-summary { + background-color: var(--success-bg-color); + border: 1px solid var(--success-border-color); + color: var(--success-text-color); + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; +} + +.homeassistant-import-summary strong { + color: var(--success-text-color); +} + +/* Loading states */ +.homeassistant-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--text-muted); +} + +.homeassistant-loading::before { + content: ''; + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-top: 2px solid var(--homeassistant-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 0.5rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Error states */ +.homeassistant-error { + background-color: var(--error-bg-color); + border: 1px solid var(--error-border-color); + color: var(--error-text-color); + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; +} + +/* Success states */ +.homeassistant-success { + background-color: var(--success-bg-color); + border: 1px solid var(--success-border-color); + color: var(--success-text-color); + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; +} + +/* Responsive design */ +@media (max-width: 768px) { + .homeassistant-device-item { + flex-direction: column; + align-items: flex-start; + } + + .homeassistant-device-checkbox { + margin-bottom: 0.5rem; + margin-right: 0; + } + + .homeassistant-filter-group { + justify-content: center; + } +} \ No newline at end of file diff --git a/public/assets/css/integration-styles.css b/public/assets/css/integration-styles.css new file mode 100644 index 0000000..7088bb0 --- /dev/null +++ b/public/assets/css/integration-styles.css @@ -0,0 +1,583 @@ +.uploader-external-link-button { + display: none; + align-items: center; + justify-content: center; + cursor: pointer; + max-width: 140px; + gap: 0.5rem; + opacity: 0.8; +} +.uploader-external-link-button:hover { + opacity: 1; + transition: opacity 0.2s ease; +} + +/* External Document Search Modal Styles */ +#externalDocModal { + display: flex; + align-items: center; + justify-content: center; +} + +#externalDocModal .modal-subtitle { + padding: 0 1.5rem; + margin-bottom: 0.5rem; +} + +#externalDocModal .modal-subtitle p { + margin: 0; + font-size: 0.875rem; + color: var(--text-color-secondary); + text-align: center; + opacity: 0.8; +} + +#externalDocModal .modal-content { + max-width: 700px; + width: 90vw; + max-height: 60vh; + height: auto; + margin: 0; + position: relative; + top: auto; + left: auto; + transform: none; +} + +.external-doc-search { + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#externalDocModal .search-controls { + margin-bottom: 0.75rem; + flex-shrink: 0; +} + +.external-doc-search-input { + width: 100%; + padding: 0.5rem; + /* border: 1px solid var(--border-color); */ + border-radius: 0.25rem; + background: var(--container); + color: var(--text-color); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.external-doc-search-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.integration-filters { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +.integration-filter-btn { + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background: var(--container); + color: var(--text-color); + cursor: pointer; + font-size: 0.75rem; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.integration-filter-btn:hover { + background: var(--hover-color); +} + +.integration-filter-btn.active { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + +.integration-filter-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--background-color); +} + +.integration-filter-warning { + padding: 0.5rem; + background: rgba(251, 191, 36, 0.1); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + color: var(--text-color-secondary); + font-size: 0.75rem; + text-align: center; +} + +.integration-icon { + width: 12px; + height: 12px; + border-radius: 2px; +} + +.external-doc-results-container { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.external-doc-results { + flex: 1; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background: var(--container); + min-height: 120px; + max-height: 250px; +} + +.search-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: var(--text-color-secondary); + opacity: 0.8; +} + +.search-placeholder svg { + margin-bottom: 1rem; + opacity: 0.5; +} + +.search-placeholder p { + margin: 0; + font-size: 1rem; +} + +.external-doc-item { + padding: 0.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s ease; +} + +.external-doc-item:last-child { + border-bottom: none; +} + +.external-doc-item:hover { + background-color: var(--hover-color); +} + +.external-doc-info { + flex: 1; + margin-right: 0.5rem; +} + +.external-doc-title { + font-weight: 600; + color: var(--text-color); + margin-bottom: 0.125rem; + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + line-height: 1.25; +} + +.external-doc-source { + display: inline-flex; + align-items: center; + gap: 0.125rem; + padding: 0.0625rem 0.375rem; + background: var(--primary-color); + color: white; + border-radius: 0.75rem; + font-size: 0.625rem; + font-weight: 500; +} + +.external-doc-meta { + font-size: 0.75rem; + color: var(--text-color-secondary); + opacity: 0.8; + margin-top: 0.125rem; + line-height: 1.2; +} + +.external-doc-link-btn { + background: var(--primary-color); + color: white; + border: none; + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.75rem; + font-weight: 500; + transition: background-color 0.2s ease; + display: flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; +} + +.external-doc-link-btn:hover { + background: var(--primary-hover); +} + +.external-doc-link-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.external-doc-link-btn.linked { + background: var(--success-color, #10b981) !important; + opacity: 0.8 !important; + cursor: default !important; +} + +.search-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--text-color-secondary); +} + +.search-loading .spinner { + width: 20px; + height: 20px; + margin-right: 0.5rem; +} + +.search-error { + padding: 1rem; + text-align: center; + color: var(--error-color); + background: rgba(239, 68, 68, 0.1); + border-radius: 0.375rem; + margin: 1rem; +} + +.no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: var(--text-color-secondary); + opacity: 0.8; +} + +.no-results svg { + margin-bottom: 1rem; + opacity: 0.5; +} + +.external-doc-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + border-top: 1px solid var(--border-color); + background: var(--background-color); + border-radius: 0 0 0.25rem 0.25rem; + flex-shrink: 0; +} + +.pagination-info { + font-size: 0.75rem; + color: var(--text-color-secondary); +} + +.pagination-controls { + display: flex; + gap: 0.375rem; +} + +.pagination-btn { + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background: var(--container); + color: var(--text-color); + cursor: pointer; + font-size: 0.75rem; + transition: all 0.2s ease; +} + +.pagination-btn:hover:not(:disabled) { + background: var(--hover-color); +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.load-more-btn { + width: 100%; + padding: 0.75rem; + margin: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background: var(--container); + color: var(--text-color); + cursor: pointer; + font-size: 0.75rem; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; +} + +.load-more-btn:hover:not(:disabled) { + background: var(--hover-color); +} + +.load-more-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Badge Styles */ + +.integration-badge { + position: absolute; + top: 4px; + left: 4px; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +/* Position badge relative to file-preview-item in modals */ +.file-preview-item .integration-badge { + top: 14px; + left: 8px; +} + +/* Position badge relative to file-item in asset details */ +.file-item .integration-badge { + top: 8px; + left: 8px; +} + +.integration-badge img { + width: 20px; + height: 20px; + /* 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; +} + +/* Coming Soon message styling */ +.coming-soon-message { + display: flex; + align-items: center; + justify-content: center; +} + +.coming-soon-message span { + background: linear-gradient(45deg, #14b8a6, #06b6d4) !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(20, 184, 166, 0.1) !important; + border: 1px solid rgba(20, 184, 166, 0.3) !important; + display: inline-block !important; + animation: smooth-pulse 3s ease-in-out infinite; +} + +@keyframes smooth-pulse { + 0%, 100% { + box-shadow: 0 0 8px rgba(20, 184, 166, 0.3); + transform: scale(1); + } + 50% { + box-shadow: 0 0 12px rgba(20, 184, 166, 0.5); + transform: scale(1.01); + } +} diff --git a/public/assets/css/responsive-styles.css b/public/assets/css/responsive-styles.css new file mode 100644 index 0000000..2f04ab3 --- /dev/null +++ b/public/assets/css/responsive-styles.css @@ -0,0 +1,349 @@ +/* RESPONSIVE ADJUSTMENTS SECTION */ +@media (max-width: 1400px) and (min-width: 1101px) { + .dashboard-charts-section.three-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + } +} + +@media (max-width: 1200px) { + .dashboard-card .card-value { + font-size: clamp(0.9rem, 3vw, 1.1rem); + line-height: 1.1; + max-width: 100%; + } +} + +@media (max-width: 1100px) { + .dashboard-charts-section, + .dashboard-charts-section.three-col { + flex-direction: column; + display: flex; + grid-template-columns: none; + } + + .chart-container { + flex: 1 1 100%; + margin-bottom: 1rem; + min-width: 0; + max-width: 100%; + } + + .event-row { + grid-template-columns: auto 1fr; + gap: 1rem; + } +} + +@media (max-width: 853px) { + /* Mobile layout adjustments */ + .app-container { + grid-template-columns: 1fr; /* Single column layout on mobile */ + /* position: relative; */ + padding: 0; /* Remove horizontal padding on mobile */ + } + .app-container.sidebar-active .sidebar-overlay { + display: block; + opacity: 1; + pointer-events: auto; + } + + .main-content { + padding: 0; /* Remove padding from main content */ + } + + .header-actions { + margin-right: 0.5rem; + } + + .dashboard-legend { + margin: 0.5rem 0 0 0; /* Remove horizontal margins */ + padding: 1rem 0.5rem 1rem 0.5rem; /* Reduce horizontal padding */ + } + + .dashboard-legend-title { + font-size: 0.9rem; /* Reduce font size on mobile */ + padding: 0.3rem 0.5rem; /* Reduce padding to make it smaller */ + top: -0.8rem; /* Move it higher to reduce overlap */ + } + + /* Add extra top margin to asset header on mobile to prevent overlap */ + .asset-header { + margin-top: 0.5rem; + margin-bottom: 0.75rem; + } + + /* Ensure asset title scales properly on mobile */ + .asset-title h2 { + font-size: 1.6rem; /* Slightly smaller on mobile */ + line-height: 1.2; + } + + /* Make asset actions buttons slightly smaller on mobile */ + .asset-actions button { + padding: 0.25rem 0.4rem; + } + + .asset-actions .copy-link-btn svg, + .asset-actions .edit-asset-btn svg, + .asset-actions .delete-asset-btn svg, + .asset-actions .back-to-parent-btn svg { + width: 18px; + height: 18px; + } + + .dashboard-cards { + padding: 0 0.25rem; /* Minimal horizontal padding for cards */ + gap: 0.75rem; /* Slightly reduce gap */ + } + + .dashboard-card { + padding: 1rem 0.5rem 0.75rem 0.5rem; /* Reduce horizontal padding */ + min-height: 90px; /* Slightly reduce height */ + } + + .chart-container { + padding: 1rem 0.5rem; /* Reduce horizontal padding */ + margin: 0; /* Remove margins */ + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + width: 300px; + height: 100vh; + z-index: 999; + transform: translateX(-100%); /* Hidden by default using transform */ + transition: transform 0.3s ease; + border-right: var(--app-border); + background-color: var(--background-color); + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1); + } + + .sidebar.open { + transform: translateX(0); /* Show sidebar when open */ + } + + .sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.4); + z-index: 998; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + } + + .sidebar-toggle { + display: block !important; + position: static !important; + top: unset !important; + left: unset !important; + z-index: auto !important; + margin: 0; + /* margin-right: auto; */ + } + #siteTitle { + font-size: 1.8rem; + margin-top: 0.5rem; + } + + .events-table-container { + margin: 0; /* Remove margins */ + } + .events-controls { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + padding: 0 0.5rem; /* Add minimal horizontal padding */ + } + .events-filters { + justify-content: center; + } + .events-sort { + justify-content: center; + } + .event-row { + grid-template-columns: auto 1fr 2fr; + } +} + +@media (max-width: 768px) { + .header-row { + grid-template-columns: auto 1fr auto; + gap: 0.5rem; + } + .header-row { + font-size: 1.25rem; + grid-column: 2; + } + .sidebar-toggle { + display: block !important; + grid-column: 1; + } + .header-row > .header-spacer { + display: none; + } + .files-grid { + grid-template-columns: repeat(2, 1fr); + } + .search-bar { + width: calc(100% - 1rem); + } + + .events-pagination { + flex-direction: column; + gap: 1rem; + text-align: center; + } + .events-pagination-controls { + justify-content: center; + flex-wrap: wrap; + } + .events-prev-btn, + .events-next-btn { + order: 1; + } + .events-pagination-numbers { + order: 2; + width: 100%; + justify-content: center; + } + + #settingsModal .export-section { + flex-direction: column; + } + + #duplicateModal .settings-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem 0.5rem; + } +} + +@media (max-width: 600px) { + .container, + .app-container, + .main-content { + max-height: calc(100vh - 9rem); + padding: 0; + margin: 0; + } + .container { + overflow-y: hidden; + } + + #siteTitle { + font-size: 1.5rem; + } + .header-row { + flex-direction: column; + } + + form { + padding: 1rem 0.5rem 0.5rem; + } + .warranty-title { + font-size: 1rem; + margin-bottom: 0.7rem; + } + + .dashboard-charts-section { + flex-direction: column; + } + + .event-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 500px) { + /* Responsive behavior for settings grid */ + #settingsModal .settings-grid { + grid-template-columns: 1fr; + } + + #settingsModal .modal-content { + max-width: 90%; + padding: 1rem; + margin: 1rem; + } +} + +@media (max-width: 480px) { + .form-actions { + flex-direction: column; + } + + .save-btn, + .cancel-btn { + width: 100%; + } + .sub-asset-header { + max-width: 99vw; + min-width: 0; + overflow-x: auto; + gap: 0.2rem; + } + .add-sub-asset-btn { + padding: 0.5rem 0.7rem; + font-size: 1.3rem; + } + .add-sub-asset-btn::after { + content: "+"; + font-size: 1.3rem; + display: inline; + } + .add-sub-asset-btn { + text-indent: -9999px; + overflow: hidden; + width: 44px; + min-width: 44px; + max-width: 44px; + padding-left: 0; + padding-right: 0; + text-align: center; + position: relative; + } + .add-sub-asset-btn::after { + text-indent: 0; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + .files-grid { + grid-template-columns: 1fr; + } + #settingsModal .tab-nav { + flex-direction: column; + align-items: center; + } + + #duplicateModal .settings-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + #duplicateModal .toggle-row span { + font-size: 0.8em; + } +} + +@media (max-width: 380px) { + .pin-input-container input.pin-input { + width: 32px; + height: 32px; + } +} diff --git a/public/styles.css b/public/assets/css/styles.css similarity index 97% rename from public/styles.css rename to public/assets/css/styles.css index 53321fa..bd680fa 100644 --- a/public/styles.css +++ b/public/assets/css/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; @@ -988,6 +989,15 @@ input[type="date"][data-has-value="true"] { letter-spacing: -0.5px; } +.file-type-hint { + display: block; + font-size: 0.8rem; + font-weight: 400; + color: var(--text-color-secondary); + margin-top: 0.3rem; + font-style: italic; +} + .form-group { margin-bottom: 0.5rem; } @@ -1009,7 +1019,7 @@ input[type="date"][data-has-value="true"] { min-height: 40px; } -.preview-container img { +.preview-container img:not(.integration-badge img) { max-width: 200px; max-height: 100px; border-radius: var(--app-border-radius); @@ -1310,6 +1320,13 @@ input[type="date"][data-has-value="true"] { margin-bottom: 20px; } +.file-upload-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; +} + .file-input { display: none; } @@ -1577,6 +1594,8 @@ input[type="date"][data-has-value="true"] { display: block; } + + .toast-container { position: fixed; top: 3rem; @@ -1681,7 +1700,7 @@ input[type="date"][data-has-value="true"] { gap: 8px; position: relative; } -.file-preview img { +.file-preview img:not(.integration-badge img) { max-width: 200px; max-height: 200px; border-radius: var(--app-border-radius); @@ -1784,9 +1803,10 @@ a:hover { grid-column: span 3; display: grid; grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); - gap: 12px; - margin-top: 8px; + gap: 0.5rem; width: 100%; + margin-bottom: 1rem; + max-width: 100%; /* Ensure grid doesn't overflow */ } .preview-grid .file-preview-item { position: relative; @@ -1811,7 +1831,7 @@ a:hover { overflow: hidden; position: relative; } -.preview-grid .file-preview-item img { +.preview-grid .file-preview-item img:not(.integration-badge img) { max-width: 100%; max-height: 85px; object-fit: contain; @@ -1932,7 +1952,7 @@ a:hover { flex-direction: column; align-items: center; transition: all 0.2s ease; - height: 250px; + /* height: 250px; */ overflow: hidden; } @@ -1962,13 +1982,13 @@ a:hover { stroke: var(--primary-color); margin: auto; transition: transform 0.2s ease; - flex: 1; + /* flex: 1; */ } -.file-item.photo img { +.file-item.photo .asset-image { width: 100%; - height: 150px; - object-fit: cover; + height: 100%; + object-fit: contain; border-radius: var(--app-border-radius); border: none; flex: 1; @@ -1977,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); @@ -2088,8 +2108,8 @@ a:hover { margin-top: 1rem; } -#photoPreview img, -#subPhotoPreview img { +#photoPreview img:not(.integration-badge img), +#subPhotoPreview img:not(.integration-badge img) { max-width: 220px; max-height: 140px; object-fit: contain; @@ -2449,7 +2469,7 @@ a:hover { .compact-file-item.photo img.compact-asset-image { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; } /* Tag styles */ @@ -3338,82 +3358,6 @@ 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; - 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; -} -.demo-banner { - background: linear-gradient(135deg, #ff6b6b 0%, #ff8383 100%); - color: white; - text-align: center; - padding: 0.5rem 1rem; - margin: 0 auto 1rem auto; - font-weight: 500; - font-size: 0.9rem; - animation: pulse 2s infinite; - box-shadow: 0 2px 8px rgba(255, 107, 107, 0.2); - border-radius: 0 0 8px 8px; - position: relative; - z-index: 5; - width: fit-content; - max-width: 90%; - backdrop-filter: blur(8px); - /* border: 1px solid rgba(255, 255, 255, 0.1); */ -} - -.demo-banner span { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - white-space: nowrap; -} - -@keyframes pulse { - 0% { - opacity: 1; - } - 50% { - opacity: 0.7; - } - 100% { - opacity: 1; - } -} - -/* Utility classes */ -.hidden { - display: none !important; -} - -.empty-state { - display: flex; - justify-content: center; - align-items: center; - text-align: center; - padding: 0.2rem; - color: var(--secondary-color); - min-height: 10px; -} - /* Duplicate Modal Specific Styles */ .duplicate-input { width: 100%; @@ -3530,22 +3474,3 @@ a:hover { #duplicateModal input:checked + .slider:before { transform: translateX(18px); } - -/* Responsive adjustments for duplicate modal */ -@media (max-width: 768px) { - #duplicateModal .settings-grid { - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem 0.5rem; - } -} - -@media (max-width: 480px) { - #duplicateModal .settings-grid { - grid-template-columns: 1fr; - gap: 0.75rem; - } - - #duplicateModal .toggle-row span { - font-size: 0.8em; - } -} diff --git a/public/assets/integrations/homeassistant/homeassistant.png b/public/assets/integrations/homeassistant/homeassistant.png new file mode 100644 index 0000000..fc6b190 Binary files /dev/null and b/public/assets/integrations/homeassistant/homeassistant.png differ diff --git a/public/assets/integrations/paperless/paperless-ngx.png b/public/assets/integrations/paperless/paperless-ngx.png new file mode 100644 index 0000000..a9ac9cb Binary files /dev/null and b/public/assets/integrations/paperless/paperless-ngx.png differ diff --git a/public/assets/integrations/papra/papra.png b/public/assets/integrations/papra/papra.png new file mode 100644 index 0000000..977000b Binary files /dev/null and b/public/assets/integrations/papra/papra.png differ diff --git a/public/index.html b/public/index.html index e397c9f..9b7f1dd 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,10 @@ DumbAssets - + + + + @@ -261,7 +264,17 @@

File Attachments

- +
+ + +
@@ -278,7 +291,17 @@

File Attachments

- +
+ + +
@@ -294,7 +317,17 @@

File Attachments

- +
+ + +
@@ -444,7 +477,17 @@

File Attachments

- +
+ + +
@@ -461,7 +504,17 @@

File Attachments

- +
+ + +
@@ -477,7 +530,17 @@

File Attachments

- +
+ + +
@@ -516,6 +579,47 @@

File Attachments

+ + + + +
+ Integrations +
+ +
+ Loading integrations... +
+ +
+
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/login.html b/public/login.html index 35f1e5a..e79ad3a 100644 --- a/public/login.html +++ b/public/login.html @@ -5,7 +5,9 @@ DumbAssets - + + + diff --git a/public/managers/externalDocManager.js b/public/managers/externalDocManager.js new file mode 100644 index 0000000..d2c6c7e --- /dev/null +++ b/public/managers/externalDocManager.js @@ -0,0 +1,974 @@ +/** + * External Document Manager - Handles searching and linking documents from document management integrations + * Only loads integrations with category 'document-management' (e.g., Paperless NGX, Papra) + * Extensible for other document management systems like Nextcloud, SharePoint, etc. + * Dynamically applies integration color schemes instead of relying on hardcoded CSS classes. + */ + +import { API_PAPERLESS_ENDPOINT, API_PAPRA_ENDPOINT, INTEGRATION_CATEGORIES } from '../../src/constants.js'; + +export class ExternalDocManager { + constructor({ modalManager, setButtonLoading, integrationsManager }) { + this.modalManager = modalManager; + this.setButtonLoading = setButtonLoading; + this.integrationsManager = integrationsManager; + this.currentAttachmentType = null; + this.currentIsSubAsset = false; + this.searchTimeout = null; + this.activeIntegrations = []; + this.selectedIntegrations = new Set(['all']); + + // Pagination state + this.currentPage = 1; + this.pageSize = 15; + this.totalDocuments = 0; + this.currentQuery = ''; + this.isLoading = false; + this.buttonIds = [ + 'linkExternalPhotos', 'linkExternalReceipts', 'linkExternalManuals', + 'linkExternalSubPhotos', 'linkExternalSubReceipts', 'linkExternalSubManuals' + ]; + + // Store event handlers for proper cleanup + this.eventHandlers = new Map(); + + // File extension filters for different attachment types (more reliable than MIME types) + this.fileExtensionFilters = { + photo: [ + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.svg' + ], + receipt: [ + '.pdf', '.doc', '.docx', '.txt', '.rtf', + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif' + ], + manual: [ + '.pdf', '.doc', '.docx', '.txt', '.rtf', + '.xls', '.xlsx', '.ppt', '.pptx', '.odt', '.ods', '.odp' + ] + }; + + // MIME type filters as backup (when file extension isn't available) + this.mimeTypeFilters = { + photo: [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/tiff', + 'image/svg+xml' + ], + receipt: [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/tiff' + ], + manual: [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + ] + }; + + this.init(); + } + + init() { + this.bindEvents(); + this.loadActiveIntegrations(); + } + + bindEvents() { + const modal = document.getElementById('externalDocModal'); + const closeBtn = modal?.querySelector('.close-btn'); + const searchInput = document.getElementById('externalDocSearchInput'); + + if (closeBtn) closeBtn.addEventListener('click', () => this.closeModal()); + if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) this.closeModal(); }); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + clearTimeout(this.searchTimeout); + this.currentQuery = e.target.value; + this.currentPage = 1; // Reset to first page on new search + this.searchTimeout = setTimeout(() => this.performSearch(e.target.value), 300); + }); + } + + // Pagination event listeners + const prevBtn = document.getElementById('prevPageBtn'); + const nextBtn = document.getElementById('nextPageBtn'); + + if (prevBtn) prevBtn.addEventListener('click', () => this.previousPage()); + if (nextBtn) nextBtn.addEventListener('click', () => this.nextPage()); + } + + async loadActiveIntegrations() { + try { + // Remove existing event listeners and hide buttons + this.buttonIds.forEach(buttonId => { + const button = document.getElementById(buttonId); + if (button) { + const handler = this.eventHandlers.get(buttonId); + if (handler) { + button.removeEventListener('click', handler); + this.eventHandlers.delete(buttonId); + } + button.style.display = 'none'; + } + }); + + // Use IntegrationsManager to get active integrations + const allActiveIntegrations = await this.integrationsManager.getActiveIntegrations(); + + // Filter to only include document-management category integrations + this.activeIntegrations = allActiveIntegrations.filter(integration => { + const integrationData = this.integrationsManager.getIntegration(integration.id); + return integrationData && integrationData.category === INTEGRATION_CATEGORIES.DOCUMENT_MANAGEMENT; + }); + + if (window.appConfig?.debug) { + console.log('ExternalDocManager: All active integrations:', allActiveIntegrations); + console.log('ExternalDocManager: Document management integrations:', this.activeIntegrations); + } + + if (this.activeIntegrations && this.activeIntegrations.length > 0) { + this.buttonIds.forEach(buttonId => { + const button = document.getElementById(buttonId); + if (button) { + const handler = (e) => this.handleLinkExternalDocs(e, buttonId); + this.eventHandlers.set(buttonId, handler); + button.addEventListener('click', handler); + button.style.display = 'flex'; + } + }); + } + this.updateIntegrationFilters(); + } catch (error) { + console.error('Failed to load active integrations:', error); + this.activeIntegrations = []; + } + } + + updateIntegrationFilters() { + const filtersContainer = document.getElementById('integrationFilters'); + if (!filtersContainer) return; + + filtersContainer.innerHTML = ''; + + if (this.activeIntegrations.length === 0) { + filtersContainer.innerHTML = '
No document management integrations enabled
'; + return; + } + + const allBtn = document.createElement('button'); + allBtn.className = 'integration-filter-btn active'; + allBtn.textContent = 'All Sources'; + allBtn.addEventListener('click', () => this.toggleIntegrationFilter('all')); + filtersContainer.appendChild(allBtn); + + this.activeIntegrations.forEach(integration => { + const btn = document.createElement('button'); + btn.className = 'integration-filter-btn'; + + // Get color scheme from IntegrationsManager + const integrationData = this.integrationsManager.getIntegration(integration.id); + if (integrationData && integrationData.colorScheme) { + btn.style.backgroundColor = integrationData.colorScheme; + } + + btn.textContent = this.integrationsManager.getIntegrationName(integration.id); + btn.addEventListener('click', () => this.toggleIntegrationFilter(integration.id)); + filtersContainer.appendChild(btn); + }); + } + + toggleIntegrationFilter(integrationId) { + // Update selected integrations + this.selectedIntegrations.clear(); + this.selectedIntegrations.add(integrationId === 'all' ? 'all' : integrationId); + + // Update UI - find the correct button + const buttons = document.querySelectorAll('.integration-filter-btn'); + buttons.forEach(btn => { + btn.classList.remove('active'); + // Check if this button corresponds to the selected integration + if ((integrationId === 'all' && btn.textContent === 'All Sources') || + (integrationId !== 'all' && btn.textContent === this.integrationsManager.getIntegrationName(integrationId))) { + btn.classList.add('active'); + } + }); + + // Reset pagination and reload data with new filter + this.currentPage = 1; + if (this.currentQuery) { + this.performSearch(this.currentQuery); + } else { + this.loadAllDocuments(); + } + } + + handleLinkExternalDocs(event, buttonId) { + const idMap = { + 'linkExternalPhotos': { type: 'photo', isSubAsset: false }, + 'linkExternalReceipts': { type: 'receipt', isSubAsset: false }, + 'linkExternalManuals': { type: 'manual', isSubAsset: false }, + 'linkExternalSubPhotos': { type: 'photo', isSubAsset: true }, + 'linkExternalSubReceipts': { type: 'receipt', isSubAsset: true }, + 'linkExternalSubManuals': { type: 'manual', isSubAsset: true } + }; + + const config = idMap[buttonId]; + if (!config) return; + + this.currentAttachmentType = config.type; + this.currentIsSubAsset = config.isSubAsset; + this.openModal(); + } + + openModal() { + const modal = document.getElementById('externalDocModal'); + if (modal) { + modal.style.display = 'flex'; + + // Reset state + this.currentPage = 1; + this.currentQuery = ''; + + const searchInput = document.getElementById('externalDocSearchInput'); + if (searchInput) { + searchInput.value = ''; + // Update placeholder based on attachment type + const attachmentTypeText = this.getAttachmentTypeDisplayText(); + const fileTypeHint = this.getFileTypeHint(); + searchInput.placeholder = `Search ${attachmentTypeText} or browse all... ${fileTypeHint}`; + } + + // Update modal title to show what type of files we're looking for + this.updateModalTitle(); + + // Load integrations and immediately load all documents + this.loadActiveIntegrations().then(() => { + this.loadAllDocuments(); + }); + + // Focus search input after a brief delay + setTimeout(() => { + if (searchInput) searchInput.focus(); + }, 100); + } + } + + closeModal() { + const modal = document.getElementById('externalDocModal'); + if (modal) modal.style.display = 'none'; + clearTimeout(this.searchTimeout); + } + + async performSearch(query) { + if (!query || query.trim().length < 2) { + this.loadAllDocuments(); + return; + } + + if (this.isLoading) return; + + // Check if we have any active integrations + if (!this.activeIntegrations || this.activeIntegrations.length === 0) { + const resultsContainer = document.getElementById('externalDocResults'); + if (resultsContainer) { + resultsContainer.innerHTML = ` +
+

No document management integrations are currently enabled.

+

Please configure at least one document integration (like Paperless NGX or Papra) to search external documents.

+
+ `; + } + this.hidePagination(); + return; + } + + this.isLoading = true; + + const resultsContainer = document.getElementById('externalDocResults'); + if (!resultsContainer) return; + + this.showLoading('Searching documents...'); + + try { + const results = await this.searchAllIntegrations(query, this.currentPage); + this.displayResults(results, query); + } catch (error) { + resultsContainer.innerHTML = `

Search failed: ${error.message}

`; + this.hidePagination(); + } finally { + this.isLoading = false; + } + } + + async loadAllDocuments() { + if (this.isLoading) return; + + // Check if we have any active integrations + if (!this.activeIntegrations || this.activeIntegrations.length === 0) { + const resultsContainer = document.getElementById('externalDocResults'); + if (resultsContainer) { + resultsContainer.innerHTML = ` +
+

No document management integrations are currently enabled.

+

Please configure at least one document integration (like Paperless NGX or Papra) to search and link external documents.

+
+ `; + } + this.hidePagination(); + return; + } + + this.isLoading = true; + + this.showLoading('Loading documents...'); + + try { + const results = await this.searchAllIntegrations('', this.currentPage); + this.displayResults(results, ''); + } catch (error) { + const resultsContainer = document.getElementById('externalDocResults'); + if (resultsContainer) { + resultsContainer.innerHTML = `

Failed to load documents: ${error.message}

`; + } + this.hidePagination(); + } finally { + this.isLoading = false; + } + } + + showLoading(message = 'Loading...') { + const resultsContainer = document.getElementById('externalDocResults'); + if (resultsContainer) { + resultsContainer.innerHTML = `
${message}
`; + } + this.hidePagination(); + } + + async searchAllIntegrations(query, page = 1) { + const targetIntegrations = this.getTargetIntegrations(); + + if (targetIntegrations.length === 0) { + return { results: [], count: 0, next: null, previous: null }; + } + + // If "All Sources" is selected, search across all integrations + if (this.selectedIntegrations.has('all')) { + return await this.searchMultipleIntegrations(targetIntegrations, query, page); + } else { + // Search specific integration(s) + const selectedIntegrations = targetIntegrations.filter(integration => + this.selectedIntegrations.has(integration.id) + ); + + if (selectedIntegrations.length === 0) { + return { results: [], count: 0, next: null, previous: null }; + } else if (selectedIntegrations.length === 1) { + return await this.searchSingleIntegration(selectedIntegrations[0], query, page); + } else { + // Multiple specific integrations selected + return await this.searchMultipleIntegrations(selectedIntegrations, query, page); + } + } + } + + async searchMultipleIntegrations(integrations, query, page = 1) { + try { + // For multiple integrations, we need to handle pagination differently + // We'll collect results from each integration and then paginate client-side + const searchPromises = integrations.map(async integration => { + try { + let allResults = []; + let totalCount = 0; + + // When filtering by attachment type, we need to fetch more pages since filtering happens client-side + const isFiltering = !!this.currentAttachmentType; + const maxPages = isFiltering ? 10 : 5; // Fetch more pages when filtering + const targetResults = isFiltering ? this.pageSize * 6 : this.pageSize * (page + 1); // Target more results when filtering + + for (let p = 1; p <= maxPages; p++) { + const result = await this.searchSingleIntegration(integration, query, p); + if (result.results && result.results.length > 0) { + // If filtering, apply the filter to see how many actually match + let resultsToAdd = result.results; + if (isFiltering) { + resultsToAdd = result.results.filter(doc => { + const fileNameForExtraction = doc.originalFileName || doc.title; + return this.isValidFileType(doc.mimeType, this.currentAttachmentType, fileNameForExtraction); + }); + } + + allResults.push(...resultsToAdd); + } + + // Update total count from the first page response + if (p === 1) { + totalCount = result.count || 0; + } + + // Stop if no more pages available + if (!result.next) break; + + // If not filtering, stop when we have enough raw results + // If filtering, stop when we have enough filtered results or enough raw results to process + if (!isFiltering && allResults.length >= this.pageSize * (page + 1)) break; + if (isFiltering && allResults.length >= targetResults) break; + } + + return { + results: allResults, + count: totalCount, + integration: integration.id + }; + } catch (error) { + console.warn(`Search failed for ${integration.id}:`, error); + return { results: [], count: 0, integration: integration.id }; + } + }); + + const results = await Promise.all(searchPromises); + + // Combine results from all integrations + const combinedResults = []; + let totalCount = 0; + + results.forEach(result => { + // Results are already filtered if attachment type filtering was applied + combinedResults.push(...(result.results || [])); + totalCount += result.count || 0; + }); + + // Sort combined results by modified date (newest first) + combinedResults.sort((a, b) => { + const dateA = new Date(a.modified || 0); + const dateB = new Date(b.modified || 0); + return dateB - dateA; + }); + + // Apply client-side pagination for combined results + const startIndex = (page - 1) * this.pageSize; + const endIndex = startIndex + this.pageSize; + const paginatedResults = combinedResults.slice(startIndex, endIndex); + + // Calculate pagination info based on available results + const availableResults = combinedResults.length; + const hasNext = endIndex < availableResults; + const hasPrevious = page > 1; + + // Store total for display - use available results for pagination + this.totalDocuments = availableResults; + + return { + results: paginatedResults, + count: availableResults, // Available results for pagination logic + totalCount: totalCount, // Total from APIs for display (may be higher than available if filtering) + next: hasNext ? true : null, + previous: hasPrevious ? true : null + }; + } catch (error) { + console.error('Failed to search multiple integrations:', error); + throw error; + } + } + + async searchSingleIntegration(integration, query, page = 1) { + switch (integration.id) { + case 'paperless': + return await this.searchPaperless(query, page); + case 'papra': + return await this.searchPapra(query, page); + default: + throw new Error(`Unsupported integration: ${integration.id}`); + } + } + + getTargetIntegrations() { + // If "all" is selected, return all active integrations + if (this.selectedIntegrations.has('all')) { + return this.activeIntegrations; + } + + // Return only selected integrations that are active + return this.activeIntegrations.filter(integration => + this.selectedIntegrations.has(integration.id) + ); + } + + async searchPaperless(query, page = 1) { + // When filtering by attachment type, load more results to account for filtering + const pageSize = this.currentAttachmentType ? Math.max(this.pageSize * 3, 50) : this.pageSize; + + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString() + }); + + if (query && query.trim()) { + params.append('q', query.trim()); + } + + const searchEndpoint = `${globalThis.getApiBaseUrl()}/${API_PAPERLESS_ENDPOINT}/search?${params.toString()}`; + const response = await fetch(searchEndpoint); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage); + + const data = await response.json(); + + // Store pagination info + this.totalDocuments = data.count || 0; + + return { + results: (data.results || []).map(doc => ({ + id: doc.id, + title: doc.title, + source: 'paperless', + downloadUrl: `${globalThis.getApiBaseUrl()}/${API_PAPERLESS_ENDPOINT}/document/${doc.id}/download`, + previewUrl: `${globalThis.getApiBaseUrl()}/${API_PAPERLESS_ENDPOINT}/document/${doc.id}/preview`, + mimeType: doc.mime_type, + fileSize: doc.file_size, + modified: doc.modified, + originalFileName: doc.original_file_name, // Include original filename for extension extraction + attachedAt: new Date().toISOString() + })), + count: data.count || 0, + next: data.next, + previous: data.previous + }; + } + + async searchPapra(query, page = 1) { + // When filtering by attachment type, load more results to account for filtering + const pageSize = this.currentAttachmentType ? Math.max(this.pageSize * 3, 50) : this.pageSize; + + // Convert from 1-based to 0-based pagination for Papra API + const pageIndex = Math.max(0, page - 1); + + const params = new URLSearchParams({ + pageIndex: pageIndex.toString(), + pageSize: pageSize.toString() + }); + + if (query && query.trim()) { + params.append('q', query.trim()); + } + + const searchEndpoint = `${globalThis.getApiBaseUrl()}/${API_PAPRA_ENDPOINT}/search?${params.toString()}`; + const response = await fetch(searchEndpoint); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage); + + const data = await response.json(); + + // Store pagination info + this.totalDocuments = data.documentsCount || 0; + + return { + results: (data.documents || []).map(doc => ({ + id: doc.id, + title: doc.name, + source: 'papra', + downloadUrl: `${globalThis.getApiBaseUrl()}/${API_PAPRA_ENDPOINT}/document/${doc.id}/download`, + previewUrl: `${globalThis.getApiBaseUrl()}/${API_PAPRA_ENDPOINT}/document/${doc.id}/preview`, + mimeType: doc.content || 'application/octet-stream', // Papra might not have MIME type + fileSize: doc.size, + modified: doc.updatedAt || doc.createdAt, + originalFileName: doc.name, // Use document name as filename + attachedAt: new Date().toISOString() + })), + count: data.documentsCount || 0, + next: null, // Papra uses different pagination + previous: null + }; + } + + displayResults(data, query) { + const resultsContainer = document.getElementById('externalDocResults'); + if (!resultsContainer) return; + + let results = data.results || []; + + // Only apply client-side filtering for single integration searches + // Multi-integration searches already have filtering applied + const isMultiIntegration = data.totalCount !== undefined; + + if (!isMultiIntegration && this.currentAttachmentType && (this.fileExtensionFilters[this.currentAttachmentType] || this.mimeTypeFilters[this.currentAttachmentType])) { + results = results.filter(doc => { + // Use originalFileName if available, otherwise fall back to title + const fileNameForExtraction = doc.originalFileName || doc.title; + const isValid = this.isValidFileType(doc.mimeType, this.currentAttachmentType, fileNameForExtraction); + return isValid; + }); + } + + if (results.length === 0) { + const attachmentTypeText = this.currentAttachmentType ? + ` matching file type for ${this.currentAttachmentType}s` : ''; + const message = query ? + `No documents found for "${query}"${attachmentTypeText}` : + `No documents available${attachmentTypeText}`; + resultsContainer.innerHTML = `

${message}

`; + this.hidePagination(); + return; + } + + const resultsHtml = results.map(doc => { + const formattedDate = doc.modified ? new Date(doc.modified).toLocaleDateString() : ''; + const formattedSize = doc.fileSize ? this.formatFileSize(doc.fileSize) : ''; + const meta = [formattedDate, formattedSize].filter(Boolean).join(' • '); + + // Get the display name for the source + const sourceDisplayName = this.getSourceDisplayName(doc.source); + + // Get integration data and color scheme for dynamic styling + const integrationData = this.integrationsManager.getIntegration(doc.source); + const colorScheme = integrationData?.colorScheme || '#6b7280'; // Default gray if no color scheme + const borderLeftStyle = `border-left: 3px solid ${colorScheme};`; + const sourceSpanStyle = `background-color: ${colorScheme};`; + + const html = ` +
+
+
+ ${this.escapeHtml(doc.title)} + ${sourceDisplayName} +
+ ${meta ? `
${this.escapeHtml(meta)}
` : ''} +
+ +
+ `; + return html; + }).join(''); + + + resultsContainer.innerHTML = resultsHtml; + const linkButtons = resultsContainer.querySelectorAll('.external-doc-link-btn'); + linkButtons.forEach(button => { + button.addEventListener('click', () => { + const docId = button.id.split('-')[1]; + // Use filtered results instead of original data.results + // Handle both string IDs (Papra) and integer IDs (Paperless) + const docData = results.find(doc => { + // Try exact string match first + if (doc.id === docId) return true; + // Try integer comparison for backward compatibility + const parsedId = parseInt(docId, 10); + if (!isNaN(parsedId) && doc.id === parsedId) return true; + return false; + }); + if (docData) { + this.linkDocument(docData); + } else { + globalThis.logError(`Document with ID ${docId} not found in filtered results`); + } + }); + }); + + // Update pagination - for single integration searches with filtering, + // update the data to reflect filtered results + let paginationData = data; + if (!isMultiIntegration && this.currentAttachmentType && results.length !== (data.results?.length || 0)) { + // Single integration with client-side filtering applied + paginationData = { + ...data, + results: results, + filteredCount: results.length + }; + } + this.updatePagination(paginationData, query); + } + + updatePagination(data, query) { + const paginationContainer = document.getElementById('externalDocPagination'); + const paginationInfo = document.getElementById('paginationInfo'); + const prevBtn = document.getElementById('prevPageBtn'); + const nextBtn = document.getElementById('nextPageBtn'); + + if (!paginationContainer || !paginationInfo || !prevBtn || !nextBtn) return; + + // For multi-integration searches, use the combined total count + const isMultiIntegration = data.totalCount !== undefined; + const currentResults = data.results?.length || 0; + + if (currentResults === 0) { + paginationContainer.style.display = 'none'; + return; + } + + // When filtering by attachment type, show a simpler pagination info + if (this.currentAttachmentType) { + const attachmentTypeDisplay = this.getAttachmentTypeDisplayText(); + paginationContainer.style.display = 'flex'; + + if (isMultiIntegration || this.currentPage > 1 || (data.next && currentResults === this.pageSize)) { + // Show page-based info when pagination is relevant + paginationInfo.textContent = `Page ${this.currentPage} - ${currentResults} ${attachmentTypeDisplay} shown`; + + // Enable/disable pagination buttons based on data availability + prevBtn.disabled = this.currentPage <= 1; + nextBtn.disabled = !data.next || currentResults < this.pageSize; + } else { + // Single page of results + paginationInfo.textContent = `${currentResults} ${attachmentTypeDisplay} found`; + prevBtn.disabled = true; + nextBtn.disabled = true; + } + return; + } + + // For non-filtered results, use the original total count + const totalCount = isMultiIntegration ? this.totalDocuments : (data.count || 0); + + // Calculate display info for non-filtered results + const startItem = ((this.currentPage - 1) * this.pageSize) + 1; + const endItem = Math.min(startItem + currentResults - 1, totalCount); + + // Calculate total pages + const totalPages = Math.ceil(totalCount / this.pageSize); + + // Show pagination if there are more results than one page OR if we're not on page 1 + if (totalPages > 1 || this.currentPage > 1) { + paginationContainer.style.display = 'flex'; + paginationInfo.textContent = `${startItem}-${endItem} of ${totalCount} documents`; + + // Update button states based on integration type + if (isMultiIntegration) { + // Multi-integration search - use client-side pagination logic + prevBtn.disabled = this.currentPage <= 1; + nextBtn.disabled = data.next === null || data.next === false; + } else { + // Single integration search - use server-side pagination + prevBtn.disabled = !data.previous; + nextBtn.disabled = !data.next; + } + } else { + paginationContainer.style.display = 'none'; + } + } + + hidePagination() { + const paginationContainer = document.getElementById('externalDocPagination'); + if (paginationContainer) { + paginationContainer.style.display = 'none'; + } + } + + async previousPage() { + if (this.currentPage > 1 && !this.isLoading) { + this.currentPage--; + if (this.currentQuery) { + this.performSearch(this.currentQuery); + } else { + this.loadAllDocuments(); + } + } + } + + async nextPage() { + if (!this.isLoading) { + this.currentPage++; + if (this.currentQuery) { + this.performSearch(this.currentQuery); + } else { + this.loadAllDocuments(); + } + } + } + + formatFileSize(bytes) { + if (!bytes) return ''; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + } + + /** + * Check if a file's MIME type is valid for the current attachment type + * @param {string} mimeType - The MIME type of the file + * @param {string} attachmentType - The attachment type (photo, receipt, manual) + * @param {string} documentTitle - The document title/filename for extension extraction + * @returns {boolean} - Whether the file type is valid + */ + isValidFileType(mimeType, attachmentType, documentTitle = '') { + // If no attachment type, allow everything + if (!attachmentType) return true; + + // Try file extension filtering first (more reliable) + const fileExtension = this.extractFileExtension(documentTitle); + if (fileExtension) { + const allowedExtensions = this.fileExtensionFilters[attachmentType]; + if (allowedExtensions) { + return allowedExtensions.includes(fileExtension.toLowerCase()); + } + } + + // Fallback to MIME type filtering if no file extension available + if (!mimeType) { + // Be strict for photos, allow for receipts/manuals if no MIME type + return attachmentType !== 'photo'; + } + + const allowedTypes = this.mimeTypeFilters[attachmentType]; + if (!allowedTypes) return true; // If no filter defined, don't filter + + // Normalize MIME type (lowercase, trim) + const normalizedMimeType = mimeType.toLowerCase().trim(); + + // Check exact match first + if (allowedTypes.includes(normalizedMimeType)) return true; + + // Check for wildcard matches (e.g., image/* for any image type) + const baseType = normalizedMimeType.split('/')[0]; + if (allowedTypes.some(type => type.startsWith(baseType + '/'))) { + return true; + } + + return false; + } + + /** + * Extract file extension from document title + * @param {string} title - Document title + * @returns {string|null} - File extension with dot (e.g., '.pdf') or null + */ + extractFileExtension(title) { + if (!title) return null; + + // Look for file extension pattern at the end of the title + const match = title.match(/\.([a-zA-Z0-9]+)(?:\s|$)/); + if (match) { + return '.' + match[1].toLowerCase(); + } + + // Try to extract from the very end if no spaces + const lastDotIndex = title.lastIndexOf('.'); + if (lastDotIndex > -1 && lastDotIndex < title.length - 1) { + const extension = title.substring(lastDotIndex).toLowerCase(); + // Only return if it looks like a valid extension (2-5 characters) + if (/^\.[a-z]{2,5}$/.test(extension)) { + return extension; + } + } + + return null; + } + + /** + * Get display text for the current attachment type + * @returns {string} - Display text for the attachment type + */ + getAttachmentTypeDisplayText() { + const typeMap = { + photo: 'images', + receipt: 'receipts', + manual: 'manuals' + }; + return typeMap[this.currentAttachmentType] || 'documents'; + } + + /** + * Update the modal title to show the attachment type and file type filter + */ + updateModalTitle() { + const modalTitle = document.querySelector('#externalDocModal .modal-title'); + if (!modalTitle) return; + + const attachmentTypeText = this.getAttachmentTypeDisplayText(); + + modalTitle.innerHTML = `Link External ${attachmentTypeText.charAt(0).toUpperCase() + attachmentTypeText.slice(1)}`; + } + + /** + * Get file type hint text for the current attachment type + * @returns {string} - File type hint text + */ + getFileTypeHint() { + const hintMap = { + photo: '(Images: JPG, PNG, GIF, WebP, etc.)', + receipt: '(Images & Documents: JPG, PNG, PDF, DOC, etc.)', + manual: '(Documents: PDF, DOC, XLS, PPT, etc.)' + }; + return hintMap[this.currentAttachmentType] || ''; + } + + async linkDocument(docData) { + // Find the button that was clicked + const clickedButton = event?.target?.closest('.external-doc-link-btn'); + + try { + if (clickedButton) this.setButtonLoading(clickedButton, true); + + const attachment = { + externalId: docData.id, + integrationId: docData.source, // Use the source (paperless, papra, etc.) + title: docData.title, + downloadUrl: docData.downloadUrl, + previewUrl: docData.previewUrl, + mimeType: docData.mimeType, + fileSize: docData.fileSize, + attachedAt: docData.attachedAt + }; + + await this.modalManager.attachPaperlessDocument(attachment, this.currentAttachmentType, this.currentIsSubAsset); + + // Show success feedback + // globalThis.toaster.show(`Linked "${docData.title}"`, 'success', false, 2000); + + // Update button to show it's been linked + if (clickedButton) { + clickedButton.innerHTML = ` + + + + Linked + `; + clickedButton.disabled = true; + clickedButton.style.background = 'var(--success-color, #10b981)'; + clickedButton.style.opacity = '0.8'; + clickedButton.style.cursor = 'default'; + } + + } catch (error) { + if (clickedButton) this.setButtonLoading(clickedButton, false); + globalThis.logError('Failed to link document:', error.message); + } + } + + getSourceDisplayName(sourceId) { + const sourceNames = { + 'paperless': 'Paperless NGX', + 'papra': 'Papra' + }; + return sourceNames[sourceId] || sourceId.charAt(0).toUpperCase() + sourceId.slice(1); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} \ No newline at end of file diff --git a/public/managers/integrations.js b/public/managers/integrations.js new file mode 100644 index 0000000..401b728 --- /dev/null +++ b/public/managers/integrations.js @@ -0,0 +1,689 @@ +// IntegrationsManager handles all integration-related functionality including loading, +// rendering, configuration, and testing of integrations in the settings modal +import { API_TEST_SUCCESS, TOKENMASK } from '../src/constants.js'; +import { calculateCollapsibleContentHeight, initCollapsibleSections } from '../js/collapsible.js'; + +export class IntegrationsManager { + constructor({ + setButtonLoading + }) { + this.setButtonLoading = setButtonLoading; + this.DEBUG = false; + this.integrations = new Map(); + this.loadingPromise = null; + } + + /** + * Initialize integrations - load and render them dynamically + */ + async initialize() { + await this.loadIntegrations(); + } + + /** + * Load and render integrations dynamically + */ + async loadIntegrations() { + // Store the loading promise to avoid duplicate requests + if (this.loadingPromise) { + return this.loadingPromise; + } + + this.loadingPromise = this._loadIntegrationsData(); + return this.loadingPromise; + } + + async _loadIntegrationsData() { + 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?t=${new Date().getTime()}`); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) { + throw new Error(responseValidation.errorMessage); + } + + const integrations = await response.json(); + + // Store integrations in a Map for badge generation + this.integrations.clear(); + integrations.forEach(integration => { + this.integrations.set(integration.id, integration); + }); + + 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'; + + // Only render UI if we're in settings context + if (integrationsContainer) { + this._renderIntegrationsUI(integrations, integrationsContainer); + } + + } 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}`; + } + } finally { + this.loadingPromise = null; // Reset promise to allow re-fetching if needed + } + } + + _renderIntegrationsUI(integrations, integrationsContainer) { + // 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); + } + + // Initialize collapsible sections for the rendered integrations + initCollapsibleSections(); + + // Bind events for all rendered integrations + this.bindIntegrationEvents(); + } + + /** + * Get integration by ID + * @param {string} integrationId - The integration identifier + * @returns {Object|null} - The integration object or null if not found + */ + getIntegration(integrationId) { + return this.integrations.get(integrationId) || null; + } + + /** + * Generate integration badge HTML based on integration ID + * @param {string} integrationId - The integration identifier + * @returns {string} - The badge HTML + */ + getIntegrationBadge(integrationId) { + if (!integrationId) return ''; + + const integration = this.getIntegration(integrationId); + + if (integration && integration.logoHref) { + // Use dynamic integration data + const badgeClass = `integration-badge ${integrationId}-badge`; + const logoSrc = integration.logoHref.startsWith('/') ? integration.logoHref : `/${integration.logoHref}`; + const title = `From ${integration.name || integrationId}`; + const alt = integration.name || integrationId; + + return `
${alt}
`; + } else { + // Fallback for unknown integrations or when data isn't loaded yet + return `
${integrationId}
`; + } + } + + /** + * Get integration color scheme + * @param {string} integrationId - The integration identifier + * @returns {string|null} - The color scheme or null if not found + */ + getIntegrationColorScheme(integrationId) { + const integration = this.getIntegration(integrationId); + return integration?.colorScheme || null; + } + + /** + * Get integration name + * @param {string} integrationId - The integration identifier + * @returns {string} - The integration name or the ID as fallback + */ + getIntegrationName(integrationId) { + const integration = this.getIntegration(integrationId); + return integration?.name || integrationId; + } + + /** + * Check if integrations are loaded + * @returns {boolean} + */ + isLoaded() { + return this.integrations.size > 0; + } + + /** + * Get all integrations as an array + * @returns {Array} - Array of all integrations + */ + getAllIntegrations() { + return Array.from(this.integrations.values()); + } + + /** + * Get enabled integrations by fetching settings and filtering + * @returns {Promise} - Array of enabled integrations + */ + async getActiveIntegrations() { + try { + // Ensure integrations are loaded first + await this.loadIntegrations(); + + // Fetch settings to check which integrations are enabled + const response = await fetch(`${globalThis.getApiBaseUrl()}/api/integrations/enabled`); + const responseValidation = await globalThis.validateResponse(response); + if (responseValidation.errorMessage) { + throw new Error(responseValidation.errorMessage); + } + + return await response.json(); + } catch (error) { + console.error('Failed to get active integrations:', error); + return []; + } + } + + /** + * 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 collapsible-section'; + section.dataset.integrationId = integration.id; + + const header = document.createElement('div'); + header.className = 'collapsible-header'; + + // Create header content with proper structure matching main app + const headerContent = document.createElement('div'); + headerContent.className = 'integration-header-content'; + + const titleElement = document.createElement('h3'); + if (integration.logoHref) { + const iconElement = document.createElement('img'); + iconElement.src = integration.logoHref; + iconElement.alt = `${integration.name} logo`; + iconElement.className = 'integration-logo'; + headerContent.appendChild(iconElement); + } + titleElement.textContent = integration.name; + headerContent.appendChild(titleElement); + + // Add description if available + if (integration.description) { + const descElement = document.createElement('p'); + descElement.className = 'integration-description'; + descElement.textContent = integration.description; + descElement.style.color = 'var(--text-color-secondary)'; + headerContent.appendChild(descElement); + } + + // Create toggle icon matching main app structure + const toggleIcon = document.createElement('div'); + // toggleIcon.className = 'collapsible-toggle'; + toggleIcon.innerHTML = ` + + `; + + header.appendChild(headerContent); + header.appendChild(toggleIcon); + + const content = document.createElement('div'); + content.className = 'collapsible-content'; + + 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.innerHTML = 'Test Connection
'; + // fieldsContainer.appendChild(testButton); + // add test button next to fields container checkbox integration-field data-field-name="enabled" + const enabledField = fieldsContainer.querySelector('.integration-field[data-field-name="enabled"]'); + if (enabledField) { + enabledField.appendChild(testButton); + } else { + // If no enabled field, append to fields container directly + fieldsContainer.appendChild(testButton); + } + } + + content.appendChild(fieldsContainer); + section.appendChild(header); + section.appendChild(content); + + // Make sections start collapsed and then use shared collapsible system + section.setAttribute('data-collapsed', 'true'); + + 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 = '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 labelContainer = document.createElement('div'); + const label = document.createElement('label'); + label.textContent = fieldConfig.label || fieldName; + label.htmlFor = `${integrationId}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`; + labelContainer.appendChild(label); + + 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': + // 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'; + input.innerHTML = ` + + `; + break; + + default: + input = document.createElement('input'); + input.type = 'text'; + input.id = fieldId; + input.name = fieldName; + input.placeholder = fieldConfig.placeholder || ''; + } + + fieldContainer.appendChild(labelContainer); + + + // Add description if provided + if (fieldConfig.description) { + const description = document.createElement('p'); + description.className = 'field-description'; + description.textContent = fieldConfig.description; + labelContainer.appendChild(description); + } + fieldContainer.appendChild(input); + return fieldContainer; + } + + /** + * 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') + ); + } + + /** + * 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', ''); + const section = e.target.closest('.collapsible-section'); + + this.toggleIntegrationFields(integrationId, e.target.checked); + + // Recalculate collapsible height on enabled/disable integration + if (section) { + const content = section.querySelector('.collapsible-content'); + if (content) { + calculateCollapsibleContentHeight(content); + } + } + }); + + // Set initial state + const integrationId = toggle.id.replace('Enabled', ''); + 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', () => { + 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); + }); + }); + + // Initialize collapsible sections using shared system + initCollapsibleSections(); + } + + /** + * Toggle integration fields visibility based on enabled state + */ + toggleIntegrationFields(integrationId, enabled) { + const section = document.querySelector(`[data-integration-id="${integrationId}"]`); + if (!section) return; + + const integration = this.integrations.get(integrationId); + const isComingSoon = integration && integration.comingSoon; + + // For coming soon integrations, always hide dependent fields and test button + if (isComingSoon) { + enabled = false; + } + + // Toggle fields that depend on enabled state + const dependentFields = section.querySelectorAll('.integration-field.depends-on-enabled'); + dependentFields.forEach(field => { + 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 ? 'flex' : 'none'; // Use flex for proper alignment + } + } + + /** + * 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 === API_TEST_SUCCESS) { + globalThis.toaster.show(result.message || `${integrationId} connection test successful!`); + } 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; + + const integration = this.integrations.get(integrationId); + if (integration && integration.comingSoon) { + // For coming soon integrations, just ensure fields are hidden and do not save any settings + this.toggleIntegrationFields(integrationId, false); + 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/modalManager.js b/public/managers/modalManager.js index b6142a9..2bfeb33 100644 --- a/public/managers/modalManager.js +++ b/public/managers/modalManager.js @@ -47,7 +47,10 @@ export class ModalManager { // Global state getAssets, - getSubAssets + getSubAssets, + + // Integrations + integrationsManager }) { // Store DOM elements this.assetModal = assetModal; @@ -95,6 +98,9 @@ export class ModalManager { this.getAssets = getAssets; this.getSubAssets = getSubAssets; + // Store integrations manager + this.integrationsManager = integrationsManager; + // Modal state this.isEditMode = false; this.currentAsset = null; @@ -128,6 +134,19 @@ export class ModalManager { this.currentAsset = asset; this.filesToDelete = []; + // For new assets, create a temporary asset object to store external document attachments + if (!this.isEditMode) { + this.currentAsset = { + id: this.generateId(), + photoPaths: [], + receiptPaths: [], + manualPaths: [], + photoInfo: [], + receiptInfo: [], + manualInfo: [] + }; + } + document.getElementById('addAssetTitle').textContent = this.isEditMode ? 'Edit Asset' : 'Add Asset'; this.assetForm.reset(); let containsExistingFiles = false; @@ -216,6 +235,19 @@ export class ModalManager { this.currentSubAsset = subAsset; this.filesToDelete = []; + // For new sub-assets, create a temporary sub-asset object to store external document attachments + if (!this.isEditMode) { + this.currentSubAsset = { + id: this.generateId(), + photoPaths: [], + receiptPaths: [], + manualPaths: [], + photoInfo: [], + receiptInfo: [], + manualInfo: [] + }; + } + document.getElementById('addComponentTitle').textContent = this.isEditMode ? 'Edit Component' : 'Add Component'; this.subAssetForm.reset(); let containsExistingFiles = false; @@ -529,7 +561,8 @@ export class ModalManager { photoInput, this, photoInfo.originalName || photoPath.split('/').pop(), - photoInfo.size ? this.formatFileSize(photoInfo.size) : null + photoInfo.size ? this.formatFileSize(photoInfo.size) : null, + photoInfo ); }); containsExistingFiles = true; @@ -544,7 +577,8 @@ export class ModalManager { photoInput, this, photoInfo.originalName || asset.photoPath.split('/').pop(), - photoInfo.size ? this.formatFileSize(photoInfo.size) : null + photoInfo.size ? this.formatFileSize(photoInfo.size) : null, + photoInfo ); containsExistingFiles = true; } @@ -561,7 +595,8 @@ export class ModalManager { receiptInput, this, receiptInfo.originalName || receiptPath.split('/').pop(), - receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null + receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null, + receiptInfo ); }); containsExistingFiles = true; @@ -576,7 +611,8 @@ export class ModalManager { receiptInput, this, receiptInfo.originalName || asset.receiptPath.split('/').pop(), - receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null + receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null, + receiptInfo ); containsExistingFiles = true; } @@ -593,7 +629,8 @@ export class ModalManager { manualInput, this, manualInfo.originalName || manualPath.split('/').pop(), - manualInfo.size ? this.formatFileSize(manualInfo.size) : null + manualInfo.size ? this.formatFileSize(manualInfo.size) : null, + manualInfo ); }); containsExistingFiles = true; @@ -608,7 +645,8 @@ export class ModalManager { manualInput, this, manualInfo.originalName || asset.manualPath.split('/').pop(), - manualInfo.size ? this.formatFileSize(manualInfo.size) : null + manualInfo.size ? this.formatFileSize(manualInfo.size) : null, + manualInfo ); containsExistingFiles = true; } @@ -642,7 +680,8 @@ export class ModalManager { photoInput, this, photoInfo.originalName || photoPath.split('/').pop(), - photoInfo.size ? this.formatFileSize(photoInfo.size) : null + photoInfo.size ? this.formatFileSize(photoInfo.size) : null, + photoInfo ); }); containsExistingFiles = true; @@ -657,7 +696,8 @@ export class ModalManager { photoInput, this, photoInfo.originalName || subAsset.photoPath.split('/').pop(), - photoInfo.size ? this.formatFileSize(photoInfo.size) : null + photoInfo.size ? this.formatFileSize(photoInfo.size) : null, + photoInfo ); containsExistingFiles = true; } @@ -674,7 +714,8 @@ export class ModalManager { receiptInput, this, receiptInfo.originalName || receiptPath.split('/').pop(), - receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null + receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null, + receiptInfo ); }); containsExistingFiles = true; @@ -689,7 +730,8 @@ export class ModalManager { receiptInput, this, receiptInfo.originalName || subAsset.receiptPath.split('/').pop(), - receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null + receiptInfo.size ? this.formatFileSize(receiptInfo.size) : null, + receiptInfo ); containsExistingFiles = true; } @@ -706,7 +748,8 @@ export class ModalManager { manualInput, this, manualInfo.originalName || manualPath.split('/').pop(), - manualInfo.size ? this.formatFileSize(manualInfo.size) : null + manualInfo.size ? this.formatFileSize(manualInfo.size) : null, + manualInfo ); }); containsExistingFiles = true; @@ -721,7 +764,8 @@ export class ModalManager { manualInput, this, manualInfo.originalName || subAsset.manualPath.split('/').pop(), - manualInfo.size ? this.formatFileSize(manualInfo.size) : null + manualInfo.size ? this.formatFileSize(manualInfo.size) : null, + manualInfo ); containsExistingFiles = true; } @@ -836,10 +880,17 @@ export class ModalManager { newAsset.manualInfo = this.currentAsset.manualInfo || []; newAsset.createdAt = this.currentAsset.createdAt; } else { - newAsset.id = this.generateId(); + // For new assets, use the temporary asset data (including external document attachments) + newAsset.id = this.currentAsset ? this.currentAsset.id : this.generateId(); newAsset.photoPath = null; newAsset.receiptPath = null; newAsset.manualPath = null; + newAsset.photoPaths = this.currentAsset ? this.currentAsset.photoPaths || [] : []; + newAsset.receiptPaths = this.currentAsset ? this.currentAsset.receiptPaths || [] : []; + newAsset.manualPaths = this.currentAsset ? this.currentAsset.manualPaths || [] : []; + newAsset.photoInfo = this.currentAsset ? this.currentAsset.photoInfo || [] : []; + newAsset.receiptInfo = this.currentAsset ? this.currentAsset.receiptInfo || [] : []; + newAsset.manualInfo = this.currentAsset ? this.currentAsset.manualInfo || [] : []; newAsset.createdAt = new Date().toISOString(); } @@ -894,12 +945,19 @@ export class ModalManager { // Handle file deletions - This is now handled by filesToDelete array } else { - const generatedId = this.generateId(); - console.log('ModalManager: Create mode - generating new ID:', generatedId); + // For new sub-assets, use the temporary sub-asset data (including external document attachments) + const generatedId = this.currentSubAsset ? this.currentSubAsset.id : this.generateId(); + console.log('ModalManager: Create mode - using ID:', generatedId); newSubAsset.id = generatedId; newSubAsset.photoPath = null; newSubAsset.receiptPath = null; newSubAsset.manualPath = null; + newSubAsset.photoPaths = this.currentSubAsset ? this.currentSubAsset.photoPaths || [] : []; + newSubAsset.receiptPaths = this.currentSubAsset ? this.currentSubAsset.receiptPaths || [] : []; + newSubAsset.manualPaths = this.currentSubAsset ? this.currentSubAsset.manualPaths || [] : []; + newSubAsset.photoInfo = this.currentSubAsset ? this.currentSubAsset.photoInfo || [] : []; + newSubAsset.receiptInfo = this.currentSubAsset ? this.currentSubAsset.receiptInfo || [] : []; + newSubAsset.manualInfo = this.currentSubAsset ? this.currentSubAsset.manualInfo || [] : []; newSubAsset.createdAt = new Date().toISOString(); } @@ -1035,4 +1093,217 @@ export class ModalManager { this.duplicationManager.openDuplicateModal(type, itemId); } } + + /** + * Attach an external document to the current asset/sub-asset + * @param {Object} attachment - The attachment object + * @param {string} attachmentType - Type of attachment ('photo', 'receipt', 'manual') + * @param {boolean} isSubAsset - Whether this is for a sub-asset + */ + async attachExternalDocument(attachment, attachmentType, isSubAsset) { + // For backward compatibility, also support the old method name + return this.attachPaperlessDocument(attachment, attachmentType, isSubAsset); + } + + /** + * Attach an external document to the current asset/sub-asset + * @param {Object} attachment - The attachment object from any external integration + * @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 external 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 external 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 external document as a "file" + targetAsset[pathsKey].push(attachment.downloadUrl); + targetAsset[infoKey].push({ + originalName: attachment.title, + size: attachment.fileSize, + attachedAt: attachment.attachedAt, + integrationId: attachment.integrationId, + externalId: attachment.externalId, + mimeType: attachment.mimeType, + downloadUrl: attachment.downloadUrl, + previewUrl: attachment.previewUrl + }); + + console.log(`Attached external document to ${isSubAsset ? 'sub-asset' : 'asset'}:`, { + type: attachmentType, + title: attachment.title, + integrationId: attachment.integrationId, + externalId: attachment.externalId + }); + + } catch (error) { + globalThis.logError('Failed to attach Paperless document:', error.message); + throw error; + } + } + + /** + * Create a preview element for an external 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 external-document ${attachment.integrationId}-document`; + + // 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) { + // Use preview URL for images if available, fallback to download URL, then to document icon + const imageUrl = attachment.previewUrl || attachment.downloadUrl; + previewContent = ` + External Document Preview + `; + } + else if (type === 'receipt') { + previewContent = ` + + + `; + } + else { + // Show document icon + previewContent = ` +
+ + + + + + + +
+ `; + } + + // Get integration badge based on integrationId + const integrationBadge = this._getIntegrationBadge(attachment.integrationId); + + previewItem.innerHTML = ` +
+ ${previewContent} + ${integrationBadge} +
+ +
+ ${this._escapeHtml(attachment.title)} + ${attachment.fileSize ? `${this.formatFileSize(attachment.fileSize)}` : ''} +
+ `; + + // 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'; + } + + // Add delete button handler + const deleteBtn = previewItem.querySelector('.delete-preview-btn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + this._removePaperlessAttachment(previewItem, attachment, type); + }); + } + + return previewItem; + } + + /** + * Remove an external 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'); + } + + /** + * Get the appropriate integration badge HTML based on integration ID + * @param {string} integrationId - The integration identifier + * @returns {string} - The badge HTML + */ + _getIntegrationBadge(integrationId) { + return this.integrationsManager?.getIntegrationBadge(integrationId) || `
${integrationId}
`; + } + + /** + * 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/settings.js b/public/managers/settings.js index c2856f2..0dbae0b 100644 --- a/public/managers/settings.js +++ b/public/managers/settings.js @@ -1,4 +1,7 @@ // 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({ settingsBtn, @@ -9,7 +12,9 @@ export class SettingsManager { settingsClose, testNotificationSettings, setButtonLoading, - renderDashboard + renderDashboard, + loadActiveIntegrations, + integrationsManager }) { this.localSettingsStorageKey = 'dumbAssetSettings'; this.localSettingsLastOpenedPaneKey = 'dumbAssetSettingsLastOpenedPane'; @@ -22,8 +27,11 @@ export class SettingsManager { this.testNotificationSettings = testNotificationSettings; this.setButtonLoading = setButtonLoading; this.renderDashboard = renderDashboard; + this.loadActiveIntegrations = loadActiveIntegrations; + this.integrationsManager = integrationsManager; this.selectedAssetId = null; this.DEBUG = false; + this._bindEvents(); this.defaultSettings = window.appConfig?.defaultSettings || { notificationSettings: { @@ -207,6 +215,13 @@ export class SettingsManager { document.getElementById('toggleWarranties').checked = finalVisibility.warranties; document.getElementById('toggleAnalytics').checked = finalVisibility.analytics; document.getElementById('toggleEvents').checked = finalVisibility.events; + + // Load integrations dynamically + await this.integrationsManager.loadIntegrations(); + + // Apply current integration settings to the dynamically loaded form + this.integrationsManager.applyIntegrationSettingsToForm(settings.integrationSettings || {}); + // Card visibility toggles if (typeof window.renderCardVisibilityToggles === 'function') { window.renderCardVisibilityToggles(settings); @@ -259,7 +274,8 @@ export class SettingsManager { expired: document.getElementById('toggleCardWarrantiesExpired')?.checked !== false, active: document.getElementById('toggleCardWarrantiesActive')?.checked !== false } - } + }, + integrationSettings: this.integrationsManager.collectAllIntegrationSettings() }; const dashboardSections = document.querySelectorAll('#dashboardSections .sortable-item'); dashboardSections.forEach(section => { @@ -278,11 +294,16 @@ export class SettingsManager { const settingsCopy = { ...settings }; localStorage.setItem(this.localSettingsStorageKey, JSON.stringify(settingsCopy)); - this.closeSettingsModal(); - globalThis.toaster.show('Settings saved'); + + // 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(); } + + globalThis.toaster.show('Settings saved'); } catch (error) { globalThis.logError('Failed to save settings:', error.message); } finally { diff --git a/public/script.js b/public/script.js index 7f5d8e0..2186806 100644 --- a/public/script.js +++ b/public/script.js @@ -13,6 +13,7 @@ new GlobalHandlers(); // Import file upload module import { initializeFileUploads, handleFileUploads } from '/src/services/fileUpload/index.js'; import { formatFileSize } from '/src/services/fileUpload/utils.js'; + // Import asset renderer module import { initRenderer, @@ -29,7 +30,8 @@ import { sortAssets, // Import file preview renderer setupFilePreview, - setupExistingFilePreview + setupExistingFilePreview, + initPreviewRenderer } from '/src/services/render/index.js'; import { ChartManager } from '/managers/charts.js'; import { registerServiceWorker } from './helpers/serviceWorkerHelper.js'; @@ -45,6 +47,8 @@ import { MaintenanceManager } from './managers/maintenanceManager.js'; import { ModalManager } from './managers/modalManager.js'; import { DashboardManager } from './managers/dashboardManager.js'; import { DuplicationManager } from './managers/duplicationManager.js'; +import { ExternalDocManager } from './managers/externalDocManager.js'; +import { IntegrationsManager } from './managers/integrations.js'; document.addEventListener('DOMContentLoaded', () => { // Initialize variables for app state @@ -116,16 +120,34 @@ document.addEventListener('DOMContentLoaded', () => { let modalManager; let dashboardManager; let duplicationManager; + let externalDocManager; + let integrationsManager; const chartManager = new ChartManager({ formatDate }); // 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'; } + // Initialize integrations manager and load integration data early for dynamic badge generation + integrationsManager = new IntegrationsManager({ + setButtonLoading + }); + + try { + await integrationsManager.loadIntegrations(); + + // Initialize preview renderer with integrations manager + initPreviewRenderer({ + integrationsManager + }); + } catch (error) { + console.warn('Failed to load integrations for badge generation:', error); + } + addWindowEventListenersAndProperties(); // initialize page title right away setupPageTitle(); @@ -207,7 +229,10 @@ document.addEventListener('DOMContentLoaded', () => { // DOM elements assetList, assetDetails, - subAssetContainer + subAssetContainer, + + // Managers + integrationsManager }); // Initialize the list renderer module @@ -293,7 +318,17 @@ document.addEventListener('DOMContentLoaded', () => { // Global state getAssets: () => assets, - getSubAssets: () => subAssets + getSubAssets: () => subAssets, + + // Integrations + integrationsManager + }); + + // Initialize ExternalDocManager + externalDocManager = new ExternalDocManager({ + modalManager, + setButtonLoading, + integrationsManager }); // Initialize SettingsManager after DashboardManager is ready @@ -308,6 +343,8 @@ document.addEventListener('DOMContentLoaded', () => { testNotificationSettings, setButtonLoading, renderDashboard: (animate = true) => dashboardManager.renderDashboard(animate), + loadActiveIntegrations: () => externalDocManager.loadActiveIntegrations(), + integrationsManager }); } diff --git a/server.js b/server.js index 437117c..798c919 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,8 @@ const { originValidationMiddleware, getCorsOptions } = require('./middleware/cor 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('./integrations/integrationManager'); const app = express(); const PORT = process.env.PORT || 3000; @@ -61,6 +63,13 @@ const DEFAULT_SETTINGS = { active: true } }, + integrationSettings: { + paperless: { + enabled: false, + hostUrl: '', + apiToken: '' + } + } }; // Currency configuration from environment variables @@ -192,7 +201,7 @@ app.use(BASE_PATH, (req, res, next) => { '/verify-pin', '/config.js', '/assets/', - '/styles.css', + '/assets/css/', '/manifest.json', '/asset-manifest.json', ]; @@ -435,8 +444,10 @@ 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', express.static('src')); +// 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')); @@ -446,6 +457,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}`); @@ -920,55 +934,112 @@ async function handleFileDuplication(source, duplicate, selectedProperties) { duplicate.photoPath = null; duplicate.receiptPath = null; duplicate.manualPath = null; - - // Handle photos + + // Helper to check if a file is external (integrationId or /external/ path) + function isExternalFile(filePath, fileInfo) { + if (!filePath && !fileInfo) return false; + if (fileInfo && fileInfo.integrationId) return true; + if (filePath && typeof filePath === 'string' && filePath.includes('/external/')) return true; + return false; + } + + // --- PHOTOS --- if (selectedProperties.photoPath) { + // Array version if (source.photoPaths && Array.isArray(source.photoPaths)) { - for (const photoPath of source.photoPaths) { - const newPhotoPath = await copyFile(photoPath, 'Images'); - if (newPhotoPath) duplicate.photoPaths.push(newPhotoPath); + for (let i = 0; i < source.photoPaths.length; i++) { + const pathVal = source.photoPaths[i]; + const infoVal = (source.photoInfo && source.photoInfo[i]) ? source.photoInfo[i] : null; + if (isExternalFile(pathVal, infoVal)) { + // External: copy as-is + duplicate.photoPaths.push(pathVal); + duplicate.photoInfo.push(infoVal); + } else { + // Local: duplicate file + const newPath = await copyFile(pathVal, 'Images'); + if (newPath) { + duplicate.photoPaths.push('/' + newPath.replace(/^[\/]+/, '')); + duplicate.photoInfo.push({ ...infoVal, fileName: newPath.split('/').pop() }); + } + } } } - if (source.photoInfo && Array.isArray(source.photoInfo)) { - duplicate.photoInfo = [...source.photoInfo]; - } + // Single path version (legacy) if (source.photoPath && !source.photoPaths) { - const newPhotoPath = await copyFile(source.photoPath, 'Images'); - if (newPhotoPath) duplicate.photoPath = newPhotoPath; + if (isExternalFile(source.photoPath, source.photoInfo && source.photoInfo[0])) { + duplicate.photoPath = source.photoPath; + duplicate.photoInfo = source.photoInfo ? [source.photoInfo[0]] : []; + } else { + const newPath = await copyFile(source.photoPath, 'Images'); + if (newPath) { + duplicate.photoPath = '/' + newPath.replace(/^[\/]+/, ''); + duplicate.photoInfo = [{ ...(source.photoInfo && source.photoInfo[0]), fileName: newPath.split('/').pop() }]; + } + } } } - - // Handle receipts + + // --- RECEIPTS --- if (selectedProperties.receiptPath) { if (source.receiptPaths && Array.isArray(source.receiptPaths)) { - for (const receiptPath of source.receiptPaths) { - const newReceiptPath = await copyFile(receiptPath, 'Receipts'); - if (newReceiptPath) duplicate.receiptPaths.push(newReceiptPath); + for (let i = 0; i < source.receiptPaths.length; i++) { + const pathVal = source.receiptPaths[i]; + const infoVal = (source.receiptInfo && source.receiptInfo[i]) ? source.receiptInfo[i] : null; + if (isExternalFile(pathVal, infoVal)) { + duplicate.receiptPaths.push(pathVal); + duplicate.receiptInfo.push(infoVal); + } else { + const newPath = await copyFile(pathVal, 'Receipts'); + if (newPath) { + duplicate.receiptPaths.push('/' + newPath.replace(/^[\/]+/, '')); + duplicate.receiptInfo.push({ ...infoVal, fileName: newPath.split('/').pop() }); + } + } } } - if (source.receiptInfo && Array.isArray(source.receiptInfo)) { - duplicate.receiptInfo = [...source.receiptInfo]; - } if (source.receiptPath && !source.receiptPaths) { - const newReceiptPath = await copyFile(source.receiptPath, 'Receipts'); - if (newReceiptPath) duplicate.receiptPath = newReceiptPath; + if (isExternalFile(source.receiptPath, source.receiptInfo && source.receiptInfo[0])) { + duplicate.receiptPath = source.receiptPath; + duplicate.receiptInfo = source.receiptInfo ? [source.receiptInfo[0]] : []; + } else { + const newPath = await copyFile(source.receiptPath, 'Receipts'); + if (newPath) { + duplicate.receiptPath = '/' + newPath.replace(/^[\/]+/, ''); + duplicate.receiptInfo = [{ ...(source.receiptInfo && source.receiptInfo[0]), fileName: newPath.split('/').pop() }]; + } + } } } - - // Handle manuals + + // --- MANUALS --- if (selectedProperties.manualPath) { if (source.manualPaths && Array.isArray(source.manualPaths)) { - for (const manualPath of source.manualPaths) { - const newManualPath = await copyFile(manualPath, 'Manuals'); - if (newManualPath) duplicate.manualPaths.push(newManualPath); + for (let i = 0; i < source.manualPaths.length; i++) { + const pathVal = source.manualPaths[i]; + const infoVal = (source.manualInfo && source.manualInfo[i]) ? source.manualInfo[i] : null; + if (isExternalFile(pathVal, infoVal)) { + duplicate.manualPaths.push(pathVal); + duplicate.manualInfo.push(infoVal); + } else { + const newPath = await copyFile(pathVal, 'Manuals'); + if (newPath) { + duplicate.manualPaths.push('/' + newPath.replace(/^[\/]+/, '')); + duplicate.manualInfo.push({ ...infoVal, fileName: newPath.split('/').pop() }); + } + } } } - if (source.manualInfo && Array.isArray(source.manualInfo)) { - duplicate.manualInfo = [...source.manualInfo]; - } if (source.manualPath && !source.manualPaths) { - const newManualPath = await copyFile(source.manualPath, 'Manuals'); - if (newManualPath) duplicate.manualPath = newManualPath; + if (isExternalFile(source.manualPath, source.manualInfo && source.manualInfo[0])) { + duplicate.manualPath = source.manualPath; + duplicate.manualInfo = source.manualInfo ? [source.manualInfo[0]] : []; + } else { + const newPath = await copyFile(source.manualPath, 'Manuals'); + if (newPath) { + duplicate.manualPath = '/' + newPath.replace(/^[\/]+/, ''); + duplicate.manualInfo = [{ ...(source.manualInfo && source.manualInfo[0]), fileName: newPath.split('/').pop() }]; + } + } } } } @@ -1997,6 +2068,27 @@ function getAppSettings() { return config; } +// Use before sending settings to frontend +function stripIntegrationTokens(appSettings) { + const sanitizedSettings = { ...appSettings }; + + // 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); + }); + } + + return sanitizedSettings; +} + +// Use integration manager for validation and sensitive data handling +function applyIntegrationSettings(serverConfig, updatedConfig) { + return integrationManager.applyIntegrationSettings(serverConfig, updatedConfig); +} + // Import assets route app.post('/api/import-assets', upload.single('file'), (req, res) => { try { @@ -2077,22 +2169,102 @@ 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(); + + // 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(appSettings); } catch (err) { res.status(500).json({ error: 'Failed to load settings' }); } }); +// Get available integrations for settings UI +app.get('/api/integrations', (req, res) => { + 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 +app.get('/api/integrations/enabled', (req, res) => { + try { + const settings = getAppSettings(); + const enabledIntegrations = integrationManager.getAllIntegrations() + .filter(integration => { + const integrationSettings = settings.integrationSettings?.[integration.id]; + return integrationSettings?.enabled === true; + }) + .map(integration => ({ + id: integration.id, + name: integration.name, + description: integration.description, + icon: integration.icon, + logoHref: integration.logoHref, + colorScheme: integration.colorScheme, + category: integration.category + })); + + res.json(enabledIntegrations); + } catch (error) { + console.error('Failed to get enabled integrations:', error); + res.status(500).json({ error: 'Failed to get enabled 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.status(200).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 { const config = getAppSettings(); // Update settings with the new values - const updatedConfig = { ...config, ...req.body }; + let updatedConfig = { ...config, ...req.body }; + + try { + updatedConfig = applyIntegrationSettings(config, updatedConfig); + } + catch (error) { + return res.status(400).json({ error: error.message }); + } const configPath = path.join(DATA_DIR, 'config.json'); fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); @@ -2252,6 +2424,10 @@ setInterval(() => { // Warranty expiration notification cron startWarrantyCron(); +// --- INTEGRATION SYSTEM --- +// Initialize and register integration routes +integrationManager.registerRoutes(app, getAppSettings); + // --- START SERVER --- app.listen(PORT, () => { debugLog('Server Configuration:', { diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..fc9f94c --- /dev/null +++ b/src/constants.js @@ -0,0 +1,38 @@ +// 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' +}; + +export const API_TEST_SUCCESS = 'connected'; +// API ENDPOINTS +export const API_INTEGRATIONS_ENPOINT = 'api/integrations'; +export const API_PAPERLESS_ENDPOINT = API_INTEGRATIONS_ENPOINT + '/paperless'; +export const API_PAPRA_ENDPOINT = API_INTEGRATIONS_ENPOINT + '/papra'; +export const API_HOMEASSISTANT_ENDPOINT = API_INTEGRATIONS_ENPOINT + '/homeassistant'; \ No newline at end of file diff --git a/src/services/render/assetRenderer.js b/src/services/render/assetRenderer.js index 8434a63..4da858e 100644 --- a/src/services/render/assetRenderer.js +++ b/src/services/render/assetRenderer.js @@ -33,6 +33,18 @@ let assetList; let assetDetails; let subAssetContainer; +// IntegrationsManager reference - will be injected +let integrationsManager; + +/** + * Get the appropriate integration badge HTML based on integration ID + * @param {string} integrationId - The integration identifier + * @returns {string} - The badge HTML + */ +function getIntegrationBadge(integrationId) { + return integrationsManager?.getIntegrationBadge(integrationId) || `
${integrationId}
`; +} + /** * Initialize the renderer with required dependencies * @@ -65,6 +77,9 @@ function initRenderer(config) { assetList = config.assetList; assetDetails = config.assetDetails; subAssetContainer = config.subAssetContainer; + + // Store reference to integrations manager + integrationsManager = config.integrationsManager; } /** @@ -227,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'; } @@ -258,76 +273,106 @@ function formatDisplayFileName(fileName, maxLength = 15) { function generateFileGridHTML(asset) { let html = ''; - + // 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(); + const integrationClass = photoInfo.integrationId ? ` ${photoInfo.integrationId}-document` : ''; + const integrationBadge = photoInfo.integrationId ? getIntegrationBadge(photoInfo.integrationId) : ''; + + // Use preview URL for external images if available + let displayPath = formatFilePath(photoPath); + if (photoInfo.integrationId && photoInfo.previewUrl) { + displayPath = photoInfo.previewUrl; + } + html += ` - `; } else if (asset.photoPath) { + html += `
`; // Backward compatibility for single photo const photoInfo = asset.photoInfo?.[0] || {}; const fileName = photoInfo.originalName || asset.photoPath.split('/').pop(); + const integrationClass = photoInfo.integrationId ? ` ${photoInfo.integrationId}-document` : ''; + const integrationBadge = photoInfo.integrationId ? getIntegrationBadge(photoInfo.integrationId) : ''; 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(); + const integrationClass = receiptInfo.integrationId ? ` ${receiptInfo.integrationId}-document` : ''; + const integrationBadge = receiptInfo.integrationId ? getIntegrationBadge(receiptInfo.integrationId) : ''; html += ` - `; } else if (asset.receiptPath) { + html += `
`; // Backward compatibility for single receipt const receiptInfo = asset.receiptInfo?.[0] || {}; const fileName = receiptInfo.originalName || asset.receiptPath.split('/').pop(); + const integrationClass = receiptInfo.integrationId ? ` ${receiptInfo.integrationId}-document` : ''; + const integrationBadge = receiptInfo.integrationId ? getIntegrationBadge(receiptInfo.integrationId) : ''; 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(); + const integrationClass = manualInfo.integrationId ? ` ${manualInfo.integrationId}-document` : ''; + const integrationBadge = manualInfo.integrationId ? getIntegrationBadge(manualInfo.integrationId) : ''; 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 += `
`; } - + return html || ''; } @@ -465,211 +517,82 @@ function renderAssetDetails(assetId, isSubAsset = false) {
${isSub ? `` : ''} - - - - + + + +
${generateAssetInfoHTML(asset)} ${maintenanceScheduleHtml} - ${generateMaintenanceEventsHTML(asset.maintenanceEvents)} -
- ${(asset.description || asset.notes) ? ` -
- Description: -

${asset.description || asset.notes}

-
- ` : ''} - ${asset.tags && asset.tags.length > 0 ? ` -
-
Tags
-
- ${asset.tags.map(tag => `${tag}`).join('')} -
-
` : ''} -
-
- ${generateFileGridHTML(asset)} -
+ ${generateFileGridHTML(asset)} + ${asset.maintenanceEvents && asset.maintenanceEvents.length > 0 ? generateMaintenanceEventsHTML(asset.maintenanceEvents) : ''} `; - // Add event listeners - if (isSub) { - const backBtn = assetDetails.querySelector('.back-to-parent-btn'); - if (backBtn) { - backBtn.addEventListener('click', () => { - // If sub-sub-asset, go to parent sub-asset; else go to main asset - if (asset.parentSubId) { - renderAssetDetails(asset.parentSubId, true); - } else { - renderAssetDetails(asset.parentId); - } - }); - } - } - + + // 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', () => { - // Generate the same URL format used in notifications - const baseUrl = window.location.origin + window.location.pathname; - let assetUrl; - - if (isSub) { - // For sub-assets: baseUrl?ass=parentId&sub=subAssetId - const parentId = asset.parentId || copyLinkBtn.dataset.parentId; - assetUrl = `${baseUrl}?ass=${parentId}&sub=${asset.id}`; - } else { - // For main assets: baseUrl?ass=assetId - assetUrl = `${baseUrl}?ass=${asset.id}`; - } - - // Copy to clipboard - navigator.clipboard.writeText(assetUrl).then(() => { - // Show success toast using global toaster - if (globalThis.toaster) { - globalThis.toaster.show('Asset link copied to clipboard!', 'success', false, 2000); - } else { - // Fallback alert if toaster is not available - alert('Asset link copied to clipboard!'); - } - }).catch(err => { - console.error('Failed to copy link to clipboard:', err); - // Show error toast using global error handler - if (globalThis.logError) { - globalThis.logError('Failed to copy link to clipboard', err, false, 3000); - } else { - // Fallback alert if error handler is not available - alert('Failed to copy link to clipboard. Please try again.'); - } + 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'); }); }); } - - const editBtn = assetDetails.querySelector('.edit-asset-btn'); + if (editBtn) { editBtn.addEventListener('click', () => { - if (isSub) openSubAssetModal(asset); - else openAssetModal(asset); + if (isSub) { + openSubAssetModal(asset); + } else { + openAssetModal(asset); + } }); } - - const duplicateBtn = assetDetails.querySelector('.duplicate-asset-btn'); - if (duplicateBtn && openDuplicateModal) { + + if (duplicateBtn) { duplicateBtn.addEventListener('click', () => { - const type = duplicateBtn.dataset.type; - const assetId = duplicateBtn.dataset.id; - openDuplicateModal(type, assetId); + const type = isSub ? 'subAsset' : 'asset'; + openDuplicateModal(type, asset.id); }); } - - const deleteBtn = assetDetails.querySelector('.delete-asset-btn'); + if (deleteBtn) { - deleteBtn.addEventListener('click', async () => { - if (isSub) await deleteSubAsset(asset.id); - else await deleteAsset(asset.id); + deleteBtn.addEventListener('click', () => { + if (isSub) { + deleteSubAsset(asset.id); + } else { + deleteAsset(asset.id); + } }); } - - // Add click event listeners to tags in the details view - const tagElements = assetDetails.querySelectorAll('.tag[data-tag]'); - tagElements.forEach(tagElement => { - tagElement.addEventListener('click', (e) => { - e.stopPropagation(); - const tagName = tagElement.dataset.tag; - - // Set the search input value to the tag name - if (searchInput) { - searchInput.value = tagName; - - // Show the clear search button - const clearSearchBtn = document.getElementById('clearSearchBtn'); - if (clearSearchBtn) { - clearSearchBtn.style.display = 'flex'; - } - - // Trigger the search by calling renderAssetList with the tag - if (renderAssetList) { - renderAssetList(tagName); - } - - // Focus the search input - searchInput.focus(); - } + + if (backBtn && isSub) { + backBtn.addEventListener('click', () => { + // Navigate back to parent asset + renderAssetDetails(asset.parentId, false); }); - }); - - // Only render sub-assets if viewing a main asset + } + + // Show sub-assets container if this is a main asset if (!isSub) { renderSubAssets(assetId); - } else { - subAssetContainer.classList.add('hidden'); - // If this is a first-level sub-asset (not a sub-sub-asset), show sub-sub-assets - if (!asset.parentSubId) { - // Get fresh list of sub-sub-assets after potential changes - const subSubAssets = subAssets.filter(sa => sa.parentSubId === asset.id); - // --- Modern legend/fieldset for sub-sub-assets --- - const fieldset = document.createElement('fieldset'); - fieldset.className = 'dashboard-legend'; - - const legend = document.createElement('legend'); - legend.className = 'dashboard-legend-title'; - legend.textContent = 'Components'; - fieldset.appendChild(legend); - - const subAssetHeader = document.createElement('div'); - subAssetHeader.className = 'sub-asset-header'; - subAssetHeader.style.marginTop = '0.5rem'; - - const addSubAssetBtn = document.createElement('button'); - addSubAssetBtn.className = 'add-sub-asset-btn'; - addSubAssetBtn.textContent = '+ Add Sub-Component'; - addSubAssetBtn.onclick = () => openSubAssetModal(null, asset.parentId, asset.id); - subAssetHeader.appendChild(addSubAssetBtn); - fieldset.appendChild(subAssetHeader); - - const subAssetList = document.createElement('div'); - subAssetList.className = 'sub-asset-list'; - - if (subSubAssets.length === 0) { - const emptyState = document.createElement('div'); - emptyState.className = 'empty-state'; - emptyState.innerHTML = '

No components found. Add your first component.

'; - subAssetList.appendChild(emptyState); - } else { - // Create DOM elements properly to maintain event listeners - subSubAssets.forEach(child => { - const childElement = createSubAssetElement(child); - subAssetList.appendChild(childElement); - }); - } - - fieldset.appendChild(subAssetList); - assetDetails.appendChild(fieldset); - } } - handleSidebarNav(); } -// Export the module functions +// Export functions for use by other modules export { initRenderer, updateState, diff --git a/src/services/render/index.js b/src/services/render/index.js index fec9bf7..b22c411 100644 --- a/src/services/render/index.js +++ b/src/services/render/index.js @@ -45,7 +45,8 @@ import { createPhotoPreview, createDocumentPreview, setupFilePreview, - setupExistingFilePreview + setupExistingFilePreview, + initPreviewRenderer } from './previewRenderer.js'; // Export all render service functions @@ -70,6 +71,7 @@ export { createDocumentPreview, setupFilePreview, setupExistingFilePreview, + initPreviewRenderer, // Sync helper function syncState diff --git a/src/services/render/previewRenderer.js b/src/services/render/previewRenderer.js index 1b037df..0c0f445 100644 --- a/src/services/render/previewRenderer.js +++ b/src/services/render/previewRenderer.js @@ -3,6 +3,9 @@ * Provides centralized functions for rendering file previews consistently across the application */ +// IntegrationsManager reference - will be injected +let integrationsManager; + /** * Create a photo preview element * @@ -10,7 +13,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, integrationId = null) { const previewItem = document.createElement('div'); previewItem.className = 'file-preview-item'; @@ -19,12 +22,16 @@ export function createPhotoPreview(filePath, onDeleteCallback, fileName = null, fileName = filePath.split('/').pop(); } + const integrationBadge = integrationId ? + getIntegrationBadge(integrationId) : ''; + previewItem.innerHTML = `
Photo Preview
+ ${integrationBadge}