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 @@
No document management integrations are currently enabled.
+Please configure at least one document integration (like Paperless NGX or Papra) to search external documents.
+Search failed: ${error.message}
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.
+Failed to load documents: ${error.message}
${message}
${asset.description || asset.notes}
-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 = `