Integrate Paperless-ngx for advanced document management and hybrid storage

Introduced full integration with Paperless-ngx to enable intelligent document management and flexible storage options.

Key changes:
- Added admin settings section for configuring Paperless-ngx (server URL, API token, connection testing, toggle).
- Implemented hybrid storage logic in `backend/app.py` allowing per-document selection between local and Paperless-ngx.
- Enhanced warranty card UI with visual indicators for storage location (cloud vs. local icons).
- Integrated storage selection and upload process into both Add and Edit Warranty workflows with parity.
- Enabled direct access to Paperless-ngx documents via the warranty interface.
- Ensured automatic cleanup of old documents when storage preference is switched.

Affected files:
- `backend/app.py`
- `frontend/script.js`
- `frontend/settings-new.html`
- `frontend/settings-new.js`
This commit is contained in:
sassanix
2025-06-17 22:05:29 -03:00
parent 619cea0603
commit b81d98a834
18 changed files with 2832 additions and 896 deletions

View File

@@ -1,4 +1,28 @@
# Changelog
## 0.10.1.3 - 2025-06-17
### Added
- **Paperless-ngx Document Management Integration:** Complete integration with Paperless-ngx for advanced document management and storage capabilities.
- **Settings Page Configuration:** Added Paperless-ngx configuration section in admin settings allowing administrators to:
- **Connection Setup:** Configure Paperless-ngx server URL and API token for secure integration
- **Test Connection:** Built-in connection testing to verify Paperless-ngx server accessibility and authentication
- **Enable/Disable Toggle:** Master switch to activate or deactivate Paperless-ngx integration across the application
- **Hybrid Storage System (`backend/app.py`):** Implemented intelligent document storage with per-document storage choice:
- **Storage Selection:** Users can choose between local storage and Paperless-ngx for each document type (invoice, manual, photos, other documents)
- **Smart File Handling:** Documents are stored exclusively in chosen location (prevents dual storage)
- **Database Integration:** Paperless document IDs properly tracked in database fields (`paperless_invoice_id`, `paperless_manual_id`, etc.)
- **Automatic Cleanup:** When switching storage methods, old files are automatically removed from previous location
- **Visual Document Identification:** Enhanced warranty cards with clear visual indicators for document storage location:
- **Cloud Icons:** Blue cloud icons (🌤️) appear next to documents stored in Paperless-ngx for instant recognition
- **Local Document Icons:** Standard document icons for locally stored files
- **Mixed Storage Support:** Warranties can have documents stored in both locations with appropriate visual indicators
- **Add/Edit Warranty Integration:** Full Paperless-ngx support in both add and edit warranty workflows:
- **Storage Option Selection:** Radio buttons for each document type allowing users to choose storage location
- **Seamless Upload Process:** Files uploaded to Paperless-ngx are automatically tagged and organized
- **Edit Modal Parity:** Edit warranty modal has identical Paperless-ngx functionality to add warranty modal
- **Document Access Integration:** Direct access to Paperless-ngx documents through warranty interface with secure authentication
- _Files: `backend/app.py`, `frontend/script.js`, `frontend/settings-new.html`, `frontend/settings-new.js`_
## 0.10.1.2 - 2025-06-16

View File

@@ -45,6 +45,7 @@ COPY backend/extensions.py /app/backend/
COPY backend/oidc_handler.py /app/backend/
COPY backend/apprise_handler.py /app/backend/
COPY backend/notifications.py /app/backend/
COPY backend/paperless_handler.py /app/backend/
# Copy other utility scripts and migrations
COPY backend/fix_permissions.py .

File diff suppressed because it is too large Load Diff

View File

@@ -1,134 +0,0 @@
#!/usr/bin/env python3
"""
Diagnostic script to check Apprise installation and functionality
"""
import sys
import os
def check_python_environment():
"""Check Python environment details"""
print("=== Python Environment ===")
print(f"Python version: {sys.version}")
print(f"Python executable: {sys.executable}")
print(f"Python path: {sys.path}")
print()
def check_apprise_import():
"""Test Apprise import"""
print("=== Apprise Import Test ===")
try:
import apprise
print("✅ Apprise imported successfully")
print(f" Apprise version: {getattr(apprise, '__version__', 'Unknown')}")
print(f" Apprise location: {apprise.__file__}")
return True
except ImportError as e:
print(f"❌ Failed to import Apprise: {e}")
return False
except Exception as e:
print(f"❌ Unexpected error importing Apprise: {e}")
return False
def check_apprise_functionality():
"""Test basic Apprise functionality"""
print("=== Apprise Functionality Test ===")
try:
import apprise
# Test creating an Apprise object
apobj = apprise.Apprise()
print("✅ Apprise object created successfully")
# Test adding a fake URL (won't send anything)
fake_url = "json://httpbin.org/post"
result = apobj.add(fake_url)
if result:
print("✅ Apprise URL addition test passed")
else:
print("⚠️ Apprise URL addition test failed (this may be normal)")
# Test notification (won't actually send)
print("✅ Apprise basic functionality test completed")
return True
except Exception as e:
print(f"❌ Apprise functionality test failed: {e}")
return False
def check_requirements_file():
"""Check if apprise is in requirements.txt"""
print("=== Requirements File Check ===")
req_paths = [
"/app/requirements.txt",
"requirements.txt",
"backend/requirements.txt"
]
for req_path in req_paths:
if os.path.exists(req_path):
print(f"Found requirements file: {req_path}")
try:
with open(req_path, 'r') as f:
content = f.read()
if 'apprise' in content.lower():
print("✅ Apprise found in requirements.txt")
# Show the specific line
for line in content.split('\n'):
if 'apprise' in line.lower():
print(f" {line.strip()}")
else:
print("❌ Apprise NOT found in requirements.txt")
break
except Exception as e:
print(f"Error reading {req_path}: {e}")
else:
print("❌ No requirements.txt file found")
print()
def check_installed_packages():
"""Check what packages are installed"""
print("=== Installed Packages Check ===")
try:
import subprocess
result = subprocess.run([sys.executable, "-m", "pip", "list"],
capture_output=True, text=True)
if result.returncode == 0:
packages = result.stdout
if 'apprise' in packages.lower():
print("✅ Apprise found in installed packages")
for line in packages.split('\n'):
if 'apprise' in line.lower():
print(f" {line.strip()}")
else:
print("❌ Apprise NOT found in installed packages")
print("Available packages:")
print(packages[:500] + "..." if len(packages) > 500 else packages)
else:
print(f"❌ Failed to list packages: {result.stderr}")
except Exception as e:
print(f"❌ Error checking installed packages: {e}")
print()
def main():
"""Run all diagnostic checks"""
print("Apprise Diagnostic Script")
print("=" * 50)
check_python_environment()
check_requirements_file()
check_installed_packages()
apprise_imported = check_apprise_import()
if apprise_imported:
check_apprise_functionality()
print("\n=== Summary ===")
print("✅ Apprise is working correctly!")
else:
print("\n=== Summary ===")
print("❌ Apprise is not available or not working")
print(" Try installing it with: pip install apprise==1.9.3")
if __name__ == "__main__":
main()

View File

@@ -1,129 +0,0 @@
#!/usr/bin/env python3
"""
Check warranties and their expiration dates
"""
import sys
import os
from datetime import datetime, timedelta, date
# Add the backend directory to the path
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/backend')
print("🔍 Checking Warranties and Expiration Dates")
print("=" * 60)
# Test imports
try:
from backend.db_handler import get_db_connection, release_db_connection, init_db_pool, get_expiring_warranties
print("✅ Database functions imported successfully")
except Exception as e:
print(f"❌ Failed to import database functions: {e}")
sys.exit(1)
# Test database connection
try:
init_db_pool()
print("✅ Database pool initialized")
except Exception as e:
print(f"❌ Database connection failed: {e}")
sys.exit(1)
# Check all warranties
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor()
print("\n1. Checking all warranties...")
cursor.execute("""
SELECT
id, product_name, expiration_date, is_lifetime,
purchase_date, user_id
FROM warranties
ORDER BY expiration_date ASC
""")
warranties = cursor.fetchall()
print(f" Total warranties in database: {len(warranties)}")
if warranties:
print("\n Sample warranties:")
today = date.today()
for i, warranty in enumerate(warranties[:10]): # Show first 10
warranty_id, product_name, exp_date, is_lifetime, purchase_date, user_id = warranty
if is_lifetime:
days_until_exp = "∞ (Lifetime)"
elif exp_date:
days_until_exp = (exp_date - today).days
if days_until_exp < 0:
days_until_exp = f"{abs(days_until_exp)} days ago (EXPIRED)"
else:
days_until_exp = f"{days_until_exp} days"
else:
days_until_exp = "No expiration date"
print(f" {i+1}. {product_name[:30]:<30} | Expires: {exp_date} | {days_until_exp}")
# Check specifically for warranties expiring soon
print("\n2. Checking warranties expiring in next 30 days...")
today = date.today()
next_30_days = today + timedelta(days=30)
cursor.execute("""
SELECT
id, product_name, expiration_date, user_id,
(expiration_date - %s) as days_until_expiry
FROM warranties
WHERE is_lifetime = false
AND expiration_date BETWEEN %s AND %s
ORDER BY expiration_date ASC
""", (today, today, next_30_days))
expiring_soon = cursor.fetchall()
print(f" Warranties expiring in next 30 days: {len(expiring_soon)}")
if expiring_soon:
print("\n Expiring warranties:")
for warranty in expiring_soon:
warranty_id, product_name, exp_date, user_id, days_until = warranty
print(f"{product_name} (ID: {warranty_id}, User: {user_id}) - Expires: {exp_date} ({days_until.days} days)")
# Test the get_expiring_warranties function specifically
print("\n3. Testing get_expiring_warranties function...")
for days in [1, 7, 14, 30, 60, 90]:
try:
expiring = get_expiring_warranties(days)
print(f" Expiring in {days} days: {len(expiring)} warranties")
if expiring and days <= 30: # Show details for shorter timeframes
for w in expiring[:3]: # Show first 3
print(f" - {w.get('product_name', 'Unknown')} (expires: {w.get('expiration_date', 'Unknown')})")
except Exception as e:
print(f" ❌ Error checking {days} days: {e}")
cursor.close()
except Exception as e:
print(f"❌ Error checking warranties: {e}")
import traceback
traceback.print_exc()
finally:
if conn:
release_db_connection(conn)
print("\n" + "=" * 60)
print("🎯 Summary:")
print("\nIf you see 0 warranties expiring soon, you have a few options:")
print("1. Add a test warranty with an upcoming expiration date")
print("2. Modify an existing warranty to expire soon")
print("3. Test with longer notification periods (like 60, 90 days)")
print("\nTo add a test warranty:")
print("- Go to your Warracker dashboard")
print("- Add a new warranty with expiration date in the next few days")
print("- Then test the Apprise notifications again")

