feat(server): webdav download and ocr actually works

This commit is contained in:
perf3ct 2025-06-15 01:12:01 +00:00
parent 08d57c6c36
commit f2136cbd7b
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
17 changed files with 653 additions and 58 deletions

View File

@ -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<boolean>(false);
const { login } = useAuth();
const navigate = useNavigate();
const { mode } = useTheme();
const theme = useMuiTheme();
const {
register,
@ -63,7 +67,9 @@ const Login: React.FC = () => {
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
background: mode === 'light'
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -102,7 +108,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 = () => {
<Typography
variant="h6"
sx={{
color: 'rgba(255, 255, 255, 0.8)',
color: mode === 'light'
? 'rgba(255, 255, 255, 0.8)'
: 'rgba(255, 255, 255, 0.9)',
fontWeight: 400,
}}
>
@ -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)',
}}
>
<CardContent sx={{ p: 4 }}>
@ -242,7 +258,9 @@ const Login: React.FC = () => {
<Typography
variant="body2"
sx={{
color: 'rgba(255, 255, 255, 0.7)',
color: mode === 'light'
? 'rgba(255, 255, 255, 0.7)'
: 'rgba(255, 255, 255, 0.8)',
}}
>
© 2026 Readur. Powered by advanced OCR and AI technology.

View File

@ -322,6 +322,9 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
</Avatar>
</ListItemAvatar>
<ListItemText
sx={{
pr: 8, // Add padding-right to prevent overlap with secondary action
}}
primary={
<Typography
variant="subtitle2"
@ -330,6 +333,7 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{doc.original_filename || doc.filename || 'Unknown Document'}

View File

@ -137,11 +137,11 @@ function DocumentList({ documents, loading }: DocumentListProps) {
<ul className="divide-y divide-gray-200">
{documents.map((document) => (
<li key={document.id}>
<div className="px-4 py-4 flex items-center justify-between">
<div className="flex items-center">
<div className="px-4 py-4 flex items-center gap-4">
<div className="flex items-center min-w-0 flex-1">
{getFileIcon(document.mime_type)}
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
<div className="ml-4 min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">
{document.original_filename}
</div>
<div className="text-sm text-gray-500">
@ -154,12 +154,14 @@ function DocumentList({ documents, loading }: DocumentListProps) {
</div>
</div>
</div>
<button
onClick={() => handleDownload(document)}
className="ml-4 inline-flex items-center p-2 border border-transparent rounded-full 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"
>
<ArrowDownTrayIcon className="h-4 w-4" />
</button>
<div className="flex-shrink-0">
<button
onClick={() => handleDownload(document)}
className="inline-flex items-center p-2 border border-transparent rounded-full 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"
>
<ArrowDownTrayIcon className="h-4 w-4" />
</button>
</div>
</div>
</li>
))}

View File

@ -94,7 +94,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
};
const handleNotificationClick = (event: React.MouseEvent<HTMLElement>): void => {
setNotificationAnchorEl(event.currentTarget);
setNotificationAnchorEl(notificationAnchorEl ? null : event.currentTarget);
};
const handleNotificationClose = (): void => {

View File

@ -339,8 +339,21 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
</ListItemIcon>
<ListItemText
sx={{
pr: 6, // Add padding-right to prevent overlap with secondary action
}}
primary={
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
title={fileItem.file.name}
>
{fileItem.file.name}
</Typography>
}

View File

@ -64,6 +64,12 @@ const DocumentDetailsPage: React.FC = () => {
}
}, [id]);
useEffect(() => {
if (document && document.has_ocr_text && !ocrData) {
fetchOcrText();
}
}, [document]);
const fetchDocumentDetails = async (): Promise<void> => {
try {
setLoading(true);
@ -395,6 +401,106 @@ const DocumentDetailsPage: React.FC = () => {
</CardContent>
</Card>
</Grid>
{/* OCR Text Section */}
{document.has_ocr_text && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 3, fontWeight: 600 }}>
Extracted Text (OCR)
</Typography>
{ocrLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 4 }}>
<CircularProgress size={24} sx={{ mr: 2 }} />
<Typography variant="body2" color="text.secondary">
Loading OCR text...
</Typography>
</Box>
) : ocrData ? (
<>
{/* OCR Stats */}
<Box sx={{ mb: 3, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{ocrData.ocr_confidence && (
<Chip
label={`${Math.round(ocrData.ocr_confidence)}% confidence`}
color="primary"
size="small"
/>
)}
{ocrData.ocr_word_count && (
<Chip
label={`${ocrData.ocr_word_count} words`}
color="secondary"
size="small"
/>
)}
{ocrData.ocr_processing_time_ms && (
<Chip
label={`${ocrData.ocr_processing_time_ms}ms processing`}
color="info"
size="small"
/>
)}
</Box>
{/* OCR Error Display */}
{ocrData.ocr_error && (
<Alert severity="error" sx={{ mb: 3 }}>
OCR Error: {ocrData.ocr_error}
</Alert>
)}
{/* OCR Text Content */}
<Paper
sx={{
p: 3,
backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.50' : 'grey.900',
border: '1px solid',
borderColor: 'divider',
maxHeight: 400,
overflow: 'auto',
position: 'relative',
}}
>
{ocrData.ocr_text ? (
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
color: 'text.primary',
}}
>
{ocrData.ocr_text}
</Typography>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
No OCR text available for this document.
</Typography>
)}
</Paper>
{/* Processing Info */}
{ocrData.ocr_completed_at && (
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary">
Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()}
</Typography>
</Box>
)}
</>
) : (
<Alert severity="info">
OCR text is available but failed to load. Try clicking the "View OCR" button above.
</Alert>
)}
</CardContent>
</Card>
</Grid>
)}
</Grid>
{/* OCR Text Dialog */}

