forked from akai/readest
feat(sync): aggregate books with metadata hash for progress and annotation sync (#2062)
This commit is contained in:
@@ -46,7 +46,7 @@ export default function ResetPasswordPage() {
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='w-full max-w-md p-8'>
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
view='update_password'
|
||||
|
||||
@@ -47,19 +47,16 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
|
||||
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role='none'
|
||||
className={clsx(
|
||||
'book-item flex',
|
||||
mode === 'grid' && 'h-full flex-col justify-end',
|
||||
mode === 'list' && 'h-28 flex-row gap-4 overflow-hidden',
|
||||
appService?.hasContextMenu ? 'cursor-pointer' : '',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -119,12 +116,10 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
<button
|
||||
aria-label={_('Show Book Details')}
|
||||
className='show-detail-button -m-2 p-2 sm:opacity-0 sm:group-hover:opacity-100'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onClick={() => showBookDetailsModal(book)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => {
|
||||
showBookDetailsModal(book);
|
||||
}}
|
||||
>
|
||||
<div className='pt-[2px] sm:pt-[1px]'>
|
||||
<LiaInfoCircleSolid size={iconSize15} />
|
||||
@@ -149,11 +144,7 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
(!book.uploadedAt || (book.uploadedAt && !book.downloadedAt)) && (
|
||||
<button
|
||||
className='show-cloud-button -m-2 p-2'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => {
|
||||
if (!user) {
|
||||
navigateToLogin(router);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -105,8 +105,6 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
const [keyboardFocused, setKeyboardFocused] = useState(false);
|
||||
|
||||
const showBookDetailsModal = useCallback(async (book: Book) => {
|
||||
if (await makeBookAvailable(book)) {
|
||||
handleShowDetailsBook(book);
|
||||
@@ -295,7 +293,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
[itemSelected],
|
||||
);
|
||||
|
||||
const { pressing, hasPointerEventsRef, handlers } = useLongPress(
|
||||
const { pressing, handlers } = useLongPress(
|
||||
{
|
||||
onLongPress: () => {
|
||||
handleSelectItem();
|
||||
@@ -325,24 +323,13 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!hasPointerEventsRef.current) {
|
||||
setKeyboardFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setKeyboardFocused(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
<div
|
||||
className={clsx(
|
||||
'group',
|
||||
'visible-focus-inset-2 group',
|
||||
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
|
||||
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
|
||||
keyboardFocused && 'focus-inset-2',
|
||||
appService?.isMobileApp && 'no-context-menu',
|
||||
pressing && mode === 'grid' ? 'scale-95' : 'scale-100',
|
||||
)}
|
||||
@@ -353,8 +340,6 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...handlers}
|
||||
>
|
||||
<div className='flex-grow'>
|
||||
|
||||
@@ -185,7 +185,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
{showFooter && (
|
||||
<ProgressInfoView
|
||||
bookKey={bookKey}
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
timeinfo={timeinfo}
|
||||
|
||||
@@ -5,11 +5,11 @@ import { PageInfo, TimeInfo } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { formatReadingProgress } from '@/utils/progress';
|
||||
|
||||
interface PageInfoProps {
|
||||
bookKey: string;
|
||||
bookFormat: string;
|
||||
section?: PageInfo;
|
||||
pageinfo?: PageInfo;
|
||||
timeinfo?: TimeInfo;
|
||||
@@ -20,7 +20,6 @@ interface PageInfoProps {
|
||||
|
||||
const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
bookKey,
|
||||
bookFormat,
|
||||
section,
|
||||
pageinfo,
|
||||
timeinfo,
|
||||
@@ -30,8 +29,10 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
|
||||
@@ -46,8 +47,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
: '{current} / {total}'
|
||||
: '{percent}%';
|
||||
|
||||
const pageProgress = ['PDF', 'CBZ'].includes(bookFormat) ? section : pageinfo;
|
||||
const progressInfo = ['PDF', 'CBZ'].includes(bookFormat)
|
||||
const pageProgress = bookData?.isFixedLayout ? section : pageinfo;
|
||||
const progressInfo = bookData?.isFixedLayout
|
||||
? formatReadingProgress(pageProgress?.current, pageProgress?.total, formatTemplate)
|
||||
: formatReadingProgress(pageProgress?.current, pageProgress?.total, formatTemplate);
|
||||
|
||||
|
||||
@@ -41,10 +41,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
const { user } = useAuth();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore();
|
||||
const config = getConfig(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const viewState = getViewState(bookKey);
|
||||
|
||||
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
|
||||
@@ -259,6 +260,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
: _('Never synced')
|
||||
}
|
||||
Icon={user ? MdSync : MdSyncProblem}
|
||||
iconClassName={user && viewState?.syncing ? 'animate-reverse-spin' : ''}
|
||||
onClick={handleSync}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isWebAppPlatform } from '@/services/environment';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
|
||||
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
@@ -104,7 +105,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
>
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
{getVisibleLibrary()
|
||||
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
|
||||
.filter((book) => !FIXED_LAYOUT_FORMATS.has(book.format))
|
||||
.filter((book) => !!book.downloadedAt)
|
||||
.slice(0, 20)
|
||||
.map((book) => (
|
||||
|
||||
@@ -9,20 +9,24 @@ import { debounce } from '@/utils/debounce';
|
||||
export const useNotesSync = (bookKey: string) => {
|
||||
const { user } = useAuth();
|
||||
const { syncedNotes, syncNotes, lastSyncedAtNotes } = useSync(bookKey);
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const { getConfig, setConfig, getBookData } = useBookDataStore();
|
||||
|
||||
const config = getConfig(bookKey);
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
|
||||
const getNewNotes = () => {
|
||||
const config = getConfig(bookKey);
|
||||
if (!config?.location || !user) return [];
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config?.location || !book || !user) return [];
|
||||
|
||||
const metaHash = book.metaHash;
|
||||
const bookNotes = config.booknotes ?? [];
|
||||
const newNotes = bookNotes.filter(
|
||||
(note) => lastSyncedAtNotes < note.updatedAt || lastSyncedAtNotes < (note.deletedAt ?? 0),
|
||||
);
|
||||
newNotes.forEach((note) => {
|
||||
note.bookHash = bookHash;
|
||||
note.metaHash = metaHash;
|
||||
});
|
||||
return newNotes;
|
||||
};
|
||||
@@ -30,8 +34,9 @@ export const useNotesSync = (bookKey: string) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleAutoSync = useCallback(
|
||||
debounce(() => {
|
||||
const book = getBookData(bookKey)?.book;
|
||||
const newNotes = getNewNotes();
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
syncNotes(newNotes, bookHash, book?.metaHash, 'both');
|
||||
}, SYNC_NOTES_INTERVAL_SEC * 1000),
|
||||
[syncNotes],
|
||||
);
|
||||
@@ -40,7 +45,7 @@ export const useNotesSync = (bookKey: string) => {
|
||||
if (!config?.location || !user) return;
|
||||
handleAutoSync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config, handleAutoSync]);
|
||||
}, [config?.booknotes, handleAutoSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const processNewNote = (note: BookNote) => {
|
||||
@@ -60,7 +65,10 @@ export const useNotesSync = (bookKey: string) => {
|
||||
return note;
|
||||
};
|
||||
if (syncedNotes?.length && config) {
|
||||
const newNotes = syncedNotes.filter((note) => note.bookHash === bookHash);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
const newNotes = syncedNotes.filter(
|
||||
(note) => note.bookHash === bookHash || note.metaHash === book?.metaHash,
|
||||
);
|
||||
if (!newNotes.length) return;
|
||||
const oldNotes = config.booknotes ?? [];
|
||||
const mergedNotes = [
|
||||
|
||||
@@ -16,31 +16,34 @@ import { getCFIFromXPointer, getXPointerFromCFI, normalizeProgressXPointer } fro
|
||||
export const useProgressSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
const { getConfig, setConfig, getBookData } = useBookDataStore();
|
||||
const { getView, getProgress } = useReaderStore();
|
||||
const { getView, getProgress, setHoveredBookKey } = useReaderStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { syncedConfigs, syncConfigs } = useSync(bookKey);
|
||||
const { user } = useAuth();
|
||||
const config = getConfig(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
|
||||
const configPulled = useRef(false);
|
||||
const hasPulledConfigOnce = useRef(false);
|
||||
|
||||
const pushConfig = (bookKey: string, config: BookConfig | null) => {
|
||||
if (!config || !user) return;
|
||||
const pushConfig = async (bookKey: string, config: BookConfig | null) => {
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config || !book || !user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
const newConfig = { ...config, bookHash };
|
||||
const metaHash = book.metaHash;
|
||||
const newConfig = { ...config, bookHash, metaHash };
|
||||
const compressedConfig = JSON.parse(
|
||||
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
|
||||
);
|
||||
delete compressedConfig.booknotes;
|
||||
syncConfigs([compressedConfig], bookHash, 'push');
|
||||
await syncConfigs([compressedConfig], bookHash, metaHash, 'push');
|
||||
};
|
||||
|
||||
const pullConfig = (bookKey: string) => {
|
||||
if (!user) return;
|
||||
const pullConfig = async (bookKey: string) => {
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!user || !book) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
syncConfigs([], bookHash, 'pull');
|
||||
const metaHash = book.metaHash;
|
||||
await syncConfigs([], bookHash, metaHash, 'pull');
|
||||
};
|
||||
|
||||
const syncConfig = async () => {
|
||||
@@ -66,10 +69,11 @@ export const useProgressSync = (bookKey: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncBookProgress = (event: CustomEvent) => {
|
||||
const handleSyncBookProgress = async (event: CustomEvent) => {
|
||||
const { bookKey: syncBookKey } = event.detail;
|
||||
if (syncBookKey === bookKey) {
|
||||
syncConfig();
|
||||
configPulled.current = false;
|
||||
await pullConfig(bookKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,9 +109,16 @@ export const useProgressSync = (bookKey: string) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
const applyRemoteProgress = useCallback(async () => {
|
||||
if (!syncedConfigs || syncedConfigs.length === 0) return;
|
||||
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
|
||||
const applyRemoteProgress = async (syncedConfigs: BookConfig[]) => {
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!syncedConfigs || syncedConfigs.length === 0 || !config || !book) return;
|
||||
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
const metaHash = book.metaHash;
|
||||
const syncedConfig = syncedConfigs.filter(
|
||||
(c) => c.bookHash === bookHash || c.metaHash === metaHash,
|
||||
)[0];
|
||||
if (syncedConfig) {
|
||||
const configCFI = config?.location;
|
||||
let remoteCFILocation = syncedConfig.location;
|
||||
@@ -132,6 +143,7 @@ export const useProgressSync = (bookKey: string) => {
|
||||
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
|
||||
if (view) {
|
||||
view.goTo(remoteCFILocation);
|
||||
setHoveredBookKey(null);
|
||||
eventDispatcher.dispatch('hint', {
|
||||
bookKey,
|
||||
message: _('Reading Progress Synced'),
|
||||
@@ -140,17 +152,16 @@ export const useProgressSync = (bookKey: string) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedConfigs, config?.location]);
|
||||
};
|
||||
|
||||
// Pull: proccess the pulled progress
|
||||
useEffect(() => {
|
||||
if (!configPulled.current && syncedConfigs) {
|
||||
configPulled.current = true;
|
||||
applyRemoteProgress().catch((error) => {
|
||||
applyRemoteProgress(syncedConfigs).catch((error) => {
|
||||
console.error('Failed to apply remote progress', error);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [applyRemoteProgress]);
|
||||
}, [syncedConfigs]);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ interface MenuItemProps {
|
||||
noIcon?: boolean;
|
||||
transient?: boolean;
|
||||
Icon?: React.ReactNode | IconType;
|
||||
iconClassName?: string;
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
@@ -34,6 +35,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
noIcon = false,
|
||||
transient = false,
|
||||
Icon,
|
||||
iconClassName,
|
||||
children,
|
||||
onClick,
|
||||
setIsDropdownOpen,
|
||||
@@ -57,7 +59,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
{typeof IconType === 'function' ? (
|
||||
<IconType
|
||||
className={disabled ? 'text-gray-400' : 'text-base-content'}
|
||||
className={clsx(disabled ? 'text-gray-400' : 'text-base-content', iconClassName)}
|
||||
size={iconSize}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -11,7 +11,6 @@ interface UseLongPressOptions {
|
||||
|
||||
interface UseLongPressResult {
|
||||
pressing: boolean;
|
||||
hasPointerEventsRef: React.MutableRefObject<boolean>;
|
||||
handlers: {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerUp: (e: React.PointerEvent) => void;
|
||||
@@ -107,7 +106,7 @@ export const useLongPress = (
|
||||
|
||||
pointerEventTimeoutRef.current = setTimeout(() => {
|
||||
hasPointerEventsRef.current = false;
|
||||
}, 100);
|
||||
}, 200);
|
||||
},
|
||||
[onTap, moveThreshold, reset],
|
||||
);
|
||||
@@ -120,12 +119,13 @@ export const useLongPress = (
|
||||
|
||||
pointerEventTimeoutRef.current = setTimeout(() => {
|
||||
hasPointerEventsRef.current = false;
|
||||
}, 100);
|
||||
}, 200);
|
||||
},
|
||||
[onCancel, reset],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// This is only for aria activation, if the user has used pointer events, we ignore the click event
|
||||
if (!hasPointerEventsRef.current) {
|
||||
onTap?.();
|
||||
}
|
||||
@@ -152,7 +152,6 @@ export const useLongPress = (
|
||||
|
||||
return {
|
||||
pressing,
|
||||
hasPointerEventsRef,
|
||||
handlers: {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerUp: handlePointerUp,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { transformBookFromDB } from '@/utils/transform';
|
||||
import { DBBook, DBBookConfig, DBBookNote } from '@/types/records';
|
||||
import { Book, BookConfig, BookDataRecord, BookNote } from '@/types/book';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
||||
const transformsFromDB = {
|
||||
books: transformBookFromDB,
|
||||
@@ -38,6 +39,7 @@ export function useSync(bookKey?: string) {
|
||||
const router = useRouter();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const { setIsSyncing } = useReaderStore();
|
||||
const config = bookKey ? getConfig(bookKey) : null;
|
||||
|
||||
const [syncingBooks, setSyncingBooks] = useState(false);
|
||||
@@ -62,6 +64,10 @@ export function useSync(bookKey?: string) {
|
||||
|
||||
const { syncClient } = useSyncContext();
|
||||
|
||||
useEffect(() => {
|
||||
setIsSyncing(bookKey || '', syncing);
|
||||
}, [bookKey, syncing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.version) return;
|
||||
if (bookKey && !config?.location) return;
|
||||
@@ -85,12 +91,13 @@ export function useSync(bookKey?: string) {
|
||||
setLastSyncedAt: React.Dispatch<React.SetStateAction<number>>,
|
||||
setSyncing: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
bookId?: string,
|
||||
metaHash?: string,
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setSyncError(null);
|
||||
|
||||
try {
|
||||
const result = await syncClient.pullChanges(since, type, bookId);
|
||||
const result = await syncClient.pullChanges(since, type, bookId, metaHash);
|
||||
setSyncResult({ ...syncResult, [type]: result[type] });
|
||||
const records = result[type];
|
||||
if (!records?.length) return;
|
||||
@@ -175,7 +182,7 @@ export function useSync(bookKey?: string) {
|
||||
);
|
||||
|
||||
const syncConfigs = useCallback(
|
||||
async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
|
||||
async (bookConfigs?: BookConfig[], bookId?: string, metaHash?: string, op: SyncOp = 'both') => {
|
||||
if (!bookId && !lastSyncedAtInited) return;
|
||||
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
|
||||
await pushChanges({ configs: bookConfigs });
|
||||
@@ -187,6 +194,7 @@ export function useSync(bookKey?: string) {
|
||||
setLastSyncedAtConfigs,
|
||||
setSyncingConfigs,
|
||||
bookId,
|
||||
metaHash,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -195,7 +203,7 @@ export function useSync(bookKey?: string) {
|
||||
);
|
||||
|
||||
const syncNotes = useCallback(
|
||||
async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
|
||||
async (bookNotes?: BookNote[], bookId?: string, metaHash?: string, op: SyncOp = 'both') => {
|
||||
if (!lastSyncedAtInited) return;
|
||||
if ((op === 'push' || op === 'both') && bookNotes?.length) {
|
||||
await pushChanges({ notes: bookNotes });
|
||||
@@ -207,9 +215,9 @@ export function useSync(bookKey?: string) {
|
||||
setLastSyncedAtNotes,
|
||||
setSyncingNotes,
|
||||
bookId,
|
||||
metaHash,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[lastSyncedAtInited, lastSyncedAtNotes],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookFormat } from '@/types/book';
|
||||
import { Contributor, LanguageMap } from '@/utils/book';
|
||||
import { Contributor, Identifier, LanguageMap } from '@/utils/book';
|
||||
import * as epubcfi from 'foliate-js/epubcfi.js';
|
||||
|
||||
// A groupBy polyfill for foliate-js
|
||||
@@ -73,6 +73,7 @@ export type BookMetadata = {
|
||||
description?: string;
|
||||
subject?: string | string[] | Contributor;
|
||||
identifier?: string;
|
||||
altIdentifier?: string | string[] | Identifier;
|
||||
|
||||
subtitle?: string;
|
||||
series?: string;
|
||||
|
||||
@@ -29,11 +29,16 @@ export class SyncClient {
|
||||
* Pull incremental changes since a given timestamp (in ms).
|
||||
* Returns updated or deleted records since that time.
|
||||
*/
|
||||
async pullChanges(since: number, type?: SyncType, book?: string): Promise<SyncResult> {
|
||||
async pullChanges(
|
||||
since: number,
|
||||
type?: SyncType,
|
||||
book?: string,
|
||||
metaHash?: string,
|
||||
): Promise<SyncResult> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
|
||||
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}&meta_hash=${metaHash ?? ''}`;
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function GET(req: NextRequest) {
|
||||
const sinceParam = searchParams.get('since');
|
||||
const typeParam = searchParams.get('type') as SyncType | undefined;
|
||||
const bookParam = searchParams.get('book');
|
||||
const metaHashParam = searchParams.get('meta_hash');
|
||||
|
||||
if (!sinceParam) {
|
||||
return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 });
|
||||
@@ -57,16 +58,38 @@ export async function GET(req: NextRequest) {
|
||||
book_configs: null,
|
||||
};
|
||||
|
||||
const queryTables = async (table: TableName) => {
|
||||
const queryTables = async (table: TableName, dedupeKeys?: (keyof BookDataRecord)[]) => {
|
||||
let query = supabase.from(table).select('*').eq('user_id', user.id);
|
||||
if (bookParam) {
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
results[DBSyncTypeMap[table] as SyncType] = data || [];
|
||||
let records = data;
|
||||
if (dedupeKeys && dedupeKeys.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
records = records.filter((rec) => {
|
||||
const key = dedupeKeys
|
||||
.map((k) => rec[k])
|
||||
.filter(Boolean)
|
||||
.join('|');
|
||||
if (key && seen.has(key)) {
|
||||
return false;
|
||||
} else {
|
||||
seen.add(key);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
results[DBSyncTypeMap[table] as SyncType] = records || [];
|
||||
};
|
||||
|
||||
if (!typeParam || typeParam === 'books') {
|
||||
@@ -76,7 +99,7 @@ export async function GET(req: NextRequest) {
|
||||
await queryTables('book_configs').catch((err) => (errors['book_configs'] = err));
|
||||
}
|
||||
if (!typeParam || typeParam === 'notes') {
|
||||
await queryTables('book_notes').catch((err) => (errors['book_notes'] = err));
|
||||
await queryTables('book_notes', ['id']).catch((err) => (errors['book_notes'] = err));
|
||||
}
|
||||
|
||||
const dbErrors = Object.values(errors).filter((err) => err !== null);
|
||||
|
||||
@@ -12,6 +12,7 @@ interface BookData {
|
||||
file: File | null;
|
||||
config: BookConfig | null;
|
||||
bookDoc: BookDoc | null;
|
||||
isFixedLayout: boolean;
|
||||
}
|
||||
|
||||
interface BookDataState {
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
BookProgress,
|
||||
ViewSettings,
|
||||
TimeInfo,
|
||||
FIXED_LAYOUT_FORMATS,
|
||||
} from '@/types/book';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import { updateToc } from '@/utils/toc';
|
||||
import { formatTitle, getPrimaryLanguage } from '@/utils/book';
|
||||
import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
import { SUPPORTED_LANGNAMES } from '@/services/constants';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
@@ -31,6 +32,7 @@ interface ViewState {
|
||||
progress: BookProgress | null;
|
||||
ribbonVisible: boolean;
|
||||
ttsEnabled: boolean;
|
||||
syncing: boolean;
|
||||
gridInsets: Insets | null;
|
||||
/* View settings for the view:
|
||||
generally view settings have a hierarchy of global settings < book settings < view settings
|
||||
@@ -47,6 +49,7 @@ interface ReaderStore {
|
||||
setHoveredBookKey: (key: string | null) => void;
|
||||
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
|
||||
setTTSEnabled: (key: string, enabled: boolean) => void;
|
||||
setIsSyncing: (key: string, syncing: boolean) => void;
|
||||
setProgress: (
|
||||
key: string,
|
||||
location: string,
|
||||
@@ -123,6 +126,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
viewSettings: null,
|
||||
},
|
||||
@@ -156,10 +160,13 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
|
||||
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
|
||||
book.metadata = book.metadata ?? bookDoc.metadata;
|
||||
book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata);
|
||||
|
||||
const isFixedLayout = FIXED_LAYOUT_FORMATS.has(book.format);
|
||||
useBookDataStore.setState((state) => ({
|
||||
booksData: {
|
||||
...state.booksData,
|
||||
[id]: { id, book, file, config, bookDoc },
|
||||
[id]: { id, book, file, config, bookDoc, isFixedLayout },
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -181,6 +188,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
viewSettings: { ...globalViewSettings, ...configViewSettings },
|
||||
},
|
||||
@@ -202,6 +210,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
viewSettings: null,
|
||||
},
|
||||
@@ -256,7 +265,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const viewState = state.viewStates[key];
|
||||
if (!viewState || !bookData) return state;
|
||||
|
||||
const progress: [number, number] = [(pageinfo.next ?? pageinfo.current) + 1, pageinfo.total];
|
||||
const pagePressInfo = bookData.isFixedLayout ? section : pageinfo;
|
||||
const progress: [number, number] = [pagePressInfo.current + 1, pagePressInfo.total];
|
||||
|
||||
// Update library book progress
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
@@ -332,6 +342,17 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
},
|
||||
})),
|
||||
|
||||
setIsSyncing: (key: string, syncing: boolean) =>
|
||||
set((state) => ({
|
||||
viewStates: {
|
||||
...state.viewStates,
|
||||
[key]: {
|
||||
...state.viewStates[key]!,
|
||||
syncing,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
getGridInsets: (key: string) =>
|
||||
get().viewStates[key]?.gridInsets || { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
setGridInsets: (key: string, insets: Insets | null) =>
|
||||
|
||||
@@ -366,10 +366,20 @@ foliate-view {
|
||||
);
|
||||
}
|
||||
|
||||
.focus-inset-2 {
|
||||
@apply focus:outline-none;
|
||||
}
|
||||
|
||||
.focus-inset-2:focus {
|
||||
.visible-focus-inset-2:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px transparent, inset 0 0 0 3px #6396e7;
|
||||
}
|
||||
|
||||
@keyframes spin-counterclockwise {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-reverse-spin {
|
||||
animation: spin-counterclockwise 1s linear;
|
||||
}
|
||||
@@ -12,7 +12,10 @@ export interface Book {
|
||||
url?: string;
|
||||
// if Book is a transient local book we can load the book content via filePath
|
||||
filePath?: string;
|
||||
// Partial md5 hash of the book file, used as the unique identifier
|
||||
hash: string;
|
||||
// Metadata md5 hash, used to aggregate different versions of the same book
|
||||
metaHash?: string;
|
||||
format: BookFormat;
|
||||
title: string; // editable title from metadata
|
||||
sourceTitle?: string; // parsed when the book is imported and used to locate the file
|
||||
@@ -57,6 +60,7 @@ export interface TimeInfo {
|
||||
|
||||
export interface BookNote {
|
||||
bookHash?: string;
|
||||
metaHash?: string;
|
||||
id: string;
|
||||
type: BookNoteType;
|
||||
cfi: string;
|
||||
@@ -228,6 +232,7 @@ export interface BookSearchResult {
|
||||
|
||||
export interface BookConfig {
|
||||
bookHash?: string;
|
||||
metaHash?: string;
|
||||
progress?: [number, number]; // [current pagenum, total pagenum], 1-based page number
|
||||
location?: string; // CFI of the current location
|
||||
xpointer?: string; // XPointer of the current location (for Koreader interoperability)
|
||||
@@ -244,6 +249,7 @@ export interface BookConfig {
|
||||
export interface BookDataRecord {
|
||||
id: string;
|
||||
book_hash: string;
|
||||
meta_hash?: string;
|
||||
user_id: string;
|
||||
updated_at: number | null;
|
||||
deleted_at: number | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface DBBook {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
meta_hash?: string;
|
||||
format: string;
|
||||
title: string;
|
||||
source_title?: string;
|
||||
@@ -20,6 +21,7 @@ export interface DBBook {
|
||||
export interface DBBookConfig {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
meta_hash?: string;
|
||||
location?: string;
|
||||
xpointer?: string;
|
||||
progress?: string;
|
||||
@@ -34,6 +36,7 @@ export interface DBBookConfig {
|
||||
export interface DBBookNote {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
meta_hash?: string;
|
||||
id: string;
|
||||
type: string;
|
||||
cfi: string;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { EXTS } from '@/libs/document';
|
||||
import { BookMetadata, EXTS } from '@/libs/document';
|
||||
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
|
||||
import { SUPPORTED_LANGS } from '@/services/constants';
|
||||
import { getUserLang, makeSafeFilename } from './misc';
|
||||
import { getStorageType } from './storage';
|
||||
import { getDirFromLanguage } from './rtl';
|
||||
import { code6392to6391, isValidLang, normalizedLangCode } from './lang';
|
||||
import { md5 } from './md5';
|
||||
|
||||
export const getDir = (book: Book) => {
|
||||
return `${book.hash}`;
|
||||
@@ -46,16 +47,21 @@ export interface LanguageMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface Identifier {
|
||||
scheme: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Contributor {
|
||||
name: LanguageMap;
|
||||
}
|
||||
|
||||
const formatLanguageMap = (x: string | LanguageMap): string => {
|
||||
const formatLanguageMap = (x: string | LanguageMap, defaultLang = false): string => {
|
||||
const userLang = getUserLang();
|
||||
if (!x) return '';
|
||||
if (typeof x === 'string') return x;
|
||||
const keys = Object.keys(x);
|
||||
return x[userLang] || x[keys[0]!]!;
|
||||
return defaultLang ? x[keys[0]!]! : x[userLang] || x[keys[0]!]!;
|
||||
};
|
||||
|
||||
export const listFormater = (narrow = false, lang = '') => {
|
||||
@@ -206,3 +212,53 @@ export const getBookDirFromLanguage = (language: string | string[] | undefined)
|
||||
const lang = getPrimaryLanguage(language) || '';
|
||||
return getDirFromLanguage(lang);
|
||||
};
|
||||
|
||||
const getTitleForHash = (title: string | LanguageMap) => {
|
||||
return typeof title === 'string' ? title : formatLanguageMap(title, true);
|
||||
};
|
||||
|
||||
const getAuthorsList = (contributors: string | string[] | Contributor | Contributor[]) => {
|
||||
if (!contributors) return [];
|
||||
return Array.isArray(contributors)
|
||||
? contributors.map((contributor) =>
|
||||
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name, true),
|
||||
)
|
||||
: [
|
||||
typeof contributors === 'string'
|
||||
? contributors
|
||||
: formatLanguageMap(contributors?.name, true),
|
||||
];
|
||||
};
|
||||
|
||||
const normalizeIdentifier = (identifier: string) => {
|
||||
if (identifier.includes('urn:')) {
|
||||
// Slice after the last ':'
|
||||
return identifier.match(/[^:]+$/)![0];
|
||||
} else if (identifier.includes(':')) {
|
||||
// Slice after the first ':'
|
||||
return identifier.match(/^[^:]+:(.+)$/)![1];
|
||||
}
|
||||
return identifier;
|
||||
};
|
||||
|
||||
const getIdentifiersList = (
|
||||
identifiers: undefined | string | string[] | Identifier | Identifier[],
|
||||
) => {
|
||||
if (!identifiers) return [];
|
||||
return Array.isArray(identifiers)
|
||||
? identifiers.map((identifier) =>
|
||||
typeof identifier === 'string' ? normalizeIdentifier(identifier) : identifier.value,
|
||||
)
|
||||
: typeof identifiers === 'string'
|
||||
? [normalizeIdentifier(identifiers)]
|
||||
: [identifiers.value];
|
||||
};
|
||||
|
||||
export const getMetadataHash = (metadata: BookMetadata) => {
|
||||
const title = getTitleForHash(metadata.title);
|
||||
const authors = getAuthorsList(metadata.author).join(',');
|
||||
const identifiers = getIdentifiersList(metadata.altIdentifier || metadata.identifier).join(',');
|
||||
const hashSource = `${title}|${authors}|${identifiers}`;
|
||||
const metaHash = md5(hashSource.normalize('NFC'));
|
||||
return metaHash;
|
||||
};
|
||||
|
||||
@@ -28,3 +28,5 @@ export async function partialMD5(file: File): Promise<string> {
|
||||
|
||||
return hasher.hex();
|
||||
}
|
||||
|
||||
export { md5 };
|
||||
|
||||
@@ -11,12 +11,21 @@ import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
|
||||
import { sanitizeString } from './sanitize';
|
||||
|
||||
export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => {
|
||||
const { bookHash, progress, location, xpointer, searchConfig, viewSettings, updatedAt } =
|
||||
bookConfig as BookConfig;
|
||||
const {
|
||||
bookHash,
|
||||
metaHash,
|
||||
progress,
|
||||
location,
|
||||
xpointer,
|
||||
searchConfig,
|
||||
viewSettings,
|
||||
updatedAt,
|
||||
} = bookConfig as BookConfig;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: bookHash!,
|
||||
meta_hash: metaHash,
|
||||
location: location,
|
||||
xpointer: xpointer,
|
||||
progress: progress && JSON.stringify(progress),
|
||||
@@ -27,10 +36,19 @@ export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DB
|
||||
};
|
||||
|
||||
export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => {
|
||||
const { book_hash, progress, location, xpointer, search_config, view_settings, updated_at } =
|
||||
dbBookConfig;
|
||||
const {
|
||||
book_hash,
|
||||
meta_hash,
|
||||
progress,
|
||||
location,
|
||||
xpointer,
|
||||
search_config,
|
||||
view_settings,
|
||||
updated_at,
|
||||
} = dbBookConfig;
|
||||
return {
|
||||
bookHash: book_hash,
|
||||
metaHash: meta_hash,
|
||||
location,
|
||||
xpointer,
|
||||
progress: progress && JSON.parse(progress),
|
||||
@@ -43,6 +61,7 @@ export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfi
|
||||
export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
const {
|
||||
hash,
|
||||
metaHash,
|
||||
format,
|
||||
title,
|
||||
sourceTitle,
|
||||
@@ -61,6 +80,7 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: hash,
|
||||
meta_hash: metaHash,
|
||||
format,
|
||||
title: sanitizeString(title)!,
|
||||
author: sanitizeString(author)!,
|
||||
@@ -80,6 +100,7 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
const {
|
||||
book_hash,
|
||||
meta_hash,
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
@@ -97,6 +118,7 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
|
||||
return {
|
||||
hash: book_hash,
|
||||
metaHash: meta_hash,
|
||||
format: format as BookFormat,
|
||||
title,
|
||||
author,
|
||||
@@ -114,12 +136,25 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
};
|
||||
|
||||
export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBookNote => {
|
||||
const { bookHash, id, type, cfi, text, style, color, note, createdAt, updatedAt, deletedAt } =
|
||||
bookNote as BookNote;
|
||||
const {
|
||||
bookHash,
|
||||
metaHash,
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
text,
|
||||
style,
|
||||
color,
|
||||
note,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
} = bookNote as BookNote;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: bookHash!,
|
||||
meta_hash: metaHash,
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
@@ -135,11 +170,24 @@ export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBook
|
||||
};
|
||||
|
||||
export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||
const { book_hash, id, type, cfi, text, style, color, note, created_at, updated_at, deleted_at } =
|
||||
dbBookNote;
|
||||
const {
|
||||
book_hash,
|
||||
meta_hash,
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
text,
|
||||
style,
|
||||
color,
|
||||
note,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
} = dbBookNote;
|
||||
|
||||
return {
|
||||
bookHash: book_hash,
|
||||
metaHash: meta_hash,
|
||||
id,
|
||||
type: type as BookNoteType,
|
||||
cfi,
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: b5231c9fab...06b415f008
Reference in New Issue
Block a user