View File

@@ -1,156 +0,0 @@
#!/usr/bin/env python3
"""
Create a test warranty for testing Apprise notifications
"""
import sys
import os
from datetime import datetime, timedelta, date
# Add the backend directory to the path
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/backend')
print("🧪 Creating Test Warranty for Apprise Notifications")
print("=" * 60)
# Test imports
try:
from backend.db_handler import get_db_connection, release_db_connection, init_db_pool
print("✅ Database functions imported successfully")
except Exception as e:
print(f"❌ Failed to import database functions: {e}")
sys.exit(1)
# Test database connection
try:
init_db_pool()
print("✅ Database pool initialized")
except Exception as e:
print(f"❌ Database connection failed: {e}")
sys.exit(1)
# Get the first user ID to assign the test warranty to
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor()
# Get a user ID (preferably admin)
cursor.execute("SELECT id FROM users WHERE is_admin = true LIMIT 1")
admin_user = cursor.fetchone()
if not admin_user:
cursor.execute("SELECT id FROM users LIMIT 1")
any_user = cursor.fetchone()
if any_user:
user_id = any_user[0]
print(f"📋 Using first available user ID: {user_id}")
else:
print("❌ No users found in database")
sys.exit(1)
else:
user_id = admin_user[0]
print(f"📋 Using admin user ID: {user_id}")
# Create test warranties with different expiration dates
today = date.today()
test_warranties = [
{
'product_name': 'Test Product - Expires in 5 days',
'expiration_date': today + timedelta(days=5),
'purchase_date': today - timedelta(days=360),
'vendor': 'Test Vendor',
'warranty_type': 'Extended',
'notes': 'Test warranty for Apprise notification testing'
},
{
'product_name': 'Test Product - Expires in 15 days',
'expiration_date': today + timedelta(days=15),
'purchase_date': today - timedelta(days=350),
'vendor': 'Test Vendor',
'warranty_type': 'Standard',
'notes': 'Test warranty for Apprise notification testing'
},
{
'product_name': 'Test Product - Expires in 25 days',
'expiration_date': today + timedelta(days=25),
'purchase_date': today - timedelta(days=340),
'vendor': 'Test Vendor',
'warranty_type': 'Manufacturer',
'notes': 'Test warranty for Apprise notification testing'
}
]
print(f"\n📝 Creating {len(test_warranties)} test warranties...")
created_warranties = []
for warranty in test_warranties:
try:
cursor.execute("""
INSERT INTO warranties (
product_name, purchase_date, expiration_date,
user_id, vendor, warranty_type, notes,
is_lifetime, warranty_duration_years, warranty_duration_months, warranty_duration_days
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
warranty['product_name'],
warranty['purchase_date'],
warranty['expiration_date'],
user_id,
warranty['vendor'],
warranty['warranty_type'],
warranty['notes'],
False, # not lifetime
1, # warranty_duration_years
0, # warranty_duration_months
0 # warranty_duration_days
))
warranty_id = cursor.fetchone()[0]
created_warranties.append({
'id': warranty_id,
'name': warranty['product_name'],
'expires': warranty['expiration_date']
})
print(f" ✅ Created: {warranty['product_name']} (ID: {warranty_id}, expires: {warranty['expiration_date']})")
except Exception as e:
print(f" ❌ Failed to create {warranty['product_name']}: {e}")
# Commit the changes
conn.commit()
cursor.close()
print(f"\n🎉 Successfully created {len(created_warranties)} test warranties!")
if created_warranties:
print("\n📅 Test warranty schedule:")
for warranty in created_warranties:
days_until = (warranty['expires'] - today).days
print(f"{warranty['name']} - {days_until} days from now ({warranty['expires']})")
print("\n🧪 To test notifications:")
print("1. Go to your Warracker admin settings")
print("2. Click 'Send Expiration Notifications' in the Apprise section")
print("3. Check your Discord/notification service for messages")
print("\n🧹 To clean up test data later:")
print("- Go to your warranty dashboard and delete the test warranties")
print("- Or run a cleanup script")
except Exception as e:
print(f"❌ Error creating test warranties: {e}")
import traceback
traceback.print_exc()
if conn:
conn.rollback()
finally:
if conn:
release_db_connection(conn)
print("\n" + "=" * 60)
print("🎯 Test warranties created! Now you can test Apprise notifications.")

View File

@@ -1,200 +0,0 @@
#!/usr/bin/env python3
"""
Debug script for Apprise settings saving and loading
"""
import sys
import os
# Add the backend directory to the path
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/backend')
print("🔍 Debugging Apprise Settings")
print("=" * 50)
# Test imports
print("\n1. Testing imports...")
try:
from backend.db_handler import get_db_connection, release_db_connection, init_db_pool, get_site_setting, update_site_setting
print("✅ Database functions imported successfully")
except Exception as e:
print(f"❌ Failed to import database functions: {e}")
sys.exit(1)
# Test database connection
print("\n2. Testing database connection...")
try:
init_db_pool()
print("✅ Database pool initialized")
except Exception as e:
print(f"❌ Database connection failed: {e}")
sys.exit(1)
# Check if site_settings table exists
print("\n3. Checking site_settings table...")
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor()
# Check if table exists
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'site_settings'
)
""")
table_exists = cursor.fetchone()[0]
print(f"✅ site_settings table exists: {table_exists}")
if table_exists:
# Check table structure
cursor.execute("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'site_settings'
ORDER BY ordinal_position
""")
columns = cursor.fetchall()
print(" Table structure:")
for column in columns:
print(f" {column[0]} ({column[1]}) {'NULL' if column[2] == 'YES' else 'NOT NULL'}")
# Check existing Apprise settings
cursor.execute("SELECT key, value FROM site_settings WHERE key LIKE 'apprise_%'")
apprise_settings = cursor.fetchall()
print(f"\n Existing Apprise settings ({len(apprise_settings)}):")
for key, value in apprise_settings:
print(f" {key} = {value}")
cursor.close()
except Exception as e:
print(f"❌ Error checking table: {e}")
import traceback
traceback.print_exc()
finally:
if conn:
release_db_connection(conn)
# Test setting and getting values
print("\n4. Testing setting and getting values...")
test_key = "apprise_test_debug"
test_value = "test_value_123"
try:
# Test setting a value
print(f" Setting {test_key} = {test_value}")
success = update_site_setting(test_key, test_value)
print(f" Update result: {success}")
if success:
# Test getting the value
retrieved_value = get_site_setting(test_key, "default")
print(f" Retrieved value: {retrieved_value}")
if retrieved_value == test_value:
print("✅ Setting and getting values works correctly")
else:
print(f"❌ Value mismatch! Expected: {test_value}, Got: {retrieved_value}")
else:
print("❌ Failed to update setting")
except Exception as e:
print(f"❌ Error testing set/get: {e}")
import traceback
traceback.print_exc()
# Test specific Apprise settings
print("\n5. Testing Apprise-specific settings...")
apprise_test_settings = {
'apprise_enabled': 'true',
'apprise_urls': 'test://url1,test://url2',
'apprise_expiration_days': '7,30',
'apprise_notification_time': '09:00',
'apprise_title_prefix': '[Test Warracker]'
}
try:
for key, value in apprise_test_settings.items():
print(f" Testing {key} = {value}")
# Save the setting
success = update_site_setting(key, value)
if not success:
print(f" ❌ Failed to save {key}")
continue
# Retrieve the setting
retrieved = get_site_setting(key, "NOT_FOUND")
if retrieved == value:
print(f"{key} saved and retrieved correctly")
else:
print(f"{key} mismatch! Expected: {value}, Got: {retrieved}")
except Exception as e:
print(f"❌ Error testing Apprise settings: {e}")
import traceback
traceback.print_exc()
# Test database query directly for troubleshooting
print("\n6. Direct database query test...")
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor()
# Insert test setting directly
test_direct_key = "apprise_direct_test"
test_direct_value = "direct_test_value"
cursor.execute("""
INSERT INTO site_settings (key, value, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
""", (test_direct_key, test_direct_value))
conn.commit()
print(f" ✅ Direct insert of {test_direct_key} successful")
# Query it back
cursor.execute("SELECT value FROM site_settings WHERE key = %s", (test_direct_key,))
result = cursor.fetchone()
if result and result[0] == test_direct_value:
print(f" ✅ Direct query retrieved correct value: {result[0]}")
else:
print(f" ❌ Direct query failed or wrong value: {result}")
cursor.close()
except Exception as e:
print(f"❌ Error with direct database test: {e}")
import traceback
traceback.print_exc()
finally:
if conn:
release_db_connection(conn)
# Check if API route works by simulating request
print("\n7. Testing API route simulation...")
try:
# Import Flask components
from backend.app import app
with app.test_client() as client:
# This won't work without proper authentication, but we can check if the route exists
print(" ✅ App imported successfully - API routes should be available")
except Exception as e:
print(f"❌ Error importing app: {e}")
print("\n" + "=" * 50)
print("🎯 Debug completed!")
print("\nIf settings are saving but not appearing in frontend:")
print("1. Check browser network tab for API errors")
print("2. Check if frontend is calling the correct API endpoints")
print("3. Verify authentication token is valid")
print("4. Check browser console for JavaScript errors")

View File

@@ -0,0 +1,14 @@
-- Migration: Add Paperless-ngx document ID columns to warranties table
-- This allows storing Paperless-ngx document IDs as an alternative to local file storage
-- Add columns for storing Paperless-ngx document IDs
ALTER TABLE warranties ADD COLUMN IF NOT EXISTS paperless_invoice_id INTEGER DEFAULT NULL;
ALTER TABLE warranties ADD COLUMN IF NOT EXISTS paperless_manual_id INTEGER DEFAULT NULL;
ALTER TABLE warranties ADD COLUMN IF NOT EXISTS paperless_photo_id INTEGER DEFAULT NULL;
ALTER TABLE warranties ADD COLUMN IF NOT EXISTS paperless_other_id INTEGER DEFAULT NULL;
-- Add indexes for better performance when filtering by Paperless-ngx document existence
CREATE INDEX IF NOT EXISTS idx_warranties_paperless_invoice ON warranties(paperless_invoice_id) WHERE paperless_invoice_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_warranties_paperless_manual ON warranties(paperless_manual_id) WHERE paperless_manual_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_warranties_paperless_photo ON warranties(paperless_photo_id) WHERE paperless_photo_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_warranties_paperless_other ON warranties(paperless_other_id) WHERE paperless_other_id IS NOT NULL;

View File

@@ -0,0 +1,468 @@
"""
Paperless-ngx API Handler for Warracker
This module provides functionality to interact with Paperless-ngx API
for uploading, retrieving, and managing documents.
"""
import requests
import logging
from typing import Optional, Dict, Any, Tuple
import os
from io import BytesIO
logger = logging.getLogger(__name__)
class PaperlessHandler:
"""Handle interactions with Paperless-ngx API"""
def __init__(self, paperless_url: str, api_token: str):
"""
Initialize Paperless handler
Args:
paperless_url: Base URL of Paperless-ngx instance (e.g., https://paperless.example.com)
api_token: API token for authentication
"""
self.paperless_url = paperless_url.rstrip('/')
self.api_token = api_token
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Token {api_token}',
'User-Agent': 'Warracker-PaperlessIntegration/1.0'
})
def test_connection(self) -> Tuple[bool, str]:
"""
Test connection to Paperless-ngx instance
Returns:
(success: bool, message: str)
"""
try:
response = self.session.get(f'{self.paperless_url}/api/documents/', params={'page_size': 1})
response.raise_for_status()
return True, "Connection successful"
except requests.exceptions.ConnectionError:
return False, "Cannot connect to Paperless-ngx instance. Check URL and network connectivity."
except requests.exceptions.Timeout:
return False, "Connection timeout. Paperless-ngx instance might be slow or unresponsive."
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
return False, "Authentication failed. Check your API token."
elif e.response.status_code == 403:
return False, "Access forbidden. Check your API token permissions."
else:
return False, f"HTTP error: {e.response.status_code} - {e.response.reason}"
except Exception as e:
return False, f"Unexpected error: {str(e)}"
def upload_document(self, file_content: bytes, filename: str, title: Optional[str] = None,
tags: Optional[list] = None, correspondent: Optional[str] = None) -> Tuple[bool, Optional[int], str]:
"""
Upload a document to Paperless-ngx
Args:
file_content: Binary content of the file
filename: Original filename
title: Optional title for the document
tags: Optional list of tag names
correspondent: Optional correspondent name
Returns:
(success: bool, document_id: Optional[int], message: str)
"""
try:
# Detect MIME type from filename
import mimetypes
mime_type, _ = mimetypes.guess_type(filename)
if not mime_type:
mime_type = 'application/octet-stream'
# Prepare files for multipart upload
# Paperless-ngx expects the file under 'document' field
files = {
'document': (filename, BytesIO(file_content), mime_type)
}
# Prepare form data - Paperless-ngx API requirements
# Note: Don't include 'document' in data, only in files
data = {}
if title:
data['title'] = title
# TODO: For future enhancement, implement proper tag/correspondent handling:
# - correspondent expects PK (ID) of existing correspondent, not string name
# - tags expects PKs (IDs) of existing tags, not string names
# For now, we'll skip these optional fields to get basic upload working
# if correspondent:
# # Would need to lookup/create correspondent ID first
# data['correspondent'] = correspondent_id
# if tags:
# # Would need to lookup/create tag IDs first
# data['tags'] = [tag_id1, tag_id2, ...]
logger.info(f"Uploading document to Paperless-ngx: {filename}")
logger.info(f"Upload data: {data}")
logger.info(f"MIME type: {mime_type}")
# Don't set Content-Type manually - let requests handle it
response = self.session.post(
f'{self.paperless_url}/api/documents/post_document/',
files=files,
headers={'Authorization': f'Token {self.api_token}'},
timeout=60 # Longer timeout for uploads
)
logger.info(f"Paperless-ngx upload response status: {response.status_code}")
logger.info(f"Paperless-ngx upload response text: {response.text[:500]}...") # First 500 chars
response.raise_for_status() # This will raise an exception for 4xx/5xx status codes
# Try to parse response as JSON first
try:
result = response.json()
logger.info(f"Paperless-ngx upload response: {result}")
except Exception as e:
logger.warning(f"Could not parse response as JSON: {e}")
# If we can't parse JSON but got 200, treat as success
return True, None, "Document uploaded successfully"
# Handle different possible response formats from Paperless-ngx
document_id = None
if isinstance(result, dict):
# JSON object response
if 'task_id' in result:
# Task-based response (asynchronous processing)
document_id = result.get('task_id')
logger.info(f"Document upload task created: {document_id}")
return True, document_id, "Document uploaded successfully (processing)"
elif 'id' in result:
# Direct document ID response (synchronous processing)
document_id = result.get('id')
logger.info(f"Document uploaded with ID: {document_id}")
return True, document_id, "Document uploaded successfully"
elif result.get('success') or response.status_code == 200:
# Generic success response
logger.info(f"Document uploaded successfully (generic success)")
return True, None, "Document uploaded successfully"
else:
logger.warning(f"Unexpected JSON response format from Paperless-ngx: {result}")
# Even if format is unexpected, if we got HTTP 200, it's likely successful
return True, None, "Document uploaded successfully (unknown JSON format)"
elif isinstance(result, str):
# String response - might contain an ID or just be a success message
logger.info(f"Document uploaded successfully (string response): {result}")
# Try to extract an ID from the string if it looks like one
import re
id_match = re.search(r'\d+', result)
if id_match:
document_id = int(id_match.group())
logger.info(f"Extracted document ID from string: {document_id}")
return True, document_id, f"Document uploaded successfully: {result}"
else:
return True, None, f"Document uploaded successfully: {result}"
else:
# Other response type
logger.warning(f"Unexpected response type from Paperless-ngx: {type(result)} - {result}")
return True, None, "Document uploaded successfully (unknown response type)"
except requests.exceptions.Timeout:
return False, None, "Upload timeout. The file might be too large or the connection is slow."
except requests.exceptions.HTTPError as e:
error_msg = f"Upload failed: HTTP {e.response.status_code}"
try:
error_detail = e.response.json()
logger.error(f"Paperless-ngx detailed error: {error_detail}")
if 'detail' in error_detail:
error_msg += f" - {error_detail['detail']}"
elif isinstance(error_detail, dict):
# Handle field-specific errors
error_parts = []
for field, errors in error_detail.items():
if isinstance(errors, list):
error_parts.append(f"{field}: {', '.join(errors)}")
else:
error_parts.append(f"{field}: {errors}")
if error_parts:
error_msg += f" - {'; '.join(error_parts)}"
else:
error_msg += f" - {error_detail}"
except Exception as parse_error:
logger.error(f"Could not parse error response: {parse_error}")
error_msg += f" - {e.response.reason}"
return False, None, error_msg
except Exception as e:
logger.error(f"Error uploading document to Paperless-ngx: {e}")
return False, None, f"Upload failed: {str(e)}"
def get_document_preview(self, document_id: int) -> Tuple[bool, Optional[bytes], str, Optional[str]]:
"""
Get document preview/content from Paperless-ngx
Args:
document_id: Paperless-ngx document ID
Returns:
(success: bool, content: Optional[bytes], message: str, content_type: Optional[str])
"""
# Try multiple endpoints in order of preference
endpoints_to_try = [
('preview', f'/api/documents/{document_id}/preview/'),
('download', f'/api/documents/{document_id}/download/'),
]
last_error = None
for endpoint_name, endpoint_path in endpoints_to_try:
try:
logger.info(f"Fetching document {endpoint_name} from Paperless-ngx: {document_id}")
response = self.session.get(
f'{self.paperless_url}{endpoint_path}',
timeout=30
)
response.raise_for_status()
content_type = response.headers.get('Content-Type', 'application/octet-stream')
logger.info(f"Successfully retrieved document {document_id} via {endpoint_name} endpoint")
return True, response.content, f"Document retrieved successfully via {endpoint_name}", content_type
except requests.exceptions.HTTPError as e:
logger.warning(f"Failed to retrieve document {document_id} via {endpoint_name}: HTTP {e.response.status_code}")
last_error = e
if e.response.status_code == 404:
continue # Try next endpoint
else:
# For non-404 errors, don't try other endpoints
return False, None, f"Failed to retrieve document: HTTP {e.response.status_code}", None
except Exception as e:
logger.warning(f"Error retrieving document {document_id} via {endpoint_name}: {e}")
last_error = e
continue # Try next endpoint
# If we get here, all endpoints failed
if last_error and isinstance(last_error, requests.exceptions.HTTPError) and last_error.response.status_code == 404:
return False, None, "Document not found in Paperless-ngx", None
else:
return False, None, f"Retrieval failed: {str(last_error) if last_error else 'All endpoints failed'}", None
def get_document_thumbnail(self, document_id: int) -> Tuple[bool, Optional[bytes], str]:
"""
Get document thumbnail from Paperless-ngx
Args:
document_id: Paperless-ngx document ID
Returns:
(success: bool, content: Optional[bytes], message: str)
"""
try:
response = self.session.get(
f'{self.paperless_url}/api/documents/{document_id}/thumb/',
timeout=15
)
response.raise_for_status()
return True, response.content, "Thumbnail retrieved successfully"
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return False, None, "Document or thumbnail not found"
else:
return False, None, f"Failed to retrieve thumbnail: HTTP {e.response.status_code}"
except Exception as e:
logger.error(f"Error retrieving thumbnail from Paperless-ngx: {e}")
return False, None, f"Thumbnail retrieval failed: {str(e)}"
def search_documents(self, query: str, limit: int = 25) -> Tuple[bool, Optional[list], str]:
"""
Search documents in Paperless-ngx
Args:
query: Search query string
limit: Maximum number of results to return
Returns:
(success: bool, documents: Optional[list], message: str)
"""
try:
params = {
'query': query,
'page_size': min(limit, 100) # Cap at 100 for performance
}
response = self.session.get(
f'{self.paperless_url}/api/documents/',
params=params,
timeout=15
)
response.raise_for_status()
result = response.json()
documents = result.get('results', [])
return True, documents, f"Found {len(documents)} documents"
except Exception as e:
logger.error(f"Error searching documents in Paperless-ngx: {e}")
return False, None, f"Search failed: {str(e)}"
def get_document_info(self, document_id: int) -> Tuple[bool, Optional[Dict[str, Any]], str]:
"""
Get document information from Paperless-ngx
Args:
document_id: Paperless-ngx document ID
Returns:
(success: bool, document_info: Optional[Dict], message: str)
"""
try:
response = self.session.get(
f'{self.paperless_url}/api/documents/{document_id}/',
timeout=15
)
response.raise_for_status()
document_info = response.json()
return True, document_info, "Document info retrieved successfully"
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return False, None, "Document not found"
else:
return False, None, f"Failed to retrieve document info: HTTP {e.response.status_code}"
except Exception as e:
logger.error(f"Error retrieving document info from Paperless-ngx: {e}")
return False, None, f"Info retrieval failed: {str(e)}"
def debug_document_status(self, document_id: int) -> Dict[str, Any]:
"""
Debug method to check document status and available endpoints
Args:
document_id: Paperless-ngx document ID
Returns:
Dictionary with debug information
"""
debug_info = {
'document_id': document_id,
'endpoints_tested': {},
'document_exists': False,
'document_info': None
}
# Test different endpoints
endpoints_to_test = [
('info', f'/api/documents/{document_id}/'),
('preview', f'/api/documents/{document_id}/preview/'),
('download', f'/api/documents/{document_id}/download/'),
('thumb', f'/api/documents/{document_id}/thumb/')
]
for endpoint_name, endpoint_path in endpoints_to_test:
try:
logger.info(f"Testing endpoint: {self.paperless_url}{endpoint_path}")
response = self.session.get(f'{self.paperless_url}{endpoint_path}', timeout=15)
debug_info['endpoints_tested'][endpoint_name] = {
'status_code': response.status_code,
'success': response.status_code < 400,
'content_type': response.headers.get('Content-Type', 'unknown'),
'content_length': len(response.content) if response.content else 0
}
if endpoint_name == 'info' and response.status_code == 200:
debug_info['document_exists'] = True
try:
debug_info['document_info'] = response.json()
except:
debug_info['document_info'] = 'Could not parse JSON'
except Exception as e:
debug_info['endpoints_tested'][endpoint_name] = {
'error': str(e),
'success': False
}
# Also try to list recent documents to see if our document is there
try:
response = self.session.get(f'{self.paperless_url}/api/documents/',
params={'ordering': '-created', 'page_size': 10},
timeout=15)
if response.status_code == 200:
recent_docs = response.json().get('results', [])
debug_info['recent_documents'] = [
{'id': doc.get('id'), 'title': doc.get('title'), 'created': doc.get('created')}
for doc in recent_docs
]
debug_info['document_in_recent'] = any(doc.get('id') == document_id for doc in recent_docs)
else:
debug_info['recent_documents'] = f'Error: {response.status_code}'
except Exception as e:
debug_info['recent_documents'] = f'Exception: {str(e)}'
return debug_info
def document_exists(self, document_id: int) -> bool:
"""
Check if a document exists in Paperless-ngx
Args:
document_id: Paperless-ngx document ID
Returns:
True if document exists, False otherwise
"""
try:
response = self.session.get(
f'{self.paperless_url}/api/documents/{document_id}/',
timeout=10
)
return response.status_code == 200
except Exception as e:
logger.warning(f"Error checking document existence {document_id}: {e}")
return False
def get_paperless_handler(conn) -> Optional[PaperlessHandler]:
"""
Get a configured Paperless handler from site settings
Args:
conn: Database connection
Returns:
PaperlessHandler instance or None if not configured/enabled
"""
try:
with conn.cursor() as cur:
cur.execute("""
SELECT key, value FROM site_settings
WHERE key IN ('paperless_enabled', 'paperless_url', 'paperless_api_token')
""")
settings = {row[0]: row[1] for row in cur.fetchall()}
# Check if Paperless-ngx is enabled
if settings.get('paperless_enabled', 'false').lower() != 'true':
return None
# Check required settings
paperless_url = settings.get('paperless_url', '').strip()
paperless_token = settings.get('paperless_api_token', '').strip()
if not paperless_url or not paperless_token:
logger.warning("Paperless-ngx is enabled but URL or API token is missing")
return None
return PaperlessHandler(paperless_url, paperless_token)
except Exception as e:
logger.error(f"Error creating Paperless handler: {e}")
return None

View File

@@ -312,7 +312,7 @@
<!-- Hero Section -->
<div class="about-hero">
<h1><i class="fas fa-shield-alt"></i> Warracker</h1>
<div class="version">Version v0.10.1.2</div>
<div class="version">Version v0.10.1.3</div>
<p class="description">
A comprehensive warranty management system designed to help you track, organize, and manage all your product warranties in one secure, user-friendly platform.
</p>

View File

@@ -442,8 +442,31 @@
</div>
<!-- Documents Tab -->
<div class="tab-content" id="documents">
<!-- Paperless-ngx Info Alert (hidden by default, shown when enabled) -->
<div id="paperlessInfoAlert" class="alert alert-info" style="display: none;">
<i class="fas fa-info-circle"></i>
<strong>Paperless-ngx Integration Enabled!</strong> You can now choose to store documents in your Paperless-ngx instance instead of locally.
</div>
<div class="form-group">
<label>Product Photo (Optional)</label>
<!-- Storage Selection -->
<div id="productPhotoStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="productPhotoStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="productPhotoStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="productPhoto" class="file-input-label">
<i class="fas fa-camera"></i> Choose Photo
@@ -458,6 +481,23 @@
<div class="form-group">
<label>Invoice/Receipt</label>
<!-- Storage Selection -->
<div id="invoiceStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="invoiceStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="invoiceStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="invoice" class="file-input-label">
<i class="fas fa-upload"></i> Choose File
@@ -469,6 +509,23 @@
<div class="form-group">
<label>Product Manual (Optional)</label>
<!-- Storage Selection -->
<div id="manualStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="manualStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="manualStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="manual" class="file-input-label">
<i class="fas fa-upload"></i> Choose File
@@ -479,6 +536,23 @@
</div>
<div class="form-group">
<label>Files (ZIP/RAR, Optional)</label>
<!-- Storage Selection -->
<div id="otherDocumentStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="otherDocumentStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="otherDocumentStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="otherDocument" class="file-input-label">
<i class="fas fa-upload"></i> Choose File
@@ -760,6 +834,23 @@
<div class="edit-tab-content" id="edit-documents">
<div class="form-group">
<label>Product Photo (Optional)</label>
<!-- Storage Selection -->
<div id="editProductPhotoStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="editProductPhotoStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="editProductPhotoStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="editProductPhoto" class="file-input-label">
<i class="fas fa-camera"></i> Choose Photo
@@ -776,6 +867,23 @@
<div class="form-group">
<label>Invoice/Receipt</label>
<!-- Storage Selection -->
<div id="editInvoiceStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="editInvoiceStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="editInvoiceStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="editInvoice" class="file-input-label">
<i class="fas fa-upload"></i> Choose File
@@ -789,6 +897,23 @@
<div class="form-group">
<label>Product Manual (Optional)</label>
<!-- Storage Selection -->
<div id="editManualStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="editManualStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="editManualStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="editManual" class="file-input-label">
<i class="fas fa-upload"></i> Choose File
@@ -801,6 +926,23 @@
</div>
<div class="form-group">
<label>Files (ZIP/RAR, Optional)</label>
<!-- Storage Selection -->
<div id="editOtherDocumentStorageSelection" class="storage-selection" style="display: none;">
<div class="storage-options">
<label class="storage-option">
<input type="radio" name="editOtherDocumentStorage" value="local" checked>
<span class="storage-label">
<i class="fas fa-hdd"></i> Store Locally
</span>
</label>
<label class="storage-option">
<input type="radio" name="editOtherDocumentStorage" value="paperless">
<span class="storage-label">
<i class="fas fa-cloud"></i> Store in Paperless-ngx
</span>
</label>
</div>
</div>
<div class="file-input-wrapper">
<label for="editOtherDocument" class="file-input-label">
<i class="fas fa-upload"></i> Choose File
@@ -900,7 +1042,7 @@
</div>
<script src="auth.js"></script>
<script src="script.js?v=20250529005"></script>
<script src="script.js?v=20250617007"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>

View File

@@ -2238,18 +2238,9 @@ async function renderWarranties(warrantiesToRender) {
<i class="fas fa-globe"></i> Product Website
</a>
` : ''}
${warranty.invoice_path && warranty.invoice_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;" class="invoice-link">
<i class="fas fa-file-invoice"></i> Invoice
</a>` : ''}
${warranty.manual_path && warranty.manual_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.manual_path}'); return false;" class="manual-link">
<i class="fas fa-book"></i> Manual
</a>` : ''}
${warranty.other_document_path && warranty.other_document_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.other_document_path}'); return false;" class="other-document-link">
<i class="fas fa-file-alt"></i> Files
</a>` : ''}
${generateDocumentLink(warranty, 'invoice')}
${generateDocumentLink(warranty, 'manual')}
${generateDocumentLink(warranty, 'other')}
${notesLinkHtml}
</div>
</div>
@@ -2305,18 +2296,9 @@ async function renderWarranties(warrantiesToRender) {
<i class="fas fa-globe"></i> Product Website
</a>
` : ''}
${warranty.invoice_path && warranty.invoice_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;" class="invoice-link">
<i class="fas fa-file-invoice"></i> Invoice
</a>` : ''}
${warranty.manual_path && warranty.manual_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.manual_path}'); return false;" class="manual-link">
<i class="fas fa-book"></i> Manual
</a>` : ''}
${warranty.other_document_path && warranty.other_document_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.other_document_path}'); return false;" class="other-document-link">
<i class="fas fa-file-alt"></i> Files
</a>` : ''}
${generateDocumentLink(warranty, 'invoice')}
${generateDocumentLink(warranty, 'manual')}
${generateDocumentLink(warranty, 'other')}
${notesLinkHtml}
</div>
</div>
@@ -2361,18 +2343,9 @@ async function renderWarranties(warrantiesToRender) {
<i class="fas fa-globe"></i> Product Website
</a>
` : ''}
${warranty.invoice_path && warranty.invoice_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;" class="invoice-link">
<i class="fas fa-file-invoice"></i> Invoice
</a>` : ''}
${warranty.manual_path && warranty.manual_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.manual_path}'); return false;" class="manual-link">
<i class="fas fa-book"></i> Manual
</a>` : ''}
${warranty.other_document_path && warranty.other_document_path !== 'null' ? `
<a href="#" onclick="openSecureFile('${warranty.other_document_path}'); return false;" class="other-document-link">
<i class="fas fa-file-alt"></i> Files
</a>` : ''}
${generateDocumentLink(warranty, 'invoice')}
${generateDocumentLink(warranty, 'manual')}
${generateDocumentLink(warranty, 'other')}
${notesLinkHtml}
</div>
</div>
@@ -2670,7 +2643,10 @@ async function openEditModal(warranty) {
const currentInvoiceElement = document.getElementById('currentInvoice');
const deleteInvoiceBtn = document.getElementById('deleteInvoiceBtn');
if (currentInvoiceElement && deleteInvoiceBtn) {
if (warranty.invoice_path && warranty.invoice_path !== 'null') {
const hasLocalInvoice = warranty.invoice_path && warranty.invoice_path !== 'null';
const hasPaperlessInvoice = warranty.paperless_invoice_id && warranty.paperless_invoice_id !== null;
if (hasLocalInvoice) {
currentInvoiceElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current invoice:
@@ -2679,6 +2655,15 @@ async function openEditModal(warranty) {
</span>
`;
deleteInvoiceBtn.style.display = '';
} else if (hasPaperlessInvoice) {
currentInvoiceElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current invoice:
<a href="#" class="view-document-link" onclick="openPaperlessDocument(${warranty.paperless_invoice_id}); return false;">View</a>
<i class="fas fa-cloud" style="color: #4dabf7; margin-left: 4px; font-size: 0.8em;" title="Stored in Paperless-ngx"></i> (Upload a new file to replace)
</span>
`;
deleteInvoiceBtn.style.display = '';
} else {
currentInvoiceElement.innerHTML = '<span>No invoice uploaded</span>';
deleteInvoiceBtn.style.display = 'none';
@@ -2695,7 +2680,10 @@ async function openEditModal(warranty) {
const currentManualElement = document.getElementById('currentManual');
const deleteManualBtn = document.getElementById('deleteManualBtn');
if (currentManualElement && deleteManualBtn) {
if (warranty.manual_path && warranty.manual_path !== 'null') {
const hasLocalManual = warranty.manual_path && warranty.manual_path !== 'null';
const hasPaperlessManual = warranty.paperless_manual_id && warranty.paperless_manual_id !== null;
if (hasLocalManual) {
currentManualElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current manual:
@@ -2704,6 +2692,15 @@ async function openEditModal(warranty) {
</span>
`;
deleteManualBtn.style.display = '';
} else if (hasPaperlessManual) {
currentManualElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current manual:
<a href="#" class="view-document-link" onclick="openPaperlessDocument(${warranty.paperless_manual_id}); return false;">View</a>
<i class="fas fa-cloud" style="color: #4dabf7; margin-left: 4px; font-size: 0.8em;" title="Stored in Paperless-ngx"></i> (Upload a new file to replace)
</span>
`;
deleteManualBtn.style.display = '';
} else {
currentManualElement.innerHTML = '<span>No manual uploaded</span>';
deleteManualBtn.style.display = 'none';
@@ -2721,7 +2718,10 @@ async function openEditModal(warranty) {
const currentProductPhotoElement = document.getElementById('currentProductPhoto');
const deleteProductPhotoBtn = document.getElementById('deleteProductPhotoBtn');
if (currentProductPhotoElement && deleteProductPhotoBtn) {
if (warranty.product_photo_path && warranty.product_photo_path !== 'null') {
const hasLocalPhoto = warranty.product_photo_path && warranty.product_photo_path !== 'null';
const hasPaperlessPhoto = warranty.paperless_photo_id && warranty.paperless_photo_id !== null;
if (hasLocalPhoto) {
currentProductPhotoElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current photo:
@@ -2732,6 +2732,16 @@ async function openEditModal(warranty) {
</span>
`;
deleteProductPhotoBtn.style.display = '';
} else if (hasPaperlessPhoto) {
currentProductPhotoElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current photo:
<a href="#" class="view-document-link" onclick="openPaperlessDocument(${warranty.paperless_photo_id}); return false;">View</a>
<i class="fas fa-cloud" style="color: #4dabf7; margin-left: 4px; font-size: 0.8em;" title="Stored in Paperless-ngx"></i>
<br><small>(Upload a new photo to replace)</small>
</span>
`;
deleteProductPhotoBtn.style.display = '';
} else {
currentProductPhotoElement.innerHTML = '<span>No photo uploaded</span>';
deleteProductPhotoBtn.style.display = 'none';
@@ -2749,7 +2759,10 @@ async function openEditModal(warranty) {
const currentOtherDocumentElement = document.getElementById('currentOtherDocument');
const deleteOtherDocumentBtn = document.getElementById('deleteOtherDocumentBtn');
if (currentOtherDocumentElement && deleteOtherDocumentBtn) {
if (warranty.other_document_path && warranty.other_document_path !== 'null') {
const hasLocalOther = warranty.other_document_path && warranty.other_document_path !== 'null';
const hasPaperlessOther = warranty.paperless_other_id && warranty.paperless_other_id !== null;
if (hasLocalOther) {
currentOtherDocumentElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current other document:
@@ -2758,6 +2771,15 @@ async function openEditModal(warranty) {
</span>
`;
deleteOtherDocumentBtn.style.display = '';
} else if (hasPaperlessOther) {
currentOtherDocumentElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current other document:
<a href="#" class="view-document-link" onclick="openPaperlessDocument(${warranty.paperless_other_id}); return false;">View</a>
<i class="fas fa-cloud" style="color: #4dabf7; margin-left: 4px; font-size: 0.8em;" title="Stored in Paperless-ngx"></i> (Upload a new file to replace)
</span>
`;
deleteOtherDocumentBtn.style.display = '';
} else {
currentOtherDocumentElement.innerHTML = '<span>No other document uploaded</span>';
deleteOtherDocumentBtn.style.display = 'none';
@@ -3192,50 +3214,59 @@ function handleFormSubmit(event) { // Renamed from submitForm
// Show loading spinner
showLoadingSpinner();
// Send the form data to the server
fetch('/api/warranties', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('auth_token')
},
body: formData
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || 'Failed to add warranty');
// Process Paperless-ngx uploads if enabled
processPaperlessNgxUploads(formData)
.then(paperlessUploads => {
// Add Paperless-ngx document IDs to form data
Object.keys(paperlessUploads).forEach(key => {
formData.append(key, paperlessUploads[key]);
});
}
return response.json();
})
.then(data => {
hideLoadingSpinner();
showToast('Warranty added successfully', 'success');
// --- Close and reset the modal on success ---
if (addWarrantyModal) {
addWarrantyModal.classList.remove('active');
}
resetAddWarrantyWizard(); // Reset the wizard form
// --- End modification ---
// Send the form data to the server
return fetch('/api/warranties', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('auth_token')
},
body: formData
});
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || 'Failed to add warranty');
});
}
return response.json();
})
.then(data => {
hideLoadingSpinner();
showToast('Warranty added successfully', 'success');
// --- Close and reset the modal on success ---
if (addWarrantyModal) {
addWarrantyModal.classList.remove('active');
}
resetAddWarrantyWizard(); // Reset the wizard form
// --- End modification ---
loadWarranties(true).then(() => {
console.log('Warranties reloaded after adding new warranty');
applyFilters();
// Load secure images for the new cards - additional call to ensure they load
setTimeout(() => {
console.log('Loading secure images for new warranty cards');
loadSecureImages();
}, 200); // Slightly longer delay to ensure everything is rendered
}).catch(error => {
console.error('Error reloading warranties after adding:', error);
}); // Reload the list and update UI
})
.catch(error => {
hideLoadingSpinner();
console.error('Error adding warranty:', error);
showToast(error.message || 'Failed to add warranty', 'error');
});
loadWarranties(true).then(() => {
console.log('Warranties reloaded after adding new warranty');
applyFilters();
// Load secure images for the new cards - additional call to ensure they load
setTimeout(() => {
console.log('Loading secure images for new warranty cards');
loadSecureImages();
}, 200); // Slightly longer delay to ensure everything is rendered
}).catch(error => {
console.error('Error reloading warranties after adding:', error);
}); // Reload the list and update UI
})
.catch(error => {
hideLoadingSpinner();
console.error('Error adding warranty:', error);
showToast(error.message || 'Failed to add warranty', 'error');
});
}
// Initialize page
@@ -3387,7 +3418,7 @@ function openSecureFile(filePath) {
showToast('Please log in to access files', 'error');
return false;
}
// Enhanced fetch with retry logic and better error handling
const fetchWithRetry = async (url, options, retries = 2) => {
for (let i = 0; i <= retries; i++) {
@@ -3406,7 +3437,7 @@ function openSecureFile(filePath) {
throw new Error(`Server error: ${response.status} ${response.statusText}`);
}
}
// Check if response has content-length header
const contentLength = response.headers.get('content-length');
console.log(`[openSecureFile] Response Content-Length: ${contentLength}`);
@@ -3499,6 +3530,65 @@ function openSecureFile(filePath) {
return false;
}
/**
* Open a Paperless-ngx document by ID
*/
/**
* Generate document link HTML for both local and Paperless-ngx documents
*/
function generateDocumentLink(warranty, docType) {
const docConfig = {
invoice: {
localPath: warranty.invoice_path,
paperlessId: warranty.paperless_invoice_id,
icon: 'fas fa-file-invoice',
label: 'Invoice',
className: 'invoice-link'
},
manual: {
localPath: warranty.manual_path,
paperlessId: warranty.paperless_manual_id,
icon: 'fas fa-book',
label: 'Manual',
className: 'manual-link'
},
other: {
localPath: warranty.other_document_path,
paperlessId: warranty.paperless_other_id,
icon: 'fas fa-file-alt',
label: 'Files',
className: 'other-document-link'
},
photo: {
localPath: warranty.product_photo_path,
paperlessId: warranty.paperless_photo_id,
icon: 'fas fa-image',
label: 'Photo',
className: 'photo-link'
}
};
const config = docConfig[docType];
if (!config) return '';
const hasLocal = config.localPath && config.localPath !== 'null';
const hasPaperless = config.paperlessId && config.paperlessId !== null;
if (hasLocal) {
return `<a href="#" onclick="openSecureFile('${config.localPath}'); return false;" class="${config.className}">
<i class="${config.icon}"></i> ${config.label}
</a>`;
} else if (hasPaperless) {
return `<a href="#" onclick="openPaperlessDocument(${config.paperlessId}); return false;" class="${config.className}">
<i class="${config.icon}"></i> ${config.label} <i class="fas fa-cloud" style="color: #4dabf7; margin-left: 4px; font-size: 0.8em;" title="Stored in Paperless-ngx"></i>
</a>`;
}
return '';
}
// Initialize the warranty form and all its components
function initWarrantyForm() {
// Initialize form tabs
@@ -4722,26 +4812,35 @@ function saveWarranty() {
showLoadingSpinner();
// Send request
fetch(`/api/warranties/${currentWarrantyId}`, {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token
},
body: formData
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || 'Failed to update warranty');
// Process Paperless-ngx uploads if enabled
processEditPaperlessNgxUploads(formData)
.then(paperlessUploads => {
// Add Paperless-ngx document IDs to form data
Object.keys(paperlessUploads).forEach(key => {
formData.append(key, paperlessUploads[key]);
});
}
return response.json();
})
.then(data => {
hideLoadingSpinner();
showToast('Warranty updated successfully', 'success');
closeModals();
// Send request
return fetch(`/api/warranties/${currentWarrantyId}`, {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token
},
body: formData
});
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || 'Failed to update warranty');
});
}
return response.json();
})
.then(data => {
hideLoadingSpinner();
showToast('Warranty updated successfully', 'success');
closeModals();
// Always reload from server to ensure we get the latest data including product photo paths
console.log('Reloading warranties after edit to ensure latest data including product photos');
@@ -6088,3 +6187,474 @@ async function loadSecureImages() {
}
}
}
// ============================================================================
// Paperless-ngx Integration Functions
// ============================================================================
// Global variable to store Paperless-ngx enabled state
let paperlessNgxEnabled = false;
/**
* Check if Paperless-ngx integration is enabled
*/
async function checkPaperlessNgxStatus() {
try {
const token = localStorage.getItem('auth_token');
if (!token) return false;
const response = await fetch('/api/admin/settings', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const settings = await response.json();
paperlessNgxEnabled = settings.paperless_enabled === 'true';
console.log('[Paperless-ngx] Integration status:', paperlessNgxEnabled);
return paperlessNgxEnabled;
}
} catch (error) {
console.error('[Paperless-ngx] Error checking status:', error);
}
return false;
}
/**
* Initialize Paperless-ngx integration UI
*/
async function initPaperlessNgxIntegration() {
// Check if Paperless-ngx is enabled
const isEnabled = await checkPaperlessNgxStatus();
if (isEnabled) {
// Show the info alert
const infoAlert = document.getElementById('paperlessInfoAlert');
if (infoAlert) {
infoAlert.style.display = 'block';
}
// Show storage selection options for add modal
const storageSelections = [
'productPhotoStorageSelection',
'invoiceStorageSelection',
'manualStorageSelection',
'otherDocumentStorageSelection'
];
storageSelections.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.style.display = 'block';
}
});
// Show storage selection options for edit modal
const editStorageSelections = [
'editProductPhotoStorageSelection',
'editInvoiceStorageSelection',
'editManualStorageSelection',
'editOtherDocumentStorageSelection'
];
editStorageSelections.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.style.display = 'block';
}
});
console.log('[Paperless-ngx] UI elements initialized and shown');
} else {
console.log('[Paperless-ngx] Integration disabled, hiding UI elements');
}
}
/**
* Get selected storage option for a document type
* @param {string} documentType - The document type (productPhoto, invoice, manual, otherDocument)
* @param {boolean} isEdit - Whether this is for the edit modal
* @returns {string} - 'local' or 'paperless'
*/
function getStorageOption(documentType, isEdit = false) {
const prefix = isEdit ? 'edit' : '';
const capitalizedType = documentType.charAt(0).toUpperCase() + documentType.slice(1);
const name = `${prefix}${capitalizedType}Storage`;
const radio = document.querySelector(`input[name="${name}"]:checked`);
return radio ? radio.value : 'local';
}
/**
* Upload file to Paperless-ngx
* @param {File} file - The file to upload
* @param {string} documentType - The type of document for tagging
* @returns {Promise<Object>} - Upload result with document ID
*/
async function uploadToPaperlessNgx(file, documentType) {
try {
const token = localStorage.getItem('auth_token');
if (!token) {
throw new Error('Authentication token not available');
}
const formData = new FormData();
formData.append('file', file);
formData.append('document_type', documentType);
formData.append('title', `Warracker ${documentType} - ${file.name}`);
// Add tags for organization
const tags = ['warracker', documentType];
formData.append('tags', tags.join(','));
console.log('[Paperless-ngx] Upload FormData contents:');
console.log(' - file:', file.name, '(' + file.size + ' bytes, ' + file.type + ')');
console.log(' - document_type:', documentType);
console.log(' - title:', `Warracker ${documentType} - ${file.name}`);
console.log(' - tags:', tags.join(','));
const response = await fetch('/api/paperless/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (!response.ok) {
let errorMessage = 'Failed to upload to Paperless-ngx';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
console.error('[Paperless-ngx] Server error details:', errorData);
} catch (parseError) {
console.error('[Paperless-ngx] Could not parse error response:', parseError);
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
const result = await response.json();
console.log('[Paperless-ngx] Upload successful:', result);
return {
success: true,
document_id: result.document_id,
message: result.message
};
} catch (error) {
console.error('[Paperless-ngx] Upload error:', error);
return {
success: false,
error: error.message
};
}
}
/**
* Handle warranty form submission with Paperless-ngx integration
* This extends the existing saveWarranty function
*/
async function processPaperlessNgxUploads(formData) {
if (!paperlessNgxEnabled) {
return {}; // Return empty object if not enabled
}
const uploads = {};
const documentTypes = ['productPhoto', 'invoice', 'manual', 'otherDocument'];
for (const docType of documentTypes) {
const storageOption = getStorageOption(docType);
if (storageOption === 'paperless') {
const fileInput = document.getElementById(docType);
const file = fileInput?.files[0];
if (file) {
console.log(`[Paperless-ngx] Uploading ${docType} to Paperless-ngx`);
// Upload to Paperless-ngx
const uploadResult = await uploadToPaperlessNgx(file, docType);
if (uploadResult.success) {
// Map frontend document types to database column names
const fieldMapping = {
'productPhoto': 'paperless_photo_id',
'invoice': 'paperless_invoice_id',
'manual': 'paperless_manual_id',
'otherDocument': 'paperless_other_id'
};
const dbField = fieldMapping[docType];
if (dbField) {
uploads[dbField] = uploadResult.document_id;
}
// Remove the file from FormData since it's stored in Paperless-ngx
if (formData.has(docType)) {
formData.delete(docType);
}
console.log(`[Paperless-ngx] ${docType} uploaded successfully, ID: ${uploadResult.document_id}, stored as: ${dbField}`);
} else {
throw new Error(`Failed to upload ${docType} to Paperless-ngx: ${uploadResult.error}`);
}
}
}
}
return uploads;
}
/**
* Handle warranty edit form submission with Paperless-ngx integration
* This extends the existing edit warranty functionality
*/
async function processEditPaperlessNgxUploads(formData) {
if (!paperlessNgxEnabled) {
return {}; // Return empty object if not enabled
}
const uploads = {};
const documentTypes = ['productPhoto', 'invoice', 'manual', 'otherDocument'];
for (const docType of documentTypes) {
const storageOption = getStorageOption(docType, true); // true for edit modal
if (storageOption === 'paperless') {
const fileInput = document.getElementById(`edit${docType.charAt(0).toUpperCase() + docType.slice(1)}`);
const file = fileInput?.files[0];
if (file) {
console.log(`[Paperless-ngx] Uploading ${docType} to Paperless-ngx (edit mode)`);
// Upload to Paperless-ngx
const uploadResult = await uploadToPaperlessNgx(file, docType);
if (uploadResult.success) {
// Map frontend document types to database column names
const fieldMapping = {
'productPhoto': 'paperless_photo_id',
'invoice': 'paperless_invoice_id',
'manual': 'paperless_manual_id',
'otherDocument': 'paperless_other_id'
};
const dbField = fieldMapping[docType];
if (dbField) {
uploads[dbField] = uploadResult.document_id;
}
// Remove the file from FormData since it's stored in Paperless-ngx
const editFieldName = `edit${docType.charAt(0).toUpperCase() + docType.slice(1)}`;
if (formData.has(editFieldName)) {
formData.delete(editFieldName);
}
console.log(`[Paperless-ngx] ${docType} uploaded successfully (edit), ID: ${uploadResult.document_id}, stored as: ${dbField}`);
} else {
throw new Error(`Failed to upload ${docType} to Paperless-ngx: ${uploadResult.error}`);
}
}
}
}
return uploads;
}
// Initialize Paperless-ngx integration when the page loads
document.addEventListener('DOMContentLoaded', function() {
// Initialize after a short delay to ensure other components are loaded
setTimeout(() => {
initPaperlessNgxIntegration();
}, 1000);
});
/**
* Open a Paperless-ngx document in a new tab
*/
async function openPaperlessDocument(paperlessId) {
console.log(`[openPaperlessDocument] Opening Paperless document: ${paperlessId}`);
const token = auth.getToken();
if (!token) {
console.error('[openPaperlessDocument] No auth token available');
showToast('Authentication required', 'error');
return;
}
// Retry mechanism for document retrieval
const maxRetries = 3;
const retryDelay = 2000; // 2 seconds
const fetchWithRetry = async (attempt = 1) => {
try {
console.log(`[openPaperlessDocument] Attempt ${attempt} to fetch: /api/paperless-file/${paperlessId}`);
const response = await fetch(`/api/paperless-file/${paperlessId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.status === 404) {
throw new Error('Document not found in Paperless-ngx.');
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
// Get the blob and create object URL
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Open in new tab
const newTab = window.open(url, '_blank');
if (!newTab) {
showToast('Please allow popups to view documents', 'warning');
}
// Clean up the object URL after a delay
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch (error) {
console.error(`[openPaperlessDocument] Attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
console.log(`[openPaperlessDocument] Retrying in ${retryDelay}ms...`);
setTimeout(() => fetchWithRetry(attempt + 1), retryDelay);
} else {
console.error(`[openPaperlessDocument] All ${maxRetries} attempts failed`);
showToast(`Failed to open document: ${error.message}`, 'error');
}
}
};
try {
await fetchWithRetry();
} catch (error) {
console.error('Error fetching Paperless document:', error);
showToast(`Error opening document: ${error.message}`, 'error');
}
}
/**
* Debug function to test Paperless document status
*/
async function debugPaperlessDocument(paperlessId) {
console.log(`[debugPaperlessDocument] Debugging Paperless document: ${paperlessId}`);
const token = auth.getToken();
if (!token) {
console.error('[debugPaperlessDocument] No auth token available');
return;
}
try {
const response = await fetch(`/api/paperless/debug-document/${paperlessId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error(`[debugPaperlessDocument] HTTP ${response.status}: ${errorText}`);
return;
}
const debugInfo = await response.json();
console.log(`[debugPaperlessDocument] Debug info for document ${paperlessId}:`, debugInfo);
// Show debug info in a more readable format
let debugMessage = `Debug info for Paperless document ${paperlessId}:\n\n`;
debugMessage += `Document exists: ${debugInfo.document_exists}\n`;
debugMessage += `Database references: ${debugInfo.database_references?.length || 0}\n\n`;
debugMessage += 'Endpoint test results:\n';
for (const [endpoint, result] of Object.entries(debugInfo.endpoints_tested || {})) {
debugMessage += `- ${endpoint}: ${result.success ? 'SUCCESS' : 'FAILED'} (${result.status_code || result.error})\n`;
}
if (debugInfo.recent_documents && Array.isArray(debugInfo.recent_documents)) {
debugMessage += `\nDocument in recent list: ${debugInfo.document_in_recent}\n`;
debugMessage += `Recent documents: ${debugInfo.recent_documents.map(d => `${d.id}: ${d.title}`).join(', ')}\n`;
}
alert(debugMessage);
} catch (error) {
console.error('Error debugging Paperless document:', error);
alert(`Debug failed: ${error.message}`);
}
}
/**
* Clean up invalid Paperless-ngx document references
*/
async function cleanupInvalidPaperlessDocuments() {
console.log('[cleanupInvalidPaperlessDocuments] Starting cleanup...');
const token = auth.getToken();
if (!token) {
console.error('[cleanupInvalidPaperlessDocuments] No auth token available');
return;
}
try {
const response = await fetch('/api/paperless/cleanup-invalid', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error(`[cleanupInvalidPaperlessDocuments] HTTP ${response.status}: ${errorText}`);
return;
}
const result = await response.json();
console.log('[cleanupInvalidPaperlessDocuments] Cleanup result:', result);
// Show result to user
let message = result.message || 'Cleanup completed';
if (result.details) {
message += `\n\nDetails:\n`;
message += `- Documents checked: ${result.details.checked}\n`;
message += `- Invalid documents found: ${result.details.invalid_found}\n`;
message += `- References cleaned up: ${result.details.cleaned_up}\n`;
if (result.details.errors && result.details.errors.length > 0) {
message += `\nErrors:\n${result.details.errors.join('\n')}`;
}
}
alert(message);
// Reload warranties to reflect changes
if (result.details && result.details.cleaned_up > 0) {
console.log('[cleanupInvalidPaperlessDocuments] Reloading warranties after cleanup...');
await loadWarranties(true);
}
} catch (error) {
console.error('Error cleaning up Paperless documents:', error);
alert(`Cleanup failed: ${error.message}`);
}
}
// Make debug and cleanup functions available globally for console testing
window.debugPaperlessDocument = debugPaperlessDocument;
window.cleanupInvalidPaperlessDocuments = cleanupInvalidPaperlessDocuments;

View File

@@ -599,6 +599,71 @@
</div>
</div>
<!-- Paperless-ngx Integration Configuration Card -->
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="paperlessSettingsContent">
<h3>Paperless-ngx Integration</h3>
<i class="fas fa-chevron-down collapse-icon"></i>
</div>
<div class="card-body collapsible-content" id="paperlessSettingsContent">
<div class="alert alert-info" style="margin-bottom: 20px;">
<i class="fas fa-info-circle"></i>
<strong>About Paperless-ngx Integration:</strong>
This optional feature allows you to store warranty documents in your self-hosted Paperless-ngx instance instead of locally.
When enabled, users can choose where to store each document: locally or in Paperless-ngx.
</div>
<form id="paperlessSettingsForm">
<div class="form-group">
<div class="preference-item">
<div>
<label>Enable Paperless-ngx Integration</label>
<p class="text-muted">Allow users to store warranty documents in Paperless-ngx</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="paperlessEnabled">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div id="paperlessSettingsContainer" style="display: none;">
<div class="form-group">
<label for="paperlessUrl">Paperless-ngx URL</label>
<input type="url" id="paperlessUrl" class="form-control" placeholder="https://paperless.yourdomain.com">
<small class="text-muted">The base URL of your Paperless-ngx instance (without trailing slash).</small>
</div>
<div class="form-group">
<label for="paperlessApiToken">API Token</label>
<input type="password" id="paperlessApiToken" class="form-control" placeholder="Enter API token or leave blank to keep existing">
<small class="text-muted">Generate an API token from your Paperless-ngx instance (Settings → API Tokens).</small>
</div>
<div class="form-group" style="margin-top: 20px;">
<label>Connection Testing</label>
<div class="paperless-button-group">
<button type="button" id="testPaperlessConnectionBtn" class="btn btn-secondary">
<i class="fas fa-plug"></i> Test Connection
</button>
<button type="button" id="debugPaperlessBtn" class="btn btn-info">
<i class="fas fa-bug"></i> Debug Config
</button>
<button type="button" id="testFileUploadBtn" class="btn btn-warning">
<i class="fas fa-upload"></i> Test Upload
</button>
</div>
<div id="paperlessConnectionStatus" class="paperless-status-message"></div>
</div>
</div>
<button type="button" id="savePaperlessSettingsBtn" class="btn btn-primary" style="margin-top: 15px;">
<i class="fas fa-save"></i> Save Paperless-ngx Settings
</button>
</form>
</div>
</div>
<!-- Apprise Notifications Configuration Card -->
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="appriseNotificationsContent">

View File

@@ -102,6 +102,15 @@ const currencyPositionSelect = document.getElementById('currencyPositionSelect')
// Add dateFormatSelect near other DOM element declarations if not already there
const dateFormatSelect = document.getElementById('dateFormat');
// Paperless-ngx Settings DOM Elements
const paperlessEnabledToggle = document.getElementById('paperlessEnabled');
const paperlessUrlInput = document.getElementById('paperlessUrl');
const paperlessApiTokenInput = document.getElementById('paperlessApiToken');
const paperlessSettingsContainer = document.getElementById('paperlessSettingsContainer');
const testPaperlessConnectionBtn = document.getElementById('testPaperlessConnectionBtn');
const savePaperlessSettingsBtn = document.getElementById('savePaperlessSettingsBtn');
const paperlessConnectionStatus = document.getElementById('paperlessConnectionStatus');
// Global variable to store currencies data for currency code lookup
let globalCurrenciesData = [];
@@ -1111,6 +1120,52 @@ function setupEventListeners() {
});
}
// Paperless-ngx event listeners
if (paperlessEnabledToggle) {
paperlessEnabledToggle.addEventListener('change', function() {
togglePaperlessSettings(this.checked);
});
}
if (testPaperlessConnectionBtn) {
testPaperlessConnectionBtn.addEventListener('click', testPaperlessConnection);
}
if (savePaperlessSettingsBtn) {
savePaperlessSettingsBtn.addEventListener('click', savePaperlessSettings);
}
// Add debug button event listener
const debugPaperlessBtn = document.getElementById('debugPaperlessBtn');
if (debugPaperlessBtn) {
debugPaperlessBtn.addEventListener('click', debugPaperlessConfiguration);
}
// Add test upload button event listener
const testFileUploadBtn = document.getElementById('testFileUploadBtn');
if (testFileUploadBtn) {
testFileUploadBtn.addEventListener('click', testFileUpload);
}
// Clear status message when inputs are changed
if (paperlessUrlInput) {
paperlessUrlInput.addEventListener('input', function() {
if (paperlessConnectionStatus) {
paperlessConnectionStatus.className = 'paperless-status-message';
paperlessConnectionStatus.innerHTML = '';
}
});
}
if (paperlessApiTokenInput) {
paperlessApiTokenInput.addEventListener('input', function() {
if (paperlessConnectionStatus) {
paperlessConnectionStatus.className = 'paperless-status-message';
paperlessConnectionStatus.innerHTML = '';
}
});
}
console.log('Event listeners setup complete');
}
@@ -4428,9 +4483,387 @@ document.addEventListener('DOMContentLoaded', function() {
const currentUser = window.auth.getCurrentUser();
if (currentUser && currentUser.is_admin) {
loadAppriseSettings();
loadPaperlessSettings(); // Also load Paperless-ngx settings for admin
} else {
console.log('User is not admin, skipping Apprise settings load in deferred initialization');
}
}
}, 1000);
});
// ============================================================================
// Paperless-ngx Integration Functions
// ============================================================================
/**
* Toggle the visibility of Paperless-ngx settings based on enabled state
* @param {boolean} enabled - Whether Paperless-ngx is enabled
*/
function togglePaperlessSettings(enabled) {
if (paperlessSettingsContainer) {
paperlessSettingsContainer.style.display = enabled ? 'block' : 'none';
}
}
/**
* Load Paperless-ngx settings from the server
*/
async function loadPaperlessSettings() {
try {
const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token');
const response = await fetch('/api/admin/settings', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to load settings');
}
const settings = await response.json();
// Update Paperless-ngx settings UI
if (paperlessEnabledToggle) {
const isEnabled = settings.paperless_enabled === 'true';
paperlessEnabledToggle.checked = isEnabled;
togglePaperlessSettings(isEnabled);
}
if (paperlessUrlInput && settings.paperless_url) {
paperlessUrlInput.value = settings.paperless_url;
}
// Update API token field placeholder to indicate if token is saved
if (paperlessApiTokenInput) {
if (settings.paperless_api_token_set === 'true') {
paperlessApiTokenInput.placeholder = 'API token saved (hidden for security) - Leave blank to keep existing';
} else {
paperlessApiTokenInput.placeholder = 'Enter API token';
}
}
console.log('✅ Paperless-ngx settings loaded successfully');
} catch (error) {
console.error('❌ Error loading Paperless-ngx settings:', error);
showToast(`Error loading Paperless-ngx settings: ${error.message}`, 'error');
}
}
/**
* Save Paperless-ngx settings to the server
*/
async function savePaperlessSettings() {
try {
showLoading();
const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token');
// Validate URL format
const url = paperlessUrlInput.value.trim();
if (url && !isValidUrl(url)) {
showToast('Please enter a valid URL (e.g., https://paperless.yourdomain.com)', 'error');
return;
}
// Prepare settings data
const settingsData = {
paperless_enabled: paperlessEnabledToggle.checked.toString(),
paperless_url: url
};
// Only include API token if it's provided (not empty)
const apiToken = paperlessApiTokenInput.value.trim();
if (apiToken) {
settingsData.paperless_api_token = apiToken;
}
const response = await fetch('/api/admin/settings', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(settingsData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to save Paperless-ngx settings');
}
const result = await response.json();
showToast('Paperless-ngx settings saved successfully!', 'success');
// Clear the API token input for security
if (paperlessApiTokenInput) {
paperlessApiTokenInput.value = '';
paperlessApiTokenInput.placeholder = 'API token saved (hidden for security) - Leave blank to keep existing';
}
console.log('✅ Paperless-ngx settings saved successfully');
} catch (error) {
console.error('❌ Error saving Paperless-ngx settings:', error);
showToast(`Error saving Paperless-ngx settings: ${error.message}`, 'error');
} finally {
hideLoading();
}
}
/**
* Test connection to Paperless-ngx instance
*/
async function testPaperlessConnection() {
try {
showLoading();
// Clear previous status
if (paperlessConnectionStatus) {
paperlessConnectionStatus.innerHTML = '';
paperlessConnectionStatus.className = 'paperless-status-message';
}
const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token');
// Get current values from the form
const url = paperlessUrlInput.value.trim();
const apiToken = paperlessApiTokenInput.value.trim();
if (!url) {
showToast('Please enter a Paperless-ngx URL first', 'warning');
return;
}
// Check if API token is provided in form, otherwise use stored settings
if (!apiToken && paperlessApiTokenInput.placeholder.includes('saved')) {
// API token is already saved, test with stored settings
const response = await fetch('/api/paperless/test', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (paperlessConnectionStatus) {
paperlessConnectionStatus.classList.add('show');
if (result.success) {
paperlessConnectionStatus.className = 'paperless-status-message show success';
paperlessConnectionStatus.innerHTML = `
<strong>✅ Connection Successful!</strong><br>
${result.message}
${result.server_info ? `<br><small>Server: ${result.server_info}</small>` : ''}
`;
showToast('Paperless-ngx connection test successful!', 'success');
} else {
paperlessConnectionStatus.className = 'paperless-status-message show error';
paperlessConnectionStatus.innerHTML = `
<strong>❌ Connection Failed!</strong><br>
${result.message}
`;
showToast('Paperless-ngx connection test failed', 'error');
}
}
return;
}
if (!apiToken) {
showToast('Please enter an API token to test the connection', 'warning');
return;
}
const response = await fetch('/api/paperless/test', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: url,
api_token: apiToken
})
});
const result = await response.json();
if (paperlessConnectionStatus) {
if (result.success) {
paperlessConnectionStatus.classList.add('show');
paperlessConnectionStatus.className = 'paperless-status-message show success';
paperlessConnectionStatus.innerHTML = `
<strong>✅ Connection Successful!</strong><br>
${result.message}
${result.server_info ? `<br><small>Server: ${result.server_info}</small>` : ''}
`;
showToast('Paperless-ngx connection test successful!', 'success');
} else {
paperlessConnectionStatus.classList.add('show');
paperlessConnectionStatus.className = 'paperless-status-message show error';
paperlessConnectionStatus.innerHTML = `
<strong>❌ Connection Failed!</strong><br>
${result.message}
`;
showToast('Paperless-ngx connection test failed', 'error');
}
}
} catch (error) {
console.error('❌ Error testing Paperless-ngx connection:', error);
if (paperlessConnectionStatus) {
paperlessConnectionStatus.classList.add('show');
paperlessConnectionStatus.className = 'paperless-status-message show error';
paperlessConnectionStatus.innerHTML = `
<strong>❌ Connection Error!</strong><br>
${error.message}
`;
}
showToast(`Error testing connection: ${error.message}`, 'error');
} finally {
hideLoading();
}
}
/**
* Debug Paperless-ngx configuration
*/
async function debugPaperlessConfiguration() {
try {
showLoading();
const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token');
const response = await fetch('/api/paperless/debug', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
// Display debug information
console.log('🔍 Paperless-ngx Debug Information:', result);
let debugHtml = '<h5>📋 Paperless-ngx Debug Information</h5>';
debugHtml += `<strong>Enabled:</strong> ${result.paperless_enabled}<br>`;
debugHtml += `<strong>URL:</strong> ${result.paperless_url || 'Not set'}<br>`;
debugHtml += `<strong>API Token Set:</strong> ${result.paperless_api_token_set}<br>`;
debugHtml += `<strong>Handler Available:</strong> ${result.paperless_handler_available}<br>`;
if (result.test_connection_result) {
debugHtml += `<strong>Connection Test:</strong> ${result.test_connection_result.success ? '✅ Success' : '❌ Failed'}<br>`;
debugHtml += `<strong>Message:</strong> ${result.test_connection_result.message || result.test_connection_result.error}<br>`;
}
if (result.paperless_handler_error) {
debugHtml += `<strong>Handler Error:</strong> ${result.paperless_handler_error}<br>`;
}
// Show in connection status area or create a temporary area
if (paperlessConnectionStatus) {
paperlessConnectionStatus.classList.add('show');
paperlessConnectionStatus.className = 'paperless-status-message show info';
paperlessConnectionStatus.innerHTML = debugHtml;
} else {
// Create temporary debug display
const debugArea = document.createElement('div');
debugArea.innerHTML = debugHtml;
document.body.appendChild(debugArea);
setTimeout(() => debugArea.remove(), 10000); // Remove after 10 seconds
}
showToast('Debug information logged to console', 'info');
} catch (error) {
console.error('❌ Error running Paperless debug:', error);
showToast(`Debug error: ${error.message}`, 'error');
} finally {
hideLoading();
}
}
/**
* Test basic file upload mechanism
*/
async function testFileUpload() {
try {
showLoading();
// Create a simple test file
const testContent = 'This is a test file for Paperless-ngx upload debugging';
const testFile = new File([testContent], 'test.txt', { type: 'text/plain' });
const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token');
const formData = new FormData();
formData.append('file', testFile);
formData.append('document_type', 'test');
const response = await fetch('/api/paperless/test-upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const result = await response.json();
console.log('🧪 File Upload Test Result:', result);
if (result.success) {
showToast('File upload test successful!', 'success');
if (paperlessConnectionStatus) {
paperlessConnectionStatus.classList.add('show');
paperlessConnectionStatus.className = 'paperless-status-message show success';
paperlessConnectionStatus.innerHTML = `
<strong>✅ File Upload Test Successful!</strong><br>
${result.message}<br>
<small>File: ${result.file_info.filename} (${result.file_info.size} bytes)</small>
`;
}
} else {
showToast('File upload test failed', 'error');
if (paperlessConnectionStatus) {
paperlessConnectionStatus.classList.add('show');
paperlessConnectionStatus.className = 'paperless-status-message show error';
paperlessConnectionStatus.innerHTML = `
<strong>❌ File Upload Test Failed!</strong><br>
${result.error}
`;
}
}
} catch (error) {
console.error('❌ Error testing file upload:', error);
showToast(`File upload test error: ${error.message}`, 'error');
} finally {
hideLoading();
}
}
/**
* Validate if a string is a valid URL
* @param {string} string - The string to validate
* @returns {boolean} - Whether the string is a valid URL
*/
function isValidUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (_) {
return false;
}
}

View File

@@ -1225,3 +1225,79 @@ textarea.form-control {
}
}
/* Paperless-ngx Integration Styles */
.paperless-button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
.paperless-button-group .btn {
flex: 0 0 auto;
min-width: 150px;
}
.paperless-status-message {
margin-top: 15px;
padding: 10px;
border-radius: 5px;
background-color: var(--light-gray);
color: var(--text-color);
font-size: 0.9rem;
display: none;
}
.paperless-status-message.show {
display: block;
}
.paperless-status-message.success {
background-color: rgba(40, 167, 69, 0.1);
color: #155724;
border: 1px solid rgba(40, 167, 69, 0.3);
}
.paperless-status-message.error {
background-color: rgba(220, 53, 69, 0.1);
color: #721c24;
border: 1px solid rgba(220, 53, 69, 0.3);
}
.paperless-status-message.info {
background-color: rgba(23, 162, 184, 0.1);
color: #0c5460;
border: 1px solid rgba(23, 162, 184, 0.3);
}
.dark-mode .paperless-status-message {
background-color: rgba(255, 255, 255, 0.05);
}
.dark-mode .paperless-status-message.success {
background-color: rgba(40, 167, 69, 0.2);
color: #5cb85c;
}
.dark-mode .paperless-status-message.error {
background-color: rgba(220, 53, 69, 0.2);
color: #d9534f;
}
.dark-mode .paperless-status-message.info {
background-color: rgba(23, 162, 184, 0.2);
color: #5bc0de;
}
/* Responsive design for paperless buttons */
@media (max-width: 576px) {
.paperless-button-group {
flex-direction: column;
}
.paperless-button-group .btn {
width: 100%;
min-width: unset;
}
}

View File

@@ -29,7 +29,7 @@
<script src="chart.js"></script>
<!-- Load fix for auth buttons -->
<script src="fix-auth-buttons-loader.js"></script>
<script src="script.js?v=20250529005" defer></script> <!-- Added script.js -->
<script src="script.js?v=20250617007" defer></script> <!-- Added script.js -->
<script src="status.js" defer></script> <!-- Status page specific functionality -->
<style>
.user-menu {

View File

@@ -216,4 +216,112 @@
#tagManagementModal {
z-index: 1050;
}
}
/* ============================================================================
Paperless-ngx Integration Styles
============================================================================ */
/* Storage Selection Styles */
.storage-selection {
margin-bottom: 15px;
padding: 12px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.storage-options {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.storage-option {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
border: 1px solid transparent;
background-color: var(--bg-main);
}
.storage-option:hover {
background-color: var(--bg-accent);
border-color: var(--border-color);
}
.storage-option input[type="radio"] {
margin: 0;
margin-right: 8px;
}
.storage-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: var(--text-primary);
}
.storage-label i {
font-size: 14px;
width: 16px;
text-align: center;
}
.storage-option input[type="radio"]:checked + .storage-label {
color: var(--accent-color);
}
.storage-option input[type="radio"]:checked + .storage-label i {
color: var(--accent-color);
}
/* Paperless-ngx info alert */
.alert {
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 6px;
border: 1px solid transparent;
display: flex;
align-items: center;
gap: 10px;
}
.alert-info {
background-color: #e7f3ff;
border-color: #b8d4f0;
color: #31708f;
}
.alert-info i {
color: #31708f;
}
/* Dark mode support for Paperless-ngx elements */
.dark-mode .storage-selection {
background-color: var(--bg-secondary);
border-color: var(--border-color);
}
.dark-mode .storage-option {
background-color: var(--bg-main);
color: var(--text-primary);
}
.dark-mode .storage-option:hover {
background-color: var(--bg-accent);
}
.dark-mode .alert-info {
background-color: rgba(49, 112, 143, 0.1);
border-color: rgba(49, 112, 143, 0.3);
color: #a8c8e1;
}
.dark-mode .alert-info i {
color: #a8c8e1;
}

View File

@@ -1,6 +1,6 @@
// Version checker for Warracker
document.addEventListener('DOMContentLoaded', () => {
const currentVersion = '0.10.1.2'; // Current version of the application
const currentVersion = '0.10.1.3'; // Current version of the application
const updateStatus = document.getElementById('updateStatus');
const updateLink = document.getElementById('updateLink');