chore: apply ruff formatting and fix whitespace issues

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-13 09:12:11 +00:00
parent 43bc58770d
commit b03ebab747
96 changed files with 991 additions and 993 deletions
+1 -1
View File
@@ -7,4 +7,4 @@ name = "python"
runtime_version = "3.x.x" runtime_version = "3.x.x"
[[analyzers]] [[analyzers]]
name = "javascript" name = "javascript"
+2 -2
View File
@@ -150,7 +150,7 @@ PAPERLESS_NGX_API_TOKEN=<PAPERLESS_API_TOKEN>
# Optional: JSON mapping of metadata fields to Paperless custom field names # Optional: JSON mapping of metadata fields to Paperless custom field names
# This allows you to map multiple extracted metadata fields to custom fields in Paperless # This allows you to map multiple extracted metadata fields to custom fields in Paperless
# The mapping format is: {"metadata_field_name": "PaperlessCustomFieldName", ...} # The mapping format is: {"metadata_field_name": "PaperlessCustomFieldName", ...}
# Available metadata fields: absender, empfaenger, correspondent, document_type, language, # Available metadata fields: absender, empfaenger, correspondent, document_type, language,
# kommunikationsart, kommunikationskategorie, reference_number, etc. # kommunikationsart, kommunikationskategorie, reference_number, etc.
# Example: PAPERLESS_CUSTOM_FIELDS_MAPPING={"absender": "Sender", "empfaenger": "Recipient", "language": "Language", "correspondent": "Correspondent"} # Example: PAPERLESS_CUSTOM_FIELDS_MAPPING={"absender": "Sender", "empfaenger": "Recipient", "language": "Language", "correspondent": "Correspondent"}
# PAPERLESS_CUSTOM_FIELDS_MAPPING= # PAPERLESS_CUSTOM_FIELDS_MAPPING=
@@ -238,4 +238,4 @@ NOTIFY_ON_FILE_PROCESSED=True
# Uptime Kuma # Uptime Kuma
UPTIME_KUMA_URL=https://status.example.com/api/push/abcdef123456?status=up UPTIME_KUMA_URL=https://status.example.com/api/push/abcdef123456?status=up
UPTIME_KUMA_PING_INTERVAL=5 UPTIME_KUMA_PING_INTERVAL=5
+1 -2
View File
@@ -10,10 +10,9 @@ updates:
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/frontend/static" directory: "/frontend/static"
schedule: schedule:
interval: "monthly" interval: "monthly"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
@@ -177,8 +177,8 @@ Example:
```markdown ```markdown
### OPENAI_API_KEY ### OPENAI_API_KEY
**Type:** String **Type:** String
**Required:** Yes **Required:** Yes
**Default:** None **Default:** None
Your OpenAI API key for metadata extraction. Your OpenAI API key for metadata extraction.
@@ -22,13 +22,13 @@ These instructions apply to all files in the `frontend/` directory (templates, C
{% block content %} {% block content %}
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">{{ page_title }}</h1> <h1 class="text-2xl font-bold mb-4">{{ page_title }}</h1>
{% if error_message %} {% if error_message %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ error_message }} {{ error_message }}
</div> </div>
{% endif %} {% endif %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<!-- Form content --> <!-- Form content -->
</form> </form>
@@ -72,9 +72,9 @@ These instructions apply to all files in the `frontend/` directory (templates, C
<label class="block text-gray-700 text-sm font-bold mb-2" for="file"> <label class="block text-gray-700 text-sm font-bold mb-2" for="file">
Document File Document File
</label> </label>
<input <input
type="file" type="file"
id="file" id="file"
name="file" name="file"
class="w-full px-3 py-2 border rounded" class="w-full px-3 py-2 border rounded"
required required
@@ -39,15 +39,15 @@ def process_document(
) -> DocumentMetadata: ) -> DocumentMetadata:
""" """
Process a document and extract metadata. Process a document and extract metadata.
Args: Args:
file_path: Path to the document file file_path: Path to the document file
user_id: ID of the user uploading the document user_id: ID of the user uploading the document
metadata: Optional additional metadata metadata: Optional additional metadata
Returns: Returns:
DocumentMetadata object with extracted information DocumentMetadata object with extracted information
Raises: Raises:
FileNotFoundError: If file doesn't exist FileNotFoundError: If file doesn't exist
ProcessingError: If processing fails ProcessingError: If processing fails
@@ -150,7 +150,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
openai_api_key: str openai_api_key: str
max_file_size: int = 10485760 # 10MB default max_file_size: int = 10485760 # 10MB default
class Config: class Config:
env_file = ".env" env_file = ".env"
``` ```
+11 -11
View File
@@ -68,9 +68,9 @@ def db_session():
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
session = Session() session = Session()
yield session yield session
session.close() session.close()
Base.metadata.drop_all(engine) Base.metadata.drop_all(engine)
@@ -99,7 +99,7 @@ def test_upload_document():
"/api/documents/upload", "/api/documents/upload",
files={"file": ("test.pdf", f, "application/pdf")} files={"file": ("test.pdf", f, "application/pdf")}
) )
assert response.status_code == 201 assert response.status_code == 201
assert "id" in response.json() assert "id" in response.json()
``` ```
@@ -131,12 +131,12 @@ def test_openai_metadata_extraction(mocker):
"amount": 100.00, "amount": 100.00,
"date": "2024-01-01" "date": "2024-01-01"
} }
mocker.patch( mocker.patch(
"app.utils.openai_client.extract_metadata", "app.utils.openai_client.extract_metadata",
return_value=mock_response return_value=mock_response
) )
result = extract_document_metadata("test.pdf") result = extract_document_metadata("test.pdf")
assert result["document_type"] == "invoice" assert result["document_type"] == "invoice"
@@ -144,12 +144,12 @@ def test_openai_metadata_extraction(mocker):
def test_azure_ocr_processing(mocker): def test_azure_ocr_processing(mocker):
"""Test OCR with mocked Azure service.""" """Test OCR with mocked Azure service."""
mock_text = "Sample extracted text" mock_text = "Sample extracted text"
mocker.patch( mocker.patch(
"app.utils.azure_client.extract_text", "app.utils.azure_client.extract_text",
return_value=mock_text return_value=mock_text
) )
result = perform_ocr("test.pdf") result = perform_ocr("test.pdf")
assert result == mock_text assert result == mock_text
``` ```
@@ -160,7 +160,7 @@ def test_azure_ocr_processing(mocker):
def test_create_document(db_session): def test_create_document(db_session):
"""Test document creation in database.""" """Test document creation in database."""
from app.models import Document from app.models import Document
doc = Document( doc = Document(
filename="test.pdf", filename="test.pdf",
user_id=1, user_id=1,
@@ -168,7 +168,7 @@ def test_create_document(db_session):
) )
db_session.add(doc) db_session.add(doc)
db_session.commit() db_session.commit()
assert doc.id is not None assert doc.id is not None
assert doc.filename == "test.pdf" assert doc.filename == "test.pdf"
``` ```
@@ -189,10 +189,10 @@ def test_document_validation():
"filename": "", # Empty filename "filename": "", # Empty filename
"size": -1 # Invalid size "size": -1 # Invalid size
} }
# Act # Act
result = validate_document(invalid_document) result = validate_document(invalid_document)
# Assert # Assert
assert result.is_valid is False assert result.is_valid is False
assert "filename" in result.errors assert "filename" in result.errors
+8 -8
View File
@@ -17,30 +17,30 @@ jobs:
name: Semantic Release name: Semantic Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'christianlouis/DocuElevate' if: github.repository == 'christianlouis/DocuElevate'
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
cache: 'pip' cache: 'pip'
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install python-semantic-release pip install python-semantic-release
- name: Configure Git - name: Configure Git
run: | run: |
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Run Semantic Release - name: Run Semantic Release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -48,7 +48,7 @@ jobs:
semantic-release version --print semantic-release version --print
semantic-release version semantic-release version
semantic-release publish semantic-release publish
- name: Update build metadata files if changed - name: Update build metadata files if changed
run: | run: |
for f in VERSION BUILD_DATE GIT_SHA RUNTIME_INFO; do for f in VERSION BUILD_DATE GIT_SHA RUNTIME_INFO; do
@@ -60,7 +60,7 @@ jobs:
git commit -m "chore(release): update build metadata files [skip ci]" git commit -m "chore(release): update build metadata files [skip ci]"
git push git push
fi fi
- name: Trigger Docker Build on Tag - name: Trigger Docker Build on Tag
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
uses: actions/github-script@v7 uses: actions/github-script@v7
@@ -70,4 +70,4 @@ jobs:
const ref = context.ref; const ref = context.ref;
const tag = ref.replace('refs/tags/', ''); const tag = ref.replace('refs/tags/', '');
console.log(`New tag created: ${tag}`); console.log(`New tag created: ${tag}`);
console.log('Docker build will be triggered automatically by the docker-build workflow'); console.log('Docker build will be triggered automatically by the docker-build workflow');
+1 -1
View File
@@ -19,4 +19,4 @@ mkdocs:
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python: python:
install: install:
- requirements: docs/requirements.txt - requirements: docs/requirements.txt
+1 -1
View File
@@ -4,4 +4,4 @@
], ],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true "python.testing.pytestEnabled": true
} }
+19 -19
View File
@@ -1,6 +1,6 @@
# Agentic Coding Guide for DocuElevate # Agentic Coding Guide for DocuElevate
**Version:** 1.0 **Version:** 1.0
**Last Updated:** 2026-02-06 **Last Updated:** 2026-02-06
This guide helps AI coding agents work effectively with the DocuElevate codebase. It provides context, conventions, and best practices for autonomous code contributions. This guide helps AI coding agents work effectively with the DocuElevate codebase. It provides context, conventions, and best practices for autonomous code contributions.
@@ -80,14 +80,14 @@ DocuElevate/
def process_document(file_path: str, metadata: Dict[str, Any]) -> DocumentMetadata: def process_document(file_path: str, metadata: Dict[str, Any]) -> DocumentMetadata:
""" """
Process a document and extract metadata. Process a document and extract metadata.
Args: Args:
file_path: Absolute path to the document file file_path: Absolute path to the document file
metadata: Additional metadata to include metadata: Additional metadata to include
Returns: Returns:
DocumentMetadata object with extracted information DocumentMetadata object with extracted information
Raises: Raises:
FileNotFoundError: If file doesn't exist FileNotFoundError: If file doesn't exist
ProcessingError: If processing fails ProcessingError: If processing fails
@@ -225,7 +225,7 @@ logger = logging.getLogger(__name__)
def my_background_task(self, param: str): def my_background_task(self, param: str):
""" """
Description of what this task does. Description of what this task does.
Args: Args:
param: Description of parameter param: Description of parameter
""" """
@@ -247,7 +247,7 @@ def my_background_task(self, param: str):
```python ```python
class MyModel(Base): class MyModel(Base):
__tablename__ = "my_table" __tablename__ = "my_table"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -275,20 +275,20 @@ logger = logging.getLogger(__name__)
def upload_to_my_provider(file_path: str, metadata: dict) -> str: def upload_to_my_provider(file_path: str, metadata: dict) -> str:
""" """
Upload file to My Provider. Upload file to My Provider.
Args: Args:
file_path: Local path to file file_path: Local path to file
metadata: Document metadata metadata: Document metadata
Returns: Returns:
URL or ID of uploaded file URL or ID of uploaded file
Raises: Raises:
ProviderError: If upload fails ProviderError: If upload fails
""" """
if not settings.my_provider_api_key: if not settings.my_provider_api_key:
raise ValueError("MY_PROVIDER_API_KEY not configured") raise ValueError("MY_PROVIDER_API_KEY not configured")
# Implementation # Implementation
pass pass
``` ```
@@ -342,11 +342,11 @@ def validate_file_path(file_path: str, base_dir: str = "/workdir") -> Path:
try: try:
path = Path(file_path).resolve() path = Path(file_path).resolve()
base = Path(base_dir).resolve() base = Path(base_dir).resolve()
# Ensure path is within base directory # Ensure path is within base directory
if not path.is_relative_to(base): if not path.is_relative_to(base):
raise ValueError("Path outside allowed directory") raise ValueError("Path outside allowed directory")
return path return path
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -467,23 +467,23 @@ def process_large_batch(self, file_ids: List[int]):
def complex_function(param1: str, param2: int = 10) -> Dict[str, Any]: def complex_function(param1: str, param2: int = 10) -> Dict[str, Any]:
""" """
One-line summary of what the function does. One-line summary of what the function does.
More detailed explanation if needed. Can span multiple More detailed explanation if needed. Can span multiple
lines and include examples. lines and include examples.
Args: Args:
param1: Description of param1 param1: Description of param1
param2: Description of param2, defaults to 10 param2: Description of param2, defaults to 10
Returns: Returns:
Dictionary containing: Dictionary containing:
- key1: Description - key1: Description
- key2: Description - key2: Description
Raises: Raises:
ValueError: If param1 is empty ValueError: If param1 is empty
FileNotFoundError: If file doesn't exist FileNotFoundError: If file doesn't exist
Examples: Examples:
>>> result = complex_function("test", 5) >>> result = complex_function("test", 5)
>>> print(result['key1']) >>> print(result['key1'])
@@ -650,7 +650,7 @@ DocuElevate uses `python-semantic-release` for automated version management.
#### How It Works #### How It Works
1. **PR merges to main** with conventional commits 1. **PR merges to main** with conventional commits
2. **semantic-release analyzes** commit messages 2. **semantic-release analyzes** commit messages
3. **Automatic updates**: 3. **Automatic updates**:
- Bumps `VERSION` file - Bumps `VERSION` file
- Updates `CHANGELOG.md` - Updates `CHANGELOG.md`
@@ -669,7 +669,7 @@ DocuElevate uses `python-semantic-release` for automated version management.
### Pull Requests ### Pull Requests
1. Create PR with descriptive title (conventional format if single change) 1. Create PR with descriptive title (conventional format if single change)
2. Fill out PR template 2. Fill out PR template
3. Link related issues 3. Link related issues
4. Ensure CI passes 4. Ensure CI passes
5. Request reviews 5. Request reviews
6. Address feedback 6. Address feedback
+1 -1
View File
@@ -346,4 +346,4 @@ DocuElevate/
- **GitHub Discussions:** Questions and community support - **GitHub Discussions:** Questions and community support
- **Documentation:** Check `docs/` directory for guides - **Documentation:** Check `docs/` directory for guides
Thank you for contributing to DocuElevate! Thank you for contributing to DocuElevate!
-2
View File
@@ -42,5 +42,3 @@ EXPOSE 8000
# Default command # Default command
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+15 -15
View File
@@ -57,8 +57,8 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
## Completed Milestones ## Completed Milestones
### v0.5.0 - Settings Management & Configuration (February 2026) ### v0.5.0 - Settings Management & Configuration (February 2026)
**Release Date:** February 8, 2026 **Release Date:** February 8, 2026
**Status:** ✅ Released **Status:** ✅ Released
**Theme:** Configuration Management, Security, User Experience **Theme:** Configuration Management, Security, User Experience
#### Goals #### Goals
@@ -90,8 +90,8 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
--- ---
### v0.3.3 - Drag-and-Drop Upload (February 2026) ### v0.3.3 - Drag-and-Drop Upload (February 2026)
**Release Date:** February 8, 2026 **Release Date:** February 8, 2026
**Status:** ✅ Released **Status:** ✅ Released
**Theme:** User Experience Enhancement **Theme:** User Experience Enhancement
#### Goals #### Goals
@@ -108,8 +108,8 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
--- ---
### v0.3.2 - Security & Testing Hardening (February 2026) ### v0.3.2 - Security & Testing Hardening (February 2026)
**Release Date:** February 6, 2026 **Release Date:** February 6, 2026
**Status:** ✅ Released **Status:** ✅ Released
**Theme:** Security, Quality, Testing **Theme:** Security, Quality, Testing
#### Goals #### Goals
@@ -131,8 +131,8 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
## Upcoming Milestones ## Upcoming Milestones
### v0.6.0 - Enhanced Search & UI Improvements (April 2026) ### v0.6.0 - Enhanced Search & UI Improvements (April 2026)
**Target Date:** April 1, 2026 **Target Date:** April 1, 2026
**Status:** 📋 Planned **Status:** 📋 Planned
**Theme:** User Experience, Search, Performance **Theme:** User Experience, Search, Performance
#### Goals #### Goals
@@ -163,8 +163,8 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
--- ---
### v0.4.5 - Workflow Automation (June 2026) ### v0.4.5 - Workflow Automation (June 2026)
**Target Date:** June 1, 2026 **Target Date:** June 1, 2026
**Status:** 📋 Planned **Status:** 📋 Planned
**Theme:** Automation, Integration, Webhooks **Theme:** Automation, Integration, Webhooks
#### Goals #### Goals
@@ -185,8 +185,8 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
--- ---
### v0.7.0 - Advanced AI & Multi-language (August 2026) ### v0.7.0 - Advanced AI & Multi-language (August 2026)
**Target Date:** August 1, 2026 **Target Date:** August 1, 2026
**Status:** 📋 Planned **Status:** 📋 Planned
**Theme:** AI Enhancement, Internationalization **Theme:** AI Enhancement, Internationalization
#### Goals #### Goals
@@ -208,8 +208,8 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
--- ---
### v1.0.0 - Enterprise Edition (November 2026) ### v1.0.0 - Enterprise Edition (November 2026)
**Target Date:** November 1, 2026 **Target Date:** November 1, 2026
**Status:** 📋 Planned **Status:** 📋 Planned
**Theme:** Enterprise Features, Scalability, Multi-tenancy **Theme:** Enterprise Features, Scalability, Multi-tenancy
This is our first major release, marking production-ready enterprise capabilities. This is our first major release, marking production-ready enterprise capabilities.
@@ -322,4 +322,4 @@ Starting with v0.6.0, releases are fully automated using `python-semantic-releas
--- ---
*This milestone document is updated regularly. For real-time status, check our [GitHub Projects](https://github.com/christianlouis/DocuElevate/projects) board.* *This milestone document is updated regularly. For real-time status, check our [GitHub Projects](https://github.com/christianlouis/DocuElevate/projects) board.*
+4 -4
View File
@@ -9,15 +9,15 @@ This software includes third-party components with their own licenses:
SPECIAL NOTICE REGARDING LGPL SOFTWARE: SPECIAL NOTICE REGARDING LGPL SOFTWARE:
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
DocuElevate incorporates Paramiko, which is licensed under the GNU Lesser General DocuElevate incorporates Paramiko, which is licensed under the GNU Lesser General
Public License (LGPL) version 2.1. In accordance with the LGPL: Public License (LGPL) version 2.1. In accordance with the LGPL:
1. The complete source code for Paramiko can be obtained from: 1. The complete source code for Paramiko can be obtained from:
https://github.com/paramiko/paramiko https://github.com/paramiko/paramiko
2. This software is distributed in the hope that it will be useful, but WITHOUT 2. This software is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
for more details. for more details.
3. A copy of the GNU Lesser General Public License version 2.1 can be found at: 3. A copy of the GNU Lesser General Public License version 2.1 can be found at:
+7 -7
View File
@@ -50,7 +50,7 @@ pytest tests/test_oauth_integration_flows.py -v
```bash ```bash
# Auto-detects and uses real OAuth credentials # Auto-detects and uses real OAuth credentials
export AUTHENTIK_CLIENT_ID="your-client-id" export AUTHENTIK_CLIENT_ID="your-client-id"
export AUTHENTIK_CLIENT_SECRET="your-client-secret" export AUTHENTIK_CLIENT_SECRET="your-client-secret"
export AUTHENTIK_CONFIG_URL="https://auth.example.com/.well-known/openid-configuration" export AUTHENTIK_CONFIG_URL="https://auth.example.com/.well-known/openid-configuration"
pytest tests/test_oauth_integration_flows.py -v -m requires_external pytest tests/test_oauth_integration_flows.py -v -m requires_external
``` ```
@@ -128,7 +128,7 @@ async def test_oauth_callback(mock_authorize, oauth_enabled_app, test_user_info)
"access_token": "test-token", "access_token": "test-token",
"userinfo": test_user_info, "userinfo": test_user_info,
} }
response = oauth_enabled_app.get("/oauth-callback?code=test-code") response = oauth_enabled_app.get("/oauth-callback?code=test-code")
assert response.status_code == 302 assert response.status_code == 302
``` ```
@@ -185,11 +185,11 @@ jobs:
## Technical Details ## Technical Details
**Container**: `ghcr.io/navikt/mock-oauth2-server:2.1.1` **Container**: `ghcr.io/navikt/mock-oauth2-server:2.1.1`
**Framework**: Testcontainers Python 4.14.1+ **Framework**: Testcontainers Python 4.14.1+
**Test Framework**: pytest with async support **Test Framework**: pytest with async support
**Languages**: Python 3.12+ **Languages**: Python 3.12+
**Dependencies**: testcontainers, requests, docker **Dependencies**: testcontainers, requests, docker
## Files Created/Modified ## Files Created/Modified
+22 -22
View File
@@ -32,12 +32,12 @@
DocuElevate automates the handling, extraction, and processing of documents using a variety of services, including: DocuElevate automates the handling, extraction, and processing of documents using a variety of services, including:
- **OpenAI** for metadata extraction and text refinement. - **OpenAI** for metadata extraction and text refinement.
- **Dropbox**, **Nextcloud**, and **Google Drive** for file storage and uploads. - **Dropbox**, **Nextcloud**, and **Google Drive** for file storage and uploads.
- **Paperless NGX** for document indexing and management. - **Paperless NGX** for document indexing and management.
- **Azure Document Intelligence** for OCR on PDFs. - **Azure Document Intelligence** for OCR on PDFs.
- **Gotenberg** for file-to-PDF conversions. - **Gotenberg** for file-to-PDF conversions.
- **Authentik** for authentication and user management. - **Authentik** for authentication and user management.
It is designed for flexibility and configurability through environment variables, making it easily customizable for different workflows. The system can fetch documents from multiple IMAP mailboxes, process them (OCR, metadata extraction, PDF conversion), and store them in the desired destinations. It is designed for flexibility and configurability through environment variables, making it easily customizable for different workflows. The system can fetch documents from multiple IMAP mailboxes, process them (OCR, metadata extraction, PDF conversion), and store them in the desired destinations.
@@ -59,7 +59,7 @@ The project includes a **UI** for uploading and managing files, and an API docum
<div align="center"> <div align="center">
<img src="docs/upload-view.png" alt="DocuElevate Upload Interface" width="80%" /> <img src="docs/upload-view.png" alt="DocuElevate Upload Interface" width="80%" />
<p><em>Upload interface for adding new documents</em></p> <p><em>Upload interface for adding new documents</em></p>
<img src="docs/files-view.png" alt="DocuElevate Files View" width="80%" /> <img src="docs/files-view.png" alt="DocuElevate Files View" width="80%" />
<p><em>Files view with processed documents and metadata</em></p> <p><em>Files view with processed documents and metadata</em></p>
</div> </div>
@@ -100,28 +100,28 @@ Users can choose to send documents to any combination of these destinations thro
## Features ## Features
- **Intuitive File Upload**: - **Intuitive File Upload**:
- Drag-and-drop file upload on both Upload and Files pages—upload anywhere on the Files page - Drag-and-drop file upload on both Upload and Files pages—upload anywhere on the Files page
- Real-time upload progress with validation - Real-time upload progress with validation
- Support for PDF, Office documents, images, and more (up to 500MB per file) - Support for PDF, Office documents, images, and more (up to 500MB per file)
- **Browser Extension**: - **Browser Extension**:
- Send files directly from your browser to DocuElevate with one click - Send files directly from your browser to DocuElevate with one click
- Compatible with Chrome, Firefox, Edge, and other Chromium-based browsers - Compatible with Chrome, Firefox, Edge, and other Chromium-based browsers
- Context menu integration for quick access - Context menu integration for quick access
- See [Browser Extension Guide](docs/BrowserExtension.md) for installation and usage - See [Browser Extension Guide](docs/BrowserExtension.md) for installation and usage
- **Document Upload & Storage**: - **Document Upload & Storage**:
- Manual uploads (via API or UI) to Dropbox, Nextcloud, Google Drive, or Paperless - Manual uploads (via API or UI) to Dropbox, Nextcloud, Google Drive, or Paperless
- **OCR Processing (Azure)**: - **OCR Processing (Azure)**:
- Extract text from scanned PDFs using Azure Document Intelligence - Extract text from scanned PDFs using Azure Document Intelligence
- **Metadata Extraction (OpenAI)**: - **Metadata Extraction (OpenAI)**:
- Use GPT to classify, label, or otherwise enrich the text with structured metadata - Use GPT to classify, label, or otherwise enrich the text with structured metadata
- **PDF Conversion (Gotenberg)**: - **PDF Conversion (Gotenberg)**:
- Convert non-PDF attachments (e.g., Word docs, images) into PDFs - Convert non-PDF attachments (e.g., Word docs, images) into PDFs
- **Document Management (Paperless NGX)**: - **Document Management (Paperless NGX)**:
- Store processed documents and metadata in a Paperless NGX instance - Store processed documents and metadata in a Paperless NGX instance
- **IMAP Integration**: - **IMAP Integration**:
- Fetch documents from multiple mailboxes (including Gmail) and automatically enqueue them for processing - Fetch documents from multiple mailboxes (including Gmail) and automatically enqueue them for processing
- **Authentication**: - **Authentication**:
- Secure access to the system using **Authentik** for OAuth2-based login - Secure access to the system using **Authentik** for OAuth2-based login
## Frameworks Used ## Frameworks Used
@@ -235,4 +235,4 @@ For a comprehensive list of all dependencies and their licenses, run:
``` ```
pip install pip-licenses pip install pip-licenses
pip-licenses pip-licenses
``` ```
+1 -1
View File
@@ -1,6 +1,6 @@
# DocuElevate Roadmap # DocuElevate Roadmap
**Last Updated:** 2026-02-08 **Last Updated:** 2026-02-08
**Version:** 1.0 **Version:** 1.0
## Vision ## Vision
+36 -36
View File
@@ -1,6 +1,6 @@
# Security Audit Report # Security Audit Report
**Date:** 2026-02-12 **Date:** 2026-02-12
**Status:** Bandit Security Scan Completed - All Critical/High/Medium Issues Resolved **Status:** Bandit Security Scan Completed - All Critical/High/Medium Issues Resolved
## Executive Summary ## Executive Summary
@@ -11,8 +11,8 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
### CVE-2023-36464: PyPDF2/pypdf Infinite Loop Vulnerability ✅ FIXED (2026-02-12) ### CVE-2023-36464: PyPDF2/pypdf Infinite Loop Vulnerability ✅ FIXED (2026-02-12)
**Severity:** Moderate (CVSS: 5.5) **Severity:** Moderate (CVSS: 5.5)
**CVE:** [CVE-2023-36464](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-36464) **CVE:** [CVE-2023-36464](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-36464)
**Advisory:** [GHSA-4vvm-4w3v-6mr8](https://github.com/advisories/GHSA-4vvm-4w3v-6mr8) **Advisory:** [GHSA-4vvm-4w3v-6mr8](https://github.com/advisories/GHSA-4vvm-4w3v-6mr8)
**Issue:** Certain versions of PyPDF2 (>=2.2.0, <=3.0.1) and pypdf (prior to 3.9.0) contain a vulnerability where specially crafted PDF files can trigger an infinite loop in `__parse_content_stream`, causing 100% CPU usage and potential denial of service. **Issue:** Certain versions of PyPDF2 (>=2.2.0, <=3.0.1) and pypdf (prior to 3.9.0) contain a vulnerability where specially crafted PDF files can trigger an infinite loop in `__parse_content_stream`, causing 100% CPU usage and potential denial of service.
@@ -57,8 +57,8 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
### Fixed Issues from Bandit Scan ### Fixed Issues from Bandit Scan
#### 1. B324: Weak MD5 Hash Usage (HIGH SEVERITY) ✅ FIXED #### 1. B324: Weak MD5 Hash Usage (HIGH SEVERITY) ✅ FIXED
**Occurrences:** 2 **Occurrences:** 2
**Locations:** **Locations:**
- `app/api/user.py:26` - Gravatar URL generation - `app/api/user.py:26` - Gravatar URL generation
- `app/auth.py:65` - Gravatar URL generation - `app/auth.py:65` - Gravatar URL generation
@@ -72,7 +72,7 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
``` ```
#### 2. B402/B321: Insecure FTP Protocol (HIGH SEVERITY) ✅ DOCUMENTED #### 2. B402/B321: Insecure FTP Protocol (HIGH SEVERITY) ✅ DOCUMENTED
**Occurrences:** 3 **Occurrences:** 3
**Location:** `app/tasks/upload_to_ftp.py` **Location:** `app/tasks/upload_to_ftp.py`
**Issue:** FTP is an insecure protocol vulnerable to eavesdropping and MITM attacks. **Issue:** FTP is an insecure protocol vulnerable to eavesdropping and MITM attacks.
@@ -88,7 +88,7 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
**Security Note:** For production environments, set `ftp_allow_plaintext=False` to prevent fallback to unencrypted FTP. **Security Note:** For production environments, set `ftp_allow_plaintext=False` to prevent fallback to unencrypted FTP.
#### 3. B507: SSH Host Key Verification Disabled (HIGH SEVERITY) ✅ FIXED #### 3. B507: SSH Host Key Verification Disabled (HIGH SEVERITY) ✅ FIXED
**Occurrences:** 1 **Occurrences:** 1
**Location:** `app/tasks/upload_to_sftp.py:47` **Location:** `app/tasks/upload_to_sftp.py:47`
**Issue:** Using `paramiko.AutoAddPolicy()` automatically trusts unknown SSH host keys, making connections vulnerable to MITM attacks. **Issue:** Using `paramiko.AutoAddPolicy()` automatically trusts unknown SSH host keys, making connections vulnerable to MITM attacks.
@@ -103,7 +103,7 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
**Security Note:** The default value is now `False` (secure). For development/testing environments where host keys cannot be pre-configured, set `SFTP_DISABLE_HOST_KEY_VERIFICATION=True` (not recommended for production). **Security Note:** The default value is now `False` (secure). For development/testing environments where host keys cannot be pre-configured, set `SFTP_DISABLE_HOST_KEY_VERIFICATION=True` (not recommended for production).
#### 4. B113: Missing Timeout on HTTP Requests (MEDIUM SEVERITY) ✅ FIXED #### 4. B113: Missing Timeout on HTTP Requests (MEDIUM SEVERITY) ✅ FIXED
**Occurrences:** 15 **Occurrences:** 15
**Locations:** **Locations:**
- `app/api/dropbox.py` (4 requests calls) - `app/api/dropbox.py` (4 requests calls)
- `app/api/google_drive.py` (1 request call) - `app/api/google_drive.py` (1 request call)
@@ -142,8 +142,8 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
## Critical Vulnerabilities (Fixed) ✅ ## Critical Vulnerabilities (Fixed) ✅
### 1. Outdated Authlib with Known Vulnerabilities ### 1. Outdated Authlib with Known Vulnerabilities
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** HIGH **Severity:** HIGH
**Description:** Authlib version 1.3.2 had two critical vulnerabilities: **Description:** Authlib version 1.3.2 had two critical vulnerabilities:
- CVE: Denial of Service via Oversized JOSE Segments - CVE: Denial of Service via Oversized JOSE Segments
- CVE: JWS/JWT accepts unknown crit headers (RFC violation → possible authz bypass) - CVE: JWS/JWT accepts unknown crit headers (RFC violation → possible authz bypass)
@@ -151,18 +151,18 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
**Fix:** Updated `requirements.txt` to require `authlib>=1.6.5` **Fix:** Updated `requirements.txt` to require `authlib>=1.6.5`
### 2. Starlette DoS Vulnerability ### 2. Starlette DoS Vulnerability
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** MEDIUM **Severity:** MEDIUM
**Description:** Starlette 0.41.3 vulnerable to O(n^2) DoS via Range header merging in `FileResponse` **Description:** Starlette 0.41.3 vulnerable to O(n^2) DoS via Range header merging in `FileResponse`
**Fix:** Updated `requirements.txt` to require `starlette>=0.49.1` **Fix:** Updated `requirements.txt` to require `starlette>=0.49.1`
### 3. Weak SESSION_SECRET Default ### 3. Weak SESSION_SECRET Default
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** HIGH **Severity:** HIGH
**Description:** Default SESSION_SECRET value in `app/main.py` was a predictable string that could be exploited if not overridden **Description:** Default SESSION_SECRET value in `app/main.py` was a predictable string that could be exploited if not overridden
**Fix:** **Fix:**
- Enhanced validation in `app/main.py` to raise error if auth is enabled without proper secret - Enhanced validation in `app/main.py` to raise error if auth is enabled without proper secret
- Updated default to be clearly marked as insecure for development only - Updated default to be clearly marked as insecure for development only
- Added generation instructions in error message - Added generation instructions in error message
@@ -170,8 +170,8 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
## Medium Risk Issues (Fixed) ✅ ## Medium Risk Issues (Fixed) ✅
### 4. Insufficient .gitignore Protection ### 4. Insufficient .gitignore Protection
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** MEDIUM **Severity:** MEDIUM
**Description:** .gitignore didn't adequately protect against accidentally committing sensitive files (credentials, private keys, secrets) **Description:** .gitignore didn't adequately protect against accidentally committing sensitive files (credentials, private keys, secrets)
**Fix:** Enhanced `.gitignore` with comprehensive patterns for: **Fix:** Enhanced `.gitignore` with comprehensive patterns for:
@@ -182,8 +182,8 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
- Explicit exclusion of patterns where needed - Explicit exclusion of patterns where needed
### 5. File Upload Size Limits ### 5. File Upload Size Limits
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** MEDIUM **Severity:** MEDIUM
**Description:** No configurable limits on file upload sizes could lead to resource exhaustion attacks and DoS. **Description:** No configurable limits on file upload sizes could lead to resource exhaustion attacks and DoS.
**Fix:** Implemented configurable file upload size limits with the following features: **Fix:** Implemented configurable file upload size limits with the following features:
@@ -329,7 +329,7 @@ For security issues, please follow the guidelines in [SECURITY.md](SECURITY.md).
## Path Traversal Vulnerability Audit (2026-02-10) ## Path Traversal Vulnerability Audit (2026-02-10)
**Status:** ✅ ALL ISSUES FIXED **Status:** ✅ ALL ISSUES FIXED
**Scope:** Comprehensive review of all file path operations for path traversal vulnerabilities **Scope:** Comprehensive review of all file path operations for path traversal vulnerabilities
### Executive Summary ### Executive Summary
@@ -338,11 +338,11 @@ A thorough security audit was conducted on all file path operations in DocuEleva
### Critical Vulnerability: Path Traversal via GPT Metadata Filename ### Critical Vulnerability: Path Traversal via GPT Metadata Filename
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** CRITICAL **Severity:** CRITICAL
**Location:** `app/tasks/embed_metadata_into_pdf.py` (line 144) **Location:** `app/tasks/embed_metadata_into_pdf.py` (line 144)
**Description:** **Description:**
The `metadata["filename"]` extracted by GPT was used directly in file path operations without sanitization. A malicious document could be crafted to make GPT return metadata containing path traversal sequences (e.g., `../../etc/passwd`, `..\\windows\\system32`), allowing file writes outside the intended `processed/` directory. The `metadata["filename"]` extracted by GPT was used directly in file path operations without sanitization. A malicious document could be crafted to make GPT return metadata containing path traversal sequences (e.g., `../../etc/passwd`, `..\\windows\\system32`), allowing file writes outside the intended `processed/` directory.
**Attack Vector:** **Attack Vector:**
@@ -376,11 +376,11 @@ suggested_filename = os.path.splitext(suggested_filename)[0]
### Medium Vulnerability: Insecure Path Validation Using String Prefix Check ### Medium Vulnerability: Insecure Path Validation Using String Prefix Check
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** MEDIUM **Severity:** MEDIUM
**Location:** `app/tasks/embed_metadata_into_pdf.py` (line 188-193) **Location:** `app/tasks/embed_metadata_into_pdf.py` (line 188-193)
**Description:** **Description:**
The code used string-based `startswith()` check to validate if a file was within the workdir/tmp directory before deletion. This is vulnerable to: The code used string-based `startswith()` check to validate if a file was within the workdir/tmp directory before deletion. This is vulnerable to:
- Partial directory name matches (e.g., `/workdir/tmp2/` would pass if workdir is `/workdir/tmp`) - Partial directory name matches (e.g., `/workdir/tmp2/` would pass if workdir is `/workdir/tmp`)
- Symlink attacks (symlinks are not resolved before checking) - Symlink attacks (symlinks are not resolved before checking)
@@ -403,7 +403,7 @@ workdir_tmp_path = Path(settings.workdir) / TMP_SUBDIR
try: try:
original_file_path = Path(original_file).resolve() original_file_path = Path(original_file).resolve()
workdir_tmp_resolved = workdir_tmp_path.resolve() workdir_tmp_resolved = workdir_tmp_path.resolve()
# Check if file is within workdir/tmp and exists # Check if file is within workdir/tmp and exists
if original_file_path.is_relative_to(workdir_tmp_resolved) and original_file_path.exists(): if original_file_path.is_relative_to(workdir_tmp_resolved) and original_file_path.exists():
original_file_path.unlink() original_file_path.unlink()
@@ -420,11 +420,11 @@ except (ValueError, OSError) as e:
### Medium Issue: Insufficient Validation of GPT-Extracted Filenames ### Medium Issue: Insufficient Validation of GPT-Extracted Filenames
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Severity:** MEDIUM **Severity:** MEDIUM
**Location:** `app/tasks/extract_metadata_with_gpt.py` (after line 124) **Location:** `app/tasks/extract_metadata_with_gpt.py` (after line 124)
**Description:** **Description:**
While the GPT prompt requested filenames in a specific format (YYYY-MM-DD_DescriptiveTitle with only letters, numbers, periods, underscores), there was no validation to enforce this constraint. GPT may not always comply with the format specification, potentially returning: While the GPT prompt requested filenames in a specific format (YYYY-MM-DD_DescriptiveTitle with only letters, numbers, periods, underscores), there was no validation to enforce this constraint. GPT may not always comply with the format specification, potentially returning:
- Filenames with path separators - Filenames with path separators
- Filenames with path traversal patterns - Filenames with path traversal patterns
@@ -449,7 +449,7 @@ if filename:
metadata["filename"] = "" metadata["filename"] = ""
``` ```
**Defense in Depth:** **Defense in Depth:**
This validation provides an additional layer of security before the filename reaches `embed_metadata_into_pdf.py`, where it is also sanitized. This validation provides an additional layer of security before the filename reaches `embed_metadata_into_pdf.py`, where it is also sanitized.
### Security-Positive Findings ### Security-Positive Findings
@@ -502,11 +502,11 @@ def resolve_file_path(base_dir, file_path):
"""Safely resolve file path within base directory.""" """Safely resolve file path within base directory."""
base = Path(base_dir).resolve() base = Path(base_dir).resolve()
target = (base / file_path).resolve() target = (base / file_path).resolve()
# Ensure target is within base directory # Ensure target is within base directory
if not target.is_relative_to(base): if not target.is_relative_to(base):
raise ValueError("Path traversal attempt detected") raise ValueError("Path traversal attempt detected")
return target return target
``` ```
@@ -610,7 +610,7 @@ All identified path traversal vulnerabilities have been remediated with defense-
## Security Headers Implementation (2026-02-10) ## Security Headers Implementation (2026-02-10)
**Status:** ✅ COMPLETED **Status:** ✅ COMPLETED
**Scope:** HTTP security headers middleware for browser-side security **Scope:** HTTP security headers middleware for browser-side security
### Executive Summary ### Executive Summary
@@ -654,7 +654,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' 'unsafe-inline'
- Mitigates XSS attack vectors - Mitigates XSS attack vectors
- Customizable per deployment needs - Customizable per deployment needs
**Trade-offs:** **Trade-offs:**
- Default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript - Default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript
- Stricter policies can be configured using nonces or hashes - Stricter policies can be configured using nonces or hashes
@@ -832,4 +832,4 @@ Security headers implementation is complete and production-ready. The middleware
--- ---
**Next Audit Due:** 2026-05-07 (Quarterly) **Next Audit Due:** 2026-05-07 (Quarterly)
+2 -2
View File
@@ -1,6 +1,6 @@
# DocuElevate TODO List # DocuElevate TODO List
**Last Updated:** 2026-02-08 **Last Updated:** 2026-02-08
**Current Version:** v0.5.0 **Current Version:** v0.5.0
This document tracks actionable tasks for the current development cycle. For long-term planning, see [ROADMAP.md](ROADMAP.md) and [MILESTONES.md](MILESTONES.md). This document tracks actionable tasks for the current development cycle. For long-term planning, see [ROADMAP.md](ROADMAP.md) and [MILESTONES.md](MILESTONES.md).
@@ -10,7 +10,7 @@ This document tracks actionable tasks for the current development cycle. For lon
## ⚠️ Important Note on Versioning ## ⚠️ Important Note on Versioning
As of this update, DocuElevate uses **automated semantic versioning** via `python-semantic-release`: As of this update, DocuElevate uses **automated semantic versioning** via `python-semantic-release`:
- **DO NOT** manually edit `VERSION` or `CHANGELOG.md` - **DO NOT** manually edit `VERSION` or `CHANGELOG.md`
- Version bumps are automated based on conventional commit messages - Version bumps are automated based on conventional commit messages
- See [CONTRIBUTING.md](CONTRIBUTING.md) for commit message format - See [CONTRIBUTING.md](CONTRIBUTING.md) for commit message format
+18 -18
View File
@@ -4,10 +4,10 @@ This document tracks test coverage improvements for DocuElevate. The goal is to
## Current Status ## Current Status
**Initial Coverage**: 45.09% **Initial Coverage**: 45.09%
**Current Coverage**: 48.17% **Current Coverage**: 48.17%
**Progress**: +3.08% **Progress**: +3.08%
**Target Coverage**: 60%+ (Phase 1), then 70%, 80% **Target Coverage**: 60%+ (Phase 1), then 70%, 80%
**Remaining to target**: ~12% **Remaining to target**: ~12%
## Completed Tests ## Completed Tests
@@ -21,22 +21,22 @@ This document tracks test coverage improvements for DocuElevate. The goal is to
- Test is_encrypted function - Test is_encrypted function
- Test is_encryption_available - Test is_encryption_available
- Mock cryptography library for error cases - Mock cryptography library for error cases
- [x] `app/celery_worker.py` (0% → 90.62%) ✅ - [x] `app/celery_worker.py` (0% → 90.62%) ✅
- Basic module structure tests (removed tests requiring Redis) - Basic module structure tests (removed tests requiring Redis)
- [x] `app/tasks/uptime_kuma_tasks.py` (0% → 100%) ✅ - [x] `app/tasks/uptime_kuma_tasks.py` (0% → 100%) ✅
- Test ping_uptime_kuma with valid URL - Test ping_uptime_kuma with valid URL
- Test skipping when URL not configured - Test skipping when URL not configured
- Test error handling for failed requests - Test error handling for failed requests
- [x] `app/utils/` package (exports via __init__.py) ✅ - [x] `app/utils/` package (exports via __init__.py) ✅
- Package exports tested in test_reexports.py - Package exports tested in test_reexports.py
- Individual module coverage from actual usage - Individual module coverage from actual usage
- [x] `app/frontend.py` (0% → 100%) ✅ - [x] `app/frontend.py` (0% → 100%) ✅
- Simple re-export module, test imports work - Simple re-export module, test imports work
- [x] `app/utils/config_validator.py` (0% → Still 0%) ⚠️ - [x] `app/utils/config_validator.py` (0% → Still 0%) ⚠️
- Re-export module, coverage is from actual usage - Re-export module, coverage is from actual usage
@@ -46,11 +46,11 @@ This document tracks test coverage improvements for DocuElevate. The goal is to
- Test get_unique_filename - Test get_unique_filename
- Test extract_remote_path - Test extract_remote_path
- Test filename validation functions - Test filename validation functions
- [x] `app/utils/logging.py` (42.86% → 100%) ✅ - [x] `app/utils/logging.py` (42.86% → 100%) ✅
- Test log_task_progress function - Test log_task_progress function
- Test various log message formats - Test various log message formats
- [x] `app/utils/oauth_helper.py` (17.50% → 100%) ✅ - [x] `app/utils/oauth_helper.py` (17.50% → 100%) ✅
- Test OAuth token exchange - Test OAuth token exchange
- Test error handling - Test error handling
@@ -80,17 +80,17 @@ This document tracks test coverage improvements for DocuElevate. The goal is to
- Test Azure connection - Test Azure connection
- Test credential validation - Test credential validation
- Mock Azure API responses - Mock Azure API responses
- [ ] `app/api/dropbox.py` (16.94% → 50%+) - [ ] `app/api/dropbox.py` (16.94% → 50%+)
- Test OAuth flow (mocked) - Test OAuth flow (mocked)
- Test token validation - Test token validation
- Test connection testing - Test connection testing
- [ ] `app/api/google_drive.py` (12.94% → 50%+) - [ ] `app/api/google_drive.py` (12.94% → 50%+)
- Test OAuth flow (mocked) - Test OAuth flow (mocked)
- Test token validation - Test token validation
- Test drive connection - Test drive connection
- [ ] `app/api/onedrive.py` (13.83% → 50%+) - [ ] `app/api/onedrive.py` (13.83% → 50%+)
- Test OAuth flow (mocked) - Test OAuth flow (mocked)
- Test token validation - Test token validation
@@ -101,7 +101,7 @@ This document tracks test coverage improvements for DocuElevate. The goal is to
- Test PDF conversion with various formats - Test PDF conversion with various formats
- Test Gotenberg integration (mocked) - Test Gotenberg integration (mocked)
- Test error handling - Test error handling
- [ ] `app/tasks/embed_metadata_into_pdf.py` (19.05% → 50%+) - [ ] `app/tasks/embed_metadata_into_pdf.py` (19.05% → 50%+)
- Test metadata embedding - Test metadata embedding
- Test PDF manipulation - Test PDF manipulation
@@ -124,18 +124,18 @@ These require complex external service mocking:
- Test credential validation for each provider - Test credential validation for each provider
- Test failure state management - Test failure state management
- Test notification system - Test notification system
- [ ] `app/tasks/imap_tasks.py` (0%) - [ ] `app/tasks/imap_tasks.py` (0%)
- Requires IMAP server mocking - Requires IMAP server mocking
- Test email fetching - Test email fetching
- Test email parsing - Test email parsing
- Test lock management with Redis - Test lock management with Redis
- [ ] `app/tasks/upload_with_rclone.py` (0%) - [ ] `app/tasks/upload_with_rclone.py` (0%)
- Test rclone command execution - Test rclone command execution
- Test configuration management - Test configuration management
- Test error handling - Test error handling
- [ ] `app/tasks/extract_metadata_with_gpt.py` (28.79%) - [ ] `app/tasks/extract_metadata_with_gpt.py` (28.79%)
- Test GPT metadata extraction - Test GPT metadata extraction
- Mock OpenAI API responses - Mock OpenAI API responses
+1 -1
View File
@@ -185,7 +185,7 @@ pytest tests/test_upload_webdav*.py -v
- **Docker:** Must be installed and running - **Docker:** Must be installed and running
- **Memory:** ~100MB per container, ~1GB total for full stack - **Memory:** ~100MB per container, ~1GB total for full stack
- **Disk:** ~2GB for all Docker images - **Disk:** ~2GB for all Docker images
- **Time:** - **Time:**
- First run: ~5-10 minutes (image pulls) - First run: ~5-10 minutes (image pulls)
- Subsequent runs: ~10-60 seconds per test - Subsequent runs: ~10-60 seconds per test
+1 -1
View File
@@ -72,7 +72,7 @@ def upload_with_rclone(self, file_path: str, destination: str):
# Try to get a public link if possible # Try to get a public link if possible
try: try:
link_cmd = ["rclone", "link", "--config", rclone_config_path, f"{destination}/{filename}"] link_cmd = ["rclone", "link", "--config", rclone_config_path, f"{destination}/{filename}"]
link_result = subprocess.run(link_cmd, capture_output=True, text=True) link_result = subprocess.run(link_cmd, capture_output=True, text=True, check=False)
public_url = link_result.stdout.strip() if link_result.returncode == 0 else None public_url = link_result.stdout.strip() if link_result.returncode == 0 else None
except (subprocess.SubprocessError, OSError) as e: except (subprocess.SubprocessError, OSError) as e:
logger.warning(f"[{task_id}] Failed to get public link for {filename}: {str(e)}") logger.warning(f"[{task_id}] Failed to get public link for {filename}: {str(e)}")
+6 -6
View File
@@ -6,8 +6,8 @@ Successfully implemented a complete, production-ready browser extension for Docu
## Implementation Date ## Implementation Date
Feature branch: `copilot/add-browser-plugin-for-docuelevate` Feature branch: `copilot/add-browser-plugin-for-docuelevate`
Commits: 7 commits implementing the complete feature Commits: 7 commits implementing the complete feature
Status: ✅ **COMPLETE AND PRODUCTION-READY** Status: ✅ **COMPLETE AND PRODUCTION-READY**
## Requirements Met ## Requirements Met
@@ -16,7 +16,7 @@ All requirements from the original issue have been fully satisfied:
### ✅ Functional Requirements ### ✅ Functional Requirements
- [x] Capture file URLs from user's browser - [x] Capture file URLs from user's browser
- [x] Send URLs to DocuElevate API endpoint - [x] Send URLs to DocuElevate API endpoint
- [x] Support for Chrome, Firefox, Edge, and Chromium-based browsers - [x] Support for Chrome, Firefox, Edge, and Chromium-based browsers
- [x] Simple user interaction (one-click + context menu) - [x] Simple user interaction (one-click + context menu)
- [x] Display status/feedback in plugin UI (success, error) - [x] Display status/feedback in plugin UI (success, error)
@@ -362,7 +362,7 @@ The browser extension implementation is **complete and production-ready**. All r
--- ---
**Implementation Team**: GitHub Copilot **Implementation Team**: GitHub Copilot
**Review Status**: All code review feedback addressed **Review Status**: All code review feedback addressed
**Documentation Status**: Complete **Documentation Status**: Complete
**Production Readiness**: ✅ READY **Production Readiness**: ✅ READY
+7 -7
View File
@@ -12,13 +12,13 @@ For Chrome / Edge / Chromium-based browsers:
2. Navigate to extensions page: 2. Navigate to extensions page:
• Chrome: chrome://extensions/ • Chrome: chrome://extensions/
• Edge: edge://extensions/ • Edge: edge://extensions/
3. Enable "Developer mode" (toggle in top right) 3. Enable "Developer mode" (toggle in top right)
4. Click "Load unpacked" 4. Click "Load unpacked"
5. Select the browser-extension folder 5. Select the browser-extension folder
6. Extension is now installed! 🎉 6. Extension is now installed! 🎉
For Firefox: For Firefox:
@@ -34,10 +34,10 @@ For Firefox:
1. Click the DocuElevate icon in toolbar 1. Click the DocuElevate icon in toolbar
2. Enter your server URL: 2. Enter your server URL:
https://your-docuelevate-server.com https://your-docuelevate-server.com
3. (Optional) Add session cookie if auth enabled: 3. (Optional) Add session cookie if auth enabled:
session=your_session_value session=your_session_value
4. Click "Save Configuration" 4. Click "Save Configuration"
5. Ready to use! 🚀 5. Ready to use! 🚀
@@ -95,7 +95,7 @@ Complete guides available:
Problem: Extension not appearing Problem: Extension not appearing
→ Check developer mode is enabled → Check developer mode is enabled
→ Reload the extension → Reload the extension
Problem: Can't connect to server Problem: Can't connect to server
→ Verify server URL is correct → Verify server URL is correct
→ Check server is running → Check server is running
+1 -1
View File
@@ -127,7 +127,7 @@ The extension can send any URL, but DocuElevate will only process supported file
**Cause**: The URL doesn't point to a supported file type. **Cause**: The URL doesn't point to a supported file type.
**Solution**: **Solution**:
- Verify the URL ends with a supported file extension - Verify the URL ends with a supported file extension
- Check that the Content-Type header is set correctly by the server - Check that the Content-Type header is set correctly by the server
+2 -2
View File
@@ -41,7 +41,7 @@ When you first install the extension, you'll see the configuration screen:
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
``` ```
**Dimensions**: 400px wide, ~300px height **Dimensions**: 400px wide, ~300px height
**Colors**: Green buttons (#4CAF50), clean white background **Colors**: Green buttons (#4CAF50), clean white background
### Send File View (Main Interface) ### Send File View (Main Interface)
@@ -180,7 +180,7 @@ After sending a file via context menu, a system notification appears:
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
``` ```
**Notification Type**: Browser native notification **Notification Type**: Browser native notification
**Duration**: Auto-dismiss after 5-10 seconds **Duration**: Auto-dismiss after 5-10 seconds
## Chrome Extensions Page ## Chrome Extensions Page
+16 -16
View File
@@ -19,20 +19,20 @@ const showConfigBtn = document.getElementById('show-config');
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
// Load saved configuration // Load saved configuration
const config = await loadConfig(); const config = await loadConfig();
if (config.serverUrl) { if (config.serverUrl) {
serverUrlInput.value = config.serverUrl; serverUrlInput.value = config.serverUrl;
} }
if (config.sessionCookie) { if (config.sessionCookie) {
sessionCookieInput.value = config.sessionCookie; sessionCookieInput.value = config.sessionCookie;
} }
// Get current tab URL // Get current tab URL
const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const currentUrl = tabs[0]?.url || ''; const currentUrl = tabs[0]?.url || '';
currentUrlDisplay.textContent = currentUrl; currentUrlDisplay.textContent = currentUrl;
// Show appropriate section // Show appropriate section
if (config.serverUrl) { if (config.serverUrl) {
showSendSection(); showSendSection();
@@ -44,12 +44,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// Save configuration // Save configuration
saveConfigBtn.addEventListener('click', async () => { saveConfigBtn.addEventListener('click', async () => {
const serverUrl = serverUrlInput.value.trim(); const serverUrl = serverUrlInput.value.trim();
if (!serverUrl) { if (!serverUrl) {
showStatus('Please enter a server URL', 'error'); showStatus('Please enter a server URL', 'error');
return; return;
} }
// Validate URL format // Validate URL format
try { try {
new URL(serverUrl); new URL(serverUrl);
@@ -57,15 +57,15 @@ saveConfigBtn.addEventListener('click', async () => {
showStatus('Invalid server URL format', 'error'); showStatus('Invalid server URL format', 'error');
return; return;
} }
const config = { const config = {
serverUrl: serverUrl, serverUrl: serverUrl,
sessionCookie: sessionCookieInput.value.trim() sessionCookie: sessionCookieInput.value.trim()
}; };
await saveConfig(config); await saveConfig(config);
showStatus('Configuration saved successfully!', 'success'); showStatus('Configuration saved successfully!', 'success');
setTimeout(() => { setTimeout(() => {
showSendSection(); showSendSection();
}, 1000); }, 1000);
@@ -76,39 +76,39 @@ sendFileBtn.addEventListener('click', async () => {
const config = await loadConfig(); const config = await loadConfig();
const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const currentUrl = tabs[0]?.url || ''; const currentUrl = tabs[0]?.url || '';
if (!currentUrl) { if (!currentUrl) {
showStatus('No URL found in current tab', 'error'); showStatus('No URL found in current tab', 'error');
return; return;
} }
// Disable button and show loading // Disable button and show loading
sendFileBtn.disabled = true; sendFileBtn.disabled = true;
sendFileBtn.classList.add('loading'); sendFileBtn.classList.add('loading');
showStatus('Sending file to DocuElevate...', 'info'); showStatus('Sending file to DocuElevate...', 'info');
try { try {
const payload = { const payload = {
url: currentUrl, url: currentUrl,
filename: filenameInput.value.trim() || null filename: filenameInput.value.trim() || null
}; };
const headers = { const headers = {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
// Add session cookie if provided // Add session cookie if provided
if (config.sessionCookie) { if (config.sessionCookie) {
headers['Cookie'] = config.sessionCookie; headers['Cookie'] = config.sessionCookie;
} }
const response = await fetch(`${config.serverUrl}/api/process-url`, { const response = await fetch(`${config.serverUrl}/api/process-url`, {
method: 'POST', method: 'POST',
headers: headers, headers: headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
credentials: 'include' credentials: 'include'
}); });
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
showStatus( showStatus(
+12 -12
View File
@@ -9,7 +9,7 @@ chrome.runtime.onInstalled.addListener((details) => {
} else if (details.reason === 'update') { } else if (details.reason === 'update') {
console.log('DocuElevate extension updated'); console.log('DocuElevate extension updated');
} }
// Create context menu item // Create context menu item
chrome.contextMenus.create({ chrome.contextMenus.create({
id: 'send-to-docuelevate', id: 'send-to-docuelevate',
@@ -31,36 +31,36 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Handle sending URL to DocuElevate // Handle sending URL to DocuElevate
async function handleSendUrl(data) { async function handleSendUrl(data) {
const { url, filename, serverUrl, sessionCookie } = data; const { url, filename, serverUrl, sessionCookie } = data;
if (!url || !serverUrl) { if (!url || !serverUrl) {
throw new Error('URL and server URL are required'); throw new Error('URL and server URL are required');
} }
const headers = { const headers = {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
if (sessionCookie) { if (sessionCookie) {
headers['Cookie'] = sessionCookie; headers['Cookie'] = sessionCookie;
} }
const payload = { const payload = {
url: url, url: url,
filename: filename || null filename: filename || null
}; };
const response = await fetch(`${serverUrl}/api/process-url`, { const response = await fetch(`${serverUrl}/api/process-url`, {
method: 'POST', method: 'POST',
headers: headers, headers: headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
credentials: 'include' credentials: 'include'
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(errorData.detail || `HTTP ${response.status}`); throw new Error(errorData.detail || `HTTP ${response.status}`);
} }
return await response.json(); return await response.json();
} }
@@ -69,18 +69,18 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'send-to-docuelevate') { if (info.menuItemId === 'send-to-docuelevate') {
// Get the URL to send (link URL or page URL) // Get the URL to send (link URL or page URL)
const targetUrl = info.linkUrl || info.pageUrl; const targetUrl = info.linkUrl || info.pageUrl;
// Load configuration // Load configuration
const config = await new Promise((resolve) => { const config = await new Promise((resolve) => {
chrome.storage.sync.get(['serverUrl', 'sessionCookie'], resolve); chrome.storage.sync.get(['serverUrl', 'sessionCookie'], resolve);
}); });
if (!config.serverUrl) { if (!config.serverUrl) {
// Open popup to configure // Open popup to configure
chrome.action.openPopup(); chrome.action.openPopup();
return; return;
} }
// Send the URL // Send the URL
try { try {
const result = await handleSendUrl({ const result = await handleSendUrl({
@@ -88,7 +88,7 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
serverUrl: config.serverUrl, serverUrl: config.serverUrl,
sessionCookie: config.sessionCookie sessionCookie: config.sessionCookie
}); });
// Show success notification // Show success notification
chrome.notifications.create({ chrome.notifications.create({
type: 'basic', type: 'basic',
+1 -1
View File
@@ -24,7 +24,7 @@ function isDirectFileUrl(url) {
'.txt', '.csv', '.rtf', '.jpg', '.jpeg', '.png', '.gif', '.txt', '.csv', '.rtf', '.jpg', '.jpeg', '.png', '.gif',
'.bmp', '.tiff', '.webp', '.svg' '.bmp', '.tiff', '.webp', '.svg'
]; ];
const urlLower = url.toLowerCase(); const urlLower = url.toLowerCase();
return fileExtensions.some(ext => urlLower.endsWith(ext)); return fileExtensions.some(ext => urlLower.endsWith(ext));
} }
+11 -11
View File
@@ -52,7 +52,7 @@
</head> </head>
<body> <body>
<h1>🧪 DocuElevate Browser Extension Test Page</h1> <h1>🧪 DocuElevate Browser Extension Test Page</h1>
<div class="instructions"> <div class="instructions">
<h2>How to Test</h2> <h2>How to Test</h2>
<ol> <ol>
@@ -66,18 +66,18 @@
<div class="test-section"> <div class="test-section">
<h2>📄 Sample Document Links</h2> <h2>📄 Sample Document Links</h2>
<p>These links point to sample documents that can be processed by DocuElevate:</p> <p>These links point to sample documents that can be processed by DocuElevate:</p>
<a href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" <a href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
class="test-link" target="_blank"> class="test-link" target="_blank">
📕 Sample PDF Document (dummy.pdf) 📕 Sample PDF Document (dummy.pdf)
</a> </a>
<a href="https://file-examples.com/storage/fe783c0bd90d0e9c119e536/2017/10/file-sample_150kB.pdf" <a href="https://file-examples.com/storage/fe783c0bd90d0e9c119e536/2017/10/file-sample_150kB.pdf"
class="test-link" target="_blank"> class="test-link" target="_blank">
📗 Example PDF File (file-sample_150kB.pdf) 📗 Example PDF File (file-sample_150kB.pdf)
</a> </a>
<a href="https://www.learningcontainer.com/wp-content/uploads/2019/09/sample-pdf-file.pdf" <a href="https://www.learningcontainer.com/wp-content/uploads/2019/09/sample-pdf-file.pdf"
class="test-link" target="_blank"> class="test-link" target="_blank">
📘 Learning Container Sample PDF 📘 Learning Container Sample PDF
</a> </a>
@@ -86,13 +86,13 @@
<div class="test-section"> <div class="test-section">
<h2>🖼️ Sample Image Links</h2> <h2>🖼️ Sample Image Links</h2>
<p>These links point to sample images that can be processed:</p> <p>These links point to sample images that can be processed:</p>
<a href="https://via.placeholder.com/800x600.png" <a href="https://via.placeholder.com/800x600.png"
class="test-link" target="_blank"> class="test-link" target="_blank">
🖼️ Placeholder Image (PNG, 800x600) 🖼️ Placeholder Image (PNG, 800x600)
</a> </a>
<a href="https://via.placeholder.com/1024x768.jpg" <a href="https://via.placeholder.com/1024x768.jpg"
class="test-link" target="_blank"> class="test-link" target="_blank">
📷 Placeholder Image (JPG, 1024x768) 📷 Placeholder Image (JPG, 1024x768)
</a> </a>
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -71,16 +71,16 @@ def make_api_request(url, max_retries=3):
"""Make API request with rate limit handling.""" """Make API request with rate limit handling."""
for attempt in range(max_retries): for attempt in range(max_retries):
response = requests.get(url) response = requests.get(url)
if response.status_code == 429: if response.status_code == 429:
# Rate limit exceeded # Rate limit exceeded
retry_after = int(response.headers.get('Retry-After', 60)) retry_after = int(response.headers.get('Retry-After', 60))
print(f"Rate limit exceeded. Retrying after {retry_after} seconds...") print(f"Rate limit exceeded. Retrying after {retry_after} seconds...")
time.sleep(retry_after) time.sleep(retry_after)
continue continue
return response return response
raise Exception("Max retries exceeded") raise Exception("Max retries exceeded")
``` ```
@@ -203,7 +203,7 @@ The DocuElevate browser extension uses this endpoint to send files directly from
Upload one or more files from your computer for processing. Upload one or more files from your computer for processing.
**Request**: **Request**:
- Multipart form data with file(s) - Multipart form data with file(s)
**Response**: **Response**:
+1 -1
View File
@@ -79,7 +79,7 @@ For larger deployments or when you need more advanced authentication features, O
### 1. Create an Application in Authentik ### 1. Create an Application in Authentik
1. Log in to your Authentik admin interface 1. Log in to your Authentik admin interface
2. Navigate to "Applications" > "Applications" 2. Navigate to "Applications" > "Applications"
3. Click "Create" 3. Click "Create"
4. Fill in the following details: 4. Fill in the following details:
- **Name**: DocuElevate - **Name**: DocuElevate
+2 -2
View File
@@ -243,7 +243,7 @@ docker build -t docuelevate .
**Cause:** Building outside of a Git repository **Cause:** Building outside of a Git repository
**Solution:** **Solution:**
- Clone the repository properly with `.git` directory - Clone the repository properly with `.git` directory
- Or set `GIT_COMMIT_SHA` environment variable - Or set `GIT_COMMIT_SHA` environment variable
@@ -261,7 +261,7 @@ docker build --no-cache -t docuelevate .
**Cause:** Files listed in `.dockerignore` **Cause:** Files listed in `.dockerignore`
**Solution:** **Solution:**
- Check `.dockerignore` doesn't block `BUILD_DATE`, `GIT_SHA`, or `RUNTIME_INFO` - Check `.dockerignore` doesn't block `BUILD_DATE`, `GIT_SHA`, or `RUNTIME_INFO`
- The `VERSION` file should always be committed to git - The `VERSION` file should always be committed to git
+1 -1
View File
@@ -352,7 +352,7 @@ PAPERLESS_CUSTOM_FIELDS_MAPPING='{"absender": "Sender", "empfaenger": "Recipient
### Dropbox ### Dropbox
| **Variable** | **Description** | | **Variable** | **Description** |
|-------------------------|--------------------------------------------------| |-------------------------|--------------------------------------------------|
| `DROPBOX_APP_KEY` | Dropbox API app key. | | `DROPBOX_APP_KEY` | Dropbox API app key. |
| `DROPBOX_APP_SECRET` | Dropbox API app secret. | | `DROPBOX_APP_SECRET` | Dropbox API app secret. |
+1 -1
View File
@@ -1,4 +1,4 @@
# DocuElevate Configuration # DocuElevate Configuration
This section contains detailed documentation about configuring DocuElevate for your environment. This section contains detailed documentation about configuring DocuElevate for your environment.
+4 -4
View File
@@ -106,17 +106,17 @@ Add security headers to your Nginx configuration:
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name docuelevate.example.com; server_name docuelevate.example.com;
# SSL configuration # SSL configuration
ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem; ssl_certificate_key /etc/nginx/ssl/key.pem;
# Security headers # Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
location / { location / {
proxy_pass http://localhost:8000; proxy_pass http://localhost:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -185,7 +185,7 @@ For production use, we recommend setting up a reverse proxy (like Nginx or Traef
server { server {
listen 80; listen 80;
server_name docuelevate.example.com; server_name docuelevate.example.com;
location / { location / {
proxy_pass http://localhost:8000; proxy_pass http://localhost:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
+5 -5
View File
@@ -4,7 +4,7 @@ This guide explains how to set up the Dropbox integration for DocuElevate.
## Required Configuration Parameters ## Required Configuration Parameters
| **Variable** | **Description** | | **Variable** | **Description** |
|-------------------------|--------------------------------------------------| |-------------------------|--------------------------------------------------|
| `DROPBOX_APP_KEY` | Dropbox API app key | | `DROPBOX_APP_KEY` | Dropbox API app key |
| `DROPBOX_APP_SECRET` | Dropbox API app secret | | `DROPBOX_APP_SECRET` | Dropbox API app secret |
@@ -64,11 +64,11 @@ The wizard handles all the token exchange steps and provides you with the exact
1. Go to the "OAuth 2" tab in your app settings 1. Go to the "OAuth 2" tab in your app settings
2. Add a redirect URI: `http://localhost` (this is for the authorization flow) 2. Add a redirect URI: `http://localhost` (this is for the authorization flow)
3. Generate an authorization URL with these instructions: 3. Generate an authorization URL with these instructions:
``` ```
https://www.dropbox.com/oauth2/authorize?client_id=YOUR_APP_KEY&response_type=code&token_access_type=offline https://www.dropbox.com/oauth2/authorize?client_id=YOUR_APP_KEY&response_type=code&token_access_type=offline
``` ```
4. Replace `YOUR_APP_KEY` with your app key 4. Replace `YOUR_APP_KEY` with your app key
5. Open this URL in your browser 5. Open this URL in your browser
6. Authorize the app when prompted 6. Authorize the app when prompted
@@ -78,7 +78,7 @@ The wizard handles all the token exchange steps and provides you with the exact
### 5. Exchange the Code for a Refresh Token ### 5. Exchange the Code for a Refresh Token
1. Use this curl command to exchange the code for tokens: 1. Use this curl command to exchange the code for tokens:
```bash ```bash
curl -X POST https://api.dropboxapi.com/oauth2/token \ curl -X POST https://api.dropboxapi.com/oauth2/token \
-d code=YOUR_AUTH_CODE \ -d code=YOUR_AUTH_CODE \
@@ -87,7 +87,7 @@ The wizard handles all the token exchange steps and provides you with the exact
-d client_secret=YOUR_APP_SECRET \ -d client_secret=YOUR_APP_SECRET \
-d redirect_uri=http://localhost -d redirect_uri=http://localhost
``` ```
2. From the response, copy the `refresh_token` value 2. From the response, copy the `refresh_token` value
### 6. Configure DocuElevate ### 6. Configure DocuElevate
-1
View File
@@ -108,4 +108,3 @@ Text extraction logs to check for `extracted_text`:
- `process_with_azure_document_intelligence` - Azure OCR processing - `process_with_azure_document_intelligence` - Azure OCR processing
Both may contain extracted text in the `detail` field when `status = 'success'`. Both may contain extracted text in the `detail` field when `status = 'success'`.
+3 -3
View File
@@ -215,9 +215,9 @@ for step in ["hash_file", "create_file_record", "check_text", ...]:
# Start # Start
update_step_status(db, file.id, step, "in_progress", started_at=now()) update_step_status(db, file.id, step, "in_progress", started_at=now())
log_event(db, file.id, step, "in_progress", "Starting...") log_event(db, file.id, step, "in_progress", "Starting...")
# Do work... # Do work...
# Complete # Complete
update_step_status(db, file.id, step, "success", completed_at=now()) update_step_status(db, file.id, step, "success", completed_at=now())
log_event(db, file.id, step, "success", "Completed successfully") log_event(db, file.id, step, "success", "Completed successfully")
@@ -349,6 +349,6 @@ For deploying to existing system:
- [ ] Run migration utility: `migrate_all_files(db, dry_run=True)` to test - [ ] Run migration utility: `migrate_all_files(db, dry_run=True)` to test
- [ ] Run actual migration: `migrate_all_files(db, dry_run=False)` - [ ] Run actual migration: `migrate_all_files(db, dry_run=False)`
- [ ] Verify: Check a few files with `verify_migration()` - [ ] Verify: Check a few files with `verify_migration()`
- [ ] Update workers to call `update_step_status()` - [ ] Update workers to call `update_step_status()`
- [ ] Update file creation to call `initialize_file_steps()` - [ ] Update file creation to call `initialize_file_steps()`
- [ ] Monitor dashboard for correct status display - [ ] Monitor dashboard for correct status display
+35 -35
View File
@@ -16,19 +16,19 @@ on: [push, pull_request]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run OAuth tests (mock) - name: Run OAuth tests (mock)
run: | run: |
pytest tests/test_oauth_integration_flows.py -v pytest tests/test_oauth_integration_flows.py -v
@@ -48,19 +48,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run if secrets are available (not on external PRs) # Only run if secrets are available (not on external PRs)
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run OAuth tests (real) - name: Run OAuth tests (real)
env: env:
AUTHENTIK_CLIENT_ID: ${{ secrets.AUTHENTIK_CLIENT_ID }} AUTHENTIK_CLIENT_ID: ${{ secrets.AUTHENTIK_CLIENT_ID }}
@@ -84,43 +84,43 @@ jobs:
test-mock-oauth: test-mock-oauth:
name: OAuth Tests (Mock) name: OAuth Tests (Mock)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run mock OAuth tests - name: Run mock OAuth tests
run: | run: |
pytest tests/test_oauth_integration_flows.py \ pytest tests/test_oauth_integration_flows.py \
-v \ -v \
-m "not requires_external" -m "not requires_external"
test-real-oauth: test-real-oauth:
name: OAuth Tests (Real - Internal Only) name: OAuth Tests (Real - Internal Only)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run on internal commits where secrets are available # Only run on internal commits where secrets are available
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run real OAuth tests - name: Run real OAuth tests
env: env:
AUTHENTIK_CLIENT_ID: ${{ secrets.AUTHENTIK_CLIENT_ID }} AUTHENTIK_CLIENT_ID: ${{ secrets.AUTHENTIK_CLIENT_ID }}
@@ -157,7 +157,7 @@ on: [push, pull_request]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
mock-oauth: mock-oauth:
image: ghcr.io/navikt/mock-oauth2-server:2.1.1 image: ghcr.io/navikt/mock-oauth2-server:2.1.1
@@ -168,24 +168,24 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Configure OAuth to use service - name: Configure OAuth to use service
run: | run: |
export OAUTH_MOCK_URL=http://localhost:8080 export OAUTH_MOCK_URL=http://localhost:8080
export USE_MOCK_OAUTH=true export USE_MOCK_OAUTH=true
- name: Run tests - name: Run tests
run: | run: |
pytest tests/test_oauth_integration_flows.py -v pytest tests/test_oauth_integration_flows.py -v
@@ -255,7 +255,7 @@ Add this step to debug mock OAuth server issues:
**Cause**: Real OAuth credentials not configured or not accessible. **Cause**: Real OAuth credentials not configured or not accessible.
**Solution**: **Solution**:
- For local dev: Use mock mode (default) - For local dev: Use mock mode (default)
- For CI: Add secrets to GitHub repository settings - For CI: Add secrets to GitHub repository settings
- Check secret availability: `if github.event_name == 'push'` - Check secret availability: `if github.event_name == 'push'`
@@ -291,20 +291,20 @@ on: [push, pull_request]
jobs: jobs:
# Fast mock OAuth tests (always run) # Fast mock OAuth tests (always run)
mock-oauth-tests: mock-oauth-tests:
name: OAuth Tests (Mock) name: OAuth Tests (Mock)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements-dev.txt run: pip install -r requirements-dev.txt
- name: Run mock OAuth tests - name: Run mock OAuth tests
run: | run: |
pytest tests/test_oauth_integration_flows.py \ pytest tests/test_oauth_integration_flows.py \
@@ -312,7 +312,7 @@ jobs:
-m "not requires_external" \ -m "not requires_external" \
--cov=app.auth \ --cov=app.auth \
--cov-report=term-missing --cov-report=term-missing
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v3
with: with:
@@ -324,18 +324,18 @@ jobs:
name: OAuth Tests (Real - Internal) name: OAuth Tests (Real - Internal)
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements-dev.txt run: pip install -r requirements-dev.txt
- name: Run real OAuth tests - name: Run real OAuth tests
env: env:
AUTHENTIK_CLIENT_ID: ${{ secrets.AUTHENTIK_CLIENT_ID }} AUTHENTIK_CLIENT_ID: ${{ secrets.AUTHENTIK_CLIENT_ID }}
@@ -345,7 +345,7 @@ jobs:
pytest tests/test_oauth_integration_flows.py \ pytest tests/test_oauth_integration_flows.py \
-v \ -v \
-m requires_external -m requires_external
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v3
with: with:
+5 -5
View File
@@ -138,7 +138,7 @@ If you're using a work/school account provided by your organization:
### 2. Configuration based on use case ### 2. Configuration based on use case
**Option A: Access your own OneDrive (Interactive Login)** **Option A: Access your own OneDrive (Interactive Login)**
This option requires a refresh token: This option requires a refresh token:
1. Use the auth wizard with your tenant ID, or 1. Use the auth wizard with your tenant ID, or
2. Follow the same manual steps as for personal accounts, but use your work email to sign in 2. Follow the same manual steps as for personal accounts, but use your work email to sign in
@@ -146,7 +146,7 @@ This option requires a refresh token:
4. Set the refresh token you receive as `ONEDRIVE_REFRESH_TOKEN` 4. Set the refresh token you receive as `ONEDRIVE_REFRESH_TOKEN`
**Option B: Access OneDrive as a system service (App-only access)** **Option B: Access OneDrive as a system service (App-only access)**
This option is for service accounts or automated systems with no user interaction: This option is for service accounts or automated systems with no user interaction:
1. In API permissions, add "Application permissions" instead of "Delegated permissions" 1. In API permissions, add "Application permissions" instead of "Delegated permissions"
2. Add `Files.ReadWrite.All` permission under "Application permissions" 2. Add `Files.ReadWrite.All` permission under "Application permissions"
@@ -158,15 +158,15 @@ This option is for service accounts or automated systems with no user interactio
If you encounter errors during authentication: If you encounter errors during authentication:
1. **Check account permissions**: 1. **Check account permissions**:
- Ensure your Microsoft account has the necessary permissions to grant access - Ensure your Microsoft account has the necessary permissions to grant access
- For corporate accounts, check if your admin has restricted third-party app access - For corporate accounts, check if your admin has restricted third-party app access
2. **Permission errors**: 2. **Permission errors**:
- Verify the app registration has the correct API permissions - Verify the app registration has the correct API permissions
- For corporate accounts, ensure an admin has consented to the permissions - For corporate accounts, ensure an admin has consented to the permissions
3. **Refresh token expired**: 3. **Refresh token expired**:
- If uploads stop working, you can generate a new refresh token using the auth wizard - If uploads stop working, you can generate a new refresh token using the auth wizard
- Click on "Refresh Token" in the OneDrive setup page - Click on "Refresh Token" in the OneDrive setup page
+1 -1
View File
@@ -271,7 +271,7 @@ from locust import HttpUser, task, between
class APIUser(HttpUser): class APIUser(HttpUser):
wait_time = between(0.1, 0.5) wait_time = between(0.1, 0.5)
@task @task
def get_files(self): def get_files(self):
self.client.get("/api/files") self.client.get("/api/files")
+3 -3
View File
@@ -93,7 +93,7 @@ The current value displayed is **always** the effective value after applying pre
6. Successfully saved settings will show a 🟢 DB badge 6. Successfully saved settings will show a 🟢 DB badge
7. If any changed setting requires a restart, you'll be notified 7. If any changed setting requires a restart, you'll be notified
**Important**: **Important**:
- You don't need to fill all fields - only change what you want to override - You don't need to fill all fields - only change what you want to override
- Saving a setting to the database makes it override environment variables - Saving a setting to the database makes it override environment variables
- Empty fields are ignored (won't clear existing values) - Empty fields are ignored (won't clear existing values)
@@ -196,7 +196,7 @@ Settings are stored in the `application_settings` table with:
### Can't Access Settings Page ### Can't Access Settings Page
- **Check authentication**: Make sure you're logged in - **Check authentication**: Make sure you're logged in
- **Check admin status**: - **Check admin status**:
- Local auth: Verify `ADMIN_USERNAME` and `ADMIN_PASSWORD` are correct - Local auth: Verify `ADMIN_USERNAME` and `ADMIN_PASSWORD` are correct
- OAuth: Verify your user is in the admin group (configurable via `ADMIN_GROUP_NAME`) - OAuth: Verify your user is in the admin group (configurable via `ADMIN_GROUP_NAME`)
- **Check logs**: Look for "Non-admin user attempted to access settings page" messages - **Check logs**: Look for "Non-admin user attempted to access settings page" messages
@@ -243,5 +243,5 @@ python3 test_integration.py
## Related Documentation ## Related Documentation
- [Configuration Guide](./ConfigurationGuide.md) - Environment variable reference - [Configuration Guide](./ConfigurationGuide.md) - Environment variable reference
- [Deployment Guide](./DeploymentGuide.md) - Production deployment - [Deployment Guide](./DeploymentGuide.md) - Production deployment
- [API Documentation](./API.md) - Full API reference - [API Documentation](./API.md) - Full API reference
+1 -1
View File
@@ -223,7 +223,7 @@ curl -X POST "http://localhost:8000/api/files/123/reprocess-with-cloud-ocr" \
```python ```python
class FileRecord(Base): class FileRecord(Base):
__tablename__ = "files" __tablename__ = "files"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
filehash = Column(String, unique=True, nullable=False) filehash = Column(String, unique=True, nullable=False)
original_filename = Column(String) # User's original name original_filename = Column(String) # User's original name
+4 -4
View File
@@ -1,7 +1,7 @@
# Repository Analysis & Improvement Summary # Repository Analysis & Improvement Summary
**Date:** 2026-02-06 **Date:** 2026-02-06
**Repository:** christianlouis/DocuElevate **Repository:** christianlouis/DocuElevate
**Current Version:** v0.5.0 **Current Version:** v0.5.0
## Executive Summary ## Executive Summary
@@ -343,6 +343,6 @@ httpx>=0.26.0
--- ---
**Prepared by:** GitHub Copilot Agent **Prepared by:** GitHub Copilot Agent
**Review Status:** Ready for maintainer review **Review Status:** Ready for maintainer review
**Recommended Action:** Merge and continue with TODO.md priorities **Recommended Action:** Merge and continue with TODO.md priorities
+1 -1
View File
@@ -61,7 +61,7 @@ Created comprehensive unit tests to verify:
1. **Test 1**: Original filename is preserved when parameter is provided 1. **Test 1**: Original filename is preserved when parameter is provided
- Uploads a file with UUID-based path but provides original filename "Apostille Sverige.pdf" - Uploads a file with UUID-based path but provides original filename "Apostille Sverige.pdf"
- Verifies the database stores the original filename, not the UUID-based path - Verifies the database stores the original filename, not the UUID-based path
2. **Test 2**: Backward compatibility is maintained 2. **Test 2**: Backward compatibility is maintained
- Calls `process_document` without the optional parameter - Calls `process_document` without the optional parameter
- Verifies it falls back to extracting filename from path - Verifies it falls back to extracting filename from path
+1 -1
View File
@@ -170,7 +170,7 @@ Phase 1 (Current - MVP):
Settings: DB + ENV + DEFAULT Settings: DB + ENV + DEFAULT
Encryption: Fernet (app-level) Encryption: Fernet (app-level)
UI: Custom settings page UI: Custom settings page
Phase 2 (Production - Optional): Phase 2 (Production - Optional):
Settings: DB + ENV + DEFAULT (keep) Settings: DB + ENV + DEFAULT (keep)
Secrets: HashiCorp Vault (add) Secrets: HashiCorp Vault (add)
+1 -1
View File
@@ -155,7 +155,7 @@
**Status: 100% COMPLETE (Code Implementation)** **Status: 100% COMPLETE (Code Implementation)**
✅ Core settings functionality: 100% complete ✅ Core settings functionality: 100% complete
✅ Encryption implementation: 100% complete ✅ Encryption implementation: 100% complete
✅ Setup wizard: 100% complete ✅ Setup wizard: 100% complete
⚠️ Testing: Manual testing recommended ⚠️ Testing: Manual testing recommended
⚠️ Documentation: Enhancement recommended ⚠️ Documentation: Enhancement recommended
+1 -1
View File
@@ -121,7 +121,7 @@ Created comprehensive `docs/SettingsManagement.md` covering:
# Non-admin users # Non-admin users
/settings @require_login @require_admin_access Redirect to / /settings @require_login @require_admin_access Redirect to /
# Admin users # Admin users
/settings @require_login @require_admin_access Settings page renders /settings @require_login @require_admin_access Settings page renders
``` ```
@@ -10,11 +10,11 @@ A comprehensive security audit was conducted on all file path operations in Docu
### 1. Critical: Path Traversal via GPT Metadata Filename ### 1. Critical: Path Traversal via GPT Metadata Filename
**Severity:** CRITICAL **Severity:** CRITICAL
**Location:** `app/tasks/embed_metadata_into_pdf.py` (line 144) **Location:** `app/tasks/embed_metadata_into_pdf.py` (line 144)
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Description:** **Description:**
The `metadata["filename"]` field extracted by GPT was used directly in file path construction without sanitization. A malicious document could be crafted to make GPT return metadata containing path traversal sequences (e.g., `../../etc/passwd`), allowing file writes outside the intended directory. The `metadata["filename"]` field extracted by GPT was used directly in file path construction without sanitization. A malicious document could be crafted to make GPT return metadata containing path traversal sequences (e.g., `../../etc/passwd`), allowing file writes outside the intended directory.
**Attack Scenario:** **Attack Scenario:**
@@ -39,11 +39,11 @@ final_path = os.path.join(processed_dir, suggested_filename) # Safe
### 2. Medium: Insecure String-Based Path Validation ### 2. Medium: Insecure String-Based Path Validation
**Severity:** MEDIUM **Severity:** MEDIUM
**Location:** `app/tasks/embed_metadata_into_pdf.py` (line 188) **Location:** `app/tasks/embed_metadata_into_pdf.py` (line 188)
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Description:** **Description:**
Used insecure string-based `startswith()` check to validate file paths before deletion. This approach is vulnerable to: Used insecure string-based `startswith()` check to validate file paths before deletion. This approach is vulnerable to:
- Partial directory name matches - Partial directory name matches
- Symlink attacks (symlinks not resolved) - Symlink attacks (symlinks not resolved)
@@ -78,11 +78,11 @@ if original_file_path.is_relative_to(workdir_tmp_resolved):
### 3. Medium: Insufficient GPT Filename Validation ### 3. Medium: Insufficient GPT Filename Validation
**Severity:** MEDIUM **Severity:** MEDIUM
**Location:** `app/tasks/extract_metadata_with_gpt.py` **Location:** `app/tasks/extract_metadata_with_gpt.py`
**Status:** ✅ FIXED **Status:** ✅ FIXED
**Description:** **Description:**
While the GPT prompt requested specific filename format, there was no enforcement. GPT could return filenames with path separators or traversal patterns. While the GPT prompt requested specific filename format, there was no enforcement. GPT could return filenames with path separators or traversal patterns.
**Fix Applied:** **Fix Applied:**
@@ -221,7 +221,7 @@ All identified path traversal vulnerabilities have been successfully remediated
--- ---
**Audit Date:** February 10, 2026 **Audit Date:** February 10, 2026
**Auditor:** GitHub Copilot Agent **Auditor:** GitHub Copilot Agent
**Scope:** All Python file path operations **Scope:** All Python file path operations
**Next Review:** Recommended within 6 months or after significant file handling changes **Next Review:** Recommended within 6 months or after significant file handling changes
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M96 128a128 128 0 1 0 256 0A128 128 0 1 0 96 128zm94.5 200.2l18.6 31L175.8 483.1l-36-146.9c-2-8.1-9.8-13.4-17.9-11.3C51.9 342.4 0 405.8 0 481.3c0 17 13.8 30.7 30.7 30.7l131.7 0c0 0 0 0 .1 0l5.5 0 112 0 5.5 0c0 0 0 0 .1 0l131.7 0c17 0 30.7-13.8 30.7-30.7c0-75.5-51.9-138.9-121.9-156.4c-8.1-2-15.9 3.3-17.9 11.3l-36 146.9L238.9 359.2l18.6-31c6.4-10.7-1.3-24.2-13.7-24.2L224 304l-19.7 0c-12.4 0-20.1 13.6-13.7 24.2z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M96 128a128 128 0 1 0 256 0A128 128 0 1 0 96 128zm94.5 200.2l18.6 31L175.8 483.1l-36-146.9c-2-8.1-9.8-13.4-17.9-11.3C51.9 342.4 0 405.8 0 481.3c0 17 13.8 30.7 30.7 30.7l131.7 0c0 0 0 0 .1 0l5.5 0 112 0 5.5 0c0 0 0 0 .1 0l131.7 0c17 0 30.7-13.8 30.7-30.7c0-75.5-51.9-138.9-121.9-156.4c-8.1-2-15.9 3.3-17.9 11.3l-36 146.9L238.9 359.2l18.6-31c6.4-10.7-1.3-24.2-13.7-24.2L224 304l-19.7 0c-12.4 0-20.1 13.6-13.7 24.2z"/></svg>

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 644 B

