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:
@@ -14,6 +14,7 @@ import type {
|
||||
import "./collector.css";
|
||||
|
||||
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||
|
||||
export interface CollectorViewActions {
|
||||
onFilterChange: (filter: CollectorWorkspaceFilter) => void;
|
||||
@@ -25,6 +26,7 @@ export interface CollectorViewActions {
|
||||
onQueryChange: (value: string) => void;
|
||||
onLinkSelectionChange: (linkId: string, selected: boolean) => void;
|
||||
onPackageSelectionChange: (packageId: string, selected: boolean) => void;
|
||||
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
|
||||
onPackageCollapseChange: (packageId: string) => void;
|
||||
onToggleAllPackages: () => void;
|
||||
onRemoveSelected: () => void;
|
||||
@@ -55,9 +57,9 @@ function packageStatus(row: CollectorWorkspacePackageRow): string {
|
||||
}
|
||||
|
||||
function packageAvailability(row: CollectorWorkspacePackageRow): string {
|
||||
if (row.onlineCount === row.totalCount) return `${row.onlineCount}/${row.totalCount} online`;
|
||||
if (row.onlineCount === row.totalCount) return `${integerFormatter.format(row.onlineCount)}/${integerFormatter.format(row.totalCount)} online`;
|
||||
if (row.offlineCount === row.totalCount) return "Offline";
|
||||
if (row.onlineCount > 0) return `${row.onlineCount}/${row.totalCount} online`;
|
||||
if (row.onlineCount > 0) return `${integerFormatter.format(row.onlineCount)}/${integerFormatter.format(row.totalCount)} online`;
|
||||
return "Ungeprüft";
|
||||
}
|
||||
|
||||
@@ -195,7 +197,7 @@ function CollectorHosterLabel({ hoster }: { hoster: ReturnType<typeof formatHost
|
||||
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
return (
|
||||
<div aria-label="Linksammler-Filter" className="collector-sidebar" data-visual-region="collector-sidebar">
|
||||
<div className="collector-sidebar-heading"><strong>Status</strong><span>{model.totalCount}</span></div>
|
||||
<div className="collector-sidebar-heading"><strong>Status</strong><span>{integerFormatter.format(model.totalCount)}</span></div>
|
||||
<SlidingSelection activeKey={model.filter} axis="vertical" className="collector-sidebar-list">
|
||||
{model.filters.map((filter) => (
|
||||
<button
|
||||
@@ -207,7 +209,7 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
|
||||
onClick={() => actions.onFilterChange(filter.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{filter.label}</span><span>{filter.count}</span>
|
||||
<span>{filter.label}</span><span>{integerFormatter.format(filter.count)}</span>
|
||||
</button>
|
||||
))}
|
||||
</SlidingSelection>
|
||||
@@ -218,9 +220,9 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
|
||||
export function CollectorSidebarStatus({ model }: { model: CollectorWorkspaceViewModel }): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<span>Pakete: {model.packageCount}</span>
|
||||
<span>Links: {model.totalCount}</span>
|
||||
<span>Ausgewählt: {model.selectedCount}</span>
|
||||
<span>Pakete: {integerFormatter.format(model.packageCount)}</span>
|
||||
<span>Links: {integerFormatter.format(model.totalCount)}</span>
|
||||
<span>Ausgewählt: {integerFormatter.format(model.selectedCount)}</span>
|
||||
{model.analyzing ? (
|
||||
<span aria-live="polite" className="collector-sidebar-analysis" role="status"><span aria-hidden="true" />Analyse läuft im Hintergrund</span>
|
||||
) : null}
|
||||
@@ -237,8 +239,8 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
|
||||
<button className="collector-action" onClick={actions.onImportFile} type="button">Datei importieren</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup label="Downloads übergeben">
|
||||
<button className="collector-action" disabled={model.selectedCount === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${model.selectedCount})`}</button>
|
||||
<button className="collector-action" disabled={model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
|
||||
<button className="collector-action" disabled={model.selectedTransferableIds.length === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${integerFormatter.format(model.selectedTransferableIds.length)})`}</button>
|
||||
<button className="collector-action" disabled={model.visibleTransferableIds.length === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${integerFormatter.format(model.visibleTransferableIds.length)})`}</button>
|
||||
<button className="collector-action collector-action-danger" disabled={model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup className="collector-toolbar-tail" label="Suche und Paketdarstellung">
|
||||
@@ -249,6 +251,32 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorTableHeader({ model, actions, scrollLeft }: Pick<CollectorViewProps, "model" | "actions"> & { scrollLeft: number }): ReactElement {
|
||||
const allVisibleSelected = model.visibleIds.length > 0 && model.selectedCount === model.visibleIds.length;
|
||||
const partiallyVisibleSelected = model.selectedCount > 0 && !allVisibleSelected;
|
||||
return (
|
||||
<div aria-rowindex={1} className="collector-table-header-row" role="row" style={collectorHeaderScrollStyle(scrollLeft)}>
|
||||
<span className="collector-column-select" role="columnheader">
|
||||
<input
|
||||
aria-checked={partiallyVisibleSelected ? "mixed" : allVisibleSelected}
|
||||
aria-label="Alle sichtbaren Links auswählen"
|
||||
checked={allVisibleSelected}
|
||||
disabled={model.visibleIds.length === 0}
|
||||
onChange={(event) => actions.onSetVisibleSelection(model.visibleIds, event.target.checked)}
|
||||
ref={(node) => { if (node) node.indeterminate = partiallyVisibleSelected; }}
|
||||
type="checkbox"
|
||||
/>
|
||||
</span>
|
||||
<span role="columnheader">Name</span>
|
||||
<span role="columnheader">Größe</span>
|
||||
<span role="columnheader">Hoster</span>
|
||||
<span role="columnheader">Status</span>
|
||||
<span role="columnheader">Verfügbarkeit</span>
|
||||
<span role="columnheader">Hinzugefügt</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, rowIndexStart }: {
|
||||
focusIndexStart: number;
|
||||
row: CollectorWorkspacePackageRow;
|
||||
@@ -257,7 +285,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
|
||||
selected: ReadonlySet<string>;
|
||||
rowIndexStart: number;
|
||||
}): ReactElement {
|
||||
const allSelected = row.selectedCount === row.totalCount;
|
||||
const allSelected = row.links.length > 0 && row.selectedCount === row.links.length;
|
||||
const partiallySelected = row.selectedCount > 0 && !allSelected;
|
||||
const animateItems = model.animationsEnabled && row.allLinks.length <= 64;
|
||||
const [renderItems, setRenderItems] = useState(!row.collapsed);
|
||||
@@ -314,7 +342,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
|
||||
type="button"
|
||||
>{row.collapsed ? "+" : "−"}</button>
|
||||
<strong title={row.name}>{row.name}</strong>
|
||||
<small>{row.totalCount} Dateien</small>
|
||||
<small>{integerFormatter.format(row.totalCount)} Dateien</small>
|
||||
</span>
|
||||
<span className="collector-size-cell" role="cell">{packageSize(row)}</span>
|
||||
<span className="collector-hoster-cell" role="cell">
|
||||
@@ -426,15 +454,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
|
||||
<section className="collector-content" aria-label="Gesammelte Downloadpakete">
|
||||
<DataTable aria-rowcount={logicalRowCount} className="collector-table" label="Gesammelte Downloadpakete">
|
||||
<DataTableHeader className="collector-table-header">
|
||||
<div aria-rowindex={1} className="collector-table-header-row" role="row" style={collectorHeaderScrollStyle(viewport.scrollLeft)}>
|
||||
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
|
||||
<span role="columnheader">Name</span>
|
||||
<span role="columnheader">Größe</span>
|
||||
<span role="columnheader">Hoster</span>
|
||||
<span role="columnheader">Status</span>
|
||||
<span role="columnheader">Verfügbarkeit</span>
|
||||
<span role="columnheader">Hinzugefügt</span>
|
||||
</div>
|
||||
<CollectorTableHeader actions={actions} model={model} scrollLeft={viewport.scrollLeft} />
|
||||
</DataTableHeader>
|
||||
<DataTableBody
|
||||
className="collector-table-body"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
inspectCollectorCapacity,
|
||||
inspectCollectorPersistenceSize,
|
||||
validateCollectorPersistenceState,
|
||||
type CollectorPersistenceState
|
||||
} from "../../../shared/collector";
|
||||
import {
|
||||
mergeCollectorPackages,
|
||||
reconcileCollectorCollapsedPackageIds
|
||||
} from "./collector-model";
|
||||
|
||||
export type CollectorLateHydrationResult = {
|
||||
ok: true;
|
||||
state: CollectorPersistenceState;
|
||||
} | {
|
||||
ok: false;
|
||||
message: string;
|
||||
packageCount: number;
|
||||
linkCount: number;
|
||||
attemptedState: CollectorPersistenceState;
|
||||
currentState: CollectorPersistenceState;
|
||||
rollbackState: CollectorPersistenceState;
|
||||
};
|
||||
|
||||
export interface CollectorBeforeUnloadPersistenceRequest {
|
||||
hydrated: boolean;
|
||||
currentState: CollectorPersistenceState;
|
||||
getPersistedStateSync: () => unknown;
|
||||
saveStateSync: (state: CollectorPersistenceState) => boolean;
|
||||
maximumBytes?: number;
|
||||
}
|
||||
|
||||
export interface CollectorBeforeUnloadEvent {
|
||||
preventDefault: () => void;
|
||||
returnValue: string;
|
||||
}
|
||||
|
||||
function mergeCollapsedPackageIds(
|
||||
persistedState: CollectorPersistenceState,
|
||||
currentState: CollectorPersistenceState,
|
||||
packages: CollectorPersistenceState["packages"]
|
||||
): string[] {
|
||||
const persistedCollapsed = reconcileCollectorCollapsedPackageIds(
|
||||
new Set(persistedState.collapsedPackageIds),
|
||||
persistedState.packages,
|
||||
packages,
|
||||
[],
|
||||
false
|
||||
);
|
||||
const currentCollapsed = reconcileCollectorCollapsedPackageIds(
|
||||
new Set(currentState.collapsedPackageIds),
|
||||
currentState.packages,
|
||||
packages,
|
||||
[],
|
||||
false
|
||||
);
|
||||
return [...new Set([...persistedCollapsed, ...currentCollapsed])];
|
||||
}
|
||||
|
||||
export function resolveCollectorLateHydration(
|
||||
persistedState: CollectorPersistenceState,
|
||||
currentState: CollectorPersistenceState,
|
||||
maximumBytes?: number
|
||||
): CollectorLateHydrationResult {
|
||||
let persisted = persistedState;
|
||||
let current = currentState;
|
||||
let attemptedState: CollectorPersistenceState = {
|
||||
packages: [],
|
||||
collapsedPackageIds: []
|
||||
};
|
||||
try {
|
||||
persisted = validateCollectorPersistenceState(persistedState);
|
||||
current = validateCollectorPersistenceState(currentState);
|
||||
const packages = mergeCollectorPackages(persisted.packages, current.packages).packages;
|
||||
attemptedState = {
|
||||
packages,
|
||||
collapsedPackageIds: mergeCollapsedPackageIds(persisted, current, packages)
|
||||
};
|
||||
const capacity = inspectCollectorCapacity(packages);
|
||||
if (!capacity.ok) {
|
||||
return {
|
||||
...capacity,
|
||||
attemptedState,
|
||||
currentState: current,
|
||||
rollbackState: persisted
|
||||
};
|
||||
}
|
||||
const persistenceSize = inspectCollectorPersistenceSize(attemptedState, maximumBytes);
|
||||
if (!persistenceSize.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
message: persistenceSize.message,
|
||||
packageCount: capacity.packageCount,
|
||||
linkCount: capacity.linkCount,
|
||||
attemptedState,
|
||||
currentState: current,
|
||||
rollbackState: persisted
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: validateCollectorPersistenceState(attemptedState)
|
||||
};
|
||||
} catch (error) {
|
||||
const capacity = inspectCollectorCapacity(attemptedState.packages);
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error
|
||||
? `Linksammler konnte nicht wiederhergestellt werden: ${error.message}`
|
||||
: "Linksammler konnte nicht wiederhergestellt werden.",
|
||||
packageCount: capacity.packageCount,
|
||||
linkCount: capacity.linkCount,
|
||||
attemptedState,
|
||||
currentState: current,
|
||||
rollbackState: persisted
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function persistCollectorStateBeforeUnload(
|
||||
request: CollectorBeforeUnloadPersistenceRequest
|
||||
): boolean {
|
||||
try {
|
||||
const current = validateCollectorPersistenceState(request.currentState);
|
||||
if (request.hydrated) {
|
||||
if (!inspectCollectorPersistenceSize(current, request.maximumBytes).ok) return false;
|
||||
return request.saveStateSync(current) === true;
|
||||
}
|
||||
const hydration = resolveCollectorLateHydration(
|
||||
validateCollectorPersistenceState(request.getPersistedStateSync()),
|
||||
current,
|
||||
request.maximumBytes
|
||||
);
|
||||
if (!hydration.ok) return false;
|
||||
return request.saveStateSync(hydration.state) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function guardCollectorBeforeUnload(
|
||||
event: CollectorBeforeUnloadEvent,
|
||||
request: CollectorBeforeUnloadPersistenceRequest
|
||||
): boolean {
|
||||
if (persistCollectorStateBeforeUnload(request)) return true;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
return false;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||
import { inspectCollectorCapacity, type CollectorAvailability, type CollectorLink, type CollectorPackage } from "../../../shared/collector";
|
||||
|
||||
export type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||
export type CollectorWorkspaceFilter = "all" | CollectorAvailability;
|
||||
@@ -37,7 +37,10 @@ export interface CollectorWorkspaceViewModel {
|
||||
empty: boolean;
|
||||
totalCount: number;
|
||||
selectedCount: number;
|
||||
visibleIds: string[];
|
||||
visibleTransferableIds: string[];
|
||||
selectedIds: string[];
|
||||
selectedTransferableIds: string[];
|
||||
animationsEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -46,8 +49,39 @@ export interface CollectorMergeResult {
|
||||
addedLinks: number;
|
||||
duplicateLinks: number;
|
||||
enrichedLinks: number;
|
||||
persistenceDelta: CollectorPersistenceDelta;
|
||||
}
|
||||
|
||||
export interface CollectorPersistenceLinkDelta {
|
||||
url: string;
|
||||
previousPackageId: string | null;
|
||||
previousLink: CollectorLink | null;
|
||||
nextPackageId: string | null;
|
||||
nextLink: CollectorLink | null;
|
||||
}
|
||||
|
||||
export interface CollectorPersistencePackageDelta {
|
||||
package: Pick<CollectorPackage, "id" | "name" | "nameSource" | "addedAt">;
|
||||
linkCount: number;
|
||||
}
|
||||
|
||||
export interface CollectorPersistenceDelta {
|
||||
links: CollectorPersistenceLinkDelta[];
|
||||
packages: CollectorPersistencePackageDelta[];
|
||||
removedPackageIds: string[];
|
||||
packageCount: number;
|
||||
}
|
||||
|
||||
export type CollectorCapacityMergeResult = {
|
||||
ok: true;
|
||||
value: CollectorMergeResult;
|
||||
} | {
|
||||
ok: false;
|
||||
packageCount: number;
|
||||
linkCount: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function collectorUrlKey(url: string): string {
|
||||
return url.trim();
|
||||
}
|
||||
@@ -96,6 +130,8 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co
|
||||
const replacements = new Map<CollectorLink, CollectorLink>();
|
||||
const movedLinks = new Set<CollectorLink>();
|
||||
const appendedLinks = new Map<CollectorPackage, CollectorLink[]>();
|
||||
const affectedPackages = new Set<CollectorPackage>();
|
||||
const persistenceLinkDeltas = new Map<string, CollectorPersistenceLinkDelta>();
|
||||
const appendToPackage = (pkg: CollectorPackage, link: CollectorLink): void => {
|
||||
const links = appendedLinks.get(pkg);
|
||||
if (links) links.push(link);
|
||||
@@ -141,8 +177,12 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co
|
||||
};
|
||||
packages.push(target);
|
||||
packageByName.set(incomingPackageKey, target);
|
||||
affectedPackages.add(target);
|
||||
}
|
||||
if (incomingPackage.nameSource === "explicit" && target.nameSource !== "explicit") {
|
||||
target.nameSource = "explicit";
|
||||
affectedPackages.add(target);
|
||||
}
|
||||
if (incomingPackage.nameSource === "explicit") target.nameSource = "explicit";
|
||||
|
||||
if (existing) {
|
||||
const enriched = mergeCollectorLinkMetadata(existing.link, incomingLink);
|
||||
@@ -150,30 +190,80 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co
|
||||
else {
|
||||
movedLinks.add(existing.link);
|
||||
appendToPackage(target, enriched);
|
||||
affectedPackages.add(existing.pkg);
|
||||
}
|
||||
affectedPackages.add(target);
|
||||
target.addedAt = Math.min(target.addedAt, enriched.addedAt);
|
||||
existingByUrl.set(urlKey, { pkg: target, link: enriched });
|
||||
persistenceLinkDeltas.set(urlKey, {
|
||||
url: urlKey,
|
||||
previousPackageId: existing.pkg.id,
|
||||
previousLink: existing.link,
|
||||
nextPackageId: target.id,
|
||||
nextLink: enriched
|
||||
});
|
||||
enrichedLinks += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const added = { ...incomingLink };
|
||||
appendToPackage(target, added);
|
||||
affectedPackages.add(target);
|
||||
target.addedAt = Math.min(target.addedAt, added.addedAt);
|
||||
existingByUrl.set(urlKey, { pkg: target, link: added });
|
||||
persistenceLinkDeltas.set(urlKey, {
|
||||
url: urlKey,
|
||||
previousPackageId: null,
|
||||
previousLink: null,
|
||||
nextPackageId: target.id,
|
||||
nextLink: added
|
||||
});
|
||||
addedLinks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const finalPackages: CollectorPackage[] = [];
|
||||
const persistencePackages: CollectorPersistencePackageDelta[] = [];
|
||||
const removedPackageIds: string[] = [];
|
||||
for (const pkg of packages) {
|
||||
const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]);
|
||||
links.push(...(appendedLinks.get(pkg) ?? []));
|
||||
if (links.length === 0) {
|
||||
if (affectedPackages.has(pkg)) removedPackageIds.push(pkg.id);
|
||||
continue;
|
||||
}
|
||||
const finalPackage = { ...pkg, links };
|
||||
finalPackages.push(finalPackage);
|
||||
if (affectedPackages.has(pkg)) {
|
||||
persistencePackages.push({
|
||||
package: {
|
||||
id: finalPackage.id,
|
||||
name: finalPackage.name,
|
||||
nameSource: finalPackage.nameSource,
|
||||
addedAt: finalPackage.addedAt
|
||||
},
|
||||
linkCount: finalPackage.links.length
|
||||
});
|
||||
}
|
||||
for (const link of links) {
|
||||
const delta = persistenceLinkDeltas.get(collectorUrlKey(link.url));
|
||||
if (!delta) continue;
|
||||
delta.nextPackageId = finalPackage.id;
|
||||
delta.nextLink = link;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packages: packages.flatMap((pkg) => {
|
||||
const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]);
|
||||
links.push(...(appendedLinks.get(pkg) ?? []));
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
}),
|
||||
packages: finalPackages,
|
||||
addedLinks,
|
||||
duplicateLinks,
|
||||
enrichedLinks
|
||||
enrichedLinks,
|
||||
persistenceDelta: {
|
||||
links: [...persistenceLinkDeltas.values()],
|
||||
packages: persistencePackages,
|
||||
removedPackageIds,
|
||||
packageCount: finalPackages.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +276,23 @@ export function mergeCollectorEnrichment(current: CollectorPackage[], incoming:
|
||||
return mergeCollectorPackages(current, retained);
|
||||
}
|
||||
|
||||
export function mergeCollectorPackagesWithinCapacity(
|
||||
current: CollectorPackage[],
|
||||
incoming: CollectorPackage[],
|
||||
enrichment = false
|
||||
): CollectorCapacityMergeResult {
|
||||
const value = enrichment
|
||||
? mergeCollectorEnrichment(current, incoming)
|
||||
: mergeCollectorPackages(current, incoming);
|
||||
const capacity = inspectCollectorCapacity(value.packages);
|
||||
return capacity.ok ? { ok: true, value } : capacity;
|
||||
}
|
||||
|
||||
function retainAvailableCollectorIds(current: ReadonlySet<string>, availableIds: Iterable<string>): Set<string> {
|
||||
const available = new Set(availableIds);
|
||||
return new Set([...current].filter((id) => available.has(id)));
|
||||
}
|
||||
|
||||
export function reconcileCollectorCollapsedPackageIds(
|
||||
current: Set<string>,
|
||||
previousPackages: CollectorPackage[],
|
||||
@@ -217,7 +324,14 @@ export function reconcileCollectorCollapsedPackageIds(
|
||||
return next;
|
||||
}
|
||||
|
||||
export function selectCollectorPackageLinks(current: Set<string>, pkg: CollectorPackage, selected: boolean): Set<string> {
|
||||
export function reconcileCollectorCollapsedPackageIdsWithPackages(
|
||||
current: ReadonlySet<string>,
|
||||
packages: readonly Pick<CollectorPackage, "id">[]
|
||||
): Set<string> {
|
||||
return retainAvailableCollectorIds(current, packages.map((pkg) => pkg.id));
|
||||
}
|
||||
|
||||
export function selectCollectorPackageLinks(current: Set<string>, pkg: Pick<CollectorPackage, "links">, selected: boolean): Set<string> {
|
||||
const next = new Set(current);
|
||||
for (const link of pkg.links) {
|
||||
if (selected) next.add(link.id);
|
||||
@@ -226,6 +340,22 @@ export function selectCollectorPackageLinks(current: Set<string>, pkg: Collector
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setCollectorVisibleSelection(current: ReadonlySet<string>, visibleIds: readonly string[], selected: boolean): Set<string> {
|
||||
const next = new Set(current);
|
||||
for (const id of visibleIds) {
|
||||
if (selected) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function reconcileCollectorSelectionWithPackages(
|
||||
current: ReadonlySet<string>,
|
||||
packages: readonly Pick<CollectorPackage, "links">[]
|
||||
): Set<string> {
|
||||
return retainAvailableCollectorIds(current, packages.flatMap((pkg) => pkg.links.map((link) => link.id)));
|
||||
}
|
||||
|
||||
export function buildCollectorTransferPackages(packages: CollectorPackage[], selectedIds: Set<string>): CollectorPackage[] {
|
||||
return packages.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => selectedIds.has(link.id));
|
||||
@@ -240,6 +370,13 @@ export function removeCollectorLinks(packages: CollectorPackage[], removedIds: S
|
||||
});
|
||||
}
|
||||
|
||||
export function reconcileCollectorSelectionAfterRemoval(
|
||||
selectedIds: ReadonlySet<string>,
|
||||
removedIds: ReadonlySet<string>
|
||||
): Set<string> {
|
||||
return new Set([...selectedIds].filter((id) => !removedIds.has(id)));
|
||||
}
|
||||
|
||||
function filterCollectorLink(link: CollectorLink, filter: CollectorWorkspaceFilter): boolean {
|
||||
return filter === "all" || link.availability === filter;
|
||||
}
|
||||
@@ -277,7 +414,6 @@ export function buildCollectorWorkspaceViewModel(
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
let unknownCount = 0;
|
||||
let selectedCount = 0;
|
||||
const hosters = new Set<string>();
|
||||
for (const link of pkg.links) {
|
||||
if (link.fileSizeBytes === null) unknownSizeCount += 1;
|
||||
@@ -285,9 +421,9 @@ export function buildCollectorWorkspaceViewModel(
|
||||
if (link.availability === "online") onlineCount += 1;
|
||||
else if (link.availability === "offline") offlineCount += 1;
|
||||
else unknownCount += 1;
|
||||
if (selected.has(link.id)) selectedCount += 1;
|
||||
if (link.hoster) hosters.add(link.hoster);
|
||||
}
|
||||
const selectedCount = visibleLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0);
|
||||
rows.push({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
@@ -306,6 +442,13 @@ export function buildCollectorWorkspaceViewModel(
|
||||
});
|
||||
}
|
||||
|
||||
const visibleIds = rows.flatMap((row) => row.links.map((link) => link.id));
|
||||
const visibleSelectedIds = visibleIds.filter((id) => selected.has(id));
|
||||
const visibleTransferableIds = rows.flatMap((row) => row.links
|
||||
.filter((link) => link.availability !== "offline")
|
||||
.map((link) => link.id));
|
||||
const selectedTransferableIds = visibleTransferableIds.filter((id) => selected.has(id));
|
||||
|
||||
return {
|
||||
packages: rows,
|
||||
packageCount: packages.length,
|
||||
@@ -321,8 +464,11 @@ export function buildCollectorWorkspaceViewModel(
|
||||
error,
|
||||
empty: rows.length === 0,
|
||||
totalCount: allLinks.length,
|
||||
selectedCount: allLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0),
|
||||
selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id),
|
||||
selectedCount: visibleSelectedIds.length,
|
||||
visibleIds,
|
||||
visibleTransferableIds,
|
||||
selectedIds: visibleSelectedIds,
|
||||
selectedTransferableIds,
|
||||
animationsEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
COLLECTOR_MAX_PERSISTENCE_BYTES,
|
||||
type CollectorLink,
|
||||
type CollectorPackage,
|
||||
type CollectorPersistenceState
|
||||
} from "../../../shared/collector";
|
||||
import type { CollectorPersistenceDelta } from "./collector-model";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const overflowMessage = "Der Linksammler ist zu groß, um gespeichert zu werden.";
|
||||
|
||||
export interface CollectorPersistenceBudget {
|
||||
byteCount: number;
|
||||
packageByteTotal: number;
|
||||
packageCount: number;
|
||||
packageByteCounts: ReadonlyMap<string, number>;
|
||||
packageBaseByteCounts: ReadonlyMap<string, number>;
|
||||
packageLinkByteTotals: ReadonlyMap<string, number>;
|
||||
packageLinkCounts: ReadonlyMap<string, number>;
|
||||
linkByteCounts: ReadonlyMap<string, number>;
|
||||
linkPackageIds: ReadonlyMap<string, string>;
|
||||
linkIds: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export type CollectorPersistenceBudgetResult = {
|
||||
ok: true;
|
||||
byteCount: number;
|
||||
nextBudget: CollectorPersistenceBudget;
|
||||
} | {
|
||||
ok: false;
|
||||
byteCount: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface PackageCandidate {
|
||||
id: string;
|
||||
existed: boolean;
|
||||
oldByteCount: number;
|
||||
baseByteCount: number;
|
||||
linkByteTotal: number;
|
||||
linkCount: number;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
interface LinkCandidate {
|
||||
byteCount: number;
|
||||
id: string;
|
||||
packageId: string;
|
||||
}
|
||||
|
||||
type MutableCollectorPersistenceBudget = CollectorPersistenceBudget & {
|
||||
packageByteCounts: Map<string, number>;
|
||||
packageBaseByteCounts: Map<string, number>;
|
||||
packageLinkByteTotals: Map<string, number>;
|
||||
packageLinkCounts: Map<string, number>;
|
||||
linkByteCounts: Map<string, number>;
|
||||
linkPackageIds: Map<string, string>;
|
||||
linkIds: Map<string, string>;
|
||||
};
|
||||
|
||||
function utf8ByteCount(value: string): number {
|
||||
return encoder.encode(value).byteLength;
|
||||
}
|
||||
|
||||
function normalizedUrl(url: string): string {
|
||||
return url.trim();
|
||||
}
|
||||
|
||||
function linkByteCount(link: CollectorLink): number {
|
||||
return utf8ByteCount(JSON.stringify(link));
|
||||
}
|
||||
|
||||
function samePersistentLink(left: CollectorLink, right: CollectorLink): boolean {
|
||||
return left.id === right.id
|
||||
&& left.url === right.url
|
||||
&& left.fileName === right.fileName
|
||||
&& left.fileSizeBytes === right.fileSizeBytes
|
||||
&& left.hoster === right.hoster
|
||||
&& left.availability === right.availability
|
||||
&& left.status === right.status
|
||||
&& left.addedAt === right.addedAt;
|
||||
}
|
||||
|
||||
function packageBaseByteCount(pkg: Pick<CollectorPackage, "id" | "name" | "nameSource" | "addedAt">): number {
|
||||
return utf8ByteCount(JSON.stringify({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
nameSource: pkg.nameSource,
|
||||
links: [],
|
||||
addedAt: pkg.addedAt
|
||||
}));
|
||||
}
|
||||
|
||||
function completePackageByteCount(baseByteCount: number, linkByteTotal: number, linkCount: number): number {
|
||||
return baseByteCount + linkByteTotal + Math.max(0, linkCount - 1);
|
||||
}
|
||||
|
||||
function envelopeByteCount(collapsedPackageIds: readonly string[]): number {
|
||||
return utf8ByteCount(JSON.stringify({
|
||||
version: 1,
|
||||
packages: [],
|
||||
collapsedPackageIds,
|
||||
updatedAt: Number.MAX_SAFE_INTEGER
|
||||
}));
|
||||
}
|
||||
|
||||
function totalByteCount(packageByteTotal: number, packageCount: number, collapsedPackageIds: readonly string[]): number {
|
||||
return envelopeByteCount(collapsedPackageIds) + packageByteTotal + Math.max(0, packageCount - 1);
|
||||
}
|
||||
|
||||
export function createCollectorPersistenceBudget(
|
||||
state: CollectorPersistenceState,
|
||||
maximumBytes = COLLECTOR_MAX_PERSISTENCE_BYTES
|
||||
): CollectorPersistenceBudgetResult {
|
||||
const packageByteCounts = new Map<string, number>();
|
||||
const packageBaseByteCounts = new Map<string, number>();
|
||||
const packageLinkByteTotals = new Map<string, number>();
|
||||
const packageLinkCounts = new Map<string, number>();
|
||||
const linkByteCounts = new Map<string, number>();
|
||||
const linkPackageIds = new Map<string, string>();
|
||||
const linkIds = new Map<string, string>();
|
||||
let packageByteTotal = 0;
|
||||
for (const pkg of state.packages) {
|
||||
const baseByteCount = packageBaseByteCount(pkg);
|
||||
let linkByteTotal = 0;
|
||||
for (const link of pkg.links) {
|
||||
const url = normalizedUrl(link.url);
|
||||
const byteCount = linkByteCount(link);
|
||||
linkByteCounts.set(url, byteCount);
|
||||
linkPackageIds.set(url, pkg.id);
|
||||
linkIds.set(url, link.id);
|
||||
linkByteTotal += byteCount;
|
||||
}
|
||||
const byteCount = completePackageByteCount(baseByteCount, linkByteTotal, pkg.links.length);
|
||||
packageByteCounts.set(pkg.id, byteCount);
|
||||
packageBaseByteCounts.set(pkg.id, baseByteCount);
|
||||
packageLinkByteTotals.set(pkg.id, linkByteTotal);
|
||||
packageLinkCounts.set(pkg.id, pkg.links.length);
|
||||
packageByteTotal += byteCount;
|
||||
}
|
||||
const packageCount = state.packages.length;
|
||||
const byteCount = totalByteCount(packageByteTotal, packageCount, state.collapsedPackageIds);
|
||||
if (byteCount > maximumBytes) return { ok: false, byteCount, message: overflowMessage };
|
||||
return {
|
||||
ok: true,
|
||||
byteCount,
|
||||
nextBudget: {
|
||||
byteCount,
|
||||
packageByteTotal,
|
||||
packageCount,
|
||||
packageByteCounts,
|
||||
packageBaseByteCounts,
|
||||
packageLinkByteTotals,
|
||||
packageLinkCounts,
|
||||
linkByteCounts,
|
||||
linkPackageIds,
|
||||
linkIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceCollectorPersistenceBudget(
|
||||
budget: CollectorPersistenceBudget,
|
||||
delta: CollectorPersistenceDelta,
|
||||
collapsedPackageIds: readonly string[],
|
||||
maximumBytes = COLLECTOR_MAX_PERSISTENCE_BYTES
|
||||
): CollectorPersistenceBudgetResult {
|
||||
const packageCandidates = new Map<string, PackageCandidate>();
|
||||
const linkCandidates = new Map<string, LinkCandidate | null>();
|
||||
const packageCandidate = (id: string): PackageCandidate => {
|
||||
const existing = packageCandidates.get(id);
|
||||
if (existing) return existing;
|
||||
const existed = budget.packageByteCounts.has(id);
|
||||
const candidate = {
|
||||
id,
|
||||
existed,
|
||||
oldByteCount: budget.packageByteCounts.get(id) ?? 0,
|
||||
baseByteCount: budget.packageBaseByteCounts.get(id) ?? 0,
|
||||
linkByteTotal: budget.packageLinkByteTotals.get(id) ?? 0,
|
||||
linkCount: budget.packageLinkCounts.get(id) ?? 0,
|
||||
removed: false
|
||||
};
|
||||
packageCandidates.set(id, candidate);
|
||||
return candidate;
|
||||
};
|
||||
|
||||
for (const change of delta.links) {
|
||||
const url = normalizedUrl(change.url);
|
||||
const previousPackageId = budget.linkPackageIds.get(url) ?? change.previousPackageId;
|
||||
const previousByteCount = budget.linkByteCounts.get(url);
|
||||
if (previousPackageId && previousByteCount !== undefined) {
|
||||
const previousPackage = packageCandidate(previousPackageId);
|
||||
previousPackage.linkByteTotal -= previousByteCount;
|
||||
previousPackage.linkCount -= 1;
|
||||
}
|
||||
if (change.nextPackageId && change.nextLink) {
|
||||
const canReuse = previousByteCount !== undefined
|
||||
&& change.previousLink !== null
|
||||
&& samePersistentLink(change.previousLink, change.nextLink);
|
||||
const byteCount = canReuse ? previousByteCount : linkByteCount(change.nextLink);
|
||||
const nextPackage = packageCandidate(change.nextPackageId);
|
||||
nextPackage.linkByteTotal += byteCount;
|
||||
nextPackage.linkCount += 1;
|
||||
linkCandidates.set(url, { byteCount, id: change.nextLink.id, packageId: change.nextPackageId });
|
||||
} else {
|
||||
linkCandidates.set(url, null);
|
||||
}
|
||||
}
|
||||
|
||||
const removedPackageIds = new Set(delta.removedPackageIds);
|
||||
for (const change of delta.packages) {
|
||||
const candidate = packageCandidate(change.package.id);
|
||||
candidate.baseByteCount = packageBaseByteCount(change.package);
|
||||
candidate.linkCount = change.linkCount;
|
||||
candidate.removed = false;
|
||||
removedPackageIds.delete(change.package.id);
|
||||
}
|
||||
for (const id of removedPackageIds) packageCandidate(id).removed = true;
|
||||
|
||||
let packageByteTotal = budget.packageByteTotal;
|
||||
const nextPackageByteCounts = new Map<string, number>();
|
||||
for (const candidate of packageCandidates.values()) {
|
||||
if (candidate.existed) packageByteTotal -= candidate.oldByteCount;
|
||||
if (candidate.removed) continue;
|
||||
const byteCount = completePackageByteCount(candidate.baseByteCount, candidate.linkByteTotal, candidate.linkCount);
|
||||
nextPackageByteCounts.set(candidate.id, byteCount);
|
||||
packageByteTotal += byteCount;
|
||||
}
|
||||
const byteCount = totalByteCount(packageByteTotal, delta.packageCount, collapsedPackageIds);
|
||||
if (byteCount > maximumBytes) return { ok: false, byteCount, message: overflowMessage };
|
||||
|
||||
const nextBudget = budget as MutableCollectorPersistenceBudget;
|
||||
for (const candidate of packageCandidates.values()) {
|
||||
if (candidate.removed) {
|
||||
nextBudget.packageByteCounts.delete(candidate.id);
|
||||
nextBudget.packageBaseByteCounts.delete(candidate.id);
|
||||
nextBudget.packageLinkByteTotals.delete(candidate.id);
|
||||
nextBudget.packageLinkCounts.delete(candidate.id);
|
||||
continue;
|
||||
}
|
||||
nextBudget.packageByteCounts.set(candidate.id, nextPackageByteCounts.get(candidate.id) ?? 0);
|
||||
nextBudget.packageBaseByteCounts.set(candidate.id, candidate.baseByteCount);
|
||||
nextBudget.packageLinkByteTotals.set(candidate.id, candidate.linkByteTotal);
|
||||
nextBudget.packageLinkCounts.set(candidate.id, candidate.linkCount);
|
||||
}
|
||||
for (const [url, candidate] of linkCandidates) {
|
||||
if (!candidate) {
|
||||
nextBudget.linkByteCounts.delete(url);
|
||||
nextBudget.linkPackageIds.delete(url);
|
||||
nextBudget.linkIds.delete(url);
|
||||
continue;
|
||||
}
|
||||
nextBudget.linkByteCounts.set(url, candidate.byteCount);
|
||||
nextBudget.linkPackageIds.set(url, candidate.packageId);
|
||||
nextBudget.linkIds.set(url, candidate.id);
|
||||
}
|
||||
nextBudget.byteCount = byteCount;
|
||||
nextBudget.packageByteTotal = packageByteTotal;
|
||||
nextBudget.packageCount = delta.packageCount;
|
||||
return { ok: true, byteCount, nextBudget };
|
||||
}
|
||||
@@ -36,26 +36,54 @@ export function restoreCollectorPersistenceState(
|
||||
|
||||
export interface CollectorPersistenceCoordinator {
|
||||
schedule: (state: CollectorPersistenceState) => void;
|
||||
setBaseline: (state: CollectorPersistenceState) => void;
|
||||
flush: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface CollectorPersistenceFailure {
|
||||
error: unknown;
|
||||
attemptedState: CollectorPersistenceState;
|
||||
rollbackState: CollectorPersistenceState | null;
|
||||
}
|
||||
|
||||
export function createCollectorPersistenceCoordinator(
|
||||
save: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>,
|
||||
delayMs = 300
|
||||
delayMs = 300,
|
||||
onFailure?: (failure: CollectorPersistenceFailure) => void
|
||||
): CollectorPersistenceCoordinator {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending: CollectorPersistenceState | null = null;
|
||||
let running: Promise<void> | null = null;
|
||||
let rollbackState: CollectorPersistenceState | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const reportFailure = (error: unknown, attemptedState: CollectorPersistenceState): void => {
|
||||
try {
|
||||
onFailure?.({ error, attemptedState, rollbackState });
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const drain = (): Promise<void> => {
|
||||
if (running) return running;
|
||||
const task = (async () => {
|
||||
while (pending && !disposed) {
|
||||
const state = pending;
|
||||
const attemptedState = pending;
|
||||
pending = null;
|
||||
await save(state);
|
||||
let state: CollectorPersistenceState;
|
||||
try {
|
||||
state = validateCollectorPersistenceState(attemptedState);
|
||||
} catch (error) {
|
||||
reportFailure(error, attemptedState);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await save(state);
|
||||
rollbackState = state;
|
||||
} catch (error) {
|
||||
reportFailure(error, attemptedState);
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
if (running === task) running = null;
|
||||
@@ -67,19 +95,30 @@ export function createCollectorPersistenceCoordinator(
|
||||
return {
|
||||
schedule: (state) => {
|
||||
if (disposed) return;
|
||||
pending = validateCollectorPersistenceState(state);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
pending = state;
|
||||
try {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void drain();
|
||||
}, Math.max(0, delayMs));
|
||||
} catch {
|
||||
timer = null;
|
||||
void drain().catch(() => {});
|
||||
}, Math.max(0, delayMs));
|
||||
void drain();
|
||||
}
|
||||
},
|
||||
setBaseline: (state) => {
|
||||
rollbackState = state;
|
||||
},
|
||||
flush: async () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
await drain();
|
||||
while (!disposed) {
|
||||
await drain();
|
||||
if (!pending) break;
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
|
||||
@@ -79,8 +79,99 @@ export interface DownloadsViewActions extends DownloadsTableActions {
|
||||
onToggleClipboardWatcher: () => void;
|
||||
onClearAll: () => void;
|
||||
onToggleAllPackages: () => void;
|
||||
onShowAllPackages: () => void;
|
||||
}
|
||||
onShowAllPackages: () => void;
|
||||
}
|
||||
|
||||
export interface DownloadContextStartActionsProps {
|
||||
actionBusy: boolean;
|
||||
canStart: boolean;
|
||||
showSelected: boolean;
|
||||
selectedLabel: string;
|
||||
onStartSelected: () => void;
|
||||
onStartAll: () => void;
|
||||
}
|
||||
|
||||
export async function runDownloadStartAction(
|
||||
canStart: boolean,
|
||||
action: () => Promise<void>,
|
||||
onBlocked: () => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<boolean> {
|
||||
if (!canStart) {
|
||||
onBlocked();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await action();
|
||||
return true;
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resumeDownloadSession(
|
||||
canStart: boolean,
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onBlocked: () => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<boolean> {
|
||||
return runDownloadStartAction(canStart, async () => {
|
||||
applyPaused(await togglePause());
|
||||
}, onBlocked, onError);
|
||||
}
|
||||
|
||||
export async function pauseDownloadSession(
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
applyPaused(await togglePause());
|
||||
return true;
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function runResumeDownloadAction(
|
||||
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
|
||||
canStart: boolean,
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onBlocked: () => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<void> {
|
||||
return runSingleFlight(() => resumeDownloadSession(canStart, togglePause, applyPaused, onBlocked, onError));
|
||||
}
|
||||
|
||||
export function runPauseDownloadAction(
|
||||
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<void> {
|
||||
return runSingleFlight(() => pauseDownloadSession(togglePause, applyPaused, onError));
|
||||
}
|
||||
|
||||
export function DownloadContextStartActions({
|
||||
actionBusy,
|
||||
canStart,
|
||||
showSelected,
|
||||
selectedLabel,
|
||||
onStartSelected,
|
||||
onStartAll
|
||||
}: DownloadContextStartActionsProps): ReactElement {
|
||||
const disabled = !canStart || actionBusy;
|
||||
return (
|
||||
<>
|
||||
{showSelected ? <button className="ctx-menu-item" disabled={disabled} onClick={onStartSelected}>{selectedLabel}</button> : null}
|
||||
<button className="ctx-menu-item" disabled={disabled} onClick={onStartAll}>Alle Downloads starten</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||
{ id: "all", label: "Alle" },
|
||||
@@ -95,7 +186,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct
|
||||
return (
|
||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical" className="downloads-filter-group">
|
||||
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{integerFormatter.format(model.counts[filter.id])}</b></button>)}
|
||||
</SlidingSelection>
|
||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||
@@ -133,7 +224,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
||||
return (
|
||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
||||
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
|
||||
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||
<button disabled={model.actionBusy || !model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
|
||||
<span className={scheduleSlotClass}>
|
||||
@@ -149,15 +240,16 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||
<span aria-label="Paketdarstellung" className="downloads-toolbar-tail" role="group">
|
||||
<button className="downloads-toolbar-toggle-all" disabled={model.empty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
<button className="downloads-toolbar-toggle-all" disabled={model.presentationEmpty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tableState(model: DownloadsViewModel): ReactElement | null {
|
||||
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
|
||||
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
|
||||
function tableState(model: DownloadsViewModel): ReactElement | null {
|
||||
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
|
||||
if (model.presentationEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Entpackte Downloads sind ausgeblendet</strong><span>Deaktiviere „Entpackte Einträge ausblenden“, um sie wieder anzuzeigen.</span></div></div>;
|
||||
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import {
|
||||
createHistoryTableColumnWidths,
|
||||
formatHistoryInteger,
|
||||
formatHistoryDuration,
|
||||
getHistoryPage,
|
||||
getHistoryTableGridTemplate,
|
||||
getHistoryTableMinWidth,
|
||||
HISTORY_TABLE_COLUMN_IDS,
|
||||
paginateHistoryRows,
|
||||
resizeHistoryTableColumn,
|
||||
type HistoryFilter,
|
||||
type HistoryPage,
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
type HistoryTableColumnWidths,
|
||||
type HistoryViewModel
|
||||
} from "./history-model";
|
||||
import "./history.css";
|
||||
import "./history.css";
|
||||
|
||||
export interface HistoryViewActions {
|
||||
onFilterChange: (filter: HistoryFilter) => void;
|
||||
@@ -45,9 +46,11 @@ export interface HistoryViewActions {
|
||||
onReveal: (entryId: string) => void;
|
||||
onRemove: (entryIds: string[]) => void;
|
||||
onClearSelection: () => void;
|
||||
onClearHistory: () => void;
|
||||
onContextMenu: (entryId: string, x: number, y: number) => void;
|
||||
}
|
||||
onClearHistory: () => void;
|
||||
onContextMenu: (entryId: string, x: number, y: number) => void;
|
||||
onVisiblePageCountChange?: (count: number) => void;
|
||||
onVisiblePageChange?: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export interface HistoryViewProps {
|
||||
model: HistoryViewModel;
|
||||
@@ -191,7 +194,7 @@ function HistoryRowDetails({
|
||||
<div className="history-detail-cell" role="cell">
|
||||
<dl className="history-details-grid">
|
||||
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
||||
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
|
||||
<div><dt>Dateien</dt><dd>{formatHistoryInteger(row.fileCount, row.language)}</dd></div>
|
||||
{row.hasStructuredLifecycle ? (
|
||||
<>
|
||||
<div><dt>Download gestartet</dt><dd>{row.startedLabel}</dd></div>
|
||||
@@ -204,8 +207,8 @@ function HistoryRowDetails({
|
||||
<div><dt>Nachbearbeitungsdauer</dt><dd>{row.postProcessDurationLabel}</dd></div>
|
||||
<div><dt>Gesamtdauer</dt><dd>{row.totalDurationLabel}</dd></div>
|
||||
<div><dt>Status</dt><dd>{row.statusLabel}</dd></div>
|
||||
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
|
||||
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
|
||||
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{formatHistoryInteger(row.successfulFiles ?? 0, row.language)} / {formatHistoryInteger(row.failedFiles ?? 0, row.language)} / {formatHistoryInteger(row.cancelledFiles ?? 0, row.language)}</dd></div>
|
||||
<div><dt>Archive / Parts / Ausgaben</dt><dd>{formatHistoryInteger(row.archiveCount ?? 0, row.language)} / {formatHistoryInteger(row.partCount ?? 0, row.language)} / {formatHistoryInteger(row.outputCount ?? 0, row.language)}</dd></div>
|
||||
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
|
||||
<div><dt>Fehlerkategorie</dt><dd>{row.errorCategory || "—"}</dd></div>
|
||||
<div><dt>Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung</dt><dd>{row.failureCountsLabel}</dd></div>
|
||||
@@ -226,7 +229,7 @@ function HistoryRowDetails({
|
||||
{row.archiveOperations.map((operation) => (
|
||||
<li key={operation.id}>
|
||||
<strong>{operation.name}</strong>
|
||||
<span>{operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
|
||||
<span>{formatHistoryInteger(operation.partCount, row.language)} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -270,7 +273,7 @@ export function HistorySidebar({ model, actions }: HistoryViewProps): ReactEleme
|
||||
type="button"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{model.counts[item.id]}</span>
|
||||
<span>{formatHistoryInteger(model.counts[item.id], model.language)}</span>
|
||||
</button>
|
||||
))}
|
||||
</SlidingSelection>
|
||||
@@ -284,14 +287,12 @@ export function HistorySidebar({ model, actions }: HistoryViewProps): ReactEleme
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
|
||||
const selectedIds = model.selectedIds;
|
||||
const selectedSet = new Set(selectedIds);
|
||||
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
|
||||
return (
|
||||
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
|
||||
const selectedIds = model.selectedIds;
|
||||
return (
|
||||
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
|
||||
<ToolbarGroup label="Einträge">
|
||||
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
|
||||
<button className="history-action" disabled={selectedIds.length === 0 || !model.restorableSelected} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
|
||||
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
|
||||
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
|
||||
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
|
||||
@@ -308,7 +309,10 @@ export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactEleme
|
||||
}
|
||||
|
||||
export function historyPageStatusLabel(page: HistoryPage): string {
|
||||
return `Seite ${page.page} von ${page.totalPages}`;
|
||||
const language = page.language ?? "de";
|
||||
return language === "en"
|
||||
? `Page ${formatHistoryInteger(page.page, language)} of ${formatHistoryInteger(page.totalPages, language)}`
|
||||
: `Seite ${formatHistoryInteger(page.page, language)} von ${formatHistoryInteger(page.totalPages, language)}`;
|
||||
}
|
||||
|
||||
export function HistoryPagination({
|
||||
@@ -317,10 +321,11 @@ export function HistoryPagination({
|
||||
}: {
|
||||
page: HistoryPage;
|
||||
onPageChange: (page: number) => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<nav aria-label="Verlaufsseiten" className="history-pagination" data-visual-region="history-pagination">
|
||||
<span className="history-pagination-size">{page.pageSize} pro Seite</span>
|
||||
}): ReactElement {
|
||||
const language = page.language ?? "de";
|
||||
return (
|
||||
<nav aria-label="Verlaufsseiten" className="history-pagination" data-visual-region="history-pagination">
|
||||
<span className="history-pagination-size">{formatHistoryInteger(page.pageSize, language)} {language === "en" ? "per page" : "pro Seite"}</span>
|
||||
<div className="history-pagination-controls">
|
||||
<button
|
||||
aria-label="Vorherige Verlaufsseite"
|
||||
@@ -341,8 +346,24 @@ export function HistoryPagination({
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function reportHistoryVisiblePageCount(
|
||||
actions: Pick<HistoryViewActions, "onVisiblePageCountChange">,
|
||||
page: HistoryPage
|
||||
): void {
|
||||
actions.onVisiblePageCountChange?.(page.rows.length);
|
||||
}
|
||||
|
||||
export function reportHistoryVisiblePage(
|
||||
actions: Pick<HistoryViewActions, "onVisiblePageChange" | "onVisiblePageCountChange">,
|
||||
page: HistoryPage
|
||||
): void {
|
||||
const ids = page.rows.map((row) => row.id);
|
||||
actions.onVisiblePageCountChange?.(ids.length);
|
||||
actions.onVisiblePageChange?.(ids);
|
||||
}
|
||||
|
||||
interface HistoryContentPageProps extends HistoryViewProps {
|
||||
page: HistoryPage;
|
||||
onPageChange: (page: number) => void;
|
||||
@@ -514,11 +535,15 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
|
||||
);
|
||||
}
|
||||
|
||||
function PaginatedHistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||
const [requestedPage, setRequestedPage] = useState(1);
|
||||
const page = paginateHistoryRows(model.rows, requestedPage);
|
||||
|
||||
useEffect(() => {
|
||||
function PaginatedHistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||
const [requestedPage, setRequestedPage] = useState(1);
|
||||
const page = getHistoryPage(model, requestedPage);
|
||||
|
||||
useEffect(() => {
|
||||
reportHistoryVisiblePage(actions, page);
|
||||
}, [actions.onVisiblePageChange, actions.onVisiblePageCountChange, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setRequestedPage((current) => current === page.page ? current : page.page);
|
||||
}, [page.page]);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { DebridProvider, HistoryEntry } from "../../../shared/types";
|
||||
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "partial" | "failed" | "cancelled" | "deleted";
|
||||
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
|
||||
export type HistoryViewEntry = Omit<HistoryEntry, "status"> & { status: HistoryViewStatus };
|
||||
export type HistoryLanguage = "de" | "en";
|
||||
|
||||
export interface HistoryRow extends HistoryViewEntry {
|
||||
language: HistoryLanguage;
|
||||
hoster: string;
|
||||
providerLabel: string;
|
||||
startAt: number;
|
||||
@@ -39,7 +41,8 @@ export interface HistoryFilterCounts {
|
||||
}
|
||||
|
||||
export interface HistoryViewModel {
|
||||
rows: HistoryRow[];
|
||||
rows: readonly HistoryViewEntry[];
|
||||
visibleIds: readonly string[];
|
||||
filter: HistoryFilter;
|
||||
query: string;
|
||||
selectedIds: string[];
|
||||
@@ -49,6 +52,9 @@ export interface HistoryViewModel {
|
||||
error: string;
|
||||
totalCount: number;
|
||||
animationsEnabled: boolean;
|
||||
language: HistoryLanguage;
|
||||
restorableSelected: boolean;
|
||||
pageSource: HistoryPageSource;
|
||||
}
|
||||
|
||||
export interface HistoryPage {
|
||||
@@ -58,6 +64,12 @@ export interface HistoryPage {
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
rangeLabel: string;
|
||||
language?: HistoryLanguage;
|
||||
}
|
||||
|
||||
export interface HistoryPageSource {
|
||||
entries: readonly HistoryViewEntry[];
|
||||
language: HistoryLanguage;
|
||||
}
|
||||
|
||||
export const HISTORY_PAGE_SIZE = 100;
|
||||
@@ -127,28 +139,57 @@ const providerLabels: Record<DebridProvider, string> = {
|
||||
linksnappy: "LinkSnappy"
|
||||
};
|
||||
|
||||
const statusLabels: Record<HistoryViewStatus, string> = {
|
||||
completed: "Abgeschlossen",
|
||||
partial: "Teilweise",
|
||||
cancelled: "Abgebrochen",
|
||||
deleted: "Gelöscht",
|
||||
failed: "Fehlgeschlagen"
|
||||
const statusLabels: Record<HistoryLanguage, Record<HistoryViewStatus, string>> = {
|
||||
de: {
|
||||
completed: "Abgeschlossen",
|
||||
partial: "Teilweise",
|
||||
cancelled: "Abgebrochen",
|
||||
deleted: "Gelöscht",
|
||||
failed: "Fehlgeschlagen"
|
||||
},
|
||||
en: {
|
||||
completed: "Completed",
|
||||
partial: "Partial",
|
||||
cancelled: "Cancelled",
|
||||
deleted: "Deleted",
|
||||
failed: "Failed"
|
||||
}
|
||||
};
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
const locales: Record<HistoryLanguage, string> = { de: "de-DE", en: "en-US" };
|
||||
const numberFormatters: Record<HistoryLanguage, Intl.NumberFormat> = {
|
||||
de: new Intl.NumberFormat(locales.de, { maximumFractionDigits: 1 }),
|
||||
en: new Intl.NumberFormat(locales.en, { maximumFractionDigits: 1 })
|
||||
};
|
||||
const integerFormatters: Record<HistoryLanguage, Intl.NumberFormat> = {
|
||||
de: new Intl.NumberFormat(locales.de, { maximumFractionDigits: 0 }),
|
||||
en: new Intl.NumberFormat(locales.en, { maximumFractionDigits: 0 })
|
||||
};
|
||||
const dateFormatters: Record<HistoryLanguage, Intl.DateTimeFormat> = {
|
||||
de: new Intl.DateTimeFormat(locales.de, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}),
|
||||
en: new Intl.DateTimeFormat(locales.en, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
export function formatHistoryInteger(value: number, language: HistoryLanguage = "de"): string {
|
||||
return integerFormatters[language].format(value);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number, language: HistoryLanguage): string {
|
||||
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
|
||||
if (safe < 1024) {
|
||||
return `${Math.round(safe)} B`;
|
||||
return `${formatHistoryInteger(Math.round(safe), language)} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = safe / 1024;
|
||||
@@ -157,7 +198,7 @@ function formatBytes(bytes: number): string {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
|
||||
return `${numberFormatters[language].format(value)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function formatHistoryDuration(durationSeconds: number): string {
|
||||
@@ -171,21 +212,21 @@ export function formatHistoryDuration(durationSeconds: number): string {
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: number | undefined): string {
|
||||
function formatTimestamp(timestamp: number | undefined, language: HistoryLanguage): string {
|
||||
const safe = Math.max(0, Number.isFinite(timestamp) ? Number(timestamp) : 0);
|
||||
return safe > 0 ? dateFormatter.format(new Date(safe)) : "—";
|
||||
return safe > 0 ? dateFormatters[language].format(new Date(safe)) : "—";
|
||||
}
|
||||
|
||||
function failurePhaseLabel(entry: HistoryViewEntry): string {
|
||||
function failurePhaseLabel(entry: HistoryViewEntry, language: HistoryLanguage): string {
|
||||
if (entry.failurePhase === "download") return "Download";
|
||||
if (entry.failurePhase === "extract") return "Entpacken";
|
||||
if (entry.failurePhase === "extract") return language === "de" ? "Entpacken" : "Extraction";
|
||||
if (entry.failurePhase === "remux") return "Remux";
|
||||
if (entry.failurePhase === "cleanup") return "Aufräumen";
|
||||
if (entry.failurePhase === "postprocess") return "Nachbearbeitung";
|
||||
if (entry.failurePhase === "cleanup") return language === "de" ? "Aufräumen" : "Cleanup";
|
||||
if (entry.failurePhase === "postprocess") return language === "de" ? "Nachbearbeitung" : "Post-processing";
|
||||
return "—";
|
||||
}
|
||||
|
||||
function failureCountsLabel(entry: HistoryViewEntry): string {
|
||||
function failureCountsLabel(entry: HistoryViewEntry, language: HistoryLanguage): string {
|
||||
const values = [
|
||||
entry.downloadFailures,
|
||||
entry.offlineFailures,
|
||||
@@ -196,61 +237,103 @@ function failureCountsLabel(entry: HistoryViewEntry): string {
|
||||
];
|
||||
return values.every((value) => value === undefined)
|
||||
? "—"
|
||||
: values.map((value) => Math.max(0, Number(value) || 0)).join(" / ");
|
||||
: values.map((value) => formatHistoryInteger(Math.max(0, Number(value) || 0), language)).join(" / ");
|
||||
}
|
||||
|
||||
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
|
||||
const totalItems = rows.length;
|
||||
function createHistoryPage(source: HistoryPageSource, requestedPage: number): HistoryPage {
|
||||
const totalItems = source.entries.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
|
||||
const normalizedPage = Number.isFinite(requestedPage) ? Math.trunc(requestedPage) : 1;
|
||||
const page = Math.min(totalPages, Math.max(1, normalizedPage));
|
||||
const startIndex = (page - 1) * HISTORY_PAGE_SIZE;
|
||||
const endIndex = Math.min(totalItems, startIndex + HISTORY_PAGE_SIZE);
|
||||
const rangeLabel = totalItems === 0
|
||||
? "0 von 0"
|
||||
: `${integerFormatter.format(startIndex + 1)}–${integerFormatter.format(endIndex)} von ${integerFormatter.format(totalItems)}`;
|
||||
? source.language === "de" ? "0 von 0" : "0 of 0"
|
||||
: source.language === "de"
|
||||
? `${formatHistoryInteger(startIndex + 1, source.language)}–${formatHistoryInteger(endIndex, source.language)} von ${formatHistoryInteger(totalItems, source.language)}`
|
||||
: `${formatHistoryInteger(startIndex + 1, source.language)}–${formatHistoryInteger(endIndex, source.language)} of ${formatHistoryInteger(totalItems, source.language)}`;
|
||||
|
||||
return {
|
||||
rows: rows.slice(startIndex, endIndex),
|
||||
rows: source.entries.slice(startIndex, endIndex).map((entry) => toHistoryRow(entry, source.language)),
|
||||
page,
|
||||
pageSize: HISTORY_PAGE_SIZE,
|
||||
totalItems,
|
||||
totalPages,
|
||||
rangeLabel
|
||||
rangeLabel,
|
||||
language: source.language
|
||||
};
|
||||
}
|
||||
|
||||
const historyPageCaches = new WeakMap<HistoryPageSource, Map<number, HistoryPage>>();
|
||||
|
||||
export function getHistoryPage(model: Pick<HistoryViewModel, "pageSource">, requestedPage: number): HistoryPage {
|
||||
const source = model.pageSource;
|
||||
const totalPages = Math.max(1, Math.ceil(source.entries.length / HISTORY_PAGE_SIZE));
|
||||
const normalizedRequest = Number.isFinite(requestedPage) ? Math.trunc(requestedPage) : 1;
|
||||
const page = Math.min(totalPages, Math.max(1, normalizedRequest));
|
||||
let pages = historyPageCaches.get(source);
|
||||
if (!pages) {
|
||||
pages = new Map();
|
||||
historyPageCaches.set(source, pages);
|
||||
}
|
||||
const cached = pages.get(page);
|
||||
if (cached) return cached;
|
||||
const projected = createHistoryPage(source, page);
|
||||
pages.set(page, projected);
|
||||
return projected;
|
||||
}
|
||||
|
||||
export function paginateHistoryRows(
|
||||
rows: readonly HistoryViewEntry[],
|
||||
requestedPage: number,
|
||||
language: HistoryLanguage = "de"
|
||||
): HistoryPage {
|
||||
return createHistoryPage({ entries: rows, language }, requestedPage);
|
||||
}
|
||||
|
||||
function localDayStart(timestamp: number): number {
|
||||
const date = new Date(timestamp);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, now: number): boolean {
|
||||
interface HistoryTimeWindow {
|
||||
todayStart: number;
|
||||
tomorrowStart: number;
|
||||
weekStart: number;
|
||||
}
|
||||
|
||||
function createHistoryTimeWindow(now: number): HistoryTimeWindow {
|
||||
const todayStart = localDayStart(now);
|
||||
const tomorrowStartDate = new Date(todayStart);
|
||||
tomorrowStartDate.setDate(tomorrowStartDate.getDate() + 1);
|
||||
const weekStartDate = new Date(todayStart);
|
||||
weekStartDate.setDate(weekStartDate.getDate() - 6);
|
||||
return {
|
||||
todayStart,
|
||||
tomorrowStart: tomorrowStartDate.getTime(),
|
||||
weekStart: weekStartDate.getTime()
|
||||
};
|
||||
}
|
||||
|
||||
function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, window: HistoryTimeWindow): boolean {
|
||||
if (filter === "completed" || filter === "partial" || filter === "failed" || filter === "cancelled" || filter === "deleted") {
|
||||
return entry.status === filter;
|
||||
}
|
||||
if (filter === "all") {
|
||||
return true;
|
||||
}
|
||||
const todayStart = localDayStart(now);
|
||||
const tomorrowStartDate = new Date(todayStart);
|
||||
tomorrowStartDate.setDate(tomorrowStartDate.getDate() + 1);
|
||||
const tomorrowStart = tomorrowStartDate.getTime();
|
||||
const weekStartDate = new Date(todayStart);
|
||||
weekStartDate.setDate(weekStartDate.getDate() - 6);
|
||||
const weekStart = weekStartDate.getTime();
|
||||
if (filter === "today") {
|
||||
return entry.completedAt >= todayStart && entry.completedAt < tomorrowStart;
|
||||
return entry.completedAt >= window.todayStart && entry.completedAt < window.tomorrowStart;
|
||||
}
|
||||
if (filter === "week") {
|
||||
return entry.completedAt >= weekStart && entry.completedAt < todayStart;
|
||||
return entry.completedAt >= window.weekStart && entry.completedAt < window.tomorrowStart;
|
||||
}
|
||||
return entry.completedAt < weekStart;
|
||||
return entry.completedAt < window.weekStart;
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string): string {
|
||||
return value.trim().toLocaleLowerCase("de-DE");
|
||||
function normalizeSearch(value: string, language: HistoryLanguage): string {
|
||||
return value.trim().toLocaleLowerCase(locales[language]);
|
||||
}
|
||||
|
||||
export function deriveHistoryHoster(urls: string[] | undefined): string {
|
||||
@@ -285,7 +368,7 @@ export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt"
|
||||
return Math.max(0, completedAt - durationMs);
|
||||
}
|
||||
|
||||
function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
|
||||
function toHistoryRow(entry: HistoryViewEntry, language: HistoryLanguage): HistoryRow {
|
||||
const hoster = deriveHistoryHoster(entry.urls);
|
||||
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
|
||||
const startAt = deriveHistoryStartAt(entry);
|
||||
@@ -297,69 +380,153 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
|
||||
|| entry.totalDurationSeconds !== undefined;
|
||||
return {
|
||||
...entry,
|
||||
language,
|
||||
hoster,
|
||||
providerLabel,
|
||||
startAt,
|
||||
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
|
||||
startedLabel: formatTimestamp(startAt),
|
||||
completedLabel: formatTimestamp(entry.completedAt),
|
||||
sizeLabel: `${formatBytes(entry.downloadedBytes, language)} / ${formatBytes(entry.totalBytes, language)}`,
|
||||
startedLabel: formatTimestamp(startAt, language),
|
||||
completedLabel: formatTimestamp(entry.completedAt, language),
|
||||
durationLabel: formatHistoryDuration(downloadDurationSeconds),
|
||||
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
|
||||
statusLabel: statusLabels[entry.status],
|
||||
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond, language)}/s` : "—",
|
||||
statusLabel: statusLabels[language][entry.status],
|
||||
hasStructuredLifecycle,
|
||||
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt),
|
||||
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt),
|
||||
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt, language),
|
||||
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt, language),
|
||||
downloadDurationLabel: formatHistoryDuration(entry.downloadDurationSeconds ?? 0),
|
||||
extractionDurationLabel: formatHistoryDuration(entry.extractionDurationSeconds ?? 0),
|
||||
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
|
||||
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
|
||||
totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0),
|
||||
failurePhaseLabel: failurePhaseLabel(entry),
|
||||
failureCountsLabel: failureCountsLabel(entry)
|
||||
failurePhaseLabel: failurePhaseLabel(entry, language),
|
||||
failureCountsLabel: failureCountsLabel(entry, language)
|
||||
};
|
||||
}
|
||||
|
||||
export function filterHistoryRows(
|
||||
entries: HistoryViewEntry[],
|
||||
entries: readonly HistoryViewEntry[],
|
||||
filter: HistoryFilter,
|
||||
query: string,
|
||||
now = Date.now()
|
||||
now = Date.now(),
|
||||
language: HistoryLanguage = "de"
|
||||
): HistoryRow[] {
|
||||
const normalizedQuery = normalizeSearch(query);
|
||||
return entries
|
||||
.filter((entry) => matchesTemporalFilter(entry, filter, now))
|
||||
.map(toHistoryRow)
|
||||
.filter((row) => {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
const searchable = [
|
||||
row.name,
|
||||
row.outputDir,
|
||||
row.hoster,
|
||||
row.providerLabel,
|
||||
...(row.urls ?? [])
|
||||
].join("\n").toLocaleLowerCase("de-DE");
|
||||
return searchable.includes(normalizedQuery);
|
||||
});
|
||||
return getHistoryAnalysis(entries, filter, query, now, language).match.entries.map((entry) => toHistoryRow(entry, language));
|
||||
}
|
||||
|
||||
function countHistoryFilters(entries: HistoryViewEntry[], now: number): HistoryFilterCounts {
|
||||
interface HistoryMatchAnalysis {
|
||||
entries: HistoryViewEntry[];
|
||||
ids: string[];
|
||||
entriesById: Map<string, HistoryViewEntry>;
|
||||
pageSource: HistoryPageSource;
|
||||
}
|
||||
|
||||
interface HistorySourceAnalysis {
|
||||
dayStart: number;
|
||||
counts: HistoryFilterCounts;
|
||||
searchTexts: string[];
|
||||
matchKey: string;
|
||||
match: HistoryMatchAnalysis;
|
||||
}
|
||||
|
||||
const historySourceCache = new WeakMap<readonly HistoryViewEntry[], HistorySourceAnalysis>();
|
||||
const emptyHistoryEntries: readonly HistoryViewEntry[] = [];
|
||||
|
||||
function createHistoryFilterCounts(): HistoryFilterCounts {
|
||||
return {
|
||||
all: entries.length,
|
||||
today: entries.filter((entry) => matchesTemporalFilter(entry, "today", now)).length,
|
||||
week: entries.filter((entry) => matchesTemporalFilter(entry, "week", now)).length,
|
||||
older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length,
|
||||
completed: entries.filter((entry) => entry.status === "completed").length,
|
||||
partial: entries.filter((entry) => entry.status === "partial").length,
|
||||
cancelled: entries.filter((entry) => entry.status === "cancelled").length,
|
||||
deleted: entries.filter((entry) => entry.status === "deleted").length,
|
||||
failed: entries.filter((entry) => entry.status === "failed").length
|
||||
all: 0,
|
||||
today: 0,
|
||||
week: 0,
|
||||
older: 0,
|
||||
completed: 0,
|
||||
partial: 0,
|
||||
cancelled: 0,
|
||||
deleted: 0,
|
||||
failed: 0
|
||||
};
|
||||
}
|
||||
|
||||
function createHistorySearchText(entry: HistoryViewEntry, language: HistoryLanguage): string {
|
||||
return [
|
||||
entry.name,
|
||||
entry.outputDir,
|
||||
entry.provider ? providerLabels[entry.provider] : "",
|
||||
...(entry.urls ?? [])
|
||||
].join("\n").toLocaleLowerCase(locales[language]);
|
||||
}
|
||||
|
||||
function createHistoryMatchAnalysis(
|
||||
entries: readonly HistoryViewEntry[],
|
||||
filter: HistoryFilter,
|
||||
normalizedQuery: string,
|
||||
window: HistoryTimeWindow,
|
||||
language: HistoryLanguage,
|
||||
searchTexts: string[],
|
||||
counts?: HistoryFilterCounts,
|
||||
populateSearchTexts = false
|
||||
): HistoryMatchAnalysis {
|
||||
const matchingEntries: HistoryViewEntry[] = [];
|
||||
const ids: string[] = [];
|
||||
const entriesById = new Map<string, HistoryViewEntry>();
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
const searchText = populateSearchTexts ? createHistorySearchText(entry, language) : searchTexts[index];
|
||||
if (populateSearchTexts) searchTexts.push(searchText);
|
||||
if (counts) {
|
||||
counts.all += 1;
|
||||
if (entry.completedAt >= window.todayStart && entry.completedAt < window.tomorrowStart) counts.today += 1;
|
||||
if (entry.completedAt >= window.weekStart && entry.completedAt < window.tomorrowStart) counts.week += 1;
|
||||
if (entry.completedAt < window.weekStart) counts.older += 1;
|
||||
counts[entry.status] += 1;
|
||||
}
|
||||
if (!matchesTemporalFilter(entry, filter, window) || (normalizedQuery && !searchText.includes(normalizedQuery))) continue;
|
||||
matchingEntries.push(entry);
|
||||
ids.push(entry.id);
|
||||
entriesById.set(entry.id, entry);
|
||||
}
|
||||
return {
|
||||
entries: matchingEntries,
|
||||
ids,
|
||||
entriesById,
|
||||
pageSource: { entries: matchingEntries, language }
|
||||
};
|
||||
}
|
||||
|
||||
function getHistoryAnalysis(
|
||||
entries: readonly HistoryViewEntry[],
|
||||
filter: HistoryFilter,
|
||||
query: string,
|
||||
now: number,
|
||||
language: HistoryLanguage
|
||||
): HistorySourceAnalysis {
|
||||
const window = createHistoryTimeWindow(now);
|
||||
const normalizedQuery = normalizeSearch(query, language);
|
||||
const matchKey = `${filter}\u0000${language}\u0000${normalizedQuery}`;
|
||||
const cached = historySourceCache.get(entries);
|
||||
if (cached?.dayStart === window.todayStart) {
|
||||
if (cached.matchKey === matchKey) return cached;
|
||||
const next = {
|
||||
...cached,
|
||||
matchKey,
|
||||
match: createHistoryMatchAnalysis(entries, filter, normalizedQuery, window, language, cached.searchTexts)
|
||||
};
|
||||
historySourceCache.set(entries, next);
|
||||
return next;
|
||||
}
|
||||
const counts = createHistoryFilterCounts();
|
||||
const searchTexts: string[] = [];
|
||||
const analysis = {
|
||||
dayStart: window.todayStart,
|
||||
counts,
|
||||
searchTexts,
|
||||
matchKey,
|
||||
match: createHistoryMatchAnalysis(entries, filter, normalizedQuery, window, language, searchTexts, counts, true)
|
||||
};
|
||||
historySourceCache.set(entries, analysis);
|
||||
return analysis;
|
||||
}
|
||||
|
||||
export function buildHistoryViewModel(
|
||||
entries: HistoryViewEntry[],
|
||||
entries: readonly HistoryViewEntry[],
|
||||
filter: HistoryFilter,
|
||||
query: string,
|
||||
selectedIds: Iterable<string>,
|
||||
@@ -367,21 +534,34 @@ export function buildHistoryViewModel(
|
||||
loading: boolean,
|
||||
error: string,
|
||||
now = Date.now(),
|
||||
animationsEnabled = true
|
||||
animationsEnabled = true,
|
||||
language: HistoryLanguage = "de"
|
||||
): HistoryViewModel {
|
||||
const rows = filterHistoryRows(entries, filter, query, now);
|
||||
const visibleIds = new Set(rows.map((row) => row.id));
|
||||
const availableEntries = loading || error ? emptyHistoryEntries : entries;
|
||||
const analysis = getHistoryAnalysis(availableEntries, filter, query, now, language);
|
||||
const selected: string[] = [];
|
||||
let restorableSelected = false;
|
||||
for (const id of selectedIds) {
|
||||
const entry = analysis.match.entriesById.get(id);
|
||||
if (!entry) continue;
|
||||
selected.push(id);
|
||||
if ((entry.urls?.length ?? 0) > 0) restorableSelected = true;
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
rows: analysis.match.entries,
|
||||
visibleIds: analysis.match.ids,
|
||||
filter,
|
||||
query,
|
||||
selectedIds: [...selectedIds].filter((id) => visibleIds.has(id)),
|
||||
selectedIds: selected,
|
||||
expandedIds: [...expandedIds],
|
||||
counts: countHistoryFilters(entries, now),
|
||||
counts: analysis.counts,
|
||||
loading,
|
||||
error,
|
||||
totalCount: entries.length,
|
||||
animationsEnabled
|
||||
totalCount: availableEntries.length,
|
||||
animationsEnabled,
|
||||
language,
|
||||
restorableSelected,
|
||||
pageSource: analysis.match.pageSource
|
||||
};
|
||||
}
|
||||
|
||||
@@ -402,3 +582,16 @@ export function pruneHistoryIds(current: Set<string>, availableIds: Iterable<str
|
||||
export function selectVisibleHistoryIds(visibleIds: Iterable<string>): Set<string> {
|
||||
return new Set(visibleIds);
|
||||
}
|
||||
|
||||
export function toggleHistoryPageSelection(current: ReadonlySet<string>, visibleIds: readonly string[]): Set<string> {
|
||||
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id));
|
||||
const next = new Set(current);
|
||||
for (const id of visibleIds) {
|
||||
if (allVisibleSelected) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
|
||||
|
||||
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
|
||||
export type SettingsSaveState = "clean" | "dirty" | "saving" | "saved" | "error";
|
||||
export type SettingsThemeChoice = RendererSettings["themePreference"];
|
||||
|
||||
export const SETTINGS_SECTIONS: readonly { id: SettingsSection; label: string }[] = [
|
||||
{ id: "allgemein", label: "Allgemein" },
|
||||
@@ -234,8 +235,8 @@ export interface SettingsSwitchFieldViewModel extends SettingsFieldBase {
|
||||
|
||||
export interface SettingsThemeFieldViewModel extends SettingsFieldBase {
|
||||
kind: "theme";
|
||||
value: string;
|
||||
options: readonly { value: string; label: string }[];
|
||||
value: SettingsThemeChoice;
|
||||
options: readonly { value: SettingsThemeChoice; label: string }[];
|
||||
}
|
||||
|
||||
export interface SettingsActionFieldViewModel extends SettingsFieldBase {
|
||||
@@ -268,7 +269,7 @@ export interface SettingsFormProjectionInput {
|
||||
section: SettingsSection;
|
||||
speedLimitInput: string;
|
||||
scheduleSpeedInputs: Readonly<Record<string, string>>;
|
||||
themeChoice?: "light" | "dark" | "system";
|
||||
themeChoice?: SettingsThemeChoice;
|
||||
}
|
||||
|
||||
const NOTIFICATION_NUMBER_LIMITS = {
|
||||
@@ -292,7 +293,7 @@ export function buildSettingsFormViewModel({
|
||||
section,
|
||||
speedLimitInput,
|
||||
scheduleSpeedInputs,
|
||||
themeChoice = settings.theme
|
||||
themeChoice = settings.themePreference
|
||||
}: SettingsFormProjectionInput): SettingsFormViewModel {
|
||||
if (section === "extract") {
|
||||
return {
|
||||
@@ -602,8 +603,8 @@ export function buildSettingsFormViewModel({
|
||||
label: "Theme",
|
||||
value: themeChoice,
|
||||
options: [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "light", label: "Hell" },
|
||||
{ value: "dark", label: "Dunkel" },
|
||||
{ value: "system", label: "System" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.md-shell-sidebar-scroll > .settings-sidebar {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.settings-sidebar-heading {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
|
||||
@@ -151,7 +151,7 @@ export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "mo
|
||||
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
|
||||
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
|
||||
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
|
||||
model.providerScope ? `${model.usageKind === "accounts" ? "Accounts" : "Provider"}: ${model.providers.length}` : null
|
||||
model.providerScope ? `${model.usageKind === "accounts" ? "Accounts" : "Provider"}: ${numberFormatter.format(model.providers.length)}` : null
|
||||
].filter((value): value is string => value !== null);
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
@@ -219,9 +219,9 @@ export function StatisticsContent({ model, actions, chart }: StatisticsViewProps
|
||||
<span className="statistics-provider-name" role="cell">{provider.label}</span>
|
||||
<span role="cell">{formatBytes(provider.bytes)}</span>
|
||||
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
|
||||
{provider.completed === null || provider.failed === null
|
||||
? "–"
|
||||
: `${provider.completed} fertig · ${provider.failed} Fehler`}
|
||||
{provider.completed === null || provider.failed === null
|
||||
? "–"
|
||||
: `${numberFormatter.format(provider.completed)} fertig · ${numberFormatter.format(provider.failed)} Fehler`}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { aggregateStatisticsRange, type StatisticsAggregate } from "../../../shared/statistics-aggregation";
|
||||
import type { DebridProvider, DownloadItem, DownloadSummary, StatisticsProviderBucket, UiSnapshot } from "../../../shared/types";
|
||||
import { getProviderUsageDayKey } from "../../../shared/provider-daily-limits";
|
||||
import type { DebridProvider, DownloadItem, DownloadSummary, StatisticsLedger, StatisticsProviderBucket, UiSnapshot } from "../../../shared/types";
|
||||
|
||||
export type StatisticsRange = "session" | "today" | "last24" | "week" | "month" | "all";
|
||||
export type StatisticsCoverage = "partial" | "unavailable";
|
||||
@@ -156,7 +157,8 @@ function deriveQueueProviders(items: DownloadItem[]): StatisticsProviderRow[] {
|
||||
|
||||
function deriveUsageProviders(
|
||||
usage: Partial<Record<DebridProvider, number>>,
|
||||
outcomes: Partial<Record<DebridProvider, StatisticsProviderBucket>> = {}
|
||||
outcomes: Partial<Record<DebridProvider, StatisticsProviderBucket>> = {},
|
||||
outcomesAvailable = true
|
||||
): StatisticsProviderRow[] {
|
||||
const rows: StatisticsProviderRow[] = [];
|
||||
const providerIds = new Set<DebridProvider>([
|
||||
@@ -175,8 +177,8 @@ function deriveUsageProviders(
|
||||
id,
|
||||
label: providerLabels[id],
|
||||
bytes,
|
||||
completed,
|
||||
failed
|
||||
completed: outcomesAvailable ? completed : null,
|
||||
failed: outcomesAvailable ? failed : null
|
||||
});
|
||||
}
|
||||
return sortProviderRows(rows);
|
||||
@@ -192,25 +194,56 @@ function deriveRollingAccounts(snapshot: UiSnapshot): StatisticsProviderRow[] {
|
||||
})).sort((left, right) => right.bytes - left.bytes || left.label.localeCompare(right.label, "de-DE", { sensitivity: "base" }) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function aggregateMetrics(aggregate: StatisticsAggregate, sourceLabel: string): StatisticsMetrics {
|
||||
function aggregateMetrics(aggregate: StatisticsAggregate, sourceLabel: string, detailsAvailable = true): StatisticsMetrics {
|
||||
return {
|
||||
downloadedBytes: availableMetric(aggregate.downloadedBytes, sourceLabel),
|
||||
files: availableMetric(aggregate.completedFiles, sourceLabel),
|
||||
successRate: successRateMetric(aggregate.completedFiles, aggregate.failedFiles, sourceLabel),
|
||||
averageSpeedBps: aggregate.averageSpeedBps === null
|
||||
files: detailsAvailable ? availableMetric(aggregate.completedFiles, sourceLabel) : unavailableMetric("Nicht verfügbar"),
|
||||
successRate: detailsAvailable
|
||||
? successRateMetric(aggregate.completedFiles, aggregate.failedFiles, sourceLabel)
|
||||
: unavailableMetric("Nicht verfügbar"),
|
||||
averageSpeedBps: !detailsAvailable
|
||||
? unavailableMetric("Nicht verfügbar")
|
||||
: aggregate.averageSpeedBps === null
|
||||
? unavailableMetric("Noch keine aktive Downloadzeit mit übertragenen Daten erfasst")
|
||||
: availableMetric(aggregate.averageSpeedBps, sourceLabel),
|
||||
errors: availableMetric(
|
||||
errors: detailsAvailable ? availableMetric(
|
||||
aggregate.failedFiles,
|
||||
sourceLabel,
|
||||
aggregate.failedFiles > 0 ? "danger" : undefined
|
||||
)
|
||||
) : unavailableMetric("Nicht verfügbar")
|
||||
};
|
||||
}
|
||||
|
||||
function statisticsWindowStart(nowMs: number, days: number | null): string {
|
||||
if (days === null) {
|
||||
return "0000-00-00";
|
||||
}
|
||||
const date = new Date(nowMs);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - Math.max(0, days - 1));
|
||||
return getProviderUsageDayKey(date.getTime());
|
||||
}
|
||||
|
||||
function hasProviderBytesOnlyDay(
|
||||
ledger: StatisticsLedger | null | undefined,
|
||||
days: number | null,
|
||||
nowMs: number
|
||||
): boolean {
|
||||
const start = statisticsWindowStart(nowMs, days);
|
||||
const end = getProviderUsageDayKey(nowMs);
|
||||
return (ledger?.providerBytesOnlyDays ?? []).some((day) => day >= start && day <= end);
|
||||
}
|
||||
|
||||
function hasProviderBytesOnlyRollingDay(ledger: StatisticsLedger | null | undefined, nowMs: number): boolean {
|
||||
const start = getProviderUsageDayKey(nowMs - (24 * 60 * 60 * 1_000));
|
||||
const end = getProviderUsageDayKey(nowMs);
|
||||
return (ledger?.providerBytesOnlyDays ?? []).some((day) => day >= start && day <= end);
|
||||
}
|
||||
|
||||
function recordedDaysMessage(label: string, coveredDays: number): string {
|
||||
const days = coveredDays === 1 ? "1 erfasster Tag" : `${coveredDays} erfasste Tage`;
|
||||
return `${label}: ${days} werden bis heute zusammengefasst.`;
|
||||
return coveredDays === 1
|
||||
? `${label}: 1 erfasster Tag wird bis heute zusammengefasst.`
|
||||
: `${label}: ${coveredDays} erfasste Tage werden bis heute zusammengefasst.`;
|
||||
}
|
||||
|
||||
export function buildStatisticsViewModel(
|
||||
@@ -222,7 +255,12 @@ export function buildStatisticsViewModel(
|
||||
|
||||
if (range === "last24") {
|
||||
const rolling = snapshot.stats.rolling24Hours;
|
||||
const hasFullCoverage = nowMs - Math.max(0, snapshot.stats.statistics?.startedAt ?? nowMs) >= 24 * 60 * 60 * 1_000;
|
||||
const minuteTrackingStartedAt = snapshot.stats.statistics?.minuteTrackingStartedAt;
|
||||
const rollingAvailable = !hasProviderBytesOnlyRollingDay(snapshot.stats.statistics, nowMs);
|
||||
const hasFullCoverage = typeof minuteTrackingStartedAt === "number"
|
||||
&& Number.isFinite(minuteTrackingStartedAt)
|
||||
&& minuteTrackingStartedAt > 0
|
||||
&& nowMs - minuteTrackingStartedAt >= 24 * 60 * 60 * 1_000;
|
||||
return {
|
||||
range,
|
||||
coverage: "partial",
|
||||
@@ -231,7 +269,9 @@ export function buildStatisticsViewModel(
|
||||
: "Letzte 24 Stunden: Werte seit Beginn der Aufzeichnung.",
|
||||
sessionState,
|
||||
metrics: {
|
||||
downloadedBytes: availableMetric(rolling?.downloadedBytes ?? 0, "Letzte 24 Stunden"),
|
||||
downloadedBytes: rollingAvailable
|
||||
? availableMetric(rolling?.downloadedBytes ?? 0, "Letzte 24 Stunden")
|
||||
: unavailableMetric("Nicht verfügbar"),
|
||||
files: unavailableMetric("Dateien werden nicht minutengenau nach Account erfasst"),
|
||||
successRate: unavailableMetric("Ergebnisse werden nicht minutengenau nach Account erfasst"),
|
||||
averageSpeedBps: unavailableMetric("Aktive Downloadzeit wird nur tagesweise erfasst"),
|
||||
@@ -239,7 +279,7 @@ export function buildStatisticsViewModel(
|
||||
},
|
||||
providerScope: "last24",
|
||||
usageKind: "accounts",
|
||||
providers: deriveRollingAccounts(snapshot),
|
||||
providers: rollingAvailable ? deriveRollingAccounts(snapshot) : [],
|
||||
errorResetAvailable: false
|
||||
};
|
||||
}
|
||||
@@ -248,6 +288,7 @@ export function buildStatisticsViewModel(
|
||||
const days = range === "today" ? 1 : range === "week" ? 7 : 30;
|
||||
const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, days, nowMs);
|
||||
const label = range === "today" ? "Heute" : range === "week" ? "Letzte sieben Tage" : "Letzte 30 Tage";
|
||||
const detailsAvailable = !hasProviderBytesOnlyDay(snapshot.stats.statistics, days, nowMs);
|
||||
return {
|
||||
range,
|
||||
coverage: "partial",
|
||||
@@ -255,12 +296,13 @@ export function buildStatisticsViewModel(
|
||||
? "Heutige Werte stammen aus der lokalen Statistikaufzeichnung."
|
||||
: recordedDaysMessage(label, aggregate.coveredDays),
|
||||
sessionState,
|
||||
metrics: aggregateMetrics(aggregate, label),
|
||||
metrics: aggregateMetrics(aggregate, label, detailsAvailable),
|
||||
providerScope: range,
|
||||
usageKind: "providers",
|
||||
providers: deriveUsageProviders(
|
||||
Object.fromEntries(Object.entries(aggregate.providers).map(([provider, bucket]) => [provider, bucket?.bytes ?? 0])),
|
||||
aggregate.providers
|
||||
aggregate.providers,
|
||||
detailsAvailable
|
||||
),
|
||||
errorResetAvailable: false
|
||||
};
|
||||
@@ -268,8 +310,9 @@ export function buildStatisticsViewModel(
|
||||
|
||||
if (range === "all") {
|
||||
const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, null, nowMs);
|
||||
const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes, aggregate.providers);
|
||||
const recordedMetrics = aggregateMetrics(aggregate, "Seit Beginn der Statistikaufzeichnung");
|
||||
const detailsAvailable = !hasProviderBytesOnlyDay(snapshot.stats.statistics, null, nowMs);
|
||||
const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes, aggregate.providers, detailsAvailable);
|
||||
const recordedMetrics = aggregateMetrics(aggregate, "Seit Beginn der Statistikaufzeichnung", detailsAvailable);
|
||||
return {
|
||||
range,
|
||||
coverage: "partial",
|
||||
|
||||
Reference in New Issue
Block a user