diff --git a/surfsense_backend/alembic/versions/117_add_local_folder_connector_and_versioning.py b/surfsense_backend/alembic/versions/117_add_local_folder_sync_and_versioning.py similarity index 82% rename from surfsense_backend/alembic/versions/117_add_local_folder_connector_and_versioning.py rename to surfsense_backend/alembic/versions/117_add_local_folder_sync_and_versioning.py index a9da3beb4..e322a608d 100644 --- a/surfsense_backend/alembic/versions/117_add_local_folder_connector_and_versioning.py +++ b/surfsense_backend/alembic/versions/117_add_local_folder_sync_and_versioning.py @@ -1,4 +1,4 @@ -"""Add LOCAL_FOLDER_FILE document type and document_versions table +"""Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table Revision ID: 117 Revises: 116 @@ -38,6 +38,19 @@ def upgrade() -> None: """ ) + # Add JSONB metadata column to folders table + col_exists = conn.execute( + sa.text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = 'folders' AND column_name = 'metadata'" + ) + ).fetchone() + if not col_exists: + op.add_column( + "folders", + sa.Column("metadata", sa.dialects.postgresql.JSONB, nullable=True), + ) + # Create document_versions table table_exists = conn.execute( sa.text( @@ -124,3 +137,13 @@ def downgrade() -> None: op.execute("DROP INDEX IF EXISTS ix_document_versions_created_at") op.execute("DROP INDEX IF EXISTS ix_document_versions_document_id") op.execute("DROP TABLE IF EXISTS document_versions") + + # Drop metadata column from folders + col_exists = conn.execute( + sa.text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = 'folders' AND column_name = 'metadata'" + ) + ).fetchone() + if col_exists: + op.drop_column("folders", "metadata") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 1a4d3ea06..077b7daa6 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -956,6 +956,7 @@ class Folder(BaseModel, TimestampMixin): onupdate=lambda: datetime.now(UTC), index=True, ) + folder_metadata = Column("metadata", JSONB, nullable=True) parent = relationship("Folder", remote_side="Folder.id", backref="children") search_space = relationship("SearchSpace", back_populates="folders") diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index 05221b192..81bbb1477 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -1310,6 +1310,13 @@ async def folder_index( "You don't have permission to create documents in this search space", ) + watched_metadata = { + "watched": True, + "folder_path": request.folder_path, + "exclude_patterns": request.exclude_patterns, + "file_extensions": request.file_extensions, + } + root_folder_id = request.root_folder_id if root_folder_id: existing = ( @@ -1319,6 +1326,9 @@ async def folder_index( ).scalar_one_or_none() if not existing: root_folder_id = None + else: + existing.folder_metadata = watched_metadata + await session.commit() if not root_folder_id: root_folder = Folder( @@ -1326,6 +1336,7 @@ async def folder_index( search_space_id=request.search_space_id, created_by_id=str(user.id), position="a0", + folder_metadata=watched_metadata, ) session.add(root_folder) await session.flush() @@ -1403,3 +1414,34 @@ async def folder_index_file( "message": "File indexing started", "status": "processing", } + + +@router.get("/documents/watched-folders", response_model=list["FolderRead"]) +async def get_watched_folders( + search_space_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Return root folders that are marked as watched (metadata->>'watched' = 'true').""" + from app.schemas import FolderRead # noqa: F811 + + await check_permission( + session, + user, + search_space_id, + Permission.DOCUMENTS_READ.value, + "You don't have permission to read documents in this search space", + ) + + folders = ( + await session.execute( + select(Folder).where( + Folder.search_space_id == search_space_id, + Folder.parent_id.is_(None), + Folder.folder_metadata.isnot(None), + Folder.folder_metadata["watched"].astext == "true", + ) + ) + ).scalars().all() + + return folders diff --git a/surfsense_backend/app/routes/folders_routes.py b/surfsense_backend/app/routes/folders_routes.py index d688e692a..6e524d4a4 100644 --- a/surfsense_backend/app/routes/folders_routes.py +++ b/surfsense_backend/app/routes/folders_routes.py @@ -192,6 +192,33 @@ async def get_folder_breadcrumb( ) from e +@router.patch("/folders/{folder_id}/watched") +async def stop_watching_folder( + folder_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Clear the watched flag from a folder's metadata.""" + folder = await session.get(Folder, folder_id) + if not folder: + raise HTTPException(status_code=404, detail="Folder not found") + + await check_permission( + session, + user, + folder.search_space_id, + Permission.DOCUMENTS_UPDATE.value, + "You don't have permission to update folders in this search space", + ) + + if folder.folder_metadata and isinstance(folder.folder_metadata, dict): + updated = {**folder.folder_metadata, "watched": False} + folder.folder_metadata = updated + await session.commit() + + return {"message": "Folder watch status updated"} + + @router.put("/folders/{folder_id}", response_model=FolderRead) async def update_folder( folder_id: int, diff --git a/surfsense_backend/app/schemas/folders.py b/surfsense_backend/app/schemas/folders.py index 263817182..e8bdf3821 100644 --- a/surfsense_backend/app/schemas/folders.py +++ b/surfsense_backend/app/schemas/folders.py @@ -3,6 +3,8 @@ from datetime import datetime from uuid import UUID +from typing import Any + from pydantic import BaseModel, ConfigDict, Field @@ -34,6 +36,7 @@ class FolderRead(BaseModel): created_by_id: UUID | None created_at: datetime updated_at: datetime + metadata: dict[str, Any] | None = Field(default=None, validation_alias="folder_metadata") model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index 1521c06fe..6780bd1e5 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -76,6 +76,7 @@ interface FolderNodeProps { isWatched?: boolean; onRescan?: (folder: FolderDisplay) => void; onStopWatching?: (folder: FolderDisplay) => void; + onViewMetadata?: (folder: FolderDisplay) => void; } function getDropZone( @@ -116,6 +117,7 @@ export const FolderNode = React.memo(function FolderNode({ isWatched, onRescan, onStopWatching, + onViewMetadata, }: FolderNodeProps) { const [renameValue, setRenameValue] = useState(folder.name); const inputRef = useRef(null); @@ -251,13 +253,21 @@ export const FolderNode = React.memo(function FolderNode({ isOver && !canDrop && "cursor-not-allowed" )} style={{ paddingLeft: `${depth * 16 + 4}px` }} - onClick={() => onToggleExpand(folder.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onToggleExpand(folder.id); - } - }} + onClick={(e) => { + if ((e.ctrlKey || e.metaKey) && onViewMetadata) { + e.preventDefault(); + e.stopPropagation(); + onViewMetadata(folder); + return; + } + onToggleExpand(folder.id); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggleExpand(folder.id); + } + }} onDoubleClick={(e) => { e.stopPropagation(); startRename(); diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index 5945edccb..f34b9a0c2 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -43,6 +43,7 @@ interface FolderTreeViewProps { watchedFolderIds?: Set; onRescanFolder?: (folder: FolderDisplay) => void; onStopWatchingFolder?: (folder: FolderDisplay) => void; + onViewFolderMetadata?: (folder: FolderDisplay) => void; } function groupBy(items: T[], keyFn: (item: T) => string | number): Record { @@ -79,6 +80,7 @@ export function FolderTreeView({ watchedFolderIds, onRescanFolder, onStopWatchingFolder, + onViewFolderMetadata, }: FolderTreeViewProps) { const foldersByParent = useMemo(() => groupBy(folders, (f) => f.parentId ?? "root"), [folders]); @@ -210,10 +212,11 @@ export function FolderTreeView({ siblingPositions={siblingPositions} contextMenuOpen={openContextMenuId === `folder-${f.id}`} onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)} - isWatched={watchedFolderIds?.has(f.id)} - onRescan={onRescanFolder} - onStopWatching={onStopWatchingFolder} - /> + isWatched={watchedFolderIds?.has(f.id)} + onRescan={onRescanFolder} + onStopWatching={onStopWatchingFolder} + onViewMetadata={onViewFolderMetadata} + /> ); if (isExpanded) { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 202d170d9..f9d32bf98 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -21,6 +21,7 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; +import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { AlertDialog, @@ -95,12 +96,46 @@ export function DocumentsSidebar({ const [activeTypes, setActiveTypes] = useState([]); const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); + const [metadataFolder, setMetadataFolder] = useState(null); + const [metadataJson, setMetadataJson] = useState | null>(null); + const [metadataLoading, setMetadataLoading] = useState(false); + useEffect(() => { const api = typeof window !== "undefined" ? window.electronAPI : null; if (!api?.getWatchedFolders) return; async function loadWatchedIds() { const folders = await api!.getWatchedFolders(); + + if (folders.length === 0) { + try { + const backendFolders = await documentsApiService.getWatchedFolders(searchSpaceId); + for (const bf of backendFolders) { + const meta = bf.metadata as Record | null; + if (!meta?.watched || !meta.folder_path) continue; + await api!.addWatchedFolder({ + path: meta.folder_path as string, + name: bf.name, + rootFolderId: bf.id, + searchSpaceId: bf.search_space_id, + excludePatterns: (meta.exclude_patterns as string[]) ?? [], + fileExtensions: (meta.file_extensions as string[] | null) ?? null, + active: true, + }); + } + const recovered = await api!.getWatchedFolders(); + const ids = new Set( + recovered + .filter((f) => f.rootFolderId != null) + .map((f) => f.rootFolderId as number) + ); + setWatchedFolderIds(ids); + return; + } catch (err) { + console.error("[DocumentsSidebar] Recovery from backend failed:", err); + } + } + const ids = new Set( folders .filter((f) => f.rootFolderId != null) @@ -110,7 +145,7 @@ export function DocumentsSidebar({ } loadWatchedIds(); - }, []); + }, [searchSpaceId]); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); @@ -318,11 +353,30 @@ export function DocumentsSidebar({ } await api.removeWatchedFolder(matched.path); + try { + await foldersApiService.stopWatching(folder.id); + } catch (err) { + console.error("[DocumentsSidebar] Failed to clear watched metadata:", err); + } toast.success(`Stopped watching: ${matched.name}`); }, [] ); + const handleViewFolderMetadata = useCallback(async (folder: FolderDisplay) => { + setMetadataFolder(folder); + setMetadataLoading(true); + try { + const fullFolder = await foldersApiService.getFolder(folder.id); + setMetadataJson((fullFolder.metadata as Record) ?? {}); + } catch (err) { + console.error("[DocumentsSidebar] Failed to fetch folder metadata:", err); + setMetadataJson({ error: "Failed to load folder metadata" }); + } finally { + setMetadataLoading(false); + } + }, []); + const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => { try { await foldersApiService.updateFolder(folder.id, { name: newName }); @@ -801,11 +855,26 @@ export function DocumentsSidebar({ onReorderFolder={handleReorderFolder} watchedFolderIds={watchedFolderIds} onRescanFolder={handleRescanFolder} - onStopWatchingFolder={handleStopWatching} - /> - + onStopWatchingFolder={handleStopWatching} + onViewFolderMetadata={handleViewFolderMetadata} + /> + - { + if (!open) { + setMetadataFolder(null); + setMetadataJson(null); + setMetadataLoading(false); + } + }} + /> + + { + return baseApiService.get(`/api/v1/documents/watched-folders?search_space_id=${searchSpaceId}`, folderListResponse); + }; + /** * Delete a document */ diff --git a/surfsense_web/lib/apis/folders-api.service.ts b/surfsense_web/lib/apis/folders-api.service.ts index 99d9ad774..2e535d615 100644 --- a/surfsense_web/lib/apis/folders-api.service.ts +++ b/surfsense_web/lib/apis/folders-api.service.ts @@ -85,6 +85,10 @@ class FoldersApiService { return baseApiService.delete(`/api/v1/folders/${folderId}`, folderDeleteResponse); }; + stopWatching = async (folderId: number) => { + return baseApiService.patch(`/api/v1/folders/${folderId}/watched`, undefined); + }; + moveDocument = async (documentId: number, request: DocumentMoveRequest) => { const parsed = documentMoveRequest.safeParse(request); if (!parsed.success) {