= ({
transition: 'transform 0.2s',
}}
onKeyDown={handleKeyDown}
- onFocus={handleFocus}
- onBlur={handleBlur}
{...handlers}
>
diff --git a/apps/readest-app/src/app/reader/components/BooksGrid.tsx b/apps/readest-app/src/app/reader/components/BooksGrid.tsx
index 24d30a01..e9d87f80 100644
--- a/apps/readest-app/src/app/reader/components/BooksGrid.tsx
+++ b/apps/readest-app/src/app/reader/components/BooksGrid.tsx
@@ -185,7 +185,6 @@ const BooksGrid: React.FC
= ({ bookKeys, onCloseBook }) => {
{showFooter && (
= ({
bookKey,
- bookFormat,
section,
pageinfo,
timeinfo,
@@ -30,8 +29,10 @@ const ProgressInfoView: React.FC = ({
}) => {
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 = ({
: '{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);
diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx
index c0cde28e..48fa485a 100644
--- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx
+++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx
@@ -41,10 +41,11 @@ const ViewMenu: React.FC = ({
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 = ({
: _('Never synced')
}
Icon={user ? MdSync : MdSyncProblem}
+ iconClassName={user && viewState?.syncing ? 'animate-reverse-spin' : ''}
onClick={handleSync}
/>
diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx
index ee43be18..bdd5af0d 100644
--- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx
+++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx
@@ -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 = ({ menuClassName, setIsDropdownOpen })
>
{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) => (
diff --git a/apps/readest-app/src/app/reader/hooks/useNotesSync.ts b/apps/readest-app/src/app/reader/hooks/useNotesSync.ts
index 8717902b..e54fd770 100644
--- a/apps/readest-app/src/app/reader/hooks/useNotesSync.ts
+++ b/apps/readest-app/src/app/reader/hooks/useNotesSync.ts
@@ -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 = [
diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts
index 6690c619..3f6fca8d 100644
--- a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts
+++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts
@@ -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]);
};
diff --git a/apps/readest-app/src/components/MenuItem.tsx b/apps/readest-app/src/components/MenuItem.tsx
index 33e94af5..24a283a7 100644
--- a/apps/readest-app/src/components/MenuItem.tsx
+++ b/apps/readest-app/src/components/MenuItem.tsx
@@ -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 = ({
noIcon = false,
transient = false,
Icon,
+ iconClassName,
children,
onClick,
setIsDropdownOpen,
@@ -57,7 +59,7 @@ const MenuItem: React.FC = ({
{typeof IconType === 'function' ? (
) : (
diff --git a/apps/readest-app/src/hooks/useLongPress.ts b/apps/readest-app/src/hooks/useLongPress.ts
index 2fa899ec..2e636f76 100644
--- a/apps/readest-app/src/hooks/useLongPress.ts
+++ b/apps/readest-app/src/hooks/useLongPress.ts
@@ -11,7 +11,6 @@ interface UseLongPressOptions {
interface UseLongPressResult {
pressing: boolean;
- hasPointerEventsRef: React.MutableRefObject;
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,
diff --git a/apps/readest-app/src/hooks/useSync.ts b/apps/readest-app/src/hooks/useSync.ts
index d2357836..c4845bcc 100644
--- a/apps/readest-app/src/hooks/useSync.ts
+++ b/apps/readest-app/src/hooks/useSync.ts
@@ -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>,
setSyncing: React.Dispatch>,
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],
diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts
index 806d7dec..9370eabc 100644
--- a/apps/readest-app/src/libs/document.ts
+++ b/apps/readest-app/src/libs/document.ts
@@ -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;
diff --git a/apps/readest-app/src/libs/sync.ts b/apps/readest-app/src/libs/sync.ts
index 139bdbf5..247bc6e5 100644
--- a/apps/readest-app/src/libs/sync.ts
+++ b/apps/readest-app/src/libs/sync.ts
@@ -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 {
+ async pullChanges(
+ since: number,
+ type?: SyncType,
+ book?: string,
+ metaHash?: string,
+ ): Promise {
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,
{
diff --git a/apps/readest-app/src/pages/api/sync.ts b/apps/readest-app/src/pages/api/sync.ts
index 9e82413a..a350298e 100644
--- a/apps/readest-app/src/pages/api/sync.ts
+++ b/apps/readest-app/src/pages/api/sync.ts
@@ -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();
+ 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);
diff --git a/apps/readest-app/src/store/bookDataStore.ts b/apps/readest-app/src/store/bookDataStore.ts
index beef6a94..820775c6 100644
--- a/apps/readest-app/src/store/bookDataStore.ts
+++ b/apps/readest-app/src/store/bookDataStore.ts
@@ -12,6 +12,7 @@ interface BookData {
file: File | null;
config: BookConfig | null;
bookDoc: BookDoc | null;
+ isFixedLayout: boolean;
}
interface BookDataState {
diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts
index 782f42db..ab8c04bf 100644
--- a/apps/readest-app/src/store/readerStore.ts
+++ b/apps/readest-app/src/store/readerStore.ts
@@ -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((set, get) => ({
progress: null,
ribbonVisible: false,
ttsEnabled: false,
+ syncing: false,
gridInsets: null,
viewSettings: null,
},
@@ -156,10 +160,13 @@ export const useReaderStore = create((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((set, get) => ({
progress: null,
ribbonVisible: false,
ttsEnabled: false,
+ syncing: false,
gridInsets: null,
viewSettings: { ...globalViewSettings, ...configViewSettings },
},
@@ -202,6 +210,7 @@ export const useReaderStore = create((set, get) => ({
progress: null,
ribbonVisible: false,
ttsEnabled: false,
+ syncing: false,
gridInsets: null,
viewSettings: null,
},
@@ -256,7 +265,8 @@ export const useReaderStore = create((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((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) =>
diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css
index 6626416d..c3657985 100644
--- a/apps/readest-app/src/styles/globals.css
+++ b/apps/readest-app/src/styles/globals.css
@@ -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;
+}
\ No newline at end of file
diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts
index 2b731aa4..9614ac76 100644
--- a/apps/readest-app/src/types/book.ts
+++ b/apps/readest-app/src/types/book.ts
@@ -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;
diff --git a/apps/readest-app/src/types/records.ts b/apps/readest-app/src/types/records.ts
index 20090a90..49b38514 100644
--- a/apps/readest-app/src/types/records.ts
+++ b/apps/readest-app/src/types/records.ts
@@ -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;
diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts
index ddeeb9ff..c86c0d58 100644
--- a/apps/readest-app/src/utils/book.ts
+++ b/apps/readest-app/src/utils/book.ts
@@ -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;
+};
diff --git a/apps/readest-app/src/utils/md5.ts b/apps/readest-app/src/utils/md5.ts
index 6be2a117..47d2d9ec 100644
--- a/apps/readest-app/src/utils/md5.ts
+++ b/apps/readest-app/src/utils/md5.ts
@@ -28,3 +28,5 @@ export async function partialMD5(file: File): Promise {
return hasher.hex();
}
+
+export { md5 };
diff --git a/apps/readest-app/src/utils/transform.ts b/apps/readest-app/src/utils/transform.ts
index 7a71ea00..9a293472 100644
--- a/apps/readest-app/src/utils/transform.ts
+++ b/apps/readest-app/src/utils/transform.ts
@@ -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,
diff --git a/packages/foliate-js b/packages/foliate-js
index b5231c9f..06b415f0 160000
--- a/packages/foliate-js
+++ b/packages/foliate-js
@@ -1 +1 @@
-Subproject commit b5231c9fab7c5afda67eaf04e27be075758978f8
+Subproject commit 06b415f008f3fdc3421debe83fdffaafdbda7119