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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user