feat: Add an Ollama-based PDF OCR script and refactor frontend folder tree management with a new FoldersManager and useSyncExternalStore.

This commit is contained in:
2025-12-06 22:40:11 +01:00
parent c88c3c13d6
commit 54d72138d2
10 changed files with 111 additions and 86 deletions
+4 -17
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect } from 'react';
import type { ReactNode, ComponentProps } from 'react'; import type { ReactNode, ComponentProps } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { import {
@@ -7,7 +7,6 @@ import {
import { PreviewProvider, usePreviewContext } from '../viewer/PreviewContext'; import { PreviewProvider, usePreviewContext } from '../viewer/PreviewContext';
import { DocumentOpenProvider } from '../lib/context/DocumentOpenContext'; import { DocumentOpenProvider } from '../lib/context/DocumentOpenContext';
import { useWorkspaceSurface } from './useWorkspaceSurface'; import { useWorkspaceSurface } from './useWorkspaceSurface';
import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
import Sidebar from '../sidebar/Sidebar'; import Sidebar from '../sidebar/Sidebar';
@@ -30,34 +29,22 @@ const DocumentsInner: React.FC<{
const { openDetailPanel } = surfaceConfig; const { openDetailPanel } = surfaceConfig;
const sidebarHidden = sidebarCollapsed || sidebarSuppressed; const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
const navigate = useNavigate();
const handleBreadcrumbNavigate = useCallback((crumb: DocumentsHeaderBreadcrumb) => {
if (!crumb || !crumb.id) {
return;
}
const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`;
navigate(target);
}, [navigate]);
const handleOpenSidepanel = useCallback((docId: string) => { const handleOpenSidepanel = useCallback((docId: string) => {
if (openDetailPanel) { if (openDetailPanel) {
openDetailPanel(docId); openDetailPanel(docId);
} }
}, [openDetailPanel]); }, [openDetailPanel]);
const documentsTablePropsWithNav = useMemo(() => ( const documentsTablePropsWithNav = surfaceConfig.documentsTableProps
surfaceConfig.documentsTableProps
? { ? {
...surfaceConfig.documentsTableProps, ...surfaceConfig.documentsTableProps,
onBreadcrumbNavigate: handleBreadcrumbNavigate, onBreadcrumbNavigate: surfaceConfig.handleBreadcrumbNavigate,
documents: folderData.documents, documents: folderData.documents,
subfolders: folderData.subfolders, subfolders: folderData.subfolders,
currentFolderName: folderData.folder?.name, currentFolderName: folderData.folder?.name,
isSearchLoading: folderData.loading, isSearchLoading: folderData.loading,
} }
: null : null;
), [surfaceConfig.documentsTableProps, handleBreadcrumbNavigate, folderData]);
const { surface } = useWorkspaceSurface({ const { surface } = useWorkspaceSurface({
sidebarHidden, sidebarHidden,
+2
View File
@@ -9,6 +9,7 @@ import type { Identifier } from '../types/identifiers';
type WorkspaceSurfaceConfig = Omit<UseWorkspaceSurfaceArgs, 'sidebarHidden' | 'onExpandSidebar'> & { type WorkspaceSurfaceConfig = Omit<UseWorkspaceSurfaceArgs, 'sidebarHidden' | 'onExpandSidebar'> & {
openDetailPanel?: (documentId: Identifier) => void; openDetailPanel?: (documentId: Identifier) => void;
closeDetailPanel?: () => void; closeDetailPanel?: () => void;
handleBreadcrumbNavigate?: (crumb: any) => void;
}; };
interface DocumentsShellView { interface DocumentsShellView {
@@ -37,6 +38,7 @@ const useDocumentsShell = (): DocumentsShellView => {
ensureAssetUrl: shell.ensureAssetUrl as WorkspaceSurfaceConfig['ensureAssetUrl'], ensureAssetUrl: shell.ensureAssetUrl as WorkspaceSurfaceConfig['ensureAssetUrl'],
getDocumentAsset: shell.getDocumentAsset as WorkspaceSurfaceConfig['getDocumentAsset'], getDocumentAsset: shell.getDocumentAsset as WorkspaceSurfaceConfig['getDocumentAsset'],
notifyApiError: shell.notifyApiError as WorkspaceSurfaceConfig['notifyApiError'], notifyApiError: shell.notifyApiError as WorkspaceSurfaceConfig['notifyApiError'],
handleBreadcrumbNavigate: shell.handleBreadcrumbNavigate as WorkspaceSurfaceConfig['handleBreadcrumbNavigate'],
}; };
const sidebarWithActions = sidebar const sidebarWithActions = sidebar
+12
View File
@@ -1,3 +1,4 @@
import type { FolderTreeNode } from '../lib/api/apiTypes';
import { import {
DEFAULT_FOLDER_NAME, DEFAULT_FOLDER_NAME,
DEFAULT_SORT_DIRECTION, DEFAULT_SORT_DIRECTION,
@@ -50,3 +51,14 @@ export const createRootNode = () => ({
loaded: false, loaded: false,
hasChildren: false, hasChildren: false,
}); });
export const flattenFolderTree = (data: FolderTreeNode[]): FolderTreeNode[] => {
const result: FolderTreeNode[] = [];
data.forEach((item) => {
result.push(item);
if (item.children) {
result.push(...flattenFolderTree(item.children));
}
});
return result;
};
+42
View File
@@ -2,17 +2,25 @@ import { shallowEqual } from 'react-redux';
import type { FolderNodeId } from '../types/identifiers'; import type { FolderNodeId } from '../types/identifiers';
import type { Folder } from '../types/documents'; import type { Folder } from '../types/documents';
import type { FolderTreeNode } from '../lib/api/apiTypes';
import { getFolderTree } from '../lib/api/apiClient';
import { flattenFolderTree } from '../app/workspaceUtils';
type ManagedFolder = Folder; type ManagedFolder = Folder;
type FetchFolder = (id: FolderNodeId) => Promise<unknown>; type FetchFolder = (id: FolderNodeId) => Promise<unknown>;
class FoldersManager<T extends ManagedFolder = ManagedFolder> { class FoldersManager<T extends ManagedFolder = ManagedFolder> {
private byId: Map<FolderNodeId, T>; private byId: Map<FolderNodeId, T>;
private fetcher?: FetchFolder; private fetcher?: FetchFolder;
private inflight: Map<FolderNodeId, Promise<T | null>>; private inflight: Map<FolderNodeId, Promise<T | null>>;
private treePromise: Promise<FolderTreeNode[]> | null = null;
private treeSnapshot: FolderTreeNode[] = [];
private listeners: Set<() => void>; private listeners: Set<() => void>;
private emitScheduled: boolean; private emitScheduled: boolean;
constructor( constructor(
fetchFolder?: FetchFolder, fetchFolder?: FetchFolder,
) { ) {
@@ -172,6 +180,40 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
getSnapshot(): Map<FolderNodeId, T> { getSnapshot(): Map<FolderNodeId, T> {
return this.byId; return this.byId;
} }
getTreeSnapshot(): FolderTreeNode[] {
return this.treeSnapshot;
}
async ensureTree(): Promise<FolderTreeNode[]> {
if (this.treeSnapshot.length > 0) {
return this.treeSnapshot;
}
if (this.treePromise) {
return this.treePromise;
}
this.treePromise = (async () => {
try {
const raw = await getFolderTree();
const flattened = flattenFolderTree(raw);
this.ingest(flattened);
const roots = raw as FolderTreeNode[];
this.treeSnapshot = roots;
this.emit();
return roots;
} catch (error) {
console.warn('Failed to fetch folder tree', error);
return [];
} finally {
this.treePromise = null;
}
})();
return this.treePromise;
}
} }
export default FoldersManager; export default FoldersManager;
@@ -139,6 +139,15 @@ const useDocumentsWorkspace = ({
const routeFolderId = folderMatch?.params?.folderId || null; const routeFolderId = folderMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null; const routeDocumentId = docMatch?.params?.documentId || null;
const previewDocumentId = routeDocumentId; const previewDocumentId = routeDocumentId;
const handleBreadcrumbNavigate = useCallback((crumb: { id?: Identifier | string } | null) => {
if (!crumb || !crumb.id) {
return;
}
const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`;
navigate(target);
}, [navigate]);
const { const {
status: appStatus, status: appStatus,
token, token,
@@ -1310,6 +1319,7 @@ const useDocumentsWorkspace = ({
documentsFilter, documentsFilter,
documentsManager, documentsManager,
foldersManager, foldersManager,
handleBreadcrumbNavigate,
}; };
// hook callers handle rendering / routing // hook callers handle rendering / routing
+1 -1
View File
@@ -14,7 +14,7 @@ interface UseFolderDataResult {
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
export const useFolderData = ( const useFolderData = (
folderId: Identifier | 'root' = 'root', folderId: Identifier | 'root' = 'root',
documentsManager?: DocumentsManager<Document>, documentsManager?: DocumentsManager<Document>,
foldersManager?: FoldersManager<Folder> foldersManager?: FoldersManager<Folder>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getFolderTree, listFolderContents } from '../../../lib/api/apiClient'; import { getFolderTree, listFolderContents } from '../../../lib/api/apiClient';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { createRootNode, DEFAULT_FOLDER_NAME } from '../../../app/workspaceUtils'; import { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils';
import { import {
getEntryId, getEntryId,
isDocumentEntry, isDocumentEntry,
@@ -76,10 +76,13 @@ const useFolderTree = ({
const next = new Map(prev); const next = new Map(prev);
const rootChildren: FolderNodeId[] = []; const rootChildren: FolderNodeId[] = [];
data.forEach((item) => { const flatData = flattenFolderTree(data);
flatData.forEach((item) => {
const id = item.id as FolderNodeId; const id = item.id as FolderNodeId;
const parentId = (item.parent_id || 'root') as FolderNodeId; const parentId = (item.parent_id || 'root') as FolderNodeId;
const children = (item.children || []).map((c) => c as FolderNodeId); // item.children is now FolderTreeNode[], so map to IDs
const children = (item.children || []).map((c) => c.id as FolderNodeId);
next.set(id, { next.set(id, {
id, id,
@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { DEFAULT_FOLDER_NAME } from '../../../constants/workspace'; import { DEFAULT_FOLDER_NAME } from '../../../app/workspaceUtils';
import { getFolderTree } from '../../../lib/api/apiClient'; import { useAppShell } from '../../../lib/context/AppShellContext';
import FoldersManager from '../../FoldersManager';
import { import {
TrashIcon, TrashIcon,
AnalyzeIcon, AnalyzeIcon,
@@ -12,10 +14,10 @@ import {
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
import SelectionFolderMenu from './SelectionFolderMenu'; import SelectionFolderMenu from './SelectionFolderMenu';
import SelectionSummary from './SelectionSummary'; import SelectionSummary from './SelectionSummary';
import { useAppState } from '../../../lib/store/appState';
import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext'; import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext';
import type { DocumentId } from '../../../types/identifiers'; import type { DocumentId } from '../../../types/identifiers';
import type { FolderTreeNode, FolderTreeResponseItem } from '../../../lib/api/apiTypes'; import type { FolderTreeNode } from '../../../lib/api/apiTypes';
type NullableDocumentId = DocumentId | null; type NullableDocumentId = DocumentId | null;
@@ -217,62 +219,33 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
onClearSelection = null, onClearSelection = null,
onMoveDocumentsToFolder, onMoveDocumentsToFolder,
}) => { }) => {
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId; name?: string } | null };
const tenantId = tenant?.id ?? null;
const documentLookupMap = useMemo(() => ( const documentLookupMap = useMemo(() => (
documentLookup instanceof Map ? documentLookup : new Map<DocumentId, Document>() documentLookup instanceof Map ? documentLookup : new Map<DocumentId, Document>()
), [documentLookup]); ), [documentLookup]);
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
const [remoteFolderTree, setRemoteFolderTree] = useState<FolderTreeNode[] | null>(null); const shell = useAppShell();
const folderTreeFetchRef = useRef<Promise<FolderTreeNode[]> | null>(null); const foldersManager = shell.foldersManager as FoldersManager;
const [remoteFolderTree, setRemoteFolderTree] = useState<FolderTreeNode[]>([]);
// Sync with manager
const treeSnapshot = useSyncExternalStore(
useCallback(cb => foldersManager.subscribe(cb), [foldersManager]),
() => foldersManager.getTreeSnapshot(),
() => foldersManager.getTreeSnapshot(),
);
useEffect(() => { useEffect(() => {
setRemoteFolderTree(null); setRemoteFolderTree(treeSnapshot);
folderTreeFetchRef.current = null; }, [treeSnapshot]);
}, [tenantId, token]);
const requestFolderTree = useCallback(async (): Promise<FolderTreeNode[]> => { const requestFolderTree = useCallback(() => {
if (!token) { foldersManager.ensureTree();
setRemoteFolderTree([]); }, [foldersManager]);
return [];
}
if (Array.isArray(remoteFolderTree)) {
return remoteFolderTree;
}
if (folderTreeFetchRef.current) {
return folderTreeFetchRef.current;
}
const fetchPromise = (async () => {
try {
const data = await getFolderTree();
const traverse = (items: FolderTreeResponseItem[]): FolderTreeNode[] => {
return items.map(item => ({
...item,
children: item.children ? traverse(item.children) : [],
}));
};
const roots = traverse(data);
setRemoteFolderTree(roots);
return roots;
} catch (error) {
console.warn('[selection] Failed to load folder tree', error);
setRemoteFolderTree([]);
return [];
} finally {
folderTreeFetchRef.current = null;
}
})();
folderTreeFetchRef.current = fetchPromise;
return fetchPromise;
}, [remoteFolderTree, token]);
const handleMoveMenuOpen = useCallback(() => { const handleMoveMenuOpen = useCallback(() => {
requestFolderTree(); requestFolderTree();
+3 -3
View File
@@ -12,7 +12,7 @@ import type {
TenantSnippet, TenantSnippet,
TagResponse, TagResponse,
CorrespondentResponse, CorrespondentResponse,
FolderTreeResponseItem, FolderTreeNode,
} from './apiTypes'; } from './apiTypes';
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios'; import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios';
@@ -79,8 +79,8 @@ export const listDocuments = async (params: Record<string, unknown> = {}): Promi
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}; };
export const getFolderTree = async (): Promise<FolderTreeResponseItem[]> => { export const getFolderTree = async (): Promise<FolderTreeNode[]> => {
const { data } = await api.get<FolderTreeResponseItem[]>('/folders/tree'); const { data } = await api.get<FolderTreeNode[]>('/folders/tree');
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}; };
-4
View File
@@ -69,10 +69,6 @@ export interface FolderTreeNode extends FolderInfo {
children?: FolderTreeNode[]; children?: FolderTreeNode[];
} }
export interface FolderTreeResponseItem extends FolderInfo {
children?: string[];
}
export interface CapabilitySetResponse { export interface CapabilitySetResponse {
id: string; id: string;
slug: string; slug: string;