feat(server): webdav download and ocr actually works
This commit is contained in:
parent
45ab63c0d6
commit
9e877e7aa1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
20
src/db.rs
20
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<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(
|
||||
|
|
|
|||
14
src/main.rs
14
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<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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¬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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue