Files
readest/apps/readest-app/src/store/transferStore.ts
T
Huang Xin cbdc3b8f52 feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* feat(sync): cross-device dictionary sync

Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.

- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
  user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
  whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
  via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
  "Book uploaded".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): boot pull and binary download path

- Defer the boot pull until TransferManager is initialized so download
  enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
  the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
  unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
  "SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
  dir; binaries previously landed at the literal lfp and openFile then
  failed with "File not found".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sync): per-page useReplicaPull hook

Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.

Also closes two refresh-loop bugs:

- Hydrate the dict store from settings BEFORE the apply loop, so the
  auto-persist doesn't clobber persisted rows that the in-memory store
  hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
  under the resolved bundle dir. Refreshing is a no-op; partial-
  download recovery still queues because some files would be missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:39:38 +02:00

380 lines
9.9 KiB
TypeScript

import { create } from 'zustand';
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';
export interface ReplicaTransferFile {
logical: string;
lfp: string;
byteSize: number;
}
export interface TransferItem {
id: string;
kind: TransferKind;
bookHash: string;
bookTitle: string;
replicaKind?: string;
replicaId?: string;
replicaReincarnation?: string;
replicaFiles?: ReplicaTransferFile[];
replicaBase?: BaseDir;
type: TransferType;
status: TransferStatus;
progress: number; // 0-100 percentage
totalBytes: number;
transferredBytes: number;
transferSpeed: number; // bytes per second
error?: string;
retryCount: number;
maxRetries: number;
createdAt: number;
startedAt?: number;
completedAt?: number;
priority: number; // Lower = higher priority
isBackground: boolean;
}
interface TransferState {
transfers: Record<string, TransferItem>;
isQueuePaused: boolean;
isTransferQueueOpen: boolean;
maxConcurrent: number;
activeCount: number;
// UI Actions
setIsTransferQueueOpen: (isOpen: boolean) => void;
// Actions
addTransfer: (
bookHash: string,
bookTitle: string,
type: TransferType,
priority?: number,
isBackground?: boolean,
) => string;
addReplicaTransfer: (
replicaKind: string,
replicaId: string,
displayTitle: string,
type: TransferType,
opts?: {
priority?: number;
isBackground?: boolean;
files?: ReplicaTransferFile[];
base?: BaseDir;
reincarnation?: string;
},
) => string;
removeTransfer: (transferId: string) => void;
updateTransferProgress: (
transferId: string,
progress: number,
transferred: number,
total: number,
speed: number,
) => void;
setTransferStatus: (transferId: string, status: TransferStatus, error?: string) => void;
retryTransfer: (transferId: string) => void;
incrementRetryCount: (transferId: string) => void;
// Queue control
pauseQueue: () => void;
resumeQueue: () => void;
clearCompleted: () => void;
clearFailed: () => void;
clearAll: () => void;
// Getters
getPendingTransfers: () => TransferItem[];
getActiveTransfers: () => TransferItem[];
getFailedTransfers: () => TransferItem[];
getCompletedTransfers: () => TransferItem[];
getTransferByBookHash: (bookHash: string, type: TransferType) => TransferItem | undefined;
getReplicaTransfer: (
replicaKind: string,
replicaId: string,
type: TransferType,
) => TransferItem | undefined;
getQueueStats: () => {
pending: number;
active: number;
completed: number;
failed: number;
total: number;
};
// Internal
setActiveCount: (count: number) => void;
// Persistence
restoreTransfers: (transfers: Record<string, TransferItem>, isQueuePaused: boolean) => void;
}
const generateTransferId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
export const useTransferStore = create<TransferState>((set, get) => ({
transfers: {},
isQueuePaused: false,
isTransferQueueOpen: false,
maxConcurrent: 2,
activeCount: 0,
setIsTransferQueueOpen: (isOpen) => set({ isTransferQueueOpen: isOpen }),
addTransfer: (bookHash, bookTitle, type, priority = 10, isBackground = false) => {
const id = generateTransferId();
const transfer: TransferItem = {
id,
kind: 'book',
bookHash,
bookTitle,
type,
status: 'pending',
progress: 0,
totalBytes: 0,
transferredBytes: 0,
transferSpeed: 0,
retryCount: 0,
maxRetries: 3,
createdAt: Date.now(),
priority,
isBackground,
};
set((state) => ({
transfers: { ...state.transfers, [id]: transfer },
}));
return id;
},
addReplicaTransfer: (replicaKind, replicaId, displayTitle, type, opts = {}) => {
const id = generateTransferId();
const transfer: TransferItem = {
id,
kind: 'replica',
bookHash: '',
bookTitle: displayTitle,
replicaKind,
replicaId,
replicaReincarnation: opts.reincarnation,
replicaFiles: opts.files,
replicaBase: opts.base,
type,
status: 'pending',
progress: 0,
totalBytes: opts.files?.reduce((sum, f) => sum + f.byteSize, 0) ?? 0,
transferredBytes: 0,
transferSpeed: 0,
retryCount: 0,
maxRetries: 3,
createdAt: Date.now(),
priority: opts.priority ?? 10,
isBackground: opts.isBackground ?? false,
};
set((state) => ({
transfers: { ...state.transfers, [id]: transfer },
}));
return id;
},
removeTransfer: (transferId) => {
set((state) => {
const { [transferId]: _, ...remaining } = state.transfers;
return { transfers: remaining };
});
},
updateTransferProgress: (transferId, progress, transferred, total, speed) => {
set((state) => {
const transfer = state.transfers[transferId];
if (!transfer) return state;
return {
transfers: {
...state.transfers,
[transferId]: {
...transfer,
progress,
transferredBytes: transferred,
totalBytes: total,
transferSpeed: speed,
},
},
};
});
},
setTransferStatus: (transferId, status, error) => {
set((state) => {
const transfer = state.transfers[transferId];
if (!transfer) return state;
const updates: Partial<TransferItem> = { status, error };
if (status === 'in_progress' && !transfer.startedAt) {
updates.startedAt = Date.now();
}
if (status === 'completed' || status === 'failed' || status === 'cancelled') {
updates.completedAt = Date.now();
}
return {
transfers: {
...state.transfers,
[transferId]: { ...transfer, ...updates },
},
};
});
},
retryTransfer: (transferId) => {
set((state) => {
const transfer = state.transfers[transferId];
if (!transfer) return state;
return {
transfers: {
...state.transfers,
[transferId]: {
...transfer,
status: 'pending',
progress: 0,
transferredBytes: 0,
transferSpeed: 0,
error: undefined,
startedAt: undefined,
completedAt: undefined,
},
},
};
});
},
incrementRetryCount: (transferId) => {
set((state) => {
const transfer = state.transfers[transferId];
if (!transfer) return state;
return {
transfers: {
...state.transfers,
[transferId]: {
...transfer,
retryCount: transfer.retryCount + 1,
},
},
};
});
},
pauseQueue: () => set({ isQueuePaused: true }),
resumeQueue: () => set({ isQueuePaused: false }),
clearCompleted: () => {
set((state) => {
const remaining: Record<string, TransferItem> = {};
Object.entries(state.transfers).forEach(([id, transfer]) => {
if (transfer.status !== 'completed') {
remaining[id] = transfer;
}
});
return { transfers: remaining };
});
},
clearFailed: () => {
set((state) => {
const remaining: Record<string, TransferItem> = {};
Object.entries(state.transfers).forEach(([id, transfer]) => {
if (transfer.status !== 'failed' && transfer.status !== 'cancelled') {
remaining[id] = transfer;
}
});
return { transfers: remaining };
});
},
clearAll: () => set({ transfers: {} }),
getPendingTransfers: () => {
return Object.values(get().transfers).filter((t) => t.status === 'pending');
},
getActiveTransfers: () => {
return Object.values(get().transfers).filter((t) => t.status === 'in_progress');
},
getFailedTransfers: () => {
return Object.values(get().transfers).filter(
(t) => t.status === 'failed' || t.status === 'cancelled',
);
},
getCompletedTransfers: () => {
return Object.values(get().transfers).filter((t) => t.status === 'completed');
},
getTransferByBookHash: (bookHash, type) => {
return Object.values(get().transfers).find(
(t) =>
t.kind === 'book' &&
t.bookHash === bookHash &&
t.type === type &&
['pending', 'in_progress'].includes(t.status),
);
},
getReplicaTransfer: (replicaKind, replicaId, type) => {
return Object.values(get().transfers).find(
(t) =>
t.kind === 'replica' &&
t.replicaKind === replicaKind &&
t.replicaId === replicaId &&
t.type === type &&
['pending', 'in_progress'].includes(t.status),
);
},
getQueueStats: () => {
const transfers = Object.values(get().transfers);
return {
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,
total: transfers.length,
};
},
setActiveCount: (count) => set({ activeCount: count }),
restoreTransfers: (transfers, isQueuePaused) => {
// Legacy rows persisted before the kind discriminator default to 'book'.
const restoredTransfers: Record<string, TransferItem> = {};
Object.entries(transfers).forEach(([id, transfer]) => {
const withKind: TransferItem = { ...transfer, kind: transfer.kind ?? 'book' };
if (withKind.status === 'in_progress') {
restoredTransfers[id] = {
...withKind,
status: 'pending',
progress: 0,
transferredBytes: 0,
transferSpeed: 0,
};
} else {
restoredTransfers[id] = withKind;
}
});
set({ transfers: restoredTransfers, isQueuePaused });
},
}));