feat: implement local folder synchronization and versioning with new metadata handling and document_versions table

This commit is contained in:
Anish Sarkar 2026-04-02 23:46:21 +05:30
parent 53df393cf7
commit 25358fddcf
11 changed files with 205 additions and 17 deletions

View file

@ -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")

View file

@ -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")

View file

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

View file

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

View file

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

View file

@ -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<HTMLInputElement>(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();

View file

@ -43,6 +43,7 @@ interface FolderTreeViewProps {
watchedFolderIds?: Set<number>;
onRescanFolder?: (folder: FolderDisplay) => void;
onStopWatchingFolder?: (folder: FolderDisplay) => void;
onViewFolderMetadata?: (folder: FolderDisplay) => void;
}
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
@ -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) {

View file

@ -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<DocumentTypeEnum[]>([]);
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
const [metadataFolder, setMetadataFolder] = useState<FolderDisplay | null>(null);
const [metadataJson, setMetadataJson] = useState<Record<string, unknown> | 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<string, unknown> | 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<string, unknown>) ?? {});
} 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}
/>
</div>
onStopWatchingFolder={handleStopWatching}
onViewFolderMetadata={handleViewFolderMetadata}
/>
</div>
<FolderPickerDialog
<JsonMetadataViewer
title={metadataFolder?.name ?? "Folder"}
metadata={metadataJson}
loading={metadataLoading}
open={!!metadataFolder}
onOpenChange={(open) => {
if (!open) {
setMetadataFolder(null);
setMetadataJson(null);
setMetadataLoading(false);
}
}}
/>
<FolderPickerDialog
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
folders={treeFolders}

View file

@ -9,6 +9,7 @@ export const folder = z.object({
created_by_id: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
metadata: z.record(z.unknown()).nullable().optional(),
});
export const folderCreateRequest = z.object({

View file

@ -37,6 +37,7 @@ import {
uploadDocumentRequest,
uploadDocumentResponse,
} from "@/contracts/types/document.types";
import { folderListResponse } from "@/contracts/types/folder.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
@ -403,6 +404,10 @@ class DocumentsApiService {
return baseApiService.post(`/api/v1/documents/folder-index-file`, undefined, { body });
};
getWatchedFolders = async (searchSpaceId: number) => {
return baseApiService.get(`/api/v1/documents/watched-folders?search_space_id=${searchSpaceId}`, folderListResponse);
};
/**
* Delete a document
*/

View file

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