diff --git a/frontend/src/app/useDocumentPreview.ts b/frontend/src/app/useDocumentPreview.ts index ccbd513..e2d46ca 100644 --- a/frontend/src/app/useDocumentPreview.ts +++ b/frontend/src/app/useDocumentPreview.ts @@ -9,13 +9,7 @@ import type { DocumentId } from '../types/identifiers'; type FolderId = DocumentId | 'root'; -type DocumentLike = { - id?: DocumentId; - folder_id?: FolderId | null; - filename?: string | null; - current_version?: Record; - [key: string]: unknown; -}; +import type { Document } from '../types/documents'; type DocumentLink = { url?: string; @@ -29,11 +23,11 @@ type NavigateHandler = (path: string, options?: { replace?: boolean }) => void; interface UseDocumentPreviewArgs { routeDocumentId?: DocumentId | null; documentsManager: { - getById: (id: DocumentId) => DocumentLike | null; - ensure: (id: DocumentId) => Promise; - getMany: (ids: DocumentId[]) => DocumentLike[]; + getById: (id: DocumentId) => Document | null; + ensure: (id: DocumentId) => Promise; + getMany: (ids: DocumentId[]) => Document[]; subscribe: (listener: () => void) => () => void; - ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; + ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; }; selectedFolder?: FolderId | null; notifyApiError: (error: unknown, message: string) => void; @@ -50,7 +44,7 @@ interface UseDocumentPreviewArgs { interface UseDocumentPreviewResult { documentLinks: Map; ensureDownloadUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise; - ensurePreviewData: (documentId: DocumentId) => Promise; + ensurePreviewData: (documentId: DocumentId) => Promise; openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => void; closeDocumentPreview: (folderId?: FolderId) => void; resetPreviewState: () => void; @@ -149,7 +143,7 @@ const useDocumentPreview = ({ ); const ensurePreviewData = useCallback( - async (documentId: DocumentId): Promise => { + async (documentId: DocumentId): Promise => { if (!documentId) return null; const findInCache = () => documentsManager.getById(documentId); @@ -163,7 +157,7 @@ const useDocumentPreview = ({ if (!doc) { const fetched = await fetchDocument(documentId); const { canonical } = documentsManager.ingest([fetched as unknown]); - doc = (canonical[0] as DocumentLike | undefined) || null; + doc = (canonical[0] as Document | undefined) || null; if (!doc) { throw new Error('Document metadata unavailable.'); } diff --git a/frontend/src/app/useDocumentsSearch.ts b/frontend/src/app/useDocumentsSearch.ts index 5e024e3..e4f9718 100644 --- a/frontend/src/app/useDocumentsSearch.ts +++ b/frontend/src/app/useDocumentsSearch.ts @@ -4,7 +4,7 @@ import { TAG_FILTER_UNTAGGED } from './workspaceUtils'; import { listDocuments } from '../lib/apiClient'; import type { Identifier } from '../types/identifiers'; -type DocumentLike = { id?: Identifier } & Record; +import type { Document } from '../types/documents'; type ApiClient = { get: (url: string, config?: { params?: Record }) => Promise<{ data: T }>; @@ -23,7 +23,7 @@ interface UseDocumentsSearchArgs { notifyApiError: (error: unknown, message: string) => void; setSearchIncludeDescendants: (value: boolean) => void; documentsManager: { - ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; + ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; }; } diff --git a/frontend/src/asset_manager.ts b/frontend/src/asset_manager.ts index 5899fc1..873afda 100644 --- a/frontend/src/asset_manager.ts +++ b/frontend/src/asset_manager.ts @@ -1,37 +1,10 @@ import type { Identifier } from './types/identifiers'; +import type { AssetObject, AssetLike } from './types/assets'; +import type { DocumentVersion, Document } from './types/documents'; type Nullable = T | null; -export interface AssetObject { - ordinal?: number; - url?: string | null; - metadata?: Record | null; - expires_at?: number; - [key: string]: unknown; -} - -export interface AssetLike { - id?: Identifier; - asset_type?: string; - cardinality?: number | null; - download?: { url: string; expires_at: number } | null; - metadata?: Record | null; - assets?: Record | AssetLike[] | null; - objects?: AssetObject[] | null; - [key: string]: unknown; -} - -export interface DocumentVersionLike { - assets?: Record | AssetLike[] | null; - metadata?: Record & { page_count?: number } | null; - [key: string]: unknown; -} - -export interface DocumentLike { - id?: Identifier; - current_version?: DocumentVersionLike | null; - [key: string]: unknown; -} +export type { AssetObject, AssetLike, DocumentVersion as DocumentVersionLike, Document }; export const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null => asset?.download?.expires_at ?? null; @@ -45,7 +18,7 @@ export type EnsureAssetUrl = ( options?: { force?: boolean;[key: string]: unknown }, ) => Promise; -export type GetAsset = (document: DocumentLike, assetType: string) => Nullable; +export type GetAsset = (document: Document, assetType: string) => Nullable; export const getAssetFromGroup = ( assets?: AssetLike[] | Record | null, @@ -62,7 +35,7 @@ export const getAssetFromGroup = ( return assets?.[assetType] || null; }; -export const getAssetFromVersion = (currentVersion: Nullable, assetType: string) => { +export const getAssetFromVersion = (currentVersion: Nullable, assetType: string) => { if (!currentVersion) { return null; } @@ -162,7 +135,7 @@ export class AssetView { export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset); export const resolveDocumentAssetUrl = ( - doc: Nullable, + doc: Nullable, type: string, { ensureAssetUrl, diff --git a/frontend/src/desktop/DesktopDocumentCard.tsx b/frontend/src/desktop/DesktopDocumentCard.tsx index 25c01ae..4208fc4 100644 --- a/frontend/src/desktop/DesktopDocumentCard.tsx +++ b/frontend/src/desktop/DesktopDocumentCard.tsx @@ -5,12 +5,7 @@ import { getTagColorStyle } from '../utils/colors'; import { preventAll } from './events'; import type { DocumentId } from '../types/identifiers'; -type DocumentLike = { - id?: string; - title?: string; - tags?: Array<{ id?: string; label?: string; color?: string | null }>; - [key: string]: unknown; -}; +import type { Document } from '../types/documents'; interface PendingRemovalTag { docId?: string; @@ -18,7 +13,7 @@ interface PendingRemovalTag { } interface DesktopDocumentCardProps { - doc: DocumentLike; + doc: Document; style?: React.CSSProperties; shouldLoad?: boolean; dragging?: boolean; @@ -35,9 +30,9 @@ interface DesktopDocumentCardProps { onTagDragEnter?: (event: React.DragEvent, docId: DocumentId) => void; onTagDragOver?: (event: React.DragEvent, docId: DocumentId) => void; onTagDragLeave?: (event: React.DragEvent, docId: DocumentId) => void; - onTagDrop?: (event: React.DragEvent, doc: DocumentLike) => void; - onDocTagPointerDown?: (event: React.PointerEvent, doc: DocumentLike, tag: any) => void; - onDocTagDragStart?: (event: React.DragEvent, doc: DocumentLike, tag: any) => void; + onTagDrop?: (event: React.DragEvent, doc: Document) => void; + onDocTagPointerDown?: (event: React.PointerEvent, doc: Document, tag: any) => void; + onDocTagDragStart?: (event: React.DragEvent, doc: Document, tag: any) => void; onDocTagDrag?: (event: React.DragEvent) => void; onDocTagDragEnd?: (event: React.DragEvent) => void; pendingRemovalTag?: PendingRemovalTag | null; diff --git a/frontend/src/desktop/DesktopPreviewCard.tsx b/frontend/src/desktop/DesktopPreviewCard.tsx index 9f73ec8..3f097e8 100644 --- a/frontend/src/desktop/DesktopPreviewCard.tsx +++ b/frontend/src/desktop/DesktopPreviewCard.tsx @@ -2,12 +2,7 @@ import { useEffect } from 'react'; import type { JSX } from 'react'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import type { Identifier } from '../types/identifiers'; - -interface DocumentLike { - id?: Identifier; - title?: string; - [key: string]: unknown; -} +import type { Document } from '../types/documents'; interface AssetLike { id?: Identifier; @@ -23,7 +18,7 @@ type EnsureAssetUrl = ( options?: { force?: boolean;[key: string]: unknown }, ) => Promise; -type GetDocumentAsset = (document: DocumentLike | null, assetType: string) => AssetLike | null; +type GetDocumentAsset = (document: Document | null, assetType: string) => AssetLike | null; interface NavigatorSnapshot { url: string | null; @@ -33,7 +28,7 @@ interface NavigatorSnapshot { } interface DesktopPreviewCardProps { - doc: DocumentLike | null; + doc: Document | null; title?: string; ensureAssetUrl?: EnsureAssetUrl | null; getDocumentAsset: GetDocumentAsset; diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index f8b465f..15bce1a 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -173,6 +173,7 @@ interface DesktopWorkspaceViewProps extends Omit void; onSelect?: (descriptor: unknown, event?: unknown) => void; onPromoteSelection?: (docId: Identifier, event?: unknown) => void; + selectionOrderRef: React.MutableRefObject; } const defaultGetDocumentAsset: GetAsset = () => null; @@ -194,6 +195,7 @@ const DesktopWorkspace: React.FC = ({ clearSelection, handleEntrySelection, promoteSelectionOrder, + selectionOrderRef, configureSelectionEnvironment, } = useWorkspaceSelectionContext(); const items = useMemo( @@ -814,6 +816,7 @@ const DesktopWorkspace: React.FC = ({ recalcVisibleDocIds, dragSettings, markLayoutDirty, + selectionOrderRef, }; return ; }; @@ -850,13 +853,14 @@ function DesktopWorkspaceView({ resolveBaseMetrics, bringToFront, setDraggingId, - canvasSize, + canvasSize: _canvasSize, openOverlayForDoc, recalcVisibleDocIds, dragSettings, onDocumentActivate, markLayoutDirty, onSelect, + selectionOrderRef, }: DesktopWorkspaceViewProps) { const handleDeskDocumentActivate = useCallback( (docId: Identifier) => { @@ -883,19 +887,18 @@ function DesktopWorkspaceView({ engine, layoutRef, dragTransformsRef, - itemRefs, documentLookup, ensureDocumentSize, resolveBaseMetrics, bringToFront, setDraggingId, - canvasSize, openOverlayForDoc, recalcVisibleDocIds, settings: dragSettings, containerRef, onDocumentActivate: handleDeskDocumentActivate, markLayoutDirty, + selectionOrderRef, selectedDocumentIds, }) as { handlePointerDown: (event: React.PointerEvent, docId: Identifier | null, options: PointerDownOptions) => void; diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.ts b/frontend/src/desktop/hooks/usePreviewMetadata.ts index 8dd63e5..b514a74 100644 --- a/frontend/src/desktop/hooks/usePreviewMetadata.ts +++ b/frontend/src/desktop/hooks/usePreviewMetadata.ts @@ -1,10 +1,6 @@ import { useEffect, useState } from 'react'; import type { DocumentId } from '../../types/identifiers'; -interface DocumentLike { - id?: string; - current_version?: unknown; - tags?: unknown; -} +import type { Document } from '../../types/documents'; interface AssetLike { id?: string; @@ -17,11 +13,11 @@ interface PreviewMetadataEntry { height: number; } -type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null; +type GetDocumentAsset = (doc: Document, type: string) => AssetLike | null; type EnsureAssetUrl = (docId: DocumentId, asset: AssetLike, options?: { force?: boolean }) => Promise; const usePreviewMetadata = ( - documents: DocumentLike[] | null, + documents: Document[] | null, getDocumentAsset?: GetDocumentAsset, ensureAssetUrl?: EnsureAssetUrl, ) => { @@ -37,7 +33,7 @@ const usePreviewMetadata = ( }; } - const fetchMetadataForDoc = async (doc: DocumentLike) => { + const fetchMetadataForDoc = async (doc: Document) => { if (!doc?.id) { return null; } diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts index 960a90a..40a4e4a 100644 --- a/frontend/src/desktop/useDocumentDrag.ts +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -7,10 +7,8 @@ import { } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { preventAll } from './events'; -import { clamp } from '../utils/math'; import usePointerTap from '../ui/usePointerTap'; import { - applyDomTransform, type WorkspaceEngine, type ActiveDragSession, type DragGroupItem, @@ -18,18 +16,10 @@ import { CARD_BASE_WEIGHT_GRAMS, CARD_PAGE_WEIGHT_GRAMS, } from './workspaceEngine'; -import { DRAG_HYSTERESIS_SQUARED, EDGE_COLLISION_THRESHOLD } from '../constants/desktop'; -import type { DocumentId, Identifier } from '../types/identifiers'; - -interface DocumentLike { - id?: Identifier | null; - title?: string; - current_version?: { - metadata?: { page_count?: number | string | null } | null; - } | null; - metadata?: { page_count?: number | string | null } | null; - [key: string]: unknown; -} +import { DRAG_HYSTERESIS_SQUARED } from '../constants/desktop'; +import type { Identifier } from '../types/identifiers'; +import type { Document } from '../types/documents'; +import { getEntryId, isDocumentEntry } from '../app/entryKey'; interface DocumentSizeInfo { width: number; @@ -56,10 +46,10 @@ interface DragTransform { -type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null; +type EnsureDocumentSizeFn = (doc: Document | null) => DocumentSizeInfo | null; type ResolveBaseMetricsFn = ( - doc: DocumentLike | null, + doc: Document | null, width: number, height: number, ) => { baseWidth: number; baseHeight: number; baseScale: number }; @@ -81,13 +71,12 @@ interface UseDocumentDragOptions { engine?: WorkspaceEngine | null; layoutRef: MutableRefObject>; dragTransformsRef: MutableRefObject>; - itemRefs: MutableRefObject>; - documentLookup: Map; + selectionOrderRef?: MutableRefObject; + documentLookup: Map; ensureDocumentSize: EnsureDocumentSizeFn; resolveBaseMetrics: ResolveBaseMetricsFn; bringToFront: (docId: Identifier | null) => void; setDraggingId: (docKey: string | null) => void; - canvasSize: { width: number; height: number }; openOverlayForDoc?: ( docId: Identifier | null, originInfo?: { rotation: number; scale: number; width: number; height: number }, @@ -107,8 +96,8 @@ interface DragTapMetadata { docTitle: string; } -const getDocumentPageCount = (doc?: DocumentLike | null): number | null => { - const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count; +const getDocumentPageCount = (doc?: Document | null): number | null => { + const raw = doc?.current_version?.metadata?.page_count ?? (doc?.metadata as { page_count?: unknown })?.page_count; if (raw == null) { return null; } @@ -116,7 +105,7 @@ const getDocumentPageCount = (doc?: DocumentLike | null): number | null => { return Number.isFinite(value) ? value : null; }; -const computeDocumentMassGrams = (doc?: DocumentLike | null): number => { +const computeDocumentMassGrams = (doc?: Document | null): number => { const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1)); return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS; }; @@ -145,19 +134,18 @@ const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds engine, layoutRef, dragTransformsRef, - itemRefs, documentLookup, ensureDocumentSize, resolveBaseMetrics, bringToFront, setDraggingId, - canvasSize, openOverlayForDoc, recalcVisibleDocIds, settings, containerRef: providedContainerRef, onDocumentActivate, markLayoutDirty, + selectionOrderRef, selectedDocumentIds, } = options; @@ -166,8 +154,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds const { canvasPadding = 24, - defaultCanvasWidth = 1024, - defaultCanvasHeight = 680, debugDrag = false, } = settings || {}; @@ -195,21 +181,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds const dragStateRef = useRef(null); const pendingDragRef = useRef(null); - const setDragTransform = useCallback((docKey: Identifier | null, transform: DragTransform | null) => { - if (!docKey) { - return; - } - const map = dragTransformsRef?.current; - if (!map) { - return; - } - if (transform) { - map.set(String(docKey), transform); - } else { - map.delete(String(docKey)); - } - }, [dragTransformsRef]); - const clearDragTransforms = useCallback(() => { const map = dragTransformsRef?.current; if (!map?.clear) { @@ -268,27 +239,19 @@ const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds ); const startDragSession = useCallback((pending: PendingDrag, event: PointerEventLike) => { - const { docId: docIdInput, modifierActive, wasSelected } = pending; + const { docId: docIdInput, modifierActive } = pending; - // 1. Get current global selection - let selectionIds: string[] = (selectedDocumentIds || []).map(String); - const docKey = String(docIdInput); + const selectionFromRef: string[] = Array.isArray(selectionOrderRef?.current) + ? selectionOrderRef.current + .map((key) => (isDocumentEntry(key) ? getEntryId(key) : null)) + .filter((id): id is string => Boolean(id)) + .map(String) + : []; - // 2. Check if clicked doc was already selected BEFORE the click - if (!wasSelected) { - // Not selected before click. Determine what SHOULD be dragged. - const targets = (pending.stackHits && pending.stackHits.length > 0) - ? pending.stackHits - : [docKey]; - - if (modifierActive) { - // Add to selection - selectionIds = [...selectionIds, ...targets]; - } else { - // Replace selection - selectionIds = targets; - } - } + // 1. Get current global selection (prefer ref for immediate updates) + let selectionIds: string[] = selectionFromRef.length + ? selectionFromRef + : (selectedDocumentIds || []).map(String); // 3. Filter for valid documents selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); @@ -472,6 +435,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds }, [ selectedDocumentIds, + selectionOrderRef, documentLookup, layoutRef, ensureDocumentSize, diff --git a/frontend/src/desktop/workspaceEngine.ts b/frontend/src/desktop/workspaceEngine.ts index a0d126e..ce92fd3 100644 --- a/frontend/src/desktop/workspaceEngine.ts +++ b/frontend/src/desktop/workspaceEngine.ts @@ -1,12 +1,9 @@ -import { clamp, formatTransform, toNumber } from '../utils/math'; +import { clamp, formatTransform } from '../utils/math'; import { - Point, Polygon, clipPolygon, isPointInsideConvex, polygonCentroid, - iterateEdges, - forEachVertex, } from './utils/geometry'; import { computeCardBounds } from './utils/layoutUtils'; import { fetchLayoutRecords, upsertLayoutRecords } from './db'; @@ -631,7 +628,7 @@ export class WorkspaceEngine { return this.state.type === 'dragging' ? this.state.session : null; } - beginDrag(docIds: Array = []): void { + beginDrag(_docIds: Array = []): void { // Legacy method support or internal helper // If we are starting a drag, we should transition state // But this method was used to set flags. diff --git a/frontend/src/detail/PreviewZoomOverlay.tsx b/frontend/src/detail/PreviewZoomOverlay.tsx index 7cc477a..a7b87d0 100644 --- a/frontend/src/detail/PreviewZoomOverlay.tsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -3,17 +3,12 @@ import { createPortal } from 'react-dom'; import { clamp } from '../utils/math'; import PdfViewer from '../preview/PdfViewer'; -type DocumentLike = { - id?: string; - title?: string; - mime_type?: string | null; - [key: string]: unknown; -}; +import type { Document } from '../types/documents'; interface PreviewZoomOverlayProps { open?: boolean; onClose?: () => void; - document?: DocumentLike | null; + document?: Document | null; } type NaturalSize = { width: number | null; height: number | null }; @@ -26,7 +21,7 @@ type DocumentLink = { mimeType?: string | null; }; -type DocumentLikeWithPreview = DocumentLike & { documentLink?: DocumentLink }; +type DocumentWithPreview = Document & { documentLink?: DocumentLink }; const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => { const type = entry?.mimeType?.toLowerCase?.() || ''; @@ -55,7 +50,7 @@ const PreviewZoomOverlay: React.FC = ({ const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); const [renderBackdrop, setRenderBackdrop] = useState(false); const [isBackdropVisible, setBackdropVisible] = useState(false); - const [documentSnapshot, setDocumentSnapshot] = useState(null); + const [documentSnapshot, setDocumentSnapshot] = useState(null); const scrollRef = useRef(null); const mediaRef = useRef(null); const focusRef = useRef(null); @@ -63,7 +58,7 @@ const PreviewZoomOverlay: React.FC = ({ const visibilityTimerRef = useRef(null); const displayTimerRef = useRef(null); - const currentDocument = overlayDocument as DocumentLikeWithPreview | null; + const currentDocument = overlayDocument; useEffect(() => { if (currentDocument?.documentLink?.url) { diff --git a/frontend/src/detail/useDetailWorkspace.ts b/frontend/src/detail/useDetailWorkspace.ts index 893df98..438cdb0 100644 --- a/frontend/src/detail/useDetailWorkspace.ts +++ b/frontend/src/detail/useDetailWorkspace.ts @@ -7,13 +7,7 @@ import { getEntryId, isDocumentEntry } from '../app/entryKey'; import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel'; import type { EnsureAssetUrl, GetDocumentAsset } from '../utils/ocr'; import type { Identifier } from '../types/identifiers'; - -interface DocumentLike { - id?: Identifier; - folder_id?: Identifier | 'root'; - title?: string; - [key: string]: unknown; -} +import type { Document } from '../types/documents'; interface FolderNode { id: Identifier | 'root'; @@ -22,10 +16,10 @@ interface FolderNode { } interface UseDetailWorkspaceArgs { - documents: DocumentLike[]; + documents: Document[]; selectionOrder: string[]; selectedDocumentIds: Identifier[]; - documentLookup: Map; + documentLookup: Map; folderNodes: Map; ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise; detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>; @@ -35,7 +29,7 @@ interface UseDetailWorkspaceArgs { openDocumentPreview?: (args: { documentIds: Identifier[] }) => void; handleDocumentTitleUpdate?: (docId: Identifier, title: string) => Promise | boolean; handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise | boolean; - handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void; + handleDocumentTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void; handleTagRemove?: (...args: unknown[]) => void; ensureAssetUrl?: EnsureAssetUrl; getDocumentAsset?: GetDocumentAsset; @@ -55,8 +49,8 @@ interface UseDetailWorkspaceResult { handleDetailPanelClose: () => void; inspectDocument: (docId: Identifier | null) => void; previewActive: boolean; - previewWorkspaceDocument: DocumentLike | null; - resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null; + previewWorkspaceDocument: Document | null; + resolveThumbnailUrlForDoc: (doc: Document | null) => string | null; resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>; } diff --git a/frontend/src/documents/DocumentSummarySection.tsx b/frontend/src/documents/DocumentSummarySection.tsx index 8605740..635ff9a 100644 --- a/frontend/src/documents/DocumentSummarySection.tsx +++ b/frontend/src/documents/DocumentSummarySection.tsx @@ -28,16 +28,7 @@ interface CorrespondentEntry { count?: number; } -interface DocumentLike { - id?: Identifier; - title?: string; - issued_at?: string | null; - folder_id?: FolderId | null; - current_version?: { version_number?: number } | null; - tags?: TagEntry[]; - correspondents?: CorrespondentEntry[]; - [key: string]: unknown; -} +import type { Document } from '../types/documents'; interface TagSectionProps { tags?: TagEntry[]; @@ -62,14 +53,14 @@ interface CorrespondentSectionProps { } export interface DocumentSummarySectionProps { - document?: DocumentLike | null; + document?: Document | null; tagLookupById?: Map; tagOptions?: SelectionAssignmentMenuItem[]; - onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void; + onTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void; onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void; correspondents?: CorrespondentEntry[]; correspondentOptions?: SelectionAssignmentMenuItem[]; - onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void; + onCorrespondentAdd?: (payload: { document: Document; name: string; option?: unknown }) => void; onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void; onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise | boolean; onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise | boolean; diff --git a/frontend/src/documents/DocumentThumbnailImage.tsx b/frontend/src/documents/DocumentThumbnailImage.tsx index 2542505..b172c97 100644 --- a/frontend/src/documents/DocumentThumbnailImage.tsx +++ b/frontend/src/documents/DocumentThumbnailImage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, JSX, MutableRefObject } from 'react'; +import type { Document } from '../types/documents'; import { getAssetFromVersion, resolveDocumentAssetUrl, @@ -7,7 +8,6 @@ import { } from '../asset_manager'; import { DEFAULT_THUMBNAIL_SIZE } from '../constants/documents'; import type { - DocumentLike as AssetManagerDocumentLike, AssetLike as AssetManagerAssetLike, EnsureAssetUrl as AssetManagerEnsureAssetUrl, GetAsset as AssetManagerGetAsset, @@ -65,18 +65,19 @@ const useLazyVisibility = ( return { ref: targetRef, isVisible }; }; -const getPageCount = (doc?: DocumentLike | null) => { + + +const getPageCount = (doc?: Document | null) => { const count = doc?.current_version?.metadata?.page_count; return Number.isFinite(count) ? Number(count) : null; }; -type DocumentLike = AssetManagerDocumentLike; type AssetLike = AssetManagerAssetLike; type EnsureAssetUrl = AssetManagerEnsureAssetUrl; type GetDocumentAsset = AssetManagerGetAsset; interface DocumentThumbnailImageProps { - document?: DocumentLike | null; + document?: Document | null; ensureAssetUrl?: EnsureAssetUrl; getDocumentAsset?: GetDocumentAsset; alt?: string; diff --git a/frontend/src/documents/DocumentsGrid.tsx b/frontend/src/documents/DocumentsGrid.tsx index 202bc4a..c017e48 100644 --- a/frontend/src/documents/DocumentsGrid.tsx +++ b/frontend/src/documents/DocumentsGrid.tsx @@ -9,9 +9,9 @@ import useInlineRename from './useInlineRename'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; import type { - FolderLike, - DocumentLike, -} from './DocumentsList'; + Folder as FolderLike, + Document, +} from '../types/documents'; import type { DocumentsViewProps } from './panel/DocumentsPanel'; interface DocumentsGridProps extends DocumentsViewProps { @@ -63,9 +63,9 @@ const DocumentsGrid: React.FC = ({ submitEditing: submitDocumentEditing, savingId: savingDocumentId, attachInputRef: attachDocumentInputRef, - } = useInlineRename(onDocumentRename, { - getCurrentValue: (doc: DocumentLike) => doc?.title ?? '', - getEntityId: (doc: DocumentLike) => doc?.id ?? null, + } = useInlineRename(onDocumentRename, { + getCurrentValue: (doc: Document) => doc?.title ?? '', + getEntityId: (doc: Document) => doc?.id ?? null, }); const { diff --git a/frontend/src/documents/DocumentsList.tsx b/frontend/src/documents/DocumentsList.tsx index 441274f..a1f5177 100644 --- a/frontend/src/documents/DocumentsList.tsx +++ b/frontend/src/documents/DocumentsList.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import type { MouseEvent } from 'react'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; import { formatDate } from '../utils/date'; @@ -9,54 +8,12 @@ import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData, parseTagTransferPayload } from './tagTransfer'; import useInlineRename from './useInlineRename'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; -import type { Identifier } from '../types/identifiers'; + import type { DocumentsViewProps } from './panel/DocumentsPanel'; - -export interface FolderLike { - id?: Identifier | 'root'; - name?: string; -} - -export interface DocumentTag { - id?: Identifier; - label?: string; - color?: string | null; -} - -export interface DocumentCorrespondent { - id?: Identifier; - name?: string; - count?: number; -} - -export interface DocumentLike { - id?: Identifier; - title?: string; - issued_at?: string | null; - created_at?: string | null; - uploaded_at?: string | null; - tags?: DocumentTag[] | null; - correspondents?: DocumentCorrespondent[] | null; -} - -export type FolderEntry = { - type: 'folder'; - id: Identifier | 'root'; - key: string; - folder: FolderLike; -}; - -export type DocumentEntry = { - type: 'document'; - id: Identifier; - key: string; - document: DocumentLike; -}; - -export type DocumentsListEntry = FolderEntry | DocumentEntry; - -export type FolderEventHandler = (folder: FolderLike, event: MouseEvent) => void; -export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent) => void; +import type { + Document, + Folder, +} from '../types/documents'; const DocumentsList: React.FC = ({ entries, @@ -105,9 +62,9 @@ const DocumentsList: React.FC = ({ submitEditing: submitDocumentEditing, savingId: savingDocumentId, attachInputRef: attachDocumentInputRef, - } = useInlineRename(onDocumentRename, { - getCurrentValue: (doc: DocumentLike) => doc?.title ?? '', - getEntityId: (doc: DocumentLike) => doc?.id ?? null, + } = useInlineRename(onDocumentRename, { + getCurrentValue: (doc: Document) => doc?.title ?? '', + getEntityId: (doc: Document) => doc?.id ?? null, }); const { @@ -119,9 +76,9 @@ const DocumentsList: React.FC = ({ submitEditing: submitFolderEditing, savingId: savingFolderId, attachInputRef: attachFolderInputRef, - } = useInlineRename(onFolderRename, { - getCurrentValue: (folder: FolderLike) => folder?.name ?? '', - getEntityId: (folder: FolderLike) => folder?.id ?? null, + } = useInlineRename(onFolderRename, { + getCurrentValue: (folder: Folder) => folder?.name ?? '', + getEntityId: (folder: Folder) => folder?.id ?? null, }); @@ -310,7 +267,6 @@ const DocumentsList: React.FC = ({ data-doc-id={doc.id} onClick={(event) => onDocumentClick?.(doc, event)} onDoubleClick={(event) => onDocumentActivate?.(doc, event)} - draggable onDragStart={(event) => onDocumentDragStart?.(event, doc)} onDragEnd={(event) => onDocumentDragEnd?.(event)} onDragOver={onDocumentTagDragOver} diff --git a/frontend/src/documents/SelectionFloatingActions.tsx b/frontend/src/documents/SelectionFloatingActions.tsx index 759cdc8..eb7119e 100644 --- a/frontend/src/documents/SelectionFloatingActions.tsx +++ b/frontend/src/documents/SelectionFloatingActions.tsx @@ -40,12 +40,7 @@ interface CorrespondentOption { label?: string; } -interface DocumentLike { - id?: DocumentId; - tags?: TagOption[]; - correspondents?: CorrespondentOption[]; - [key: string]: unknown; -} +import type { Document } from '../types/documents'; interface BulkTagMutationArgs { label: string; @@ -68,7 +63,7 @@ export interface SelectionFloatingActionsProps { selectionCount?: number; selectedDocumentIds?: SelectedIdList; selectedFolderIds?: SelectedIdList; - documentLookup?: Map | null; + documentLookup?: Map | null; tags?: TagOption[] | null; tagLookupById?: Map | null; correspondents?: CorrespondentOption[] | null; @@ -135,7 +130,7 @@ const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssign }; const buildTagAssignments = ( - selectedDocuments: DocumentLike[], + selectedDocuments: Document[], tagLookupById: Map | null, tags: TagOption[] | null, total: number, @@ -200,7 +195,7 @@ const buildTagAssignments = ( }; const buildCorrespondentAssignments = ( - selectedDocuments: DocumentLike[], + selectedDocuments: Document[], correspondents: CorrespondentOption[] | null, total: number, ): SelectionAssignmentMenuItem[] => { @@ -280,7 +275,7 @@ const SelectionFloatingActions: React.FC = ({ const tenantId = tenant?.id ?? null; const documentLookupMap = useMemo(() => ( - documentLookup instanceof Map ? documentLookup : new Map() + documentLookup instanceof Map ? documentLookup : new Map() ), [documentLookup]); const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; @@ -350,13 +345,13 @@ const SelectionFloatingActions: React.FC = ({ const folderCount = folderIdList.length; const totalCount = selectionCount ?? documentCount + folderCount; - const selectedDocuments = useMemo(() => { + const selectedDocuments = useMemo(() => { if (!documentIdList.length || !(documentLookupMap instanceof Map)) { return []; } return documentIdList .map((id) => documentLookupMap.get(id)) - .filter((doc): doc is DocumentLike => Boolean(doc)); + .filter((doc): doc is Document => Boolean(doc)); }, [documentIdList, documentLookupMap]); const selectedDocCount = selectedDocuments.length; diff --git a/frontend/src/documents/correspondents.ts b/frontend/src/documents/correspondents.ts index 8c5c0b5..b70a662 100644 --- a/frontend/src/documents/correspondents.ts +++ b/frontend/src/documents/correspondents.ts @@ -4,9 +4,7 @@ export interface CorrespondentReference { key?: string; } -export interface DocumentLike { - correspondents?: CorrespondentReference[]; -} +import type { Document } from '../types/documents'; export interface ResolvedCorrespondent { id?: string | null; @@ -14,7 +12,7 @@ export interface ResolvedCorrespondent { key: string; } -export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorrespondent[] => { +export const resolveCorrespondents = (doc?: Document | null): ResolvedCorrespondent[] => { if (!doc || !Array.isArray(doc.correspondents)) { return []; } diff --git a/frontend/src/documents/documentActions.ts b/frontend/src/documents/documentActions.ts index f635f44..675a01e 100644 --- a/frontend/src/documents/documentActions.ts +++ b/frontend/src/documents/documentActions.ts @@ -3,14 +3,14 @@ import type { EnsureAssetUrl, EnsurePreviewData, GetDocumentAsset, - DocumentLike as OcrDocumentLike, } from '../utils/ocr'; +import type { Document } from '../types/documents'; -export type DocumentLike = OcrDocumentLike; +export type { Document }; const asyncFalse = async () => false; -const resolveDocumentDownloadHref = (document?: DocumentLike | null): string | null => { +const resolveDocumentDownloadHref = (document?: Document | null): string | null => { if (!document) { return null; } @@ -21,7 +21,7 @@ const resolveDocumentDownloadHref = (document?: DocumentLike | null): string | n return downloadUrl; }; -const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset | null): boolean => { +const hasDocumentOcrAsset = (document?: Document | null, getDocumentAsset?: GetDocumentAsset | null): boolean => { if (!document || !getDocumentAsset) { return false; } @@ -29,7 +29,7 @@ const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: }; interface CreateDocumentActionStateArgs { - document: DocumentLike | null; + document: Document | null; ensurePreviewData: EnsurePreviewData; ensureAssetUrl: EnsureAssetUrl; getDocumentAsset?: GetDocumentAsset | null; diff --git a/frontend/src/documents/documentSummary.ts b/frontend/src/documents/documentSummary.ts index 1537cd1..f9f3b8a 100644 --- a/frontend/src/documents/documentSummary.ts +++ b/frontend/src/documents/documentSummary.ts @@ -2,39 +2,7 @@ import { formatFileSize } from '../utils/format'; import { formatDateTime as defaultFormatDateTime } from '../utils/date'; import { DEFAULT_FOLDER_NAME } from '../app/workspaceUtils'; -interface DocumentPageMetadata { - page_count?: number | string | null; -} - -interface DocumentVersion { - size_bytes?: number | string | null; - metadata?: DocumentPageMetadata | null; - checksum?: string | null; -} - -interface TagEntry { - label?: string | null; -} - -interface CorrespondentEntry { - name?: string | null; -} - -export interface SummaryDocument { - title?: string | null; - original_name?: string | null; - filename?: string | null; - mime_type?: string | null; - folder_id?: string | null; - folder_name?: string; - current_version?: DocumentVersion | null; - created_at?: string | null; - updated_at?: string | null; - issued_at?: string | null; - folder_path?: string; - tags?: TagEntry[] | null; - correspondents?: CorrespondentEntry[] | null; -} +import type { DocumentTag, DocumentCorrespondent, Document } from '../types/documents'; interface DescribeSummaryOptions { formatDateTime?: typeof defaultFormatDateTime; @@ -51,7 +19,7 @@ export interface DocumentSummaryRow { export type DocumentSummary = DocumentSummaryRow[]; -const coercePageCount = (metadata?: DocumentPageMetadata | null): number | null => { +const coercePageCount = (metadata?: { page_count?: number | string | null } | null): number | null => { const raw = metadata?.page_count; if (raw == null || raw === '') { return null; @@ -67,31 +35,26 @@ interface DocumentMetadataPayload { [key: string]: unknown; } -export interface MetadataDocumentLike { - created_at?: string | null; - updated_at?: string | null; - filename?: string | null; - original_name?: string | null; - mime_type?: string | null; - metadata?: DocumentMetadataPayload | null; - current_version?: { checksum?: string | null } | null; -} - -export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => { +export const describeDocumentSummary = (document?: Document | null, options: DescribeSummaryOptions = {}): DocumentSummary => { const { formatDateTime = defaultFormatDateTime, } = options; - const formatDateLabel = (value?: string | null) => formatDateTime(value) || '—'; - const doc = document ?? {}; + const formatDateLabel = (value?: string | number | null) => { + if (typeof value === 'number') { + return formatDateTime(new Date(value)) || '—'; + } + return formatDateTime(value) || '—'; + }; + const doc = document ?? ({} as Document); const sizeBytes = Number(doc.current_version?.size_bytes); const sizeLabel = Number.isFinite(sizeBytes) && sizeBytes > 0 ? formatFileSize(sizeBytes) : '—'; const metadata = doc.current_version?.metadata || null; const pageCount = coercePageCount(metadata); const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—'; const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`; - const tags = sanitizeArray(doc.tags); - const correspondents = sanitizeArray(doc.correspondents); + const tags = sanitizeArray(doc.tags); + const correspondents = sanitizeArray(doc.correspondents); const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[]; const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[]; const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—'; @@ -113,13 +76,14 @@ export const describeDocumentSummary = (document?: SummaryDocument | null, optio ]; }; -export const extractDocumentMetadataPayload = (document?: MetadataDocumentLike | null): DocumentMetadataPayload | null => { - if (!document?.metadata) { +export const extractDocumentMetadataPayload = (document?: Document | null): DocumentMetadataPayload | null => { + const metadata = document?.['metadata'] as DocumentMetadataPayload | undefined; + if (!metadata) { return null; } - const keys = Object.keys(document.metadata); + const keys = Object.keys(metadata); if (!keys.length) { return null; } - return document.metadata; + return metadata; }; diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index a50e305..bb476cd 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -6,9 +6,9 @@ import type { DocumentsListEntry, FolderEventHandler, DocumentEventHandler, - DocumentLike, + Document, DocumentTag, -} from '../DocumentsList'; +} from '../../types/documents'; import DesktopWorkspace from '../../desktop/DesktopWorkspace'; import { isTagTransferEvent } from '../tagTransfer'; import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; @@ -64,7 +64,7 @@ export interface DocumentsViewProps { onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise | boolean; onDocumentClick?: DocumentEventHandler; onDocumentActivate?: DocumentEventHandler; - onDocumentDragStart?: (event: DragEvent, document: DocumentLike) => void; + onDocumentDragStart?: (event: DragEvent, document: Document) => void; onDocumentDragEnd?: (event: DragEvent) => void; onDocumentTagDragOver?: (event: DragEvent) => void; onDocumentTagDragLeave?: (event: DragEvent) => void; diff --git a/frontend/src/hooks/documents/useDocumentDragHandlers.ts b/frontend/src/hooks/documents/useDocumentDragHandlers.ts index ab9bd9c..01c413f 100644 --- a/frontend/src/hooks/documents/useDocumentDragHandlers.ts +++ b/frontend/src/hooks/documents/useDocumentDragHandlers.ts @@ -6,11 +6,7 @@ import type { FolderId, Identifier } from '../../types/identifiers'; type FolderIdentifier = FolderId | 'root'; type FolderInput = FolderIdentifier | number; -interface DocumentLike { - id?: Identifier | null; - title?: string; - [key: string]: unknown; -} +import type { Document } from '../../types/documents'; type ApplySelectionFn = ( keys: string[], @@ -28,7 +24,7 @@ interface UseDocumentDragHandlersOptions { selectedFolderIds: FolderInput[]; applySelection: ApplySelectionFn; handleEntrySelection: HandleEntrySelectionFn; - documentLookup: Map; + documentLookup: Map; setDraggedDocumentIds: (ids: Identifier[] | []) => void; setDraggedFolderId: (id: FolderIdentifier | null) => void; documentsViewMode: string; @@ -62,7 +58,7 @@ const useDocumentDragHandlers = ({ useEffect(() => destroyDragPreview, [destroyDragPreview]); const createDragPreview = useCallback( - ({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: FolderIdentifier[] } = {}) => { + ({ documents = [], folders = [] }: { documents?: Document[]; folders?: FolderIdentifier[] } = {}) => { destroyDragPreview(); const docEntries = (documents || []).filter(Boolean); @@ -202,9 +198,9 @@ const useDocumentDragHandlers = ({ ); const handleDocumentDragStart = useCallback( - (event: DragEvent, documentOrId: DocumentLike | Identifier | null) => { + (event: DragEvent, documentOrId: Document | Identifier | null) => { const documentId: Identifier | null = Object(documentOrId) === documentOrId - ? (documentOrId as DocumentLike)?.id ?? null + ? (documentOrId as Document)?.id ?? null : (documentOrId as Identifier | null); if (!documentId) { return; diff --git a/frontend/src/hooks/documents/useDocumentMutations.ts b/frontend/src/hooks/documents/useDocumentMutations.ts index edb7779..e95d12e 100644 --- a/frontend/src/hooks/documents/useDocumentMutations.ts +++ b/frontend/src/hooks/documents/useDocumentMutations.ts @@ -14,6 +14,7 @@ import { updateDocument, } from '../../lib/apiClient'; import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; type FolderId = FolderIdentifier | 'root'; type NullableFolderId = FolderId | null; @@ -21,8 +22,8 @@ type NullableFolderId = FolderId | null; type StatusLevel = 'success' | 'error' | 'info' | string; type DocumentCacheMapper = ( - doc: DocumentLike | null, -) => DocumentLike | null; + doc: Document | null, +) => Document | null; type MapDocumentCaches = (mapper: DocumentCacheMapper) => void; @@ -53,19 +54,8 @@ interface Tag { [key: string]: unknown; } -interface DocumentLike { - id?: DocumentId; - folder_id?: NullableFolderId; - folder_path?: string | null; - folder_name?: string | null; - issued_at?: number | null; - title?: string; - tags?: Tag[]; - [key: string]: unknown; -} - interface FolderContents { - documents?: DocumentLike[]; + documents?: Document[]; subfolders?: Array<{ id?: FolderId;[key: string]: unknown }>; [key: string]: unknown; } @@ -109,12 +99,12 @@ interface FolderDeleteOptions { interface UseDocumentMutationsArgs { token?: string | null; - documentLookup: Map; + documentLookup: Map; folderLabelMap: Map; ensureFolderData: EnsureFolderData; selectedFolder: FolderId; setSelectedFolder: Dispatch>; - setDocuments: Dispatch>; + setDocuments: Dispatch>; setFolderContents: Dispatch>>; setSearchResultIds: Dispatch>; setSelectedEntries: Dispatch>; @@ -140,13 +130,13 @@ interface UseDocumentMutationsArgs { tags: Tag[]; refreshTags: () => Promise; tagManager: TagManager; - extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null; - ingestDocuments?: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; + extractDocumentFromResponse?: (payload: unknown) => Document | null; + ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; } interface UseDocumentMutationsResult { moveDocumentsToFolder: ( - documentIds: Array, + documentIds: Array, targetFolderId?: NullableFolderId, ) => Promise; handleThumbnailRegeneration: (documentId: DocumentId) => Promise; @@ -155,7 +145,7 @@ interface UseDocumentMutationsResult { options?: DeleteOptions, ) => Promise; handleDocumentTagAdd: ( - document: DocumentLike, + document: Document, label: string, extras?: DocumentTagExtras | null, ) => Promise; @@ -218,7 +208,7 @@ const useDocumentMutations = ({ ingestDocuments, }: UseDocumentMutationsArgs): UseDocumentMutationsResult => { const moveDocumentsToFolder = useCallback( - async (documentIds: Array, targetFolderId?: NullableFolderId) => { + async (documentIds: Array, targetFolderId?: NullableFolderId) => { const uniqueIds = Array.from( new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]), ); @@ -241,9 +231,9 @@ const useDocumentMutations = ({ document: doc, }; }) - .filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: DocumentLike }>; + .filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>; - const updatedDocsMap = new Map(); + const updatedDocsMap = new Map(); const resolveTargetName = () => { if (!targetLabel) { return null; @@ -257,7 +247,7 @@ const useDocumentMutations = ({ if (!document) { return; } - const updated: DocumentLike = { + const updated: Document = { ...document, folder_id: target, }; @@ -572,7 +562,7 @@ const useDocumentMutations = ({ ); const handleDocumentTagAdd = useCallback( - async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => { + async (document: Document, label: string, extras: DocumentTagExtras | null = null) => { const normalizedLabel = tagManager.normalizeLabel(label); const optionCandidate = extras?.option ?? null; const input = extras?.input ?? null; diff --git a/frontend/src/hooks/documents/useDocuments.ts b/frontend/src/hooks/documents/useDocuments.ts index af8957e..1058315 100644 --- a/frontend/src/hooks/documents/useDocuments.ts +++ b/frontend/src/hooks/documents/useDocuments.ts @@ -8,20 +8,16 @@ import { } from 'react'; import DocumentsManager from '../../documents/DocumentsManager'; import type { DocumentId } from '../../types/identifiers'; - -interface DocumentLike { - id?: DocumentId; - [key: string]: unknown; -} +import type { Document } from '../../types/documents'; interface FolderContentsEntry { - documents?: DocumentLike[]; + documents?: Document[]; [key: string]: unknown; } interface UseDocumentsOptions { setFolderContents: Dispatch>>; - fetchDocumentById?: (id: DocumentId) => Promise; + fetchDocumentById?: (id: DocumentId) => Promise; } const useDocuments = ({ @@ -29,16 +25,16 @@ const useDocuments = ({ fetchDocumentById, }: UseDocumentsOptions) => { const managerRef = useRef( - new DocumentsManager(fetchDocumentById), + new DocumentsManager(fetchDocumentById), ); - const [documents, setDocumentsState] = useState([]); + const [documents, setDocumentsState] = useState([]); useEffect(() => { managerRef.current.setFetcher(fetchDocumentById); }, [fetchDocumentById]); const setDocuments = useCallback( - (value: DocumentLike[] | ((prev: DocumentLike[]) => DocumentLike[])) => { + (value: Document[] | ((prev: Document[]) => Document[])) => { setDocumentsState((prev) => { const resolved = typeof value === 'function' ? value(prev) : value; if (!Array.isArray(resolved)) { @@ -52,7 +48,7 @@ const useDocuments = ({ ); const mapDocumentCaches = useCallback( - (mapper: (doc: DocumentLike) => DocumentLike | undefined) => { + (mapper: (doc: Document) => Document | undefined) => { managerRef.current.map(mapper); const lookupSnapshot = managerRef.current.getSnapshot(); @@ -64,7 +60,7 @@ const useDocuments = ({ const next = prev.map((doc) => { const id = doc?.id; if (id != null && lookupSnapshot.has(id as DocumentId)) { - const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike; + const canonical = lookupSnapshot.get(id as DocumentId) as Document; if (canonical !== doc) { changed = true; } @@ -95,7 +91,7 @@ const useDocuments = ({ const updatedDocs = docs.map((doc) => { const id = doc?.id; if (id != null && lookupSnapshot.has(id as DocumentId)) { - const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike; + const canonical = lookupSnapshot.get(id as DocumentId) as Document; if (canonical !== doc) { docsChanged = true; } diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 968fbe7..c90a6db 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -62,15 +62,11 @@ const noop = () => { }; type FolderId = FolderIdentifier | 'root'; -interface DocumentLike { - id?: DocumentId | null; - title?: string | null; - [key: string]: unknown; -} +import type { Document } from '../../types/documents'; interface FolderContentsEntry { folder?: { id?: FolderId; name?: string | null } | null; - documents?: DocumentLike[]; + documents?: Document[]; subfolders?: Array<{ id?: FolderId; name?: string | null;[key: string]: unknown }>; __includesDocuments?: boolean; __sortField?: string | null; @@ -397,7 +393,7 @@ const useDocumentsWorkspace = ({ () => visibleDocumentIds .map((id) => documentLookup.get(id) || null) - .filter((doc): doc is DocumentLike => Boolean(doc)), + .filter((doc): doc is Document => Boolean(doc)), [visibleDocumentIds, documentLookup], ); diff --git a/frontend/src/hooks/documents/useFolderTree.ts b/frontend/src/hooks/documents/useFolderTree.ts index a684a06..436aa03 100644 --- a/frontend/src/hooks/documents/useFolderTree.ts +++ b/frontend/src/hooks/documents/useFolderTree.ts @@ -9,14 +9,10 @@ import { createFolderEntryKey, } from '../../app/entryKey'; import type { FolderId as FolderIdentifier, Identifier } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; type FolderId = FolderIdentifier | 'root'; -interface DocumentLike { - id?: Identifier | null; - [key: string]: unknown; -} - interface FolderSummary { id?: FolderId; name?: string; @@ -31,7 +27,7 @@ interface FolderSummary { interface FolderContentsEntry { folder?: FolderSummary | null; - documents?: DocumentLike[]; + documents?: Document[]; subfolders?: FolderSummary[]; __includesDocuments?: boolean; __sortField?: string | null; @@ -67,7 +63,7 @@ interface UseFolderTreeOptions { documentsSortFieldRef: MutableRefObject; documentsSortDirectionRef: MutableRefObject; selectionHelpers: SelectionHelpers; - setDocuments: Dispatch>; + setDocuments: Dispatch>; setFolderContents: Dispatch>>; folderContentsRef: MutableRefObject>; } diff --git a/frontend/src/hooks/useAssetNavigator.ts b/frontend/src/hooks/useAssetNavigator.ts index 112af21..c938f9d 100644 --- a/frontend/src/hooks/useAssetNavigator.ts +++ b/frontend/src/hooks/useAssetNavigator.ts @@ -1,11 +1,8 @@ + import { useEffect, useMemo, useState } from 'react'; import { resolveAssetUrl } from '../asset_manager'; import type { Identifier } from '../types/identifiers'; - -type DocumentLike = { - id?: Identifier; - [key: string]: unknown; -}; +import type { Document } from '../types/documents'; type AssetObject = { url?: string | null; @@ -29,7 +26,7 @@ type EnsureAssetUrl = ( options?: { force?: boolean;[key: string]: unknown }, ) => Promise; -type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null; +type GetAsset = (document: Document, assetType: string) => AssetLike | null; type AssetViewLike = { url: string | null; @@ -37,14 +34,14 @@ type AssetViewLike = { }; interface UseAssetNavigatorOptions { - document?: DocumentLike | null; + document?: Document | null; assetType: string; ensureAssetUrl?: EnsureAssetUrl | null; getAsset?: GetAsset; } interface AssetNavigatorReturn { - document: DocumentLike | null; + document: Document | null; documentId: Identifier | null; asset: AssetLike | null; assetType: string; diff --git a/frontend/src/preview/DocumentViewerLayout.tsx b/frontend/src/preview/DocumentViewerLayout.tsx index efe8b41..bc87faf 100644 --- a/frontend/src/preview/DocumentViewerLayout.tsx +++ b/frontend/src/preview/DocumentViewerLayout.tsx @@ -5,14 +5,7 @@ import { DownloadIcon } from '../ui/icons'; import PdfViewer from './PdfViewer'; import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview'; -interface DocumentLike { - id?: string; - title?: string; - mime_type?: string; - filename?: string; - original_name?: string; - [key: string]: unknown; -} +import type { Document } from '../types/documents'; interface DocumentLink { url?: string; @@ -36,7 +29,7 @@ interface ContentTabConfig { type LayoutMode = 'split' | 'stacked' | (string & {}); interface DocumentViewerLayoutProps { - document?: DocumentLike | null; + document?: Document | null; documentLink?: DocumentLink | null; summaryProps?: Record; metadataPayload?: unknown; diff --git a/frontend/src/preview/DocumentViewerPanel.tsx b/frontend/src/preview/DocumentViewerPanel.tsx index ad1c477..2610f0f 100644 --- a/frontend/src/preview/DocumentViewerPanel.tsx +++ b/frontend/src/preview/DocumentViewerPanel.tsx @@ -29,53 +29,26 @@ import DocumentViewerLayout from './DocumentViewerLayout'; import useViewerLayoutMode from './useViewerLayoutMode'; import { usePanelResizeBindings } from '../app/PanelManagerContext'; import type { DocumentId, FolderId } from '../types/identifiers'; - -interface DocumentLike { - id?: DocumentId; - title?: string; - mime_type?: string | null; - issued_at?: string | null; - folder_id?: FolderId | null; - correspondents?: Array<{ id?: string; name?: string }>; - current_version?: { - version_number?: number; - download?: { url?: string | null; expires_at?: number } | null; - mime_type?: string | null; - filename?: string | null; - } | null; - documentLink?: { - url: string; - alt?: string; - mimeType?: string | null; - } | null; - [key: string]: unknown; -} - -interface AssetLike { - id?: string; - url?: string | null; - metadata?: Record | null; - [key: string]: unknown; -} +import type { Document } from '../types/documents'; +import type { AssetLike } from '../types/assets'; type SidebarMode = 'overlay' | 'inline'; interface DocumentViewerPanelProps extends DocumentSummarySectionProps { - document: DocumentLike | null; + document: Document | null; ensureAssetUrl?: (docId: DocumentId, asset: AssetLike, options?: { force?: boolean }) => Promise; - getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null; - ensurePreviewData?: (docId: DocumentId, options?: { signal?: AbortSignal }) => Promise; + getDocumentAsset?: (doc: Document | null, type: string) => AssetLike | null; + ensurePreviewData?: (docId: DocumentId, options?: { signal?: AbortSignal }) => Promise; notifyApiError?: (error: unknown, fallbackMessage?: string) => void; sidebarToggle?: ReactNode; onClosePanel?: () => void; - resolveFolderPath?: (doc: DocumentLike | null) => Array<{ id?: string; name?: string }>; + resolveFolderPath?: (doc: Document | null) => Array<{ id?: string; name?: string }>; variant?: 'viewer' | 'sidebar'; onCollapsePanel?: () => void; onMaximizePanel?: (args: { documentIds: Array }) => void; sidebarMode?: SidebarMode; } - export const createDocumentViewerHeaderActions = ({ document, actionState, diff --git a/frontend/src/types/assets.ts b/frontend/src/types/assets.ts new file mode 100644 index 0000000..56792e8 --- /dev/null +++ b/frontend/src/types/assets.ts @@ -0,0 +1,20 @@ +import type { Identifier } from './identifiers'; + +export interface AssetObject { + ordinal?: number; + url?: string | null; + metadata?: Record | null; + expires_at?: number; + [key: string]: unknown; +} + +export interface AssetLike { + id?: Identifier; + asset_type?: string; + cardinality?: number | null; + download?: { url: string; expires_at: number } | null; + metadata?: Record | null; + assets?: Record | AssetLike[] | null; + objects?: AssetObject[] | null; + [key: string]: unknown; +} diff --git a/frontend/src/types/documents.ts b/frontend/src/types/documents.ts new file mode 100644 index 0000000..629ba0f --- /dev/null +++ b/frontend/src/types/documents.ts @@ -0,0 +1,74 @@ +import type { Identifier } from './identifiers'; +import type { AssetLike } from './assets'; + +export interface DocumentTag { + id?: Identifier; + label?: string | null; + color?: string | null; +} + +export interface DocumentCorrespondent { + id?: Identifier; + name?: string | null; + count?: number; +} + +export interface DocumentVersion { + assets?: Record | AssetLike[] | null; + metadata?: Record & { page_count?: number } | null; + size_bytes?: number | string | null; + checksum?: string | null; + [key: string]: unknown; +} + +export interface Document { + id?: Identifier; + title?: string | null; + original_name?: string | null; + filename?: string | null; + mime_type?: string | null; + + issued_at?: string | number | null; + created_at?: string | null; + uploaded_at?: string | null; + updated_at?: string | null; + + folder_id?: Identifier | null; + folder_name?: string; + folder_path?: string; + + tags?: DocumentTag[] | null; + correspondents?: DocumentCorrespondent[] | null; + + current_version?: DocumentVersion | null; + + // Allow for other properties as we unify loosely typed interfaces + [key: string]: unknown; +} + +export interface Folder { + id?: Identifier | 'root'; + name?: string; +} + +export type FolderEntry = { + type: 'folder'; + id: Identifier | 'root'; + key: string; + folder: Folder; +}; + +export type DocumentEntry = { + type: 'document'; + id: Identifier; + key: string; + document: Document; +}; + +export type DocumentsListEntry = FolderEntry | DocumentEntry; + +// Event Handlers +import type { MouseEvent } from 'react'; + +export type FolderEventHandler = (folder: Folder, event: MouseEvent) => void; +export type DocumentEventHandler = (document: Document, event: MouseEvent) => void; diff --git a/frontend/src/utils/ocr.ts b/frontend/src/utils/ocr.ts index 81b0d93..51cdeb4 100644 --- a/frontend/src/utils/ocr.ts +++ b/frontend/src/utils/ocr.ts @@ -1,18 +1,16 @@ import { resolveDocumentAssetUrl, resolveAssetUrl } from '../asset_manager'; import type { - DocumentLike as AssetManagerDocumentLike, - DocumentVersionLike, - AssetLike as AssetManagerAssetLike, GetAsset as AssetManagerGetAsset, } from '../asset_manager'; +import type { + Document, + DocumentVersion, +} from '../types/documents'; +import type { AssetLike } from '../types/assets'; -export interface DocumentLike extends AssetManagerDocumentLike { - current_version?: DocumentVersionLike | null; -} +export type { Document, DocumentVersion, AssetLike }; -export type AssetLike = AssetManagerAssetLike; - -export type EnsurePreviewData = (id: string) => Promise; +export type EnsurePreviewData = (id: string) => Promise; export type EnsureAssetUrl = ( id: string, asset: AssetLike, @@ -21,13 +19,13 @@ export type EnsureAssetUrl = ( export type GetDocumentAsset = AssetManagerGetAsset; interface ResolveOcrTextUrlOptions { - document: DocumentLike | null; + document: Document | null; ensurePreviewData?: EnsurePreviewData; getDocumentAsset?: GetDocumentAsset; ensureAssetUrl?: EnsureAssetUrl; } -const pickAsset = (doc?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset): AssetLike | null => { +const pickAsset = (doc?: Document | null, getDocumentAsset?: GetDocumentAsset): AssetLike | null => { if (!doc || !getDocumentAsset) { return null; }