+8 -8
View File
@@ -7,15 +7,15 @@
const response = await fetch('/api/auth/whoami'); const response = await fetch('/api/auth/whoami');
const data = await response.json(); const data = await response.json();
const authSection = document.getElementById("authSection"); const authSection = document.getElementById("authSection");
const mobileAuthSection = document.getElementById("mobileAuthSection"); const mobileAuthSection = document.getElementById("mobileAuthSection");
// If we have an email, user is authenticated (the whoami endpoint would have thrown 401 otherwise) // If we have an email, user is authenticated (the whoami endpoint would have thrown 401 otherwise)
if (data.email) { if (data.email) {
// Get the display name (prefer name, fall back to preferred_username, then email) // Get the display name (prefer name, fall back to preferred_username, then email)
const displayName = data.name || data.preferred_username || data.email; const displayName = data.name || data.preferred_username || data.email;
// User is logged in // User is logged in
let authHTML = ` let authHTML = `
<div class="flex items-center"> <div class="flex items-center">
@@ -26,11 +26,11 @@
</a> </a>
</div> </div>
`; `;
if (authSection) { if (authSection) {
authSection.innerHTML = authHTML; authSection.innerHTML = authHTML;
} }
if (mobileAuthSection) { if (mobileAuthSection) {
mobileAuthSection.innerHTML = ` mobileAuthSection.innerHTML = `
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -49,7 +49,7 @@
if (authSection) { if (authSection) {
authSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`; authSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
} }
if (mobileAuthSection) { if (mobileAuthSection) {
mobileAuthSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`; mobileAuthSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
} }
@@ -59,11 +59,11 @@
// Fallback if whoami endpoint fails // Fallback if whoami endpoint fails
const authSection = document.getElementById("authSection"); const authSection = document.getElementById("authSection");
const mobileAuthSection = document.getElementById("mobileAuthSection"); const mobileAuthSection = document.getElementById("mobileAuthSection");
if (authSection) { if (authSection) {
authSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`; authSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
} }
if (mobileAuthSection) { if (mobileAuthSection) {
mobileAuthSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`; mobileAuthSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
} }
+1 -1
View File
@@ -7,6 +7,6 @@
link.rel = 'stylesheet'; link.rel = 'stylesheet';
link.href = '/static/fontawesome/css/all.min.css'; link.href = '/static/fontawesome/css/all.min.css';
document.head.appendChild(link); document.head.appendChild(link);
console.log('Font Awesome loaded locally'); console.log('Font Awesome loaded locally');
})(); })();
+31 -31
View File
@@ -8,30 +8,30 @@ const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
const ACCEPTED_TYPES = { const ACCEPTED_TYPES = {
// PDF files // PDF files
'application/pdf': true, 'application/pdf': true,
// Image formats // Image formats
'image/jpeg': true, 'image/jpg': true, 'image/png': true, 'image/jpeg': true, 'image/jpg': true, 'image/png': true,
'image/gif': true, 'image/bmp': true, 'image/tiff': true, 'image/gif': true, 'image/bmp': true, 'image/tiff': true,
'image/webp': true, 'image/svg+xml': true, 'image/webp': true, 'image/svg+xml': true,
// Office document formats - Word // Office document formats - Word
'application/msword': true, 'application/msword': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': true, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.template': true, 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': true,
'application/vnd.ms-word.document.macroEnabled.12': true, 'application/vnd.ms-word.document.macroEnabled.12': true,
// Excel // Excel
'application/vnd.ms-excel': true, 'application/vnd.ms-excel': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.template': true, 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': true,
'application/vnd.ms-excel.sheet.macroEnabled.12': true, 'application/vnd.ms-excel.sheet.macroEnabled.12': true,
// PowerPoint // PowerPoint
'application/vnd.ms-powerpoint': true, 'application/vnd.ms-powerpoint': true,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': true, 'application/vnd.openxmlformats-officedocument.presentationml.presentation': true,
'application/vnd.openxmlformats-officedocument.presentationml.template': true, 'application/vnd.openxmlformats-officedocument.presentationml.template': true,
'application/vnd.openxmlformats-officedocument.presentationml.slideshow': true, 'application/vnd.openxmlformats-officedocument.presentationml.slideshow': true,
// Other common formats // Other common formats
'text/plain': true, 'text/plain': true,
'text/csv': true, 'text/csv': true,
@@ -57,16 +57,16 @@ const ACCEPTED_EXTENSIONS = [
*/ */
function processFiles(files, progressContainer, statusMessage) { function processFiles(files, progressContainer, statusMessage) {
if (files.length === 0) return; if (files.length === 0) return;
if (statusMessage) { if (statusMessage) {
statusMessage.textContent = `Processing ${files.length} file(s)...`; statusMessage.textContent = `Processing ${files.length} file(s)...`;
} }
// Clear previous upload progress // Clear previous upload progress
if (progressContainer) { if (progressContainer) {
progressContainer.innerHTML = ""; progressContainer.innerHTML = "";
} }
// Process each file // Process each file
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
@@ -94,32 +94,32 @@ function validateAndUpload(file, progressContainer, statusMessage) {
</div> </div>
<div class="file-status text-xs text-gray-600 mt-1">Validating...</div> <div class="file-status text-xs text-gray-600 mt-1">Validating...</div>
`; `;
if (progressContainer) { if (progressContainer) {
progressContainer.appendChild(fileProgress); progressContainer.appendChild(fileProgress);
} }
const progressBar = fileProgress.querySelector(".file-progress-bar"); const progressBar = fileProgress.querySelector(".file-progress-bar");
const statusEl = fileProgress.querySelector(".file-status"); const statusEl = fileProgress.querySelector(".file-status");
// Validate file type by checking both MIME type and extension // Validate file type by checking both MIME type and extension
const isValidMimeType = ACCEPTED_TYPES[file.type] || false; const isValidMimeType = ACCEPTED_TYPES[file.type] || false;
const fileExtension = '.' + file.name.split('.').pop().toLowerCase(); const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
const isValidExtension = ACCEPTED_EXTENSIONS.includes(fileExtension); const isValidExtension = ACCEPTED_EXTENSIONS.includes(fileExtension);
if (!isValidMimeType && !isValidExtension) { if (!isValidMimeType && !isValidExtension) {
statusEl.textContent = `Error: ${file.name} - Unsupported file type`; statusEl.textContent = `Error: ${file.name} - Unsupported file type`;
statusEl.className = "text-xs text-red-500 mt-1"; statusEl.className = "text-xs text-red-500 mt-1";
return; return;
} }
// Validate file size // Validate file size
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
statusEl.textContent = `Error: ${file.name} - File size exceeds 500MB limit`; statusEl.textContent = `Error: ${file.name} - File size exceeds 500MB limit`;
statusEl.className = "text-xs text-red-500 mt-1"; statusEl.className = "text-xs text-red-500 mt-1";
return; return;
} }
// Upload the file // Upload the file
uploadFile(file, progressBar, statusEl, statusMessage); uploadFile(file, progressBar, statusEl, statusMessage);
} }
@@ -136,10 +136,10 @@ async function uploadFile(file, progressBar, statusEl, statusMessage) {
try { try {
let formData = new FormData(); let formData = new FormData();
formData.append("file", file); formData.append("file", file);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/ui-upload", true); xhr.open("POST", "/api/ui-upload", true);
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) { if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100; const percentComplete = (e.loaded / e.total) * 100;
@@ -147,7 +147,7 @@ async function uploadFile(file, progressBar, statusEl, statusMessage) {
statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`; statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`;
} }
}; };
xhr.onload = function() { xhr.onload = function() {
if (xhr.status === 200) { if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText); const result = JSON.parse(xhr.responseText);
@@ -160,13 +160,13 @@ async function uploadFile(file, progressBar, statusEl, statusMessage) {
throw new Error(`Upload failed with status ${xhr.status}`); throw new Error(`Upload failed with status ${xhr.status}`);
} }
}; };
xhr.onerror = function() { xhr.onerror = function() {
throw new Error("Network error occurred"); throw new Error("Network error occurred");
}; };
xhr.send(formData); xhr.send(formData);
} catch (err) { } catch (err) {
statusEl.textContent = `Error: ${err.message}`; statusEl.textContent = `Error: ${err.message}`;
statusEl.className = "text-xs text-red-500 mt-1"; statusEl.className = "text-xs text-red-500 mt-1";
@@ -181,21 +181,21 @@ async function uploadFile(file, progressBar, statusEl, statusMessage) {
*/ */
function updateOverallStatus(statusMessage) { function updateOverallStatus(statusMessage) {
if (!statusMessage) return; if (!statusMessage) return;
// Count success/failure // Count success/failure
const fileStatuses = document.querySelectorAll('.file-status'); const fileStatuses = document.querySelectorAll('.file-status');
let completed = 0; let completed = 0;
let total = fileStatuses.length; let total = fileStatuses.length;
fileStatuses.forEach(status => { fileStatuses.forEach(status => {
if (status.textContent.includes('Success') || status.textContent.includes('Error')) { if (status.textContent.includes('Success') || status.textContent.includes('Error')) {
completed++; completed++;
} }
}); });
if (completed === total) { if (completed === total) {
statusMessage.textContent = `All uploads completed (${completed}/${total})`; statusMessage.textContent = `All uploads completed (${completed}/${total})`;
// Trigger a custom event when all uploads are complete // Trigger a custom event when all uploads are complete
const allUploadsComplete = new CustomEvent('allUploadsComplete', { const allUploadsComplete = new CustomEvent('allUploadsComplete', {
detail: { total: total, completed: completed } detail: { total: total, completed: completed }
@@ -231,38 +231,38 @@ function initDragAndDrop(element, progressContainer, statusMessage, options = {}
console.error("Element not found for drag-and-drop initialization"); console.error("Element not found for drag-and-drop initialization");
return; return;
} }
// Add event listeners for drag-and-drop // Add event listeners for drag-and-drop
element.addEventListener("dragover", (e) => { element.addEventListener("dragover", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.dropEffect = "copy"; e.dataTransfer.dropEffect = "copy";
// Add visual feedback // Add visual feedback
if (options.dragOverClass) { if (options.dragOverClass) {
element.classList.add(options.dragOverClass); element.classList.add(options.dragOverClass);
} }
}); });
element.addEventListener("dragleave", (e) => { element.addEventListener("dragleave", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Remove visual feedback // Remove visual feedback
if (options.dragOverClass) { if (options.dragOverClass) {
element.classList.remove(options.dragOverClass); element.classList.remove(options.dragOverClass);
} }
}); });
element.addEventListener("drop", (e) => { element.addEventListener("drop", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Remove visual feedback // Remove visual feedback
if (options.dragOverClass) { if (options.dragOverClass) {
element.classList.remove(options.dragOverClass); element.classList.remove(options.dragOverClass);
} }
if (e.dataTransfer.files.length) { if (e.dataTransfer.files.length) {
processFiles(e.dataTransfer.files, progressContainer, statusMessage); processFiles(e.dataTransfer.files, progressContainer, statusMessage);
} }
+10 -10
View File
@@ -55,7 +55,7 @@ modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be author's reputation will not be affected by problems that might be
introduced by others. introduced by others.
Finally, software patents pose a constant threat to the existence of Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a effectively restrict the users of a free program by obtaining a
@@ -111,7 +111,7 @@ modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The "work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must former contains code derived from the library, whereas the latter must
be combined with the library in order to run. be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
@@ -158,7 +158,7 @@ Library.
You may charge a fee for the physical act of transferring a copy, You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a and you may at your option offer warranty protection in exchange for a
fee. fee.
2. You may modify your copy or copies of the Library or any portion 2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1 distribute such modifications or work under the terms of Section 1
@@ -216,7 +216,7 @@ instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in that version instead if you wish.) Do not make any other change in
these notices. these notices.
Once this change is made in a given copy, it is irreversible for Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy. subsequent copies and derivative works made from that copy.
@@ -267,7 +267,7 @@ Library will still fall under Section 6.)
distribute the object code for the work under the terms of Section 6. distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6, Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself. whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or 6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work work containing portions of the Library, and distribute that work
@@ -329,7 +329,7 @@ restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you use both them and the Library together in an executable that you
distribute. distribute.
7. You may place library facilities that are a work based on the 7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined facilities not covered by this License, and distribute such a combined
@@ -370,7 +370,7 @@ subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein. restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with You are not responsible for enforcing compliance by third parties with
this License. this License.
11. If, as a consequence of a court judgment or allegation of patent 11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues), infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or conditions are imposed on you (whether by court order, agreement or
@@ -422,7 +422,7 @@ conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by license version number, you may choose any version ever published by
the Free Software Foundation. the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free 14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these, programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is write to the author to ask for permission. For software which is
@@ -456,7 +456,7 @@ SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES. DAMAGES.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest If you develop a new library, and you want it to be of the greatest
@@ -498,4 +498,4 @@ necessary. Here is a sample; alter the names:
<signature of Moe Ghoul>, 1 April 1990 <signature of Moe Ghoul>, 1 April 1990
Moe Ghoul, President of Vice Moe Ghoul, President of Vice
That's all there is to it! That's all there is to it!
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z"/></svg>

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 918 B

+1 -1
View File
@@ -15,4 +15,4 @@ body {
mask-repeat: no-repeat; mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%; -webkit-mask-size: 100% 100%;
mask-size: 100% 100%; mask-size: 100% 100%;
} }
+4 -4
View File
@@ -7,19 +7,19 @@
<p class="text-gray-700 mb-6 leading-relaxed"> <p class="text-gray-700 mb-6 leading-relaxed">
Welcome to <strong>DocuElevate</strong> your modern, intelligent solution for document processing! Welcome to <strong>DocuElevate</strong> your modern, intelligent solution for document processing!
We've built DocuElevate to completely transform the way you handle your documents from upload We've built DocuElevate to completely transform the way you handle your documents from upload
to extraction, from processing to storage. to extraction, from processing to storage.
</p> </p>
<!-- Our Story Section --> <!-- Our Story Section -->
<section class="bg-white shadow rounded p-6 mb-8"> <section class="bg-white shadow rounded p-6 mb-8">
<h2 class="text-2xl font-semibold mb-2">Our Story</h2> <h2 class="text-2xl font-semibold mb-2">Our Story</h2>
<p class="text-gray-600 mb-4"> <p class="text-gray-600 mb-4">
DocuElevate was created with one goal in mind: to simplify and streamline document management DocuElevate was created with one goal in mind: to simplify and streamline document management
for everyone, whether you're a small startup or a large enterprise. for everyone, whether you're a small startup or a large enterprise.
</p> </p>
<p class="text-gray-600"> <p class="text-gray-600">
We harness the power of OpenAI for metadata extraction and text refinement, integrate seamlessly We harness the power of OpenAI for metadata extraction and text refinement, integrate seamlessly
with Dropbox, Nextcloud, and Paperless NGX for storage and indexing, leverage Azure Document with Dropbox, Nextcloud, and Paperless NGX for storage and indexing, leverage Azure Document
Intelligence for OCR, and even use Gotenberg for file-to-PDF conversions. Intelligence for OCR, and even use Gotenberg for file-to-PDF conversions.
</p> </p>
</section> </section>
+6 -6
View File
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">Third-Party Software Attributions</h1> <h1 class="text-2xl font-bold mb-4">Third-Party Software Attributions</h1>
<div class="bg-white shadow-md rounded-lg p-6 mb-6"> <div class="bg-white shadow-md rounded-lg p-6 mb-6">
<p class="mb-4"> <p class="mb-4">
DocuElevate uses several open source libraries and tools. We are grateful to the DocuElevate uses several open source libraries and tools. We are grateful to the
@@ -21,11 +21,11 @@
</div> </div>
<div class="ml-3"> <div class="ml-3">
<p class="text-sm text-yellow-700"> <p class="text-sm text-yellow-700">
<strong>Special Attribution:</strong> This software includes Paramiko, which is licensed under LGPL. The source code for Paramiko is available at <strong>Special Attribution:</strong> This software includes Paramiko, which is licensed under LGPL. The source code for Paramiko is available at
<a href="https://github.com/paramiko/paramiko" class="font-medium underline text-yellow-700 hover:text-yellow-600"> <a href="https://github.com/paramiko/paramiko" class="font-medium underline text-yellow-700 hover:text-yellow-600">
https://github.com/paramiko/paramiko https://github.com/paramiko/paramiko
</a>. </a>.
A copy of the LGPL license can be found A copy of the LGPL license can be found
<a href="/static/licenses/lgpl.txt" class="font-medium underline text-yellow-700 hover:text-yellow-600"> <a href="/static/licenses/lgpl.txt" class="font-medium underline text-yellow-700 hover:text-yellow-600">
here here
</a>. </a>.
@@ -33,7 +33,7 @@
</div> </div>
</div> </div>
</div> </div>
<h2 class="text-xl font-semibold mb-2">Python Dependencies</h2> <h2 class="text-xl font-semibold mb-2">Python Dependencies</h2>
<ul class="list-disc pl-5 mb-4"> <ul class="list-disc pl-5 mb-4">
<li class="mb-2"> <li class="mb-2">
@@ -138,7 +138,7 @@
<a href="https://github.com/caronc/apprise" class="text-blue-600 hover:underline">https://github.com/caronc/apprise</a> <a href="https://github.com/caronc/apprise" class="text-blue-600 hover:underline">https://github.com/caronc/apprise</a>
</li> </li>
</ul> </ul>
<h2 class="text-xl font-semibold mb-2">Docker Images</h2> <h2 class="text-xl font-semibold mb-2">Docker Images</h2>
<ul class="list-disc pl-5 mb-4"> <ul class="list-disc pl-5 mb-4">
<li class="mb-2"> <li class="mb-2">
@@ -152,7 +152,7 @@
<a href="https://github.com/gotenberg/gotenberg" class="text-blue-600 hover:underline">https://github.com/gotenberg/gotenberg</a> <a href="https://github.com/gotenberg/gotenberg" class="text-blue-600 hover:underline">https://github.com/gotenberg/gotenberg</a>
</li> </li>
</ul> </ul>
<h2 class="text-xl font-semibold mb-2">Frontend Dependencies</h2> <h2 class="text-xl font-semibold mb-2">Frontend Dependencies</h2>
<ul class="list-disc pl-5 mb-4"> <ul class="list-disc pl-5 mb-4">
<li class="mb-2"> <li class="mb-2">
+21 -21
View File
@@ -13,8 +13,8 @@
<!-- Tailwind CSS and other CSS --> <!-- Tailwind CSS and other CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<!-- Font Awesome --> <!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" /> crossorigin="anonymous" referrerpolicy="no-referrer" />
{% endblock %} {% endblock %}
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
@@ -24,12 +24,12 @@
<!-- Global Nav --> <!-- Global Nav -->
<nav class="bg-white shadow"> <nav class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between h-16 items-center"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between h-16 items-center">
<!-- Brand + Icon --> <!-- Brand + Icon -->
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<a href="/" class="inline-flex items-center space-x-2"> <a href="/" class="inline-flex items-center space-x-2">
<!-- Icon --> <!-- Icon -->
<span <span
class="material-symbols-light--folder-managed-outline text-blue-500" class="material-symbols-light--folder-managed-outline text-blue-500"
style="width: 24px; height: 24px;" style="width: 24px; height: 24px;"
></span> ></span>
@@ -39,7 +39,7 @@
</span> </span>
</a> </a>
</div> </div>
<!-- Menu Items - using x-data for mobile menu toggle --> <!-- Menu Items - using x-data for mobile menu toggle -->
<div x-data="{ mobileMenuOpen: false }"> <div x-data="{ mobileMenuOpen: false }">
<div class="hidden md:flex space-x-4 items-center"> <div class="hidden md:flex space-x-4 items-center">
@@ -49,15 +49,15 @@
<a href="/status" class="text-gray-700 hover:text-gray-900">Status</a> <a href="/status" class="text-gray-700 hover:text-gray-900">Status</a>
<a href="/settings" class="text-gray-700 hover:text-gray-900">Settings</a> <a href="/settings" class="text-gray-700 hover:text-gray-900">Settings</a>
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a> <a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
<!-- Dynamic Auth Section --> <!-- Dynamic Auth Section -->
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div> <div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
</div> </div>
<!-- Mobile menu button --> <!-- Mobile menu button -->
<button <button
@click="mobileMenuOpen = !mobileMenuOpen" @click="mobileMenuOpen = !mobileMenuOpen"
type="button" type="button"
class="md:hidden inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500" class="md:hidden inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
:aria-expanded="mobileMenuOpen" :aria-expanded="mobileMenuOpen"
> >
@@ -66,10 +66,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
</button> </button>
<!-- Mobile menu, show/hide based on menu state --> <!-- Mobile menu, show/hide based on menu state -->
<div <div
x-show="mobileMenuOpen" x-show="mobileMenuOpen"
x-transition:enter="transition ease-out duration-100 transform" x-transition:enter="transition ease-out duration-100 transform"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
@@ -85,7 +85,7 @@
<a href="/status" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Status</a> <a href="/status" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Status</a>
<a href="/settings" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Settings</a> <a href="/settings" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Settings</a>
<a href="/about" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">About</a> <a href="/about" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">About</a>
<!-- Mobile Auth Section --> <!-- Mobile Auth Section -->
<div id="mobileAuthSection" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"> <div id="mobileAuthSection" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<!-- Will be populated by JS --> <!-- Will be populated by JS -->
@@ -104,13 +104,13 @@
<!-- Footer --> <!-- Footer -->
<footer class="bg-white shadow"> <footer class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600"> <div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600">
DocuElevate 2025 - DocuElevate 2025 -
<a href="/privacy" class="text-blue-500 hover:underline">Privacy</a> - <a href="/privacy" class="text-blue-500 hover:underline">Privacy</a> -
<a href="/imprint" class="text-blue-500 hover:underline">Imprint</a> - <a href="/imprint" class="text-blue-500 hover:underline">Imprint</a> -
<a href="/terms" class="text-blue-500 hover:underline">Terms</a> - <a href="/terms" class="text-blue-500 hover:underline">Terms</a> -
<a href="/cookies" class="text-blue-500 hover:underline">Cookies</a> - <a href="/cookies" class="text-blue-500 hover:underline">Cookies</a> -
<a href="/license" class="text-blue-500 hover:underline">License</a> - <a href="/license" class="text-blue-500 hover:underline">License</a> -
<a href="/attribution" class="text-blue-500 hover:underline">Attributions</a> - <a href="/attribution" class="text-blue-500 hover:underline">Attributions</a> -
<span class="text-xs">Version {{ app_version|default(version, true) }}</span> <span class="text-xs">Version {{ app_version|default(version, true) }}</span>
</div> </div>
</footer> </footer>
+34 -34
View File
@@ -8,10 +8,10 @@
<p class="text-gray-600 mb-4"> <p class="text-gray-600 mb-4">
Configure the Dropbox integration for DocuElevate using our setup wizard. Configure the Dropbox integration for DocuElevate using our setup wizard.
</p> </p>
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert"> <div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<p class="font-bold">Current Status:</p> <p class="font-bold">Current Status:</p>
<p>Dropbox integration is <p>Dropbox integration is
{% if is_configured %} {% if is_configured %}
<span class="text-green-700 font-semibold">configured</span>. <span class="text-green-700 font-semibold">configured</span>.
{% else %} {% else %}
@@ -22,7 +22,7 @@
<p class="mt-2"><strong>Target folder:</strong> {{ folder_path }}</p> <p class="mt-2"><strong>Target folder:</strong> {{ folder_path }}</p>
{% endif %} {% endif %}
</div> </div>
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-4"> <div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -41,7 +41,7 @@
<div class="bg-white shadow-md rounded-lg p-6 mb-8"> <div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-2xl font-semibold mb-4">Quick Setup Guide</h2> <h2 class="text-2xl font-semibold mb-4">Quick Setup Guide</h2>
<div class="mb-6"> <div class="mb-6">
<h3 class="text-xl font-medium mb-4">Step 1: Create a Dropbox App</h3> <h3 class="text-xl font-medium mb-4">Step 1: Create a Dropbox App</h3>
<ol class="list-decimal ml-6 space-y-3"> <ol class="list-decimal ml-6 space-y-3">
@@ -80,30 +80,30 @@
<div class="bg-white shadow-md rounded-lg p-6 mb-8"> <div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-2xl font-semibold mb-4">Complete Setup with Wizard</h2> <h2 class="text-2xl font-semibold mb-4">Complete Setup with Wizard</h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="app-key" class="block text-sm font-medium text-gray-700">App Key</label> <label for="app-key" class="block text-sm font-medium text-gray-700">App Key</label>
<input type="text" id="app-key" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Dropbox app key" value="{{ app_key_value }}"> <input type="text" id="app-key" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Dropbox app key" value="{{ app_key_value }}">
</div> </div>
<div> <div>
<label for="app-secret" class="block text-sm font-medium text-gray-700">App Secret</label> <label for="app-secret" class="block text-sm font-medium text-gray-700">App Secret</label>
<input type="password" id="app-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Dropbox app secret" value="{{ app_secret_value }}"> <input type="password" id="app-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Dropbox app secret" value="{{ app_secret_value }}">
</div> </div>
<div> <div>
<label for="folder-path" class="block text-sm font-medium text-gray-700">Folder Path (Optional)</label> <label for="folder-path" class="block text-sm font-medium text-gray-700">Folder Path (Optional)</label>
<input type="text" id="folder-path" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="/Documents/Uploads" value="{{ folder_path }}"> <input type="text" id="folder-path" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="/Documents/Uploads" value="{{ folder_path }}">
<p class="text-xs text-gray-500 mt-1">Enter the folder path where files should be uploaded (e.g., /Documents/Uploads)</p> <p class="text-xs text-gray-500 mt-1">Enter the folder path where files should be uploaded (e.g., /Documents/Uploads)</p>
</div> </div>
<div> <div>
<button id="start-auth-flow" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <button id="start-auth-flow" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Start Authentication Flow Start Authentication Flow
</button> </button>
</div> </div>
<!-- Token validation and status --> <!-- Token validation and status -->
<div id="token-status" class="mt-6 {{ 'hidden' if not is_configured else '' }}"> <div id="token-status" class="mt-6 {{ 'hidden' if not is_configured else '' }}">
<div class="rounded-md {{ 'bg-green-50' if is_configured else 'bg-yellow-50' }} p-4"> <div class="rounded-md {{ 'bg-green-50' if is_configured else 'bg-yellow-50' }} p-4">
@@ -130,7 +130,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 flex space-x-3"> <div class="mt-4 flex space-x-3">
<button id="test-token" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <button id="test-token" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Test Token Test Token
@@ -139,25 +139,25 @@
Refresh Token Refresh Token
</button> </button>
</div> </div>
<!-- Configuration for Worker Nodes section --> <!-- Configuration for Worker Nodes section -->
<div class="mt-6 p-4 bg-gray-100 rounded-md"> <div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3> <h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3"> <p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes: Copy these environment variables to configure all worker nodes:
</p> </p>
<div class="relative"> <div class="relative">
<pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>DROPBOX_APP_KEY={{ app_key_value }} <pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>DROPBOX_APP_KEY={{ app_key_value }}
DROPBOX_APP_SECRET={{ app_secret_value|default('YOUR_APP_SECRET', true) }} DROPBOX_APP_SECRET={{ app_secret_value|default('YOUR_APP_SECRET', true) }}
DROPBOX_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }} DROPBOX_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }}
DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre> DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre>
<button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded"> <button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-2"> <p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration. Add these variables to your .env file or environment configuration.
</p> </p>
@@ -188,7 +188,7 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
Back to Status Back to Status
</a> </a>
</div> </div>
<!-- Result Modal --> <!-- Result Modal -->
<div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog"> <div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white"> <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white">
@@ -222,19 +222,19 @@ document.addEventListener('DOMContentLoaded', function() {
const refreshTokenBtn = document.getElementById('refresh-token-btn'); const refreshTokenBtn = document.getElementById('refresh-token-btn');
const tokenStatus = document.getElementById('token-status'); const tokenStatus = document.getElementById('token-status');
const appSecretInput = document.getElementById('app-secret'); const appSecretInput = document.getElementById('app-secret');
// Modal elements // Modal elements
const resultModal = document.getElementById('resultModal'); const resultModal = document.getElementById('resultModal');
const modalTitle = document.getElementById('modalTitle'); const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage'); const modalMessage = document.getElementById('modalMessage');
const modalIcon = document.getElementById('modalIcon'); const modalIcon = document.getElementById('modalIcon');
const modalClose = document.getElementById('modalClose'); const modalClose = document.getElementById('modalClose');
// Modal functions // Modal functions
function showModal(status, title, message) { function showModal(status, title, message) {
modalTitle.textContent = title; modalTitle.textContent = title;
modalMessage.textContent = message; modalMessage.textContent = message;
// Set the appropriate icon // Set the appropriate icon
if (status === 'success') { if (status === 'success') {
modalIcon.innerHTML = ` modalIcon.innerHTML = `
@@ -251,24 +251,24 @@ document.addEventListener('DOMContentLoaded', function() {
`; `;
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4'; modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4';
} }
resultModal.classList.remove('hidden'); resultModal.classList.remove('hidden');
} }
function hideModal() { function hideModal() {
resultModal.classList.add('hidden'); resultModal.classList.add('hidden');
} }
// Close modal when clicking the close button // Close modal when clicking the close button
modalClose.addEventListener('click', hideModal); modalClose.addEventListener('click', hideModal);
// Close modal when clicking outside of it // Close modal when clicking outside of it
resultModal.addEventListener('click', function(e) { resultModal.addEventListener('click', function(e) {
if (e.target === resultModal) { if (e.target === resultModal) {
hideModal(); hideModal();
} }
}); });
// Start Authentication Flow button click // Start Authentication Flow button click
startAuthFlowBtn.addEventListener('click', function() { startAuthFlowBtn.addEventListener('click', function() {
const appKey = document.getElementById('app-key').value.trim(); const appKey = document.getElementById('app-key').value.trim();
@@ -280,7 +280,7 @@ document.addEventListener('DOMContentLoaded', function() {
showModal('error', 'Validation Error', 'Please enter your App Key'); showModal('error', 'Validation Error', 'Please enter your App Key');
return; return;
} }
if (!appSecret) { if (!appSecret) {
showModal('error', 'Validation Error', 'Please enter your App Secret'); showModal('error', 'Validation Error', 'Please enter your App Secret');
return; return;
@@ -295,7 +295,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Generate the authorization URL // Generate the authorization URL
const authUrl = `https://www.dropbox.com/oauth2/authorize?client_id=${encodeURIComponent(appKey)}&response_type=code&token_access_type=offline&redirect_uri=${encodeURIComponent(redirectUri)}`; const authUrl = `https://www.dropbox.com/oauth2/authorize?client_id=${encodeURIComponent(appKey)}&response_type=code&token_access_type=offline&redirect_uri=${encodeURIComponent(redirectUri)}`;
// Redirect the user to the Dropbox login page // Redirect the user to the Dropbox login page
window.location.href = authUrl; window.location.href = authUrl;
}); });
@@ -305,7 +305,7 @@ document.addEventListener('DOMContentLoaded', function() {
testTokenBtn.addEventListener('click', function() { testTokenBtn.addEventListener('click', function() {
testTokenBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Testing...'; testTokenBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Testing...';
testTokenBtn.disabled = true; testTokenBtn.disabled = true;
fetch('/api/dropbox/test-token') fetch('/api/dropbox/test-token')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@@ -336,13 +336,13 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
} }
// Refresh Token button click // Refresh Token button click
if (refreshTokenBtn) { if (refreshTokenBtn) {
refreshTokenBtn.addEventListener('click', function() { refreshTokenBtn.addEventListener('click', function() {
showModal('info', 'Confirm', 'This will start a new authentication flow to obtain a fresh token from Dropbox. Continue?'); showModal('info', 'Confirm', 'This will start a new authentication flow to obtain a fresh token from Dropbox. Continue?');
modalClose.textContent = "Cancel"; modalClose.textContent = "Cancel";
// Add a confirm button // Add a confirm button
const confirmBtn = document.createElement('button'); const confirmBtn = document.createElement('button');
confirmBtn.className = 'ml-2 px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300'; confirmBtn.className = 'ml-2 px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300';
@@ -351,10 +351,10 @@ document.addEventListener('DOMContentLoaded', function() {
hideModal(); hideModal();
startAuthFlowBtn.click(); startAuthFlowBtn.click();
}); });
// Add to modal // Add to modal
modalClose.parentNode.appendChild(confirmBtn); modalClose.parentNode.appendChild(confirmBtn);
// Make sure to remove the confirm button when modal is closed // Make sure to remove the confirm button when modal is closed
const removeConfirmBtn = function() { const removeConfirmBtn = function() {
if (confirmBtn.parentNode) { if (confirmBtn.parentNode) {
@@ -363,11 +363,11 @@ document.addEventListener('DOMContentLoaded', function() {
modalClose.textContent = "Close"; modalClose.textContent = "Close";
modalClose.removeEventListener('click', removeConfirmBtn); modalClose.removeEventListener('click', removeConfirmBtn);
}; };
modalClose.addEventListener('click', removeConfirmBtn, { once: true }); modalClose.addEventListener('click', removeConfirmBtn, { once: true });
}); });
} }
// Copy Environment Variables Button // Copy Environment Variables Button
const copyEnvVarsBtn = document.getElementById('copy-env-vars'); const copyEnvVarsBtn = document.getElementById('copy-env-vars');
if (copyEnvVarsBtn) { if (copyEnvVarsBtn) {
@@ -389,14 +389,14 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
} }
// Try to retrieve app secret from session storage (if coming back from auth) // Try to retrieve app secret from session storage (if coming back from auth)
if (appSecretInput && !appSecretInput.value && sessionStorage.getItem('dropbox_app_secret')) { if (appSecretInput && !appSecretInput.value && sessionStorage.getItem('dropbox_app_secret')) {
appSecretInput.value = sessionStorage.getItem('dropbox_app_secret'); appSecretInput.value = sessionStorage.getItem('dropbox_app_secret');
// Clear it after use // Clear it after use
sessionStorage.removeItem('dropbox_app_secret'); sessionStorage.removeItem('dropbox_app_secret');
} }
// If token is not configured but we have an app key, show the token status section // If token is not configured but we have an app key, show the token status section
if (document.getElementById('app-key').value && !tokenStatus.classList.contains('hidden')) { if (document.getElementById('app-key').value && !tokenStatus.classList.contains('hidden')) {
tokenStatus.classList.remove('hidden'); tokenStatus.classList.remove('hidden');
+28 -28
View File
@@ -11,15 +11,15 @@
<h2 class="text-2xl font-bold mt-4">Processing Authorization</h2> <h2 class="text-2xl font-bold mt-4">Processing Authorization</h2>
<p class="text-gray-600 mt-2">Please wait while we complete the Dropbox authorization process...</p> <p class="text-gray-600 mt-2">Please wait while we complete the Dropbox authorization process...</p>
</div> </div>
<div class="flex justify-center my-6"> <div class="flex justify-center my-6">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div> </div>
<div id="processing-message" class="text-center text-gray-700"> <div id="processing-message" class="text-center text-gray-700">
<p>Exchanging authorization code for refresh token...</p> <p>Exchanging authorization code for refresh token...</p>
</div> </div>
<div id="error-container" class="hidden mt-6"> <div id="error-container" class="hidden mt-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4"> <div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex"> <div class="flex">
@@ -42,7 +42,7 @@
</a> </a>
</div> </div>
</div> </div>
<div id="success-container" class="hidden mt-6"> <div id="success-container" class="hidden mt-6">
<div class="rounded-md bg-green-50 p-4"> <div class="rounded-md bg-green-50 p-4">
<div class="flex"> <div class="flex">
@@ -58,26 +58,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6 p-4 bg-gray-100 rounded-md"> <div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3> <h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3"> <p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes: Copy these environment variables to configure all worker nodes:
</p> </p>
<div class="relative"> <div class="relative">
<pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code></code></pre> <pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code></code></pre>
<button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded"> <button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-2"> <p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration. Add these variables to your .env file or environment configuration.
</p> </p>
</div> </div>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Go to Status Page Go to Status Page
@@ -92,26 +92,26 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const code = "{{ code }}"; const code = "{{ code }}";
// Get credentials from session storage (these take precedence over server-provided values) // Get credentials from session storage (these take precedence over server-provided values)
const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}"; const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}";
const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}"; const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}";
const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads'; const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads';
const redirectUri = window.location.origin + "/dropbox-callback"; const redirectUri = window.location.origin + "/dropbox-callback";
// Automatically exchange the code for a refresh token // Automatically exchange the code for a refresh token
if (code) { if (code) {
if (!appKey || !appSecret) { if (!appKey || !appSecret) {
showError("Missing App Key or App Secret. Please go back to the setup page and try again."); showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
return; return;
} }
exchangeCode(code, appKey, appSecret, redirectUri); exchangeCode(code, appKey, appSecret, redirectUri);
} else { } else {
showError("No authorization code was found in the URL"); showError("No authorization code was found in the URL");
} }
function exchangeCode(code, appKey, appSecret, redirectUri) { function exchangeCode(code, appKey, appSecret, redirectUri) {
const formData = new FormData(); const formData = new FormData();
formData.append('client_id', appKey); formData.append('client_id', appKey);
@@ -119,10 +119,10 @@ document.addEventListener('DOMContentLoaded', function() {
formData.append('redirect_uri', redirectUri); formData.append('redirect_uri', redirectUri);
formData.append('code', code); formData.append('code', code);
formData.append('folder_path', folderPath); formData.append('folder_path', folderPath);
document.getElementById('processing-message').innerHTML = document.getElementById('processing-message').innerHTML =
'<p>Exchanging authorization code for refresh token...</p>'; '<p>Exchanging authorization code for refresh token...</p>';
fetch('/api/dropbox/exchange-token', { fetch('/api/dropbox/exchange-token', {
method: 'POST', method: 'POST',
body: formData body: formData
@@ -140,15 +140,15 @@ document.addEventListener('DOMContentLoaded', function() {
// Update settings in memory // Update settings in memory
const updateFormData = new FormData(); const updateFormData = new FormData();
updateFormData.append('refresh_token', data.refresh_token); updateFormData.append('refresh_token', data.refresh_token);
// Use the app key and app secret from session storage // Use the app key and app secret from session storage
if (appKey) updateFormData.append('app_key', appKey); if (appKey) updateFormData.append('app_key', appKey);
if (appSecret) updateFormData.append('app_secret', appSecret); if (appSecret) updateFormData.append('app_secret', appSecret);
if (folderPath) updateFormData.append('folder_path', folderPath); if (folderPath) updateFormData.append('folder_path', folderPath);
document.getElementById('processing-message').innerHTML = document.getElementById('processing-message').innerHTML =
'<p>Updating system settings with new token...</p>'; '<p>Updating system settings with new token...</p>';
return fetch('/api/dropbox/update-settings', { return fetch('/api/dropbox/update-settings', {
method: 'POST', method: 'POST',
body: updateFormData body: updateFormData
@@ -166,7 +166,7 @@ document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => { setTimeout(() => {
window.location.href = '/status'; window.location.href = '/status';
}, 10000); }, 10000);
// Clean up session storage // Clean up session storage
sessionStorage.removeItem('dropbox_app_key'); sessionStorage.removeItem('dropbox_app_key');
sessionStorage.removeItem('dropbox_app_secret'); sessionStorage.removeItem('dropbox_app_secret');
@@ -180,23 +180,23 @@ document.addEventListener('DOMContentLoaded', function() {
showError(error.message); showError(error.message);
}); });
} }
function showError(message) { function showError(message) {
document.getElementById('processing-message').classList.add('hidden'); document.getElementById('processing-message').classList.add('hidden');
document.getElementById('error-container').classList.remove('hidden'); document.getElementById('error-container').classList.remove('hidden');
document.getElementById('error-message').innerText = message; document.getElementById('error-message').innerText = message;
// Hide the spinner when showing error // Hide the spinner when showing error
document.querySelector('.animate-spin').parentNode.classList.add('hidden'); document.querySelector('.animate-spin').parentNode.classList.add('hidden');
} }
function showSuccess(refreshToken, appKey, appSecret, folderPath) { function showSuccess(refreshToken, appKey, appSecret, folderPath) {
document.getElementById('processing-message').classList.add('hidden'); document.getElementById('processing-message').classList.add('hidden');
document.getElementById('success-container').classList.remove('hidden'); document.getElementById('success-container').classList.remove('hidden');
// Hide the spinner when showing success // Hide the spinner when showing success
document.querySelector('.animate-spin').parentNode.classList.add('hidden'); document.querySelector('.animate-spin').parentNode.classList.add('hidden');
// Update the environment variables pre block with the new token // Update the environment variables pre block with the new token
const envVarsCode = document.querySelector('#env-vars code'); const envVarsCode = document.querySelector('#env-vars code');
if (envVarsCode) { if (envVarsCode) {
@@ -205,7 +205,7 @@ DROPBOX_APP_SECRET=${appSecret}
DROPBOX_REFRESH_TOKEN=${refreshToken} DROPBOX_REFRESH_TOKEN=${refreshToken}
DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`; DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`;
} }
// Add copy functionality // Add copy functionality
const copyEnvVarsBtn = document.getElementById('copy-env-vars'); const copyEnvVarsBtn = document.getElementById('copy-env-vars');
if (copyEnvVarsBtn) { if (copyEnvVarsBtn) {
@@ -11,7 +11,7 @@
<h2 class="text-2xl font-bold mt-4">Authorization Failed</h2> <h2 class="text-2xl font-bold mt-4">Authorization Failed</h2>
<p class="text-gray-600 mt-2">Sorry, we couldn't complete the Dropbox authorization.</p> <p class="text-gray-600 mt-2">Sorry, we couldn't complete the Dropbox authorization.</p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4"> <div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex"> <div class="flex">
@@ -29,7 +29,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6"> <div class="mt-6">
<a href="/dropbox-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <a href="/dropbox-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Return to Setup Return to Setup
+1 -1
View File
@@ -69,7 +69,7 @@
Configuration is loaded from environment variables or .env files. Configuration is loaded from environment variables or .env files.
Make sure your environment variables are correctly set. Make sure your environment variables are correctly set.
</p> </p>
<div class="mt-4"> <div class="mt-4">
<a href="/api/diagnostic/settings" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <a href="/api/diagnostic/settings" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
View API Diagnostic View API Diagnostic
+96 -96
View File
@@ -27,7 +27,7 @@
.back-button i { .back-button i {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.detail-card { .detail-card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -41,7 +41,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
color: #2d3748; color: #2d3748;
} }
.detail-grid { .detail-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -64,7 +64,7 @@
font-size: 1rem; font-size: 1rem;
word-break: break-word; word-break: break-word;
} }
/* Status badges */ /* Status badges */
.status-badge { .status-badge {
display: inline-block; display: inline-block;
@@ -94,7 +94,7 @@
background-color: #E5E7EB; background-color: #E5E7EB;
color: #374151; color: #374151;
} }
/* Step summary section */ /* Step summary section */
.step-summary { .step-summary {
display: grid; display: grid;
@@ -158,7 +158,7 @@
background-color: #ecc94b; background-color: #ecc94b;
color: white; color: white;
} }
/* Processing logs - collapsible */ /* Processing logs - collapsible */
.timeline { .timeline {
position: relative; position: relative;
@@ -243,7 +243,7 @@
font-size: 0.75rem; font-size: 0.75rem;
color: #a0aec0; color: #a0aec0;
} }
.logs-toggle { .logs-toggle {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -298,7 +298,7 @@
.timeline-detail.visible { .timeline-detail.visible {
display: block; display: block;
} }
.no-logs { .no-logs {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
@@ -309,7 +309,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
opacity: 0.5; opacity: 0.5;
} }
.error-message { .error-message {
background-color: #FEE2E2; background-color: #FEE2E2;
border: 1px solid #F87171; border: 1px solid #F87171;
@@ -318,7 +318,7 @@
border-radius: 0.25rem; border-radius: 0.25rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.file-status-indicator { .file-status-indicator {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -335,7 +335,7 @@
background-color: #FEE2E2; background-color: #FEE2E2;
color: #991B1B; color: #991B1B;
} }
/* Flow visualization with branches */ /* Flow visualization with branches */
.flow-stage { .flow-stage {
display: flex; display: flex;
@@ -416,7 +416,7 @@
margin-left: 19px; margin-left: 19px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
/* Branch visualization */ /* Branch visualization */
.flow-branches { .flow-branches {
margin-left: 56px; margin-left: 56px;
@@ -507,7 +507,7 @@
background-color: #cbd5e0; background-color: #cbd5e0;
cursor: not-allowed; cursor: not-allowed;
} }
/* Text Modal Styles */ /* Text Modal Styles */
.text-modal { .text-modal {
position: fixed; position: fixed;
@@ -528,7 +528,7 @@
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
} }
/* PDF Canvas Viewer Styles */ /* PDF Canvas Viewer Styles */
.pdf-viewer-container { .pdf-viewer-container {
border: 2px solid #e2e8f0; border: 2px solid #e2e8f0;
@@ -589,12 +589,12 @@
const fileId = {{ file.id | tojson }}; const fileId = {{ file.id | tojson }};
const button = document.getElementById('reprocess-btn'); const button = document.getElementById('reprocess-btn');
const statusDiv = document.getElementById('reprocess-status'); const statusDiv = document.getElementById('reprocess-status');
// Disable button and show loading // Disable button and show loading
button.disabled = true; button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...'; button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
statusDiv.innerHTML = ''; statusDiv.innerHTML = '';
try { try {
const response = await fetch(`/api/files/${fileId}/reprocess`, { const response = await fetch(`/api/files/${fileId}/reprocess`, {
method: 'POST', method: 'POST',
@@ -602,9 +602,9 @@
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div style="background-color: #D1FAE5; color: #065F46; padding: 1rem; border-radius: 0.25rem; margin-top: 1rem;"> <div style="background-color: #D1FAE5; color: #065F46; padding: 1rem; border-radius: 0.25rem; margin-top: 1rem;">
@@ -633,20 +633,20 @@
button.innerHTML = '<i class="fas fa-redo"></i> Retry Processing'; button.innerHTML = '<i class="fas fa-redo"></i> Retry Processing';
} }
} }
// JavaScript for handling per-subtask retry // JavaScript for handling per-subtask retry
async function retrySubtask(subtaskName, buttonId) { async function retrySubtask(subtaskName, buttonId) {
const fileId = {{ file.id | tojson }}; const fileId = {{ file.id | tojson }};
const button = document.getElementById(buttonId); const button = document.getElementById(buttonId);
const statusDiv = document.getElementById('subtask-status-' + subtaskName); const statusDiv = document.getElementById('subtask-status-' + subtaskName);
// Disable button and show loading // Disable button and show loading
button.disabled = true; button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Retrying...'; button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Retrying...';
if (statusDiv) { if (statusDiv) {
statusDiv.innerHTML = ''; statusDiv.innerHTML = '';
} }
try { try {
const response = await fetch(`/api/files/${fileId}/retry-subtask?subtask_name=${subtaskName}`, { const response = await fetch(`/api/files/${fileId}/retry-subtask?subtask_name=${subtaskName}`, {
method: 'POST', method: 'POST',
@@ -654,9 +654,9 @@
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
if (statusDiv) { if (statusDiv) {
statusDiv.innerHTML = ` statusDiv.innerHTML = `
@@ -690,12 +690,12 @@
button.innerHTML = '<i class="fas fa-redo"></i> Retry'; button.innerHTML = '<i class="fas fa-redo"></i> Retry';
} }
} }
// Toggle logs visibility // Toggle logs visibility
function toggleLogs() { function toggleLogs() {
const logsContent = document.getElementById('logs-content'); const logsContent = document.getElementById('logs-content');
const toggleIcon = document.getElementById('logs-toggle-icon'); const toggleIcon = document.getElementById('logs-toggle-icon');
if (logsContent.classList.contains('expanded')) { if (logsContent.classList.contains('expanded')) {
logsContent.classList.remove('expanded'); logsContent.classList.remove('expanded');
toggleIcon.classList.remove('fa-chevron-up'); toggleIcon.classList.remove('fa-chevron-up');
@@ -721,13 +721,13 @@
icon.classList.add('fa-chevron-up'); icon.classList.add('fa-chevron-up');
} }
} }
// JavaScript for metadata JSON toggle // JavaScript for metadata JSON toggle
function toggleMetadata() { function toggleMetadata() {
const jsonView = document.getElementById('metadata-json-view'); const jsonView = document.getElementById('metadata-json-view');
const icon = document.getElementById('metadata-toggle-icon'); const icon = document.getElementById('metadata-toggle-icon');
const btn = document.getElementById('metadata-toggle-btn'); const btn = document.getElementById('metadata-toggle-btn');
if (jsonView.style.display === 'none') { if (jsonView.style.display === 'none') {
jsonView.style.display = 'block'; jsonView.style.display = 'block';
icon.classList.remove('fa-chevron-down'); icon.classList.remove('fa-chevron-down');
@@ -740,18 +740,18 @@
btn.innerHTML = '<i id="metadata-toggle-icon" class="fas fa-chevron-down"></i> Show JSON'; btn.innerHTML = '<i id="metadata-toggle-icon" class="fas fa-chevron-down"></i> Show JSON';
} }
} }
// JavaScript for text modal toggle with on-demand loading // JavaScript for text modal toggle with on-demand loading
let textCache = { original: null, processed: null }; let textCache = { original: null, processed: null };
async function loadAndShowText(type, fileId) { async function loadAndShowText(type, fileId) {
const modalId = type + '-text-modal'; const modalId = type + '-text-modal';
const loadingId = type + '-text-loading'; const loadingId = type + '-text-loading';
const contentId = type + '-text-content'; const contentId = type + '-text-content';
// Show modal immediately // Show modal immediately
toggleTextModal(modalId); toggleTextModal(modalId);
// If already loaded, just show it // If already loaded, just show it
if (textCache[type]) { if (textCache[type]) {
document.getElementById(loadingId).style.display = 'none'; document.getElementById(loadingId).style.display = 'none';
@@ -759,20 +759,20 @@
document.getElementById(contentId).textContent = textCache[type]; document.getElementById(contentId).textContent = textCache[type];
return; return;
} }
// Show loading state // Show loading state
document.getElementById(loadingId).style.display = 'block'; document.getElementById(loadingId).style.display = 'block';
document.getElementById(contentId).style.display = 'none'; document.getElementById(contentId).style.display = 'none';
try { try {
const response = await fetch(`/files/${fileId}/text/${type}`); const response = await fetch(`/files/${fileId}/text/${type}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to extract text'); throw new Error('Failed to extract text');
} }
const data = await response.json(); const data = await response.json();
textCache[type] = data.text; textCache[type] = data.text;
// Show the text // Show the text
document.getElementById(loadingId).style.display = 'none'; document.getElementById(loadingId).style.display = 'none';
document.getElementById(contentId).style.display = 'block'; document.getElementById(contentId).style.display = 'block';
@@ -788,7 +788,7 @@
`; `;
} }
} }
function toggleTextModal(modalId) { function toggleTextModal(modalId) {
const modal = document.getElementById(modalId); const modal = document.getElementById(modalId);
if (modal.style.display === 'none' || modal.style.display === '') { if (modal.style.display === 'none' || modal.style.display === '') {
@@ -799,7 +799,7 @@
document.body.style.overflow = 'auto'; // Re-enable scrolling document.body.style.overflow = 'auto'; // Re-enable scrolling
} }
} }
// Close modal when clicking outside the content // Close modal when clicking outside the content
window.onclick = function(event) { window.onclick = function(event) {
const modals = document.querySelectorAll('.text-modal'); const modals = document.querySelectorAll('.text-modal');
@@ -810,26 +810,26 @@
} }
}); });
} }
// PDF.js viewer functionality // PDF.js viewer functionality
const pdfViewers = { const pdfViewers = {
original: { currentPage: 1, totalPages: 0, pdfDoc: null }, original: { currentPage: 1, totalPages: 0, pdfDoc: null },
processed: { currentPage: 1, totalPages: 0, pdfDoc: null } processed: { currentPage: 1, totalPages: 0, pdfDoc: null }
}; };
async function loadPDF(type, fileId) { async function loadPDF(type, fileId) {
const url = `/files/${fileId}/preview/${type}`; const url = `/files/${fileId}/preview/${type}`;
const canvasWrapper = document.getElementById(`${type}-canvas-wrapper`); const canvasWrapper = document.getElementById(`${type}-canvas-wrapper`);
try { try {
// Load PDF document // Load PDF document
const loadingTask = pdfjsLib.getDocument(url); const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise; const pdf = await loadingTask.promise;
pdfViewers[type].pdfDoc = pdf; pdfViewers[type].pdfDoc = pdf;
pdfViewers[type].totalPages = pdf.numPages; pdfViewers[type].totalPages = pdf.numPages;
pdfViewers[type].currentPage = 1; pdfViewers[type].currentPage = 1;
// Clear loading message and render first page // Clear loading message and render first page
canvasWrapper.innerHTML = ''; canvasWrapper.innerHTML = '';
await renderPage(type); await renderPage(type);
@@ -844,58 +844,58 @@
`; `;
} }
} }
async function renderPage(type) { async function renderPage(type) {
const viewer = pdfViewers[type]; const viewer = pdfViewers[type];
if (!viewer.pdfDoc) return; if (!viewer.pdfDoc) return;
const canvasWrapper = document.getElementById(`${type}-canvas-wrapper`); const canvasWrapper = document.getElementById(`${type}-canvas-wrapper`);
const page = await viewer.pdfDoc.getPage(viewer.currentPage); const page = await viewer.pdfDoc.getPage(viewer.currentPage);
// Calculate scale to fit container width (max 600px width) // Calculate scale to fit container width (max 600px width)
const viewport = page.getViewport({ scale: 1.0 }); const viewport = page.getViewport({ scale: 1.0 });
const scale = Math.min(600 / viewport.width, 2.0); const scale = Math.min(600 / viewport.width, 2.0);
const scaledViewport = page.getViewport({ scale }); const scaledViewport = page.getViewport({ scale });
// Create canvas for this page // Create canvas for this page
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.className = 'pdf-canvas'; canvas.className = 'pdf-canvas';
canvas.height = scaledViewport.height; canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width; canvas.width = scaledViewport.width;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
const renderContext = { const renderContext = {
canvasContext: context, canvasContext: context,
viewport: scaledViewport viewport: scaledViewport
}; };
// Clear previous canvas and render new one // Clear previous canvas and render new one
canvasWrapper.innerHTML = ''; canvasWrapper.innerHTML = '';
canvasWrapper.appendChild(canvas); canvasWrapper.appendChild(canvas);
await page.render(renderContext).promise; await page.render(renderContext).promise;
} }
function changePage(type, delta) { function changePage(type, delta) {
const viewer = pdfViewers[type]; const viewer = pdfViewers[type];
const newPage = viewer.currentPage + delta; const newPage = viewer.currentPage + delta;
if (newPage >= 1 && newPage <= viewer.totalPages) { if (newPage >= 1 && newPage <= viewer.totalPages) {
viewer.currentPage = newPage; viewer.currentPage = newPage;
renderPage(type); renderPage(type);
updatePageInfo(type); updatePageInfo(type);
} }
} }
function updatePageInfo(type) { function updatePageInfo(type) {
const viewer = pdfViewers[type]; const viewer = pdfViewers[type];
document.getElementById(`${type}-page-info`).textContent = document.getElementById(`${type}-page-info`).textContent =
`Page ${viewer.currentPage} of ${viewer.totalPages}`; `Page ${viewer.currentPage} of ${viewer.totalPages}`;
document.getElementById(`${type}-prev-btn`).disabled = viewer.currentPage === 1; document.getElementById(`${type}-prev-btn`).disabled = viewer.currentPage === 1;
document.getElementById(`${type}-next-btn`).disabled = viewer.currentPage === viewer.totalPages; document.getElementById(`${type}-next-btn`).disabled = viewer.currentPage === viewer.totalPages;
} }
// Load PDFs when page loads // Load PDFs when page loads
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const fileId = {{ file.id | tojson }}; const fileId = {{ file.id | tojson }};
@@ -916,13 +916,13 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
Back to File List Back to File List
</a> </a>
{% if error %} {% if error %}
<div class="error-message"> <div class="error-message">
<p><strong>Error:</strong> {{ error }}</p> <p><strong>Error:</strong> {{ error }}</p>
</div> </div>
{% else %} {% else %}
<!-- Overall Processing Status Banner --> <!-- Overall Processing Status Banner -->
{% if step_summary %} {% if step_summary %}
<div style="margin-bottom: 1.5rem; padding: 1.5rem; background-color: #f0f9ff; border-left: 4px solid #3b82f6; border-radius: 0.5rem;"> <div style="margin-bottom: 1.5rem; padding: 1.5rem; background-color: #f0f9ff; border-left: 4px solid #3b82f6; border-radius: 0.5rem;">
@@ -980,7 +980,7 @@
} }
</style> </style>
{% endif %} {% endif %}
<!-- File Information Card --> <!-- File Information Card -->
<div class="detail-card"> <div class="detail-card">
<h3>File Information</h3> <h3>File Information</h3>
@@ -1051,7 +1051,7 @@
</div> </div>
</div> </div>
</div> </div>
<!-- GPT Metadata Card --> <!-- GPT Metadata Card -->
{% if gpt_metadata %} {% if gpt_metadata %}
<div class="detail-card"> <div class="detail-card">
@@ -1061,7 +1061,7 @@
<i id="metadata-toggle-icon" class="fas fa-chevron-down"></i> Show JSON <i id="metadata-toggle-icon" class="fas fa-chevron-down"></i> Show JSON
</button> </button>
</div> </div>
<div class="detail-grid"> <div class="detail-grid">
{% if gpt_metadata.document_type %} {% if gpt_metadata.document_type %}
<div class="detail-item"> <div class="detail-item">
@@ -1069,49 +1069,49 @@
<span class="detail-value">{{ gpt_metadata.document_type }}</span> <span class="detail-value">{{ gpt_metadata.document_type }}</span>
</div> </div>
{% endif %} {% endif %}
{% if gpt_metadata.filename %} {% if gpt_metadata.filename %}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Suggested Filename</span> <span class="detail-label">Suggested Filename</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">{{ gpt_metadata.filename }}</span> <span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">{{ gpt_metadata.filename }}</span>
</div> </div>
{% endif %} {% endif %}
{% if gpt_metadata.date %} {% if gpt_metadata.date %}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Document Date</span> <span class="detail-label">Document Date</span>
<span class="detail-value">{{ gpt_metadata.date }}</span> <span class="detail-value">{{ gpt_metadata.date }}</span>
</div> </div>
{% endif %} {% endif %}
{% if gpt_metadata.absender %} {% if gpt_metadata.absender %}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Sender (Absender)</span> <span class="detail-label">Sender (Absender)</span>
<span class="detail-value">{{ gpt_metadata.absender }}</span> <span class="detail-value">{{ gpt_metadata.absender }}</span>
</div> </div>
{% endif %} {% endif %}
{% if gpt_metadata.empfaenger %} {% if gpt_metadata.empfaenger %}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Recipient (Empfänger)</span> <span class="detail-label">Recipient (Empfänger)</span>
<span class="detail-value">{{ gpt_metadata.empfaenger }}</span> <span class="detail-value">{{ gpt_metadata.empfaenger }}</span>
</div> </div>
{% endif %} {% endif %}
{% if gpt_metadata.betrag %} {% if gpt_metadata.betrag %}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Amount (Betrag)</span> <span class="detail-label">Amount (Betrag)</span>
<span class="detail-value">{{ gpt_metadata.betrag }}</span> <span class="detail-value">{{ gpt_metadata.betrag }}</span>
</div> </div>
{% endif %} {% endif %}
{% if gpt_metadata.kontonummer %} {% if gpt_metadata.kontonummer %}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Account Number</span> <span class="detail-label">Account Number</span>
<span class="detail-value" style="font-family: monospace;">{{ gpt_metadata.kontonummer }}</span> <span class="detail-value" style="font-family: monospace;">{{ gpt_metadata.kontonummer }}</span>
</div> </div>
{% endif %} {% endif %}
{% if gpt_metadata.tags %} {% if gpt_metadata.tags %}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Tags</span> <span class="detail-label">Tags</span>
@@ -1125,18 +1125,18 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Collapsible JSON view --> <!-- Collapsible JSON view -->
<div id="metadata-json-view" style="display: none; margin-top: 1rem;"> <div id="metadata-json-view" style="display: none; margin-top: 1rem;">
<pre style="background-color: #1a202c; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; font-size: 0.875rem; line-height: 1.5;">{{ gpt_metadata | tojson(indent=2) }}</pre> <pre style="background-color: #1a202c; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; font-size: 0.875rem; line-height: 1.5;">{{ gpt_metadata | tojson(indent=2) }}</pre>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- PDF Preview Card --> <!-- PDF Preview Card -->
<div class="detail-card"> <div class="detail-card">
<h3>Document Previews</h3> <h3>Document Previews</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1rem;">
<!-- Original PDF Preview --> <!-- Original PDF Preview -->
<div> <div>
@@ -1155,8 +1155,8 @@
</div> </div>
</div> </div>
</div> </div>
<button <button
onclick="loadAndShowText('original', {{ file.id }})" onclick="loadAndShowText('original', {{ file.id }})"
style="margin-top: 0.5rem; background-color: #4299e1; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; width: 100%;" style="margin-top: 0.5rem; background-color: #4299e1; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; width: 100%;"
> >
<i class="fas fa-file-alt"></i> View Extracted Text <i class="fas fa-file-alt"></i> View Extracted Text
@@ -1168,7 +1168,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Processed PDF Preview --> <!-- Processed PDF Preview -->
<div> <div>
<h4 style="font-weight: 600; color: #2d3748; margin-bottom: 0.5rem;">Processed Document</h4> <h4 style="font-weight: 600; color: #2d3748; margin-bottom: 0.5rem;">Processed Document</h4>
@@ -1186,8 +1186,8 @@
</div> </div>
</div> </div>
</div> </div>
<button <button
onclick="loadAndShowText('processed', {{ file.id }})" onclick="loadAndShowText('processed', {{ file.id }})"
style="margin-top: 0.5rem; background-color: #48bb78; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; width: 100%;" style="margin-top: 0.5rem; background-color: #48bb78; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; width: 100%;"
> >
<i class="fas fa-file-alt"></i> View Extracted Text <i class="fas fa-file-alt"></i> View Extracted Text
@@ -1201,15 +1201,15 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Text Modals (Hidden by default, loaded on-demand) --> <!-- Text Modals (Hidden by default, loaded on-demand) -->
<!-- Original Text Modal --> <!-- Original Text Modal -->
<div id="original-text-modal" class="text-modal" style="display: none;"> <div id="original-text-modal" class="text-modal" style="display: none;">
<div class="text-modal-content"> <div class="text-modal-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0;">
<h3 style="margin: 0; color: #2d3748;">Extracted Text (Original)</h3> <h3 style="margin: 0; color: #2d3748;">Extracted Text (Original)</h3>
<button <button
onclick="toggleTextModal('original-text-modal')" onclick="toggleTextModal('original-text-modal')"
style="background-color: #f56565; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;" style="background-color: #f56565; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;"
> >
<i class="fas fa-times"></i> Close <i class="fas fa-times"></i> Close
@@ -1222,14 +1222,14 @@
<pre id="original-text-content" style="display: none; background-color: #1a202c; color: #e2e8f0; padding: 1.5rem; border-radius: 0.5rem; max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.6;"></pre> <pre id="original-text-content" style="display: none; background-color: #1a202c; color: #e2e8f0; padding: 1.5rem; border-radius: 0.5rem; max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.6;"></pre>
</div> </div>
</div> </div>
<!-- Processed Text Modal --> <!-- Processed Text Modal -->
<div id="processed-text-modal" class="text-modal" style="display: none;"> <div id="processed-text-modal" class="text-modal" style="display: none;">
<div class="text-modal-content"> <div class="text-modal-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0;">
<h3 style="margin: 0; color: #2d3748;">Extracted Text (Processed)</h3> <h3 style="margin: 0; color: #2d3748;">Extracted Text (Processed)</h3>
<button <button
onclick="toggleTextModal('processed-text-modal')" onclick="toggleTextModal('processed-text-modal')"
style="background-color: #f56565; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;" style="background-color: #f56565; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;"
> >
<i class="fas fa-times"></i> Close <i class="fas fa-times"></i> Close
@@ -1242,7 +1242,7 @@
<pre id="processed-text-content" style="display: none; background-color: #1a202c; color: #e2e8f0; padding: 1.5rem; border-radius: 0.5rem; max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.6;"></pre> <pre id="processed-text-content" style="display: none; background-color: #1a202c; color: #e2e8f0; padding: 1.5rem; border-radius: 0.5rem; max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.6;"></pre>
</div> </div>
</div> </div>
<!-- Step Summary Card --> <!-- Step Summary Card -->
{% if step_summary %} {% if step_summary %}
<div class="detail-card"> <div class="detail-card">
@@ -1278,7 +1278,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if step_summary.total_upload_tasks > 0 %} {% if step_summary.total_upload_tasks > 0 %}
<div class="summary-card upload-steps"> <div class="summary-card upload-steps">
<div class="summary-title">Upload Destinations</div> <div class="summary-title">Upload Destinations</div>
@@ -1314,7 +1314,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Processing History Card (Collapsible) --> <!-- Processing History Card (Collapsible) -->
<div class="detail-card"> <div class="detail-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
@@ -1369,7 +1369,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Process Flow Visualization Card --> <!-- Process Flow Visualization Card -->
{% if flow_data %} {% if flow_data %}
<div class="detail-card"> <div class="detail-card">
@@ -1385,7 +1385,7 @@
{% elif stage.status in ['pending', 'queued'] %}<i class="fas fa-clock"></i> {% elif stage.status in ['pending', 'queued'] %}<i class="fas fa-clock"></i>
{% else %}<i class="fas fa-circle"></i>{% endif %} {% else %}<i class="fas fa-circle"></i>{% endif %}
</div> </div>
<!-- Stage content --> <!-- Stage content -->
<div class="flow-content {{ stage.status.lower().replace(' ', '_') }}"> <div class="flow-content {{ stage.status.lower().replace(' ', '_') }}">
<div class="flow-title">{{ stage.label }}</div> <div class="flow-title">{{ stage.label }}</div>
@@ -1396,9 +1396,9 @@
<div style="color: #718096; font-size: 0.875rem; font-style: italic;">Not executed</div> <div style="color: #718096; font-size: 0.875rem; font-style: italic;">Not executed</div>
{% endif %} {% endif %}
{% if stage.can_retry and stage.status == 'failure' %} {% if stage.can_retry and stage.status == 'failure' %}
<button <button
id="retry-btn-{{ stage.key }}" id="retry-btn-{{ stage.key }}"
class="retry-btn" class="retry-btn"
onclick="retrySubtask('{{ stage.key }}', 'retry-btn-{{ stage.key }}')" onclick="retrySubtask('{{ stage.key }}', 'retry-btn-{{ stage.key }}')"
style="margin-top: 0.5rem;"> style="margin-top: 0.5rem;">
<i class="fas fa-redo"></i> Retry from this step <i class="fas fa-redo"></i> Retry from this step
@@ -1411,7 +1411,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Upload branches (if this stage has them) --> <!-- Upload branches (if this stage has them) -->
{% if stage.is_branch_parent and stage.branches %} {% if stage.is_branch_parent and stage.branches %}
<div class="flow-branches"> <div class="flow-branches">
@@ -1427,9 +1427,9 @@
<div class="branch-title"> <div class="branch-title">
<span>{{ branch.label }}</span> <span>{{ branch.label }}</span>
{% if branch.can_retry and branch.status == 'failure' %} {% if branch.can_retry and branch.status == 'failure' %}
<button <button
id="retry-btn-{{ branch.key }}" id="retry-btn-{{ branch.key }}"
class="retry-btn" class="retry-btn"
onclick="retrySubtask('{{ branch.key }}', 'retry-btn-{{ branch.key }}')"> onclick="retrySubtask('{{ branch.key }}', 'retry-btn-{{ branch.key }}')">
<i class="fas fa-redo"></i> Retry <i class="fas fa-redo"></i> Retry
</button> </button>
@@ -1449,7 +1449,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- Connector line (except for last item) --> <!-- Connector line (except for last item) -->
{% if not loop.last %} {% if not loop.last %}
<div class="flow-connector"></div> <div class="flow-connector"></div>
@@ -1458,7 +1458,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- File Preview Card --> <!-- File Preview Card -->
{% if file_exists or processed_exists %} {% if file_exists or processed_exists %}
<div class="detail-card"> <div class="detail-card">
@@ -1489,7 +1489,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if processed_exists %} {% if processed_exists %}
<div> <div>
<h4 style="font-weight: 600; color: #2d3748; margin-bottom: 1rem;">Processed File</h4> <h4 style="font-weight: 600; color: #2d3748; margin-bottom: 1rem;">Processed File</h4>
@@ -1510,7 +1510,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
+5 -5
View File
@@ -10,7 +10,7 @@
console.log('Before Alpine init on /files page'); console.log('Before Alpine init on /files page');
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
console.log('Alpine.js initialized in files view'); console.log('Alpine.js initialized in files view');
}); });
console.log('After Alpine init listener registration'); console.log('After Alpine init listener registration');
</script> </script>
@@ -57,7 +57,7 @@
<h2 class="text-3xl font-bold mb-6">File Records</h2> <h2 class="text-3xl font-bold mb-6">File Records</h2>
<!-- Grid.js will render the table in this container --> <!-- Grid.js will render the table in this container -->
<div id="gridjs-wrapper"></div> <div id="gridjs-wrapper"></div>
<!-- Confirmation Modal (temporarily disabled) --> <!-- Confirmation Modal (temporarily disabled) -->
<div id="confirmDeleteModal" class="confirm-delete-modal hidden"> <div id="confirmDeleteModal" class="confirm-delete-modal hidden">
<div class="confirm-delete-content"> <div class="confirm-delete-content">
@@ -104,7 +104,7 @@
const response = await fetch(`/api/files/${fileId}`, { const response = await fetch(`/api/files/${fileId}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (response.ok) { if (response.ok) {
// Success - reload the grid // Success - reload the grid
grid.forceRender(); grid.forceRender();
@@ -132,7 +132,7 @@
// Handle delete confirmation // Handle delete confirmation
confirmDelete.addEventListener('click', async function() { confirmDelete.addEventListener('click', async function() {
if (!fileToDelete) return; if (!fileToDelete) return;
deleteFile(fileToDelete.id); deleteFile(fileToDelete.id);
closeDeleteModal(); closeDeleteModal();
}); });
@@ -151,7 +151,7 @@
{ id: 'file_size', name: 'File Size', formatter: (size) => `${(size / 1024).toFixed(2)} KB` }, { id: 'file_size', name: 'File Size', formatter: (size) => `${(size / 1024).toFixed(2)} KB` },
{ id: 'mime_type', name: 'Mime Type' }, { id: 'mime_type', name: 'Mime Type' },
{ id: 'created_at', name: 'Created At' }, { id: 'created_at', name: 'Created At' },
{ {
id: 'actions', id: 'actions',
name: 'Actions', name: 'Actions',
formatter: (_, row) => { formatter: (_, row) => {
+60 -60
View File
@@ -54,7 +54,7 @@
font-weight: 400; font-weight: 400;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* Upload progress modal */ /* Upload progress modal */
.upload-modal { .upload-modal {
display: none; display: none;
@@ -95,14 +95,14 @@
.close-modal-btn:hover { .close-modal-btn:hover {
opacity: 0.8; opacity: 0.8;
} }
<style> <style>
.file-table { .file-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.file-table th, .file-table th,
.file-table td { .file-table td {
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
@@ -133,7 +133,7 @@
opacity: 1; opacity: 1;
font-weight: bold; font-weight: bold;
} }
/* Status badges */ /* Status badges */
.status-badge { .status-badge {
display: inline-block; display: inline-block;
@@ -163,7 +163,7 @@
background-color: #E5E7EB; background-color: #E5E7EB;
color: #374151; color: #374151;
} }
/* Action buttons */ /* Action buttons */
.action-btn { .action-btn {
color: #3182ce; color: #3182ce;
@@ -185,7 +185,7 @@
.action-btn.delete:hover { .action-btn.delete:hover {
background-color: #fed7d7; background-color: #fed7d7;
} }
/* Filters section */ /* Filters section */
.filters-section { .filters-section {
background-color: #f7fafc; background-color: #f7fafc;
@@ -234,7 +234,7 @@
.filter-item button.clear:hover { .filter-item button.clear:hover {
background-color: #4a5568; background-color: #4a5568;
} }
/* Pagination */ /* Pagination */
.pagination { .pagination {
display: flex; display: flex;
@@ -271,7 +271,7 @@
color: white; color: white;
border-color: #3182ce; border-color: #3182ce;
} }
/* Modal styles */ /* Modal styles */
.modal { .modal {
display: none; display: none;
@@ -325,7 +325,7 @@
.modal-btn-delete:hover { .modal-btn-delete:hover {
background-color: #c53030; background-color: #c53030;
} }
.error-message { .error-message {
background-color: #FEE2E2; background-color: #FEE2E2;
border: 1px solid #F87171; border: 1px solid #F87171;
@@ -363,7 +363,7 @@
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-6">File Records</h2> <h2 class="text-3xl font-bold mb-6">File Records</h2>
{% if error %} {% if error %}
<div class="error-message"> <div class="error-message">
<p><strong>Error:</strong> {{ error }}</p> <p><strong>Error:</strong> {{ error }}</p>
@@ -378,7 +378,7 @@
<label for="search">Search Filename</label> <label for="search">Search Filename</label>
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Enter filename..."> <input type="text" id="search" name="search" value="{{ search }}" placeholder="Enter filename...">
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label for="mime_type">MIME Type</label> <label for="mime_type">MIME Type</label>
<select id="mime_type" name="mime_type"> <select id="mime_type" name="mime_type">
@@ -388,7 +388,7 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label for="status">Status</label> <label for="status">Status</label>
<select id="status" name="status"> <select id="status" name="status">
@@ -400,24 +400,24 @@
<option value="duplicate" {% if status == "duplicate" %}selected{% endif %}>Duplicate</option> <option value="duplicate" {% if status == "duplicate" %}selected{% endif %}>Duplicate</option>
</select> </select>
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>&nbsp;</label> <label>&nbsp;</label>
<button type="submit">Apply Filters</button> <button type="submit">Apply Filters</button>
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>&nbsp;</label> <label>&nbsp;</label>
<button type="button" class="clear" onclick="clearFilters()">Clear</button> <button type="button" class="clear" onclick="clearFilters()">Clear</button>
</div> </div>
<!-- Hidden fields to preserve sort order --> <!-- Hidden fields to preserve sort order -->
<input type="hidden" name="sort_by" value="{{ sort_by }}"> <input type="hidden" name="sort_by" value="{{ sort_by }}">
<input type="hidden" name="sort_order" value="{{ sort_order }}"> <input type="hidden" name="sort_order" value="{{ sort_order }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}"> <input type="hidden" name="per_page" value="{{ pagination.per_page }}">
</form> </form>
</div> </div>
<!-- Bulk Actions Section --> <!-- Bulk Actions Section -->
<div id="bulkActionsBar" class="filters-section" style="display: none; background-color: #e6f3ff;"> <div id="bulkActionsBar" class="filters-section" style="display: none; background-color: #e6f3ff;">
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
@@ -437,7 +437,7 @@
</div> </div>
</div> </div>
</div> </div>
<!-- File table --> <!-- File table -->
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="file-table" id="fileTable"> <table class="file-table" id="fileTable">
@@ -535,13 +535,13 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
{% if pagination.total_pages > 1 %} {% if pagination.total_pages > 1 %}
<div class="pagination"> <div class="pagination">
<div class="pagination-info"> <div class="pagination-info">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} - Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
{{ min(pagination.page * pagination.per_page, pagination.total_items) }} {{ min(pagination.page * pagination.per_page, pagination.total_items) }}
of {{ pagination.total_items }} files of {{ pagination.total_items }} files
</div> </div>
<div class="pagination-buttons"> <div class="pagination-buttons">
@@ -549,13 +549,13 @@
<button class="pagination-button" onclick="goToPage(1)">First</button> <button class="pagination-button" onclick="goToPage(1)">First</button>
<button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})">Previous</button> <button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})">Previous</button>
{% endif %} {% endif %}
{% for p in range(max(1, pagination.page - 2), min(pagination.total_pages + 1, pagination.page + 3)) %} {% for p in range(max(1, pagination.page - 2), min(pagination.total_pages + 1, pagination.page + 3)) %}
<button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})"> <button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})">
{{ p }} {{ p }}
</button> </button>
{% endfor %} {% endfor %}
{% if pagination.page < pagination.total_pages %} {% if pagination.page < pagination.total_pages %}
<button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})">Next</button> <button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})">Next</button>
<button class="pagination-button" onclick="goToPage({{ pagination.total_pages }})">Last</button> <button class="pagination-button" onclick="goToPage({{ pagination.total_pages }})">Last</button>
@@ -563,7 +563,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Delete confirmation modal --> <!-- Delete confirmation modal -->
<div id="deleteModal" class="modal"> <div id="deleteModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -575,41 +575,41 @@
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// Modal functionality // Modal functionality
const deleteModal = document.getElementById('deleteModal'); const deleteModal = document.getElementById('deleteModal');
const cancelDelete = document.getElementById('cancelDelete'); const cancelDelete = document.getElementById('cancelDelete');
const confirmDelete = document.getElementById('confirmDelete'); const confirmDelete = document.getElementById('confirmDelete');
let currentFileId = null; let currentFileId = null;
function showDeleteModal(fileId, event) { function showDeleteModal(fileId, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
currentFileId = fileId; currentFileId = fileId;
deleteModal.style.display = 'flex'; deleteModal.style.display = 'flex';
} }
function viewFileDetail(fileId, event) { function viewFileDetail(fileId, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
window.location.href = `/files/${fileId}/detail`; window.location.href = `/files/${fileId}/detail`;
} }
cancelDelete.addEventListener('click', () => { cancelDelete.addEventListener('click', () => {
deleteModal.style.display = 'none'; deleteModal.style.display = 'none';
}); });
confirmDelete.addEventListener('click', () => { confirmDelete.addEventListener('click', () => {
deleteFile(currentFileId); deleteFile(currentFileId);
deleteModal.style.display = 'none'; deleteModal.style.display = 'none';
}); });
// Close modal if clicking outside of it // Close modal if clicking outside of it
window.addEventListener('click', (event) => { window.addEventListener('click', (event) => {
if (event.target === deleteModal) { if (event.target === deleteModal) {
deleteModal.style.display = 'none'; deleteModal.style.display = 'none';
} }
}); });
function deleteFile(fileId) { function deleteFile(fileId) {
fetch(`/api/files/${fileId}`, { fetch(`/api/files/${fileId}`, {
method: 'DELETE', method: 'DELETE',
@@ -645,35 +645,35 @@
alert(`Error deleting file: ${error.message}`); alert(`Error deleting file: ${error.message}`);
}); });
} }
function sortTable(column) { function sortTable(column) {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const currentSortBy = urlParams.get('sort_by') || 'created_at'; const currentSortBy = urlParams.get('sort_by') || 'created_at';
const currentSortOrder = urlParams.get('sort_order') || 'desc'; const currentSortOrder = urlParams.get('sort_order') || 'desc';
// Toggle sort order if clicking the same column // Toggle sort order if clicking the same column
let newSortOrder = 'asc'; let newSortOrder = 'asc';
if (column === currentSortBy) { if (column === currentSortBy) {
newSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc'; newSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
} }
urlParams.set('sort_by', column); urlParams.set('sort_by', column);
urlParams.set('sort_order', newSortOrder); urlParams.set('sort_order', newSortOrder);
urlParams.set('page', '1'); // Reset to first page on sort urlParams.set('page', '1'); // Reset to first page on sort
window.location.search = urlParams.toString(); window.location.search = urlParams.toString();
} }
function goToPage(page) { function goToPage(page) {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
urlParams.set('page', page); urlParams.set('page', page);
window.location.search = urlParams.toString(); window.location.search = urlParams.toString();
} }
function clearFilters() { function clearFilters() {
window.location.href = '/files'; window.location.href = '/files';
} }
// Bulk selection functionality // Bulk selection functionality
function toggleSelectAll() { function toggleSelectAll() {
const selectAll = document.getElementById('selectAll'); const selectAll = document.getElementById('selectAll');
@@ -683,48 +683,48 @@
}); });
updateBulkActionsBar(); updateBulkActionsBar();
} }
function updateBulkActionsBar() { function updateBulkActionsBar() {
const checkboxes = document.querySelectorAll('.file-checkbox:checked'); const checkboxes = document.querySelectorAll('.file-checkbox:checked');
const selectedCount = checkboxes.length; const selectedCount = checkboxes.length;
const bulkActionsBar = document.getElementById('bulkActionsBar'); const bulkActionsBar = document.getElementById('bulkActionsBar');
const selectedCountEl = document.getElementById('selectedCount'); const selectedCountEl = document.getElementById('selectedCount');
if (selectedCount > 0) { if (selectedCount > 0) {
bulkActionsBar.style.display = 'block'; bulkActionsBar.style.display = 'block';
selectedCountEl.textContent = selectedCount; selectedCountEl.textContent = selectedCount;
} else { } else {
bulkActionsBar.style.display = 'none'; bulkActionsBar.style.display = 'none';
} }
// Update "select all" checkbox state // Update "select all" checkbox state
const allCheckboxes = document.querySelectorAll('.file-checkbox'); const allCheckboxes = document.querySelectorAll('.file-checkbox');
const selectAll = document.getElementById('selectAll'); const selectAll = document.getElementById('selectAll');
selectAll.checked = allCheckboxes.length > 0 && selectedCount === allCheckboxes.length; selectAll.checked = allCheckboxes.length > 0 && selectedCount === allCheckboxes.length;
} }
function clearSelection() { function clearSelection() {
document.querySelectorAll('.file-checkbox').forEach(cb => cb.checked = false); document.querySelectorAll('.file-checkbox').forEach(cb => cb.checked = false);
document.getElementById('selectAll').checked = false; document.getElementById('selectAll').checked = false;
updateBulkActionsBar(); updateBulkActionsBar();
} }
function getSelectedFileIds() { function getSelectedFileIds() {
const checkboxes = document.querySelectorAll('.file-checkbox:checked'); const checkboxes = document.querySelectorAll('.file-checkbox:checked');
return Array.from(checkboxes).map(cb => parseInt(cb.value)); return Array.from(checkboxes).map(cb => parseInt(cb.value));
} }
function bulkDelete() { function bulkDelete() {
const fileIds = getSelectedFileIds(); const fileIds = getSelectedFileIds();
if (fileIds.length === 0) { if (fileIds.length === 0) {
alert('Please select files to delete'); alert('Please select files to delete');
return; return;
} }
if (!confirm(`Are you sure you want to delete ${fileIds.length} file(s)?`)) { if (!confirm(`Are you sure you want to delete ${fileIds.length} file(s)?`)) {
return; return;
} }
fetch('/api/files/bulk-delete', { fetch('/api/files/bulk-delete', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -749,18 +749,18 @@
alert(`Error deleting files: ${error.message}`); alert(`Error deleting files: ${error.message}`);
}); });
} }
function bulkReprocess() { function bulkReprocess() {
const fileIds = getSelectedFileIds(); const fileIds = getSelectedFileIds();
if (fileIds.length === 0) { if (fileIds.length === 0) {
alert('Please select files to reprocess'); alert('Please select files to reprocess');
return; return;
} }
if (!confirm(`Are you sure you want to reprocess ${fileIds.length} file(s)?`)) { if (!confirm(`Are you sure you want to reprocess ${fileIds.length} file(s)?`)) {
return; return;
} }
fetch('/api/files/bulk-reprocess', { fetch('/api/files/bulk-reprocess', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -791,55 +791,55 @@
alert(`Error reprocessing files: ${error.message}`); alert(`Error reprocessing files: ${error.message}`);
}); });
} }
// ===== Drag-and-Drop Upload Functionality ===== // ===== Drag-and-Drop Upload Functionality =====
const dropOverlay = document.getElementById('dropOverlay'); const dropOverlay = document.getElementById('dropOverlay');
const uploadModal = document.getElementById('uploadModal'); const uploadModal = document.getElementById('uploadModal');
const uploadStatusMessage = document.getElementById('uploadStatusMessage'); const uploadStatusMessage = document.getElementById('uploadStatusMessage');
const uploadProgressContainer = document.getElementById('uploadProgressContainer'); const uploadProgressContainer = document.getElementById('uploadProgressContainer');
let dragCounter = 0; // Track nested drag events let dragCounter = 0; // Track nested drag events
// Show overlay when dragging files over the window // Show overlay when dragging files over the window
window.addEventListener('dragenter', (e) => { window.addEventListener('dragenter', (e) => {
e.preventDefault(); e.preventDefault();
dragCounter++; dragCounter++;
// Only show overlay if dragging files // Only show overlay if dragging files
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer.types.includes('Files')) {
dropOverlay.classList.add('active'); dropOverlay.classList.add('active');
} }
}); });
window.addEventListener('dragleave', (e) => { window.addEventListener('dragleave', (e) => {
e.preventDefault(); e.preventDefault();
dragCounter--; dragCounter--;
if (dragCounter === 0) { if (dragCounter === 0) {
dropOverlay.classList.remove('active'); dropOverlay.classList.remove('active');
} }
}); });
window.addEventListener('dragover', (e) => { window.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
}); });
window.addEventListener('drop', (e) => { window.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
dragCounter = 0; dragCounter = 0;
dropOverlay.classList.remove('active'); dropOverlay.classList.remove('active');
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {
// Show upload modal // Show upload modal
uploadModal.classList.add('active'); uploadModal.classList.add('active');
uploadProgressContainer.innerHTML = ''; uploadProgressContainer.innerHTML = '';
// Process the dropped files // Process the dropped files
processFiles(e.dataTransfer.files, uploadProgressContainer, uploadStatusMessage); processFiles(e.dataTransfer.files, uploadProgressContainer, uploadStatusMessage);
} }
}); });
// Listen for upload completion event and reload the page to show new files // Listen for upload completion event and reload the page to show new files
window.addEventListener('allUploadsComplete', (e) => { window.addEventListener('allUploadsComplete', (e) => {
// Wait 2 seconds to let users see the success message // Wait 2 seconds to let users see the success message
@@ -847,7 +847,7 @@
window.location.reload(); window.location.reload();
}, 2000); }, 2000);
}); });
function closeUploadModal() { function closeUploadModal() {
uploadModal.classList.remove('active'); uploadModal.classList.remove('active');
} }
+89 -89
View File
@@ -8,10 +8,10 @@
<p class="text-gray-600 mb-4"> <p class="text-gray-600 mb-4">
Configure the Google Drive integration for DocuElevate using our setup wizard. Configure the Google Drive integration for DocuElevate using our setup wizard.
</p> </p>
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert"> <div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<p class="font-bold">Current Status:</p> <p class="font-bold">Current Status:</p>
<p>Google Drive integration is <p>Google Drive integration is
{% if is_configured %} {% if is_configured %}
<span class="text-green-700 font-semibold">configured</span>. <span class="text-green-700 font-semibold">configured</span>.
{% else %} {% else %}
@@ -27,7 +27,7 @@
<p class="mt-2"><strong>Target folder ID:</strong> {{ folder_id }}</p> <p class="mt-2"><strong>Target folder ID:</strong> {{ folder_id }}</p>
{% endif %} {% endif %}
</div> </div>
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-4"> <div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -47,7 +47,7 @@
<div class="bg-white shadow-md rounded-lg p-6 mb-8"> <div class="bg-white shadow-md rounded-lg p-6 mb-8">
<div class="flex justify-between items-start mb-4"> <div class="flex justify-between items-start mb-4">
<h2 class="text-2xl font-semibold">Authentication Method</h2> <h2 class="text-2xl font-semibold">Authentication Method</h2>
{% if is_configured %} {% if is_configured %}
<span class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> <span class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Configured Configured
@@ -58,7 +58,7 @@
</span> </span>
{% endif %} {% endif %}
</div> </div>
<div class="flex space-x-4 mb-6"> <div class="flex space-x-4 mb-6">
<button id="oauth-tab-btn" class="px-4 py-2 rounded-md {{ 'text-white bg-blue-600' if use_oauth else 'text-gray-700 bg-gray-200' }}"> <button id="oauth-tab-btn" class="px-4 py-2 rounded-md {{ 'text-white bg-blue-600' if use_oauth else 'text-gray-700 bg-gray-200' }}">
OAuth User Account OAuth User Account
@@ -67,7 +67,7 @@
Service Account Service Account
</button> </button>
</div> </div>
<!-- OAuth Tab Content --> <!-- OAuth Tab Content -->
<div id="oauth-tab" class="{{ 'block' if use_oauth else 'hidden' }}"> <div id="oauth-tab" class="{{ 'block' if use_oauth else 'hidden' }}">
<div class="mb-6"> <div class="mb-6">
@@ -132,24 +132,24 @@
<div class="mb-6"> <div class="mb-6">
<h3 class="text-xl font-medium mb-4">Step 5: Complete OAuth Setup</h3> <h3 class="text-xl font-medium mb-4">Step 5: Complete OAuth Setup</h3>
<p class="mb-4">Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.</p> <p class="mb-4">Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.</p>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label> <label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value }}"> <input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value }}">
</div> </div>
<div> <div>
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label> <label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value }}"> <input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value }}">
</div> </div>
<div> <div>
<label for="folder-id" class="block text-sm font-medium text-gray-700">Folder ID (Optional)</label> <label for="folder-id" class="block text-sm font-medium text-gray-700">Folder ID (Optional)</label>
<input type="text" id="folder-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Google Drive folder ID" value="{{ folder_id }}"> <input type="text" id="folder-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Google Drive folder ID" value="{{ folder_id }}">
<p class="text-xs text-gray-500 mt-1">Optional: You can set this after authentication if you prefer</p> <p class="text-xs text-gray-500 mt-1">Optional: You can set this after authentication if you prefer</p>
</div> </div>
<div> <div>
<button id="select-folder-btn" class="mb-2 inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <button id="select-folder-btn" class="mb-2 inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg class="h-5 w-5 mr-2 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 mr-2 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
@@ -159,7 +159,7 @@
Select Folder with Picker Select Folder with Picker
</button> </button>
</div> </div>
<div> <div>
<button id="start-oauth-flow" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <button id="start-oauth-flow" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Start Authentication Flow Start Authentication Flow
@@ -168,14 +168,14 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Service Account Tab Content --> <!-- Service Account Tab Content -->
<div id="sa-tab" class="{{ 'block' if not use_oauth else 'hidden' }}"> <div id="sa-tab" class="{{ 'block' if not use_oauth else 'hidden' }}">
<div class="mb-6"> <div class="mb-6">
<h3 class="text-xl font-medium mb-4">Service Account Configuration</h3> <h3 class="text-xl font-medium mb-4">Service Account Configuration</h3>
<p class="mb-4">Service accounts allow for server-to-server authentication without user involvement. This method is useful for background tasks.</p> <p class="mb-4">Service accounts allow for server-to-server authentication without user involvement. This method is useful for background tasks.</p>
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6"> <div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -190,14 +190,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="sa-folder-id" class="block text-sm font-medium text-gray-700">Folder ID</label> <label for="sa-folder-id" class="block text-sm font-medium text-gray-700">Folder ID</label>
<input type="text" id="sa-folder-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Google Drive folder ID" value="{{ folder_id }}"> <input type="text" id="sa-folder-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Google Drive folder ID" value="{{ folder_id }}">
<p class="text-xs text-gray-500 mt-1">This is the ID from the Google Drive folder URL where files will be saved</p> <p class="text-xs text-gray-500 mt-1">This is the ID from the Google Drive folder URL where files will be saved</p>
</div> </div>
<div> <div>
<label for="sa-credentials" class="block text-sm font-medium text-gray-700">Service Account Credentials</label> <label for="sa-credentials" class="block text-sm font-medium text-gray-700">Service Account Credentials</label>
<div class="mt-1"> <div class="mt-1">
@@ -213,7 +213,7 @@
</a> </a>
</div> </div>
</div> </div>
<div> <div>
<button id="save-sa-settings" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <button id="save-sa-settings" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Save Service Account Settings Save Service Account Settings
@@ -223,11 +223,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Token validation and status --> <!-- Token validation and status -->
<div id="token-status" class="mt-6 bg-white shadow-md rounded-lg p-6 {{ 'hidden' if not is_configured else '' }}"> <div id="token-status" class="mt-6 bg-white shadow-md rounded-lg p-6 {{ 'hidden' if not is_configured else '' }}">
<h2 class="text-xl font-semibold mb-4">Connection Status</h2> <h2 class="text-xl font-semibold mb-4">Connection Status</h2>
<div class="rounded-md {{ 'bg-green-50' if is_configured else 'bg-yellow-50' }} p-4"> <div class="rounded-md {{ 'bg-green-50' if is_configured else 'bg-yellow-50' }} p-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -252,7 +252,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 flex space-x-3"> <div class="mt-4 flex space-x-3">
<button id="test-connection" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <button id="test-connection" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Test Connection Test Connection
@@ -261,47 +261,47 @@
Refresh Token Refresh Token
</button> </button>
</div> </div>
<!-- Configuration for Worker Nodes section --> <!-- Configuration for Worker Nodes section -->
<div id="oauth-env-section" class="mt-6 p-4 bg-gray-100 rounded-md {{ 'hidden' if not use_oauth else '' }}"> <div id="oauth-env-section" class="mt-6 p-4 bg-gray-100 rounded-md {{ 'hidden' if not use_oauth else '' }}">
<h3 class="font-medium text-lg mb-2">OAuth Configuration for Worker Nodes</h3> <h3 class="font-medium text-lg mb-2">OAuth Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3"> <p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes: Copy these environment variables to configure all worker nodes:
</p> </p>
<div class="relative"> <div class="relative">
<pre id="oauth-env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>GOOGLE_DRIVE_USE_OAUTH=true <pre id="oauth-env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>GOOGLE_DRIVE_USE_OAUTH=true
GOOGLE_DRIVE_CLIENT_ID={{ client_id_value }} GOOGLE_DRIVE_CLIENT_ID={{ client_id_value }}
GOOGLE_DRIVE_CLIENT_SECRET={{ client_secret_value|default('YOUR_CLIENT_SECRET', true) }} GOOGLE_DRIVE_CLIENT_SECRET={{ client_secret_value|default('YOUR_CLIENT_SECRET', true) }}
GOOGLE_DRIVE_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }} GOOGLE_DRIVE_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }}
GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}</code></pre> GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}</code></pre>
<button id="copy-oauth-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded"> <button id="copy-oauth-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-2"> <p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration. Add these variables to your .env file or environment configuration.
</p> </p>
</div> </div>
<div id="sa-env-section" class="mt-6 p-4 bg-gray-100 rounded-md {{ 'hidden' if use_oauth else '' }}"> <div id="sa-env-section" class="mt-6 p-4 bg-gray-100 rounded-md {{ 'hidden' if use_oauth else '' }}">
<h3 class="font-medium text-lg mb-2">Service Account Configuration for Worker Nodes</h3> <h3 class="font-medium text-lg mb-2">Service Account Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3"> <p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes: Copy these environment variables to configure all worker nodes:
</p> </p>
<div class="relative"> <div class="relative">
<pre id="sa-env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>GOOGLE_DRIVE_USE_OAUTH=false <pre id="sa-env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>GOOGLE_DRIVE_USE_OAUTH=false
GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }} GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
# Make sure to add your GOOGLE_DRIVE_CREDENTIALS_JSON to your .env file</code></pre> # Make sure to add your GOOGLE_DRIVE_CREDENTIALS_JSON to your .env file</code></pre>
<button id="copy-sa-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded"> <button id="copy-sa-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-2"> <p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file along with your service account credentials JSON. Add these variables to your .env file along with your service account credentials JSON.
</p> </p>
@@ -330,7 +330,7 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
Back to Status Back to Status
</a> </a>
</div> </div>
<!-- Result Modal --> <!-- Result Modal -->
<div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog"> <div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white"> <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white">
@@ -363,7 +363,7 @@ document.addEventListener('DOMContentLoaded', function() {
const saTabBtn = document.getElementById('sa-tab-btn'); const saTabBtn = document.getElementById('sa-tab-btn');
const oauthTab = document.getElementById('oauth-tab'); const oauthTab = document.getElementById('oauth-tab');
const saTab = document.getElementById('sa-tab'); const saTab = document.getElementById('sa-tab');
// Form elements // Form elements
const clientIdInput = document.getElementById('client-id'); const clientIdInput = document.getElementById('client-id');
const clientSecretInput = document.getElementById('client-secret'); const clientSecretInput = document.getElementById('client-secret');
@@ -372,27 +372,27 @@ document.addEventListener('DOMContentLoaded', function() {
const startOauthFlowBtn = document.getElementById('start-oauth-flow'); const startOauthFlowBtn = document.getElementById('start-oauth-flow');
const saveSaSettingsBtn = document.getElementById('save-sa-settings'); const saveSaSettingsBtn = document.getElementById('save-sa-settings');
const selectFolderBtn = document.getElementById('select-folder-btn'); const selectFolderBtn = document.getElementById('select-folder-btn');
// Status and test elements // Status and test elements
const tokenStatus = document.getElementById('token-status'); const tokenStatus = document.getElementById('token-status');
const testConnectionBtn = document.getElementById('test-connection'); const testConnectionBtn = document.getElementById('test-connection');
const refreshTokenBtn = document.getElementById('refresh-token-btn'); const refreshTokenBtn = document.getElementById('refresh-token-btn');
// Environment sections // Environment sections
const oauthEnvSection = document.getElementById('oauth-env-section'); const oauthEnvSection = document.getElementById('oauth-env-section');
const saEnvSection = document.getElementById('sa-env-section'); const saEnvSection = document.getElementById('sa-env-section');
// Modal elements // Modal elements
const resultModal = document.getElementById('resultModal'); const resultModal = document.getElementById('resultModal');
const modalTitle = document.getElementById('modalTitle'); const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage'); const modalMessage = document.getElementById('modalMessage');
const modalIcon = document.getElementById('modalIcon'); const modalIcon = document.getElementById('modalIcon');
const modalClose = document.getElementById('modalClose'); const modalClose = document.getElementById('modalClose');
// Google Picker variables // Google Picker variables
let pickerApiLoaded = false; let pickerApiLoaded = false;
let pickerOAuthToken = null; let pickerOAuthToken = null;
// Google Picker API functions // Google Picker API functions
function loadPickerApi() { function loadPickerApi() {
gapi.load('picker', { gapi.load('picker', {
@@ -401,7 +401,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
} }
// Load the Google API Loader script if select folder button exists // Load the Google API Loader script if select folder button exists
if (selectFolderBtn) { if (selectFolderBtn) {
const script = document.createElement('script'); const script = document.createElement('script');
@@ -410,7 +410,7 @@ document.addEventListener('DOMContentLoaded', function() {
loadPickerApi(); loadPickerApi();
}; };
document.body.appendChild(script); document.body.appendChild(script);
// Load the GSI Client for OAuth // Load the GSI Client for OAuth
const gsiScript = document.createElement('script'); const gsiScript = document.createElement('script');
gsiScript.src = 'https://accounts.google.com/gsi/client'; gsiScript.src = 'https://accounts.google.com/gsi/client';
@@ -434,14 +434,14 @@ document.addEventListener('DOMContentLoaded', function() {
showPicker(clientId, data.access_token); showPicker(clientId, data.access_token);
} else { } else {
// If no valid token exists, inform the user they need to authenticate first // If no valid token exists, inform the user they need to authenticate first
showModal('error', 'Authentication Required', showModal('error', 'Authentication Required',
'You need to complete OAuth authentication before selecting a folder. ' + 'You need to complete OAuth authentication before selecting a folder. ' +
'Please click "Start Authentication Flow" first and then select a folder after authentication.'); 'Please click "Start Authentication Flow" first and then select a folder after authentication.');
} }
}) })
.catch(error => { .catch(error => {
console.error('Error checking token:', error); console.error('Error checking token:', error);
showModal('error', 'Authentication Required', showModal('error', 'Authentication Required',
'Unable to verify authentication status. Please complete the OAuth flow first.'); 'Unable to verify authentication status. Please complete the OAuth flow first.');
}); });
} }
@@ -451,13 +451,13 @@ document.addEventListener('DOMContentLoaded', function() {
showModal('error', 'Authentication Required', 'No access token available. Please authenticate first.'); showModal('error', 'Authentication Required', 'No access token available. Please authenticate first.');
return; return;
} }
// Use the folders view specifically // Use the folders view specifically
const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS) const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS)
.setIncludeFolders(true) .setIncludeFolders(true)
.setSelectFolderEnabled(true) .setSelectFolderEnabled(true)
.setMode(google.picker.DocsViewMode.LIST); // Use LIST mode which doesn't require broader permissions .setMode(google.picker.DocsViewMode.LIST); // Use LIST mode which doesn't require broader permissions
const picker = new google.picker.PickerBuilder() const picker = new google.picker.PickerBuilder()
.addView(folderView) .addView(folderView)
.setOAuthToken(oauthToken) .setOAuthToken(oauthToken)
@@ -468,25 +468,25 @@ document.addEventListener('DOMContentLoaded', function() {
.setSelectableMimeTypes('application/vnd.google-apps.folder') // Allow only folder selection .setSelectableMimeTypes('application/vnd.google-apps.folder') // Allow only folder selection
.setCallback(pickerCallback) .setCallback(pickerCallback)
.build(); .build();
picker.setVisible(true); picker.setVisible(true);
} }
function pickerCallback(data) { function pickerCallback(data) {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) { if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
const folder = data[google.picker.Response.DOCUMENTS][0]; const folder = data[google.picker.Response.DOCUMENTS][0];
const folderId = folder[google.picker.Document.ID]; const folderId = folder[google.picker.Document.ID];
const folderName = folder[google.picker.Document.NAME]; const folderName = folder[google.picker.Document.NAME];
// Update the folder ID input // Update the folder ID input
folderIdInput.value = folderId; folderIdInput.value = folderId;
if (saFolderIdInput) { if (saFolderIdInput) {
saFolderIdInput.value = folderId; saFolderIdInput.value = folderId;
} }
// Automatically save the folder ID to ensure it's stored // Automatically save the folder ID to ensure it's stored
saveFolderId(folderId); saveFolderId(folderId);
// Show success message // Show success message
showModal('success', 'Folder Selected', `You selected folder: "${folderName}" (ID: ${folderId}) and saved it to your configuration.`); showModal('success', 'Folder Selected', `You selected folder: "${folderName}" (ID: ${folderId}) and saved it to your configuration.`);
} }
@@ -496,21 +496,21 @@ document.addEventListener('DOMContentLoaded', function() {
function saveFolderId(folderId) { function saveFolderId(folderId) {
// Check which tab is active to determine if we're using OAuth or Service Account // Check which tab is active to determine if we're using OAuth or Service Account
const isOauthActive = !oauthTab.classList.contains('hidden'); const isOauthActive = !oauthTab.classList.contains('hidden');
// Prepare form data // Prepare form data
const formData = new FormData(); const formData = new FormData();
formData.append('folder_id', folderId); formData.append('folder_id', folderId);
formData.append('use_oauth', isOauthActive ? 'true' : 'false'); formData.append('use_oauth', isOauthActive ? 'true' : 'false');
// If using OAuth, also include client credentials if available // If using OAuth, also include client credentials if available
if (isOauthActive) { if (isOauthActive) {
const clientId = clientIdInput.value.trim(); const clientId = clientIdInput.value.trim();
const clientSecret = clientSecretInput.value.trim(); const clientSecret = clientSecretInput.value.trim();
if (clientId) formData.append('client_id', clientId); if (clientId) formData.append('client_id', clientId);
if (clientSecret) formData.append('client_secret', clientSecret); if (clientSecret) formData.append('client_secret', clientSecret);
} }
// Send the folder ID to be saved server-side // Send the folder ID to be saved server-side
fetch('/api/google-drive/save-settings', { fetch('/api/google-drive/save-settings', {
method: 'POST', method: 'POST',
@@ -531,7 +531,7 @@ document.addEventListener('DOMContentLoaded', function() {
console.log('Folder ID saved successfully'); console.log('Folder ID saved successfully');
// Make the token status visible if it was hidden // Make the token status visible if it was hidden
tokenStatus.classList.remove('hidden'); tokenStatus.classList.remove('hidden');
// Update environment variables display if they exist // Update environment variables display if they exist
updateEnvVarsDisplay(); updateEnvVarsDisplay();
} }
@@ -540,24 +540,24 @@ document.addEventListener('DOMContentLoaded', function() {
console.error('Error saving folder ID:', error); console.error('Error saving folder ID:', error);
}); });
} }
// Function to update environment variables display // Function to update environment variables display
function updateEnvVarsDisplay() { function updateEnvVarsDisplay() {
const folderId = folderIdInput.value || saFolderIdInput.value || 'YOUR_FOLDER_ID'; const folderId = folderIdInput.value || saFolderIdInput.value || 'YOUR_FOLDER_ID';
// Update OAuth env vars if the element exists // Update OAuth env vars if the element exists
const oauthEnvVarsCode = document.querySelector('#oauth-env-vars code'); const oauthEnvVarsCode = document.querySelector('#oauth-env-vars code');
if (oauthEnvVarsCode) { if (oauthEnvVarsCode) {
const clientId = clientIdInput.value || 'YOUR_CLIENT_ID'; const clientId = clientIdInput.value || 'YOUR_CLIENT_ID';
const clientSecret = clientSecretInput.value ? 'YOUR_CLIENT_SECRET' : 'YOUR_CLIENT_SECRET'; const clientSecret = clientSecretInput.value ? 'YOUR_CLIENT_SECRET' : 'YOUR_CLIENT_SECRET';
oauthEnvVarsCode.textContent = `GOOGLE_DRIVE_USE_OAUTH=true oauthEnvVarsCode.textContent = `GOOGLE_DRIVE_USE_OAUTH=true
GOOGLE_DRIVE_CLIENT_ID=${clientId} GOOGLE_DRIVE_CLIENT_ID=${clientId}
GOOGLE_DRIVE_CLIENT_SECRET=${clientSecret} GOOGLE_DRIVE_CLIENT_SECRET=${clientSecret}
GOOGLE_DRIVE_REFRESH_TOKEN=YOUR_REFRESH_TOKEN GOOGLE_DRIVE_REFRESH_TOKEN=YOUR_REFRESH_TOKEN
GOOGLE_DRIVE_FOLDER_ID=${folderId}`; GOOGLE_DRIVE_FOLDER_ID=${folderId}`;
} }
// Update Service Account env vars if the element exists // Update Service Account env vars if the element exists
const saEnvVarsCode = document.querySelector('#sa-env-vars code'); const saEnvVarsCode = document.querySelector('#sa-env-vars code');
if (saEnvVarsCode) { if (saEnvVarsCode) {
@@ -571,7 +571,7 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
function showModal(status, title, message) { function showModal(status, title, message) {
modalTitle.textContent = title; modalTitle.textContent = title;
modalMessage.textContent = message; modalMessage.textContent = message;
// Set the appropriate icon // Set the appropriate icon
if (status === 'success') { if (status === 'success') {
modalIcon.innerHTML = ` modalIcon.innerHTML = `
@@ -588,14 +588,14 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
`; `;
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4'; modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4';
} }
resultModal.classList.remove('hidden'); resultModal.classList.remove('hidden');
} }
function hideModal() { function hideModal() {
resultModal.classList.add('hidden'); resultModal.classList.add('hidden');
} }
// Close modal when clicking the close button or outside // Close modal when clicking the close button or outside
modalClose.addEventListener('click', hideModal); modalClose.addEventListener('click', hideModal);
resultModal.addEventListener('click', function(e) { resultModal.addEventListener('click', function(e) {
@@ -603,12 +603,12 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
hideModal(); hideModal();
} }
}); });
// Attach Google Picker button event listener // Attach Google Picker button event listener
if (selectFolderBtn) { if (selectFolderBtn) {
selectFolderBtn.addEventListener('click', createPicker); selectFolderBtn.addEventListener('click', createPicker);
} }
// Tab switching // Tab switching
oauthTabBtn.addEventListener('click', function() { oauthTabBtn.addEventListener('click', function() {
oauthTabBtn.className = 'px-4 py-2 rounded-md text-white bg-blue-600'; oauthTabBtn.className = 'px-4 py-2 rounded-md text-white bg-blue-600';
@@ -618,7 +618,7 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
oauthEnvSection.classList.remove('hidden'); oauthEnvSection.classList.remove('hidden');
saEnvSection.classList.add('hidden'); saEnvSection.classList.add('hidden');
}); });
saTabBtn.addEventListener('click', function() { saTabBtn.addEventListener('click', function() {
saTabBtn.className = 'px-4 py-2 rounded-md text-white bg-blue-600'; saTabBtn.className = 'px-4 py-2 rounded-md text-white bg-blue-600';
oauthTabBtn.className = 'px-4 py-2 rounded-md text-gray-700 bg-gray-200'; oauthTabBtn.className = 'px-4 py-2 rounded-md text-gray-700 bg-gray-200';
@@ -627,39 +627,39 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
saEnvSection.classList.remove('hidden'); saEnvSection.classList.remove('hidden');
oauthEnvSection.classList.add('hidden'); oauthEnvSection.classList.add('hidden');
}); });
// Sync folder IDs between tabs // Sync folder IDs between tabs
folderIdInput.addEventListener('input', function() { folderIdInput.addEventListener('input', function() {
if (saFolderIdInput) { if (saFolderIdInput) {
saFolderIdInput.value = folderIdInput.value; saFolderIdInput.value = folderIdInput.value;
} }
}); });
if (saFolderIdInput) { if (saFolderIdInput) {
saFolderIdInput.addEventListener('input', function() { saFolderIdInput.addEventListener('input', function() {
folderIdInput.value = saFolderIdInput.value; folderIdInput.value = saFolderIdInput.value;
}); });
} }
// Start OAuth flow button // Start OAuth flow button
if (startOauthFlowBtn) { if (startOauthFlowBtn) {
startOauthFlowBtn.addEventListener('click', function() { startOauthFlowBtn.addEventListener('click', function() {
const clientId = clientIdInput.value.trim(); const clientId = clientIdInput.value.trim();
const clientSecret = clientSecretInput.value.trim(); const clientSecret = clientSecretInput.value.trim();
const folderId = folderIdInput.value.trim(); const folderId = folderIdInput.value.trim();
if (!clientId) { if (!clientId) {
showModal('error', 'Validation Error', 'Please enter your Client ID'); showModal('error', 'Validation Error', 'Please enter your Client ID');
return; return;
} }
if (!clientSecret) { if (!clientSecret) {
showModal('error', 'Validation Error', 'Please enter your Client Secret'); showModal('error', 'Validation Error', 'Please enter your Client Secret');
return; return;
} }
// Don't require folder ID, make it optional // Don't require folder ID, make it optional
// Save values to session storage for use after redirect // Save values to session storage for use after redirect
sessionStorage.setItem('google_drive_client_id', clientId); sessionStorage.setItem('google_drive_client_id', clientId);
sessionStorage.setItem('google_drive_client_secret', clientSecret); sessionStorage.setItem('google_drive_client_secret', clientSecret);
@@ -667,35 +667,35 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
sessionStorage.setItem('google_drive_folder_id', folderId); sessionStorage.setItem('google_drive_folder_id', folderId);
} }
sessionStorage.setItem('google_drive_use_oauth', 'true'); sessionStorage.setItem('google_drive_use_oauth', 'true');
// Create redirect URI // Create redirect URI
const redirectUri = `${window.location.origin}/google-drive-callback`; const redirectUri = `${window.location.origin}/google-drive-callback`;
// Redirect to auth start endpoint // Redirect to auth start endpoint
window.location.href = `/google-drive-auth-start?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}`; window.location.href = `/google-drive-auth-start?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}`;
}); });
} }
// Save service account settings button // Save service account settings button
if (saveSaSettingsBtn) { if (saveSaSettingsBtn) {
saveSaSettingsBtn.addEventListener('click', function() { saveSaSettingsBtn.addEventListener('click', function() {
const folderId = saFolderIdInput.value.trim(); const folderId = saFolderIdInput.value.trim();
if (!folderId) { if (!folderId) {
showModal('error', 'Validation Error', 'Please enter your Google Drive Folder ID'); showModal('error', 'Validation Error', 'Please enter your Google Drive Folder ID');
return; return;
} }
// Prepare form data // Prepare form data
const formData = new FormData(); const formData = new FormData();
formData.append('folder_id', folderId); formData.append('folder_id', folderId);
formData.append('use_oauth', 'false'); formData.append('use_oauth', 'false');
// Send update request // Send update request
const originalText = saveSaSettingsBtn.textContent; const originalText = saveSaSettingsBtn.textContent;
saveSaSettingsBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Saving...'; saveSaSettingsBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Saving...';
saveSaSettingsBtn.disabled = true; saveSaSettingsBtn.disabled = true;
fetch('/api/google-drive/save-settings', { fetch('/api/google-drive/save-settings', {
method: 'POST', method: 'POST',
body: formData body: formData
@@ -712,7 +712,7 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
if (data.status === 'success') { if (data.status === 'success') {
showModal('success', 'Settings Saved', 'Google Drive service account settings have been saved'); showModal('success', 'Settings Saved', 'Google Drive service account settings have been saved');
tokenStatus.classList.remove('hidden'); tokenStatus.classList.remove('hidden');
// Update environment variables display // Update environment variables display
const saEnvVarsCode = document.querySelector('#sa-env-vars code'); const saEnvVarsCode = document.querySelector('#sa-env-vars code');
if (saEnvVarsCode) { if (saEnvVarsCode) {
@@ -733,14 +733,14 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
}); });
}); });
} }
// Test connection button // Test connection button
if (testConnectionBtn) { if (testConnectionBtn) {
testConnectionBtn.addEventListener('click', function() { testConnectionBtn.addEventListener('click', function() {
const originalText = testConnectionBtn.textContent; const originalText = testConnectionBtn.textContent;
testConnectionBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Testing...'; testConnectionBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Testing...';
testConnectionBtn.disabled = true; testConnectionBtn.disabled = true;
fetch('/api/google-drive/test-token') fetch('/api/google-drive/test-token')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@@ -763,32 +763,32 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
}); });
}); });
} }
// Refresh token button // Refresh token button
if (refreshTokenBtn) { if (refreshTokenBtn) {
refreshTokenBtn.addEventListener('click', function() { refreshTokenBtn.addEventListener('click', function() {
showModal('info', 'Confirm', 'This will start a new OAuth flow to obtain a fresh token. Continue?'); showModal('info', 'Confirm', 'This will start a new OAuth flow to obtain a fresh token. Continue?');
modalClose.textContent = "Cancel"; modalClose.textContent = "Cancel";
// Add a confirm button // Add a confirm button
const confirmBtn = document.createElement('button'); const confirmBtn = document.createElement('button');
confirmBtn.className = 'ml-2 px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300'; confirmBtn.className = 'ml-2 px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300';
confirmBtn.textContent = 'Continue'; confirmBtn.textContent = 'Continue';
confirmBtn.addEventListener('click', function() { confirmBtn.addEventListener('click', function() {
hideModal(); hideModal();
// Check if we have necessary info before starting flow // Check if we have necessary info before starting flow
if (!clientIdInput.value.trim() || !clientSecretInput.value.trim()) { if (!clientIdInput.value.trim() || !clientSecretInput.value.trim()) {
showModal('error', 'Missing Information', 'Please enter your Client ID and Client Secret first'); showModal('error', 'Missing Information', 'Please enter your Client ID and Client Secret first');
return; return;
} }
startOauthFlowBtn.click(); startOauthFlowBtn.click();
}); });
// Add to modal // Add to modal
modalClose.parentNode.appendChild(confirmBtn); modalClose.parentNode.appendChild(confirmBtn);
// Clean up modal when closed // Clean up modal when closed
const onModalClose = function() { const onModalClose = function() {
if (confirmBtn.parentNode) { if (confirmBtn.parentNode) {
@@ -797,15 +797,15 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
modalClose.textContent = "Close"; modalClose.textContent = "Close";
resultModal.removeEventListener('hidden', onModalClose); resultModal.removeEventListener('hidden', onModalClose);
}; };
resultModal.addEventListener('hidden', onModalClose); resultModal.addEventListener('hidden', onModalClose);
}); });
} }
// Copy environment variables buttons // Copy environment variables buttons
const copyOAuthEnvVarsBtn = document.getElementById('copy-oauth-env-vars'); const copyOAuthEnvVarsBtn = document.getElementById('copy-oauth-env-vars');
const copySAEnvVarsBtn = document.getElementById('copy-sa-env-vars'); const copySAEnvVarsBtn = document.getElementById('copy-sa-env-vars');
if (copyOAuthEnvVarsBtn) { if (copyOAuthEnvVarsBtn) {
copyOAuthEnvVarsBtn.addEventListener('click', function() { copyOAuthEnvVarsBtn.addEventListener('click', function() {
const envVarsText = document.getElementById('oauth-env-vars').textContent; const envVarsText = document.getElementById('oauth-env-vars').textContent;
@@ -823,7 +823,7 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
}); });
}); });
} }
if (copySAEnvVarsBtn) { if (copySAEnvVarsBtn) {
copySAEnvVarsBtn.addEventListener('click', function() { copySAEnvVarsBtn.addEventListener('click', function() {
const envVarsText = document.getElementById('sa-env-vars').textContent; const envVarsText = document.getElementById('sa-env-vars').textContent;
+57 -57
View File
@@ -11,15 +11,15 @@
<h2 class="text-2xl font-bold mt-4">Processing Authorization</h2> <h2 class="text-2xl font-bold mt-4">Processing Authorization</h2>
<p class="text-gray-600 mt-2">Please wait while we complete the Google Drive authorization process...</p> <p class="text-gray-600 mt-2">Please wait while we complete the Google Drive authorization process...</p>
</div> </div>
<div class="flex justify-center my-6"> <div class="flex justify-center my-6">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div> </div>
<div id="processing-message" class="text-center text-gray-700"> <div id="processing-message" class="text-center text-gray-700">
<p>Exchanging authorization code for refresh token...</p> <p>Exchanging authorization code for refresh token...</p>
</div> </div>
<div id="error-container" class="hidden mt-6"> <div id="error-container" class="hidden mt-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4"> <div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex"> <div class="flex">
@@ -42,7 +42,7 @@
</a> </a>
</div> </div>
</div> </div>
<div id="folder-selection-container" class="hidden mt-6"> <div id="folder-selection-container" class="hidden mt-6">
<div class="rounded-md bg-blue-50 p-4 mb-6"> <div class="rounded-md bg-blue-50 p-4 mb-6">
<div class="flex"> <div class="flex">
@@ -58,7 +58,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-4 mb-6"> <div class="space-y-4 mb-6">
<div> <div>
<label for="folder-id-input" class="block text-sm font-medium text-gray-700">Google Drive Folder ID</label> <label for="folder-id-input" class="block text-sm font-medium text-gray-700">Google Drive Folder ID</label>
@@ -75,7 +75,7 @@
You can paste a folder ID directly or use the selector to pick a folder. Root folder is "root". You can paste a folder ID directly or use the selector to pick a folder. Root folder is "root".
</p> </p>
</div> </div>
<div> <div>
<button id="save-folder-btn" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <button id="save-folder-btn" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Save Settings Save Settings
@@ -83,7 +83,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="success-container" class="hidden mt-6"> <div id="success-container" class="hidden mt-6">
<div class="rounded-md bg-green-50 p-4"> <div class="rounded-md bg-green-50 p-4">
<div class="flex"> <div class="flex">
@@ -99,26 +99,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6 p-4 bg-gray-100 rounded-md"> <div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3> <h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3"> <p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes: Copy these environment variables to configure all worker nodes:
</p> </p>
<div class="relative"> <div class="relative">
<pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code></code></pre> <pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code></code></pre>
<button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded"> <button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-2"> <p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration. Add these variables to your .env file or environment configuration.
</p> </p>
</div> </div>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Go to Status Page Go to Status Page
@@ -133,34 +133,34 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const code = "{{ code }}"; const code = "{{ code }}";
// Get credentials from session storage // Get credentials from session storage
const clientId = sessionStorage.getItem('google_drive_client_id'); const clientId = sessionStorage.getItem('google_drive_client_id');
const clientSecret = sessionStorage.getItem('google_drive_client_secret'); const clientSecret = sessionStorage.getItem('google_drive_client_secret');
const folderId = sessionStorage.getItem('google_drive_folder_id'); const folderId = sessionStorage.getItem('google_drive_folder_id');
const redirectUri = window.location.origin + "/google-drive-callback"; const redirectUri = window.location.origin + "/google-drive-callback";
let accessToken = null; let accessToken = null;
let refreshToken = null; let refreshToken = null;
// Define folderIdInput at the top level so it's accessible throughout the script // Define folderIdInput at the top level so it's accessible throughout the script
const folderIdInput = document.getElementById('folder-id-input'); const folderIdInput = document.getElementById('folder-id-input');
const folderSelectBtn = document.getElementById('folder-select-picker-btn'); const folderSelectBtn = document.getElementById('folder-select-picker-btn');
const saveFolderBtn = document.getElementById('save-folder-btn'); const saveFolderBtn = document.getElementById('save-folder-btn');
// Automatically exchange the code for a refresh token // Automatically exchange the code for a refresh token
if (code) { if (code) {
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
showError("Missing Client ID or Client Secret. Please go back to the setup page and try again."); showError("Missing Client ID or Client Secret. Please go back to the setup page and try again.");
return; return;
} }
exchangeCode(code, clientId, clientSecret, redirectUri, folderId); exchangeCode(code, clientId, clientSecret, redirectUri, folderId);
} else { } else {
showError("No authorization code was found in the URL"); showError("No authorization code was found in the URL");
} }
function exchangeCode(code, clientId, clientSecret, redirectUri, folderId) { function exchangeCode(code, clientId, clientSecret, redirectUri, folderId) {
const formData = new FormData(); const formData = new FormData();
formData.append('client_id', clientId); formData.append('client_id', clientId);
@@ -170,10 +170,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (folderId) { if (folderId) {
formData.append('folder_id', folderId); formData.append('folder_id', folderId);
} }
document.getElementById('processing-message').innerHTML = document.getElementById('processing-message').innerHTML =
'<p>Exchanging authorization code for refresh token...</p>'; '<p>Exchanging authorization code for refresh token...</p>';
fetch('/api/google-drive/exchange-token', { fetch('/api/google-drive/exchange-token', {
method: 'POST', method: 'POST',
body: formData body: formData
@@ -191,11 +191,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Store tokens // Store tokens
refreshToken = data.refresh_token; refreshToken = data.refresh_token;
accessToken = data.access_token; accessToken = data.access_token;
// Update settings in memory first // Update settings in memory first
const updateFormData = new FormData(); const updateFormData = new FormData();
updateFormData.append('refresh_token', data.refresh_token); updateFormData.append('refresh_token', data.refresh_token);
// Use the client ID and client secret from session storage // Use the client ID and client secret from session storage
updateFormData.append('client_id', clientId); updateFormData.append('client_id', clientId);
updateFormData.append('client_secret', clientSecret); updateFormData.append('client_secret', clientSecret);
@@ -203,10 +203,10 @@ document.addEventListener('DOMContentLoaded', function() {
updateFormData.append('folder_id', folderId); updateFormData.append('folder_id', folderId);
} }
updateFormData.append('use_oauth', 'true'); updateFormData.append('use_oauth', 'true');
document.getElementById('processing-message').innerHTML = document.getElementById('processing-message').innerHTML =
'<p>Updating system settings with new token...</p>'; '<p>Updating system settings with new token...</p>';
return fetch('/api/google-drive/update-settings', { return fetch('/api/google-drive/update-settings', {
method: 'POST', method: 'POST',
body: updateFormData body: updateFormData
@@ -225,10 +225,10 @@ document.addEventListener('DOMContentLoaded', function() {
// Show folder selection UI // Show folder selection UI
document.getElementById('processing-message').classList.add('hidden'); document.getElementById('processing-message').classList.add('hidden');
document.getElementById('folder-selection-container').classList.remove('hidden'); document.getElementById('folder-selection-container').classList.remove('hidden');
// Initialize Google Picker for folder selection // Initialize Google Picker for folder selection
loadGooglePicker(accessToken, clientId); loadGooglePicker(accessToken, clientId);
return null; // Return null to avoid further .then() processing return null; // Return null to avoid further .then() processing
} }
}); });
@@ -246,17 +246,17 @@ document.addEventListener('DOMContentLoaded', function() {
showError(error.message); showError(error.message);
}); });
} }
function showError(message) { function showError(message) {
document.getElementById('processing-message').classList.add('hidden'); document.getElementById('processing-message').classList.add('hidden');
document.getElementById('folder-selection-container').classList.add('hidden'); document.getElementById('folder-selection-container').classList.add('hidden');
document.getElementById('error-container').classList.remove('hidden'); document.getElementById('error-container').classList.remove('hidden');
document.getElementById('error-message').innerText = message; document.getElementById('error-message').innerText = message;
// Hide the spinner when showing error // Hide the spinner when showing error
document.querySelector('.animate-spin').parentNode.classList.add('hidden'); document.querySelector('.animate-spin').parentNode.classList.add('hidden');
} }
function saveSettings(refreshToken, clientId, clientSecret, folderId) { function saveSettings(refreshToken, clientId, clientSecret, folderId) {
const saveFormData = new FormData(); const saveFormData = new FormData();
saveFormData.append('refresh_token', refreshToken); saveFormData.append('refresh_token', refreshToken);
@@ -266,10 +266,10 @@ document.addEventListener('DOMContentLoaded', function() {
saveFormData.append('folder_id', folderId); saveFormData.append('folder_id', folderId);
} }
saveFormData.append('use_oauth', 'true'); saveFormData.append('use_oauth', 'true');
document.getElementById('processing-message').innerHTML = document.getElementById('processing-message').innerHTML =
'<p>Saving settings to configuration...</p>'; '<p>Saving settings to configuration...</p>';
return fetch('/api/google-drive/save-settings', { return fetch('/api/google-drive/save-settings', {
method: 'POST', method: 'POST',
body: saveFormData body: saveFormData
@@ -300,15 +300,15 @@ document.addEventListener('DOMContentLoaded', function() {
}; };
}); });
} }
function showSuccess(refreshToken, clientId, clientSecret, folderId, inMemoryOnly) { function showSuccess(refreshToken, clientId, clientSecret, folderId, inMemoryOnly) {
document.getElementById('processing-message').classList.add('hidden'); document.getElementById('processing-message').classList.add('hidden');
document.getElementById('folder-selection-container').classList.add('hidden'); document.getElementById('folder-selection-container').classList.add('hidden');
document.getElementById('success-container').classList.remove('hidden'); document.getElementById('success-container').classList.remove('hidden');
// Hide the spinner when showing success // Hide the spinner when showing success
document.querySelector('.animate-spin').parentNode.classList.add('hidden'); document.querySelector('.animate-spin').parentNode.classList.add('hidden');
// Update the environment variables pre block with the new token // Update the environment variables pre block with the new token
const envVarsCode = document.querySelector('#env-vars code'); const envVarsCode = document.querySelector('#env-vars code');
if (envVarsCode) { if (envVarsCode) {
@@ -318,7 +318,7 @@ GOOGLE_DRIVE_CLIENT_SECRET=${clientSecret}
GOOGLE_DRIVE_REFRESH_TOKEN=${refreshToken} GOOGLE_DRIVE_REFRESH_TOKEN=${refreshToken}
GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`; GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
} }
// If settings were only saved in memory, add a warning // If settings were only saved in memory, add a warning
if (inMemoryOnly) { if (inMemoryOnly) {
const successContainer = document.getElementById('success-container'); const successContainer = document.getElementById('success-container');
@@ -333,18 +333,18 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
</div> </div>
<div class="ml-3"> <div class="ml-3">
<p class="text-sm font-medium text-yellow-800"> <p class="text-sm font-medium text-yellow-800">
Note: Settings were saved in memory only. The .env file could not be updated. Note: Settings were saved in memory only. The .env file could not be updated.
Make sure to add these environment variables to your configuration files manually. Make sure to add these environment variables to your configuration files manually.
</p> </p>
</div> </div>
</div> </div>
`; `;
// Insert warning after the success message but before the environment vars section // Insert warning after the success message but before the environment vars section
const envVarsSection = document.querySelector('#success-container .mt-6'); const envVarsSection = document.querySelector('#success-container .mt-6');
successContainer.insertBefore(warningDiv, envVarsSection); successContainer.insertBefore(warningDiv, envVarsSection);
} }
// Add copy functionality // Add copy functionality
const copyEnvVarsBtn = document.getElementById('copy-env-vars'); const copyEnvVarsBtn = document.getElementById('copy-env-vars');
if (copyEnvVarsBtn) { if (copyEnvVarsBtn) {
@@ -366,18 +366,18 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
}); });
}); });
} }
// Clear session storage // Clear session storage
sessionStorage.removeItem('google_drive_client_id'); sessionStorage.removeItem('google_drive_client_id');
sessionStorage.removeItem('google_drive_client_secret'); sessionStorage.removeItem('google_drive_client_secret');
sessionStorage.removeItem('google_drive_folder_id'); sessionStorage.removeItem('google_drive_folder_id');
// In 10 seconds, redirect to status page // In 10 seconds, redirect to status page
setTimeout(() => { setTimeout(() => {
window.location.href = '/status'; window.location.href = '/status';
}, 10000); }, 10000);
} }
// Handle folder selection UI // Handle folder selection UI
if (saveFolderBtn) { if (saveFolderBtn) {
saveFolderBtn.addEventListener('click', function() { saveFolderBtn.addEventListener('click', function() {
@@ -385,11 +385,11 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
alert('Error: Folder input element not found'); alert('Error: Folder input element not found');
return; return;
} }
const folderId = folderIdInput.value.trim() || 'root'; const folderId = folderIdInput.value.trim() || 'root';
saveFolderBtn.disabled = true; saveFolderBtn.disabled = true;
saveFolderBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Saving...'; saveFolderBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Saving...';
saveSettings(refreshToken, clientId, clientSecret, folderId) saveSettings(refreshToken, clientId, clientSecret, folderId)
.then(result => { .then(result => {
showSuccess(result.refresh_token, result.client_id, result.client_secret, result.folderId); showSuccess(result.refresh_token, result.client_id, result.client_secret, result.folderId);
@@ -401,7 +401,7 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
}); });
}); });
} }
// Function to load and initialize the Google Picker // Function to load and initialize the Google Picker
function loadGooglePicker(accessToken, clientId) { function loadGooglePicker(accessToken, clientId) {
// Load the Google API Loader script // Load the Google API Loader script
@@ -414,13 +414,13 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
}; };
document.body.appendChild(script); document.body.appendChild(script);
} }
// Initialize and setup the Google Picker // Initialize and setup the Google Picker
function initGooglePicker(accessToken, clientId) { function initGooglePicker(accessToken, clientId) {
if (!accessToken || !clientId || !folderSelectBtn) { if (!accessToken || !clientId || !folderSelectBtn) {
return; return;
} }
// Setup the click handler for the folder select button // Setup the click handler for the folder select button
folderSelectBtn.addEventListener('click', function() { folderSelectBtn.addEventListener('click', function() {
// Create the folder picker view // Create the folder picker view
@@ -428,7 +428,7 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
.setIncludeFolders(true) .setIncludeFolders(true)
.setSelectFolderEnabled(true) .setSelectFolderEnabled(true)
.setMode(google.picker.DocsViewMode.LIST); // Use LIST mode to work with the drive.file scope .setMode(google.picker.DocsViewMode.LIST); // Use LIST mode to work with the drive.file scope
// Create and render the picker // Create and render the picker
const picker = new google.picker.PickerBuilder() const picker = new google.picker.PickerBuilder()
.addView(folderView) .addView(folderView)
@@ -437,35 +437,35 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId || 'YOUR_FOLDER_ID'}`;
.setTitle('Select a folder for DocuElevate') .setTitle('Select a folder for DocuElevate')
.setCallback(pickerCallback) .setCallback(pickerCallback)
.build(); .build();
picker.setVisible(true); picker.setVisible(true);
}); });
} }
// Callback function for picker // Callback function for picker
function pickerCallback(data) { function pickerCallback(data) {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) { if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
const folder = data[google.picker.Response.DOCUMENTS][0]; const folder = data[google.picker.Response.DOCUMENTS][0];
const folderId = folder[google.picker.Document.ID]; const folderId = folder[google.picker.Document.ID];
const folderName = folder[google.picker.Document.NAME]; const folderName = folder[google.picker.Document.NAME];
// Update the folder ID input // Update the folder ID input
if (folderIdInput) { if (folderIdInput) {
folderIdInput.value = folderId; folderIdInput.value = folderId;
// Add visual confirmation instead of alert // Add visual confirmation instead of alert
const confirmationMsg = document.createElement('div'); const confirmationMsg = document.createElement('div');
confirmationMsg.className = 'mt-2 text-sm text-green-600'; confirmationMsg.className = 'mt-2 text-sm text-green-600';
confirmationMsg.innerHTML = `<svg class="inline-block h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> confirmationMsg.innerHTML = `<svg class="inline-block h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg> Selected folder: "${folderName}"`; </svg> Selected folder: "${folderName}"`;
// Remove previous confirmation if it exists // Remove previous confirmation if it exists
const existingConfirmation = folderIdInput.parentNode.querySelector('.text-green-600'); const existingConfirmation = folderIdInput.parentNode.querySelector('.text-green-600');
if (existingConfirmation) { if (existingConfirmation) {
existingConfirmation.remove(); existingConfirmation.remove();
} }
// Insert the confirmation message after the input field // Insert the confirmation message after the input field
folderIdInput.parentNode.appendChild(confirmationMsg); folderIdInput.parentNode.appendChild(confirmationMsg);
} }
@@ -11,7 +11,7 @@
<h2 class="text-2xl font-bold mt-4">Authorization Failed</h2> <h2 class="text-2xl font-bold mt-4">Authorization Failed</h2>
<p class="text-gray-600 mt-2">Sorry, we couldn't complete the Google Drive authorization.</p> <p class="text-gray-600 mt-2">Sorry, we couldn't complete the Google Drive authorization.</p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4"> <div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex"> <div class="flex">
@@ -29,7 +29,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6"> <div class="mt-6">
<a href="/google-drive-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <a href="/google-drive-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Return to Setup Return to Setup
+2 -2
View File
@@ -24,7 +24,7 @@
Website: www.docuelevate.com Website: www.docuelevate.com
</p> </p>
</div> </div>
<div class="bg-white shadow rounded-lg p-6 mb-6"> <div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Business Registration</h2> <h2 class="text-2xl font-semibold mb-4">Business Registration</h2>
<p class="text-gray-700 mb-3"> <p class="text-gray-700 mb-3">
@@ -56,7 +56,7 @@
<div class="bg-white shadow rounded-lg p-6 mb-6"> <div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Online Dispute Resolution</h2> <h2 class="text-2xl font-semibold mb-4">Online Dispute Resolution</h2>
<p class="text-gray-700 mb-3"> <p class="text-gray-700 mb-3">
The European Commission provides a platform for online dispute resolution (OS): The European Commission provides a platform for online dispute resolution (OS):
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">https://ec.europa.eu/consumers/odr/</a> <a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">https://ec.europa.eu/consumers/odr/</a>
</p> </p>
<p class="text-gray-700 mb-3"> <p class="text-gray-700 mb-3">
+8 -8
View File
@@ -5,31 +5,31 @@
{% block content %} {% block content %}
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">License Information</h1> <h1 class="text-2xl font-bold mb-4">License Information</h1>
<div class="bg-white shadow-md rounded-lg p-6 mb-6"> <div class="bg-white shadow-md rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Apache License 2.0</h2> <h2 class="text-xl font-semibold mb-4">Apache License 2.0</h2>
<div class="prose"> <div class="prose">
<pre class="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded border overflow-auto max-h-96 mb-4">{{ license_text }}</pre> <pre class="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded border overflow-auto max-h-96 mb-4">{{ license_text }}</pre>
</div> </div>
<p class="mt-4 text-gray-600"> <p class="mt-4 text-gray-600">
DocuElevate is distributed under the Apache License 2.0, which is a permissive DocuElevate is distributed under the Apache License 2.0, which is a permissive
open-source software license that allows you to use, modify, distribute, and open-source software license that allows you to use, modify, distribute, and
contribute to the project. contribute to the project.
</p> </p>
</div> </div>
<div class="bg-white shadow-md rounded-lg p-6"> <div class="bg-white shadow-md rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Related Information</h2> <h2 class="text-xl font-semibold mb-4">Related Information</h2>
<p class="mb-3 text-gray-600"> <p class="mb-3 text-gray-600">
While this license governs the use of our software, please also review our While this license governs the use of our software, please also review our
<a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> and <a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> and
<a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> for <a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> for
information about using the DocuElevate service. information about using the DocuElevate service.
</p> </p>
<p class="text-gray-600"> <p class="text-gray-600">
For more information about DocuElevate, please visit the For more information about DocuElevate, please visit the
<a href="/about" class="text-blue-600 hover:underline">About page</a>. <a href="/about" class="text-blue-600 hover:underline">About page</a>.
</p> </p>
</div> </div>
+9 -9
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Login</title> <title>DocuElevate - Login</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" /> crossorigin="anonymous" referrerpolicy="no-referrer" />
</head> </head>
<body class="bg-gray-100 h-screen flex items-center justify-center"> <body class="bg-gray-100 h-screen flex items-center justify-center">
@@ -16,7 +16,7 @@
</div> </div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-6">Welcome to DocuElevate</h1> <h1 class="text-2xl font-bold text-center text-gray-800 mb-6">Welcome to DocuElevate</h1>
{% if error %} {% if error %}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert"> <div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
<p>{{ error }}</p> <p>{{ error }}</p>
@@ -35,16 +35,16 @@
<form method="POST" action="/auth" class="space-y-4"> <form method="POST" action="/auth" class="space-y-4">
<div> <div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label> <label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<input type="text" id="username" name="username" required <input type="text" id="username" name="username" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"> class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label> <label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" id="password" name="password" required <input type="password" id="password" name="password" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"> class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
</div> </div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign in Sign in
</button> </button>
@@ -63,7 +63,7 @@
</div> </div>
<div class="mt-6 grid grid-cols-1 gap-3"> <div class="mt-6 grid grid-cols-1 gap-3">
<a href="/oauth-login" <a href="/oauth-login"
class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<span class="sr-only">Sign in with SSO</span> <span class="sr-only">Sign in with SSO</span>
<i class="fas fa-lock mr-2"></i> <i class="fas fa-lock mr-2"></i>
@@ -71,7 +71,7 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
<div class="mt-8 text-center"> <div class="mt-8 text-center">
<a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500"> <a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Return to Home Return to Home
+39 -39
View File
@@ -8,10 +8,10 @@
<p class="text-gray-600 mb-4"> <p class="text-gray-600 mb-4">
Configure the Microsoft OneDrive integration for DocuElevate using our setup wizard. Configure the Microsoft OneDrive integration for DocuElevate using our setup wizard.
</p> </p>
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert"> <div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<p class="font-bold">Current Status:</p> <p class="font-bold">Current Status:</p>
<p>OneDrive integration is <p>OneDrive integration is
{% if is_configured %} {% if is_configured %}
<span class="text-green-700 font-semibold">configured</span>. <span class="text-green-700 font-semibold">configured</span>.
{% else %} {% else %}
@@ -22,7 +22,7 @@
<p class="mt-2"><strong>Target folder:</strong> {{ folder_path }}</p> <p class="mt-2"><strong>Target folder:</strong> {{ folder_path }}</p>
{% endif %} {% endif %}
</div> </div>
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-4"> <div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -41,7 +41,7 @@
<div class="bg-white shadow-md rounded-lg p-6 mb-8"> <div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-2xl font-semibold mb-4">Quick Setup Guide</h2> <h2 class="text-2xl font-semibold mb-4">Quick Setup Guide</h2>
<div class="mb-6"> <div class="mb-6">
<h3 class="text-xl font-medium mb-4">Step 1: Register an Azure Application</h3> <h3 class="text-xl font-medium mb-4">Step 1: Register an Azure Application</h3>
<ol class="list-decimal ml-6 space-y-3"> <ol class="list-decimal ml-6 space-y-3">
@@ -94,36 +94,36 @@
<div class="bg-white shadow-md rounded-lg p-6 mb-8"> <div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-2xl font-semibold mb-4">Complete Setup with Wizard</h2> <h2 class="text-2xl font-semibold mb-4">Complete Setup with Wizard</h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label> <label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value }}"> <input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value }}">
</div> </div>
<div> <div>
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label> <label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value }}"> <input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value }}">
</div> </div>
<div> <div>
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label> <label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}"> <input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p> <p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
</div> </div>
<div> <div>
<label for="folder-path" class="block text-sm font-medium text-gray-700">Folder Path (Optional)</label> <label for="folder-path" class="block text-sm font-medium text-gray-700">Folder Path (Optional)</label>
<input type="text" id="folder-path" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Documents/Uploads" value="{{ folder_path }}"> <input type="text" id="folder-path" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Documents/Uploads" value="{{ folder_path }}">
<p class="text-xs text-gray-500 mt-1">Enter the folder path where files should be uploaded (e.g., Documents/Uploads)</p> <p class="text-xs text-gray-500 mt-1">Enter the folder path where files should be uploaded (e.g., Documents/Uploads)</p>
</div> </div>
<div> <div>
<button id="start-auth-flow" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <button id="start-auth-flow" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Start Authentication Flow Start Authentication Flow
</button> </button>
</div> </div>
<!-- Token validation and status --> <!-- Token validation and status -->
<div id="token-status" class="mt-6 {{ 'hidden' if not is_configured else '' }}"> <div id="token-status" class="mt-6 {{ 'hidden' if not is_configured else '' }}">
<div class="rounded-md {{ 'bg-green-50' if is_configured else 'bg-yellow-50' }} p-4"> <div class="rounded-md {{ 'bg-green-50' if is_configured else 'bg-yellow-50' }} p-4">
@@ -150,7 +150,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 flex space-x-3"> <div class="mt-4 flex space-x-3">
<button id="test-token" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <button id="test-token" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Test Token Test Token
@@ -159,26 +159,26 @@
Refresh Token Refresh Token
</button> </button>
</div> </div>
<!-- Configuration for Worker Nodes section --> <!-- Configuration for Worker Nodes section -->
<div class="mt-6 p-4 bg-gray-100 rounded-md"> <div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3> <h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3"> <p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes: Copy these environment variables to configure all worker nodes:
</p> </p>
<div class="relative"> <div class="relative">
<pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>ONEDRIVE_CLIENT_ID={{ client_id_value }} <pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>ONEDRIVE_CLIENT_ID={{ client_id_value }}
ONEDRIVE_CLIENT_SECRET={{ client_secret_value|default('YOUR_CLIENT_SECRET', true) }} ONEDRIVE_CLIENT_SECRET={{ client_secret_value|default('YOUR_CLIENT_SECRET', true) }}
ONEDRIVE_TENANT_ID={{ tenant_id }} ONEDRIVE_TENANT_ID={{ tenant_id }}
ONEDRIVE_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }} ONEDRIVE_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }}
ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code></pre> ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code></pre>
<button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded"> <button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-2"> <p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration. Add these variables to your .env file or environment configuration.
</p> </p>
@@ -209,7 +209,7 @@ ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code>
Back to Status Back to Status
</a> </a>
</div> </div>
<!-- Result Modal --> <!-- Result Modal -->
<div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog"> <div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white"> <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white">
@@ -243,19 +243,19 @@ document.addEventListener('DOMContentLoaded', function() {
const refreshTokenBtn = document.getElementById('refresh-token-btn'); const refreshTokenBtn = document.getElementById('refresh-token-btn');
const tokenStatus = document.getElementById('token-status'); const tokenStatus = document.getElementById('token-status');
const clientSecretInput = document.getElementById('client-secret'); const clientSecretInput = document.getElementById('client-secret');
// Modal elements // Modal elements
const resultModal = document.getElementById('resultModal'); const resultModal = document.getElementById('resultModal');
const modalTitle = document.getElementById('modalTitle'); const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage'); const modalMessage = document.getElementById('modalMessage');
const modalIcon = document.getElementById('modalIcon'); const modalIcon = document.getElementById('modalIcon');
const modalClose = document.getElementById('modalClose'); const modalClose = document.getElementById('modalClose');
// Modal functions // Modal functions
function showModal(status, title, message) { function showModal(status, title, message) {
modalTitle.textContent = title; modalTitle.textContent = title;
modalMessage.textContent = message; modalMessage.textContent = message;
// Set the appropriate icon // Set the appropriate icon
if (status === 'success') { if (status === 'success') {
modalIcon.innerHTML = ` modalIcon.innerHTML = `
@@ -272,24 +272,24 @@ document.addEventListener('DOMContentLoaded', function() {
`; `;
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4'; modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4';
} }
resultModal.classList.remove('hidden'); resultModal.classList.remove('hidden');
} }
function hideModal() { function hideModal() {
resultModal.classList.add('hidden'); resultModal.classList.add('hidden');
} }
// Close modal when clicking the close button // Close modal when clicking the close button
modalClose.addEventListener('click', hideModal); modalClose.addEventListener('click', hideModal);
// Close modal when clicking outside of it // Close modal when clicking outside of it
resultModal.addEventListener('click', function(e) { resultModal.addEventListener('click', function(e) {
if (e.target === resultModal) { if (e.target === resultModal) {
hideModal(); hideModal();
} }
}); });
// Start Authentication Flow button click // Start Authentication Flow button click
startAuthFlowBtn.addEventListener('click', function() { startAuthFlowBtn.addEventListener('click', function() {
const clientId = document.getElementById('client-id').value.trim(); const clientId = document.getElementById('client-id').value.trim();
@@ -302,7 +302,7 @@ document.addEventListener('DOMContentLoaded', function() {
showModal('error', 'Validation Error', 'Please enter your Client ID'); showModal('error', 'Validation Error', 'Please enter your Client ID');
return; return;
} }
if (!clientSecret) { if (!clientSecret) {
showModal('error', 'Validation Error', 'Please enter your Client Secret'); showModal('error', 'Validation Error', 'Please enter your Client Secret');
return; return;
@@ -312,14 +312,14 @@ document.addEventListener('DOMContentLoaded', function() {
sessionStorage.setItem('onedrive_client_id', clientId); sessionStorage.setItem('onedrive_client_id', clientId);
sessionStorage.setItem('onedrive_client_secret', clientSecret); sessionStorage.setItem('onedrive_client_secret', clientSecret);
sessionStorage.setItem('onedrive_tenant_id', tenantId); sessionStorage.setItem('onedrive_tenant_id', tenantId);
if (folderPath) { if (folderPath) {
sessionStorage.setItem('onedrive_folder_path', folderPath); sessionStorage.setItem('onedrive_folder_path', folderPath);
} }
// Generate the authorization URL with .default scope // Generate the authorization URL with .default scope
const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${encodeURIComponent(clientId)}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&response_mode=query&scope=${encodeURIComponent('https://graph.microsoft.com/.default offline_access')}&prompt=consent`; const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${encodeURIComponent(clientId)}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&response_mode=query&scope=${encodeURIComponent('https://graph.microsoft.com/.default offline_access')}&prompt=consent`;
// Redirect the user to the Microsoft login page // Redirect the user to the Microsoft login page
window.location.href = authUrl; window.location.href = authUrl;
}); });
@@ -329,7 +329,7 @@ document.addEventListener('DOMContentLoaded', function() {
testTokenBtn.addEventListener('click', function() { testTokenBtn.addEventListener('click', function() {
testTokenBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Testing...'; testTokenBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Testing...';
testTokenBtn.disabled = true; testTokenBtn.disabled = true;
fetch('/api/onedrive/test-token') fetch('/api/onedrive/test-token')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@@ -360,13 +360,13 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
} }
// Refresh Token button click // Refresh Token button click
if (refreshTokenBtn) { if (refreshTokenBtn) {
refreshTokenBtn.addEventListener('click', function() { refreshTokenBtn.addEventListener('click', function() {
showModal('info', 'Confirm', 'This will start a new authentication flow to obtain a fresh token from Microsoft. Continue?'); showModal('info', 'Confirm', 'This will start a new authentication flow to obtain a fresh token from Microsoft. Continue?');
modalClose.textContent = "Cancel"; modalClose.textContent = "Cancel";
// Add a confirm button // Add a confirm button
const confirmBtn = document.createElement('button'); const confirmBtn = document.createElement('button');
confirmBtn.className = 'ml-2 px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300'; confirmBtn.className = 'ml-2 px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300';
@@ -375,10 +375,10 @@ document.addEventListener('DOMContentLoaded', function() {
hideModal(); hideModal();
startAuthFlowBtn.click(); startAuthFlowBtn.click();
}); });
// Add to modal // Add to modal
modalClose.parentNode.appendChild(confirmBtn); modalClose.parentNode.appendChild(confirmBtn);
// Make sure to remove the confirm button when modal is closed // Make sure to remove the confirm button when modal is closed
const removeConfirmBtn = function() { const removeConfirmBtn = function() {
if (confirmBtn.parentNode) { if (confirmBtn.parentNode) {
@@ -387,11 +387,11 @@ document.addEventListener('DOMContentLoaded', function() {
modalClose.textContent = "Close"; modalClose.textContent = "Close";
modalClose.removeEventListener('click', removeConfirmBtn); modalClose.removeEventListener('click', removeConfirmBtn);
}; };
modalClose.addEventListener('click', removeConfirmBtn, { once: true }); modalClose.addEventListener('click', removeConfirmBtn, { once: true });
}); });
} }
// Copy Environment Variables Button // Copy Environment Variables Button
const copyEnvVarsBtn = document.getElementById('copy-env-vars'); const copyEnvVarsBtn = document.getElementById('copy-env-vars');
if (copyEnvVarsBtn) { if (copyEnvVarsBtn) {
@@ -413,30 +413,30 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
} }
// Try to retrieve values from session storage (if coming back from auth or browser refresh) // Try to retrieve values from session storage (if coming back from auth or browser refresh)
if (clientSecretInput && !clientSecretInput.value && sessionStorage.getItem('onedrive_client_secret')) { if (clientSecretInput && !clientSecretInput.value && sessionStorage.getItem('onedrive_client_secret')) {
clientSecretInput.value = sessionStorage.getItem('onedrive_client_secret'); clientSecretInput.value = sessionStorage.getItem('onedrive_client_secret');
} }
// Also check for client ID in session storage // Also check for client ID in session storage
const clientIdInput = document.getElementById('client-id'); const clientIdInput = document.getElementById('client-id');
if (clientIdInput && !clientIdInput.value && sessionStorage.getItem('onedrive_client_id')) { if (clientIdInput && !clientIdInput.value && sessionStorage.getItem('onedrive_client_id')) {
clientIdInput.value = sessionStorage.getItem('onedrive_client_id'); clientIdInput.value = sessionStorage.getItem('onedrive_client_id');
} }
// Check for tenant ID in session storage // Check for tenant ID in session storage
const tenantIdInput = document.getElementById('tenant-id'); const tenantIdInput = document.getElementById('tenant-id');
if (tenantIdInput && !tenantIdInput.value && sessionStorage.getItem('onedrive_tenant_id')) { if (tenantIdInput && !tenantIdInput.value && sessionStorage.getItem('onedrive_tenant_id')) {
tenantIdInput.value = sessionStorage.getItem('onedrive_tenant_id'); tenantIdInput.value = sessionStorage.getItem('onedrive_tenant_id');
} }
// Check for folder path in session storage // Check for folder path in session storage
const folderPathInput = document.getElementById('folder-path'); const folderPathInput = document.getElementById('folder-path');
if (folderPathInput && !folderPathInput.value && sessionStorage.getItem('onedrive_folder_path')) { if (folderPathInput && !folderPathInput.value && sessionStorage.getItem('onedrive_folder_path')) {
folderPathInput.value = sessionStorage.getItem('onedrive_folder_path'); folderPathInput.value = sessionStorage.getItem('onedrive_folder_path');
} }
// If token is not configured but we have a client ID, show the token status section // If token is not configured but we have a client ID, show the token status section
if (document.getElementById('client-id').value && !tokenStatus.classList.contains('hidden')) { if (document.getElementById('client-id').value && !tokenStatus.classList.contains('hidden')) {
tokenStatus.classList.remove('hidden'); tokenStatus.classList.remove('hidden');
+30 -30
View File
@@ -11,15 +11,15 @@
<h2 class="text-2xl font-bold mt-4">Processing Authorization</h2> <h2 class="text-2xl font-bold mt-4">Processing Authorization</h2>
<p class="text-gray-600 mt-2">Please wait while we complete the OneDrive authorization process...</p> <p class="text-gray-600 mt-2">Please wait while we complete the OneDrive authorization process...</p>
</div> </div>
<div class="flex justify-center my-6"> <div class="flex justify-center my-6">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div> </div>
<div id="processing-message" class="text-center text-gray-700"> <div id="processing-message" class="text-center text-gray-700">
<p>Exchanging authorization code for refresh token...</p> <p>Exchanging authorization code for refresh token...</p>
</div> </div>
<div id="error-container" class="hidden mt-6"> <div id="error-container" class="hidden mt-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4"> <div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex"> <div class="flex">
@@ -42,7 +42,7 @@
</a> </a>
</div> </div>
</div> </div>
<div id="success-container" class="hidden mt-6"> <div id="success-container" class="hidden mt-6">
<div class="rounded-md bg-green-50 p-4"> <div class="rounded-md bg-green-50 p-4">
<div class="flex"> <div class="flex">
@@ -58,26 +58,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6 p-4 bg-gray-100 rounded-md"> <div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3> <h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3"> <p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes: Copy these environment variables to configure all worker nodes:
</p> </p>
<div class="relative"> <div class="relative">
<pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code></code></pre> <pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code></code></pre>
<button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded"> <button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-2"> <p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration. Add these variables to your .env file or environment configuration.
</p> </p>
</div> </div>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Go to Status Page Go to Status Page
@@ -92,27 +92,27 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const code = "{{ code }}"; const code = "{{ code }}";
// Get credentials from session storage (these take precedence over server-provided values) // Get credentials from session storage (these take precedence over server-provided values)
const clientId = sessionStorage.getItem('onedrive_client_id') || "{{ client_id_value }}"; const clientId = sessionStorage.getItem('onedrive_client_id') || "{{ client_id_value }}";
const clientSecret = sessionStorage.getItem('onedrive_client_secret') || "{{ client_secret_value }}"; const clientSecret = sessionStorage.getItem('onedrive_client_secret') || "{{ client_secret_value }}";
const tenantId = sessionStorage.getItem('onedrive_tenant_id') || "{{ tenant_id }}" || "common"; const tenantId = sessionStorage.getItem('onedrive_tenant_id') || "{{ tenant_id }}" || "common";
const folderPath = sessionStorage.getItem('onedrive_folder_path') || ""; const folderPath = sessionStorage.getItem('onedrive_folder_path') || "";
const redirectUri = window.location.origin + "/onedrive-callback"; const redirectUri = window.location.origin + "/onedrive-callback";
// Automatically exchange the code for a refresh token // Automatically exchange the code for a refresh token
if (code) { if (code) {
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
showError("Missing Client ID or Client Secret. Please go back to the setup page and try again."); showError("Missing Client ID or Client Secret. Please go back to the setup page and try again.");
return; return;
} }
exchangeCode(code, clientId, clientSecret, redirectUri, tenantId, folderPath); exchangeCode(code, clientId, clientSecret, redirectUri, tenantId, folderPath);
} else { } else {
showError("No authorization code was found in the URL"); showError("No authorization code was found in the URL");
} }
function exchangeCode(code, clientId, clientSecret, redirectUri, tenantId, folderPath) { function exchangeCode(code, clientId, clientSecret, redirectUri, tenantId, folderPath) {
const formData = new FormData(); const formData = new FormData();
formData.append('client_id', clientId); formData.append('client_id', clientId);
@@ -120,12 +120,12 @@ document.addEventListener('DOMContentLoaded', function() {
formData.append('redirect_uri', redirectUri); formData.append('redirect_uri', redirectUri);
formData.append('code', code); formData.append('code', code);
formData.append('tenant_id', tenantId); formData.append('tenant_id', tenantId);
// Show more details in processing message // Show more details in processing message
document.getElementById('processing-message').innerHTML = document.getElementById('processing-message').innerHTML =
'<p>Exchanging authorization code for refresh token...</p>' + '<p>Exchanging authorization code for refresh token...</p>' +
'<p class="text-xs text-gray-500 mt-2">Using tenant: ' + (tenantId || 'common') + '</p>'; '<p class="text-xs text-gray-500 mt-2">Using tenant: ' + (tenantId || 'common') + '</p>';
fetch('/api/onedrive/exchange-token', { fetch('/api/onedrive/exchange-token', {
method: 'POST', method: 'POST',
body: formData body: formData
@@ -143,19 +143,19 @@ document.addEventListener('DOMContentLoaded', function() {
// Instead of saving to .env file, update settings in memory // Instead of saving to .env file, update settings in memory
const updateFormData = new FormData(); const updateFormData = new FormData();
updateFormData.append('refresh_token', data.refresh_token); updateFormData.append('refresh_token', data.refresh_token);
// Use the values from session storage // Use the values from session storage
updateFormData.append('client_id', clientId); updateFormData.append('client_id', clientId);
updateFormData.append('client_secret', clientSecret); updateFormData.append('client_secret', clientSecret);
updateFormData.append('tenant_id', tenantId); updateFormData.append('tenant_id', tenantId);
if (folderPath) { if (folderPath) {
updateFormData.append('folder_path', folderPath); updateFormData.append('folder_path', folderPath);
} }
document.getElementById('processing-message').innerHTML = document.getElementById('processing-message').innerHTML =
'<p>Updating system settings with new token...</p>'; '<p>Updating system settings with new token...</p>';
return fetch('/api/onedrive/update-settings', { return fetch('/api/onedrive/update-settings', {
method: 'POST', method: 'POST',
body: updateFormData body: updateFormData
@@ -169,12 +169,12 @@ document.addEventListener('DOMContentLoaded', function() {
}).then(() => { }).then(() => {
// Show the success message and environment variables // Show the success message and environment variables
showSuccess(data.refresh_token, clientId, clientSecret, tenantId, folderPath); showSuccess(data.refresh_token, clientId, clientSecret, tenantId, folderPath);
// In 10 seconds, redirect to status page (giving more time to copy) // In 10 seconds, redirect to status page (giving more time to copy)
setTimeout(() => { setTimeout(() => {
window.location.href = '/status'; window.location.href = '/status';
}, 10000); }, 10000);
// Clean up session storage // Clean up session storage
sessionStorage.removeItem('onedrive_client_id'); sessionStorage.removeItem('onedrive_client_id');
sessionStorage.removeItem('onedrive_client_secret'); sessionStorage.removeItem('onedrive_client_secret');
@@ -189,23 +189,23 @@ document.addEventListener('DOMContentLoaded', function() {
showError(error.message); showError(error.message);
}); });
} }
function showError(message) { function showError(message) {
document.getElementById('processing-message').classList.add('hidden'); document.getElementById('processing-message').classList.add('hidden');
document.getElementById('error-container').classList.remove('hidden'); document.getElementById('error-container').classList.remove('hidden');
document.getElementById('error-message').innerText = message; document.getElementById('error-message').innerText = message;
// Hide the spinner when showing error // Hide the spinner when showing error
document.querySelector('.animate-spin').parentNode.classList.add('hidden'); document.querySelector('.animate-spin').parentNode.classList.add('hidden');
} }
function showSuccess(refreshToken, clientId, clientSecret, tenantId, folderPath) { function showSuccess(refreshToken, clientId, clientSecret, tenantId, folderPath) {
document.getElementById('processing-message').classList.add('hidden'); document.getElementById('processing-message').classList.add('hidden');
document.getElementById('success-container').classList.remove('hidden'); document.getElementById('success-container').classList.remove('hidden');
// Hide the spinner when showing success // Hide the spinner when showing success
document.querySelector('.animate-spin').parentNode.classList.add('hidden'); document.querySelector('.animate-spin').parentNode.classList.add('hidden');
// Update the environment variables pre block with the new token // Update the environment variables pre block with the new token
const envVarsCode = document.querySelector('#env-vars code'); const envVarsCode = document.querySelector('#env-vars code');
if (envVarsCode) { if (envVarsCode) {
@@ -215,7 +215,7 @@ ONEDRIVE_TENANT_ID=${tenantId || 'common'}
ONEDRIVE_REFRESH_TOKEN=${refreshToken} ONEDRIVE_REFRESH_TOKEN=${refreshToken}
ONEDRIVE_FOLDER_PATH=${folderPath || 'Documents/Uploads'}`; ONEDRIVE_FOLDER_PATH=${folderPath || 'Documents/Uploads'}`;
} }
// Add copy functionality // Add copy functionality
const copyEnvVarsBtn = document.getElementById('copy-env-vars'); const copyEnvVarsBtn = document.getElementById('copy-env-vars');
if (copyEnvVarsBtn) { if (copyEnvVarsBtn) {
@@ -11,7 +11,7 @@
<h2 class="text-2xl font-bold mt-4">Authorization Failed</h2> <h2 class="text-2xl font-bold mt-4">Authorization Failed</h2>
<p class="text-gray-600 mt-2">Sorry, we couldn't complete the OneDrive authorization.</p> <p class="text-gray-600 mt-2">Sorry, we couldn't complete the OneDrive authorization.</p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4"> <div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex"> <div class="flex">
@@ -29,7 +29,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6"> <div class="mt-6">
<a href="/onedrive-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <a href="/onedrive-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Return to Setup Return to Setup
+13 -13
View File
@@ -40,7 +40,7 @@
<!-- Alert Messages --> <!-- Alert Messages -->
<div x-show="showAlert" x-transition class="mb-4"> <div x-show="showAlert" x-transition class="mb-4">
<div :class="alertType === 'success' ? 'bg-green-100 border-green-500 text-green-700' : 'bg-red-100 border-red-500 text-red-700'" <div :class="alertType === 'success' ? 'bg-green-100 border-green-500 text-green-700' : 'bg-red-100 border-red-500 text-red-700'"
class="border-l-4 p-4" role="alert"> class="border-l-4 p-4" role="alert">
<p class="font-bold" x-text="alertTitle"></p> <p class="font-bold" x-text="alertTitle"></p>
<p x-text="alertMessage"></p> <p x-text="alertMessage"></p>
@@ -85,7 +85,7 @@
{{ setting.source_label }} {{ setting.source_label }}
</span> </span>
</div> </div>
<p class="text-xs text-gray-500 mb-2"> <p class="text-xs text-gray-500 mb-2">
{{ setting.metadata.description }} {{ setting.metadata.description }}
</p> </p>
@@ -93,9 +93,9 @@
{% if setting.metadata.type == 'boolean' %} {% if setting.metadata.type == 'boolean' %}
<!-- Boolean/Checkbox Input --> <!-- Boolean/Checkbox Input -->
<div class="flex items-center"> <div class="flex items-center">
<input <input
type="checkbox" type="checkbox"
id="{{ setting.key }}" id="{{ setting.key }}"
name="{{ setting.key }}" name="{{ setting.key }}"
:checked="formData['{{ setting.key }}'] === 'true' || formData['{{ setting.key }}'] === true" :checked="formData['{{ setting.key }}'] === 'true' || formData['{{ setting.key }}'] === true"
@change="formData['{{ setting.key }}'] = $event.target.checked ? 'true' : 'false'" @change="formData['{{ setting.key }}'] = $event.target.checked ? 'true' : 'false'"
@@ -111,9 +111,9 @@
{% if setting.metadata.sensitive %} {% if setting.metadata.sensitive %}
<!-- Sensitive Field with Show/Hide Toggle --> <!-- Sensitive Field with Show/Hide Toggle -->
<div class="relative"> <div class="relative">
<input <input
:type="showPassword['{{ setting.key }}'] ? 'text' : 'password'" :type="showPassword['{{ setting.key }}'] ? 'text' : 'password'"
id="{{ setting.key }}" id="{{ setting.key }}"
name="{{ setting.key }}" name="{{ setting.key }}"
x-model="formData['{{ setting.key }}']" x-model="formData['{{ setting.key }}']"
class="setting-input w-full px-3 py-2 pr-24 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" class="setting-input w-full px-3 py-2 pr-24 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
@@ -126,7 +126,7 @@
<i class="fas fa-lock"></i> <i class="fas fa-lock"></i>
</span> </span>
<!-- Show/Hide Toggle --> <!-- Show/Hide Toggle -->
<button <button
type="button" type="button"
@click="togglePassword('{{ setting.key }}')" @click="togglePassword('{{ setting.key }}')"
class="text-gray-400 hover:text-gray-600 focus:outline-none" class="text-gray-400 hover:text-gray-600 focus:outline-none"
@@ -138,9 +138,9 @@
</div> </div>
{% else %} {% else %}
<!-- Non-Sensitive Field --> <!-- Non-Sensitive Field -->
<input <input
type="text" type="text"
id="{{ setting.key }}" id="{{ setting.key }}"
name="{{ setting.key }}" name="{{ setting.key }}"
x-model="formData['{{ setting.key }}']" x-model="formData['{{ setting.key }}']"
class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
@@ -159,14 +159,14 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-end space-x-4 mt-6"> <div class="flex justify-end space-x-4 mt-6">
<button <button
type="button" type="button"
@click="resetForm" @click="resetForm"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
> >
Reset Reset
</button> </button>
<button <button
type="submit" type="submit"
:disabled="saving" :disabled="saving"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
+14 -14
View File
@@ -23,7 +23,7 @@
{% block content %} {% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<!-- Wizard Header --> <!-- Wizard Header -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-2"> <h1 class="text-4xl font-bold text-gray-900 mb-2">
@@ -44,7 +44,7 @@
<div class="w-full bg-gray-200 rounded-full h-3"> <div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-indigo-600 h-3 rounded-full transition-all duration-500" style="width: {{ progress_percent }}%"></div> <div class="bg-indigo-600 h-3 rounded-full transition-all duration-500" style="width: {{ progress_percent }}%"></div>
</div> </div>
<!-- Step Indicators --> <!-- Step Indicators -->
<div class="flex justify-between mt-4"> <div class="flex justify-between mt-4">
{% for step_num in range(1, max_step + 1) %} {% for step_num in range(1, max_step + 1) %}
@@ -66,7 +66,7 @@
<!-- Wizard Card --> <!-- Wizard Card -->
<div class="bg-white rounded-lg shadow-xl overflow-hidden"> <div class="bg-white rounded-lg shadow-xl overflow-hidden">
<!-- Card Header --> <!-- Card Header -->
<div class="bg-indigo-600 px-6 py-4"> <div class="bg-indigo-600 px-6 py-4">
<h2 class="text-2xl font-bold text-white"> <h2 class="text-2xl font-bold text-white">
@@ -79,7 +79,7 @@
<!-- Card Body --> <!-- Card Body -->
<form method="post" action="/setup" class="px-6 py-8"> <form method="post" action="/setup" class="px-6 py-8">
<input type="hidden" name="step" value="{{ current_step }}"> <input type="hidden" name="step" value="{{ current_step }}">
{% if request.query_params.get('error') == 'save_failed' %} {% if request.query_params.get('error') == 'save_failed' %}
<div class="mb-6 bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert"> <div class="mb-6 bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p class="font-bold">⚠️ Error</p> <p class="font-bold">⚠️ Error</p>
@@ -96,7 +96,7 @@
<span class="text-red-600">*</span> <span class="text-red-600">*</span>
{% endif %} {% endif %}
</label> </label>
<p class="text-xs text-gray-500 mb-3"> <p class="text-xs text-gray-500 mb-3">
{{ setting.description }} {{ setting.description }}
</p> </p>
@@ -106,8 +106,8 @@
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="radio" name="session_secret_mode" value="auto" checked <input type="radio" name="session_secret_mode" value="auto" checked
class="form-radio text-indigo-600" class="form-radio text-indigo-600"
onchange="document.getElementById('session_secret').value = 'auto-generate'; document.getElementById('session_secret').disabled = true;"> onchange="document.getElementById('session_secret').value = 'auto-generate'; document.getElementById('session_secret').disabled = true;">
<span class="ml-2 text-sm">Auto-generate (recommended)</span> <span class="ml-2 text-sm">Auto-generate (recommended)</span>
</label> </label>
@@ -118,7 +118,7 @@
<span class="ml-2 text-sm">Enter manually</span> <span class="ml-2 text-sm">Enter manually</span>
</label> </label>
</div> </div>
<input <input
type="{% if setting.sensitive %}password{% else %}text{% endif %}" type="{% if setting.sensitive %}password{% else %}text{% endif %}"
id="{{ setting.key }}" id="{{ setting.key }}"
name="{{ setting.key }}" name="{{ setting.key }}"
@@ -130,7 +130,7 @@
</div> </div>
{% else %} {% else %}
<!-- Regular input --> <!-- Regular input -->
<input <input
type="{% if setting.sensitive %}password{% else %}text{% endif %}" type="{% if setting.sensitive %}password{% else %}text{% endif %}"
id="{{ setting.key }}" id="{{ setting.key }}"
name="{{ setting.key }}" name="{{ setting.key }}"
@@ -140,7 +140,7 @@
{% if setting.default is none or setting.key in ['admin_password', 'openai_api_key', 'azure_ai_key', 'azure_endpoint'] %}required{% endif %} {% if setting.default is none or setting.key in ['admin_password', 'openai_api_key', 'azure_ai_key', 'azure_endpoint'] %}required{% endif %}
/> />
{% endif %} {% endif %}
{% if setting.key == 'admin_password' %} {% if setting.key == 'admin_password' %}
<p class="mt-2 text-xs text-amber-600"> <p class="mt-2 text-xs text-amber-600">
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
@@ -166,17 +166,17 @@
</a> </a>
{% endif %} {% endif %}
</div> </div>
<div class="flex space-x-4"> <div class="flex space-x-4">
{% if current_step > 1 %} {% if current_step > 1 %}
<a href="/setup?step={{ current_step - 1 }}" <a href="/setup?step={{ current_step - 1 }}"
class="px-6 py-3 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> class="px-6 py-3 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fas fa-arrow-left mr-2"></i> <i class="fas fa-arrow-left mr-2"></i>
Previous Previous
</a> </a>
{% endif %} {% endif %}
<button type="submit" <button type="submit"
class="px-6 py-3 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 shadow-lg"> class="px-6 py-3 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 shadow-lg">
{% if current_step < max_step %} {% if current_step < max_step %}
Next Step Next Step
+57 -57
View File
@@ -55,7 +55,7 @@
<div class="border-t border-gray-200"> <div class="border-t border-gray-200">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<p class="text-sm text-gray-500">{{ provider.description }}</p> <p class="text-sm text-gray-500">{{ provider.description }}</p>
<!-- NextCloud or link to provider URL --> <!-- NextCloud or link to provider URL -->
{% if provider.configured and name == "NextCloud" %} {% if provider.configured and name == "NextCloud" %}
{% if provider.details and provider.details.url %} {% if provider.details and provider.details.url %}
@@ -73,7 +73,7 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
{% if provider.configured %} {% if provider.configured %}
@@ -92,21 +92,21 @@
</span> </span>
{% endif %} {% endif %}
</div> </div>
<!-- Action buttons --> <!-- Action buttons -->
<div class="flex space-x-2"> <div class="flex space-x-2">
{% if provider.configured and provider.details %} {% if provider.configured and provider.details %}
<button <button
class="view-details-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="view-details-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="{{ name }}" data-provider="{{ name }}"
data-details="{{ provider.details|tojson|forceescape }}"> data-details="{{ provider.details|tojson|forceescape }}">
View Details View Details
</button> </button>
{% endif %} {% endif %}
<!-- Notification Test Button --> <!-- Notification Test Button -->
{% if name == "Notifications" and provider.configured %} {% if name == "Notifications" and provider.configured %}
<button <button
id="testNotificationBtn" id="testNotificationBtn"
class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-endpoint="{{ provider.test_endpoint }}" data-endpoint="{{ provider.test_endpoint }}"
@@ -114,21 +114,21 @@
Test Notifications Test Notifications
</button> </button>
{% endif %} {% endif %}
<!-- Generic Test Button for any provider with test_endpoint --> <!-- Generic Test Button for any provider with test_endpoint -->
{% if provider.testable and provider.configured and provider.test_endpoint and name != "Notifications" %} {% if provider.testable and provider.configured and provider.test_endpoint and name != "Notifications" %}
<button <button
class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-endpoint="{{ provider.test_endpoint }}" data-endpoint="{{ provider.test_endpoint }}"
data-method="{{ provider.test_method|default('GET') }}"> data-method="{{ provider.test_method|default('GET') }}">
Test {{ name }} Test {{ name }}
</button> </button>
{% endif %} {% endif %}
<!-- Provider-specific buttons --> <!-- Provider-specific buttons -->
{% if name == "Dropbox" %} {% if name == "Dropbox" %}
{% if provider.configured %} {% if provider.configured %}
<button <button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="dropbox"> data-provider="dropbox">
Test Connection Test Connection
@@ -144,7 +144,7 @@
{% endif %} {% endif %}
{% elif name == "OneDrive" %} {% elif name == "OneDrive" %}
{% if provider.configured %} {% if provider.configured %}
<button <button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="onedrive"> data-provider="onedrive">
Test Connection Test Connection
@@ -160,7 +160,7 @@
{% endif %} {% endif %}
{% elif name == "Google Drive" %} {% elif name == "Google Drive" %}
{% if provider.configured %} {% if provider.configured %}
<button <button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="google_drive"> data-provider="google_drive">
Test Connection Test Connection
@@ -176,7 +176,7 @@
{% endif %} {% endif %}
{% elif name == "OpenAI" %} {% elif name == "OpenAI" %}
{% if provider.configured %} {% if provider.configured %}
<button <button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="openai"> data-provider="openai">
Test Connection Test Connection
@@ -184,7 +184,7 @@
{% endif %} {% endif %}
{% elif name == "Azure AI" %} {% elif name == "Azure AI" %}
{% if provider.configured %} {% if provider.configured %}
<button <button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="azure"> data-provider="azure">
Test Connection Test Connection
@@ -217,14 +217,14 @@
<p class="text-sm text-gray-600 mt-1"> <p class="text-sm text-gray-600 mt-1">
For more detailed configuration settings and environment variables, check the environment debug page. For more detailed configuration settings and environment variables, check the environment debug page.
</p> </p>
<div class="mt-4"> <div class="mt-4">
<a href="/env" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <a href="/env" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
View Detailed Configuration View Detailed Configuration
</a> </a>
</div> </div>
</div> </div>
<!-- Result Modal --> <!-- Result Modal -->
<div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog"> <div id="resultModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white"> <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 lg:w-1/3 shadow-lg rounded-md bg-white">
@@ -246,7 +246,7 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Details Modal --> <!-- Details Modal -->
<div id="detailsModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog"> <div id="detailsModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-2/3 lg:w-1/2 shadow-lg rounded-md bg-white"> <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-2/3 lg:w-1/2 shadow-lg rounded-md bg-white">
@@ -285,19 +285,19 @@ document.addEventListener('DOMContentLoaded', function() {
const modalMessage = document.getElementById('modalMessage'); const modalMessage = document.getElementById('modalMessage');
const modalIcon = document.getElementById('modalIcon'); const modalIcon = document.getElementById('modalIcon');
const modalClose = document.getElementById('modalClose'); const modalClose = document.getElementById('modalClose');
// Details Modal elements // Details Modal elements
const detailsModal = document.getElementById('detailsModal'); const detailsModal = document.getElementById('detailsModal');
const detailsModalTitle = document.getElementById('detailsModalTitle'); const detailsModalTitle = document.getElementById('detailsModalTitle');
const detailsContent = document.getElementById('detailsContent'); const detailsContent = document.getElementById('detailsContent');
const closeDetailsModal = document.getElementById('closeDetailsModal'); const closeDetailsModal = document.getElementById('closeDetailsModal');
const closeDetailsBtn = document.getElementById('closeDetailsBtn'); const closeDetailsBtn = document.getElementById('closeDetailsBtn');
// Modal functions // Modal functions
function showModal(status, title, message) { function showModal(status, title, message) {
modalTitle.textContent = title; modalTitle.textContent = title;
modalMessage.textContent = message; modalMessage.textContent = message;
// Set the appropriate icon using Font Awesome // Set the appropriate icon using Font Awesome
if (status === 'success') { if (status === 'success') {
modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>'; modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>';
@@ -306,25 +306,25 @@ document.addEventListener('DOMContentLoaded', function() {
modalIcon.innerHTML = '<i class="fa-solid fa-xmark text-red-600 fa-2x"></i>'; modalIcon.innerHTML = '<i class="fa-solid fa-xmark text-red-600 fa-2x"></i>';
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4'; modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4';
} }
resultModal.classList.remove('hidden'); resultModal.classList.remove('hidden');
} }
function hideModal() { function hideModal() {
resultModal.classList.add('hidden'); resultModal.classList.add('hidden');
} }
function showDetailsModal(providerName, details) { function showDetailsModal(providerName, details) {
detailsModalTitle.textContent = providerName + ' Configuration Details'; detailsModalTitle.textContent = providerName + ' Configuration Details';
// Clear previous content // Clear previous content
detailsContent.innerHTML = ''; detailsContent.innerHTML = '';
// Create and populate the details list // Create and populate the details list
if (details && Object.keys(details).length > 0) { if (details && Object.keys(details).length > 0) {
const table = document.createElement('table'); const table = document.createElement('table');
table.className = 'min-w-full divide-y divide-gray-200'; table.className = 'min-w-full divide-y divide-gray-200';
const thead = document.createElement('thead'); const thead = document.createElement('thead');
thead.className = 'bg-gray-50'; thead.className = 'bg-gray-50';
thead.innerHTML = ` thead.innerHTML = `
@@ -333,101 +333,101 @@ document.addEventListener('DOMContentLoaded', function() {
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</th>
</tr> </tr>
`; `;
const tbody = document.createElement('tbody'); const tbody = document.createElement('tbody');
tbody.className = 'bg-white divide-y divide-gray-200'; tbody.className = 'bg-white divide-y divide-gray-200';
let count = 0; let count = 0;
for (const [key, value] of Object.entries(details)) { for (const [key, value] of Object.entries(details)) {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.className = count % 2 === 0 ? 'bg-white' : 'bg-gray-50'; row.className = count % 2 === 0 ? 'bg-white' : 'bg-gray-50';
const keyCell = document.createElement('td'); const keyCell = document.createElement('td');
keyCell.className = 'px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900'; keyCell.className = 'px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900';
keyCell.textContent = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' '); keyCell.textContent = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
const valueCell = document.createElement('td'); const valueCell = document.createElement('td');
valueCell.className = 'px-6 py-4 whitespace-nowrap text-sm text-gray-500'; valueCell.className = 'px-6 py-4 whitespace-nowrap text-sm text-gray-500';
// Check if value contains sensitive information that should be masked // Check if value contains sensitive information that should be masked
const sensitiveKeys = ['token', 'password', 'secret', 'key', 'credentials']; const sensitiveKeys = ['token', 'password', 'secret', 'key', 'credentials'];
const isSensitive = sensitiveKeys.some(sKey => key.toLowerCase().includes(sKey)); const isSensitive = sensitiveKeys.some(sKey => key.toLowerCase().includes(sKey));
if (isSensitive && value !== 'Not set' && value !== '') { if (isSensitive && value !== 'Not set' && value !== '') {
valueCell.textContent = value.slice(4) + '********' + value.slice(-4); valueCell.textContent = value.slice(4) + '********' + value.slice(-4);
// For better readability, we can also use HTML to mask the middle part of the string // For better readability, we can also use HTML to mask the middle part of the string
valueCell.innerHTML = value.slice(0, 4) + '<span class="text-gray-400">********</span>' + value.slice(-4); valueCell.innerHTML = value.slice(0, 4) + '<span class="text-gray-400">********</span>' + value.slice(-4);
} else { } else {
valueCell.textContent = value; valueCell.textContent = value;
} }
row.appendChild(keyCell); row.appendChild(keyCell);
row.appendChild(valueCell); row.appendChild(valueCell);
tbody.appendChild(row); tbody.appendChild(row);
count++; count++;
} }
table.appendChild(thead); table.appendChild(thead);
table.appendChild(tbody); table.appendChild(tbody);
detailsContent.appendChild(table); detailsContent.appendChild(table);
} else { } else {
detailsContent.innerHTML = '<p class="text-sm text-gray-500">No details available</p>'; detailsContent.innerHTML = '<p class="text-sm text-gray-500">No details available</p>';
} }
detailsModal.classList.remove('hidden'); detailsModal.classList.remove('hidden');
} }
function hideDetailsModal() { function hideDetailsModal() {
detailsModal.classList.add('hidden'); detailsModal.classList.add('hidden');
} }
// Close modal when clicking the close button // Close modal when clicking the close button
modalClose.addEventListener('click', hideModal); modalClose.addEventListener('click', hideModal);
// Close modal when clicking outside of it // Close modal when clicking outside of it
resultModal.addEventListener('click', function(e) { resultModal.addEventListener('click', function(e) {
if (e.target === resultModal) { if (e.target === resultModal) {
hideModal(); hideModal();
} }
}); });
// Close details modal // Close details modal
closeDetailsModal.addEventListener('click', hideDetailsModal); closeDetailsModal.addEventListener('click', hideDetailsModal);
closeDetailsBtn.addEventListener('click', hideDetailsModal); closeDetailsBtn.addEventListener('click', hideDetailsModal);
// Close details modal when clicking outside // Close details modal when clicking outside
detailsModal.addEventListener('click', function(e) { detailsModal.addEventListener('click', function(e) {
if (e.target === detailsModal) { if (e.target === detailsModal) {
hideDetailsModal(); hideDetailsModal();
} }
}); });
// View Details button handlers // View Details button handlers
const detailsButtons = document.querySelectorAll('.view-details-btn'); const detailsButtons = document.querySelectorAll('.view-details-btn');
detailsButtons.forEach(button => { detailsButtons.forEach(button => {
button.addEventListener('click', function() { button.addEventListener('click', function() {
const providerName = this.getAttribute('data-provider'); const providerName = this.getAttribute('data-provider');
let detailsData = {}; let detailsData = {};
try { try {
detailsData = JSON.parse(this.getAttribute('data-details')); detailsData = JSON.parse(this.getAttribute('data-details'));
} catch (e) { } catch (e) {
console.error('Error parsing details data:', e); console.error('Error parsing details data:', e);
} }
showDetailsModal(providerName, detailsData); showDetailsModal(providerName, detailsData);
}); });
}); });
// Test notifications // Test notifications
const testNotificationBtn = document.getElementById('testNotificationBtn'); const testNotificationBtn = document.getElementById('testNotificationBtn');
if (testNotificationBtn) { if (testNotificationBtn) {
testNotificationBtn.addEventListener('click', function() { testNotificationBtn.addEventListener('click', function() {
const originalText = this.innerHTML; const originalText = this.innerHTML;
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Sending...'; this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Sending...';
this.disabled = true; this.disabled = true;
fetch('/api/diagnostic/test-notification', { fetch('/api/diagnostic/test-notification', {
method: 'POST', method: 'POST',
}) })
@@ -451,7 +451,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
} }
// Generic test button functionality // Generic test button functionality
const testGenericBtns = document.querySelectorAll('.test-generic-btn:not(#testNotificationBtn)'); const testGenericBtns = document.querySelectorAll('.test-generic-btn:not(#testNotificationBtn)');
testGenericBtns.forEach(button => { testGenericBtns.forEach(button => {
@@ -459,10 +459,10 @@ document.addEventListener('DOMContentLoaded', function() {
const originalText = this.innerHTML; const originalText = this.innerHTML;
const endpoint = this.getAttribute('data-endpoint'); const endpoint = this.getAttribute('data-endpoint');
const method = this.getAttribute('data-method') || 'GET'; const method = this.getAttribute('data-method') || 'GET';
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...'; this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
this.disabled = true; this.disabled = true;
fetch(endpoint, { fetch(endpoint, {
method: method, method: method,
}) })
@@ -475,7 +475,7 @@ document.addEventListener('DOMContentLoaded', function() {
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2"> message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human} <span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
</div>`; </div>`;
modalTitle.textContent = 'Test Successful'; modalTitle.textContent = 'Test Successful';
modalMessage.innerHTML = message; modalMessage.innerHTML = message;
modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>'; modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>';
@@ -503,17 +503,17 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
}); });
// Test provider connections // Test provider connections
const testButtons = document.querySelectorAll('.test-provider-btn'); const testButtons = document.querySelectorAll('.test-provider-btn');
testButtons.forEach(button => { testButtons.forEach(button => {
button.addEventListener('click', function() { button.addEventListener('click', function() {
const provider = this.getAttribute('data-provider'); const provider = this.getAttribute('data-provider');
const originalText = this.textContent; const originalText = this.textContent;
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...'; this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
this.disabled = true; this.disabled = true;
let endpoint = ''; let endpoint = '';
if (provider === 'dropbox') { if (provider === 'dropbox') {
endpoint = '/api/dropbox/test-token'; endpoint = '/api/dropbox/test-token';
@@ -526,20 +526,20 @@ document.addEventListener('DOMContentLoaded', function() {
} else if (provider === 'azure') { } else if (provider === 'azure') {
endpoint = '/api/azure/test'; endpoint = '/api/azure/test';
} }
fetch(endpoint) fetch(endpoint)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.status === 'success') { if (data.status === 'success') {
// Create successful message // Create successful message
let message = data.message || 'Connection successful'; let message = data.message || 'Connection successful';
// Add token expiration info if available (especially for Google Drive) // Add token expiration info if available (especially for Google Drive)
if (data.token_info && data.token_info.expires_in_human) { if (data.token_info && data.token_info.expires_in_human) {
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2"> message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human} <span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
</div>`; </div>`;
// Show the message with HTML // Show the message with HTML
modalTitle.textContent = 'Connection Test Successful'; modalTitle.textContent = 'Connection Test Successful';
modalMessage.innerHTML = message; modalMessage.innerHTML = message;
+6 -6
View File
@@ -115,12 +115,12 @@
if (e.dataTransfer.files.length) { if (e.dataTransfer.files.length) {
// Clear previous upload progress // Clear previous upload progress
uploadProgress.innerHTML = ""; uploadProgress.innerHTML = "";
// Create progress container // Create progress container
const progressContainer = document.createElement("div"); const progressContainer = document.createElement("div");
progressContainer.className = "space-y-2"; progressContainer.className = "space-y-2";
uploadProgress.appendChild(progressContainer); uploadProgress.appendChild(progressContainer);
// Use the shared processFiles function // Use the shared processFiles function
processFiles(e.dataTransfer.files, progressContainer, statusMessage); processFiles(e.dataTransfer.files, progressContainer, statusMessage);
} }
@@ -130,12 +130,12 @@
if (e.target.files.length) { if (e.target.files.length) {
// Clear previous upload progress // Clear previous upload progress
uploadProgress.innerHTML = ""; uploadProgress.innerHTML = "";
// Create progress container // Create progress container
const progressContainer = document.createElement("div"); const progressContainer = document.createElement("div");
progressContainer.className = "space-y-2"; progressContainer.className = "space-y-2";
uploadProgress.appendChild(progressContainer); uploadProgress.appendChild(progressContainer);
// Use the shared processFiles function // Use the shared processFiles function
processFiles(e.target.files, progressContainer, statusMessage); processFiles(e.target.files, progressContainer, statusMessage);
} }
@@ -168,7 +168,7 @@
// Show loading state // Show loading state
showUrlStatus("Downloading file from URL...", "info"); showUrlStatus("Downloading file from URL...", "info");
const submitButton = urlUploadForm.querySelector('button[type="submit"]'); const submitButton = urlUploadForm.querySelector('button[type="submit"]');
const originalButtonText = submitButton.textContent; const originalButtonText = submitButton.textContent;
submitButton.textContent = "Processing..."; submitButton.textContent = "Processing...";
@@ -198,7 +198,7 @@
// Clear form // Clear form
urlInput.value = ""; urlInput.value = "";
urlFilename.value = ""; urlFilename.value = "";
// Optionally redirect to files page after short delay // Optionally redirect to files page after short delay
setTimeout(() => { setTimeout(() => {
window.location.href = "/files"; window.location.href = "/files";
+1 -1
View File
@@ -1 +1 @@
Generic single-database configuration. Generic single-database configuration.
@@ -6,7 +6,7 @@ Create Date: 2026-02-11
""" """
from typing import Sequence, Union from typing import Union
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
+1 -1
View File
@@ -6,7 +6,7 @@ Create Date: 2026-02-11
""" """
from typing import Sequence, Union from typing import Union
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
@@ -6,7 +6,7 @@ Create Date: 2026-02-12
""" """
from typing import Sequence, Union from typing import Union
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
@@ -21,13 +21,13 @@ def upgrade() -> None:
"""Add is_duplicate and duplicate_of_id columns to files table.""" """Add is_duplicate and duplicate_of_id columns to files table."""
# Add is_duplicate column with default False # Add is_duplicate column with default False
op.add_column("files", sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="0")) op.add_column("files", sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="0"))
# Add duplicate_of_id column as foreign key to self # Add duplicate_of_id column as foreign key to self
op.add_column("files", sa.Column("duplicate_of_id", sa.Integer(), nullable=True)) op.add_column("files", sa.Column("duplicate_of_id", sa.Integer(), nullable=True))
# Create index on is_duplicate for efficient filtering # Create index on is_duplicate for efficient filtering
op.create_index("ix_files_is_duplicate", "files", ["is_duplicate"]) op.create_index("ix_files_is_duplicate", "files", ["is_duplicate"])
# Create foreign key relationship # Create foreign key relationship
op.create_foreign_key("fk_files_duplicate_of_id", "files", "files", ["duplicate_of_id"], ["id"]) op.create_foreign_key("fk_files_duplicate_of_id", "files", "files", ["duplicate_of_id"], ["id"])
@@ -36,10 +36,10 @@ def downgrade() -> None:
"""Remove is_duplicate and duplicate_of_id columns from files table.""" """Remove is_duplicate and duplicate_of_id columns from files table."""
# Drop foreign key # Drop foreign key
op.drop_constraint("fk_files_duplicate_of_id", "files", type_="foreignkey") op.drop_constraint("fk_files_duplicate_of_id", "files", type_="foreignkey")
# Drop index # Drop index
op.drop_index("ix_files_is_duplicate", table_name="files") op.drop_index("ix_files_is_duplicate", table_name="files")
# Drop columns # Drop columns
op.drop_column("files", "duplicate_of_id") op.drop_column("files", "duplicate_of_id")
op.drop_column("files", "is_duplicate") op.drop_column("files", "is_duplicate")
+3 -1
View File
@@ -124,10 +124,12 @@ ignore = [
"PLR0911", # Too many return statements "PLR0911", # Too many return statements
"PLR2004", # Magic value comparison "PLR2004", # Magic value comparison
"PLW0603", # Global statement "PLW0603", # Global statement
"B008", # Function call in default argument (FastAPI Depends pattern)
"B904", # Raise without from (overly strict for error handlers)
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S110", "B017"] # Allow assert, try-except-pass, assert-raises-exception in tests "tests/*" = ["S101", "S110", "B017", "F841", "B007", "E402"] # Allow assert, try-except-pass, assert-raises-exception, unused vars, unused loop vars, module imports not at top in tests
# pytest configuration # pytest configuration
[tool.pytest.ini_options] [tool.pytest.ini_options]
+1 -1
View File
@@ -30,4 +30,4 @@ pre-commit>=3.6.0
pip-licenses==5.5.1 # For license compliance checking pip-licenses==5.5.1 # For license compliance checking
# Release automation # Release automation
python-semantic-release>=9.0.0 python-semantic-release>=9.0.0
+1 -1
View File
@@ -34,4 +34,4 @@ boto3>=1.28.0
paramiko>=3.4.0 # SSH/SFTP implementation for Python (LGPL license) paramiko>=3.4.0 # SSH/SFTP implementation for Python (LGPL license)
# Notification service # Notification service
apprise>=1.4.0 apprise>=1.4.0
+3 -3
View File
@@ -37,13 +37,13 @@ if git rev-parse --git-dir > /dev/null 2>&1; then
GIT_SHA=$(git rev-parse --short=7 HEAD) GIT_SHA=$(git rev-parse --short=7 HEAD)
echo "${GIT_SHA}" > GIT_SHA echo "${GIT_SHA}" > GIT_SHA
echo "✓ GIT_SHA: ${GIT_SHA}" echo "✓ GIT_SHA: ${GIT_SHA}"
# Get full commit SHA for reference # Get full commit SHA for reference
GIT_FULL_SHA=$(git rev-parse HEAD) GIT_FULL_SHA=$(git rev-parse HEAD)
# Get commit date # Get commit date
GIT_COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict) GIT_COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)
# Get branch name (if available) # Get branch name (if available)
GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
else else
+14 -14
View File
@@ -10,7 +10,7 @@ Unlike unit tests that mock external dependencies, these integration tests use *
- **Redis** - Real message broker for Celery tasks - **Redis** - Real message broker for Celery tasks
- **Gotenberg** - Real PDF conversion service - **Gotenberg** - Real PDF conversion service
- **WebDAV Server** - Real upload target - **WebDAV Server** - Real upload target
- **SFTP Server** - Real SSH/SFTP server - **SFTP Server** - Real SSH/SFTP server
- **MinIO** - Real S3-compatible object storage - **MinIO** - Real S3-compatible object storage
- **FTP Server** - Real FTP server - **FTP Server** - Real FTP server
@@ -202,15 +202,15 @@ def test_celery_tasks(celery_app, celery_worker):
def test_upload_to_webdav(webdav_container, sample_text_file): def test_upload_to_webdav(webdav_container, sample_text_file):
"""Upload file to real WebDAV server and verify.""" """Upload file to real WebDAV server and verify."""
from app.tasks.upload_to_webdav import upload_to_webdav from app.tasks.upload_to_webdav import upload_to_webdav
with patch("app.tasks.upload_to_webdav.settings") as mock_settings: with patch("app.tasks.upload_to_webdav.settings") as mock_settings:
mock_settings.webdav_url = webdav_container["url"] + "/" mock_settings.webdav_url = webdav_container["url"] + "/"
mock_settings.webdav_username = webdav_container["username"] mock_settings.webdav_username = webdav_container["username"]
mock_settings.webdav_password = webdav_container["password"] mock_settings.webdav_password = webdav_container["password"]
# Execute upload # Execute upload
result = upload_to_webdav.apply(args=[sample_text_file]).get() result = upload_to_webdav.apply(args=[sample_text_file]).get()
# Verify on server # Verify on server
response = requests.get( response = requests.get(
f"{webdav_container['url']}/test.txt", f"{webdav_container['url']}/test.txt",
@@ -226,14 +226,14 @@ def test_upload_to_webdav(webdav_container, sample_text_file):
def test_async_upload(redis_container, webdav_container, celery_worker, sample_text_file): def test_async_upload(redis_container, webdav_container, celery_worker, sample_text_file):
"""Queue task in Redis, worker executes, uploads to WebDAV.""" """Queue task in Redis, worker executes, uploads to WebDAV."""
from app.tasks.upload_to_webdav import upload_to_webdav from app.tasks.upload_to_webdav import upload_to_webdav
# Queue task (goes to Redis) # Queue task (goes to Redis)
result = upload_to_webdav.delay(sample_text_file, file_id=1) result = upload_to_webdav.delay(sample_text_file, file_id=1)
# Wait for worker to process # Wait for worker to process
while not result.ready(): while not result.ready():
time.sleep(0.5) time.sleep(0.5)
# Verify result # Verify result
assert result.get()["status"] == "Completed" assert result.get()["status"] == "Completed"
``` ```
@@ -299,9 +299,9 @@ Set a breakpoint after test to inspect:
```python ```python
def test_inspect(webdav_container): def test_inspect(webdav_container):
result = upload_file() result = upload_file()
import pdb; pdb.set_trace() # Container still running here import pdb; pdb.set_trace() # Container still running here
# Manually inspect: docker ps, docker logs, etc. # Manually inspect: docker ps, docker logs, etc.
``` ```
@@ -391,24 +391,24 @@ on: [push, pull_request]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
docker: docker:
image: docker:latest image: docker:latest
options: --privileged options: --privileged
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run integration tests - name: Run integration tests
run: | run: |
pytest -m "integration or e2e" -v --tb=short pytest -m "integration or e2e" -v --tb=short
+1 -1
View File
@@ -98,7 +98,7 @@ async def test_oauth_callback(mock_authorize, oauth_enabled_app, test_user_info)
"access_token": "test-token", "access_token": "test-token",
"userinfo": test_user_info, "userinfo": test_user_info,
} }
response = oauth_enabled_app.get("/oauth-callback?code=test-code") response = oauth_enabled_app.get("/oauth-callback?code=test-code")
assert response.status_code == 302 # Redirects after login assert response.status_code == 302 # Redirects after login
``` ```
+6 -6
View File
@@ -244,9 +244,9 @@ class TestAzureDocumentIntelligenceIntegration:
assert len(result.content) > 10, f"OCR text too short: {result.content[:50]}" assert len(result.content) > 10, f"OCR text too short: {result.content[:50]}"
# Verify the generated text is recognizable # Verify the generated text is recognizable
assert "Acme" in result.content or "Invoice" in result.content, ( assert (
f"OCR text does not contain expected keywords: {result.content[:200]}" "Acme" in result.content or "Invoice" in result.content
) ), f"OCR text does not contain expected keywords: {result.content[:200]}"
# Retrieve the searchable PDF output # Retrieve the searchable PDF output
operation_id = poller.details["operation_id"] operation_id = poller.details["operation_id"]
@@ -602,9 +602,9 @@ class TestFullOCRMetadataPipeline:
# The generated invoice should be classified reasonably # The generated invoice should be classified reasonably
doc_type = metadata["document_type"].lower() doc_type = metadata["document_type"].lower()
assert any(kw in doc_type for kw in ("invoice", "rechnung", "bill")), ( assert any(
f"Unexpected document_type: {metadata['document_type']}" kw in doc_type for kw in ("invoice", "rechnung", "bill")
) ), f"Unexpected document_type: {metadata['document_type']}"
finally: finally:
os.unlink(pdf_path) os.unlink(pdf_path)
+3 -3
View File
@@ -83,9 +83,9 @@ class TestSplitPdfBySize:
# that can cause files to exceed the target size by ~20-50%. We allow 1.5x (50%) margin. # that can cause files to exceed the target size by ~20-50%. We allow 1.5x (50%) margin.
PDF_OVERHEAD_MULTIPLIER = 1.5 PDF_OVERHEAD_MULTIPLIER = 1.5
for split_file in split_files: for split_file in split_files:
assert os.path.getsize(split_file) <= max_size * PDF_OVERHEAD_MULTIPLIER, ( assert (
f"Split file {split_file} should respect size limit (with PDF overhead allowance)" os.path.getsize(split_file) <= max_size * PDF_OVERHEAD_MULTIPLIER
) ), f"Split file {split_file} should respect size limit (with PDF overhead allowance)"
# Cleanup split files # Cleanup split files
for split_file in split_files: for split_file in split_files:
+3 -3
View File
@@ -142,9 +142,9 @@ def test_x_frame_options_valid_value(client):
x_frame_value = response.headers["X-Frame-Options"] x_frame_value = response.headers["X-Frame-Options"]
valid_values = ["DENY", "SAMEORIGIN"] valid_values = ["DENY", "SAMEORIGIN"]
# Note: ALLOW-FROM is deprecated in modern browsers; use CSP frame-ancestors instead # Note: ALLOW-FROM is deprecated in modern browsers; use CSP frame-ancestors instead
assert x_frame_value in valid_values or x_frame_value.startswith("ALLOW-FROM"), ( assert x_frame_value in valid_values or x_frame_value.startswith(
f"Invalid X-Frame-Options value: {x_frame_value}" "ALLOW-FROM"
) ), f"Invalid X-Frame-Options value: {x_frame_value}"
@pytest.mark.integration @pytest.mark.integration
+3 -3
View File
@@ -305,9 +305,9 @@ class TestWebDAVIntegration:
response = requests.get(file_url, auth=(webdav_server["username"], webdav_server["password"]), timeout=10) response = requests.get(file_url, auth=(webdav_server["username"], webdav_server["password"]), timeout=10)
assert response.status_code == 200 assert response.status_code == 200
assert len(response.content) == 1024 * 1024, ( assert (
f"File size mismatch: expected 1MB, got {len(response.content)} bytes" len(response.content) == 1024 * 1024
) ), f"File size mismatch: expected 1MB, got {len(response.content)} bytes"
@pytest.mark.integration @pytest.mark.integration