View File

@ -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' && (
<Box
@ -449,7 +451,10 @@ const DocumentsPage: React.FC = () => {
<IconButton
size="small"
onClick={(e) => handleDocMenuClick(e, doc)}
onClick={(e) => {
e.stopPropagation();
handleDocMenuClick(e, doc);
}}
>
<MoreIcon />
</IconButton>
@ -461,7 +466,10 @@ const DocumentsPage: React.FC = () => {
<Button
size="small"
startIcon={<DownloadIcon />}
onClick={() => handleDownload(doc)}
onClick={(e) => {
e.stopPropagation();
handleDownload(doc);
}}
fullWidth
>
Download

View File

@ -1111,7 +1111,7 @@ const SearchPage: React.FC = () => {
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Box sx={{ flexGrow: 1, minWidth: 0, pr: 1 }}>
<Typography
variant="h6"
sx={{

View File

@ -34,10 +34,11 @@ import {
SelectChangeEvent,
Chip,
LinearProgress,
CircularProgress,
} from '@mui/material';
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
CloudSync as CloudSyncIcon, Folder as FolderIcon,
Assessment as AssessmentIcon } from '@mui/icons-material';
Assessment as AssessmentIcon, PlayArrow as PlayArrowIcon } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
@ -163,6 +164,12 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
const [estimatingCrawl, setEstimatingCrawl] = useState(false);
const [newFolder, setNewFolder] = useState('');
// WebDAV sync state
const [syncStatus, setSyncStatus] = useState<any>(null);
const [startingSync, setStartingSync] = useState(false);
const [cancellingSync, setCancellingSync] = useState(false);
const [pollingSyncStatus, setPollingSyncStatus] = useState(false);
// Local state for input fields to prevent focus loss
const [localWebdavServerUrl, setLocalWebdavServerUrl] = useState(settings.webdavServerUrl);
const [localWebdavUsername, setLocalWebdavUsername] = useState(settings.webdavUsername);
@ -285,6 +292,109 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
{ value: 'generic', label: 'Generic WebDAV' },
];
// WebDAV sync functions
const fetchSyncStatus = async () => {
try {
const response = await api.get('/webdav/sync-status');
setSyncStatus(response.data);
} catch (error) {
console.error('Failed to fetch sync status:', error);
}
};
const startManualSync = async () => {
setStartingSync(true);
try {
const response = await api.post('/webdav/start-sync');
if (response.data.success) {
onShowSnackbar('WebDAV sync started successfully', 'success');
setPollingSyncStatus(true);
fetchSyncStatus(); // Get initial status
} else if (response.data.error === 'sync_already_running') {
onShowSnackbar('A WebDAV sync is already in progress', 'warning');
} else {
onShowSnackbar(response.data.message || 'Failed to start sync', 'error');
}
} catch (error: any) {
console.error('Failed to start sync:', error);
onShowSnackbar('Failed to start WebDAV sync', 'error');
} finally {
setStartingSync(false);
}
};
const cancelManualSync = async () => {
setCancellingSync(true);
try {
const response = await api.post('/webdav/cancel-sync');
if (response.data.success) {
onShowSnackbar('WebDAV sync cancelled successfully', 'info');
fetchSyncStatus(); // Update status
} else {
onShowSnackbar(response.data.message || 'Failed to cancel sync', 'error');
}
} catch (error: any) {
console.error('Failed to cancel sync:', error);
onShowSnackbar('Failed to cancel WebDAV sync', 'error');
} finally {
setCancellingSync(false);
}
};
// Poll sync status when enabled
useEffect(() => {
if (!settings.webdavEnabled) {
setSyncStatus(null);
setPollingSyncStatus(false);
return;
}
// Initial fetch
fetchSyncStatus();
// Set up polling interval
const interval = setInterval(() => {
fetchSyncStatus();
}, 3000); // Poll every 3 seconds
return () => clearInterval(interval);
}, [settings.webdavEnabled]);
// Stop polling when sync is not running
useEffect(() => {
if (syncStatus && !syncStatus.is_running && pollingSyncStatus) {
setPollingSyncStatus(false);
}
}, [syncStatus, pollingSyncStatus]);
// Auto-restart sync when folder list changes (if sync was running)
const [previousFolders, setPreviousFolders] = useState<string[]>([]);
useEffect(() => {
if (previousFolders.length > 0 &&
JSON.stringify(previousFolders.sort()) !== JSON.stringify([...settings.webdavWatchFolders].sort()) &&
syncStatus?.is_running) {
onShowSnackbar('Folder list changed - restarting WebDAV sync', 'info');
// Cancel current sync and start a new one
const restartSync = async () => {
try {
await api.post('/webdav/cancel-sync');
// Small delay to ensure cancellation is processed
setTimeout(() => {
startManualSync();
}, 1000);
} catch (error) {
console.error('Failed to restart sync after folder change:', error);
}
};
restartSync();
}
setPreviousFolders([...settings.webdavWatchFolders]);
}, [settings.webdavWatchFolders, syncStatus?.is_running]);
return (
<Box>
<Typography variant="h6" sx={{ mb: 3 }}>
@ -473,7 +583,7 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
)}
{/* Crawl Estimation */}
{settings.webdavEnabled && connectionResult?.success && (
{settings.webdavEnabled && settings.webdavServerUrl && settings.webdavUsername && settings.webdavWatchFolders.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
@ -573,6 +683,129 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
</CardContent>
</Card>
)}
{/* Manual Sync & Status */}
{settings.webdavEnabled && settings.webdavServerUrl && settings.webdavUsername && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
<PlayArrowIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Manual Sync & Status
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={3}>
{/* Sync Controls */}
<Grid item xs={12} md={6}>
<Box>
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
Start a manual WebDAV sync to immediately pull new or changed files from your configured folders.
</Typography>
<Button
variant="contained"
startIcon={startingSync ? <CircularProgress size={16} /> : <PlayArrowIcon />}
onClick={startManualSync}
disabled={startingSync || loading || syncStatus?.is_running}
sx={{ mr: 2 }}
>
{startingSync ? 'Starting...' : syncStatus?.is_running ? 'Sync Running...' : 'Start Sync Now'}
</Button>
{syncStatus?.is_running && (
<Button
variant="outlined"
color="error"
startIcon={cancellingSync ? <CircularProgress size={16} /> : undefined}
onClick={cancelManualSync}
disabled={cancellingSync || loading}
sx={{ mr: 2 }}
>
{cancellingSync ? 'Cancelling...' : 'Cancel Sync'}
</Button>
)}
{syncStatus?.is_running && (
<Chip
label="Sync Active"
color="primary"
variant="outlined"
icon={<CircularProgress size={12} />}
sx={{ ml: 1 }}
/>
)}
</Box>
</Grid>
{/* Sync Status */}
<Grid item xs={12} md={6}>
{syncStatus && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Sync Status
</Typography>
<Grid container spacing={1}>
<Grid item xs={6}>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h6" color="primary">
{syncStatus.files_processed || 0}
</Typography>
<Typography variant="caption" color="text.secondary">
Files Processed
</Typography>
</Paper>
</Grid>
<Grid item xs={6}>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h6" color="secondary">
{syncStatus.files_remaining || 0}
</Typography>
<Typography variant="caption" color="text.secondary">
Files Remaining
</Typography>
</Paper>
</Grid>
</Grid>
{syncStatus.current_folder && (
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="body2">
<strong>Currently syncing:</strong> {syncStatus.current_folder}
</Typography>
</Alert>
)}
{syncStatus.last_sync && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Last sync: {new Date(syncStatus.last_sync).toLocaleString()}
</Typography>
)}
{syncStatus.errors && syncStatus.errors.length > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Recent Errors:</strong>
</Typography>
{syncStatus.errors.slice(0, 3).map((error: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
{error}
</Typography>
))}
{syncStatus.errors.length > 3 && (
<Typography variant="caption" color="text.secondary">
... and {syncStatus.errors.length - 3} more errors
</Typography>
)}
</Alert>
)}
</Box>
)}
</Grid>
</Grid>
</CardContent>
</Card>
)}
</Box>
);
};

