diff --git a/frontend/src/components/Auth/Login.tsx b/frontend/src/components/Auth/Login.tsx index 1fc26b4..1736fa4 100644 --- a/frontend/src/components/Auth/Login.tsx +++ b/frontend/src/components/Auth/Login.tsx @@ -23,6 +23,8 @@ import { import { useForm, SubmitHandler } from 'react-hook-form'; import { useAuth } from '../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useTheme as useMuiTheme } from '@mui/material/styles'; interface LoginFormData { username: string; @@ -35,6 +37,8 @@ const Login: React.FC = () => { const [loading, setLoading] = useState(false); const { login } = useAuth(); const navigate = useNavigate(); + const { mode } = useTheme(); + const theme = useMuiTheme(); const { register, @@ -63,7 +67,9 @@ const Login: React.FC = () => { { color: 'white', fontWeight: 700, mb: 1, - textShadow: '0 4px 6px rgba(0, 0, 0, 0.1)', + textShadow: mode === 'light' + ? '0 4px 6px rgba(0, 0, 0, 0.1)' + : '0 4px 12px rgba(0, 0, 0, 0.5)', }} > Welcome to Readur @@ -110,7 +118,9 @@ const Login: React.FC = () => { @@ -125,9 +135,15 @@ const Login: React.FC = () => { sx={{ borderRadius: 4, backdropFilter: 'blur(20px)', - backgroundColor: 'rgba(255, 255, 255, 0.95)', - border: '1px solid rgba(255, 255, 255, 0.2)', - boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', + backgroundColor: mode === 'light' + ? 'rgba(255, 255, 255, 0.95)' + : 'rgba(30, 30, 30, 0.95)', + border: mode === 'light' + ? '1px solid rgba(255, 255, 255, 0.2)' + : '1px solid rgba(255, 255, 255, 0.1)', + boxShadow: mode === 'light' + ? '0 25px 50px -12px rgba(0, 0, 0, 0.25)' + : '0 25px 50px -12px rgba(0, 0, 0, 0.6)', }} > @@ -242,7 +258,9 @@ const Login: React.FC = () => { © 2026 Readur. Powered by advanced OCR and AI technology. diff --git a/frontend/src/components/Dashboard/Dashboard.tsx b/frontend/src/components/Dashboard/Dashboard.tsx index 437fdfc..c78648d 100644 --- a/frontend/src/components/Dashboard/Dashboard.tsx +++ b/frontend/src/components/Dashboard/Dashboard.tsx @@ -322,6 +322,9 @@ const RecentDocuments: React.FC = ({ documents = [] }) => = ({ documents = [] }) => overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + maxWidth: '100%', }} > {doc.original_filename || doc.filename || 'Unknown Document'} diff --git a/frontend/src/components/DocumentList.tsx b/frontend/src/components/DocumentList.tsx index 95bdc2c..6281fd7 100644 --- a/frontend/src/components/DocumentList.tsx +++ b/frontend/src/components/DocumentList.tsx @@ -137,11 +137,11 @@ function DocumentList({ documents, loading }: DocumentListProps) {
    {documents.map((document) => (
  • -
    -
    +
    +
    {getFileIcon(document.mime_type)} -
    -
    +
    +
    {document.original_filename}
    @@ -154,12 +154,14 @@ function DocumentList({ documents, loading }: DocumentListProps) {
    - +
    + +
  • ))} diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index c39a4a7..b9feea6 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -94,7 +94,7 @@ const AppLayout: React.FC = ({ children }) => { }; const handleNotificationClick = (event: React.MouseEvent): void => { - setNotificationAnchorEl(event.currentTarget); + setNotificationAnchorEl(notificationAnchorEl ? null : event.currentTarget); }; const handleNotificationClose = (): void => { diff --git a/frontend/src/components/Upload/UploadZone.tsx b/frontend/src/components/Upload/UploadZone.tsx index e82b646..5ba7046 100644 --- a/frontend/src/components/Upload/UploadZone.tsx +++ b/frontend/src/components/Upload/UploadZone.tsx @@ -339,8 +339,21 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { + {fileItem.file.name} } diff --git a/frontend/src/pages/DocumentDetailsPage.tsx b/frontend/src/pages/DocumentDetailsPage.tsx index 05f9122..d346fe7 100644 --- a/frontend/src/pages/DocumentDetailsPage.tsx +++ b/frontend/src/pages/DocumentDetailsPage.tsx @@ -64,6 +64,12 @@ const DocumentDetailsPage: React.FC = () => { } }, [id]); + useEffect(() => { + if (document && document.has_ocr_text && !ocrData) { + fetchOcrText(); + } + }, [document]); + const fetchDocumentDetails = async (): Promise => { try { setLoading(true); @@ -395,6 +401,106 @@ const DocumentDetailsPage: React.FC = () => { + + {/* OCR Text Section */} + {document.has_ocr_text && ( + + + + + Extracted Text (OCR) + + + {ocrLoading ? ( + + + + Loading OCR text... + + + ) : ocrData ? ( + <> + {/* OCR Stats */} + + {ocrData.ocr_confidence && ( + + )} + {ocrData.ocr_word_count && ( + + )} + {ocrData.ocr_processing_time_ms && ( + + )} + + + {/* OCR Error Display */} + {ocrData.ocr_error && ( + + OCR Error: {ocrData.ocr_error} + + )} + + {/* OCR Text Content */} + theme.palette.mode === 'light' ? 'grey.50' : 'grey.900', + border: '1px solid', + borderColor: 'divider', + maxHeight: 400, + overflow: 'auto', + position: 'relative', + }} + > + {ocrData.ocr_text ? ( + + {ocrData.ocr_text} + + ) : ( + + No OCR text available for this document. + + )} + + + {/* Processing Info */} + {ocrData.ocr_completed_at && ( + + + Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()} + + + )} + + ) : ( + + OCR text is available but failed to load. Try clicking the "View OCR" button above. + + )} + + + + )} {/* OCR Text Dialog */} diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 922dbf0..a3adcad 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -357,11 +357,13 @@ const DocumentsPage: React.FC = () => { display: 'flex', flexDirection: viewMode === 'list' ? 'row' : 'column', transition: 'all 0.2s ease-in-out', + cursor: 'pointer', '&:hover': { transform: 'translateY(-4px)', boxShadow: (theme) => theme.shadows[4], }, }} + onClick={() => navigate(`/documents/${doc.id}`)} > {viewMode === 'grid' && ( { handleDocMenuClick(e, doc)} + onClick={(e) => { + e.stopPropagation(); + handleDocMenuClick(e, doc); + }} > @@ -461,7 +466,10 @@ const DocumentsPage: React.FC = () => { + + {syncStatus?.is_running && ( + + )} + + {syncStatus?.is_running && ( + } + sx={{ ml: 1 }} + /> + )} + + + + {/* Sync Status */} + + {syncStatus && ( + + + Sync Status + + + + + + + {syncStatus.files_processed || 0} + + + Files Processed + + + + + + + {syncStatus.files_remaining || 0} + + + Files Remaining + + + + + + {syncStatus.current_folder && ( + + + Currently syncing: {syncStatus.current_folder} + + + )} + + {syncStatus.last_sync && ( + + Last sync: {new Date(syncStatus.last_sync).toLocaleString()} + + )} + + {syncStatus.errors && syncStatus.errors.length > 0 && ( + + + Recent Errors: + + {syncStatus.errors.slice(0, 3).map((error: string, index: number) => ( + + • {error} + + ))} + {syncStatus.errors.length > 3 && ( + + ... and {syncStatus.errors.length - 3} more errors + + )} + + )} + + )} + + + + + )} ); }; diff --git a/frontend/src/pages/WatchFolderPage.tsx b/frontend/src/pages/WatchFolderPage.tsx index 23dd721..7b26ca1 100644 --- a/frontend/src/pages/WatchFolderPage.tsx +++ b/frontend/src/pages/WatchFolderPage.tsx @@ -18,6 +18,7 @@ import { Alert, Button, IconButton, + CircularProgress, } from '@mui/material'; import { Refresh as RefreshIcon, @@ -47,6 +48,7 @@ const WatchFolderPage: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); + const [requeuingFailed, setRequeuingFailed] = useState(false); // Mock configuration data (would typically come from API) const watchConfig: WatchConfig = { @@ -79,6 +81,26 @@ const WatchFolderPage: React.FC = () => { } }; + const requeueFailedJobs = async (): Promise => { + try { + setRequeuingFailed(true); + const response = await queueService.requeueFailedItems(); + const requeued = response.data.requeued_count || 0; + + if (requeued > 0) { + // Show success message + setError(null); + // Refresh stats to see updated counts + await fetchQueueStats(); + } + } catch (err) { + console.error('Error requeuing failed jobs:', err); + setError('Failed to requeue failed jobs'); + } finally { + setRequeuingFailed(false); + } + }; + const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; @@ -132,9 +154,22 @@ const WatchFolderPage: React.FC = () => { startIcon={} onClick={fetchQueueStats} disabled={loading} + sx={{ mr: 2 }} > Refresh + + {queueStats && queueStats.failed_count > 0 && ( + + )} {error && ( diff --git a/src/db.rs b/src/db.rs index eca92a5..9e5fb7e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1649,6 +1649,26 @@ impl Database { Ok(()) } + // Reset any running WebDAV syncs on startup (handles server restart during sync) + pub async fn reset_running_webdav_syncs(&self) -> Result { + let result = sqlx::query( + r#"UPDATE webdav_sync_state + SET is_running = false, + current_folder = NULL, + errors = CASE + WHEN array_length(errors, 1) IS NULL OR array_length(errors, 1) = 0 + THEN ARRAY['Sync interrupted by server restart'] + ELSE array_append(errors, 'Sync interrupted by server restart') + END, + updated_at = NOW() + WHERE is_running = true"# + ) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() as i64) + } + // WebDAV file tracking operations pub async fn get_webdav_file_by_path(&self, user_id: Uuid, webdav_path: &str) -> Result> { let row = sqlx::query( diff --git a/src/main.rs b/src/main.rs index 63c8e36..0ad01b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use axum::{ use sqlx::Row; use std::sync::Arc; use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}}; -use tracing::{info, error}; +use tracing::{info, error, warn}; use readur::{config::Config, db::Database, AppState, *}; @@ -116,6 +116,18 @@ async fn main() -> Result<(), Box> { // Seed system user for watcher seed::seed_system_user(&db).await?; + // Reset any running WebDAV syncs from previous server instance + match db.reset_running_webdav_syncs().await { + Ok(count) => { + if count > 0 { + info!("Reset {} orphaned WebDAV sync states from server restart", count); + } + } + Err(e) => { + warn!("Failed to reset running WebDAV syncs: {}", e); + } + } + let state = AppState { db, config: config.clone() }; let state = Arc::new(state); diff --git a/src/ocr_tests.rs b/src/ocr_tests.rs index 77e5083..6702013 100644 --- a/src/ocr_tests.rs +++ b/src/ocr_tests.rs @@ -195,21 +195,18 @@ mod tests { fn test_image_size_validation() { let checker = OcrHealthChecker::new(); - // Assuming we have at least 100MB available - let available = checker.check_memory_available(); - if available > 100 { - // Small image should pass - assert!(checker.validate_memory_for_image(640, 480).is_ok()); - - // Extremely large image should fail - let result = checker.validate_memory_for_image(50000, 50000); - assert!(result.is_err()); - - if let Err(OcrError::InsufficientMemory { required, available }) = result { - assert!(required > available); - } else { - panic!("Expected InsufficientMemory error"); - } + // Small image should pass + assert!(checker.validate_memory_for_image(640, 480).is_ok()); + + // Test with a ridiculously large image that would require more memory than any system has + // 100,000 x 100,000 pixels = 10 billion pixels * 4 bytes * 3 buffers = ~120GB + let result = checker.validate_memory_for_image(100000, 100000); + assert!(result.is_err()); + + if let Err(OcrError::InsufficientMemory { required, available }) = result { + assert!(required > available); + } else { + panic!("Expected InsufficientMemory error, got: {:?}", result); } } } \ No newline at end of file diff --git a/src/routes/webdav.rs b/src/routes/webdav.rs index 9941441..b672d3b 100644 --- a/src/routes/webdav.rs +++ b/src/routes/webdav.rs @@ -29,6 +29,7 @@ pub fn router() -> Router> { .route("/estimate-crawl", post(estimate_webdav_crawl)) .route("/sync-status", get(get_webdav_sync_status)) .route("/start-sync", post(start_webdav_sync)) + .route("/cancel-sync", post(cancel_webdav_sync)) } async fn get_user_webdav_config(state: &Arc, user_id: uuid::Uuid) -> Result { @@ -303,6 +304,25 @@ async fn start_webdav_sync( ) -> Result, StatusCode> { info!("Starting WebDAV sync for user: {}", auth_user.user.username); + // Check if a sync is already running for this user + match state.db.get_webdav_sync_state(auth_user.user.id).await { + Ok(Some(sync_state)) if sync_state.is_running => { + warn!("WebDAV sync already running for user {}", auth_user.user.id); + return Ok(Json(serde_json::json!({ + "success": false, + "error": "sync_already_running", + "message": "A WebDAV sync is already in progress. Please wait for it to complete before starting a new sync." + }))); + } + Ok(_) => { + // No sync running or no sync state exists yet - proceed + } + Err(e) => { + error!("Failed to check sync state for user {}: {}", auth_user.user.id, e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + // Get user's WebDAV configuration and settings let webdav_config = get_user_webdav_config(&state, auth_user.user.id).await?; @@ -378,3 +398,90 @@ async fn start_webdav_sync( }))) } +#[utoipa::path( + post, + path = "/api/webdav/cancel-sync", + tag = "webdav", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "Sync cancelled successfully"), + (status = 400, description = "No sync running or WebDAV not configured"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] +async fn cancel_webdav_sync( + State(state): State>, + auth_user: AuthUser, +) -> Result, StatusCode> { + info!("Cancelling WebDAV sync for user: {}", auth_user.user.username); + + // Check if a sync is currently running + match state.db.get_webdav_sync_state(auth_user.user.id).await { + Ok(Some(sync_state)) if sync_state.is_running => { + // Mark sync as cancelled + let cancelled_state = crate::models::UpdateWebDAVSyncState { + last_sync_at: Some(chrono::Utc::now()), + sync_cursor: sync_state.sync_cursor, + is_running: false, + files_processed: sync_state.files_processed, + files_remaining: 0, + current_folder: None, + errors: vec!["Sync cancelled by user".to_string()], + }; + + if let Err(e) = state.db.update_webdav_sync_state(auth_user.user.id, &cancelled_state).await { + error!("Failed to update sync state for cancellation: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + info!("WebDAV sync cancelled for user {}", auth_user.user.id); + + // Send cancellation notification + let notification = crate::models::CreateNotification { + notification_type: "info".to_string(), + title: "WebDAV Sync Cancelled".to_string(), + message: "WebDAV sync was cancelled by user request".to_string(), + action_url: Some("/settings".to_string()), + metadata: Some(serde_json::json!({ + "sync_type": "webdav_manual", + "cancelled": true + })), + }; + + if let Err(e) = state.db.create_notification(auth_user.user.id, ¬ification).await { + error!("Failed to create cancellation notification: {}", e); + } + + Ok(Json(serde_json::json!({ + "success": true, + "message": "WebDAV sync cancelled successfully" + }))) + } + Ok(Some(_)) => { + // No sync running + warn!("Attempted to cancel WebDAV sync for user {} but no sync is running", auth_user.user.id); + Ok(Json(serde_json::json!({ + "success": false, + "error": "no_sync_running", + "message": "No WebDAV sync is currently running" + }))) + } + Ok(None) => { + // No sync state exists + warn!("No WebDAV sync state found for user {}", auth_user.user.id); + Ok(Json(serde_json::json!({ + "success": false, + "error": "no_sync_state", + "message": "No WebDAV sync state found" + }))) + } + Err(e) => { + error!("Failed to get sync state for user {}: {}", auth_user.user.id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + diff --git a/src/routes/webdav/webdav_sync.rs b/src/routes/webdav/webdav_sync.rs index 41796bd..7df23c3 100644 --- a/src/routes/webdav/webdav_sync.rs +++ b/src/routes/webdav/webdav_sync.rs @@ -34,6 +34,51 @@ pub async fn perform_webdav_sync_with_tracking( if let Err(e) = state.db.update_webdav_sync_state(user_id, &sync_state_update).await { error!("Failed to update sync state: {}", e); } + + // Ensure sync state is cleared on any exit path + let cleanup_sync_state = |errors: Vec, files_processed: usize| { + let state_clone = state.clone(); + let user_id_clone = user_id; + tokio::spawn(async move { + let final_state = UpdateWebDAVSyncState { + last_sync_at: Some(Utc::now()), + sync_cursor: None, + is_running: false, + files_processed: files_processed as i64, + files_remaining: 0, + current_folder: None, + errors, + }; + + if let Err(e) = state_clone.db.update_webdav_sync_state(user_id_clone, &final_state).await { + error!("Failed to cleanup sync state: {}", e); + } + }); + }; + + // Perform sync with proper cleanup + let sync_result = perform_sync_internal(state.clone(), user_id, webdav_service, config, enable_background_ocr).await; + + match &sync_result { + Ok(files_processed) => { + cleanup_sync_state(Vec::new(), *files_processed); + } + Err(e) => { + let error_msg = format!("Sync failed: {}", e); + cleanup_sync_state(vec![error_msg], 0); + } + } + + sync_result +} + +async fn perform_sync_internal( + state: Arc, + user_id: uuid::Uuid, + webdav_service: WebDAVService, + config: WebDAVConfig, + enable_background_ocr: bool, +) -> Result> { let mut total_files_processed = 0; let mut sync_errors = Vec::new(); @@ -267,21 +312,6 @@ pub async fn perform_webdav_sync_with_tracking( } } - // Update final sync state - let final_state = UpdateWebDAVSyncState { - last_sync_at: Some(Utc::now()), - sync_cursor: None, - is_running: false, - files_processed: total_files_processed as i64, - files_remaining: 0, - current_folder: None, - errors: sync_errors, - }; - - if let Err(e) = state.db.update_webdav_sync_state(user_id, &final_state).await { - error!("Failed to update final sync state: {}", e); - } - info!("WebDAV sync completed for user {}: {} files processed", user_id, total_files_processed); Ok(total_files_processed) } \ No newline at end of file diff --git a/src/tests/ocr_tests.rs b/src/tests/ocr_tests.rs index ac9a749..217b266 100644 --- a/src/tests/ocr_tests.rs +++ b/src/tests/ocr_tests.rs @@ -90,7 +90,7 @@ mod tests { .await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Unsupported file type")); + assert!(result.unwrap_err().to_string().contains("Unsupported MIME type")); } #[tokio::test] diff --git a/src/webdav_service.rs b/src/webdav_service.rs index dd0e9a6..c2401cf 100644 --- a/src/webdav_service.rs +++ b/src/webdav_service.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; -use reqwest::{Client, Method}; +use reqwest::{Client, Method, Url}; use std::collections::HashSet; use std::time::Duration; use tokio::time::sleep; @@ -389,9 +389,19 @@ impl WebDAVService { } async fn download_file_impl(&self, file_path: &str) -> Result> { - let file_url = format!("{}{}", self.base_webdav_url, file_path); + // For Nextcloud/ownCloud, the file_path might already be an absolute WebDAV path + // The path comes from href which is already URL-encoded + let file_url = if file_path.starts_with("/remote.php/dav/") { + // Use the server URL + the full WebDAV path + // Don't double-encode - the path from href is already properly encoded + format!("{}{}", self.config.server_url.trim_end_matches('/'), file_path) + } else { + // Traditional approach for other WebDAV servers or relative paths + format!("{}{}", self.base_webdav_url, file_path) + }; debug!("Downloading file: {}", file_url); + debug!("Original file_path: {}", file_path); let response = self.client .get(&file_url)