feat(sync): cloud deletion in transfer queue (#2964)

This commit is contained in:
Huang Xin
2026-01-15 13:50:24 +01:00
committed by GitHub
parent 7d97826e4b
commit 7f26e45ae7
38 changed files with 281 additions and 100 deletions
@@ -886,5 +886,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "تعذر تحميل المقال. حاول البحث مباشرة على {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "تعذر تحميل الكلمة. حاول البحث مباشرة على {{link}}.",
"Date Published": "تاريخ النشر",
"Only for TTS:": "فقط لـ TTS:"
"Only for TTS:": "فقط لـ TTS:",
"Uploaded": "تم الرفع",
"Downloaded": "تم التنزيل",
"Deleted": "تم الحذف"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "নিবন্ধ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Unable to load the word. Try searching directly on {{link}}.": "শব্দ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Date Published": "প্রকাশনার তারিখ",
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:"
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:",
"Uploaded": "আপলোড হয়েছে",
"Downloaded": "ডাউনলোড হয়েছে",
"Deleted": "মুছে ফেলা হয়েছে"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "རྩོམ་ཡིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Unable to load the word. Try searching directly on {{link}}.": "ཚིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།",
"Only for TTS:": "TTS ལ་ཁོ་ན།:"
"Only for TTS:": "TTS ལ་ཁོ་ན།:",
"Uploaded": "ཡར་སྐྱེལ་བྱས་ཟིན།",
"Downloaded": "མར་ལེན་བྱས་ཟིན།",
"Deleted": "བསུབས་ཟིན།"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Artikel kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Wort kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Date Published": "Veröffentlichungsdatum",
"Only for TTS:": "Nur für TTS:"
"Only for TTS:": "Nur für TTS:",
"Uploaded": "Hochgeladen",
"Downloaded": "Heruntergeladen",
"Deleted": "Gelöscht"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης του άρθρου. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης της λέξης. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Date Published": "Ημερομηνία δημοσίευσης",
"Only for TTS:": "Μόνο για TTS:"
"Only for TTS:": "Μόνο για TTS:",
"Uploaded": "Μεταφορτώθηκε",
"Downloaded": "Λήφθηκε",
"Deleted": "Διαγράφηκε"
}
@@ -856,5 +856,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "No se puede cargar el artículo. Intenta buscar directamente en {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "No se puede cargar la palabra. Intenta buscar directamente en {{link}}.",
"Date Published": "Fecha de publicación",
"Only for TTS:": "Solo para TTS:"
"Only for TTS:": "Solo para TTS:",
"Uploaded": "Subido",
"Downloaded": "Descargado",
"Deleted": "Eliminado"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "بارگذاری مقاله امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Unable to load the word. Try searching directly on {{link}}.": "بارگذاری کلمه امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Date Published": "تاریخ انتشار",
"Only for TTS:": "فقط برای TTS:"
"Only for TTS:": "فقط برای TTS:",
"Uploaded": "بارگذاری شد",
"Downloaded": "دانلود شد",
"Deleted": "حذف شد"
}
@@ -856,5 +856,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Impossible de charger l'article. Essayez de rechercher directement sur {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossible de charger le mot. Essayez de rechercher directement sur {{link}}.",
"Date Published": "Date de publication",
"Only for TTS:": "Uniquement pour TTS :"
"Only for TTS:": "Uniquement pour TTS :",
"Uploaded": "Téléversé",
"Downloaded": "Téléchargé",
"Deleted": "Supprimé"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "लेख लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Unable to load the word. Try searching directly on {{link}}.": "शब्द लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Date Published": "प्रकाशन तिथि",
"Only for TTS:": "केवल TTS के लिए:"
"Only for TTS:": "केवल TTS के लिए:",
"Uploaded": "अपलोड किया गया",
"Downloaded": "डाउनलोड किया गया",
"Deleted": "हटाया गया"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuat artikel. Coba cari langsung di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuat kata. Coba cari langsung di {{link}}.",
"Date Published": "Tanggal terbit",
"Only for TTS:": "Hanya untuk TTS:"
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Diunggah",
"Downloaded": "Diunduh",
"Deleted": "Dihapus"
}
@@ -856,5 +856,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Impossibile caricare l'articolo. Prova a cercare direttamente su {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossibile caricare la parola. Prova a cercare direttamente su {{link}}.",
"Date Published": "Data di pubblicazione",
"Only for TTS:": "Solo per TTS:"
"Only for TTS:": "Solo per TTS:",
"Uploaded": "Caricato",
"Downloaded": "Scaricato",
"Deleted": "Eliminato"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "記事を読み込めません。{{link}}で直接検索してみてください。",
"Unable to load the word. Try searching directly on {{link}}.": "単語を読み込めません。{{link}}で直接検索してみてください。",
"Date Published": "出版日",
"Only for TTS:": "TTSのみ:"
"Only for TTS:": "TTSのみ:",
"Uploaded": "アップロード済み",
"Downloaded": "ダウンロード済み",
"Deleted": "削除済み"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "문서를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Unable to load the word. Try searching directly on {{link}}.": "단어를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Date Published": "출판일",
"Only for TTS:": "TTS 전용:"
"Only for TTS:": "TTS 전용:",
"Uploaded": "업로드됨",
"Downloaded": "다운로드됨",
"Deleted": "삭제됨"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuatkan artikel. Cuba cari terus di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuatkan perkataan. Cuba cari terus di {{link}}.",
"Date Published": "Tarikh diterbitkan",
"Only for TTS:": "Hanya untuk TTS:"
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Dimuat naik",
"Downloaded": "Dimuat turun",
"Deleted": "Dipadam"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Kan het artikel niet laden. Probeer direct te zoeken op {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan het woord niet laden. Probeer direct te zoeken op {{link}}.",
"Date Published": "Publicatiedatum",
"Only for TTS:": "Alleen voor TTS:"
"Only for TTS:": "Alleen voor TTS:",
"Uploaded": "Geüpload",
"Downloaded": "Gedownload",
"Deleted": "Verwijderd"
}
@@ -866,5 +866,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Nie można załadować artykułu. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Nie można załadować słowa. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Date Published": "Data wydania",
"Only for TTS:": "Tylko dla TTS:"
"Only for TTS:": "Tylko dla TTS:",
"Uploaded": "Przesłano",
"Downloaded": "Pobrano",
"Deleted": "Usunięto"
}
@@ -856,5 +856,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Não foi possível carregar o artigo. Tente pesquisar diretamente em {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Não foi possível carregar a palavra. Tente pesquisar diretamente em {{link}}.",
"Date Published": "Data de publicação",
"Only for TTS:": "Apenas para TTS:"
"Only for TTS:": "Apenas para TTS:",
"Uploaded": "Enviado",
"Downloaded": "Baixado",
"Deleted": "Excluído"
}
@@ -866,5 +866,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Не удалось загрузить статью. Попробуйте искать напрямую на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не удалось загрузить слово. Попробуйте искать напрямую на {{link}}.",
"Date Published": "Дата публикации",
"Only for TTS:": "Только для TTS:"
"Only for TTS:": "Только для TTS:",
"Uploaded": "Загружено",
"Downloaded": "Скачано",
"Deleted": "Удалено"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "ලිපිය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Unable to load the word. Try searching directly on {{link}}.": "වචනය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Date Published": "ප්‍රකාශන දිනය",
"Only for TTS:": "TTS සඳහා පමණක්:"
"Only for TTS:": "TTS සඳහා පමණක්:",
"Uploaded": "උඩුගත කරන ලදී",
"Downloaded": "බාගත කරන ලදී",
"Deleted": "මකා දමන ලදී"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Kan inte ladda artikeln. Försök söka direkt på {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan inte ladda ordet. Försök söka direkt på {{link}}.",
"Date Published": "Publiceringsdatum",
"Only for TTS:": "Endast för TTS:"
"Only for TTS:": "Endast för TTS:",
"Uploaded": "Uppladdad",
"Downloaded": "Nedladdad",
"Deleted": "Borttagen"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "கட்டுரையை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Unable to load the word. Try searching directly on {{link}}.": "சொல்லை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Date Published": "வெளியீட்டு தேதி",
"Only for TTS:": "TTS க்கு மட்டும்:"
"Only for TTS:": "TTS க்கு மட்டும்:",
"Uploaded": "பதிவேற்றப்பட்டது",
"Downloaded": "பதிவிறக்கப்பட்டது",
"Deleted": "நீக்கப்பட்டது"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "ไม่สามารถโหลดบทความได้ ลองค้นหาโดยตรงที่ {{link}}",
"Unable to load the word. Try searching directly on {{link}}.": "ไม่สามารถโหลดคำได้ ลองค้นหาโดยตรงที่ {{link}}",
"Date Published": "วันที่เผยแพร่",
"Only for TTS:": "สำหรับ TTS เท่านั้น:"
"Only for TTS:": "สำหรับ TTS เท่านั้น:",
"Uploaded": "อัปโหลดแล้ว",
"Downloaded": "ดาวน์โหลดแล้ว",
"Deleted": "ลบแล้ว"
}
@@ -846,5 +846,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Makale yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Unable to load the word. Try searching directly on {{link}}.": "Kelime yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Date Published": "Yayın tarihi",
"Only for TTS:": "Sadece TTS için:"
"Only for TTS:": "Sadece TTS için:",
"Uploaded": "Yüklendi",
"Downloaded": "İndirildi",
"Deleted": "Silindi"
}
@@ -866,5 +866,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Не вдалося завантажити статтю. Спробуйте шукати безпосередньо на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не вдалося завантажити слово. Спробуйте шукати безпосередньо на {{link}}.",
"Date Published": "Дата публікації",
"Only for TTS:": "Тільки для TTS:"
"Only for TTS:": "Тільки для TTS:",
"Uploaded": "Завантажено",
"Downloaded": "Звантажено",
"Deleted": "Видалено"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "Không thể tải bài viết. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Không thể tải từ. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Date Published": "Ngày xuất bản",
"Only for TTS:": "Chỉ dành cho TTS:"
"Only for TTS:": "Chỉ dành cho TTS:",
"Uploaded": "Đã tải lên",
"Downloaded": "Đã tải xuống",
"Deleted": "Đã xóa"
}
@@ -836,5 +836,8 @@
"Error": "错误",
"Unable to load the article. Try searching directly on {{link}}.": "无法加载文章。请直接在 {{link}} 上搜索。",
"Unable to load the word. Try searching directly on {{link}}.": "无法加载词汇。请直接在 {{link}} 上搜索。",
"Date Published": "出版日期"
"Date Published": "出版日期",
"Uploaded": "已上传",
"Downloaded": "已下载",
"Deleted": "已删除"
}
@@ -836,5 +836,8 @@
"Unable to load the article. Try searching directly on {{link}}.": "無法載入文章。請直接在 {{link}} 上搜尋。",
"Unable to load the word. Try searching directly on {{link}}.": "無法載入詞彙。請直接在 {{link}} 上搜尋。",
"Date Published": "出版日期",
"Only for TTS:": "僅用於TTS"
"Only for TTS:": "僅用於TTS",
"Uploaded": "已上傳",
"Downloaded": "已下載",
"Deleted": "已刪除"
}
@@ -10,7 +10,6 @@ import { useThemeStore } from '@/store/themeStore';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTransferStore } from '@/store/transferStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
@@ -24,7 +23,6 @@ import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import SelectModeActions from './SelectModeActions';
import GroupingModal from './GroupingModal';
import TransferQueuePanel from './TransferQueuePanel';
interface BookshelfProps {
libraryBooks: Book[];
@@ -79,6 +77,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const abortDeletionRef = useRef(false);
const isImportingBook = useRef(false);
const iconSize15 = useResponsiveSize(15);
const autofocusRef = useAutoFocus<HTMLDivElement>();
@@ -87,8 +86,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const { getGroupName } = useLibraryStore();
const { isTransferQueueOpen } = useTransferStore();
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const updateUrlParams = useCallback(
@@ -223,9 +220,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const confirmDelete = async () => {
const books = getBooksToDelete();
const concurrency = 4;
const concurrency = 20;
for (let i = 0; i < books.length; i += concurrency) {
if (abortDeletionRef.current) {
abortDeletionRef.current = false;
break;
}
const batch = books.slice(i, i + concurrency);
await Promise.all(batch.map((book) => handleBookDelete(book, false)));
}
@@ -383,11 +384,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
/>
</ModalPortal>
)}
{isTransferQueueOpen && (
<ModalPortal>
<TransferQueuePanel />
</ModalPortal>
)}
{showDeleteAlert && (
<div
className={clsx('delete-alert fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
@@ -401,6 +397,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
count: getBooksToDelete().length,
})}
onCancel={() => {
abortDeletionRef.current = true;
setShowDeleteAlert(false);
setShowSelectModeActions(true);
}}
@@ -52,7 +52,7 @@ const formatDateTime = (timestamp: number): string => {
const StatusIcon: React.FC<{
status: TransferStatus;
type: 'upload' | 'download';
type: 'upload' | 'download' | 'delete';
size: number;
}> = ({ status, type, size }) => {
switch (status) {
@@ -67,6 +67,8 @@ const StatusIcon: React.FC<{
default:
return type === 'upload' ? (
<MdCloudUpload className='text-primary' size={size} />
) : type === 'delete' ? (
<MdDeleteSweep className='text-primary' size={size} />
) : (
<MdCloudDownload className='text-primary' size={size} />
);
@@ -81,6 +83,12 @@ const TransferItemRow: React.FC<{
}> = ({ transfer, onCancel, onRetry, iconSize }) => {
const _ = useTranslation();
const completedLabel = {
upload: _('Uploaded'),
download: _('Downloaded'),
delete: _('Deleted'),
};
return (
<div className='hover:bg-base-200 flex items-center gap-3 rounded-lg p-3'>
<StatusIcon status={transfer.status} type={transfer.type} size={iconSize} />
@@ -100,7 +108,7 @@ const TransferItemRow: React.FC<{
{transfer.status === 'failed' && (
<span className='text-error'>{transfer.error || _('Failed')}</span>
)}
{transfer.status === 'completed' && _('Completed')}
{transfer.status === 'completed' && (completedLabel[transfer.type] || _('Completed'))}
{transfer.status === 'cancelled' && _('Cancelled')}
{' · '}
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
@@ -167,9 +175,7 @@ const TransferQueuePanel: React.FC = () => {
const onClose = () => setIsOpen(false);
const divRef = useKeyDownActions({ onCancel: onClose, onConfirm: onClose });
const booksToUpload = getVisibleLibrary().filter(
(book) => book.downloadedAt && !book.uploadedAt,
);
const booksToUpload = getVisibleLibrary().filter((book) => book.downloadedAt && !book.uploadedAt);
const booksToDownload = getVisibleLibrary().filter(
(book) => book.uploadedAt && !book.downloadedAt,
);
@@ -11,7 +11,7 @@ export const useBooksSync = () => {
const { user } = useAuth();
const { appService } = useEnv();
const { library, isSyncing, setLibrary, setIsSyncing, setSyncProgress } = useLibraryStore();
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const { useSyncInited, syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const isPullingRef = useRef(false);
const getNewBooks = useCallback(() => {
@@ -64,8 +64,7 @@ export const useBooksSync = () => {
return;
}
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [library, handleAutoSync]);
}, [user, library, handleAutoSync]);
const pushLibrary = useCallback(async () => {
if (!user) return;
@@ -76,9 +75,9 @@ export const useBooksSync = () => {
}, [user, syncBooks, getNewBooks]);
useEffect(() => {
if (!user) return;
if (!user || !useSyncInited) return;
pullLibrary();
}, [user, pullLibrary]);
}, [user, useSyncInited, pullLibrary]);
const updateLibrary = async () => {
if (isSyncing) return;
@@ -107,13 +106,16 @@ export const useBooksSync = () => {
return oldBook;
};
const oldBooksBatchSize = 20;
const oldBooksBatchSize = 100;
for (let i = 0; i < oldBooksNeedsDownload.length; i += oldBooksBatchSize) {
const batch = oldBooksNeedsDownload.slice(i, i + oldBooksBatchSize);
await appService?.downloadBookCovers(batch);
}
const updatedLibrary = await Promise.all(library.map(processOldBook));
setLibrary(updatedLibrary);
appService?.saveLibraryBooks(updatedLibrary);
const bookHashesInLibrary = new Set(updatedLibrary.map((book) => book.hash));
const newBooks = syncedBooks.filter(
(newBook) =>
@@ -140,7 +142,8 @@ export const useBooksSync = () => {
setLibrary([...updatedLibrary]);
appService?.saveLibraryBooks(updatedLibrary);
}
setLibrary(updatedLibrary);
} catch (err) {
console.error('Error updating new books:', err);
} finally {
if (newBooks.length > 0) {
setIsSyncing(false);
+32 -5
View File
@@ -36,6 +36,7 @@ import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTransferStore } from '@/store/transferStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
@@ -65,6 +66,8 @@ import Bookshelf from './components/Bookshelf';
import useShortcuts from '@/hooks/useShortcuts';
import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal';
import TransferQueuePanel from './components/TransferQueuePanel';
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
@@ -95,6 +98,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { clearBookData } = useBookDataStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const { isTransferQueueOpen } = useTransferStore();
const [showCatalogManager, setShowCatalogManager] = useState(
searchParams?.get('opds') === 'true',
);
@@ -558,14 +562,32 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
cloud: _('Failed to delete cloud backup of the book: {{title}}', { title: book.title }),
local: _('Failed to delete local copy of the book: {{title}}', { title: book.title }),
};
try {
await appService?.deleteBook(book, deleteAction);
await updateBook(envConfig, book);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
// Handle local deletion immediately
if (deleteAction === 'local' || deleteAction === 'both') {
await appService?.deleteBook(book, 'local');
if (deleteAction === 'both') {
book.deletedAt = Date.now();
book.downloadedAt = null;
book.coverDownloadedAt = null;
}
await updateBook(envConfig, book);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
}
// Queue cloud deletion
if (deleteAction === 'cloud' || deleteAction === 'both') {
const transferId = transferManager.queueDelete(book, 1, true);
if (!transferId) {
throw new Error('Failed to queue cloud deletion');
}
}
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
timeout: 1000,
message: deletionMessages[deleteAction],
});
return true;
@@ -849,6 +871,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleBookMetadataUpdate={handleUpdateMetadata}
/>
)}
{isTransferQueueOpen && (
<ModalPortal>
<TransferQueuePanel />
</ModalPortal>
)}
<AboutWindow />
<UpdaterWindow />
<MigrateDataWindow />
@@ -3,7 +3,7 @@ import React from 'react';
import Image from 'next/image';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
@@ -12,10 +12,11 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
@@ -27,8 +28,9 @@ interface BookMenuProps {
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { bookKeys, getViewSettings, setViewSettings } = useReaderStore();
const { bookKeys, recreateViewer, getViewSettings, setViewSettings } = useReaderStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
const { sideBarBookKey } = useSidebarStore();
@@ -64,8 +66,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings.sortedTOC = !isSortedTOC;
setViewSettings(sideBarBookKey, viewSettings);
recreateViewer(envConfig, sideBarBookKey);
}
setTimeout(() => window.location.reload(), 100);
};
const handleSetParallel = () => {
setParallel(bookKeys);
+10 -3
View File
@@ -10,6 +10,7 @@ const Alert: React.FC<{
onConfirm: () => void;
}> = ({ title, message, onCancel, onConfirm }) => {
const _ = useTranslation();
const [isProcessing, setIsProcessing] = React.useState(false);
const divRef = useKeyDownActions({ onCancel, onConfirm });
return (
@@ -39,15 +40,21 @@ const Alert: React.FC<{
></path>
</svg>
<div className='flex flex-col gap-y-2'>
<h3 className='text-start text-sm sm:text-center'>{title}</h3>
<div className='text-start text-xs sm:text-center'>{message}</div>
<h3 className='text-start text-sm font-medium sm:text-center'>{title}</h3>
<div className='text-start text-sm sm:text-center'>{message}</div>
</div>
</div>
<div className='buttons flex flex-wrap items-center justify-end gap-2 self-end sm:max-w-[20vw] sm:self-center'>
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
<button
className={clsx('btn btn-sm btn-warning', { 'btn-disabled': isProcessing })}
onClick={() => {
setIsProcessing(true);
onConfirm();
}}
>
{_('Confirm')}
</button>
</div>
+2 -1
View File
@@ -181,7 +181,7 @@ export function useSync(bookKey?: string) {
await pushChanges({ books });
}
if (op === 'pull' || op === 'both') {
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
await pullChanges('books', lastSyncedAtBooks + 1, setLastSyncedAtBooks, setSyncingBooks);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -258,6 +258,7 @@ export function useSync(bookKey?: string) {
lastSyncedAtBooks,
lastSyncedAtNotes,
lastSyncedAtConfigs,
useSyncInited: lastSyncedAtInited,
pullChanges,
pushChanges,
syncBooks,
+2
View File
@@ -18,6 +18,8 @@ export interface SyncResult {
configs: BookConfigRecord[] | null;
}
export type SyncRecord = BookRecord & BookConfigRecord & BookNoteRecord;
export interface SyncData {
books?: Partial<BookRecord>[];
notes?: Partial<BookNoteRecord>[];
+39 -16
View File
@@ -7,7 +7,7 @@ import { transformBookConfigToDB } from '@/utils/transform';
import { transformBookNoteToDB } from '@/utils/transform';
import { transformBookToDB } from '@/utils/transform';
import { runMiddleware, corsAllMethods } from '@/utils/cors';
import { SyncData, SyncResult, SyncType } from '@/libs/sync';
import { SyncData, SyncRecord, SyncResult, SyncType } from '@/libs/sync';
import { validateUserAndToken } from '@/utils/access';
import { DBBook, DBBookConfig } from '@/types/records';
@@ -60,21 +60,44 @@ export async function GET(req: NextRequest) {
};
const queryTables = async (table: TableName, dedupeKeys?: (keyof BookDataRecord)[]) => {
let query = supabase.from(table).select('*').eq('user_id', user.id);
if (bookParam && metaHashParam) {
query.or(`book_hash.eq.${bookParam},meta_hash.eq.${metaHashParam}`);
} else if (bookParam) {
query.eq('book_hash', bookParam);
} else if (metaHashParam) {
query.eq('meta_hash', metaHashParam);
const PAGE_SIZE = 1000;
let allRecords: SyncRecord[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
let query = supabase
.from(table)
.select('*')
.eq('user_id', user.id)
.range(offset, offset + PAGE_SIZE - 1);
if (bookParam && metaHashParam) {
query = query.or(`book_hash.eq.${bookParam},meta_hash.eq.${metaHashParam}`);
} else if (bookParam) {
query = query.eq('book_hash', bookParam);
} else if (metaHashParam) {
query = query.eq('meta_hash', metaHashParam);
}
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
query = query.order('updated_at', { ascending: false });
console.log('Querying table:', table, 'since:', sinceIso, 'offset:', offset);
const { data, error } = await query;
if (error) throw { table, error } as DBError;
if (data && data.length > 0) {
allRecords = allRecords.concat(data);
offset += PAGE_SIZE;
hasMore = data.length === PAGE_SIZE;
} else {
hasMore = false;
}
}
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
query = query.order('updated_at', { ascending: false });
console.log('Querying table:', table, 'since:', sinceIso);
const { data, error } = await query;
if (error) throw { table, error } as DBError;
let records = data;
let records = allRecords;
if (dedupeKeys && dedupeKeys.length > 0) {
const seen = new Set<string>();
records = records.filter((rec) => {
@@ -167,8 +190,8 @@ export async function POST(req: NextRequest) {
// Fetch existing records for this batch
const orConditions = matchConditions
.map((cond) => {
const parts = Object.entries(cond).map(([key, val]) => `and(${key}.eq.${val})`);
return parts.join(',');
const parts = Object.entries(cond).map(([key, val]) => `${key}.eq.${val}`);
return `and(${parts.join(',')})`;
})
.join(',');
@@ -94,6 +94,25 @@ class TransferManager {
return transferId;
}
queueDelete(book: Book, priority: number = 10, isBackground: boolean = false): string | null {
if (!this.isReady()) {
console.warn('TransferManager not initialized');
return null;
}
const store = useTransferStore.getState();
const existing = store.getTransferByBookHash(book.hash, 'delete');
if (existing) {
return existing.id;
}
const transferId = store.addTransfer(book.hash, book.title, 'delete', priority, isBackground);
this.persistQueue();
this.processQueue();
return transferId;
}
queueBatchUploads(books: Book[], priority: number = 10): string[] {
return books
.map((book) => this.queueUpload(book, priority))
@@ -221,23 +240,31 @@ class TransferManager {
if (transfer.type === 'upload') {
await this.appService.uploadBook(book, progressHandler);
book.uploadedAt = Date.now();
} else {
await this.updateBook(book);
} else if (transfer.type === 'download') {
await this.appService.downloadBook(book, false, false, progressHandler);
book.downloadedAt = Date.now();
await this.updateBook(book);
} else if (transfer.type === 'delete') {
await this.appService.deleteBook(book, 'cloud');
await this.updateBook(book);
}
await this.updateBook(book);
useTransferStore.getState().setTransferStatus(transfer.id, 'completed');
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message:
transfer.type === 'upload'
? _('Book uploaded: {{title}}', { title: transfer.bookTitle })
: _('Book downloaded: {{title}}', { title: transfer.bookTitle }),
});
const successMessages = {
upload: _('Book uploaded: {{title}}', { title: transfer.bookTitle }),
download: _('Book downloaded: {{title}}', { title: transfer.bookTitle }),
delete: _('Deleted cloud backup of the book: {{title}}', { title: transfer.bookTitle }),
};
if (!transfer.isBackground) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: successMessages[transfer.type],
});
}
} catch (error) {
if (abortController.signal.aborted) {
// Already cancelled, don't update status
@@ -273,16 +300,17 @@ class TransferManager {
message: _('Insufficient storage quota'),
});
} else {
const errorMessages = {
upload: _('Failed to upload book: {{title}}', { title: transfer.bookTitle }),
download: _('Failed to download book: {{title}}', { title: transfer.bookTitle }),
delete: _('Failed to delete cloud backup of the book: {{title}}', {
title: transfer.bookTitle,
}),
};
eventDispatcher.dispatch('toast', {
type: 'error',
message:
transfer.type === 'upload'
? _('Failed to upload book: {{title}}', {
title: transfer.bookTitle,
})
: _('Failed to download book: {{title}}', {
title: transfer.bookTitle,
}),
message: errorMessages[transfer.type],
});
}
+7 -3
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
export type TransferType = 'upload' | 'download';
export type TransferType = 'upload' | 'download' | 'delete';
export type TransferStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
export interface TransferItem {
@@ -20,6 +20,7 @@ export interface TransferItem {
startedAt?: number;
completedAt?: number;
priority: number; // Lower = higher priority
isBackground: boolean;
}
interface TransferState {
@@ -38,6 +39,7 @@ interface TransferState {
bookTitle: string,
type: TransferType,
priority?: number,
isBackground?: boolean,
) => string;
removeTransfer: (transferId: string) => void;
updateTransferProgress: (
@@ -92,7 +94,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
setIsTransferQueueOpen: (isOpen) => set({ isTransferQueueOpen: isOpen }),
addTransfer: (bookHash, bookTitle, type, priority = 10) => {
addTransfer: (bookHash, bookTitle, type, priority = 10, isBackground = false) => {
const id = generateTransferId();
const transfer: TransferItem = {
id,
@@ -108,6 +110,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
maxRetries: 3,
createdAt: Date.now(),
priority,
isBackground,
};
set((state) => ({
@@ -257,7 +260,8 @@ export const useTransferStore = create<TransferState>((set, get) => ({
getTransferByBookHash: (bookHash, type) => {
return Object.values(get().transfers).find(
(t) => t.bookHash === bookHash && t.type === type && ['pending', 'in_progress'].includes(t.status),
(t) =>
t.bookHash === bookHash && t.type === type && ['pending', 'in_progress'].includes(t.status),
);
},