Prepare v2.0.74 sidepanel reliability release
Restore and persist the package-based link collector with progressive metadata, bounded high-volume updates, safe hydration, visible selection, and complete localization. Harden download controls, snapshot ordering, history pagination, statistics recovery, settings saves, backup imports, notification persistence, and Windows storage races with regression coverage.
This commit is contained in:
@@ -55,11 +55,18 @@ export interface DownloadsViewModelCore {
|
||||
mainRowCount: number;
|
||||
totalMainRowCount: number;
|
||||
paginationLabel: string;
|
||||
limited: boolean;
|
||||
empty: boolean;
|
||||
limited: boolean;
|
||||
sourceEmpty: boolean;
|
||||
presentationEmpty: boolean;
|
||||
empty: boolean;
|
||||
filteredEmpty: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadByteSummary {
|
||||
bytes: number;
|
||||
unknownItems: number;
|
||||
}
|
||||
|
||||
export type DownloadLogicalRow =
|
||||
| (DownloadVirtualRowInput & { type: "package"; packageId: string; packageRow: DownloadPackageRow })
|
||||
| (DownloadVirtualRowInput & { type: "item"; packageId: string; item: DownloadItem });
|
||||
@@ -84,12 +91,18 @@ export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): Downl
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
|
||||
let total = 0;
|
||||
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): DownloadByteSummary {
|
||||
let bytes = 0;
|
||||
let unknownItems = 0;
|
||||
for (const item of items) {
|
||||
total += item.totalBytes || item.downloadedBytes || 0;
|
||||
if (item.totalBytes && item.totalBytes > 0) {
|
||||
bytes += item.totalBytes;
|
||||
} else {
|
||||
bytes += Math.max(0, item.downloadedBytes || 0);
|
||||
unknownItems += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
return { bytes, unknownItems };
|
||||
}
|
||||
|
||||
function isPendingDownloadItem(item: DownloadItem): boolean {
|
||||
@@ -121,14 +134,14 @@ export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { byte
|
||||
export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
|
||||
packageCount: number;
|
||||
pendingItemCount: number;
|
||||
totalBytes: number;
|
||||
total: DownloadByteSummary;
|
||||
remaining: { bytes: number; unknownItems: number };
|
||||
hosterCount: number;
|
||||
} {
|
||||
return {
|
||||
packageCount: new Set(items.map((item) => item.packageId)).size,
|
||||
pendingItemCount: getPendingDownloadItemCount(items),
|
||||
totalBytes: getDownloadQueueTotalBytes(items),
|
||||
total: getDownloadQueueTotalBytes(items),
|
||||
remaining: getRemainingDownloadBytes(items),
|
||||
hosterCount: new Set(items.map((item) => extractHoster(item.url)).filter(Boolean)).size
|
||||
};
|
||||
@@ -147,6 +160,23 @@ export function formatRemainingDownloadTooltip(summary: { bytes: number; unknown
|
||||
return `Noch unbekannte Dateigrößen: ${summary.unknownItems}. Die tatsächliche Restmenge kann höher sein.`;
|
||||
}
|
||||
|
||||
export function formatDownloadEta(
|
||||
remaining: DownloadByteSummary,
|
||||
speedBps: number,
|
||||
running: boolean,
|
||||
paused: boolean
|
||||
): string {
|
||||
if (!running || paused || speedBps <= 0 || remaining.unknownItems > 0 || remaining.bytes <= 0) return "--";
|
||||
const totalSeconds = Math.ceil(remaining.bytes / speedBps);
|
||||
const seconds = totalSeconds % 60;
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
||||
: `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
|
||||
let total = 0;
|
||||
for (const speed of Object.values(packageSpeeds)) {
|
||||
@@ -159,13 +189,43 @@ function isExtracted(item: DownloadItem): boolean {
|
||||
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
||||
}
|
||||
|
||||
function matchesQuery(value: string | undefined, query: string): boolean {
|
||||
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
||||
}
|
||||
|
||||
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
|
||||
return filter === "all" || classifyDownloadStatus(item.status) === filter;
|
||||
}
|
||||
function matchesQuery(value: string | undefined, query: string): boolean {
|
||||
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
||||
}
|
||||
|
||||
function isExtractFailure(item: DownloadItem): boolean {
|
||||
const status = item.fullStatus.trim();
|
||||
return /^(?:Entpack(?:-|\s*)Fehler\b|Entpacken\b.*(?:\bFehler\b|\bError\b|fehlgeschlagen)|Extraction\b.*(?:\bError\b|failed))/i.test(status);
|
||||
}
|
||||
|
||||
function classifyDownloadItem(item: DownloadItem, pkg: PackageEntry): DownloadSidebarFilter {
|
||||
if (item.status === "failed" || isExtractFailure(item)) return "failed";
|
||||
if (item.status === "cancelled") return "all";
|
||||
if (isExtracted(item)) return "completed";
|
||||
if ((pkg.status === "extracting" || pkg.status === "integrity_check") && item.status === "completed") return "active";
|
||||
if (item.status === "completed") return "completed";
|
||||
if (pkg.status === "failed") return "failed";
|
||||
if (pkg.status === "paused") return "paused";
|
||||
return classifyDownloadStatus(item.status);
|
||||
}
|
||||
|
||||
function buildDownloadLifecycleCounts(packages: readonly PackageEntry[], items: Record<string, DownloadItem>, hideExtractedItems: boolean): DownloadFilterCounts {
|
||||
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
|
||||
for (const pkg of packages) {
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = items[itemId];
|
||||
if (!item || (hideExtractedItems && isExtracted(item))) continue;
|
||||
counts.all += 1;
|
||||
const category = classifyDownloadItem(item, pkg);
|
||||
if (category !== "all") counts[category] += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function matchesFilter(item: DownloadItem, pkg: PackageEntry, filter: DownloadSidebarFilter): boolean {
|
||||
return filter === "all" || classifyDownloadItem(item, pkg) === filter;
|
||||
}
|
||||
|
||||
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
|
||||
return providerFilter === "all" || item.provider === providerFilter;
|
||||
@@ -194,11 +254,12 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
|
||||
const eligibleItems = input.hideExtractedItems ? allItems.filter((item) => !isExtracted(item)) : allItems;
|
||||
const eligiblePackageCount = new Set(eligibleItems.map((item) => item.packageId)).size;
|
||||
const counts = buildDownloadSidebarCounts(eligibleItems);
|
||||
const counts = buildDownloadLifecycleCounts(allPackages, input.items, input.hideExtractedItems);
|
||||
const providerMap = new Map<string, string>();
|
||||
for (const entry of eligibleItems) {
|
||||
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
||||
}
|
||||
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
||||
}
|
||||
const providerFilter = input.providerFilter === "all" || providerMap.has(input.providerFilter) ? input.providerFilter : "all";
|
||||
|
||||
const query = input.query.trim().toLocaleLowerCase("de-DE");
|
||||
const collapsed = new Set(input.collapsedPackageIds);
|
||||
@@ -218,13 +279,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
|| matchesQuery(item.providerAccountLabel, query)
|
||||
|| matchesQuery(item.fullStatus, query)
|
||||
|| matchesQuery(item.lastError, query);
|
||||
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
|
||||
});
|
||||
return matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter) && (packageMatchesQuery || itemMatchesQuery);
|
||||
});
|
||||
if (matchingItems.length === 0) return [];
|
||||
const visibleItems = packageMatchesQuery && query !== ""
|
||||
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
||||
? items.filter((item) => matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter))
|
||||
: matchingItems;
|
||||
return [{ package: entry, items: visibleItems, allItems: items, collapsed: collapsed.has(entry.id) }];
|
||||
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
|
||||
});
|
||||
|
||||
const totalPackageRows = packageRows.length;
|
||||
@@ -247,10 +308,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
? fileRows.length
|
||||
: totalPackageRows;
|
||||
|
||||
return {
|
||||
const sourceEmpty = allItems.length === 0;
|
||||
const presentationEmpty = eligibleItems.length === 0;
|
||||
|
||||
return {
|
||||
displayMode: input.displayMode,
|
||||
filter: input.filter,
|
||||
providerFilter: input.providerFilter,
|
||||
providerFilter,
|
||||
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
|
||||
query: input.query,
|
||||
counts,
|
||||
@@ -267,7 +331,9 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
totalMainRowCount,
|
||||
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
||||
limited: false,
|
||||
empty: eligibleItems.length === 0,
|
||||
filteredEmpty: eligibleItems.length > 0 && mainRowCount === 0
|
||||
sourceEmpty,
|
||||
presentationEmpty,
|
||||
empty: sourceEmpty,
|
||||
filteredEmpty: !sourceEmpty && mainRowCount === 0
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user