diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9d6de7d..ffc4678 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -12,6 +12,7 @@ import DocumentsPage from './pages/DocumentsPage';
import SearchPage from './pages/SearchPage';
import DocumentDetailsPage from './pages/DocumentDetailsPage';
import SettingsPage from './pages/SettingsPage';
+import SourcesPage from './pages/SourcesPage';
import WatchFolderPage from './pages/WatchFolderPage';
function App(): JSX.Element {
@@ -65,6 +66,7 @@ function App(): JSX.Element {
} />
} />
} />
+ } />
} />
} />
Profile Page - Coming Soon} />
diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx
index b9feea6..65a1fa9 100644
--- a/frontend/src/components/Layout/AppLayout.tsx
+++ b/frontend/src/components/Layout/AppLayout.tsx
@@ -31,6 +31,7 @@ import {
AccountCircle as AccountIcon,
Logout as LogoutIcon,
Description as DocumentIcon,
+ Storage as StorageIcon,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
@@ -61,6 +62,7 @@ const navigationItems: NavigationItem[] = [
{ text: 'Upload', icon: UploadIcon, path: '/upload' },
{ text: 'Documents', icon: DocumentIcon, path: '/documents' },
{ text: 'Search', icon: SearchIcon, path: '/search' },
+ { text: 'Sources', icon: StorageIcon, path: '/sources' },
{ text: 'Watch Folder', icon: FolderIcon, path: '/watch' },
];
diff --git a/frontend/src/pages/SourcesPage.tsx b/frontend/src/pages/SourcesPage.tsx
new file mode 100644
index 0000000..a91e119
--- /dev/null
+++ b/frontend/src/pages/SourcesPage.tsx
@@ -0,0 +1,1078 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Box,
+ Container,
+ Typography,
+ Paper,
+ Button,
+ Grid,
+ Card,
+ CardContent,
+ Chip,
+ IconButton,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Alert,
+ LinearProgress,
+ Snackbar,
+ Divider,
+ FormControlLabel,
+ Switch,
+ Tooltip,
+ CircularProgress,
+ Fade,
+ Stack,
+ Avatar,
+ Badge,
+ useTheme,
+ alpha,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+} from '@mui/material';
+import {
+ Add as AddIcon,
+ CloudSync as CloudSyncIcon,
+ Error as ErrorIcon,
+ CheckCircle as CheckCircleIcon,
+ Edit as EditIcon,
+ Delete as DeleteIcon,
+ PlayArrow as PlayArrowIcon,
+ Storage as StorageIcon,
+ Cloud as CloudIcon,
+ Speed as SpeedIcon,
+ Timeline as TimelineIcon,
+ TrendingUp as TrendingUpIcon,
+ Security as SecurityIcon,
+ AutoFixHigh as AutoFixHighIcon,
+ Sync as SyncIcon,
+ MoreVert as MoreVertIcon,
+ Menu as MenuIcon,
+ Folder as FolderIcon,
+ Assessment as AssessmentIcon,
+ Extension as ExtensionIcon,
+ Server as ServerIcon,
+} from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
+import api from '../services/api';
+import { formatDistanceToNow } from 'date-fns';
+
+interface Source {
+ id: string;
+ name: string;
+ source_type: 'webdav' | 'local_folder' | 's3';
+ enabled: boolean;
+ config: any;
+ status: 'idle' | 'syncing' | 'error';
+ last_sync_at: string | null;
+ last_error: string | null;
+ last_error_at: string | null;
+ total_files_synced: number;
+ total_files_pending: number;
+ total_size_bytes: number;
+ created_at: string;
+ updated_at: string;
+}
+
+interface SnackbarState {
+ open: boolean;
+ message: string;
+ severity: 'success' | 'error' | 'warning' | 'info';
+}
+
+const SourcesPage: React.FC = () => {
+ const theme = useTheme();
+ const navigate = useNavigate();
+ const [sources, setSources] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingSource, setEditingSource] = useState(null);
+ const [snackbar, setSnackbar] = useState({
+ open: false,
+ message: '',
+ severity: 'info',
+ });
+
+ // Form state
+ const [formData, setFormData] = useState({
+ name: '',
+ source_type: 'webdav' as 'webdav' | 'local_folder' | 's3',
+ enabled: true,
+ server_url: '',
+ username: '',
+ password: '',
+ watch_folders: ['/Documents'],
+ file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
+ auto_sync: false,
+ sync_interval_minutes: 60,
+ server_type: 'generic' as 'nextcloud' | 'owncloud' | 'generic',
+ });
+
+ // Additional state for enhanced features
+ const [newFolder, setNewFolder] = useState('');
+ const [newExtension, setNewExtension] = useState('');
+ const [crawlEstimate, setCrawlEstimate] = useState(null);
+ const [estimatingCrawl, setEstimatingCrawl] = useState(false);
+
+ const [testingConnection, setTestingConnection] = useState(false);
+ const [syncingSource, setSyncingSource] = useState(null);
+
+ useEffect(() => {
+ loadSources();
+ }, []);
+
+ const loadSources = async () => {
+ try {
+ const response = await api.get('/sources');
+ setSources(response.data);
+ } catch (error) {
+ console.error('Failed to load sources:', error);
+ showSnackbar('Failed to load sources', 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const showSnackbar = (message: string, severity: SnackbarState['severity']) => {
+ setSnackbar({ open: true, message, severity });
+ };
+
+ const handleCreateSource = () => {
+ setEditingSource(null);
+ setFormData({
+ name: '',
+ source_type: 'webdav',
+ enabled: true,
+ server_url: '',
+ username: '',
+ password: '',
+ watch_folders: ['/Documents'],
+ file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
+ auto_sync: false,
+ sync_interval_minutes: 60,
+ server_type: 'generic',
+ });
+ setCrawlEstimate(null);
+ setNewFolder('');
+ setNewExtension('');
+ setDialogOpen(true);
+ };
+
+ const handleEditSource = (source: Source) => {
+ setEditingSource(source);
+ const config = source.config;
+ setFormData({
+ name: source.name,
+ source_type: source.source_type,
+ enabled: source.enabled,
+ server_url: config.server_url || '',
+ username: config.username || '',
+ password: config.password || '',
+ watch_folders: config.watch_folders || ['/Documents'],
+ file_extensions: config.file_extensions || ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
+ auto_sync: config.auto_sync || false,
+ sync_interval_minutes: config.sync_interval_minutes || 60,
+ server_type: config.server_type || 'generic',
+ });
+ setCrawlEstimate(null);
+ setNewFolder('');
+ setNewExtension('');
+ setDialogOpen(true);
+ };
+
+ const handleSaveSource = async () => {
+ try {
+ const config = {
+ server_url: formData.server_url,
+ username: formData.username,
+ password: formData.password,
+ watch_folders: formData.watch_folders,
+ file_extensions: formData.file_extensions,
+ auto_sync: formData.auto_sync,
+ sync_interval_minutes: formData.sync_interval_minutes,
+ server_type: formData.server_type,
+ };
+
+ if (editingSource) {
+ await api.put(`/sources/${editingSource.id}`, {
+ name: formData.name,
+ enabled: formData.enabled,
+ config,
+ });
+ showSnackbar('Source updated successfully', 'success');
+ } else {
+ await api.post('/sources', {
+ name: formData.name,
+ source_type: formData.source_type,
+ enabled: formData.enabled,
+ config,
+ });
+ showSnackbar('Source created successfully', 'success');
+ }
+
+ setDialogOpen(false);
+ loadSources();
+ } catch (error) {
+ console.error('Failed to save source:', error);
+ showSnackbar('Failed to save source', 'error');
+ }
+ };
+
+ const handleDeleteSource = async (source: Source) => {
+ if (!confirm(`Are you sure you want to delete "${source.name}"?`)) {
+ return;
+ }
+
+ try {
+ await api.delete(`/sources/${source.id}`);
+ showSnackbar('Source deleted successfully', 'success');
+ loadSources();
+ } catch (error) {
+ console.error('Failed to delete source:', error);
+ showSnackbar('Failed to delete source', 'error');
+ }
+ };
+
+ const handleTestConnection = async () => {
+ if (!editingSource) return;
+
+ setTestingConnection(true);
+ try {
+ const response = await api.post(`/sources/${editingSource.id}/test`);
+ if (response.data.success) {
+ showSnackbar('Connection successful!', 'success');
+ } else {
+ showSnackbar(response.data.message || 'Connection failed', 'error');
+ }
+ } catch (error) {
+ console.error('Failed to test connection:', error);
+ showSnackbar('Failed to test connection', 'error');
+ } finally {
+ setTestingConnection(false);
+ }
+ };
+
+ const handleTriggerSync = async (sourceId: string) => {
+ setSyncingSource(sourceId);
+ try {
+ await api.post(`/sources/${sourceId}/sync`);
+ showSnackbar('Sync started successfully', 'success');
+ setTimeout(loadSources, 1000);
+ } catch (error: any) {
+ console.error('Failed to trigger sync:', error);
+ if (error.response?.status === 409) {
+ showSnackbar('Source is already syncing', 'warning');
+ } else {
+ showSnackbar('Failed to start sync', 'error');
+ }
+ } finally {
+ setSyncingSource(null);
+ }
+ };
+
+ // Utility functions for folder management
+ const addFolder = () => {
+ if (newFolder && !formData.watch_folders.includes(newFolder)) {
+ setFormData({
+ ...formData,
+ watch_folders: [...formData.watch_folders, newFolder]
+ });
+ setNewFolder('');
+ }
+ };
+
+ const removeFolder = (folderToRemove: string) => {
+ setFormData({
+ ...formData,
+ watch_folders: formData.watch_folders.filter(folder => folder !== folderToRemove)
+ });
+ };
+
+ // Utility functions for file extension management
+ const addFileExtension = () => {
+ if (newExtension && !formData.file_extensions.includes(newExtension)) {
+ setFormData({
+ ...formData,
+ file_extensions: [...formData.file_extensions, newExtension]
+ });
+ setNewExtension('');
+ }
+ };
+
+ const removeFileExtension = (extensionToRemove: string) => {
+ setFormData({
+ ...formData,
+ file_extensions: formData.file_extensions.filter(ext => ext !== extensionToRemove)
+ });
+ };
+
+ // Crawl estimation function
+ const estimateCrawl = async () => {
+ if (!editingSource) return;
+
+ setEstimatingCrawl(true);
+ try {
+ const response = await api.post('/webdav/estimate', {
+ server_url: formData.server_url,
+ username: formData.username,
+ password: formData.password,
+ watch_folders: formData.watch_folders,
+ file_extensions: formData.file_extensions,
+ server_type: formData.server_type,
+ });
+ setCrawlEstimate(response.data);
+ showSnackbar('Crawl estimation completed', 'success');
+ } catch (error) {
+ console.error('Failed to estimate crawl:', error);
+ showSnackbar('Failed to estimate crawl', 'error');
+ } finally {
+ setEstimatingCrawl(false);
+ }
+ };
+
+ const getSourceIcon = (sourceType: string) => {
+ switch (sourceType) {
+ case 'webdav':
+ return ;
+ case 's3':
+ return ;
+ case 'local_folder':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusIcon = (source: Source) => {
+ if (source.status === 'syncing') {
+ return ;
+ } else if (source.status === 'error') {
+ return ;
+ } else {
+ return ;
+ }
+ };
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'syncing':
+ return theme.palette.info.main;
+ case 'error':
+ return theme.palette.error.main;
+ default:
+ return theme.palette.success.main;
+ }
+ };
+
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+ };
+
+ const StatCard = ({ icon, label, value, color = 'primary' }: {
+ icon: React.ReactNode;
+ label: string;
+ value: string | number;
+ color?: 'primary' | 'success' | 'warning' | 'error'
+ }) => (
+
+
+
+ {icon}
+
+
+
+ {typeof value === 'number' ? value.toLocaleString() : value}
+
+
+ {label}
+
+
+
+
+ );
+
+ const renderSourceCard = (source: Source) => (
+
+
+
+ {/* Header */}
+
+
+
+ {getSourceIcon(source.source_type)}
+
+
+
+ {source.name}
+
+
+
+
+ {!source.enabled && (
+
+ )}
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+ handleTriggerSync(source.id)}
+ disabled={source.status === 'syncing' || !source.enabled}
+ sx={{
+ bgcolor: alpha(theme.palette.primary.main, 0.1),
+ '&:hover': { bgcolor: alpha(theme.palette.primary.main, 0.2) },
+ }}
+ >
+ {syncingSource === source.id ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ handleEditSource(source)}
+ sx={{
+ bgcolor: alpha(theme.palette.grey[500], 0.1),
+ '&:hover': { bgcolor: alpha(theme.palette.grey[500], 0.2) },
+ }}
+ >
+
+
+
+
+ handleDeleteSource(source)}
+ sx={{
+ bgcolor: alpha(theme.palette.error.main, 0.1),
+ '&:hover': { bgcolor: alpha(theme.palette.error.main, 0.2) },
+ color: theme.palette.error.main,
+ }}
+ >
+
+
+
+
+
+
+ {/* Stats Grid */}
+
+
+ }
+ label="Files Synced"
+ value={source.total_files_synced}
+ color="success"
+ />
+
+
+ }
+ label="Files Pending"
+ value={source.total_files_pending}
+ color="warning"
+ />
+
+
+ }
+ label="Total Size"
+ value={formatBytes(source.total_size_bytes)}
+ color="primary"
+ />
+
+
+ }
+ label="Last Sync"
+ value={source.last_sync_at
+ ? formatDistanceToNow(new Date(source.last_sync_at), { addSuffix: true })
+ : 'Never'}
+ color="primary"
+ />
+
+
+
+ {/* Error Alert */}
+ {source.last_error && (
+
+
+ {source.last_error}
+
+ {source.last_error_at && (
+
+ {formatDistanceToNow(new Date(source.last_error_at), { addSuffix: true })}
+
+ )}
+
+ )}
+
+
+
+ );
+
+ return (
+
+ {/* Header */}
+
+
+ Document Sources
+
+
+ Connect and manage your document sources with intelligent syncing
+
+
+
+ }
+ onClick={handleCreateSource}
+ sx={{
+ borderRadius: 3,
+ px: 4,
+ py: 1.5,
+ background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
+ boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.3)}`,
+ '&:hover': {
+ transform: 'translateY(-2px)',
+ boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.4)}`,
+ },
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
+ }}
+ >
+ Add Source
+
+
+ }
+ onClick={loadSources}
+ sx={{
+ borderRadius: 3,
+ px: 4,
+ py: 1.5,
+ borderWidth: 2,
+ '&:hover': {
+ borderWidth: 2,
+ transform: 'translateY(-1px)',
+ },
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
+ }}
+ >
+ Refresh
+
+
+
+
+ {/* Content */}
+ {loading ? (
+
+
+
+ ) : sources.length === 0 ? (
+
+
+
+
+
+ No Sources Configured
+
+
+ Connect your first document source to start automatically syncing and processing your files with AI-powered OCR.
+
+ }
+ onClick={handleCreateSource}
+ sx={{
+ borderRadius: 3,
+ px: 6,
+ py: 2,
+ fontSize: '1.1rem',
+ background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
+ boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.3)}`,
+ '&:hover': {
+ transform: 'translateY(-2px)',
+ boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.4)}`,
+ },
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
+ }}
+ >
+ Add Your First Source
+
+
+ ) : (
+
+ {sources.map(renderSourceCard)}
+
+ )}
+
+ {/* Create/Edit Dialog - Enhanced */}
+
+
+ {/* Snackbar */}
+ setSnackbar({ ...snackbar, open: false })}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
+ >
+ setSnackbar({ ...snackbar, open: false })}
+ severity={snackbar.severity}
+ sx={{
+ width: '100%',
+ borderRadius: 3,
+ }}
+ >
+ {snackbar.message}
+
+
+
+ {/* Custom CSS for animations */}
+
+
+ );
+};
+
+export default SourcesPage;
\ No newline at end of file
diff --git a/migrations/20240101000011_add_sources_table.sql b/migrations/20240101000011_add_sources_table.sql
new file mode 100644
index 0000000..c1977c6
--- /dev/null
+++ b/migrations/20240101000011_add_sources_table.sql
@@ -0,0 +1,87 @@
+-- Create sources table to support multiple document sources per user
+CREATE TABLE IF NOT EXISTS sources (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ source_type TEXT NOT NULL, -- 'webdav', 'local_folder', 's3', etc.
+ enabled BOOLEAN DEFAULT TRUE,
+
+ -- Configuration (JSON to allow flexibility for different source types)
+ config JSONB NOT NULL DEFAULT '{}',
+
+ -- Status tracking
+ status TEXT DEFAULT 'idle', -- 'idle', 'syncing', 'error'
+ last_sync_at TIMESTAMPTZ,
+ last_error TEXT,
+ last_error_at TIMESTAMPTZ,
+
+ -- Statistics
+ total_files_synced BIGINT DEFAULT 0,
+ total_files_pending BIGINT DEFAULT 0,
+ total_size_bytes BIGINT DEFAULT 0,
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(user_id, name)
+);
+
+-- Create indexes for performance
+CREATE INDEX IF NOT EXISTS idx_sources_user_id ON sources(user_id);
+CREATE INDEX IF NOT EXISTS idx_sources_source_type ON sources(source_type);
+CREATE INDEX IF NOT EXISTS idx_sources_status ON sources(status);
+
+-- Update documents table to link to sources
+ALTER TABLE documents ADD COLUMN IF NOT EXISTS source_id UUID REFERENCES sources(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_documents_source_id ON documents(source_id);
+
+-- Update webdav_files table to link to sources instead of users directly
+ALTER TABLE webdav_files ADD COLUMN IF NOT EXISTS source_id UUID REFERENCES sources(id) ON DELETE CASCADE;
+
+-- Migrate existing WebDAV settings to sources table
+INSERT INTO sources (user_id, name, source_type, enabled, config, created_at, updated_at)
+SELECT
+ s.user_id,
+ 'WebDAV Server' as name,
+ 'webdav' as source_type,
+ s.webdav_enabled as enabled,
+ jsonb_build_object(
+ 'server_url', s.webdav_server_url,
+ 'username', s.webdav_username,
+ 'password', s.webdav_password,
+ 'watch_folders', s.webdav_watch_folders,
+ 'file_extensions', s.webdav_file_extensions,
+ 'auto_sync', s.webdav_auto_sync,
+ 'sync_interval_minutes', s.webdav_sync_interval_minutes
+ ) as config,
+ NOW() as created_at,
+ NOW() as updated_at
+FROM settings s
+WHERE s.webdav_enabled = TRUE
+ AND s.webdav_server_url IS NOT NULL
+ AND s.webdav_username IS NOT NULL;
+
+-- Update webdav_files to link to the newly created sources
+UPDATE webdav_files wf
+SET source_id = s.id
+FROM sources s
+WHERE wf.user_id = s.user_id
+ AND s.source_type = 'webdav';
+
+-- Create a function to update the updated_at timestamp
+CREATE OR REPLACE FUNCTION update_sources_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Create trigger to auto-update updated_at
+CREATE TRIGGER sources_updated_at_trigger
+BEFORE UPDATE ON sources
+FOR EACH ROW
+EXECUTE FUNCTION update_sources_updated_at();
+
+-- Note: We're keeping the webdav fields in settings table for now to ensure backward compatibility
+-- They will be removed in a future migration after ensuring all code is updated
\ No newline at end of file
diff --git a/src/db.rs b/src/db.rs
index b3283f7..503ef15 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -1836,4 +1836,260 @@ impl Database {
Ok(files)
}
+
+ // Sources methods
+ pub async fn create_source(&self, user_id: Uuid, source: &crate::models::CreateSource) -> Result {
+ let id = Uuid::new_v4();
+ let now = Utc::now();
+
+ let row = sqlx::query(
+ r#"INSERT INTO sources (id, user_id, name, source_type, enabled, config, status, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'idle', $7, $8)
+ RETURNING *"#
+ )
+ .bind(id)
+ .bind(user_id)
+ .bind(&source.name)
+ .bind(source.source_type.to_string())
+ .bind(source.enabled.unwrap_or(true))
+ .bind(&source.config)
+ .bind(now)
+ .bind(now)
+ .fetch_one(&self.pool)
+ .await?;
+
+ Ok(crate::models::Source {
+ id: row.get("id"),
+ user_id: row.get("user_id"),
+ name: row.get("name"),
+ source_type: row.get::("source_type").try_into().map_err(|e: String| anyhow::anyhow!(e))?,
+ enabled: row.get("enabled"),
+ config: row.get("config"),
+ status: row.get::("status").try_into().map_err(|e: String| anyhow::anyhow!(e))?,
+ last_sync_at: row.get("last_sync_at"),
+ last_error: row.get("last_error"),
+ last_error_at: row.get("last_error_at"),
+ total_files_synced: row.get("total_files_synced"),
+ total_files_pending: row.get("total_files_pending"),
+ total_size_bytes: row.get("total_size_bytes"),
+ created_at: row.get("created_at"),
+ updated_at: row.get("updated_at"),
+ })
+ }
+
+ pub async fn get_source(&self, user_id: Uuid, source_id: Uuid) -> Result