When a third-party provider (WebDAV or Google Drive) is the selected cloud sync backend, Readest Cloud storage is no longer written to: - New src/services/sync/cloudSyncProvider.ts policy module: the selected provider is derived from the existing per-device enabled flags (webdav wins deterministically if both are ever set); the premium guard resolves to a PAUSED state instead of silently falling back to Readest Cloud, with the user plan cached for non-React modules. - transferManager gates book uploads on the selected provider: queueUpload returns null when gated; pending book uploads from before a provider switch are visibly cancelled (cancelReason policy) and pruned on the next restore; downloads and replica transfers are never gated. - Book uploads are deferred until settings hydrate (settings.version barrier) so a persisted queue cannot be mis-processed at startup; replica transfers are not stalled. - Quota-exceeded uploads fail fast with zero retries, and a batch import produces one summary toast instead of one toast per book. - Policy cancellations are a distinct bucket via a shared predicate: excluded from failed stats, Retry All, and the per-item Retry button. - Auto-upload call sites (ingest, OPDS, subscriptions) check the provider gate; the explicit Upload Book action explains the gate with a toast instead of silently doing nothing. - Activating a provider auto-enables its syncBooks so books keep backing up somewhere; a one-time migration (20260706) applies the same flip for users who already had a provider enabled. - webdav.deviceId and webdav.lastSyncedAt are excluded from backups, matching the existing googleDrive entries. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,46 @@ describe('withActiveCloudProvider', () => {
|
||||
expect(next.webdav.serverUrl).toBe('https://dav');
|
||||
expect(next.googleDrive.accountLabel).toBe('a@b.com');
|
||||
});
|
||||
|
||||
// Selecting a third-party provider hands it the book-file channel:
|
||||
// native Readest Cloud uploads gate off, so without syncBooks the books
|
||||
// would back up nowhere. Activation therefore turns syncBooks on.
|
||||
describe('syncBooks auto-enable on activation', () => {
|
||||
const inactive = {
|
||||
webdav: { enabled: false, serverUrl: 'https://dav', syncBooks: false },
|
||||
googleDrive: { enabled: false, syncBooks: false },
|
||||
} as unknown as SystemSettings;
|
||||
|
||||
test('activating a disabled provider turns its syncBooks on', () => {
|
||||
const next = withActiveCloudProvider(inactive, 'webdav');
|
||||
expect(next.webdav.syncBooks).toBe(true);
|
||||
expect(next.googleDrive.syncBooks).toBe(false);
|
||||
});
|
||||
|
||||
test('activating gdrive turns only gdrive syncBooks on', () => {
|
||||
const next = withActiveCloudProvider(inactive, 'gdrive');
|
||||
expect(next.googleDrive.syncBooks).toBe(true);
|
||||
expect(next.webdav.syncBooks).toBe(false);
|
||||
});
|
||||
|
||||
test('re-activating an already-active provider respects an explicit syncBooks opt-out', () => {
|
||||
const active = {
|
||||
webdav: { enabled: true, syncBooks: false },
|
||||
googleDrive: { enabled: false },
|
||||
} as unknown as SystemSettings;
|
||||
const next = withActiveCloudProvider(active, 'webdav');
|
||||
expect(next.webdav.syncBooks).toBe(false);
|
||||
});
|
||||
|
||||
test('deactivating a provider leaves its syncBooks untouched', () => {
|
||||
const active = {
|
||||
webdav: { enabled: true, syncBooks: true },
|
||||
googleDrive: { enabled: false, syncBooks: false },
|
||||
} as unknown as SystemSettings;
|
||||
const next = withActiveCloudProvider(active, null);
|
||||
expect(next.webdav.syncBooks).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCloudSyncInPlan', () => {
|
||||
|
||||
@@ -59,6 +59,15 @@ function makeSettings(overrides: Partial<SystemSettings> = {}): SystemSettings {
|
||||
checksumMethod: 'binary',
|
||||
strategy: 'prompt',
|
||||
},
|
||||
webdav: {
|
||||
enabled: true,
|
||||
serverUrl: 'https://dav.example',
|
||||
username: 'wuser',
|
||||
password: 'wpass',
|
||||
rootPath: '/',
|
||||
deviceId: 'webdav-device-id',
|
||||
lastSyncedAt: 666,
|
||||
},
|
||||
readwise: { enabled: true, accessToken: 'rw-token', lastSyncedAt: 999 },
|
||||
hardcover: { enabled: false, accessToken: 'hc-token', lastSyncedAt: 888 },
|
||||
googleDrive: {
|
||||
@@ -104,6 +113,11 @@ describe('sanitizeSettingsForBackup - blacklist', () => {
|
||||
expect(rec(out['googleDrive'])['deviceId']).toBeUndefined();
|
||||
// Non-identity Drive settings still travel with the backup.
|
||||
expect(rec(out['googleDrive'])['enabled']).toBe(true);
|
||||
// WebDAV device identity and cursor stay on the device; restoring
|
||||
// them onto a second device would duplicate WebDAV sync identity.
|
||||
expect(rec(out['webdav'])['deviceId']).toBeUndefined();
|
||||
expect(rec(out['webdav'])['lastSyncedAt']).toBeUndefined();
|
||||
expect(rec(out['webdav'])['serverUrl']).toBe('https://dav.example');
|
||||
});
|
||||
|
||||
it('strips sync cursors', () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
|
||||
vi.mock('@/utils/access', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utils/access')>();
|
||||
return {
|
||||
...actual,
|
||||
isCloudSyncAllowed: vi.fn(actual.isCloudSyncAllowed),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
applySyncBooksAutoEnable,
|
||||
getCloudSyncProvider,
|
||||
isReadestCloudStorageActive,
|
||||
resolveCloudSyncGate,
|
||||
setCachedUserPlan,
|
||||
} from '@/services/sync/cloudSyncProvider';
|
||||
import { isCloudSyncAllowed } from '@/utils/access';
|
||||
|
||||
const makeSettings = (overrides: Partial<SystemSettings> = {}): SystemSettings =>
|
||||
({
|
||||
webdav: { enabled: false },
|
||||
googleDrive: { enabled: false },
|
||||
...overrides,
|
||||
}) as SystemSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isCloudSyncAllowed).mockReturnValue(true);
|
||||
setCachedUserPlan('free');
|
||||
});
|
||||
|
||||
describe('getCloudSyncProvider', () => {
|
||||
test('derives readest when no third-party provider is enabled', () => {
|
||||
expect(getCloudSyncProvider(makeSettings())).toBe('readest');
|
||||
});
|
||||
|
||||
test('derives webdav when webdav is enabled', () => {
|
||||
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(getCloudSyncProvider(settings)).toBe('webdav');
|
||||
});
|
||||
|
||||
test('derives gdrive when google drive is enabled', () => {
|
||||
const settings = makeSettings({ googleDrive: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(getCloudSyncProvider(settings)).toBe('gdrive');
|
||||
});
|
||||
|
||||
test('webdav wins deterministically when both flags are enabled (corrupt state)', () => {
|
||||
const settings = makeSettings({
|
||||
webdav: { enabled: true },
|
||||
googleDrive: { enabled: true },
|
||||
} as Partial<SystemSettings>);
|
||||
expect(getCloudSyncProvider(settings)).toBe('webdav');
|
||||
});
|
||||
|
||||
test('defaults to readest for missing slices and missing settings', () => {
|
||||
expect(getCloudSyncProvider({} as SystemSettings)).toBe('readest');
|
||||
expect(getCloudSyncProvider(null)).toBe('readest');
|
||||
expect(getCloudSyncProvider(undefined)).toBe('readest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCloudSyncGate', () => {
|
||||
test('readest provider is never paused, even when cloud sync is disallowed', () => {
|
||||
vi.mocked(isCloudSyncAllowed).mockReturnValue(false);
|
||||
expect(resolveCloudSyncGate(makeSettings(), 'free')).toEqual({
|
||||
provider: 'readest',
|
||||
paused: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('third-party provider stays selected but paused when disallowed (no silent readest fallback)', () => {
|
||||
vi.mocked(isCloudSyncAllowed).mockReturnValue(false);
|
||||
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(resolveCloudSyncGate(settings, 'free')).toEqual({ provider: 'webdav', paused: true });
|
||||
});
|
||||
|
||||
test('third-party provider is active when allowed', () => {
|
||||
const settings = makeSettings({ googleDrive: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(resolveCloudSyncGate(settings, 'plus')).toEqual({ provider: 'gdrive', paused: false });
|
||||
});
|
||||
|
||||
test('falls back to the cached user plan when no plan argument is given', () => {
|
||||
vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free');
|
||||
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
|
||||
setCachedUserPlan('free');
|
||||
expect(resolveCloudSyncGate(settings).paused).toBe(true);
|
||||
|
||||
setCachedUserPlan('pro');
|
||||
expect(resolveCloudSyncGate(settings).paused).toBe(false);
|
||||
});
|
||||
|
||||
test('undefined cached plan is treated as free', () => {
|
||||
vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free');
|
||||
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
setCachedUserPlan(undefined);
|
||||
expect(resolveCloudSyncGate(settings).paused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applySyncBooksAutoEnable (upgrade migration for already-enabled providers)', () => {
|
||||
test('flips syncBooks on for an enabled webdav provider, mutating the given settings', () => {
|
||||
const settings = makeSettings({
|
||||
webdav: { enabled: true, syncBooks: false },
|
||||
} as Partial<SystemSettings>);
|
||||
expect(applySyncBooksAutoEnable(settings)).toBe(true);
|
||||
expect(settings.webdav?.syncBooks).toBe(true);
|
||||
});
|
||||
|
||||
test('flips syncBooks on for an enabled gdrive provider', () => {
|
||||
const settings = makeSettings({
|
||||
googleDrive: { enabled: true, syncBooks: false },
|
||||
} as Partial<SystemSettings>);
|
||||
expect(applySyncBooksAutoEnable(settings)).toBe(true);
|
||||
expect(settings.googleDrive?.syncBooks).toBe(true);
|
||||
});
|
||||
|
||||
test('no-op when readest is the provider', () => {
|
||||
const settings = makeSettings();
|
||||
expect(applySyncBooksAutoEnable(settings)).toBe(false);
|
||||
expect(settings.webdav?.syncBooks).toBeUndefined();
|
||||
});
|
||||
|
||||
test('no-op when syncBooks is already on', () => {
|
||||
const settings = makeSettings({
|
||||
webdav: { enabled: true, syncBooks: true },
|
||||
} as Partial<SystemSettings>);
|
||||
expect(applySyncBooksAutoEnable(settings)).toBe(false);
|
||||
});
|
||||
|
||||
test('only the selected provider is flipped when both are enabled', () => {
|
||||
const settings = makeSettings({
|
||||
webdav: { enabled: true, syncBooks: false },
|
||||
googleDrive: { enabled: true, syncBooks: false },
|
||||
} as Partial<SystemSettings>);
|
||||
expect(applySyncBooksAutoEnable(settings)).toBe(true);
|
||||
expect(settings.webdav?.syncBooks).toBe(true);
|
||||
expect(settings.googleDrive?.syncBooks).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReadestCloudStorageActive', () => {
|
||||
test('true when readest is the derived provider', () => {
|
||||
expect(isReadestCloudStorageActive(makeSettings())).toBe(true);
|
||||
});
|
||||
|
||||
test('false when a third-party provider is selected', () => {
|
||||
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(isReadestCloudStorageActive(settings)).toBe(false);
|
||||
});
|
||||
|
||||
test('false while paused: uploads must not silently resume to Readest Cloud', () => {
|
||||
vi.mocked(isCloudSyncAllowed).mockReturnValue(false);
|
||||
const settings = makeSettings({ googleDrive: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(isReadestCloudStorageActive(settings, 'free')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { useTransferStore, TransferItem } from '@/store/transferStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
|
||||
vi.mock('@/utils/event', () => ({
|
||||
eventDispatcher: {
|
||||
dispatch: vi.fn(),
|
||||
dispatchSync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import type { Book } from '@/types/book';
|
||||
|
||||
function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
hash: 'hash1',
|
||||
format: 'EPUB',
|
||||
title: 'Test Book',
|
||||
author: 'Author',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTransferItem(overrides: Partial<TransferItem> = {}): TransferItem {
|
||||
return {
|
||||
id: 't1',
|
||||
kind: 'book',
|
||||
bookHash: 'hash1',
|
||||
bookTitle: 'Test Book',
|
||||
type: 'upload',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
transferSpeed: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
createdAt: Date.now(),
|
||||
priority: 10,
|
||||
isBackground: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const resetTransferManager = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only introspection
|
||||
const mgr = transferManager as unknown as Record<string, unknown>;
|
||||
mgr['isInitialized'] = false;
|
||||
mgr['isProcessing'] = false;
|
||||
mgr['appService'] = null;
|
||||
mgr['getLibrary'] = null;
|
||||
mgr['updateBook'] = null;
|
||||
mgr['_'] = null;
|
||||
(mgr['abortControllers'] as Map<string, AbortController>).clear();
|
||||
let resolveReady: () => void = () => {};
|
||||
mgr['readyPromise'] = new Promise<void>((res) => {
|
||||
resolveReady = res;
|
||||
});
|
||||
mgr['readyResolve'] = resolveReady;
|
||||
};
|
||||
|
||||
const resetTransferStore = () => {
|
||||
useTransferStore.setState({
|
||||
transfers: {},
|
||||
isQueuePaused: false,
|
||||
isTransferQueueOpen: false,
|
||||
maxConcurrent: 2,
|
||||
activeCount: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const settingsLoaded = (overrides: Partial<SystemSettings> = {}): void => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
version: 1,
|
||||
webdav: { enabled: false },
|
||||
googleDrive: { enabled: false },
|
||||
...overrides,
|
||||
} as SystemSettings,
|
||||
});
|
||||
};
|
||||
|
||||
const settingsNotLoaded = (): void => {
|
||||
useSettingsStore.setState({ settings: {} as SystemSettings });
|
||||
};
|
||||
|
||||
const webdavSelected = (): void =>
|
||||
settingsLoaded({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
|
||||
function makeAppService(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
uploadBook: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBook: vi.fn().mockResolvedValue(undefined),
|
||||
deleteBook: vi.fn().mockResolvedValue(undefined),
|
||||
uploadReplicaFile: vi.fn().mockResolvedValue(undefined),
|
||||
downloadReplicaFile: vi.fn().mockResolvedValue(undefined),
|
||||
deleteReplicaBundle: vi.fn().mockResolvedValue(undefined),
|
||||
isMacOSApp: false,
|
||||
...overrides,
|
||||
} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const translationFn = (key: string, params?: Record<string, string | number>) => {
|
||||
if (params) {
|
||||
return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const initManager = async (appService = makeAppService(), library: Book[] = []) => {
|
||||
await transferManager.initialize(appService as never, () => library, vi.fn(), translationFn);
|
||||
return appService;
|
||||
};
|
||||
|
||||
const flushAsync = async (ms = 5000) => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
resetTransferStore();
|
||||
resetTransferManager();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
settingsLoaded();
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('provider gating of book uploads', () => {
|
||||
test('queueUpload returns null and queues nothing when a third-party provider is selected', async () => {
|
||||
webdavSelected();
|
||||
await initManager();
|
||||
|
||||
const id = transferManager.queueUpload(makeBook());
|
||||
expect(id).toBeNull();
|
||||
expect(Object.keys(useTransferStore.getState().transfers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('queueUpload works when Readest Cloud is the provider', async () => {
|
||||
await initManager();
|
||||
|
||||
const id = transferManager.queueUpload(makeBook());
|
||||
expect(id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('queueBatchUploads returns empty when gated', async () => {
|
||||
webdavSelected();
|
||||
await initManager();
|
||||
|
||||
const ids = transferManager.queueBatchUploads([makeBook(), makeBook({ hash: 'hash2' })]);
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
|
||||
test('downloads and replica transfers are never gated', async () => {
|
||||
webdavSelected();
|
||||
await initManager();
|
||||
|
||||
const downloadId = transferManager.queueDownload(makeBook());
|
||||
expect(downloadId).toBeTruthy();
|
||||
|
||||
const replicaId = transferManager.queueReplicaUpload(
|
||||
'font',
|
||||
'font-1',
|
||||
'A Font',
|
||||
[{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }],
|
||||
'Data' as never,
|
||||
);
|
||||
expect(replicaId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings-loaded barrier', () => {
|
||||
test('a pending book upload does not execute before settings hydration', async () => {
|
||||
settingsNotLoaded();
|
||||
const persisted = {
|
||||
transfers: { t1: makeTransferItem() },
|
||||
isQueuePaused: false,
|
||||
};
|
||||
localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted));
|
||||
|
||||
const appService = makeAppService();
|
||||
await initManager(appService, [makeBook()]);
|
||||
await flushAsync();
|
||||
|
||||
expect(appService['uploadBook']).not.toHaveBeenCalled();
|
||||
expect(useTransferStore.getState().transfers['t1']?.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('the deferred upload executes once settings hydrate with readest selected', async () => {
|
||||
settingsNotLoaded();
|
||||
localStorage.setItem(
|
||||
'readest_transfer_queue',
|
||||
JSON.stringify({ transfers: { t1: makeTransferItem() }, isQueuePaused: false }),
|
||||
);
|
||||
|
||||
const appService = makeAppService();
|
||||
await initManager(appService, [makeBook()]);
|
||||
await flushAsync();
|
||||
expect(appService['uploadBook']).not.toHaveBeenCalled();
|
||||
|
||||
settingsLoaded();
|
||||
await flushAsync();
|
||||
|
||||
expect(appService['uploadBook']).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('replica transfers are not stalled by the barrier', async () => {
|
||||
settingsNotLoaded();
|
||||
const appService = makeAppService();
|
||||
await initManager(appService);
|
||||
|
||||
transferManager.queueReplicaDownload(
|
||||
'font',
|
||||
'font-1',
|
||||
'A Font',
|
||||
[{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }],
|
||||
'Data' as never,
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(appService['downloadReplicaFile']).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy cancellation on restore/reconcile', () => {
|
||||
test('pre-switch pending book uploads are policy-cancelled on restore; downloads and replicas survive', async () => {
|
||||
webdavSelected();
|
||||
const persisted = {
|
||||
transfers: {
|
||||
up1: makeTransferItem({ id: 'up1' }),
|
||||
down1: makeTransferItem({ id: 'down1', type: 'download', bookHash: 'hash2' }),
|
||||
rep1: makeTransferItem({
|
||||
id: 'rep1',
|
||||
kind: 'replica',
|
||||
type: 'upload',
|
||||
bookHash: '',
|
||||
replicaKind: 'font',
|
||||
replicaId: 'font-1',
|
||||
replicaFiles: [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }],
|
||||
replicaBase: 'Data' as never,
|
||||
}),
|
||||
},
|
||||
isQueuePaused: true,
|
||||
};
|
||||
localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted));
|
||||
|
||||
await initManager();
|
||||
await flushAsync();
|
||||
|
||||
const transfers = useTransferStore.getState().transfers;
|
||||
expect(transfers['up1']?.status).toBe('cancelled');
|
||||
expect(transfers['up1']?.cancelReason).toBe('policy');
|
||||
expect(transfers['down1']?.status).toBe('pending');
|
||||
expect(transfers['rep1']?.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('switching providers after init policy-cancels pending book uploads', async () => {
|
||||
await initManager();
|
||||
useTransferStore.getState().pauseQueue();
|
||||
const id = transferManager.queueUpload(makeBook())!;
|
||||
|
||||
webdavSelected();
|
||||
await flushAsync();
|
||||
|
||||
const transfer = useTransferStore.getState().transfers[id];
|
||||
expect(transfer?.status).toBe('cancelled');
|
||||
expect(transfer?.cancelReason).toBe('policy');
|
||||
});
|
||||
|
||||
test('policy-cancelled rows from a previous session are pruned on restore', async () => {
|
||||
const persisted = {
|
||||
transfers: {
|
||||
old1: makeTransferItem({ id: 'old1', status: 'cancelled', cancelReason: 'policy' }),
|
||||
user1: makeTransferItem({ id: 'user1', status: 'cancelled' }),
|
||||
},
|
||||
isQueuePaused: false,
|
||||
};
|
||||
localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted));
|
||||
|
||||
await initManager();
|
||||
|
||||
const transfers = useTransferStore.getState().transfers;
|
||||
expect(transfers['old1']).toBeUndefined();
|
||||
expect(transfers['user1']).toBeDefined();
|
||||
});
|
||||
|
||||
test('legacy persisted entries without kind or cancelReason restore cleanly', async () => {
|
||||
const legacy = makeTransferItem({ id: 'l1', status: 'completed' }) as Partial<TransferItem>;
|
||||
delete legacy.kind;
|
||||
localStorage.setItem(
|
||||
'readest_transfer_queue',
|
||||
JSON.stringify({ transfers: { l1: legacy }, isQueuePaused: false }),
|
||||
);
|
||||
|
||||
await initManager();
|
||||
|
||||
const restored = useTransferStore.getState().transfers['l1'];
|
||||
expect(restored?.kind).toBe('book');
|
||||
expect(restored?.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelled bucket accounting', () => {
|
||||
test('policy-cancelled rows are excluded from failed stats and getFailedTransfers; user-cancelled stay', () => {
|
||||
useTransferStore.setState({
|
||||
transfers: {
|
||||
p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }),
|
||||
u1: makeTransferItem({ id: 'u1', status: 'cancelled', cancelReason: 'user' }),
|
||||
f1: makeTransferItem({ id: 'f1', status: 'failed' }),
|
||||
},
|
||||
});
|
||||
|
||||
const store = useTransferStore.getState();
|
||||
const failedIds = store.getFailedTransfers().map((t) => t.id);
|
||||
expect(failedIds).toContain('u1');
|
||||
expect(failedIds).toContain('f1');
|
||||
expect(failedIds).not.toContain('p1');
|
||||
expect(store.getQueueStats().failed).toBe(2);
|
||||
});
|
||||
|
||||
test('retryAllFailed does not resurrect policy-cancelled rows', async () => {
|
||||
webdavSelected();
|
||||
await initManager();
|
||||
useTransferStore.setState({
|
||||
transfers: {
|
||||
p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }),
|
||||
},
|
||||
});
|
||||
|
||||
transferManager.retryAllFailed();
|
||||
await flushAsync();
|
||||
|
||||
expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('retryTransfer no-ops on a policy-cancelled row', async () => {
|
||||
webdavSelected();
|
||||
await initManager();
|
||||
useTransferStore.setState({
|
||||
transfers: {
|
||||
p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }),
|
||||
},
|
||||
});
|
||||
|
||||
transferManager.retryTransfer('p1');
|
||||
await flushAsync();
|
||||
|
||||
expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('user cancelTransfer records cancelReason user', async () => {
|
||||
await initManager();
|
||||
useTransferStore.getState().pauseQueue();
|
||||
const id = transferManager.queueUpload(makeBook())!;
|
||||
|
||||
transferManager.cancelTransfer(id);
|
||||
|
||||
const transfer = useTransferStore.getState().transfers[id];
|
||||
expect(transfer?.status).toBe('cancelled');
|
||||
expect(transfer?.cancelReason).toBe('user');
|
||||
});
|
||||
|
||||
test('gate-off reconcile settles: a force-pended policy row is re-cancelled without looping', async () => {
|
||||
webdavSelected();
|
||||
await initManager();
|
||||
useTransferStore.setState({
|
||||
transfers: {
|
||||
p1: makeTransferItem({ id: 'p1', status: 'pending', cancelReason: 'policy' }),
|
||||
},
|
||||
});
|
||||
|
||||
await (transferManager as unknown as { processQueue: () => Promise<void> }).processQueue();
|
||||
await flushAsync();
|
||||
|
||||
const transfer = useTransferStore.getState().transfers['p1'];
|
||||
expect(transfer?.status).toBe('cancelled');
|
||||
expect(useTransferStore.getState().getPendingTransfers()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quota failure handling', () => {
|
||||
test('quota 403 fails immediately with zero retries', async () => {
|
||||
const appService = makeAppService({
|
||||
uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')),
|
||||
});
|
||||
const book = makeBook();
|
||||
await initManager(appService, [book]);
|
||||
|
||||
transferManager.queueUpload(book);
|
||||
await flushAsync();
|
||||
|
||||
const transfers = Object.values(useTransferStore.getState().transfers);
|
||||
expect(transfers).toHaveLength(1);
|
||||
expect(transfers[0]?.status).toBe('failed');
|
||||
expect(transfers[0]?.retryCount).toBe(0);
|
||||
expect(appService['uploadBook']).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a batch of quota failures produces one summary toast, not one per book', async () => {
|
||||
const appService = makeAppService({
|
||||
uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')),
|
||||
});
|
||||
const books = [makeBook(), makeBook({ hash: 'hash2' }), makeBook({ hash: 'hash3' })];
|
||||
await initManager(appService, books);
|
||||
|
||||
transferManager.queueBatchUploads(books);
|
||||
await flushAsync(10000);
|
||||
|
||||
const dispatched = vi.mocked(eventDispatcher.dispatch).mock.calls.filter(
|
||||
([event, payload]) =>
|
||||
event === 'toast' &&
|
||||
String((payload as { message?: string })?.message ?? '')
|
||||
.toLowerCase()
|
||||
.includes('quota'),
|
||||
);
|
||||
expect(dispatched).toHaveLength(1);
|
||||
expect(String((dispatched[0]![1] as { message: string }).message)).toContain('3');
|
||||
});
|
||||
|
||||
test('non-quota errors keep the existing retry behavior', async () => {
|
||||
const appService = makeAppService({
|
||||
uploadBook: vi.fn().mockRejectedValue(new Error('network boom')),
|
||||
});
|
||||
const book = makeBook();
|
||||
await initManager(appService, [book]);
|
||||
|
||||
transferManager.queueUpload(book);
|
||||
await flushAsync(60000);
|
||||
|
||||
const transfer = Object.values(useTransferStore.getState().transfers)[0];
|
||||
expect(transfer?.status).toBe('failed');
|
||||
expect(transfer?.retryCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
import { useTransferStore, TransferItem } from '@/store/transferStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
// The transferManager module is a singleton, so we need to mock its
|
||||
@@ -105,6 +107,16 @@ beforeEach(() => {
|
||||
resetTransferManager();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
// Book uploads are gated on the selected cloud sync provider and
|
||||
// deferred until settings hydrate; hydrate with Readest Cloud selected
|
||||
// so the pre-gating behavior under test is preserved.
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
version: 1,
|
||||
webdav: { enabled: false },
|
||||
googleDrive: { enabled: false },
|
||||
} as SystemSettings,
|
||||
});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,12 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { TransferItem, TransferStatus, useTransferStore } from '@/store/transferStore';
|
||||
import {
|
||||
TransferItem,
|
||||
TransferStatus,
|
||||
isFailedLikeTransfer,
|
||||
useTransferStore,
|
||||
} from '@/store/transferStore';
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -109,7 +114,10 @@ const TransferItemRow: React.FC<{
|
||||
<span className='text-error'>{transfer.error || _('Failed')}</span>
|
||||
)}
|
||||
{transfer.status === 'completed' && (completedLabel[transfer.type] || _('Completed'))}
|
||||
{transfer.status === 'cancelled' && _('Cancelled')}
|
||||
{transfer.status === 'cancelled' &&
|
||||
(transfer.cancelReason === 'policy'
|
||||
? `${_('Cancelled')} · ${_('Cloud provider switched')}`
|
||||
: _('Cancelled'))}
|
||||
{' · '}
|
||||
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
|
||||
</div>
|
||||
@@ -125,7 +133,7 @@ const TransferItemRow: React.FC<{
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-1'>
|
||||
{(transfer.status === 'failed' || transfer.status === 'cancelled') && (
|
||||
{isFailedLikeTransfer(transfer) && (
|
||||
<button
|
||||
onClick={() => onRetry(transfer.id)}
|
||||
className='btn btn-ghost btn-sm btn-circle'
|
||||
@@ -197,7 +205,7 @@ const TransferQueuePanel: React.FC = () => {
|
||||
case 'completed':
|
||||
return t.status === 'completed';
|
||||
case 'failed':
|
||||
return t.status === 'failed' || t.status === 'cancelled';
|
||||
return isFailedLikeTransfer(t) || t.status === 'cancelled';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createFileSyncProvider,
|
||||
type FileSyncBackendKind,
|
||||
} from '@/services/sync/file/providerRegistry';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
|
||||
/**
|
||||
* Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
|
||||
@@ -55,11 +56,8 @@ export const useLibraryFileSync = () => {
|
||||
const { userProfilePlan } = useQuotaStats();
|
||||
|
||||
// The single active cloud provider (WebDAV and Google Drive are exclusive).
|
||||
const activeKind: FileSyncBackendKind | null = settings.webdav?.enabled
|
||||
? 'webdav'
|
||||
: settings.googleDrive?.enabled
|
||||
? 'gdrive'
|
||||
: null;
|
||||
const provider = getCloudSyncProvider(settings);
|
||||
const activeKind: FileSyncBackendKind | null = provider === 'readest' ? null : provider;
|
||||
|
||||
const isAllowed = isCloudSyncAllowed(userProfilePlan ?? 'free');
|
||||
const isReady = useMemo(() => {
|
||||
|
||||
@@ -22,6 +22,10 @@ import { eventDispatcher } from '@/utils/event';
|
||||
import { ProgressPayload } from '@/utils/transfer';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import {
|
||||
getCloudSyncProvider,
|
||||
isReadestCloudStorageActive,
|
||||
} from '@/services/sync/cloudSyncProvider';
|
||||
import { getDirPath, getFilename, joinPaths } from '@/utils/path';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
@@ -954,6 +958,19 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// An explicit Upload action must never silently no-op: explain the
|
||||
// provider gate when it is the reason the queue refused the book.
|
||||
const currentSettings = useSettingsStore.getState().settings;
|
||||
if (!isReadestCloudStorageActive(currentSettings)) {
|
||||
const provider = getCloudSyncProvider(currentSettings);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 5000,
|
||||
message: _('Uploads to Readest Cloud are paused while {{provider}} sync is selected', {
|
||||
provider: provider === 'gdrive' ? 'Google Drive' : 'WebDAV',
|
||||
}),
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomOPDSStore } from '@/store/customOPDSStore';
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
|
||||
import { useTransferQueue } from '@/hooks/useTransferQueue';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useLibrary } from '@/hooks/useLibrary';
|
||||
@@ -625,7 +626,13 @@ export default function BrowserPage() {
|
||||
console.error('OPDS: failed to update source map:', sourceMapError);
|
||||
}
|
||||
}
|
||||
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
||||
if (
|
||||
user &&
|
||||
book &&
|
||||
!book.uploadedAt &&
|
||||
settings.autoUpload &&
|
||||
isReadestCloudStorageActive(settings)
|
||||
) {
|
||||
setTimeout(() => {
|
||||
transferManager.queueUpload(book);
|
||||
}, 3000);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
createFileSyncProvider,
|
||||
type FileSyncBackendKind,
|
||||
} from '@/services/sync/file/providerRegistry';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
import { removeBookNoteOverlays } from '../utils/annotatorUtil';
|
||||
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
||||
|
||||
@@ -79,11 +80,8 @@ export const useFileSync = (bookKey: string) => {
|
||||
const progress = useBookProgress(bookKey);
|
||||
|
||||
// The single active cloud provider (WebDAV and Google Drive are exclusive).
|
||||
const activeKind: FileSyncBackendKind | null = settings.webdav?.enabled
|
||||
? 'webdav'
|
||||
: settings.googleDrive?.enabled
|
||||
? 'gdrive'
|
||||
: null;
|
||||
const provider = getCloudSyncProvider(settings);
|
||||
const activeKind: FileSyncBackendKind | null = provider === 'readest' ? null : provider;
|
||||
const providerSettings = activeKind === 'gdrive' ? settings.googleDrive : settings.webdav;
|
||||
|
||||
/** Flips true on the first local change after a push, false right before each push. */
|
||||
|
||||
@@ -8,12 +8,26 @@ import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry'
|
||||
* (WebDAV creds, the Drive keychain token) is left untouched, so switching back
|
||||
* to a previously-configured provider needs no re-entry; only an explicit
|
||||
* Disconnect tears a provider's config down.
|
||||
*
|
||||
* Activating a provider (disabled -> enabled) also turns its `syncBooks` on:
|
||||
* the selected provider owns the book-file channel — native Readest Cloud
|
||||
* uploads gate off — so leaving syncBooks at its `false` default would back
|
||||
* books up nowhere. An explicit opt-out while the provider stays active is
|
||||
* respected (re-activation of an already-active provider changes nothing).
|
||||
*/
|
||||
export const withActiveCloudProvider = (
|
||||
settings: SystemSettings,
|
||||
active: FileSyncBackendKind | null,
|
||||
): SystemSettings => ({
|
||||
...settings,
|
||||
webdav: { ...settings.webdav, enabled: active === 'webdav' },
|
||||
googleDrive: { ...settings.googleDrive, enabled: active === 'gdrive' },
|
||||
webdav: {
|
||||
...settings.webdav,
|
||||
enabled: active === 'webdav',
|
||||
...(active === 'webdav' && !settings.webdav?.enabled ? { syncBooks: true } : {}),
|
||||
},
|
||||
googleDrive: {
|
||||
...settings.googleDrive,
|
||||
enabled: active === 'gdrive',
|
||||
...(active === 'gdrive' && !settings.googleDrive?.enabled ? { syncBooks: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { syncSubscribedCatalogs } from '@/services/opds';
|
||||
import { AUTO_CHECK_INTERVAL_MS } from '@/services/opds/types';
|
||||
@@ -51,7 +52,12 @@ export function useOPDSSubscriptions() {
|
||||
// autoUpload setting on. Delay so the transfer manager has a chance
|
||||
// to finish initializing if this fires right after libraryLoaded.
|
||||
const { settings: currentSettings } = useSettingsStore.getState();
|
||||
if (user && currentSettings.autoUpload && uniqueNewBooks.length > 0) {
|
||||
if (
|
||||
user &&
|
||||
currentSettings.autoUpload &&
|
||||
isReadestCloudStorageActive(currentSettings) &&
|
||||
uniqueNewBooks.length > 0
|
||||
) {
|
||||
const booksToUpload = uniqueNewBooks.filter((b) => !b.uploadedAt);
|
||||
if (booksToUpload.length > 0) {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { QuotaType, UserPlan } from '@/types/quota';
|
||||
import { getStoragePlanData, getTranslationPlanData, getUserProfilePlan } from '@/utils/access';
|
||||
import { setCachedUserPlan } from '@/services/sync/cloudSyncProvider';
|
||||
import { useTranslation } from './useTranslation';
|
||||
|
||||
export const useQuotaStats = (briefName = false) => {
|
||||
@@ -41,7 +42,12 @@ export const useQuotaStats = (briefName = false) => {
|
||||
unit: 'K',
|
||||
resetAt: translationResetAt,
|
||||
};
|
||||
setUserProfilePlan(getUserProfilePlan(token));
|
||||
const profilePlan = getUserProfilePlan(token);
|
||||
setUserProfilePlan(profilePlan);
|
||||
// Non-React modules (transferManager, syncCategories) need the plan
|
||||
// synchronously for the cloud-sync provider gate; cache it here, the
|
||||
// one place the plan is resolved from the JWT.
|
||||
setCachedUserPlan(profilePlan);
|
||||
setQuotas([storageQuota, translationQuota]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
@@ -2,10 +2,15 @@ import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from './useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTransferStore, TransferType } from '@/store/transferStore';
|
||||
import { useTransferStore, TransferType, isFailedLikeTransfer } from '@/store/transferStore';
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { Book } from '@/types/book';
|
||||
|
||||
// The `libraryLoaded = true` default lets surfaces like SettingsMenu and
|
||||
// TransferQueuePanel initialize the manager on mount, before settings
|
||||
// hydrate. That is safe: the manager defers book uploads until
|
||||
// settings.version is truthy and reconciles them against the selected
|
||||
// cloud sync provider once it is (see transferManager.isSettingsLoaded).
|
||||
export function useTransferQueue(libraryLoaded = true, delayInit = 0) {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const _ = useTranslation();
|
||||
@@ -92,7 +97,7 @@ export function useTransferQueue(libraryLoaded = true, delayInit = 0) {
|
||||
pending: transferList.filter((t) => t.status === 'pending').length,
|
||||
active: transferList.filter((t) => t.status === 'in_progress').length,
|
||||
completed: transferList.filter((t) => t.status === 'completed').length,
|
||||
failed: transferList.filter((t) => t.status === 'failed' || t.status === 'cancelled').length,
|
||||
failed: transferList.filter(isFailedLikeTransfer).length,
|
||||
total: transferList.length,
|
||||
};
|
||||
}, [transfers]);
|
||||
@@ -106,9 +111,7 @@ export function useTransferQueue(libraryLoaded = true, delayInit = 0) {
|
||||
}, [transfers]);
|
||||
|
||||
const failedTransfers = useMemo(() => {
|
||||
return Object.values(transfers).filter(
|
||||
(t) => t.status === 'failed' || t.status === 'cancelled',
|
||||
);
|
||||
return Object.values(transfers).filter(isFailedLikeTransfer);
|
||||
}, [transfers]);
|
||||
|
||||
const completedTransfers = useMemo(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { applySyncBooksAutoEnable } from '@/services/sync/cloudSyncProvider';
|
||||
import {
|
||||
AppPlatform,
|
||||
AppService,
|
||||
@@ -68,7 +69,7 @@ export abstract class BaseAppService implements AppService {
|
||||
storefrontRegionCode: string | null = null;
|
||||
isOnlineCatalogsAccessible = true;
|
||||
|
||||
protected CURRENT_MIGRATION_VERSION = 20251124;
|
||||
protected CURRENT_MIGRATION_VERSION = 20260706;
|
||||
|
||||
protected abstract fs: FileSystem;
|
||||
protected abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
|
||||
@@ -100,7 +101,10 @@ export abstract class BaseAppService implements AppService {
|
||||
opts?: DatabaseOpts,
|
||||
): Promise<DatabaseService>;
|
||||
|
||||
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
|
||||
protected async runMigrations(
|
||||
lastMigrationVersion: number,
|
||||
settings?: SystemSettings,
|
||||
): Promise<void> {
|
||||
if (lastMigrationVersion < 20251124) {
|
||||
try {
|
||||
await this.migrate20251124();
|
||||
@@ -108,6 +112,26 @@ export abstract class BaseAppService implements AppService {
|
||||
console.error('Error migrating to version 20251124:', error);
|
||||
}
|
||||
}
|
||||
if (lastMigrationVersion < 20260706 && settings) {
|
||||
try {
|
||||
this.migrate20260706(settings);
|
||||
} catch (error) {
|
||||
console.error('Error migrating to version 20260706:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Users with WebDAV/Drive already enabled become "third-party selected"
|
||||
* when cloud sync provider selection ships, gating native Readest Cloud
|
||||
* uploads off; flip the selected provider's syncBooks on once so their
|
||||
* books keep backing up somewhere. Mutates the caller's settings
|
||||
* snapshot, which the caller persists together with migrationVersion.
|
||||
*/
|
||||
private migrate20260706(settings: SystemSettings): void {
|
||||
if (applySyncBooksAutoEnable(settings)) {
|
||||
console.log('Migration 20260706: enabled syncBooks for the selected cloud sync provider.');
|
||||
}
|
||||
}
|
||||
|
||||
private async migrate20251124(): Promise<void> {
|
||||
|
||||
@@ -51,6 +51,8 @@ export const BACKUP_SETTINGS_BLACKLIST = [
|
||||
'hardcover.lastSyncedAt',
|
||||
'googleDrive.deviceId',
|
||||
'googleDrive.lastSyncedAt',
|
||||
'webdav.deviceId',
|
||||
'webdav.lastSyncedAt',
|
||||
// Transient runtime state — book keys may not exist post-restore; screen
|
||||
// brightness is live device state.
|
||||
'lastOpenBooks',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Book, BookLookupIndex } from '@/types/book';
|
||||
import type { AppService, OsPlatform } from '@/types/system';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
|
||||
import { normalizeFilePathForIndex } from '@/services/bookService';
|
||||
import { isContentURI, isValidURL } from '@/utils/misc';
|
||||
import { isPseStreamFileName } from '@/services/opds/pseStream';
|
||||
@@ -237,11 +238,16 @@ export async function ingestFile(
|
||||
// they are equivalent to a hash-copy book — only the local storage
|
||||
// location differs. uploadBook reads straight from book.filePath in that
|
||||
// case; downloads on other devices land in Books/<hash>/ as a normal copy.
|
||||
// When a third-party provider (WebDAV/Drive) is the selected cloud sync
|
||||
// backend, Readest Cloud storage is not written to at all — the file-sync
|
||||
// engine mirrors the import instead (including Sent books, which then reach
|
||||
// other devices via that provider when its syncBooks toggle is on).
|
||||
if (
|
||||
!opts.transient &&
|
||||
isLoggedIn &&
|
||||
!book.uploadedAt &&
|
||||
(opts.forceUpload || settings.autoUpload)
|
||||
(opts.forceUpload || settings.autoUpload) &&
|
||||
isReadestCloudStorageActive(settings)
|
||||
) {
|
||||
transferManager.queueUpload(book);
|
||||
}
|
||||
|
||||
@@ -658,7 +658,7 @@ export class NativeAppService extends BaseAppService {
|
||||
const settings = await this.loadSettings();
|
||||
const lastMigrationVersion = settings.migrationVersion || 0;
|
||||
|
||||
await super.runMigrations(lastMigrationVersion);
|
||||
await super.runMigrations(lastMigrationVersion, settings);
|
||||
|
||||
if (lastMigrationVersion < 20251029) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
import type { UserPlan } from '@/types/quota';
|
||||
import { isCloudSyncAllowed } from '@/utils/access';
|
||||
import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
|
||||
|
||||
/**
|
||||
* The user's selected cloud sync provider for library data (book files,
|
||||
* book rows, progress, notes). 'readest' is the native Readest Cloud;
|
||||
* the others are the third-party file-sync backends. Account-level data
|
||||
* (settings replicas, reading stats, dictionaries/fonts, translations)
|
||||
* always syncs via Readest Cloud regardless of this selection.
|
||||
*
|
||||
* The selection is DERIVED from the existing per-device enabled flags —
|
||||
* there is no separate persisted field, so it inherits the device-local
|
||||
* semantics of `webdav.enabled` / `googleDrive.enabled` and needs no
|
||||
* migration. `withActiveCloudProvider` keeps the flags mutually
|
||||
* exclusive; if both are ever enabled (hand-edited or restored
|
||||
* settings), WebDAV wins deterministically.
|
||||
*/
|
||||
export type CloudSyncProviderKind = 'readest' | FileSyncBackendKind;
|
||||
|
||||
export interface CloudSyncGate {
|
||||
provider: CloudSyncProviderKind;
|
||||
/**
|
||||
* True when a third-party provider is selected but cloud sync is not
|
||||
* allowed for the user's plan. Paused means paused: Readest Cloud
|
||||
* uploads do NOT silently resume (that would push a possibly-private
|
||||
* library to Readest servers without consent and reintroduce the
|
||||
* #4959 quota path); the UI surfaces the paused state instead.
|
||||
*/
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export const getCloudSyncProvider = (
|
||||
settings: SystemSettings | null | undefined,
|
||||
): CloudSyncProviderKind =>
|
||||
settings?.webdav?.enabled ? 'webdav' : settings?.googleDrive?.enabled ? 'gdrive' : 'readest';
|
||||
|
||||
/**
|
||||
* `isCloudSyncAllowed` needs the UserPlan, which comes from the async
|
||||
* auth JWT — non-React modules (transferManager, syncCategories) cannot
|
||||
* resolve it synchronously. The plan-resolution flow (auth / quota
|
||||
* refresh) writes the latest plan here; gate checks read it back.
|
||||
* Defaults to 'free', the most restrictive plan, so a gate evaluated
|
||||
* before the first auth resolution can only be too cautious, never too
|
||||
* permissive.
|
||||
*/
|
||||
let cachedUserPlan: UserPlan = 'free';
|
||||
|
||||
export const setCachedUserPlan = (plan: UserPlan | undefined): void => {
|
||||
cachedUserPlan = plan ?? 'free';
|
||||
};
|
||||
|
||||
export const getCachedUserPlan = (): UserPlan => cachedUserPlan;
|
||||
|
||||
export const resolveCloudSyncGate = (
|
||||
settings: SystemSettings | null | undefined,
|
||||
plan: UserPlan = cachedUserPlan,
|
||||
): CloudSyncGate => {
|
||||
const provider = getCloudSyncProvider(settings);
|
||||
if (provider !== 'readest' && !isCloudSyncAllowed(plan)) {
|
||||
return { provider, paused: true };
|
||||
}
|
||||
return { provider, paused: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* One-time upgrade migration helper (appService migrate20260706): users
|
||||
* who already had WebDAV/Drive enabled before provider selection shipped
|
||||
* become "third-party selected" on upgrade, which gates native Readest
|
||||
* Cloud uploads off — with syncBooks at its old `false` default their
|
||||
* books would back up nowhere. Flip syncBooks on for the SELECTED
|
||||
* provider only. Mutates `settings` in place (the migration runner saves
|
||||
* the same snapshot afterwards) and returns whether anything changed.
|
||||
*/
|
||||
export const applySyncBooksAutoEnable = (settings: SystemSettings): boolean => {
|
||||
const provider = getCloudSyncProvider(settings);
|
||||
if (provider === 'webdav' && settings.webdav && !settings.webdav.syncBooks) {
|
||||
settings.webdav = { ...settings.webdav, syncBooks: true };
|
||||
return true;
|
||||
}
|
||||
if (provider === 'gdrive' && settings.googleDrive && !settings.googleDrive.syncBooks) {
|
||||
settings.googleDrive = { ...settings.googleDrive, syncBooks: true };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether Readest Cloud storage may be written to (book file uploads).
|
||||
* Strictly: only when Readest Cloud is the selected provider. A selected
|
||||
* third-party provider — active or paused — means no Readest Cloud
|
||||
* uploads.
|
||||
*/
|
||||
export const isReadestCloudStorageActive = (
|
||||
settings: SystemSettings | null | undefined,
|
||||
plan?: UserPlan,
|
||||
): boolean => resolveCloudSyncGate(settings, plan).provider === 'readest';
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService, BaseDir } from '@/types/system';
|
||||
import { useTransferStore, TransferItem, ReplicaTransferFile } from '@/store/transferStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
|
||||
import { TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { ProgressHandler, ProgressPayload } from '@/utils/transfer';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
@@ -8,12 +10,18 @@ import { getTransferMessages } from './transferMessages';
|
||||
|
||||
const TRANSFER_QUEUE_KEY = 'readest_transfer_queue';
|
||||
const RETRY_DELAY_BASE_MS = 2000;
|
||||
// Quota failures in a batch import arrive one per book as transfers drain;
|
||||
// collapse them into one summary toast per burst instead of N identical toasts.
|
||||
const QUOTA_TOAST_FLUSH_MS = 1500;
|
||||
|
||||
interface PersistedQueueData {
|
||||
schemaVersion?: number;
|
||||
transfers: Record<string, TransferItem>;
|
||||
isQueuePaused: boolean;
|
||||
}
|
||||
|
||||
const QUEUE_SCHEMA_VERSION = 1;
|
||||
|
||||
class TransferManager {
|
||||
private static instance: TransferManager;
|
||||
private appService: AppService | null = null;
|
||||
@@ -23,6 +31,9 @@ class TransferManager {
|
||||
private getLibrary: (() => Book[]) | null = null;
|
||||
private updateBook: ((book: Book) => Promise<void>) | null = null;
|
||||
private _: TranslationFunc | null = null;
|
||||
private settingsUnsub: (() => void) | null = null;
|
||||
private quotaFailureCount = 0;
|
||||
private quotaToastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readyResolve: () => void = () => {};
|
||||
private readyPromise: Promise<void> = new Promise<void>((resolve) => {
|
||||
this.readyResolve = resolve;
|
||||
@@ -30,6 +41,52 @@ class TransferManager {
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Settings hydrate asynchronously at app start (`settings` begins as
|
||||
* `{}`); `settings.version` truthiness is the loaded signal (same
|
||||
* convention as useSync). Before hydration the provider is unknown, so
|
||||
* book uploads are deferred rather than judged — acting on unknown
|
||||
* settings could both mis-cancel and mis-allow. Replica transfers and
|
||||
* downloads are never deferred: they are not provider-gated and the
|
||||
* boot-time replica pull relies on waitUntilReady().
|
||||
*/
|
||||
private isSettingsLoaded(): boolean {
|
||||
return !!useSettingsStore.getState().settings?.version;
|
||||
}
|
||||
|
||||
private isBookUploadAllowed(): boolean {
|
||||
return isReadestCloudStorageActive(useSettingsStore.getState().settings);
|
||||
}
|
||||
|
||||
private isDeferredBookUpload(t: TransferItem): boolean {
|
||||
return t.kind === 'book' && t.type === 'upload' && !this.isSettingsLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending book uploads when Readest Cloud is not the selected
|
||||
* provider. Idempotent (acts on pending rows only) — safe to run on
|
||||
* every processQueue pass, which also re-settles rows another window
|
||||
* or a rogue retry re-pended. Cancellation is visible ('cancelled'
|
||||
* with cancelReason 'policy'), never a silent drop.
|
||||
*/
|
||||
private reconcileUploadsWithProvider(): void {
|
||||
if (!this.isSettingsLoaded() || this.isBookUploadAllowed()) return;
|
||||
|
||||
const store = useTransferStore.getState();
|
||||
const gated = store
|
||||
.getPendingTransfers()
|
||||
.filter((t) => t.kind === 'book' && t.type === 'upload');
|
||||
if (gated.length === 0) return;
|
||||
|
||||
gated.forEach((t) => {
|
||||
store.setTransferStatus(t.id, 'cancelled', undefined, 'policy');
|
||||
});
|
||||
console.info(
|
||||
`[cloudSync] cancelled ${gated.length} pending Readest Cloud upload(s): third-party provider selected`,
|
||||
);
|
||||
this.persistQueue();
|
||||
}
|
||||
|
||||
static getInstance(): TransferManager {
|
||||
if (!TransferManager.instance) {
|
||||
TransferManager.instance = new TransferManager();
|
||||
@@ -50,9 +107,18 @@ class TransferManager {
|
||||
this.updateBook = updateBook;
|
||||
this._ = translationFn;
|
||||
await this.loadPersistedQueue();
|
||||
this.reconcileUploadsWithProvider();
|
||||
this.isInitialized = true;
|
||||
this.readyResolve();
|
||||
|
||||
// Re-gate when settings hydrate or the selected provider changes.
|
||||
this.settingsUnsub?.();
|
||||
this.settingsUnsub = useSettingsStore.subscribe((state, prev) => {
|
||||
if (state.settings === prev.settings) return;
|
||||
this.reconcileUploadsWithProvider();
|
||||
this.processQueue();
|
||||
});
|
||||
|
||||
// Start processing queue
|
||||
this.processQueue();
|
||||
}
|
||||
@@ -77,6 +143,13 @@ class TransferManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Readest Cloud storage is not written to while a third-party
|
||||
// provider is selected. Before settings hydrate the entry is queued
|
||||
// and deferred; the reconcile on hydration decides its fate.
|
||||
if (this.isSettingsLoaded() && !this.isBookUploadAllowed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = useTransferStore.getState();
|
||||
|
||||
// Check if already queued or in progress
|
||||
@@ -222,7 +295,7 @@ class TransferManager {
|
||||
this.abortControllers.delete(transferId);
|
||||
}
|
||||
|
||||
useTransferStore.getState().setTransferStatus(transferId, 'cancelled');
|
||||
useTransferStore.getState().setTransferStatus(transferId, 'cancelled', undefined, 'user');
|
||||
this.persistQueue();
|
||||
}
|
||||
|
||||
@@ -287,11 +360,13 @@ class TransferManager {
|
||||
}
|
||||
|
||||
private async _processQueueInternal(): Promise<void> {
|
||||
this.reconcileUploadsWithProvider();
|
||||
|
||||
const store = useTransferStore.getState();
|
||||
|
||||
if (store.isQueuePaused) return;
|
||||
|
||||
const pending = store.getPendingTransfers();
|
||||
const pending = store.getPendingTransfers().filter((t) => !this.isDeferredBookUpload(t));
|
||||
const activeCount = store.getActiveTransfers().length;
|
||||
const maxConcurrent = store.maxConcurrent;
|
||||
|
||||
@@ -308,9 +383,12 @@ class TransferManager {
|
||||
|
||||
await Promise.all(toProcess.map((transfer) => this.executeTransfer(transfer)));
|
||||
|
||||
// Check if more items to process
|
||||
// Check if more items to process. Deferred book uploads (settings
|
||||
// not yet hydrated) don't count — re-looping on them every 100ms
|
||||
// would busy-wait; the settings subscription wakes them instead.
|
||||
const newStore = useTransferStore.getState();
|
||||
if (newStore.getPendingTransfers().length > 0 && !newStore.isQueuePaused) {
|
||||
const processable = newStore.getPendingTransfers().filter((t) => !this.isDeferredBookUpload(t));
|
||||
if (processable.length > 0 && !newStore.isQueuePaused) {
|
||||
setTimeout(() => this.processQueue(), 100);
|
||||
}
|
||||
}
|
||||
@@ -373,7 +451,15 @@ class TransferManager {
|
||||
const currentStore = useTransferStore.getState();
|
||||
const currentTransfer = currentStore.transfers[transfer.id];
|
||||
|
||||
if (currentTransfer && currentTransfer.retryCount < currentTransfer.maxRetries) {
|
||||
// Quota exhaustion is permanent for this account state; retrying
|
||||
// burns three backoff rounds per book for the same 403.
|
||||
const isQuotaError = errorMessage.includes('Insufficient storage quota');
|
||||
|
||||
if (
|
||||
!isQuotaError &&
|
||||
currentTransfer &&
|
||||
currentTransfer.retryCount < currentTransfer.maxRetries
|
||||
) {
|
||||
// Schedule retry with exponential backoff
|
||||
const delay = RETRY_DELAY_BASE_MS * Math.pow(2, currentTransfer.retryCount);
|
||||
currentStore.incrementRetryCount(transfer.id);
|
||||
@@ -392,11 +478,8 @@ class TransferManager {
|
||||
type: 'error',
|
||||
message: _('Please log in to continue'),
|
||||
});
|
||||
} else if (errorMessage.includes('Insufficient storage quota')) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Insufficient storage quota'),
|
||||
});
|
||||
} else if (isQuotaError) {
|
||||
this.recordQuotaFailure();
|
||||
} else {
|
||||
const errorMessages = getTransferMessages(transfer, _).failure;
|
||||
|
||||
@@ -420,6 +503,28 @@ class TransferManager {
|
||||
}
|
||||
}
|
||||
|
||||
private recordQuotaFailure(): void {
|
||||
this.quotaFailureCount += 1;
|
||||
if (this.quotaToastTimer) clearTimeout(this.quotaToastTimer);
|
||||
this.quotaToastTimer = setTimeout(() => this.flushQuotaToast(), QUOTA_TOAST_FLUSH_MS);
|
||||
}
|
||||
|
||||
private flushQuotaToast(): void {
|
||||
const _ = this._;
|
||||
const count = this.quotaFailureCount;
|
||||
this.quotaFailureCount = 0;
|
||||
this.quotaToastTimer = null;
|
||||
if (!count || !_) return;
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message:
|
||||
count === 1
|
||||
? _('Insufficient storage quota')
|
||||
: _('{{count}} uploads failed: insufficient storage quota', { count }),
|
||||
});
|
||||
}
|
||||
|
||||
private async executeBookTransfer(
|
||||
transfer: TransferItem,
|
||||
progressHandler: (p: ProgressPayload) => void,
|
||||
@@ -561,6 +666,7 @@ class TransferManager {
|
||||
|
||||
// Persist all transfers including completed (for history)
|
||||
const data: PersistedQueueData = {
|
||||
schemaVersion: QUEUE_SCHEMA_VERSION,
|
||||
transfers: store.transfers,
|
||||
isQueuePaused: store.isQueuePaused,
|
||||
};
|
||||
|
||||
@@ -302,7 +302,7 @@ export class WebAppService extends BaseAppService {
|
||||
const settings = await this.loadSettings();
|
||||
const lastMigrationVersion = settings.migrationVersion || 0;
|
||||
|
||||
await super.runMigrations(lastMigrationVersion);
|
||||
await super.runMigrations(lastMigrationVersion, settings);
|
||||
|
||||
if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) {
|
||||
await this.saveSettings({
|
||||
|
||||
@@ -4,6 +4,15 @@ import type { BaseDir } from '@/types/system';
|
||||
export type TransferType = 'upload' | 'download' | 'delete';
|
||||
export type TransferStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
||||
export type TransferKind = 'book' | 'replica';
|
||||
/**
|
||||
* Why a transfer was cancelled. 'user' = an explicit cancel action;
|
||||
* 'policy' = the app cancelled it because Readest Cloud is not the
|
||||
* selected sync provider. Policy cancellations are not failures: they
|
||||
* are excluded from the failed bucket, Retry All, and per-item retry
|
||||
* (retrying would either no-op against the provider gate or loop
|
||||
* cancel-retry-cancel), and they are pruned on the next restore.
|
||||
*/
|
||||
export type TransferCancelReason = 'user' | 'policy';
|
||||
|
||||
export interface ReplicaTransferFile {
|
||||
logical: string;
|
||||
@@ -28,6 +37,7 @@ export interface TransferItem {
|
||||
transferredBytes: number;
|
||||
transferSpeed: number; // bytes per second
|
||||
error?: string;
|
||||
cancelReason?: TransferCancelReason;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
createdAt: number;
|
||||
@@ -76,7 +86,12 @@ interface TransferState {
|
||||
total: number,
|
||||
speed: number,
|
||||
) => void;
|
||||
setTransferStatus: (transferId: string, status: TransferStatus, error?: string) => void;
|
||||
setTransferStatus: (
|
||||
transferId: string,
|
||||
status: TransferStatus,
|
||||
error?: string,
|
||||
cancelReason?: TransferCancelReason,
|
||||
) => void;
|
||||
retryTransfer: (transferId: string) => void;
|
||||
incrementRetryCount: (transferId: string) => void;
|
||||
|
||||
@@ -118,6 +133,16 @@ const generateTransferId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single failed-bucket predicate. Every surface that shows, counts,
|
||||
* or retries "failed" transfers (store getters, useTransferQueue stats,
|
||||
* TransferQueuePanel filter/retry) must use this instead of matching
|
||||
* statuses inline, so policy cancellations stay out of the bucket
|
||||
* everywhere at once.
|
||||
*/
|
||||
export const isFailedLikeTransfer = (t: TransferItem): boolean =>
|
||||
t.status === 'failed' || (t.status === 'cancelled' && t.cancelReason !== 'policy');
|
||||
|
||||
export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
transfers: {},
|
||||
isQueuePaused: false,
|
||||
@@ -225,12 +250,15 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
setTransferStatus: (transferId, status, error) => {
|
||||
setTransferStatus: (transferId, status, error, cancelReason) => {
|
||||
set((state) => {
|
||||
const transfer = state.transfers[transferId];
|
||||
if (!transfer) return state;
|
||||
|
||||
const updates: Partial<TransferItem> = { status, error };
|
||||
if (status === 'cancelled') {
|
||||
updates.cancelReason = cancelReason ?? transfer.cancelReason ?? 'user';
|
||||
}
|
||||
|
||||
if (status === 'in_progress' && !transfer.startedAt) {
|
||||
updates.startedAt = Date.now();
|
||||
@@ -253,6 +281,9 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
set((state) => {
|
||||
const transfer = state.transfers[transferId];
|
||||
if (!transfer) return state;
|
||||
// Policy cancellations are not retryable: the provider gate would
|
||||
// re-cancel the row immediately (cancel-retry-cancel loop).
|
||||
if (transfer.status === 'cancelled' && transfer.cancelReason === 'policy') return state;
|
||||
|
||||
return {
|
||||
transfers: {
|
||||
@@ -264,6 +295,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
transferredBytes: 0,
|
||||
transferSpeed: 0,
|
||||
error: undefined,
|
||||
cancelReason: undefined,
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
},
|
||||
@@ -339,9 +371,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
},
|
||||
|
||||
getFailedTransfers: () => {
|
||||
return Object.values(get().transfers).filter(
|
||||
(t) => t.status === 'failed' || t.status === 'cancelled',
|
||||
);
|
||||
return Object.values(get().transfers).filter(isFailedLikeTransfer);
|
||||
},
|
||||
|
||||
getCompletedTransfers: () => {
|
||||
@@ -375,7 +405,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
pending: transfers.filter((t) => t.status === 'pending').length,
|
||||
active: transfers.filter((t) => t.status === 'in_progress').length,
|
||||
completed: transfers.filter((t) => t.status === 'completed').length,
|
||||
failed: transfers.filter((t) => t.status === 'failed' || t.status === 'cancelled').length,
|
||||
failed: transfers.filter(isFailedLikeTransfer).length,
|
||||
total: transfers.length,
|
||||
};
|
||||
},
|
||||
@@ -386,6 +416,11 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
||||
// Legacy rows persisted before the kind discriminator default to 'book'.
|
||||
const restoredTransfers: Record<string, TransferItem> = {};
|
||||
Object.entries(transfers).forEach(([id, transfer]) => {
|
||||
// Policy-cancelled rows are session-scoped history: a large
|
||||
// pre-switch queue must not leave hundreds of permanent
|
||||
// "Cancelled" rows in the panel and localStorage. Prune on
|
||||
// restore.
|
||||
if (transfer.status === 'cancelled' && transfer.cancelReason === 'policy') return;
|
||||
const withKind: TransferItem = { ...transfer, kind: transfer.kind ?? 'book' };
|
||||
if (withKind.status === 'in_progress') {
|
||||
restoredTransfers[id] = {
|
||||
|
||||
Reference in New Issue
Block a user