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 { useForm, SubmitHandler } from 'react-hook-form';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { useTheme as useMuiTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
interface LoginFormData {
|
interface LoginFormData {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -35,6 +37,8 @@ const Login: React.FC = () => {
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { mode } = useTheme();
|
||||||
|
const theme = useMuiTheme();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -63,7 +67,9 @@ const Login: React.FC = () => {
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: '100vh',
|
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',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|
@ -102,7 +108,9 @@ const Login: React.FC = () => {
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
mb: 1,
|
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
|
Welcome to Readur
|
||||||
|
|
@ -110,7 +118,9 @@ const Login: React.FC = () => {
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
sx={{
|
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,
|
fontWeight: 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -125,9 +135,15 @@ const Login: React.FC = () => {
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: mode === 'light'
|
||||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
? 'rgba(255, 255, 255, 0.95)'
|
||||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
: '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 }}>
|
<CardContent sx={{ p: 4 }}>
|
||||||
|
|
@ -242,7 +258,9 @@ const Login: React.FC = () => {
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
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.
|
© 2026 Readur. Powered by advanced OCR and AI technology.
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,9 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
|
sx={{
|
||||||
|
pr: 8, // Add padding-right to prevent overlap with secondary action
|
||||||
|
}}
|
||||||
primary={
|
primary={
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
|
|
@ -330,6 +333,7 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{doc.original_filename || doc.filename || 'Unknown Document'}
|
{doc.original_filename || doc.filename || 'Unknown Document'}
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,11 @@ function DocumentList({ documents, loading }: DocumentListProps) {
|
||||||
<ul className="divide-y divide-gray-200">
|
<ul className="divide-y divide-gray-200">
|
||||||
{documents.map((document) => (
|
{documents.map((document) => (
|
||||||
<li key={document.id}>
|
<li key={document.id}>
|
||||||
<div className="px-4 py-4 flex items-center justify-between">
|
<div className="px-4 py-4 flex items-center gap-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center min-w-0 flex-1">
|
||||||
{getFileIcon(document.mime_type)}
|
{getFileIcon(document.mime_type)}
|
||||||
<div className="ml-4">
|
<div className="ml-4 min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900 truncate">
|
||||||
{document.original_filename}
|
{document.original_filename}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
|
|
@ -154,13 +154,15 @@ function DocumentList({ documents, loading }: DocumentListProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(document)}
|
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"
|
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" />
|
<ArrowDownTrayIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNotificationClick = (event: React.MouseEvent<HTMLElement>): void => {
|
const handleNotificationClick = (event: React.MouseEvent<HTMLElement>): void => {
|
||||||
setNotificationAnchorEl(event.currentTarget);
|
setNotificationAnchorEl(notificationAnchorEl ? null : event.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNotificationClose = (): void => {
|
const handleNotificationClose = (): void => {
|
||||||
|
|
|
||||||
|
|
@ -339,8 +339,21 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
|
|
||||||
<ListItemText
|
<ListItemText
|
||||||
|
sx={{
|
||||||
|
pr: 6, // Add padding-right to prevent overlap with secondary action
|
||||||
|
}}
|
||||||
primary={
|
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}
|
{fileItem.file.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ const DocumentDetailsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (document && document.has_ocr_text && !ocrData) {
|
||||||
|
fetchOcrText();
|
||||||
|
}
|
||||||
|
}, [document]);
|
||||||
|
|
||||||
const fetchDocumentDetails = async (): Promise<void> => {
|
const fetchDocumentDetails = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -395,6 +401,106 @@ const DocumentDetailsPage: React.FC = () => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</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>
|
</Grid>
|
||||||
|
|
||||||
{/* OCR Text Dialog */}
|
{/* OCR Text Dialog */}
|
||||||
|
|
|
||||||
|
|
@ -357,11 +357,13 @@ const DocumentsPage: React.FC = () => {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: viewMode === 'list' ? 'row' : 'column',
|
flexDirection: viewMode === 'list' ? 'row' : 'column',
|
||||||
transition: 'all 0.2s ease-in-out',
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
cursor: 'pointer',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'translateY(-4px)',
|
transform: 'translateY(-4px)',
|
||||||
boxShadow: (theme) => theme.shadows[4],
|
boxShadow: (theme) => theme.shadows[4],
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||||
>
|
>
|
||||||
{viewMode === 'grid' && (
|
{viewMode === 'grid' && (
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -449,7 +451,10 @@ const DocumentsPage: React.FC = () => {
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => handleDocMenuClick(e, doc)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDocMenuClick(e, doc);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MoreIcon />
|
<MoreIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -461,7 +466,10 @@ const DocumentsPage: React.FC = () => {
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
onClick={() => handleDownload(doc)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDownload(doc);
|
||||||
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
|
|
|
||||||
|
|
@ -1111,7 +1111,7 @@ const SearchPage: React.FC = () => {
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0, pr: 1 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,11 @@ import {
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
Chip,
|
Chip,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
|
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
|
||||||
CloudSync as CloudSyncIcon, Folder as FolderIcon,
|
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 { useAuth } from '../contexts/AuthContext';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
|
|
@ -163,6 +164,12 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
|
||||||
const [estimatingCrawl, setEstimatingCrawl] = useState(false);
|
const [estimatingCrawl, setEstimatingCrawl] = useState(false);
|
||||||
const [newFolder, setNewFolder] = useState('');
|
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
|
// Local state for input fields to prevent focus loss
|
||||||
const [localWebdavServerUrl, setLocalWebdavServerUrl] = useState(settings.webdavServerUrl);
|
const [localWebdavServerUrl, setLocalWebdavServerUrl] = useState(settings.webdavServerUrl);
|
||||||
const [localWebdavUsername, setLocalWebdavUsername] = useState(settings.webdavUsername);
|
const [localWebdavUsername, setLocalWebdavUsername] = useState(settings.webdavUsername);
|
||||||
|
|
@ -285,6 +292,109 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
|
||||||
{ value: 'generic', label: 'Generic WebDAV' },
|
{ 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||||
|
|
@ -473,7 +583,7 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Crawl Estimation */}
|
{/* Crawl Estimation */}
|
||||||
{settings.webdavEnabled && connectionResult?.success && (
|
{settings.webdavEnabled && settings.webdavServerUrl && settings.webdavUsername && settings.webdavWatchFolders.length > 0 && (
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
|
@ -573,6 +683,129 @@ const WebDAVTabContent: React.FC<WebDAVTabContentProps> = ({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
|
|
@ -47,6 +48,7 @@ const WatchFolderPage: React.FC = () => {
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
|
const [requeuingFailed, setRequeuingFailed] = useState<boolean>(false);
|
||||||
|
|
||||||
// Mock configuration data (would typically come from API)
|
// Mock configuration data (would typically come from API)
|
||||||
const watchConfig: WatchConfig = {
|
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 => {
|
const formatFileSize = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|
@ -132,9 +154,22 @@ const WatchFolderPage: React.FC = () => {
|
||||||
startIcon={<RefreshIcon />}
|
startIcon={<RefreshIcon />}
|
||||||
onClick={fetchQueueStats}
|
onClick={fetchQueueStats}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
20
src/db.rs
20
src/db.rs
|
|
@ -1649,6 +1649,26 @@ impl Database {
|
||||||
Ok(())
|
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
|
// WebDAV file tracking operations
|
||||||
pub async fn get_webdav_file_by_path(&self, user_id: Uuid, webdav_path: &str) -> Result<Option<crate::models::WebDAVFile>> {
|
pub async fn get_webdav_file_by_path(&self, user_id: Uuid, webdav_path: &str) -> Result<Option<crate::models::WebDAVFile>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
|
|
|
||||||
14
src/main.rs
14
src/main.rs
|
|
@ -7,7 +7,7 @@ use axum::{
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}};
|
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}};
|
||||||
use tracing::{info, error};
|
use tracing::{info, error, warn};
|
||||||
|
|
||||||
use readur::{config::Config, db::Database, AppState, *};
|
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 system user for watcher
|
||||||
seed::seed_system_user(&db).await?;
|
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 = AppState { db, config: config.clone() };
|
||||||
let state = Arc::new(state);
|
let state = Arc::new(state);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,21 +195,18 @@ mod tests {
|
||||||
fn test_image_size_validation() {
|
fn test_image_size_validation() {
|
||||||
let checker = OcrHealthChecker::new();
|
let checker = OcrHealthChecker::new();
|
||||||
|
|
||||||
// Assuming we have at least 100MB available
|
|
||||||
let available = checker.check_memory_available();
|
|
||||||
if available > 100 {
|
|
||||||
// Small image should pass
|
// Small image should pass
|
||||||
assert!(checker.validate_memory_for_image(640, 480).is_ok());
|
assert!(checker.validate_memory_for_image(640, 480).is_ok());
|
||||||
|
|
||||||
// Extremely large image should fail
|
// Test with a ridiculously large image that would require more memory than any system has
|
||||||
let result = checker.validate_memory_for_image(50000, 50000);
|
// 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());
|
assert!(result.is_err());
|
||||||
|
|
||||||
if let Err(OcrError::InsufficientMemory { required, available }) = result {
|
if let Err(OcrError::InsufficientMemory { required, available }) = result {
|
||||||
assert!(required > available);
|
assert!(required > available);
|
||||||
} else {
|
} else {
|
||||||
panic!("Expected InsufficientMemory error");
|
panic!("Expected InsufficientMemory error, got: {:?}", result);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||||
.route("/estimate-crawl", post(estimate_webdav_crawl))
|
.route("/estimate-crawl", post(estimate_webdav_crawl))
|
||||||
.route("/sync-status", get(get_webdav_sync_status))
|
.route("/sync-status", get(get_webdav_sync_status))
|
||||||
.route("/start-sync", post(start_webdav_sync))
|
.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> {
|
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> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
info!("Starting WebDAV sync for user: {}", auth_user.user.username);
|
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
|
// Get user's WebDAV configuration and settings
|
||||||
let webdav_config = get_user_webdav_config(&state, auth_user.user.id).await?;
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,51 @@ pub async fn perform_webdav_sync_with_tracking(
|
||||||
error!("Failed to update sync state: {}", e);
|
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 total_files_processed = 0;
|
||||||
let mut sync_errors = Vec::new();
|
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);
|
info!("WebDAV sync completed for user {}: {} files processed", user_id, total_files_processed);
|
||||||
Ok(total_files_processed)
|
Ok(total_files_processed)
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +90,7 @@ mod tests {
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(result.is_err());
|
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]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use reqwest::{Client, Method};
|
use reqwest::{Client, Method, Url};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
@ -389,9 +389,19 @@ impl WebDAVService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_file_impl(&self, file_path: &str) -> Result<Vec<u8>> {
|
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!("Downloading file: {}", file_url);
|
||||||
|
debug!("Original file_path: {}", file_path);
|
||||||
|
|
||||||
let response = self.client
|
let response = self.client
|
||||||
.get(&file_url)
|
.get(&file_url)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue