refactor(documents): unify document upload and deletion logic

Replace type-specific document add/remove functions with generalized uploadDocuments and deleteDocument methods. Consolidate file upload handling in DocumentUploader for improved maintainability. Introduce evictCachedDocument to streamline cache eviction across document types. Update DocumentContext to support batch uploads and single-method deletion, simplifying context API and reducing duplication.
This commit is contained in:
Richard R 2026-06-03 23:31:18 -06:00
parent cc868511d9
commit d489356b00
4 changed files with 114 additions and 116 deletions

View file

@ -228,13 +228,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const {
pdfDocs,
removePDFDocument,
isPDFLoading,
epubDocs,
removeEPUBDocument,
isEPUBLoading,
htmlDocs,
removeHTMLDocument,
deleteDocument,
isHTMLLoading,
} = useDocuments();
@ -436,9 +434,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const handleDelete = useCallback(async () => {
if (!documentToDelete) return;
try {
if (documentToDelete.type === 'pdf') await removePDFDocument(documentToDelete.id);
else if (documentToDelete.type === 'epub') await removeEPUBDocument(documentToDelete.id);
else if (documentToDelete.type === 'html') await removeHTMLDocument(documentToDelete.id);
await deleteDocument(documentToDelete.id);
setFolders((prev) =>
prev.map((f) => ({
...f,
@ -451,7 +447,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
} catch (err) {
console.error('Failed to remove document:', err);
}
}, [documentToDelete, removePDFDocument, removeEPUBDocument, removeHTMLDocument]);
}, [deleteDocument, documentToDelete]);
const handleDeleteDoc = useCallback((doc: DocumentListDocument) => {
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type });

View file

@ -33,9 +33,7 @@ export function DocumentUploader({
const uploaderId = useId();
const enableDocx = useFeatureFlag('enableDocxConversion');
const {
addPDFDocument: addPDF,
addEPUBDocument: addEPUB,
addHTMLDocument: addHTML,
uploadDocuments,
refreshDocuments,
} = useDocuments();
const [isUploading, setIsUploading] = useState(false);
@ -63,57 +61,60 @@ export function DocumentUploader({
});
try {
const docxFiles: File[] = [];
const regularFiles: File[] = [];
for (const file of acceptedFiles) {
if (file.type === 'application/pdf') {
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: file.name,
});
await addPDF(file);
completedFiles += 1;
} else if (file.type === 'application/epub+zip') {
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: file.name,
});
await addEPUB(file);
completedFiles += 1;
} else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) {
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: file.name,
});
await addHTML(file);
completedFiles += 1;
} else if (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
// Preserve prior UX: show "Converting DOCX..." state rather than generic uploading.
setIsUploading(false);
setIsConverting(true);
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'converting',
currentFileName: file.name,
});
// Convert+upload directly on the server. Use sha(docx) as stable ID to avoid duplicates.
await uploadDocxAsPdf(file);
await refreshDocuments();
setIsConverting(false);
setIsUploading(true);
completedFiles += 1;
} else {
if (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
docxFiles.push(file);
continue;
}
if (
file.type === 'application/pdf'
|| file.type === 'application/epub+zip'
|| file.type === 'text/plain'
|| file.type === 'text/markdown'
|| file.name.endsWith('.md')
) {
regularFiles.push(file);
}
}
if (regularFiles.length > 0) {
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: regularFiles[0]?.name ?? null,
});
await uploadDocuments(regularFiles);
completedFiles += regularFiles.length;
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: null,
});
}
for (const file of docxFiles) {
// Preserve prior UX: show "Converting DOCX..." state rather than generic uploading.
setIsUploading(false);
setIsConverting(true);
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'converting',
currentFileName: file.name,
});
await uploadDocxAsPdf(file);
await refreshDocuments();
setIsConverting(false);
setIsUploading(true);
completedFiles += 1;
emitBatchState({
isActive: true,
@ -137,7 +138,7 @@ export function DocumentUploader({
currentFileName: null,
});
}
}, [addHTML, addPDF, addEPUB, refreshDocuments, enableDocx, emitBatchState]);
}, [uploadDocuments, refreshDocuments, enableDocx, emitBatchState]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,

View file

