cbdc3b8f52
* 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>
380 lines
9.9 KiB
TypeScript
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 });
|
|
},
|
|
}));
|