451f0ccd90
* fix(library): count only uploaded, non-deleted books in synced toast The "N book(s) synced" toast shown on pull-to-refresh and the last-synced menu counted every non-deleted book record a pull returned, including books indexed in the cloud as metadata only whose file blob was never uploaded. Those books are never added to the library (updateLibrary requires uploadedAt && !deletedAt), so the toast over-reported. Extract the count into a pure countSyncedRecords(type, records) helper that excludes deleted records and, for books only, requires uploaded_at — matching what actually lands in the library. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(memory): record agent notes for recent fixes Persisted project-memory docs accumulated alongside recently merged work (biometric app-lock, download-file scope regression, inline-block column overflow, iOS instant-dict double popup, RSVP RTL words, web security advisories) plus MEMORY.md/share-feature index updates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useSyncContext } from '@/context/SyncContext';
|
|
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
|
|
import { isSyncCategoryEnabled } from '@/services/sync/syncCategories';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { transformBookConfigFromDB } from '@/utils/transform';
|
|
import { transformBookNoteFromDB } from '@/utils/transform';
|
|
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,
|
|
notes: transformBookNoteFromDB,
|
|
configs: transformBookConfigFromDB,
|
|
};
|
|
|
|
const computeMaxTimestamp = (records: BookDataRecord[]): number => {
|
|
let maxTime = 0;
|
|
for (const rec of records) {
|
|
if (rec.updated_at) {
|
|
const updatedTime = new Date(rec.updated_at).getTime();
|
|
maxTime = Math.max(maxTime, updatedTime);
|
|
}
|
|
if (rec.deleted_at) {
|
|
const deletedTime = new Date(rec.deleted_at).getTime();
|
|
maxTime = Math.max(maxTime, deletedTime);
|
|
}
|
|
}
|
|
return maxTime;
|
|
};
|
|
|
|
// Count the records a pull surfaces to the user as "synced". Deleted records
|
|
// never count. For books we additionally require an upload state: a book is
|
|
// indexed in the cloud as soon as its metadata syncs, but updateLibrary only
|
|
// adds books that are uploaded && !deleted, so counting metadata-only books
|
|
// would over-report relative to what actually lands in the library.
|
|
export const countSyncedRecords = (
|
|
type: SyncType,
|
|
records: BookDataRecord[] | null | undefined,
|
|
): number => {
|
|
if (!records?.length) return 0;
|
|
return records.filter((rec) => !rec.deleted_at && (type !== 'books' || !!rec.uploaded_at)).length;
|
|
};
|
|
|
|
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
export function useSync(bookKey?: string) {
|
|
const router = useRouter();
|
|
const { envConfig } = useEnv();
|
|
const { settings, setSettings, saveSettings } = useSettingsStore();
|
|
const { getConfig, setConfig } = useBookDataStore();
|
|
const { setIsSyncing } = useReaderStore();
|
|
const config = bookKey ? getConfig(bookKey) : null;
|
|
|
|
const [syncingBooks, setSyncingBooks] = useState(false);
|
|
const [syncingConfigs, setSyncingConfigs] = useState(false);
|
|
const [syncingNotes, setSyncingNotes] = useState(false);
|
|
const [syncError, setSyncError] = useState<string | null>(null);
|
|
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(0);
|
|
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(0);
|
|
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(0);
|
|
const [lastSyncedAtInited, setLastSyncedAtInited] = useState(false);
|
|
|
|
const [syncing, setSyncing] = useState(false);
|
|
// null means unsynced, empty array means synced no changes
|
|
const [syncResult, setSyncResult] = useState<SyncResult>({
|
|
books: null,
|
|
configs: null,
|
|
notes: null,
|
|
});
|
|
const [syncedBooks, setSyncedBooks] = useState<Book[] | null>(null);
|
|
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[] | null>(null);
|
|
const [syncedNotes, setSyncedNotes] = useState<BookNote[] | null>(null);
|
|
|
|
const { syncClient } = useSyncContext();
|
|
|
|
useEffect(() => {
|
|
if (!bookKey) return;
|
|
setIsSyncing(bookKey, syncing);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [bookKey, syncing]);
|
|
|
|
useEffect(() => {
|
|
if (!settings.version) return;
|
|
if (bookKey && !config?.location) return;
|
|
if (lastSyncedAtInited) return;
|
|
|
|
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
|
|
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
|
|
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
|
|
const now = Date.now();
|
|
setLastSyncedAtBooks(
|
|
now - lastSyncedBooksAt > 3 * ONE_DAY_IN_MS ? 0 : lastSyncedBooksAt - ONE_DAY_IN_MS,
|
|
);
|
|
setLastSyncedAtConfigs(
|
|
now - lastSyncedConfigsAt > 3 * ONE_DAY_IN_MS ? 0 : lastSyncedConfigsAt - ONE_DAY_IN_MS,
|
|
);
|
|
setLastSyncedAtNotes(
|
|
now - lastSyncedNotesAt > 3 * ONE_DAY_IN_MS ? 0 : lastSyncedNotesAt - ONE_DAY_IN_MS,
|
|
);
|
|
setLastSyncedAtInited(true);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [bookKey, settings, config]);
|
|
|
|
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
|
|
// and update the lastSyncedAt for that book in the book config
|
|
const pullChanges = async (
|
|
type: SyncType,
|
|
since: number,
|
|
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, metaHash);
|
|
const resultAsRecord = result as unknown as Record<
|
|
string,
|
|
BookDataRecord[] | null | undefined
|
|
>;
|
|
setSyncResult({ ...syncResult, [type]: resultAsRecord[type] });
|
|
const records = resultAsRecord[type];
|
|
if (since > 1000 && !records?.length) return 0;
|
|
// For since <= 1000, we set lastSyncedAt to now if no records returned
|
|
const maxTime = records?.length ? computeMaxTimestamp(records) : Date.now();
|
|
setLastSyncedAt(maxTime);
|
|
|
|
// due to closures in React hooks the settings might be stale
|
|
// we need to fetch the latest settings from store
|
|
const settings = useSettingsStore.getState().settings;
|
|
switch (type) {
|
|
case 'books':
|
|
settings.lastSyncedAtBooks = maxTime;
|
|
setSettings(settings);
|
|
break;
|
|
case 'configs':
|
|
if (!bookId) {
|
|
settings.lastSyncedAtConfigs = maxTime;
|
|
setSettings(settings);
|
|
} else if (bookKey) {
|
|
setConfig(bookKey, { lastSyncedAtConfig: maxTime });
|
|
}
|
|
break;
|
|
case 'notes':
|
|
if (!bookId) {
|
|
settings.lastSyncedAtNotes = maxTime;
|
|
setSettings(settings);
|
|
} else if (bookKey) {
|
|
setConfig(bookKey, { lastSyncedAtNotes: maxTime });
|
|
}
|
|
break;
|
|
}
|
|
return countSyncedRecords(type, records);
|
|
} catch (err: unknown) {
|
|
console.error(err);
|
|
if (err instanceof Error) {
|
|
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
|
settings.keepLogin = false;
|
|
setSettings(settings);
|
|
navigateToLogin(router);
|
|
}
|
|
setSyncError(err.message || `Error pulling ${type}`);
|
|
} else {
|
|
setSyncError(`Error pulling ${type}`);
|
|
}
|
|
return 0;
|
|
} finally {
|
|
setSyncing(false);
|
|
saveSettings(envConfig, settings);
|
|
}
|
|
};
|
|
|
|
const pushChanges = async (payload: SyncData): Promise<boolean> => {
|
|
setSyncing(true);
|
|
setSyncError(null);
|
|
|
|
try {
|
|
const result = await syncClient.pushChanges(payload);
|
|
setSyncResult(result);
|
|
return true;
|
|
} catch (err: unknown) {
|
|
console.error(err);
|
|
if (err instanceof Error) {
|
|
setSyncError(err.message || 'Error pushing changes');
|
|
} else {
|
|
setSyncError('Error pushing changes');
|
|
}
|
|
return false;
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
const syncBooks = useCallback(
|
|
async (books?: Book[], op: SyncOp = 'both', since?: number) => {
|
|
if (!lastSyncedAtInited) return;
|
|
if (!isSyncCategoryEnabled('book')) return;
|
|
if ((op === 'push' || op === 'both') && books?.length) {
|
|
await pushChanges({ books });
|
|
}
|
|
if (op === 'pull' || op === 'both') {
|
|
return await pullChanges(
|
|
'books',
|
|
since ?? lastSyncedAtBooks + 1,
|
|
setLastSyncedAtBooks,
|
|
setSyncingBooks,
|
|
);
|
|
}
|
|
return;
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[lastSyncedAtInited, lastSyncedAtBooks],
|
|
);
|
|
|
|
const syncConfigs = useCallback(
|
|
async (bookConfigs?: BookConfig[], bookId?: string, metaHash?: string, op: SyncOp = 'both') => {
|
|
if (!bookId && !lastSyncedAtInited) return;
|
|
if (!isSyncCategoryEnabled('progress')) return;
|
|
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
|
|
const pushed = await pushChanges({ configs: bookConfigs });
|
|
if (pushed && bookId && bookKey) {
|
|
setConfig(bookKey, { lastPushedAtConfig: Date.now() });
|
|
}
|
|
}
|
|
if (op === 'pull' || op === 'both') {
|
|
await pullChanges(
|
|
'configs',
|
|
lastSyncedAtConfigs,
|
|
setLastSyncedAtConfigs,
|
|
setSyncingConfigs,
|
|
bookId,
|
|
metaHash,
|
|
);
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[lastSyncedAtInited, lastSyncedAtConfigs],
|
|
);
|
|
|
|
const syncNotes = useCallback(
|
|
async (bookNotes?: BookNote[], bookId?: string, metaHash?: string, op: SyncOp = 'both') => {
|
|
if (!lastSyncedAtInited) return;
|
|
if (!isSyncCategoryEnabled('note')) return;
|
|
if ((op === 'push' || op === 'both') && bookNotes?.length) {
|
|
const pushed = await pushChanges({ notes: bookNotes });
|
|
if (pushed && bookId && bookKey) {
|
|
setConfig(bookKey, { lastPushedAtNotes: Date.now() });
|
|
}
|
|
}
|
|
if (op === 'pull' || op === 'both') {
|
|
await pullChanges(
|
|
'notes',
|
|
lastSyncedAtNotes,
|
|
setLastSyncedAtNotes,
|
|
setSyncingNotes,
|
|
bookId,
|
|
metaHash,
|
|
);
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[lastSyncedAtInited, lastSyncedAtNotes],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!syncing && syncResult) {
|
|
const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult;
|
|
const books = dbBooks?.map((dbBook) =>
|
|
transformsFromDB['books'](dbBook as unknown as DBBook),
|
|
);
|
|
const configs = dbBookConfigs?.map((dbBookConfig) =>
|
|
transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig),
|
|
);
|
|
const notes = dbBookNotes?.map((dbBookNote) =>
|
|
transformsFromDB['notes'](dbBookNote as unknown as DBBookNote),
|
|
);
|
|
if (books) setSyncedBooks(books);
|
|
if (configs) setSyncedConfigs(configs);
|
|
if (notes) setSyncedNotes(notes);
|
|
}
|
|
}, [syncResult, syncing]);
|
|
|
|
return {
|
|
syncing: syncingBooks || syncingConfigs || syncingNotes,
|
|
syncError,
|
|
syncResult,
|
|
syncedBooks,
|
|
syncedConfigs,
|
|
syncedNotes,
|
|
lastSyncedAtBooks,
|
|
lastSyncedAtNotes,
|
|
lastSyncedAtConfigs,
|
|
useSyncInited: lastSyncedAtInited,
|
|
pullChanges,
|
|
pushChanges,
|
|
syncBooks,
|
|
syncConfigs,
|
|
syncNotes,
|
|
};
|
|
}
|