@ -3,32 +3,53 @@
import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { BaseDocument } from '@/types/documents';
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client/api/documents';
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/client/cache/documents';
import {
deleteDocuments as deleteServerDocuments,
listDocuments,
uploadDocuments as uploadServerDocuments,
} from '@/lib/client/api/documents';
import { cacheStoredDocumentFromBytes, evictCachedDocument } from '@/lib/client/cache/documents';
import { useAuthSession } from '@/hooks/useAuthSession';
interface DocumentContextType {
pdfDocs: Array<BaseDocument & { type: 'pdf' }>;
addPDFDocument: (file: File) => Promise<string>;
removePDFDocument: (id: string) => Promise<void>;
isPDFLoading: boolean;
epubDocs: Array<BaseDocument & { type: 'epub' }>;
addEPUBDocument: (file: File) => Promise<string>;
removeEPUBDocument: (id: string) => Promise<void>;
isEPUBLoading: boolean;
htmlDocs: Array<BaseDocument & { type: 'html' }>;
addHTMLDocument: (file: File) => Promise<string>;
removeHTMLDocument: (id: string) => Promise<void>;
isHTMLLoading: boolean;
uploadDocuments: (files: File[]) => Promise<BaseDocument[]>;
deleteDocument: (id: string) => Promise<void>;
refreshDocuments: () => Promise<void>;
}
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
const DOCUMENTS_QUERY_KEY = 'documents';
type SupportedDocument = BaseDocument & { type: 'pdf' | 'epub' | 'html' };
function mergeStoredDocuments(
previous: SupportedDocument[] | undefined,
uploaded: BaseDocument[],
): SupportedDocument[] {
const next = [...(previous ?? [])];
for (let index = uploaded.length - 1; index >= 0; index -= 1) {
const stored = uploaded[index];
if (stored.type !== 'pdf' && stored.type !== 'epub' && stored.type !== 'html') {
continue;
}
const supportedStored = stored as SupportedDocument;
const withoutExisting = next.filter((document) => document.id !== supportedStored.id);
next.splice(0, next.length, supportedStored, ...withoutExisting);
}
return next;
}
export function DocumentProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
@ -81,67 +102,43 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
return { pdfDocs, epubDocs, htmlDocs };
}, [docs]);
const cacheUploaded = useCallback(async (stored: BaseDocument, file: File) => {
try {
if (stored.type === 'pdf') {
await putCachedPdf(stored, await file.arrayBuffer());
} else if (stored.type === 'epub') {
await putCachedEpub(stored, await file.arrayBuffer());
} else if (stored.type === 'html') {
const buf = await file.arrayBuffer();
const decoded = new TextDecoder().decode(new Uint8Array(buf));
await putCachedHtml(stored, decoded);
}
} catch (err) {
// Cache failures should not block uploads.
console.warn('Failed to cache uploaded document:', stored.id, err);
}
}, []);
const uploadDocuments = useCallback(async (files: File[]): Promise<BaseDocument[]> => {
if (files.length === 0) return [];
const addDocument = useCallback(async (file: File): Promise<string> => {
const [stored] = await uploadDocuments([file]);
if (!stored) throw new Error('Upload succeeded but returned no document');
await cacheUploaded(stored, file);
const isSupported = stored.type === 'pdf' || stored.type === 'epub' || stored.type === 'html';
if (!isSupported) return stored.id;
const supportedStored = stored as BaseDocument & { type: 'pdf' | 'epub' | 'html' };
queryClient.setQueryData<Array<BaseDocument & { type: 'pdf' | 'epub' | 'html' }>>(documentsQueryKey, (prev = []) => {
const next = prev.filter((d) => d.id !== supportedStored.id);
return [supportedStored, ...next];
});
return stored.id;
}, [cacheUploaded, queryClient, documentsQueryKey]);
const addPDFDocument = useCallback(async (file: File) => addDocument(file), [addDocument]);
const addEPUBDocument = useCallback(async (file: File) => addDocument(file), [addDocument]);
const addHTMLDocument = useCallback(async (file: File) => addDocument(file), [addDocument]);
const removeById = useCallback(async (id: string) => {
await deleteDocuments({ ids: [id] });
await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]);
queryClient.setQueryData<Array<BaseDocument & { type: 'pdf' | 'epub' | 'html' }>>(documentsQueryKey, (prev = []) =>
prev.filter((d) => d.id !== id),
const stored = await uploadServerDocuments(files);
await Promise.allSettled(
stored.map(async (document, index) => {
const file = files[index];
if (!file) return;
await cacheStoredDocumentFromBytes(document, await file.arrayBuffer());
}),
);
}, [queryClient, documentsQueryKey]);
const removePDFDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]);
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous) =>
mergeStoredDocuments(previous, stored),
);
return stored;
}, [documentsQueryKey, queryClient]);
const deleteDocument = useCallback(async (id: string) => {
await deleteServerDocuments({ ids: [id] });
await evictCachedDocument(id);
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous = []) =>
previous.filter((document) => document.id !== id),
);
}, [documentsQueryKey, queryClient]);
return (
<DocumentContext.Provider value={{
pdfDocs: docsByType.pdfDocs,
addPDFDocument,
removePDFDocument,
isPDFLoading: isLoading,
epubDocs: docsByType.epubDocs,
addEPUBDocument,
removeEPUBDocument,
isEPUBLoading: isLoading,
htmlDocs: docsByType.htmlDocs,
addHTMLDocument,
removeHTMLDocument,
isHTMLLoading: isLoading,
uploadDocuments,
deleteDocument,
refreshDocuments,
}}>

View file

@ -107,6 +107,10 @@ export async function evictCachedHtml(id: string): Promise<void> {
await removeHtmlDocument(id);
}
export async function evictCachedDocument(id: string): Promise<void> {
await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]);
}
export async function clearDocumentCache(): Promise<void> {
const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/client/dexie');
await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]);