feat(sync): aggregate books with metadata hash for progress and annotation sync (#2062)

This commit is contained in:
Huang Xin
2025-09-18 20:05:16 +08:00
committed by GitHub
parent e0c5acf4c8
commit 91a5454b46
24 changed files with 284 additions and 101 deletions
@@ -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]);
};
+3 -1
View File
@@ -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}
/>
) : (
+3 -4
View File
@@ -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,
+12 -4
View File
@@ -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],
+2 -1
View File
@@ -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;
+7 -2
View File
@@ -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,
{
+27 -4
View File
@@ -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 {
+24 -3
View File
@@ -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) =>
+15 -5
View File
@@ -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;
}
+6
View File
@@ -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;
+3
View File
@@ -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;
+59 -3
View File
@@ -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;
};
+2
View File
@@ -28,3 +28,5 @@ export async function partialMD5(file: File): Promise<string> {
return hasher.hex();
}
export { md5 };
+56 -8
View File
@@ -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,