View File

@ -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<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [requeuingFailed, setRequeuingFailed] = useState<boolean>(false);
// Mock configuration data (would typically come from API)
const watchConfig: WatchConfig = {
@ -79,6 +81,26 @@ const WatchFolderPage: React.FC = () => {
}
};
const requeueFailedJobs = async (): Promise<void> => {
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={<RefreshIcon />}
onClick={fetchQueueStats}
disabled={loading}
sx={{ mr: 2 }}
>
Refresh
</Button>
{queueStats && queueStats.failed_count > 0 && (
<Button
variant="contained"
color="warning"
startIcon={requeuingFailed ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={requeueFailedJobs}
disabled={requeuingFailed || loading}
>
{requeuingFailed ? 'Requeuing...' : `Retry ${queueStats.failed_count} Failed Jobs`}
</Button>
)}
</Box>
{error && (

View File

@ -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<i64> {
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<Option<crate::models::WebDAVFile>> {
let row = sqlx::query(

View File

@ -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<dyn std::error::Error>> {
// 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);

View File

@ -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);
}
}
}

View File

@ -29,6 +29,7 @@ pub fn router() -> Router<Arc<AppState>> {
.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<AppState>, user_id: uuid::Uuid) -> Result<WebDAVConfig, StatusCode> {
@ -303,6 +304,25 @@ async fn start_webdav_sync(
) -> Result<Json<Value>, 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<Arc<AppState>>,
auth_user: AuthUser,
) -> Result<Json<Value>, 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, &notification).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)
}
}
}

View File

@ -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<String>, 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<AppState>,
user_id: uuid::Uuid,
webdav_service: WebDAVService,
config: WebDAVConfig,
enable_background_ocr: bool,
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
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)
}

View File

@ -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]

View File

@ -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<Vec<u8>> {
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)