diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 08ba8e5..fdb98a9 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -39,7 +39,7 @@ function parseSyncedFileName(fileName: string): { id: string; name: string } | n } } -async function loadSyncedDocuments(includeData: boolean): Promise<(BaseDocument | SyncedDocument)[]> { +async function loadSyncedDocuments(includeData: boolean, targetIds?: Set): Promise<(BaseDocument | SyncedDocument)[]> { const results: (BaseDocument | SyncedDocument)[] = []; let files: string[] = []; @@ -53,6 +53,9 @@ async function loadSyncedDocuments(includeData: boolean): Promise<(BaseDocument const parsed = parseSyncedFileName(file); if (!parsed) continue; + // Filter by ID if specific IDs are requested + if (targetIds && !targetIds.has(parsed.id)) continue; + const filePath = path.join(SYNC_DIR, file); let fileStat: Awaited>; try { @@ -154,10 +157,21 @@ export async function GET(req: NextRequest) { } const url = new URL(req.url); - const format = url.searchParams.get('format') ?? 'sync'; - const includeData = format !== 'metadata'; + const list = url.searchParams.get('list') === 'true'; + const format = url.searchParams.get('format'); + const idsParam = url.searchParams.get('ids'); - const documents = await loadSyncedDocuments(includeData); + // If list=true, force metadata only. + // If format=metadata, force metadata only. + // Otherwise include data. + const includeData = !list && format !== 'metadata'; + + let targetIds: Set | undefined; + if (idsParam) { + targetIds = new Set(idsParam.split(',').filter(Boolean)); + } + + const documents = await loadSyncedDocuments(includeData, targetIds); return NextResponse.json({ documents }); } catch (error) { diff --git a/src/components/DocumentSelectionModal.tsx b/src/components/DocumentSelectionModal.tsx new file mode 100644 index 0000000..e03af18 --- /dev/null +++ b/src/components/DocumentSelectionModal.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; +import { Fragment, useEffect, useState } from 'react'; +import { BaseDocument } from '@/types/documents'; + +interface DocumentSelectionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (selectedFiles: BaseDocument[]) => void; + title: string; + confirmLabel: string; + isProcessing: boolean; + defaultSelected?: boolean; + /** + * Data source: + * 1. `initialFiles`: Pass local files directly (synchronous). + * 2. `fetcher`: Pass an async function to load files (e.g. from server). + */ + initialFiles?: BaseDocument[]; + fetcher?: () => Promise; +} + +export function DocumentSelectionModal({ + isOpen, + onClose, + onConfirm, + title, + confirmLabel, + isProcessing, + defaultSelected = false, + initialFiles, + fetcher, +}: DocumentSelectionModalProps) { + const [files, setFiles] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [lastSelectedId, setLastSelectedId] = useState(null); + + useEffect(() => { + if (isOpen) { + if (initialFiles) { + setFiles(initialFiles); + if (defaultSelected) { + setSelectedIds(new Set(initialFiles.map((f) => f.id))); + } else { + setSelectedIds(new Set()); + } + setLastSelectedId(null); + } else if (fetcher) { + setIsLoading(true); + fetcher() + .then((data) => { + setFiles(data); + if (defaultSelected) { + setSelectedIds(new Set(data.map((f) => f.id))); + } else { + setSelectedIds(new Set()); + } + setLastSelectedId(null); + }) + .catch((err) => console.error('Failed to load documents:', err)) + .finally(() => setIsLoading(false)); + } else { + setFiles([]); + setSelectedIds(new Set()); + } + } + }, [isOpen, initialFiles, fetcher, defaultSelected]); + + const toggleSelection = (id: string, multiSelect: boolean, rangeSelect: boolean) => { + const newSelected = new Set(multiSelect ? selectedIds : []); + + if (rangeSelect && lastSelectedId && files.some((f) => f.id === lastSelectedId)) { + const lastIndex = files.findIndex((f) => f.id === lastSelectedId); + const currentIndex = files.findIndex((f) => f.id === id); + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + + for (let i = start; i <= end; i++) { + newSelected.add(files[i].id); + } + } else { + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + } + + setSelectedIds(newSelected); + setLastSelectedId(id); + }; + + const handleRowClick = (e: React.MouseEvent, id: string) => { + if (e.metaKey || e.ctrlKey) { + toggleSelection(id, true, false); + } else if (e.shiftKey) { + toggleSelection(id, true, true); + } else { + // "Finder" behavior: Click selects only this one (unless multiselect modifier used) + // Checkbox click is handled separately to allow toggling without clearing + const newSelected = new Set(); + newSelected.add(id); + setSelectedIds(newSelected); + setLastSelectedId(id); + } + }; + + const handleCheckboxChange = (id: string, checked: boolean) => { + const newSelected = new Set(selectedIds); + if (checked) newSelected.add(id); + else newSelected.delete(id); + setSelectedIds(newSelected); + setLastSelectedId(id); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedIds(new Set(files.map(f => f.id))); + } else { + setSelectedIds(new Set()); + } + }; + + const selectedCount = selectedIds.size; + const allSelected = files.length > 0 && selectedCount === files.length; + const isIndeterminate = selectedCount > 0 && selectedCount < files.length; + + const handleConfirmClick = () => { + const selectedFiles = files.filter((f) => selectedIds.has(f.id)); + onConfirm(selectedFiles); + }; + + const formatSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( + + + +
+ + +
+
+ + + + {title} + {files.length > 0 && ( +
+ +
+ )} +
+ +
+ {isLoading ? ( +
Loading documents...
+ ) : files.length === 0 ? ( +
No documents found.
+ ) : ( +
+ {files.map((file) => { + const isSelected = selectedIds.has(file.id); + return ( +
handleRowClick(e, file.id)} + className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none + ${isSelected ? 'bg-accent/10' : 'hover:bg-offbase'} + `} + > +
e.stopPropagation()}> + handleCheckboxChange(file.id, e.target.checked)} + className="rounded border-muted text-accent focus:ring-accent" + /> +
+
+ {file.name} +
+
{formatSize(file.size)}
+
+ ); + })} +
+ )} +
+ +
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index cbbba06..dc25e1f 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,13 +22,15 @@ import { import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncDocumentsToServer, loadDocumentsFromServer, importDocumentsFromLibrary, getFirstVisit, setFirstVisit } from '@/lib/dexie'; +import { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { THEMES } from '@/contexts/ThemeContext'; import { deleteServerDocuments } from '@/lib/client'; +import { DocumentSelectionModal } from '@/components/DocumentSelectionModal'; +import { BaseDocument } from '@/types/documents'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -52,6 +54,21 @@ export function SettingsModal() { const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isImportingLibrary, setIsImportingLibrary] = useState(false); + const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + const [selectionModalProps, setSelectionModalProps] = useState<{ + title: string; + confirmLabel: string; + mode: 'library' | 'load' | 'save'; + defaultSelected: boolean; + initialFiles?: BaseDocument[]; + fetcher?: () => Promise; + }>({ + title: '', + confirmLabel: '', + mode: 'library', + defaultSelected: false + }); + const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [operationType, setOperationType] = useState<'sync' | 'load' | 'library'>('sync'); @@ -150,100 +167,126 @@ export function SettingsModal() { }, [modelValue, ttsModels]); const handleSync = async () => { - const controller = new AbortController(); - setAbortController(controller); + // Collect local documents + const pdfs = await getAllPdfDocuments(); + const epubs = await getAllEpubDocuments(); + const htmls = await getAllHtmlDocuments(); - try { - setIsSyncing(true); - setShowProgress(true); - setProgress(0); - setOperationType('sync'); - setStatusMessage('Preparing documents...'); - await syncDocumentsToServer((progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - } catch (error) { - if (controller.signal.aborted) { - console.log('Sync operation cancelled'); - setStatusMessage('Operation cancelled'); - } else { - console.error('Sync failed:', error); - setStatusMessage('Sync failed. Please try again.'); - } - } finally { - setIsSyncing(false); - setShowProgress(false); - setProgress(0); - setStatusMessage(''); - setAbortController(null); - } + const allDocs: BaseDocument[] = [ + ...pdfs.map(d => ({ ...d, type: 'pdf' as const })), + ...epubs.map(d => ({ ...d, type: 'epub' as const })), + ...htmls.map(d => ({ ...d, type: 'html' as const })) + ]; + + setSelectionModalProps({ + title: 'Save to Server', + confirmLabel: 'Save', + mode: 'save', + defaultSelected: true, + initialFiles: allDocs + }); + setIsSelectionModalOpen(true); }; const handleLoad = async () => { - const controller = new AbortController(); - setAbortController(controller); - - try { - setIsLoading(true); - setShowProgress(true); - setProgress(0); - setOperationType('load'); - setStatusMessage('Downloading documents from server...'); - await loadDocumentsFromServer((progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - if (controller.signal.aborted) return; - setStatusMessage('Documents loaded from server'); - } catch (error) { - if (controller.signal.aborted) { - console.log('Load operation cancelled'); - setStatusMessage('Operation cancelled'); - } else { - console.error('Load failed:', error); - setStatusMessage('Load failed. Please try again.'); - } - } finally { - setIsLoading(false); - setShowProgress(false); - setProgress(0); - setStatusMessage(''); - setAbortController(null); - } + setSelectionModalProps({ + title: 'Load from Server', + confirmLabel: 'Load', + mode: 'load', + defaultSelected: true, + fetcher: async () => { + const res = await fetch('/api/documents?list=true'); + if (!res.ok) throw new Error('Failed to list server documents'); + const data = await res.json(); + // Handle case where API might return error object + if (data.error) throw new Error(data.error); + return data.documents || []; + } + }); + setIsSelectionModalOpen(true); }; const handleImportLibrary = async () => { + setSelectionModalProps({ + title: 'Import from Library', + confirmLabel: 'Import', + mode: 'library', + defaultSelected: false, + fetcher: async () => { + const res = await fetch('/api/documents/library?limit=10000'); + if (!res.ok) throw new Error('Failed to list library documents'); + const data = await res.json(); + return data.documents || []; + } + }); + setIsSelectionModalOpen(true); + }; + + const handleModalConfirm = async (selectedFiles: BaseDocument[]) => { const controller = new AbortController(); setAbortController(controller); + + const mode = selectionModalProps.mode; + + // Close modal? Maybe keep open until started? + // Let's close it here, process starts. + // Actually we keep it open if we want to show loading state INSIDE modal? + // But existing UI uses a separate ProgressPopup. + // So close modal, show popup. + setIsSelectionModalOpen(false); try { - setIsImportingLibrary(true); - setShowProgress(true); - setProgress(0); - setOperationType('library'); - setStatusMessage('Scanning server library...'); - await importDocumentsFromLibrary((progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); + setShowProgress(true); + setProgress(0); + + if (mode === 'save') { + setIsSyncing(true); + setOperationType('sync'); + setStatusMessage('Preparing documents...'); + await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } else if (mode === 'load') { + setIsLoading(true); + setOperationType('load'); + setStatusMessage('Downloading documents...'); + // Need ids + const ids = selectedFiles.map(f => f.id); + await loadSelectedDocumentsFromServer(ids, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + if (!controller.signal.aborted) setStatusMessage('Documents loaded'); + } else if (mode === 'library') { + setIsImportingLibrary(true); + setOperationType('library'); + setStatusMessage('Importing selected documents...'); + await importSelectedDocuments(selectedFiles, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } + } catch (error) { - if (controller.signal.aborted) { - console.log('Library import cancelled'); - setStatusMessage('Operation cancelled'); - } else { - console.error('Library import failed:', error); - setStatusMessage('Library import failed. Please try again.'); - } + if (controller.signal.aborted) { + console.log(`${mode} operation cancelled`); + setStatusMessage('Operation cancelled'); + } else { + console.error(`${mode} failed:`, error); + setStatusMessage(`${mode} failed. Please try again.`); + } } finally { - setIsImportingLibrary(false); - setShowProgress(false); - setProgress(0); - setStatusMessage(''); - setAbortController(null); + setIsSyncing(false); + setIsLoading(false); + setIsImportingLibrary(false); + setShowProgress(false); + setProgress(0); + setStatusMessage(''); + setAbortController(null); } }; @@ -752,6 +795,17 @@ export function SettingsModal() { operationType={operationType} cancelText="Cancel" /> + !isImportingLibrary && !isSyncing && !isLoading && setIsSelectionModalOpen(false)} + onConfirm={handleModalConfirm} + title={selectionModalProps.title} + confirmLabel={selectionModalProps.confirmLabel} + isProcessing={false} // Processing happens in ProgressPopup after closing + defaultSelected={selectionModalProps.defaultSelected} + initialFiles={selectionModalProps.initialFiles} + fetcher={selectionModalProps.fetcher} + /> ); } diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts index 9b671e6..e43d39e 100644 --- a/src/lib/dexie.ts +++ b/src/lib/dexie.ts @@ -731,6 +731,67 @@ export async function syncDocumentsToServer( return { lastSync: Date.now() }; } +export async function syncSelectedDocumentsToServer( + documents: BaseDocument[], + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<{ lastSync: number }> { + // Re-use logic from syncDocumentsToServer but only for specific documents + // Actually, syncDocumentsToServer fetches all docs from DB. + // We need to fetch the *full content* of the selected docs from DB. + + const fullDocs: SyncedDocument[] = []; + let processed = 0; + + for (const doc of documents) { + if (doc.type === 'pdf') { + const data = await getPdfDocument(doc.id); + if (data) fullDocs.push({ ...data, type: 'pdf', data: Array.from(new Uint8Array(data.data)) }); + } else if (doc.type === 'epub') { + const data = await getEpubDocument(doc.id); + if (data) fullDocs.push({ ...data, type: 'epub', data: Array.from(new Uint8Array(data.data)) }); + } else { + const data = await getHtmlDocument(doc.id); + if (data) { + const encoder = new TextEncoder(); + fullDocs.push({ ...data, type: 'html', data: Array.from(encoder.encode(data.data)) }); + } + } + processed++; + if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`); + } + + if (onProgress) onProgress(50, 'Uploading to server...'); + + const response = await fetch('/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documents: fullDocs }), + signal, + }); + + if (!response.ok) { + throw new Error('Failed to sync documents to server'); + } + + const payload = (await response.json().catch(() => null)) as + | { stored?: Array<{ oldId: string; id: string }> } + | null; + const stored = payload?.stored ?? []; + for (const mapping of stored) { + if (!mapping || typeof mapping.oldId !== 'string' || typeof mapping.id !== 'string') continue; + if (mapping.oldId === mapping.id) continue; + await applyDocumentIdMapping(mapping.oldId, mapping.id); + } + + if (onProgress) { + onProgress(100, 'Upload complete!'); + } + + return { lastSync: Date.now() }; +} + + export async function loadDocumentsFromServer( onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal, @@ -754,6 +815,52 @@ export async function loadDocumentsFromServer( onProgress(40, 'Parsing documents...'); } + await saveSyncedDocumentsLocally(documents, onProgress); + + if (onProgress) { + onProgress(100, 'Load complete!'); + } + + return { lastSync: Date.now() }; +} + +export async function loadSelectedDocumentsFromServer( + selectedIds: string[], + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<{ lastSync: number }> { + if (onProgress) { + onProgress(10, 'Starting download...'); + } + + // Use new filtered API + const idsParam = selectedIds.join(','); + const response = await fetch(`/api/documents?ids=${encodeURIComponent(idsParam)}`, { signal }); + + if (!response.ok) { + throw new Error('Failed to fetch documents from server'); + } + + if (onProgress) { + onProgress(30, 'Download complete'); + } + + const { documents } = (await response.json()) as { documents: SyncedDocument[] }; + + if (onProgress) { + onProgress(40, 'Parsing documents...'); + } + + await saveSyncedDocumentsLocally(documents, onProgress); + + if (onProgress) { + onProgress(100, 'Load complete!'); + } + + return { lastSync: Date.now() }; +} + +async function saveSyncedDocumentsLocally(documents: SyncedDocument[], onProgress?: (progress: number, status?: string) => void) { const textDecoder = new TextDecoder(); for (let i = 0; i < documents.length; i++) { @@ -801,39 +908,15 @@ export async function loadDocumentsFromServer( onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`); } } - - if (onProgress) { - onProgress(100, 'Load complete!'); - } - - return { lastSync: Date.now() }; } -export async function importDocumentsFromLibrary( + +export async function importSelectedDocuments( + documents: BaseDocument[], onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal, ): Promise { - if (onProgress) { - onProgress(5, 'Scanning server library...'); - } - - const listResponse = await fetch('/api/documents/library', { signal }); - if (!listResponse.ok) { - throw new Error('Failed to list library documents'); - } - - const { documents } = (await listResponse.json()) as { documents: BaseDocument[] }; - - if (documents.length === 0) { - if (onProgress) { - onProgress(100, 'No documents found in server library'); - } - return; - } - - if (onProgress) { - onProgress(10, `Found ${documents.length} documents. Importing...`); - } + if (documents.length === 0) return; const textDecoder = new TextDecoder(); @@ -890,8 +973,39 @@ export async function importDocumentsFromLibrary( onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`); } } +} + +export async function importDocumentsFromLibrary( + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise { + if (onProgress) { + onProgress(5, 'Scanning server library...'); + } + + const listResponse = await fetch('/api/documents/library', { signal }); + if (!listResponse.ok) { + throw new Error('Failed to list library documents'); + } + + const { documents } = (await listResponse.json()) as { documents: BaseDocument[] }; + + if (documents.length === 0) { + if (onProgress) { + onProgress(100, 'No documents found in server library'); + } + return; + } + + if (onProgress) { + onProgress(10, `Found ${documents.length} documents. Importing...`); + } + + await importSelectedDocuments(documents, onProgress, signal); if (onProgress) { onProgress(100, 'Library import complete!'); } } + +