mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 10:21:11 +01:00
Download history refactor (#700)
- Much simpler handling of downloads in the activity sidebar, and improved storage, persistence and UI behavior. - Replace `ActivityService` with direct storage on `DownloadHistoryService` and `download_requests` and removes the activity_log/activity_dismissals tables - Simplify no-auth mode by removing the fake user row pattern, handled internally - Add local download fallback so history entries can still serve files after tasks leave the queue - Downloads, requests and history are now entirely persistent between updates / restarts, and correctly tied to each user.
This commit is contained in:
@@ -135,6 +135,17 @@ type PendingOnBehalfDownload =
|
||||
actingAsUser: ActingAsUserSelection;
|
||||
};
|
||||
|
||||
const mergeTerminalBucket = (
|
||||
persistedBucket: Record<string, Book> | undefined,
|
||||
realtimeBucket: Record<string, Book> | undefined
|
||||
): Record<string, Book> | undefined => {
|
||||
const merged = {
|
||||
...(persistedBucket || {}),
|
||||
...(realtimeBucket || {}),
|
||||
};
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const { toasts, showToast, removeToast } = useToast();
|
||||
const { socket } = useSocket();
|
||||
@@ -227,6 +238,7 @@ function App() {
|
||||
});
|
||||
|
||||
const {
|
||||
activityStatus,
|
||||
requestItems,
|
||||
dismissedActivityKeys,
|
||||
historyItems,
|
||||
@@ -286,6 +298,18 @@ function App() {
|
||||
};
|
||||
}, [currentStatus, dismissedDownloadTaskIds]);
|
||||
|
||||
// Use real-time buckets for active work and merge persisted terminal buckets
|
||||
// so completed/errored entries survive restarts.
|
||||
const activitySidebarStatus = useMemo<StatusData>(() => ({
|
||||
queued: currentStatus.queued,
|
||||
resolving: currentStatus.resolving,
|
||||
locating: currentStatus.locating,
|
||||
downloading: currentStatus.downloading,
|
||||
complete: mergeTerminalBucket(activityStatus.complete, currentStatus.complete),
|
||||
error: mergeTerminalBucket(activityStatus.error, currentStatus.error),
|
||||
cancelled: mergeTerminalBucket(activityStatus.cancelled, currentStatus.cancelled),
|
||||
}), [activityStatus, currentStatus]);
|
||||
|
||||
const showRequestsTab = useMemo(() => {
|
||||
if (requestRoleIsAdmin) {
|
||||
return true;
|
||||
@@ -425,14 +449,14 @@ function App() {
|
||||
};
|
||||
|
||||
const ongoing = [
|
||||
currentStatus.queued,
|
||||
currentStatus.resolving,
|
||||
currentStatus.locating,
|
||||
currentStatus.downloading,
|
||||
activitySidebarStatus.queued,
|
||||
activitySidebarStatus.resolving,
|
||||
activitySidebarStatus.locating,
|
||||
activitySidebarStatus.downloading,
|
||||
].reduce((sum, status) => sum + countVisibleDownloads(status, { filterDismissed: false }), 0);
|
||||
|
||||
const completed = countVisibleDownloads(currentStatus.complete, { filterDismissed: true });
|
||||
const errored = countVisibleDownloads(currentStatus.error, { filterDismissed: true });
|
||||
const completed = countVisibleDownloads(activitySidebarStatus.complete, { filterDismissed: true });
|
||||
const errored = countVisibleDownloads(activitySidebarStatus.error, { filterDismissed: true });
|
||||
const pendingVisibleRequests = requestItems.filter((item) => {
|
||||
const requestId = item.requestId;
|
||||
if (!requestId || item.requestRecord?.status !== 'pending') {
|
||||
@@ -447,7 +471,7 @@ function App() {
|
||||
errored,
|
||||
pendingRequests: pendingVisibleRequests,
|
||||
};
|
||||
}, [currentStatus, dismissedActivityKeys, requestItems]);
|
||||
}, [activitySidebarStatus, dismissedActivityKeys, requestItems]);
|
||||
|
||||
|
||||
// Compute visibility states
|
||||
@@ -1602,7 +1626,7 @@ function App() {
|
||||
<ActivitySidebar
|
||||
isOpen={downloadsSidebarOpen}
|
||||
onClose={() => setDownloadsSidebarOpen(false)}
|
||||
status={currentStatus}
|
||||
status={activitySidebarStatus}
|
||||
isAdmin={requestRoleIsAdmin}
|
||||
onClearCompleted={handleClearCompleted}
|
||||
onCancel={handleCancel}
|
||||
|
||||
@@ -510,11 +510,14 @@ export const ActivityCard = ({
|
||||
? 'text-sm leading-tight min-w-0 whitespace-normal break-words'
|
||||
: 'text-sm truncate leading-tight min-w-0';
|
||||
|
||||
const titleNode =
|
||||
const canShowDownloadLink =
|
||||
item.kind === 'download' &&
|
||||
item.visualStatus === 'complete' &&
|
||||
item.downloadPath &&
|
||||
item.downloadBookId ? (
|
||||
Boolean(item.downloadBookId) &&
|
||||
Boolean(item.downloadPath);
|
||||
|
||||
const titleNode =
|
||||
canShowDownloadLink && item.downloadBookId ? (
|
||||
<a
|
||||
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(item.downloadBookId)}`)}
|
||||
className="text-sky-600 hover:underline"
|
||||
|
||||
@@ -28,6 +28,19 @@ const toNumber = (value: unknown, fallback = 0): number => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toEpochMillis = (value: unknown): number => {
|
||||
const parsed = toNumber(value, 0);
|
||||
if (parsed <= 0) {
|
||||
return 0;
|
||||
}
|
||||
// Queue `added_time` values are epoch seconds; request/history timestamps are ms.
|
||||
// Normalize to ms so sorting is consistent across merged activity items.
|
||||
if (parsed >= 1_000_000_000 && parsed < 1_000_000_000_000) {
|
||||
return parsed * 1000;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const toSourceLabel = (value: unknown): string | undefined => {
|
||||
const text = toOptionalText(value);
|
||||
if (!text) {
|
||||
@@ -94,7 +107,7 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
|
||||
statusDetail,
|
||||
progress,
|
||||
progressAnimated: isActiveDownloadStatus(visualStatus),
|
||||
timestamp: toNumber(book.added_time, 0),
|
||||
timestamp: toEpochMillis(book.added_time),
|
||||
username: toOptionalText(book.username),
|
||||
downloadBookId: book.id,
|
||||
downloadPath: toOptionalText(book.download_path),
|
||||
|
||||
@@ -282,20 +282,35 @@ export const useActivity = ({
|
||||
.map((row) => mapHistoryRowToActivityItem(row, isAdmin ? 'admin' : 'user'))
|
||||
.sort((left, right) => right.timestamp - left.timestamp);
|
||||
|
||||
// Request history entries with attached release metadata are approval artifacts,
|
||||
// not actual download outcomes. Keep history focused on concrete download results.
|
||||
const nonAttachedReleaseRequestItems = mappedItems.filter((item) => {
|
||||
if (item.kind !== 'request') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const releaseData = item.requestRecord?.release_data;
|
||||
if (!releaseData || typeof releaseData !== 'object') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.keys(releaseData as Record<string, unknown>).length === 0;
|
||||
});
|
||||
|
||||
// Download dismissals already carry linked request context; hide redundant
|
||||
// fulfilled-request history rows that would otherwise appear as "Approved".
|
||||
const requestIdsWithDownloadRows = new Set<number>();
|
||||
mappedItems.forEach((item) => {
|
||||
nonAttachedReleaseRequestItems.forEach((item) => {
|
||||
if (item.kind === 'download' && typeof item.requestId === 'number') {
|
||||
requestIdsWithDownloadRows.add(item.requestId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!requestIdsWithDownloadRows.size) {
|
||||
return mappedItems;
|
||||
return nonAttachedReleaseRequestItems;
|
||||
}
|
||||
|
||||
return mappedItems.filter((item) => {
|
||||
return nonAttachedReleaseRequestItems.filter((item) => {
|
||||
if (item.kind !== 'request' || typeof item.requestId !== 'number') {
|
||||
return true;
|
||||
}
|
||||
@@ -389,12 +404,16 @@ export const useActivity = ({
|
||||
|
||||
const handleClearHistory = useCallback(() => {
|
||||
resetActivityHistory();
|
||||
void clearActivityHistory().catch((error) => {
|
||||
console.error('Clear history failed:', error);
|
||||
void refreshActivityHistory();
|
||||
showToast('Failed to clear history', 'error');
|
||||
});
|
||||
}, [refreshActivityHistory, resetActivityHistory, showToast]);
|
||||
void clearActivityHistory()
|
||||
.then(() => {
|
||||
void refreshActivitySnapshot();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Clear history failed:', error);
|
||||
void refreshActivityHistory();
|
||||
showToast('Failed to clear history', 'error');
|
||||
});
|
||||
}, [refreshActivityHistory, refreshActivitySnapshot, resetActivityHistory, showToast]);
|
||||
|
||||
return {
|
||||
activityStatus,
|
||||
|
||||
@@ -35,7 +35,6 @@ const API = {
|
||||
cancelDownload: `${API_BASE}/download`,
|
||||
retryDownload: `${API_BASE}/download`,
|
||||
setPriority: `${API_BASE}/queue`,
|
||||
clearCompleted: `${API_BASE}/queue/clear`,
|
||||
config: `${API_BASE}/config`,
|
||||
login: `${API_BASE}/auth/login`,
|
||||
logout: `${API_BASE}/auth/logout`,
|
||||
@@ -402,10 +401,6 @@ export const retryDownload = async (id: string): Promise<void> => {
|
||||
await fetchJSON(`${API.retryDownload}/${encodeURIComponent(id)}/retry`, { method: 'POST' });
|
||||
};
|
||||
|
||||
export const clearCompleted = async (): Promise<void> => {
|
||||
await fetchJSON(`${API_BASE}/queue/clear`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const getConfig = async (): Promise<AppConfig> => {
|
||||
return fetchJSON<AppConfig>(API.config);
|
||||
};
|
||||
@@ -432,7 +427,6 @@ export interface ActivitySnapshotResponse {
|
||||
export interface ActivityDismissPayload {
|
||||
item_type: 'download' | 'request';
|
||||
item_key: string;
|
||||
activity_log_id?: number;
|
||||
}
|
||||
|
||||
export interface ActivityHistoryItem {
|
||||
@@ -440,7 +434,6 @@ export interface ActivityHistoryItem {
|
||||
user_id: number;
|
||||
item_type: 'download' | 'request';
|
||||
item_key: string;
|
||||
activity_log_id: number | null;
|
||||
dismissed_at: string;
|
||||
snapshot: Record<string, unknown> | null;
|
||||
origin: 'direct' | 'request' | 'requested' | null;
|
||||
|
||||
@@ -80,6 +80,17 @@ describe('activityMappers.downloadToActivityItem', () => {
|
||||
assert.equal(item.timestamp, 123);
|
||||
});
|
||||
|
||||
it('normalizes epoch-second added_time values to milliseconds', () => {
|
||||
const item = downloadToActivityItem(
|
||||
makeBook({
|
||||
added_time: 1700000000,
|
||||
}),
|
||||
'complete'
|
||||
);
|
||||
|
||||
assert.equal(item.timestamp, 1700000000000);
|
||||
});
|
||||
|
||||
it('maps downloading progress using 20 + progress*0.8', () => {
|
||||
const item = downloadToActivityItem(makeBook({ progress: 60 }), 'downloading');
|
||||
assert.equal(item.visualStatus, 'downloading');
|
||||
|
||||
Reference in New Issue
Block a user