feat: Implement document selection modal to allow users to select specific documents for sync, load, and import operations.
This commit is contained in:
parent
741126c0b4
commit
f947ece01a
4 changed files with 551 additions and 114 deletions
|
|
@ -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<string>): Promise<(BaseDocument | SyncedDocument)[]> {
|
||||||
const results: (BaseDocument | SyncedDocument)[] = [];
|
const results: (BaseDocument | SyncedDocument)[] = [];
|
||||||
let files: string[] = [];
|
let files: string[] = [];
|
||||||
|
|
||||||
|
|
@ -53,6 +53,9 @@ async function loadSyncedDocuments(includeData: boolean): Promise<(BaseDocument
|
||||||
const parsed = parseSyncedFileName(file);
|
const parsed = parseSyncedFileName(file);
|
||||||
if (!parsed) continue;
|
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);
|
const filePath = path.join(SYNC_DIR, file);
|
||||||
let fileStat: Awaited<ReturnType<typeof stat>>;
|
let fileStat: Awaited<ReturnType<typeof stat>>;
|
||||||
try {
|
try {
|
||||||
|
|
@ -154,10 +157,21 @@ export async function GET(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const format = url.searchParams.get('format') ?? 'sync';
|
const list = url.searchParams.get('list') === 'true';
|
||||||
const includeData = format !== 'metadata';
|
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<string> | undefined;
|
||||||
|
if (idsParam) {
|
||||||
|
targetIds = new Set(idsParam.split(',').filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await loadSyncedDocuments(includeData, targetIds);
|
||||||
|
|
||||||
return NextResponse.json({ documents });
|
return NextResponse.json({ documents });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
255
src/components/DocumentSelectionModal.tsx
Normal file
255
src/components/DocumentSelectionModal.tsx
Normal file
|
|
@ -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<BaseDocument[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentSelectionModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
confirmLabel,
|
||||||
|
isProcessing,
|
||||||
|
defaultSelected = false,
|
||||||
|
initialFiles,
|
||||||
|
fetcher,
|
||||||
|
}: DocumentSelectionModalProps) {
|
||||||
|
const [files, setFiles] = useState<BaseDocument[]>([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [lastSelectedId, setLastSelectedId] = useState<string | null>(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<string>();
|
||||||
|
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 (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-[60]" onClose={onClose}>
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all flex flex-col h-[80vh]">
|
||||||
|
<DialogTitle
|
||||||
|
as="h3"
|
||||||
|
className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex items-center text-sm font-normal">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none text-muted hover:text-foreground transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-muted text-accent focus:ring-accent"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={input => {
|
||||||
|
if (input) input.indeterminate = isIndeterminate;
|
||||||
|
}}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Select All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto border border-offbase rounded-lg bg-background p-2 min-h-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted">Loading documents...</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted">No documents found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{files.map((file) => {
|
||||||
|
const isSelected = selectedIds.has(file.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
onClick={(e) => 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'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
||||||
|
className="rounded border-muted text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex-1 truncate ${
|
||||||
|
isSelected ? 'text-accent font-medium' : 'text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted text-xs whitespace-nowrap">{formatSize(file.size)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onClick={handleConfirmClick}
|
||||||
|
disabled={selectedCount === 0 || isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,13 +22,15 @@ import {
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
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 { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
import { THEMES } from '@/contexts/ThemeContext';
|
import { THEMES } from '@/contexts/ThemeContext';
|
||||||
import { deleteServerDocuments } from '@/lib/client';
|
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;
|
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 [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isImportingLibrary, setIsImportingLibrary] = 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<BaseDocument[]>;
|
||||||
|
}>({
|
||||||
|
title: '',
|
||||||
|
confirmLabel: '',
|
||||||
|
mode: 'library',
|
||||||
|
defaultSelected: false
|
||||||
|
});
|
||||||
|
|
||||||
const [showProgress, setShowProgress] = useState(false);
|
const [showProgress, setShowProgress] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [operationType, setOperationType] = useState<'sync' | 'load' | 'library'>('sync');
|
const [operationType, setOperationType] = useState<'sync' | 'load' | 'library'>('sync');
|
||||||
|
|
@ -150,100 +167,126 @@ export function SettingsModal() {
|
||||||
}, [modelValue, ttsModels]);
|
}, [modelValue, ttsModels]);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
const controller = new AbortController();
|
// Collect local documents
|
||||||
setAbortController(controller);
|
const pdfs = await getAllPdfDocuments();
|
||||||
|
const epubs = await getAllEpubDocuments();
|
||||||
|
const htmls = await getAllHtmlDocuments();
|
||||||
|
|
||||||
try {
|
const allDocs: BaseDocument[] = [
|
||||||
setIsSyncing(true);
|
...pdfs.map(d => ({ ...d, type: 'pdf' as const })),
|
||||||
setShowProgress(true);
|
...epubs.map(d => ({ ...d, type: 'epub' as const })),
|
||||||
setProgress(0);
|
...htmls.map(d => ({ ...d, type: 'html' as const }))
|
||||||
setOperationType('sync');
|
];
|
||||||
setStatusMessage('Preparing documents...');
|
|
||||||
await syncDocumentsToServer((progress, status) => {
|
setSelectionModalProps({
|
||||||
if (controller.signal.aborted) return;
|
title: 'Save to Server',
|
||||||
setProgress(progress);
|
confirmLabel: 'Save',
|
||||||
if (status) setStatusMessage(status);
|
mode: 'save',
|
||||||
}, controller.signal);
|
defaultSelected: true,
|
||||||
} catch (error) {
|
initialFiles: allDocs
|
||||||
if (controller.signal.aborted) {
|
});
|
||||||
console.log('Sync operation cancelled');
|
setIsSelectionModalOpen(true);
|
||||||
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 handleLoad = async () => {
|
const handleLoad = async () => {
|
||||||
const controller = new AbortController();
|
setSelectionModalProps({
|
||||||
setAbortController(controller);
|
title: 'Load from Server',
|
||||||
|
confirmLabel: 'Load',
|
||||||
try {
|
mode: 'load',
|
||||||
setIsLoading(true);
|
defaultSelected: true,
|
||||||
setShowProgress(true);
|
fetcher: async () => {
|
||||||
setProgress(0);
|
const res = await fetch('/api/documents?list=true');
|
||||||
setOperationType('load');
|
if (!res.ok) throw new Error('Failed to list server documents');
|
||||||
setStatusMessage('Downloading documents from server...');
|
const data = await res.json();
|
||||||
await loadDocumentsFromServer((progress, status) => {
|
// Handle case where API might return error object
|
||||||
if (controller.signal.aborted) return;
|
if (data.error) throw new Error(data.error);
|
||||||
setProgress(progress);
|
return data.documents || [];
|
||||||
if (status) setStatusMessage(status);
|
}
|
||||||
}, controller.signal);
|
});
|
||||||
if (controller.signal.aborted) return;
|
setIsSelectionModalOpen(true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportLibrary = async () => {
|
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();
|
const controller = new AbortController();
|
||||||
setAbortController(controller);
|
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 {
|
try {
|
||||||
setIsImportingLibrary(true);
|
setShowProgress(true);
|
||||||
setShowProgress(true);
|
setProgress(0);
|
||||||
setProgress(0);
|
|
||||||
setOperationType('library');
|
if (mode === 'save') {
|
||||||
setStatusMessage('Scanning server library...');
|
setIsSyncing(true);
|
||||||
await importDocumentsFromLibrary((progress, status) => {
|
setOperationType('sync');
|
||||||
if (controller.signal.aborted) return;
|
setStatusMessage('Preparing documents...');
|
||||||
setProgress(progress);
|
await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => {
|
||||||
if (status) setStatusMessage(status);
|
if (controller.signal.aborted) return;
|
||||||
}, controller.signal);
|
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) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
console.log('Library import cancelled');
|
console.log(`${mode} operation cancelled`);
|
||||||
setStatusMessage('Operation cancelled');
|
setStatusMessage('Operation cancelled');
|
||||||
} else {
|
} else {
|
||||||
console.error('Library import failed:', error);
|
console.error(`${mode} failed:`, error);
|
||||||
setStatusMessage('Library import failed. Please try again.');
|
setStatusMessage(`${mode} failed. Please try again.`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsImportingLibrary(false);
|
setIsSyncing(false);
|
||||||
setShowProgress(false);
|
setIsLoading(false);
|
||||||
setProgress(0);
|
setIsImportingLibrary(false);
|
||||||
setStatusMessage('');
|
setShowProgress(false);
|
||||||
setAbortController(null);
|
setProgress(0);
|
||||||
|
setStatusMessage('');
|
||||||
|
setAbortController(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -752,6 +795,17 @@ export function SettingsModal() {
|
||||||
operationType={operationType}
|
operationType={operationType}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
/>
|
/>
|
||||||
|
<DocumentSelectionModal
|
||||||
|
isOpen={isSelectionModalOpen}
|
||||||
|
onClose={() => !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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
170
src/lib/dexie.ts
170
src/lib/dexie.ts
|
|
@ -731,6 +731,67 @@ export async function syncDocumentsToServer(
|
||||||
return { lastSync: Date.now() };
|
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(
|
export async function loadDocumentsFromServer(
|
||||||
onProgress?: (progress: number, status?: string) => void,
|
onProgress?: (progress: number, status?: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
|
@ -754,6 +815,52 @@ export async function loadDocumentsFromServer(
|
||||||
onProgress(40, 'Parsing documents...');
|
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();
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
for (let i = 0; i < documents.length; i++) {
|
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}...`);
|
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,
|
onProgress?: (progress: number, status?: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (onProgress) {
|
if (documents.length === 0) return;
|
||||||
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...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textDecoder = new TextDecoder();
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
|
@ -890,8 +973,39 @@ export async function importDocumentsFromLibrary(
|
||||||
onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`);
|
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<void> {
|
||||||
|
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) {
|
if (onProgress) {
|
||||||
onProgress(100, 'Library import complete!');
|
onProgress(100, 'Library import complete!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue