fix(downloads): restore actionable package states and extraction controls
Resolve child archive selections to complete multipart sets, support bulk package extraction, refresh extraction passwords at execution time, and keep archive failures scoped by their full paths. Restore sortable download columns, preserve verified availability, classify package retry and extraction states, secure link copying through the preload bridge, and release cancelled provider work without stale cooldowns.
This commit is contained in:
@@ -13,13 +13,24 @@ import {
|
||||
providerLabels
|
||||
} from "../../download-format";
|
||||
import type { DownloadPackageRow } from "./downloads-model";
|
||||
import { buildPackagePresentation } from "./package-presentation";
|
||||
|
||||
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
|
||||
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service";
|
||||
|
||||
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
|
||||
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
|
||||
const DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX = 5;
|
||||
const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter";
|
||||
|
||||
interface DownloadColumnPointerGesture {
|
||||
dragged: boolean;
|
||||
pointerId: number;
|
||||
sortColumn?: DownloadSortColumn;
|
||||
startX: number;
|
||||
}
|
||||
|
||||
const downloadColumnPointerGestures = new WeakMap<HTMLDivElement, DownloadColumnPointerGesture>();
|
||||
|
||||
type HosterLabel = ReturnType<typeof formatHosterLabel>;
|
||||
|
||||
function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement {
|
||||
@@ -54,7 +65,7 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
|
||||
size: { label: "Geladen / Größe", width: "minmax(var(--downloads-size-min, 140px), 1.1fr)", sortable: "size" },
|
||||
progress: { label: "Fortschritt", width: "minmax(var(--downloads-progress-min, 105px), 0.85fr)", sortable: "progress" },
|
||||
hoster: { label: "Hoster", width: "minmax(var(--downloads-hoster-min, 90px), 0.85fr)", sortable: "hoster" },
|
||||
account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)" },
|
||||
account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)", sortable: "service" },
|
||||
prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" },
|
||||
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
|
||||
speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" },
|
||||
@@ -64,10 +75,16 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
|
||||
|
||||
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
|
||||
|
||||
function effectiveItemOnlineStatus(item: DownloadItem): DownloadItem["onlineStatus"] {
|
||||
return item.onlineStatus
|
||||
?? (item.status === "downloading" || item.status === "integrity_check" || item.status === "completed" ? "online" : undefined);
|
||||
}
|
||||
|
||||
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
|
||||
const total = items.length;
|
||||
const online = items.filter((item) => item.onlineStatus === "online").length;
|
||||
const offline = items.filter((item) => item.onlineStatus === "offline").length;
|
||||
const availability = items.map(effectiveItemOnlineStatus);
|
||||
const online = availability.filter((status) => status === "online").length;
|
||||
const offline = availability.filter((status) => status === "offline").length;
|
||||
if (total > 0 && online === total) return { online, total, state: "online" };
|
||||
if (total > 0 && offline === total) return { online, total, state: "offline" };
|
||||
if (total > 0 && online + offline === total) return { online, total, state: "partial" };
|
||||
@@ -216,7 +233,7 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
|
||||
? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}`
|
||||
: error;
|
||||
if (column === "name") {
|
||||
return <span className="downloads-cell downloads-name-cell downloads-copyable" title={item.fileName}><span className={`downloads-link-state ${item.onlineStatus ?? "unknown"}`} />{item.fileName}</span>;
|
||||
return <span className="downloads-cell downloads-name-cell downloads-copyable" title={item.fileName}><span className={`downloads-link-state ${effectiveItemOnlineStatus(item) ?? "unknown"}`} />{item.fileName}</span>;
|
||||
}
|
||||
if (column === "size") {
|
||||
const total = item.totalBytes || item.downloadedBytes || 0;
|
||||
@@ -241,7 +258,8 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
|
||||
if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />;
|
||||
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
|
||||
if (column === "availability") {
|
||||
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
|
||||
const effectiveStatus = effectiveItemOnlineStatus(item);
|
||||
const state = effectiveStatus === "online" ? "online" : effectiveStatus === "offline" ? "offline" : "checking";
|
||||
const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft";
|
||||
return <Availability online={state === "online" ? 1 : 0} total={1} state={state} text={text} />;
|
||||
}
|
||||
@@ -318,37 +336,7 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
|
||||
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
||||
|
||||
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
|
||||
let failed = 0;
|
||||
let cancelled = 0;
|
||||
let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0));
|
||||
let extracting = false;
|
||||
let activeProgress = 0;
|
||||
let extractingProgress = 0;
|
||||
for (const item of row.allItems) {
|
||||
if (item.status === "completed") done += 1;
|
||||
else if (item.status === "failed") failed += 1;
|
||||
else if (item.status === "cancelled") cancelled += 1;
|
||||
const fullStatus = item.fullStatus || "";
|
||||
if (fullStatus.startsWith("Entpackt")) {
|
||||
extracted += 1;
|
||||
} else if (fullStatus.startsWith("Entpacken")) {
|
||||
extracting = true;
|
||||
const match = fullStatus.match(/^Entpacken\s+(\d+)%/);
|
||||
if (match) extractingProgress += Number(match[1]) / 100;
|
||||
}
|
||||
if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
|
||||
activeProgress += (item.progressPercent || 0) / 100;
|
||||
}
|
||||
}
|
||||
const total = Math.max(1, Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)) + row.allItems.length);
|
||||
const allDownloaded = done + failed + cancelled >= total;
|
||||
const allExtracted = extracted >= total;
|
||||
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
|
||||
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
|
||||
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
||||
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
|
||||
return { done, failed, cancelled, total, value };
|
||||
return buildPackagePresentation(row).progress;
|
||||
}
|
||||
|
||||
export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } {
|
||||
@@ -361,7 +349,8 @@ export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: n
|
||||
|
||||
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
|
||||
const entry = row.package;
|
||||
const stats = getPackageProgress(row);
|
||||
const presentation = buildPackagePresentation(row);
|
||||
const stats = presentation.progress;
|
||||
if (column === "name") {
|
||||
return (
|
||||
<span className="downloads-cell downloads-name-cell">
|
||||
@@ -403,16 +392,14 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
|
||||
? "Entpacken - Ausstehend"
|
||||
: compactPostProcessLabel;
|
||||
const extractFailure = row.allItems.find((item) => /^Entpack-Fehler\b/i.test(item.fullStatus || ""));
|
||||
const waitsForDisk = row.allItems.some((item) => compactDownloadStatus(item.fullStatus || "") === "Warte auf Festplatte");
|
||||
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${extractFailure ? " · Entpack-Fehler" : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
|
||||
const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
|
||||
const details = `${presentation.details}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||
const status = presentation.extractFailureCount === 0
|
||||
&& presentation.retryCount === 0
|
||||
&& postProcessLabel
|
||||
&& (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
|
||||
? postProcessLabel
|
||||
: extractFailure ? "Entpack-Fehler"
|
||||
: waitsForDisk ? "Warte auf Festplatte"
|
||||
: downloading ? "Download läuft" : details;
|
||||
const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details;
|
||||
: presentation.status;
|
||||
const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details;
|
||||
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
|
||||
return <DownloadStatusCell status={status} title={title} />;
|
||||
}
|
||||
@@ -533,6 +520,11 @@ function moveColumnWithPointerActions(column: string, direction: -1 | 1, element
|
||||
actions.onColumnPointerUp(column, pointerEvent(clientX));
|
||||
}
|
||||
|
||||
function isColumnSortPointerTarget(target: EventTarget | null): boolean {
|
||||
const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest;
|
||||
return typeof closest === "function" && closest.call(target, ".downloads-column-sort") !== null;
|
||||
}
|
||||
|
||||
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
|
||||
const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length;
|
||||
const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length;
|
||||
@@ -550,22 +542,41 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
|
||||
data-download-column={column}
|
||||
key={column}
|
||||
onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
|
||||
onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)}
|
||||
onPointerCancel={(event) => {
|
||||
downloadColumnPointerGestures.delete(event.currentTarget);
|
||||
actions.onColumnPointerCancel(column, event);
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !event.isPrimary) return;
|
||||
if (event.currentTarget.closest<HTMLElement>(".downloads-table")?.classList.contains("is-column-drag-settling")) return;
|
||||
downloadColumnPointerGestures.set(event.currentTarget, {
|
||||
dragged: false,
|
||||
pointerId: event.pointerId,
|
||||
sortColumn: definition.sortable && isColumnSortPointerTarget(event.target) ? definition.sortable : undefined,
|
||||
startX: event.clientX
|
||||
});
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
actions.onColumnPointerDown(column, event);
|
||||
}}
|
||||
onPointerMove={(event) => actions.onColumnPointerMove(column, event)}
|
||||
onPointerMove={(event) => {
|
||||
const gesture = downloadColumnPointerGestures.get(event.currentTarget);
|
||||
if (gesture?.pointerId === event.pointerId && Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true;
|
||||
actions.onColumnPointerMove(column, event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const gesture = downloadColumnPointerGestures.get(event.currentTarget);
|
||||
if (gesture?.pointerId === event.pointerId) {
|
||||
if (Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true;
|
||||
downloadColumnPointerGestures.delete(event.currentTarget);
|
||||
}
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
actions.onColumnPointerUp(column, event);
|
||||
if (gesture?.pointerId === event.pointerId && gesture.sortColumn && !gesture.dragged) actions.onSortColumn(gesture.sortColumn);
|
||||
}}
|
||||
role="columnheader"
|
||||
>
|
||||
{definition.sortable
|
||||
? <button className="downloads-column-sort" onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
|
||||
? <button className="downloads-column-sort" onClick={(event) => { if (event.detail === 0) actions.onSortColumn(definition.sortable!); }} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
|
||||
: <span className="downloads-column-label">{definition.label}</span>}
|
||||
<span aria-label={`${definition.label} verschieben`} className="downloads-column-move-controls" onPointerDown={(event) => event.stopPropagation()} role="group">
|
||||
{index > 0 ? <button aria-label={`${definition.label} nach links verschieben`} onClick={(event) => { event.stopPropagation(); const element = event.currentTarget.closest<HTMLDivElement>(".downloads-column-header"); if (element) moveColumnWithPointerActions(column, -1, element, actions); }} type="button">←</button> : null}
|
||||
|
||||
Reference in New Issue
Block a user