feat(collector): rebuild link preview as package workspace

Inspect pasted links, text files and DLC containers before queue insertion and resolve supported hoster metadata without starting downloads. Group multipart files into expandable packages with size, status, availability, timestamps, filtering and stable selection. Add selected or complete transfer to Downloads while retaining entries on failed or partial handoff. Keep collector toolbar spacing consistent and show the full 1Fichier identity with its provider icon in Downloads.
This commit is contained in:
Sucukdeluxe
2026-08-22 02:22:42 +02:00
parent 5f391a35d6
commit 40428a69b4
27 changed files with 1265 additions and 1229 deletions
+6
View File
@@ -9,6 +9,12 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
- Resolve original 1Fichier filenames, exact sizes, and availability in batches before downloads start. - Resolve original 1Fichier filenames, exact sizes, and availability in batches before downloads start.
- Keep resolved filenames when a debrid provider returns only a generic `download.bin` name. - Keep resolved filenames when a debrid provider returns only a generic `download.bin` name.
- Group supported 1Fichier mirror domains under one hoster identity. - Group supported 1Fichier mirror domains under one hoster identity.
- Display the full `1Fichier` hoster name consistently in the link collector and Downloads.
### Link collector
- Rebuilt the link collector as a package-oriented preview with expandable file rows, resolved metadata, availability filters, stable selection, and selected or complete transfer to Downloads.
- Route pasted links, clipboard detections, text files, drag-and-drop, and DLC containers through inspection before they enter the download queue.
## [2.0.54] - 2026-08-21 ## [2.0.54] - 2026-08-21
+11
View File
@@ -45,6 +45,8 @@ import { applyAccountCommand, resolveStoredAccountSecret } from "./account-comma
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer";
import { createRendererState } from "./renderer-state"; import { createRendererState } from "./renderer-state";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
import { inspectCollectorPackages, inspectCollectorText } from "./collector-inspection";
import type { CollectorInspectionRequest, CollectorInspectionResult } from "../shared/collector";
import { configureLogger, flushLoggerSync, getLogFilePath, logger } from "./logger"; import { configureLogger, flushLoggerSync, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web"; import { AllDebridWebFallback } from "./all-debrid-web";
import { BestDebridWebFallback } from "./bestdebrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web";
@@ -926,6 +928,15 @@ export class AppController {
return { ...result, invalidCount: 0 }; return { ...result, invalidCount: 0 };
} }
public inspectCollectorText(request: CollectorInspectionRequest): Promise<CollectorInspectionResult> {
return inspectCollectorText(request, this.settings);
}
public async inspectCollectorContainers(filePaths: string[], addedAt: number): Promise<CollectorInspectionResult> {
const packages = await importDlcContainers(filePaths);
return inspectCollectorPackages(packages, this.settings, addedAt, {}, true);
}
public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> {
const packages = await importDlcContainers(filePaths); const packages = await importDlcContainers(filePaths);
const merged: ParsedPackageInput[] = packages.map((pkg) => ({ const merged: ParsedPackageInput[] = packages.map((pkg) => ({
+219
View File
@@ -0,0 +1,219 @@
import crypto from "node:crypto";
import path from "node:path";
import { serializeCollectorPackages } from "../shared/collector";
import type { CollectorInspectionRequest, CollectorInspectionResult, CollectorLink, CollectorPackage } from "../shared/collector";
import type { AppSettings, ParsedPackageInput } from "../shared/types";
import { extractHosterFromUrl } from "../shared/hoster";
import { checkOneFichierLinks, checkRapidgatorOnline, DebridService, isOneFichierLink, type OneFichierCheckResult } from "./debrid";
import { parseCollectorInput } from "./link-parser";
import { filenameFromUrl, isHttpLink, looksLikeOpaqueFilename, sanitizeFilename } from "./utils";
interface CollectorInspectionDependencies {
checkOneFichier?: (links: string[]) => Promise<Map<string, OneFichierCheckResult>>;
checkRapidgator?: typeof checkRapidgatorOnline;
resolveFilenames?: (links: string[]) => Promise<Map<string, string>>;
createId?: (prefix: "package" | "link") => string;
}
interface SourceLink {
url: string;
fileName: string;
packageName: string;
explicitFileName: boolean;
}
function readableHosterName(hoster: string): string {
if (hoster === "1fichier") return "1Fichier";
if (hoster === "rapidgator") return "RapidGator";
if (hoster === "ddownload") return "DDownload";
return hoster ? hoster.charAt(0).toLocaleUpperCase("de") + hoster.slice(1) : "Gesammelte Links";
}
export function inferCollectorPackageName(fileName: string, hoster: string): string {
const safeName = sanitizeFilename(fileName || "");
if (!safeName || looksLikeOpaqueFilename(safeName)) {
return readableHosterName(hoster);
}
const patterns = [
/^(.*)\.part\d+\.rar$/i,
/^(.*)\.pa?r?t?\.?\d+.*?\.rar$/i,
/^(.*)\.r\d{2,3}$/i,
/^(.*)\.(?:7z|zip)\.\d{3}$/i,
/^(.*)\.part\d+$/i
];
for (const pattern of patterns) {
const match = safeName.match(pattern);
if (match?.[1]?.trim()) {
return sanitizeFilename(match[1]);
}
}
const stem = path.parse(safeName).name.trim();
return sanitizeFilename(stem || readableHosterName(hoster));
}
async function runWithConcurrency<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
let index = 0;
const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
while (index < items.length) {
const current = items[index];
index += 1;
await worker(current);
}
});
await Promise.all(runners);
}
function countInputLines(rawText: string): { invalidCount: number; duplicateCount: number } {
const seen = new Set<string>();
let invalidCount = 0;
let duplicateCount = 0;
for (const rawLine of String(rawText || "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || /^#\s*(?:package|file)\s*:/i.test(line)) {
continue;
}
if (!isHttpLink(line)) {
invalidCount += 1;
continue;
}
if (seen.has(line)) {
duplicateCount += 1;
} else {
seen.add(line);
}
}
return { invalidCount, duplicateCount };
}
function flattenPackages(packages: ParsedPackageInput[]): SourceLink[] {
const seen = new Set<string>();
const links: SourceLink[] = [];
for (const pkg of packages) {
for (let index = 0; index < pkg.links.length; index += 1) {
const url = String(pkg.links[index] || "").trim();
if (!url || seen.has(url)) {
continue;
}
seen.add(url);
const explicitName = String(pkg.fileNames?.[index] || "").trim();
links.push({
url,
fileName: explicitName ? sanitizeFilename(explicitName) : filenameFromUrl(url),
packageName: sanitizeFilename(pkg.name),
explicitFileName: Boolean(explicitName)
});
}
}
return links;
}
function groupLinks(
links: CollectorLink[],
sourceLinks: SourceLink[],
preservePackageNames: boolean,
addedAt: number,
createId: (prefix: "package" | "link") => string
): CollectorPackage[] {
const sourceByUrl = new Map(sourceLinks.map((link) => [link.url, link]));
const packageByKey = new Map<string, CollectorPackage>();
const packages: CollectorPackage[] = [];
for (const link of links) {
const source = sourceByUrl.get(link.url);
const name = preservePackageNames && source?.packageName
? source.packageName
: inferCollectorPackageName(link.fileName, link.hoster);
const key = name.toLocaleLowerCase("de");
let pkg = packageByKey.get(key);
if (!pkg) {
pkg = { id: createId("package"), name, links: [], addedAt };
packageByKey.set(key, pkg);
packages.push(pkg);
}
pkg.links.push(link);
}
return packages;
}
export async function inspectCollectorPackages(
packages: ParsedPackageInput[],
settings: AppSettings,
addedAt: number,
dependencies: CollectorInspectionDependencies = {},
preservePackageNames = true
): Promise<CollectorInspectionResult> {
const createId = dependencies.createId ?? ((prefix) => `${prefix}-${crypto.randomUUID()}`);
const sourceLinks = flattenPackages(packages);
const linksByUrl = new Map<string, CollectorLink>();
for (const source of sourceLinks) {
const hoster = extractHosterFromUrl(source.url);
linksByUrl.set(source.url, {
id: createId("link"),
url: source.url,
fileName: source.fileName,
fileSizeBytes: null,
hoster,
availability: "unknown",
status: source.explicitFileName ? "ready" : "unknown",
addedAt
});
}
const oneFichierLinks = sourceLinks.map((link) => link.url).filter(isOneFichierLink);
const rapidgatorLinks = sourceLinks.map((link) => link.url).filter((url) => extractHosterFromUrl(url) === "rapidgator");
const genericLinks = sourceLinks
.filter((source) => !source.explicitFileName && looksLikeOpaqueFilename(source.fileName))
.map((source) => source.url)
.filter((url) => !oneFichierLinks.includes(url) && !rapidgatorLinks.includes(url));
const checkOneFichier = dependencies.checkOneFichier ?? checkOneFichierLinks;
const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline;
const resolveFilenames = dependencies.resolveFilenames ?? ((urls) => new DebridService(settings).resolveFilenames(urls));
const oneFichierPromise = checkOneFichier(oneFichierLinks).catch(() => new Map<string, OneFichierCheckResult>());
const rapidgatorPromise = runWithConcurrency(rapidgatorLinks, 8, async (url) => {
const result = await checkRapidgator(url).catch(() => null);
const link = linksByUrl.get(url);
if (!link || !result) return;
link.availability = result.online ? "online" : "offline";
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
});
const genericPromise = resolveFilenames(genericLinks).catch(() => new Map<string, string>());
const [oneFichierResults, genericResults] = await Promise.all([oneFichierPromise, genericPromise, rapidgatorPromise]).then(([one, generic]) => [one, generic] as const);
for (const [url, result] of oneFichierResults) {
const link = linksByUrl.get(url);
if (!link) continue;
link.availability = result.online ? "online" : "offline";
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
}
for (const [url, fileName] of genericResults) {
const link = linksByUrl.get(url);
if (!link || !fileName) continue;
link.fileName = sanitizeFilename(fileName);
link.status = "ready";
}
return {
packages: groupLinks(Array.from(linksByUrl.values()), sourceLinks, preservePackageNames, addedAt, createId),
invalidCount: 0,
duplicateCount: 0
};
}
export async function inspectCollectorText(
request: CollectorInspectionRequest,
settings: AppSettings,
dependencies: CollectorInspectionDependencies = {}
): Promise<CollectorInspectionResult> {
const counts = countInputLines(request.rawText);
const parsed = parseCollectorInput(request.rawText, "");
const preservePackageNames = /^#\s*package\s*:/im.test(request.rawText);
const result = await inspectCollectorPackages(parsed, settings, request.addedAt, dependencies, preservePackageNames);
return { ...result, ...counts };
}
export { serializeCollectorPackages };
+12
View File
@@ -23,6 +23,7 @@ import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EX
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { validateRealDebridLoginRequest } from "../shared/preload-api"; import { validateRealDebridLoginRequest } from "../shared/preload-api";
import { migrateProductUserDataDirectory } from "./storage"; import { migrateProductUserDataDirectory } from "./storage";
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
function validateString(value: unknown, name: string): string { function validateString(value: unknown, name: string): string {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -476,6 +477,17 @@ function registerIpcHandlers(): void {
const safePaths = validPaths.filter((p) => path.isAbsolute(p)); const safePaths = validPaths.filter((p) => path.isAbsolute(p));
return controller.addContainers(safePaths); return controller.addContainers(safePaths);
}); });
handleTrusted(IPC_CHANNELS.INSPECT_COLLECTOR_TEXT, (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
return controller.inspectCollectorText(validateCollectorInspectionRequest(rawRequest));
});
handleTrusted(IPC_CHANNELS.INSPECT_COLLECTOR_CONTAINERS, (_event: IpcMainInvokeEvent, rawPaths: unknown, rawAddedAt: unknown) => {
const request = validateCollectorContainerInspectionRequest(rawPaths, rawAddedAt);
const safePaths = request.filePaths.filter((filePath) => path.isAbsolute(filePath));
if (safePaths.length !== request.filePaths.length) {
throw new Error("Container-Payload ist ungültig");
}
return controller.inspectCollectorContainers(safePaths, request.addedAt);
});
handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
+5
View File
@@ -31,6 +31,7 @@ import {
UpdateInstallProgress UpdateInstallProgress
} from "../shared/types"; } from "../shared/types";
import type { RealDebridLoginRequest } from "../shared/preload-api"; import type { RealDebridLoginRequest } from "../shared/preload-api";
import type { CollectorInspectionRequest, CollectorInspectionResult } from "../shared/collector";
import { IPC_CHANNELS } from "../shared/ipc"; import { IPC_CHANNELS } from "../shared/ipc";
import { ElectronApi } from "../shared/preload-api"; import { ElectronApi } from "../shared/preload-api";
@@ -51,6 +52,10 @@ const api: ElectronApi = {
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
inspectCollectorText: (request: CollectorInspectionRequest): Promise<CollectorInspectionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.INSPECT_COLLECTOR_TEXT, request),
inspectCollectorContainers: (filePaths: string[], addedAt: number): Promise<CollectorInspectionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.INSPECT_COLLECTOR_CONTAINERS, filePaths, addedAt),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> => resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
+123 -289
View File
@@ -2,6 +2,8 @@ import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import type { RealDebridLoginRequest } from "../shared/preload-api"; import type { RealDebridLoginRequest } from "../shared/preload-api";
import { serializeCollectorPackages } from "../shared/collector";
import type { CollectorInspectionResult, CollectorPackage } from "../shared/collector";
import type { import type {
AccountCreateCommand, AccountCreateCommand,
AllDebridHostInfo, AllDebridHostInfo,
@@ -61,8 +63,12 @@ import { Dialog } from "./ui/Dialog";
import { Icon } from "./ui/Icon"; import { Icon } from "./ui/Icon";
import { Toast } from "./ui/Toast"; import { Toast } from "./ui/Toast";
import { import {
buildCollectorViewModel, buildCollectorTransferPackages,
type CollectorSourceTab buildCollectorWorkspaceViewModel,
mergeCollectorPackages,
removeCollectorLinks,
selectCollectorPackageLinks,
type CollectorWorkspaceFilter
} from "./views/collector/collector-model"; } from "./views/collector/collector-model";
import { import {
CollectorContent, CollectorContent,
@@ -138,64 +144,9 @@ import {
type Tab = MainView; type Tab = MainView;
type CollectorTab = CollectorSourceTab;
interface CollectorInputState { interface CollectorInputState {
tabId: string;
tabName: string;
baseText: string;
draft: string; draft: string;
} }
export function mergeCollectorDraftText(baseText: string, currentText: string, draft: string): string {
if (currentText === baseText) {
return draft;
}
const appended = currentText.startsWith(baseText) ? currentText.slice(baseText.length) : currentText;
if (!appended) {
return draft;
}
const normalizedAppend = appended.replace(/^\r?\n/, "");
if (!draft) {
return normalizedAppend;
}
if (!normalizedAppend) {
return draft;
}
return `${draft}${draft.endsWith("\n") ? "" : "\n"}${normalizedAppend}`;
}
export function planCollectorTabRemoval(
tabs: CollectorTab[],
activeTabId: string,
removedTabId: string
): { tabs: CollectorTab[]; activeTabId: string } {
if (tabs.length <= 1) {
return { tabs, activeTabId };
}
const removedIndex = tabs.findIndex((tab) => tab.id === removedTabId);
if (removedIndex < 0) {
return {
tabs,
activeTabId: tabs.some((tab) => tab.id === activeTabId) ? activeTabId : (tabs[0]?.id ?? "")
};
}
const nextTabs = tabs.filter((tab) => tab.id !== removedTabId);
const nextActiveTabId = activeTabId === removedTabId
? (nextTabs[Math.max(0, removedIndex - 1)]?.id ?? nextTabs[0]?.id ?? "")
: (nextTabs.some((tab) => tab.id === activeTabId) ? activeTabId : (nextTabs[0]?.id ?? ""));
return { tabs: nextTabs, activeTabId: nextActiveTabId };
}
export function planCollectorTextReplacement(
tabs: CollectorTab[],
tabId: string,
text: string
): { tabs: CollectorTab[]; selectedIds: string[] } {
return {
tabs: tabs.map((tab) => tab.id === tabId ? { ...tab, text } : tab),
selectedIds: []
};
}
interface StartConflictPromptState { interface StartConflictPromptState {
entry: StartConflictEntry; entry: StartConflictEntry;
@@ -1375,8 +1326,6 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
); );
}); });
let nextCollectorId = 1;
function createScheduleId(): string { function createScheduleId(): string {
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
} }
@@ -1556,16 +1505,18 @@ export function App(): ReactElement {
const [providerDropTarget, setProviderDropTarget] = useState<DebridProvider | null>(null); const [providerDropTarget, setProviderDropTarget] = useState<DebridProvider | null>(null);
const [editingPackageId, setEditingPackageId] = useState<string | null>(null); const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([ const [collectorPackages, setCollectorPackages] = useState<CollectorPackage[]>([]);
{ id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } const [collectorFilter, setCollectorFilter] = useState<CollectorWorkspaceFilter>("all");
]);
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
const [collectorQuery, setCollectorQuery] = useState(""); const [collectorQuery, setCollectorQuery] = useState("");
const [selectedCollectorRowIds, setSelectedCollectorRowIds] = useState<Set<string>>(() => new Set()); const [selectedCollectorLinkIds, setSelectedCollectorLinkIds] = useState<Set<string>>(() => new Set());
const [collapsedCollectorPackageIds, setCollapsedCollectorPackageIds] = useState<Set<string>>(() => new Set());
const [collectorBusy, setCollectorBusy] = useState(false);
const [collectorError, setCollectorError] = useState(""); const [collectorError, setCollectorError] = useState("");
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null); const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs); const collectorPackagesRef = useRef<CollectorPackage[]>(collectorPackages);
const activeCollectorTabRef = useRef(activeCollectorTab); const inspectCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
const collectorInspectionQueueRef = useRef<Promise<void>>(Promise.resolve());
const collectorInspectionPendingRef = useRef(0);
const activeTabRef = useRef<Tab>(tab); const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]); const packageOrderRef = useRef<string[]>([]);
const serverPackageOrderRef = useRef<string[]>([]); const serverPackageOrderRef = useRef<string[]>([]);
@@ -1680,14 +1631,16 @@ export function App(): ReactElement {
columnOrderPersistenceRef.current?.enqueue(order); columnOrderPersistenceRef.current?.enqueue(order);
}, []); }, []);
const collectorViewModel = useMemo(() => buildCollectorViewModel( const collectorViewModel = useMemo(() => buildCollectorWorkspaceViewModel(
collectorTabs, collectorPackages,
activeCollectorTab, collectorFilter,
collectorQuery, collectorQuery,
actionBusy, collectorBusy || actionBusy,
[...selectedCollectorRowIds], [...selectedCollectorLinkIds],
collectorError [...collapsedCollectorPackageIds],
), [actionBusy, activeCollectorTab, collectorError, collectorQuery, collectorTabs, selectedCollectorRowIds]); collectorError,
snapshot.settings.animatePackageDisclosure
), [actionBusy, collapsedCollectorPackageIds, collectorBusy, collectorError, collectorFilter, collectorPackages, collectorQuery, selectedCollectorLinkIds, snapshot.settings.animatePackageDisclosure]);
const historyViewModel = useMemo(() => buildHistoryViewModel( const historyViewModel = useMemo(() => buildHistoryViewModel(
historyEntries, historyEntries,
@@ -1706,13 +1659,7 @@ export function App(): ReactElement {
[runtimeNow, snapshot, statisticsRange] [runtimeNow, snapshot, statisticsRange]
); );
useEffect(() => { collectorPackagesRef.current = collectorPackages;
activeCollectorTabRef.current = activeCollectorTab;
}, [activeCollectorTab]);
useEffect(() => {
collectorTabsRef.current = collectorTabs;
}, [collectorTabs]);
useEffect(() => { useEffect(() => {
activeTabRef.current = tab; activeTabRef.current = tab;
@@ -2048,12 +1995,7 @@ export function App(): ReactElement {
}); });
unsubClipboard = window.rd.onClipboardDetected((links) => { unsubClipboard = window.rd.onClipboardDetected((links) => {
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
setCollectorTabs((prev) => { void inspectCollectorTextRef.current(links.join("\n"));
const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0];
if (!active) { return prev; }
const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n");
return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
});
}); });
unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => { unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => {
if (!mountedRef.current) { if (!mountedRef.current) {
@@ -3463,48 +3405,73 @@ export function App(): ReactElement {
} }
}; };
const onAddLinks = async (): Promise<void> => { const enqueueCollectorInspection = (inspect: () => Promise<CollectorInspectionResult>): Promise<void> => {
collectorInspectionPendingRef.current += 1;
setCollectorBusy(true);
setCollectorError(""); setCollectorError("");
const run = collectorInspectionQueueRef.current.catch(() => undefined).then(async () => {
const result = await inspect();
const incomingCount = result.packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
setCollectorPackages((current) => mergeCollectorPackages(current, result.packages).packages);
setCollectorFilter("all");
setTab("collector");
const ignored = result.invalidCount + result.duplicateCount;
showToast(`${incomingCount} Link(s) analysiert${ignored > 0 ? ` · ${ignored} ignoriert` : ""}`, 2600);
}).catch((error) => {
setCollectorError(`Analyse fehlgeschlagen: ${String(error)}`);
showToast(`Analyse fehlgeschlagen: ${String(error)}`, 3000);
}).finally(() => {
collectorInspectionPendingRef.current = Math.max(0, collectorInspectionPendingRef.current - 1);
if (collectorInspectionPendingRef.current === 0) setCollectorBusy(false);
});
collectorInspectionQueueRef.current = run;
return run;
};
const inspectCollectorRawText = (rawText: string): Promise<void> => {
if (!rawText.trim()) {
showToast("Keine Links gefunden", 2200);
return Promise.resolve();
}
return enqueueCollectorInspection(() => window.rd.inspectCollectorText({ rawText, addedAt: Date.now() }));
};
inspectCollectorTextRef.current = inspectCollectorRawText;
const transferCollectorPackages = async (packages: CollectorPackage[]): Promise<void> => {
const transferable = packages.flatMap((pkg) => {
const links = pkg.links.filter((link) => link.availability !== "offline");
return links.length > 0 ? [{ ...pkg, links }] : [];
});
const transferredIds = new Set(transferable.flatMap((pkg) => pkg.links.map((link) => link.id)));
if (transferredIds.size === 0) {
showToast("Keine übertragbaren Links ausgewählt", 2400);
return;
}
await performQuickAction(async () => { await performQuickAction(async () => {
const activeId = activeCollectorTabRef.current;
const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0];
const rawText = active?.text ?? "";
const persisted = await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName }); const result = await window.rd.addLinks({ rawText: serializeCollectorPackages(transferable) });
if (result.addedLinks > 0) { if (result.addedLinks !== transferredIds.size) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); throw new Error(`Nur ${result.addedLinks} von ${transferredIds.size} Links wurden übernommen`);
setCollectorTabs((prev) => planCollectorTextReplacement(prev, activeId, "").tabs);
setSelectedCollectorRowIds(new Set());
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
showToast("Keine gültigen Links gefunden");
} }
setCollectorPackages((current) => removeCollectorLinks(current, transferredIds));
setSelectedCollectorLinkIds((current) => {
const next = new Set(current);
for (const id of transferredIds) next.delete(id);
return next;
});
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) an Downloads übergeben`);
if (snapshotRef.current.settings.collapseNewPackages) await collapseNewPackages(existingIds);
}, (error) => { }, (error) => {
setCollectorError(`Fehler beim Hinzufügen: ${String(error)}`); setCollectorError(`Übergabe fehlgeschlagen: ${String(error)}`);
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); showToast(`Übergabe fehlgeschlagen: ${String(error)}`, 3000);
}); });
}; };
const onImportDlc = async (): Promise<void> => { const onImportDlc = async (): Promise<void> => {
setCollectorError(""); setCollectorError("");
await performQuickAction(async () => { const files = await window.rd.pickContainers();
const files = await window.rd.pickContainers(); if (files.length === 0) return;
if (files.length === 0) { return; } await enqueueCollectorInspection(() => window.rd.inspectCollectorContainers(files, Date.now()));
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const result = await window.rd.addContainers(files);
if (result.addedLinks > 0) {
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
}
}, (error) => {
setCollectorError(`Fehler beim DLC-Import: ${String(error)}`);
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
});
}; };
const onExportPackageSelection = async (packageIds: string[]): Promise<void> => { const onExportPackageSelection = async (packageIds: string[]): Promise<void> => {
@@ -3546,52 +3513,12 @@ export function App(): ReactElement {
const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name)); const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name));
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
if (dlc.length > 0) { if (dlc.length > 0) {
setCollectorError(""); await enqueueCollectorInspection(() => window.rd.inspectCollectorContainers(dlc, Date.now()));
await performQuickAction(async () => {
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const result = await window.rd.addContainers(dlc);
if (result.addedLinks > 0) {
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
}
}, (error) => {
setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`);
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
});
} else if (importFiles.length > 0) { } else if (importFiles.length > 0) {
setCollectorError(""); const importedText = (await Promise.all(importFiles.map((file) => file.text()))).join("\n");
await performQuickAction(async () => { await inspectCollectorRawText(importedText);
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
let addedPackages = 0;
let addedLinks = 0;
for (const file of importFiles) {
const text = await file.text();
const result = await window.rd.importQueue(text);
addedPackages += result.addedPackages;
addedLinks += result.addedLinks;
}
if (addedLinks > 0) {
showToast(`Importiert: ${addedPackages} Paket(e), ${addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
setCollectorError("Keine gültigen Links in den Import-Dateien gefunden");
showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000);
}
}, (error) => {
setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`);
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
});
} else if (droppedText.trim()) { } else if (droppedText.trim()) {
const activeCollectorId = activeCollectorTabRef.current; await inspectCollectorRawText(droppedText);
setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId
? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
setTab("collector");
showToast("Links per Drag-and-Drop eingefügt");
} }
}; };
@@ -3639,22 +3566,7 @@ export function App(): ReactElement {
return; return;
} }
releasePickerBusy(); releasePickerBusy();
await performQuickAction(async () => { await inspectCollectorRawText(await file.text());
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const text = await file.text();
const result = await window.rd.importQueue(text);
if (result.addedLinks > 0) {
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
setCollectorError("Keine gültigen Links in der Datei gefunden");
showToast("Keine gültigen Links in der Datei gefunden", 3000);
}
}, (error) => {
setCollectorError(`Import fehlgeschlagen: ${String(error)}`);
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
});
}; };
clearImportQueueFocusListener(); clearImportQueueFocusListener();
@@ -3759,112 +3671,35 @@ export function App(): ReactElement {
}); });
}, [showToast]); }, [showToast]);
const addCollectorTab = (): void => {
const id = `tab-${nextCollectorId++}`;
setCollectorTabs((prev) => {
const name = `Tab ${prev.length + 1}`;
return [...prev, { id, name, text: "" }];
});
setActiveCollectorTab(id);
setSelectedCollectorRowIds(new Set());
setCollectorError("");
};
const removeCollectorTab = (id: string): void => {
const tab = collectorTabsRef.current.find((entry) => entry.id === id);
if (!tab || collectorTabsRef.current.length <= 1) {
return;
}
const linkCount = tab.text.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
void askConfirmPrompt({
title: "Sammlung entfernen",
message: linkCount > 0
? `Soll die Sammlung ${tab.name} mit ${linkCount} Link(s) wirklich entfernt werden?`
: `Soll die leere Sammlung ${tab.name} wirklich entfernt werden?`,
confirmLabel: "Sammlung entfernen",
danger: true
}).then((confirmed) => {
if (!confirmed) {
return;
}
const removal = planCollectorTabRemoval(
collectorTabsRef.current,
activeCollectorTabRef.current,
id
);
if (removal.tabs === collectorTabsRef.current) {
return;
}
collectorTabsRef.current = removal.tabs;
activeCollectorTabRef.current = removal.activeTabId;
setCollectorTabs(removal.tabs);
setActiveCollectorTab(removal.activeTabId);
setSelectedCollectorRowIds(new Set());
setCollectorError("");
});
};
const openCollectorInput = (): void => { const openCollectorInput = (): void => {
const activeId = activeCollectorTabRef.current;
const active = collectorTabsRef.current.find((entry) => entry.id === activeId) ?? collectorTabsRef.current[0];
if (!active) {
return;
}
setCollectorError(""); setCollectorError("");
setCollectorInput({ setCollectorInput({ draft: "" });
tabId: active.id,
tabName: active.name,
baseText: active.text,
draft: active.text
});
}; };
const commitCollectorInput = (): void => { const commitCollectorInput = (): void => {
if (!collectorInput) { if (!collectorInput) return;
return; const rawText = collectorInput.draft;
}
const input = collectorInput;
setCollectorTabs((prev) => {
const currentText = prev.find((entry) => entry.id === input.tabId)?.text ?? input.baseText;
const text = mergeCollectorDraftText(input.baseText, currentText, input.draft);
return planCollectorTextReplacement(prev, input.tabId, text).tabs;
});
setSelectedCollectorRowIds(new Set());
setCollectorInput(null); setCollectorInput(null);
setCollectorError(""); void inspectCollectorRawText(rawText);
}; };
const toggleCollectorRowSelection = (rowId: string): void => { const toggleCollectorLinkSelection = (linkId: string, selected: boolean): void => {
setSelectedCollectorRowIds((prev) => { setSelectedCollectorLinkIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(rowId)) { if (selected) next.add(linkId);
next.delete(rowId); else next.delete(linkId);
} else {
next.add(rowId);
}
return next; return next;
}); });
}; };
const toggleCollectorPackageSelection = (packageId: string, selected: boolean): void => {
const pkg = collectorPackagesRef.current.find((entry) => entry.id === packageId);
if (!pkg) return;
setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, pkg, selected));
};
const removeSelectedCollectorRows = (): void => { const removeSelectedCollectorRows = (): void => {
if (selectedCollectorRowIds.size === 0) { if (selectedCollectorLinkIds.size === 0) return;
return;
}
const activeId = activeCollectorTabRef.current;
const indexes = new Set<number>();
for (const rowId of selectedCollectorRowIds) {
const separator = rowId.lastIndexOf(":");
if (separator <= 0 || rowId.slice(0, separator) !== activeId) {
continue;
}
const index = Number(rowId.slice(separator + 1));
if (Number.isInteger(index) && index >= 0) {
indexes.add(index);
}
}
if (indexes.size === 0) {
return;
}
void askConfirmPrompt({ void askConfirmPrompt({
title: "Ausgewählte Links löschen", title: "Ausgewählte Links löschen",
message: "Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", message: "Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.",
@@ -3874,10 +3709,8 @@ export function App(): ReactElement {
if (!confirmed) { if (!confirmed) {
return; return;
} }
setCollectorTabs((prev) => prev.map((entry) => entry.id === activeId setCollectorPackages((current) => removeCollectorLinks(current, selectedCollectorLinkIds));
? { ...entry, text: entry.text.split(/\r?\n/).filter((_line, index) => !indexes.has(index)).join("\n") } setSelectedCollectorLinkIds(new Set());
: entry));
setSelectedCollectorRowIds(new Set());
setCollectorError(""); setCollectorError("");
}); });
}; };
@@ -4346,6 +4179,7 @@ export function App(): ReactElement {
if (selectionScope) { if (selectionScope) {
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return; if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
if (selectionScope === "downloads") setSelectedIds(new Set()); if (selectionScope === "downloads") setSelectedIds(new Set());
else if (selectionScope === "collector") setSelectedCollectorLinkIds(new Set());
else if (selectionScope === "history") setSelectedHistoryIds(new Set()); else if (selectionScope === "history") setSelectedHistoryIds(new Set());
else if (selectedAccountRowKeys.size > 0) { else if (selectedAccountRowKeys.size > 0) {
setSelectedAccountRowKeys(new Set()); setSelectedAccountRowKeys(new Set());
@@ -4368,7 +4202,7 @@ export function App(): ReactElement {
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("mousedown", onDown); window.addEventListener("mousedown", onDown);
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); }; return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); };
}, [requestDeleteSelection, selectedAccountRowKeys, selectedIds, settingsSubTab]); }, [requestDeleteSelection, selectedAccountRowKeys, selectedCollectorLinkIds, selectedIds, settingsSubTab]);
const onExportBackup = async (): Promise<void> => { const onExportBackup = async (): Promise<void> => {
closeMenus(); closeMenus();
@@ -5067,21 +4901,21 @@ export function App(): ReactElement {
} }
}; };
const collectorActions: CollectorViewActions = { const collectorActions: CollectorViewActions = {
onTabSelect: (tabId) => { onFilterChange: setCollectorFilter,
activeCollectorTabRef.current = tabId;
setActiveCollectorTab(tabId);
setSelectedCollectorRowIds(new Set());
setCollectorError("");
},
onTabAdd: addCollectorTab,
onTabRemove: removeCollectorTab,
onOpenInput: openCollectorInput, onOpenInput: openCollectorInput,
onImportDlc: () => { void onImportDlc(); }, onImportDlc: () => { void onImportDlc(); },
onImportFile: () => { void onImportQueue(); }, onImportFile: () => { void onImportQueue(); },
onExportQueue: () => { void onExportQueue(); }, onSubmitSelected: () => { void transferCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, selectedCollectorLinkIds)); },
onSubmit: () => { void onAddLinks(); }, onSubmitAll: () => { void transferCollectorPackages(collectorPackagesRef.current); },
onQueryChange: setCollectorQuery, onQueryChange: setCollectorQuery,
onSelectionChange: toggleCollectorRowSelection, onLinkSelectionChange: toggleCollectorLinkSelection,
onPackageSelectionChange: toggleCollectorPackageSelection,
onPackageCollapseChange: (packageId) => setCollapsedCollectorPackageIds((current) => {
const next = new Set(current);
if (next.has(packageId)) next.delete(packageId);
else next.add(packageId);
return next;
}),
onRemoveSelected: removeSelectedCollectorRows onRemoveSelected: removeSelectedCollectorRows
}; };
@@ -6044,8 +5878,9 @@ export function App(): ReactElement {
<DownloadsSidebarStatus model={downloadsViewModel} /> <DownloadsSidebarStatus model={downloadsViewModel} />
) : tab === "collector" ? ( ) : tab === "collector" ? (
<> <>
<span>Sammlungen: {collectorViewModel.tabs.length}</span> <span>Pakete: {collectorPackages.length}</span>
<span>Links: {collectorViewModel.tabs.reduce((sum, entry) => sum + entry.linkCount, 0)}</span> <span>Links: {collectorViewModel.totalCount}</span>
<span>Ausgewählt: {collectorViewModel.selectedCount}</span>
<span>Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"}</span> <span>Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"}</span>
</> </>
) : tab === "history" ? ( ) : tab === "history" ? (
@@ -6683,7 +6518,6 @@ export function App(): ReactElement {
onClose={() => setCollectorInput(null)} onClose={() => setCollectorInput(null)}
onCommit={commitCollectorInput} onCommit={commitCollectorInput}
open open
tabName={collectorInput.tabName}
value={collectorInput.draft} value={collectorInput.draft}
/> />
) : null} ) : null}
+1 -1
View File
@@ -56,7 +56,7 @@ export function formatHosterLabel(hoster: string): { compact: string; title: str
const normalized = hoster.trim().toLowerCase(); const normalized = hoster.trim().toLowerCase();
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator }; if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload }; if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
if (normalized === "1fichier") return { compact: "1F", title: "1Fichier" }; if (normalized === "1fichier") return { compact: "1Fichier", title: "1Fichier", iconSrc: hosterIconSources.onefichier };
return { compact: hoster, title: hoster }; return { compact: hoster, title: hoster };
} }
+1
View File
@@ -1,4 +1,5 @@
export const hosterIconSources: Readonly<Record<string, string>> = { export const hosterIconSources: Readonly<Record<string, string>> = {
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////", rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
onefichier: "./provider-icons/onefichier.png",
ddownload: "./provider-icons/ddownload.ico" ddownload: "./provider-icons/ddownload.ico"
}; };
+20
View File
@@ -70,6 +70,10 @@ const pairs = [
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"], ["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"],
["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"], ["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"],
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"], ["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
["Linksammler-Filter", "Link collector filters"], ["Alle Links", "All links"], ["Downloads übergeben", "Send to downloads"], ["Gesammelte Downloadpakete", "Collected download packages"], ["Hinzugefügt", "Added"],
["Name, URL oder Hoster", "Name, URL or hoster"], ["Links werden analysiert", "Analyzing links"], ["Dateinamen, Größen und Verfügbarkeit werden geprüft.", "Checking filenames, sizes, and availability."],
["Bereits gesammelte Pakete bleiben unverändert.", "Already collected packages remain unchanged."], ["Füge Links hinzu, um Pakete vor dem Download zu prüfen.", "Add links to inspect packages before downloading."],
["Links werden geprüft und automatisch zu Downloadpaketen gruppiert.", "Links are inspected and automatically grouped into download packages."], ["Analysieren", "Analyze"], ["Eine URL pro Zeile", "One URL per line"],
["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Ausgewählte Links löschen", "Delete selected links"], ["Links löschen", "Delete links"], ["Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", "The selected links will be removed from the collection. This action cannot be undone."], ["Gesammelte Links", "Collected links"], ["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Ausgewählte Links löschen", "Delete selected links"], ["Links löschen", "Delete links"], ["Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", "The selected links will be removed from the collection. This action cannot be undone."], ["Gesammelte Links", "Collected links"],
["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."], ["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."],
["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"], ["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"],
@@ -319,6 +323,14 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (capture) return `Capture links locally for ${capture[1]}.`; if (capture) return `Capture links locally for ${capture[1]}.`;
const collectorSelection = value.match(/^(.+) aus (.+), Zeile (\d+) auswählen$/); const collectorSelection = value.match(/^(.+) aus (.+), Zeile (\d+) auswählen$/);
if (collectorSelection) return `Select ${collectorSelection[1]} from ${collectorSelection[2]}, line ${collectorSelection[3]}`; if (collectorSelection) return `Select ${collectorSelection[1]} from ${collectorSelection[2]}, line ${collectorSelection[3]}`;
const collectorTransfer = value.match(/^(Auswahl|Alle) übergeben \((\d+)\)$/);
if (collectorTransfer) return `${collectorTransfer[1] === "Auswahl" ? "Send selection" : "Send all"} (${collectorTransfer[2]})`;
const collectorPackageSelect = value.match(/^Paket (.+) auswählen$/);
if (collectorPackageSelect) return `Select package ${collectorPackageSelect[1]}`;
const collectorFiles = value.match(/^(\d+) Dateien$/);
if (collectorFiles) return `${collectorFiles[1]} files`;
const collectorChecked = value.match(/^(\d+)\/(\d+) geprüft$/);
if (collectorChecked) return `${collectorChecked[1]}/${collectorChecked[2]} checked`;
const copy = value.match(/^([\s\S]+?)\s+Klicken zum Kopieren$/); const copy = value.match(/^([\s\S]+?)\s+Klicken zum Kopieren$/);
if (copy) return `Click to copy ${copy[1]}`; if (copy) return `Click to copy ${copy[1]}`;
const cancelledPart = value.match(/^· (\d+) abgebrochen$/); const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
@@ -530,6 +542,14 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (pageStatus) return `Seite ${pageStatus[1]} von ${pageStatus[2]}`; if (pageStatus) return `Seite ${pageStatus[1]} von ${pageStatus[2]}`;
const collectorSelection = value.match(/^Select (.+) from (.+), line (\d+)$/); const collectorSelection = value.match(/^Select (.+) from (.+), line (\d+)$/);
if (collectorSelection) return `${collectorSelection[1]} aus ${collectorSelection[2]}, Zeile ${collectorSelection[3]} auswählen`; if (collectorSelection) return `${collectorSelection[1]} aus ${collectorSelection[2]}, Zeile ${collectorSelection[3]} auswählen`;
const collectorTransfer = value.match(/^Send (selection|all) \((\d+)\)$/);
if (collectorTransfer) return `${collectorTransfer[1] === "selection" ? "Auswahl" : "Alle"} übergeben (${collectorTransfer[2]})`;
const collectorPackageSelect = value.match(/^Select package (.+)$/);
if (collectorPackageSelect) return `Paket ${collectorPackageSelect[1]} auswählen`;
const collectorFiles = value.match(/^(\d+) files$/);
if (collectorFiles) return `${collectorFiles[1]} Dateien`;
const collectorChecked = value.match(/^(\d+)\/(\d+) checked$/);
if (collectorChecked) return `${collectorChecked[1]}/${collectorChecked[2]} geprüft`;
const moveColumnDirection = value.match(/^Move (.+) (left|right)$/); const moveColumnDirection = value.match(/^Move (.+) (left|right)$/);
if (moveColumnDirection) return `${enToDe.get(moveColumnDirection[1]) ?? moveColumnDirection[1]} nach ${moveColumnDirection[2] === "left" ? "links" : "rechts"} verschieben`; if (moveColumnDirection) return `${enToDe.get(moveColumnDirection[1]) ?? moveColumnDirection[1]} nach ${moveColumnDirection[2] === "left" ? "links" : "rechts"} verschieben`;
const moveColumn = value.match(/^Move (.+)$/); const moveColumn = value.match(/^Move (.+)$/);
+2 -2
View File
@@ -18,11 +18,11 @@ export function resolveEscapeSelectionScope(
settingsSection: string, settingsSection: string,
tagName: string, tagName: string,
inputType = "" inputType = ""
): "downloads" | "history" | "accounts" | null { ): "downloads" | "collector" | "history" | "accounts" | null {
if (!shouldClearDownloadSelectionOnEscape(tagName, inputType)) { if (!shouldClearDownloadSelectionOnEscape(tagName, inputType)) {
return null; return null;
} }
if (view === "downloads" || view === "history") { if (view === "downloads" || view === "history" || view === "collector") {
return view; return view;
} }
return view === "settings" && settingsSection === "accounts" ? "accounts" : null; return view === "settings" && settingsSection === "accounts" ? "accounts" : null;
+89 -150
View File
@@ -1,84 +1,79 @@
import type { ChangeEvent, ReactElement } from "react"; import type { ChangeEvent, ReactElement } from "react";
import { import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable";
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog"; import { Dialog } from "../../ui/Dialog";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar"; import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import { SlidingSelection } from "../../ui/SlidingSelection"; import { SlidingSelection } from "../../ui/SlidingSelection";
import type { CollectorViewModel } from "./collector-model"; import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format";
import type { CollectorWorkspaceFilter, CollectorWorkspacePackageRow, CollectorWorkspaceViewModel } from "./collector-model";
import "./collector.css"; import "./collector.css";
export interface CollectorViewActions { export interface CollectorViewActions {
onTabSelect: (tabId: string) => void; onFilterChange: (filter: CollectorWorkspaceFilter) => void;
onTabAdd: () => void;
onTabRemove: (tabId: string) => void;
onOpenInput: () => void; onOpenInput: () => void;
onImportDlc: () => void; onImportDlc: () => void;
onImportFile: () => void; onImportFile: () => void;
onExportQueue: () => void; onSubmitSelected: () => void;
onSubmit: () => void; onSubmitAll: () => void;
onQueryChange: (value: string) => void; onQueryChange: (value: string) => void;
onSelectionChange: (rowId: string) => void; onLinkSelectionChange: (linkId: string, selected: boolean) => void;
onPackageSelectionChange: (packageId: string, selected: boolean) => void;
onPackageCollapseChange: (packageId: string) => void;
onRemoveSelected: () => void; onRemoveSelected: () => void;
} }
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content"; export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
export interface CollectorViewProps { export interface CollectorViewProps {
model: CollectorViewModel; model: CollectorWorkspaceViewModel;
actions: CollectorViewActions; actions: CollectorViewActions;
region?: CollectorViewRegion; region?: CollectorViewRegion;
} }
export interface CollectorInputDialogProps { export interface CollectorInputDialogProps {
open: boolean; open: boolean;
tabName: string; tabName?: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onClose: () => void; onClose: () => void;
onCommit: () => void; onCommit: () => void;
} }
function packageStatus(row: CollectorWorkspacePackageRow): string {
const ready = row.allLinks.filter((link) => link.status === "ready").length;
if (row.offlineCount === row.totalCount) return "Offline";
if (ready === row.totalCount) return "Bereit";
if (ready > 0 || row.onlineCount > 0) return `${ready}/${row.totalCount} geprüft`;
return "Ungeprüft";
}
function packageAvailability(row: CollectorWorkspacePackageRow): string {
if (row.onlineCount === row.totalCount) return `${row.onlineCount}/${row.totalCount} online`;
if (row.offlineCount === row.totalCount) return "Offline";
if (row.onlineCount > 0) return `${row.onlineCount}/${row.totalCount} online`;
return "Ungeprüft";
}
function packageSize(row: CollectorWorkspacePackageRow): string {
if (row.totalBytes <= 0) return "Unbekannt";
return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`;
}
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement { export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
return ( return (
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar"> <div aria-label="Linksammler-Filter" className="collector-sidebar" data-visual-region="collector-sidebar">
<div className="collector-sidebar-heading"> <div className="collector-sidebar-heading"><strong>Status</strong><span>{model.totalCount}</span></div>
<strong>Sammlungen</strong> <SlidingSelection activeKey={model.filter} axis="vertical" className="collector-sidebar-list">
<span>{model.tabs.length}</span> {model.filters.map((filter) => (
</div> <button aria-current={filter.id === model.filter ? "page" : undefined} className={`collector-sidebar-filter${filter.id === model.filter ? " is-active" : ""}`} data-sliding-selection-active={filter.id === model.filter} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button">
<SlidingSelection activeKey={model.activeTabId} axis="vertical" className="collector-sidebar-list"> <span>{filter.label}</span><span>{filter.count}</span>
{model.tabs.map((tab) => ( </button>
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} data-sliding-selection-active={tab.id === model.activeTabId} data-sliding-selection-item="true" key={tab.id}>
<button
aria-current={tab.id === model.activeTabId ? "page" : undefined}
className="collector-sidebar-select"
onClick={() => actions.onTabSelect(tab.id)}
type="button"
>
<span>{tab.name}</span>
<span className="collector-sidebar-count">{tab.linkCount}</span>
</button>
{model.tabs.length > 1 ? (
<button
aria-label={`${tab.name} entfernen`}
className="collector-sidebar-remove"
onClick={() => actions.onTabRemove(tab.id)}
type="button"
>×</button>
) : null}
</div>
))} ))}
</SlidingSelection> </SlidingSelection>
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
</div> </div>
); );
} }
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement { export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
const activeTab = model.tabs.find((tab) => tab.id === model.activeTabId) ?? model.tabs[0];
return ( return (
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen"> <Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
<ToolbarGroup label="Links erfassen"> <ToolbarGroup label="Links erfassen">
@@ -86,125 +81,69 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button> <button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button> <button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
</ToolbarGroup> </ToolbarGroup>
<ToolbarGroup label="Sammlung verarbeiten"> <ToolbarGroup label="Downloads übergeben">
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button> <button className="collector-action" disabled={model.busy || model.selectedCount === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${model.selectedCount})`}</button>
<button className="collector-action" disabled={model.busy || !activeTab || activeTab.linkCount === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button> <button className="collector-action" disabled={model.busy || model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button> <button className="collector-action collector-action-danger" disabled={model.busy || model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
</ToolbarGroup> </ToolbarGroup>
<ToolbarSearch <ToolbarSearch label="Links durchsuchen" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} />
label="Links durchsuchen"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Links durchsuchen"
value={model.query}
/>
</Toolbar> </Toolbar>
); );
} }
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement { function CollectorPackageGroup({ row, model, actions }: { row: CollectorWorkspacePackageRow; model: CollectorWorkspaceViewModel; actions: CollectorViewActions }): ReactElement {
const selected = new Set(model.selectedIds); const selected = new Set(model.selectedIds);
const allSelected = row.selectedCount === row.totalCount;
const partiallySelected = row.selectedCount > 0 && !allSelected;
const animateItems = model.animationsEnabled && row.allLinks.length <= 64;
const renderItems = !row.collapsed || animateItems;
return ( return (
<section className="collector-content" aria-label="Gesammelte Links"> <div className={`collector-package-group${row.collapsed ? " is-collapsed" : ""}${model.animationsEnabled ? " is-motion-enabled" : ""}`} role="rowgroup">
<DataTable className="collector-table" label="Gesammelte Links"> <div className={`collector-package-row${row.selectedCount > 0 ? " is-selected" : ""}`} role="row">
<DataTableHeader className="collector-table-header"> <span className="collector-column-select" role="cell"><input aria-checked={partiallySelected ? "mixed" : allSelected} aria-label={`Paket ${row.name} auswählen`} checked={allSelected} onChange={(event) => actions.onPackageSelectionChange(row.id, event.target.checked)} ref={(node) => { if (node) node.indeterminate = partiallySelected; }} type="checkbox" /></span>
<div className="collector-table-header-row" role="row"> <span className="collector-name-cell" role="cell"><button aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${row.name} ausklappen` : `${row.name} einklappen`} className="collector-collapse-button" onClick={() => actions.onPackageCollapseChange(row.id)} type="button">{row.collapsed ? "+" : ""}</button><strong title={row.name}>{row.name}</strong><small>{row.totalCount} Dateien</small></span>
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" /> <span className="collector-size-cell" role="cell">{packageSize(row)}</span>
<span role="columnheader">Sammlung</span> <span className="collector-hoster-cell" role="cell">{row.hosters.map(formatHosterLabel).map((hoster) => <span key={hoster.title} title={hoster.title}>{hoster.compact}</span>)}</span>
<span role="columnheader">URL oder Rohzeile</span> <span className="collector-status-cell" role="cell">{packageStatus(row)}</span>
<span role="columnheader">Zeile</span> <span className={`collector-availability-cell is-${row.offlineCount === row.totalCount ? "offline" : row.onlineCount === row.totalCount ? "online" : "unknown"}`} role="cell">{packageAvailability(row)}</span>
<span role="columnheader">Status</span> <span className="collector-added-cell" role="cell">{formatDateTime(row.addedAt)}</span>
</div>
</DataTableHeader>
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
{model.busy ? (
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
) : model.error ? (
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
) : model.empty ? (
<DataTableEmpty
data-visual-region="collector-empty-state"
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
/>
) : (
model.rows.map((row) => (
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
<span className="collector-column-select" role="cell">
<input
aria-label={`${row.value} aus ${row.tabName}, Zeile ${row.lineNumber} auswählen`}
checked={selected.has(row.id)}
onChange={() => actions.onSelectionChange(row.id)}
type="checkbox"
/>
</span>
<span className="collector-row-source" role="cell">{row.tabName}</span>
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
<span className="collector-row-status" role="cell">Lokal</span>
</div>
))
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
if (region === "sidebar") {
return <CollectorSidebar actions={actions} model={model} />;
}
if (region === "toolbar") {
return <CollectorToolbar actions={actions} model={model} />;
}
if (region === "content") {
return <CollectorContent actions={actions} model={model} />;
}
return (
<div className="collector-view">
<CollectorSidebar actions={actions} model={model} />
<div className="collector-view-main">
<CollectorToolbar actions={actions} model={model} />
<CollectorContent actions={actions} model={model} />
</div> </div>
{renderItems ? <div className={`collector-package-items-frame${row.collapsed ? " is-collapsed" : ""}${animateItems ? " is-animated" : ""}`}><div className="collector-package-items">{row.links.map((link) => {
const hoster = formatHosterLabel(link.hoster);
return (
<div className={`collector-file-row${selected.has(link.id) ? " is-selected" : ""}`} key={link.id} role="row">
<span className="collector-column-select" role="cell"><input aria-label={`${link.fileName} auswählen`} checked={selected.has(link.id)} onChange={(event) => actions.onLinkSelectionChange(link.id, event.target.checked)} type="checkbox" /></span>
<span className="collector-name-cell is-file" role="cell" title={link.url}><span className={`collector-link-state is-${link.availability}`} />{link.fileName}</span>
<span className="collector-size-cell" role="cell">{link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)}</span>
<span className="collector-hoster-cell" role="cell"><span title={hoster.title}>{hoster.compact}</span></span>
<span className="collector-status-cell" role="cell">{link.status === "ready" ? "Bereit" : link.status === "offline" ? "Offline" : "Ungeprüft"}</span>
<span className={`collector-availability-cell is-${link.availability}`} role="cell">{link.availability === "online" ? "Online" : link.availability === "offline" ? "Offline" : "Ungeprüft"}</span>
<span className="collector-added-cell" role="cell">{formatDateTime(link.addedAt)}</span>
</div>
);
})}</div></div> : null}
</div> </div>
); );
} }
export function CollectorInputDialog({ export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
open,
tabName,
value,
onChange,
onClose,
onCommit
}: CollectorInputDialogProps): ReactElement | null {
return ( return (
<Dialog <section className="collector-content" aria-label="Gesammelte Downloadpakete"><DataTable className="collector-table" label="Gesammelte Downloadpakete">
actions={( <DataTableHeader className="collector-table-header"><div className="collector-table-header-row" role="row"><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></DataTableHeader>
<> <DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button> {model.busy ? <DataTableEmpty title="Links werden analysiert" description="Dateinamen, Größen und Verfügbarkeit werden geprüft." /> : model.error ? <DataTableEmpty className="collector-table-error" title={model.error} description="Bereits gesammelte Pakete bleiben unverändert." /> : model.empty ? <DataTableEmpty data-visual-region="collector-empty-state" description={model.query || model.filter !== "all" ? "Passe Suche oder Statusfilter an." : "Füge Links hinzu, um Pakete vor dem Download zu prüfen."} title={model.query || model.filter !== "all" ? "Keine passenden Links" : "Noch keine Links"} /> : model.packages.map((row) => <CollectorPackageGroup actions={actions} key={row.id} model={model} row={row} />)}
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button> </DataTableBody>
</> </DataTable></section>
)}
description={`Links für ${tabName} lokal erfassen.`}
onClose={onClose}
open={open}
size="wide"
title="Links hinzufügen"
>
<label className="collector-input-label">
<span>Links</span>
<textarea
aria-label="Links"
autoFocus
className="collector-input"
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
placeholder="Eine URL oder Rohzeile pro Zeile"
rows={12}
value={value}
/>
</label>
</Dialog>
); );
} }
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
if (region === "sidebar") return <CollectorSidebar actions={actions} model={model} />;
if (region === "toolbar") return <CollectorToolbar actions={actions} model={model} />;
if (region === "content") return <CollectorContent actions={actions} model={model} />;
return <div className="collector-view"><CollectorSidebar actions={actions} model={model} /><div className="collector-view-main"><CollectorToolbar actions={actions} model={model} /><CollectorContent actions={actions} model={model} /></div></div>;
}
export function CollectorInputDialog({ open, value, onChange, onClose, onCommit }: CollectorInputDialogProps): ReactElement | null {
return <Dialog actions={<><button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button><button className="collector-dialog-primary" onClick={onCommit} type="button">Analysieren</button></>} description="Links werden geprüft und automatisch zu Downloadpaketen gruppiert." onClose={onClose} open={open} size="wide" title="Links hinzufügen"><label className="collector-input-label"><span>Links</span><textarea aria-label="Links" autoFocus className="collector-input" onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)} placeholder="Eine URL pro Zeile" rows={12} value={value} /></label></Dialog>;
}
+148 -68
View File
@@ -1,88 +1,168 @@
export interface CollectorSourceTab { import type { CollectorLink, CollectorPackage } from "../../../shared/collector";
export type CollectorWorkspaceFilter = "all" | "online" | "unknown" | "offline";
export interface CollectorWorkspaceFilterEntry {
id: CollectorWorkspaceFilter;
label: string;
count: number;
}
export interface CollectorWorkspacePackageRow {
id: string; id: string;
name: string; name: string;
text: string; links: CollectorLink[];
allLinks: CollectorLink[];
totalBytes: number;
unknownSizeCount: number;
onlineCount: number;
offlineCount: number;
unknownCount: number;
totalCount: number;
selectedCount: number;
collapsed: boolean;
addedAt: number;
hosters: string[];
} }
export interface CollectorTabSummary { export interface CollectorWorkspaceViewModel {
id: string; packages: CollectorWorkspacePackageRow[];
name: string; filters: CollectorWorkspaceFilterEntry[];
linkCount: number; filter: CollectorWorkspaceFilter;
}
export interface CollectorRow {
id: string;
tabId: string;
tabName: string;
originalLineIndex: number;
lineNumber: number;
value: string;
linkCount: number;
}
export interface CollectorViewModel {
tabs: CollectorTabSummary[];
activeTabId: string;
rows: CollectorRow[];
busy: boolean;
query: string; query: string;
selectedIds: string[]; busy: boolean;
empty: boolean;
error: string; error: string;
empty: boolean;
totalCount: number;
selectedCount: number;
selectedIds: string[];
animationsEnabled: boolean;
} }
function nonEmptyLines(tab: CollectorSourceTab): Array<{ originalLineIndex: number; value: string }> { export function mergeCollectorPackages(
return tab.text current: CollectorPackage[],
.split(/\r?\n/) incoming: CollectorPackage[]
.map((value, originalLineIndex) => ({ originalLineIndex, value: value.trim() })) ): { packages: CollectorPackage[]; addedLinks: number; duplicateLinks: number } {
.filter((line) => line.value.length > 0); const packages = current.map((pkg) => ({ ...pkg, links: [...pkg.links] }));
} const packageByName = new Map(packages.map((pkg) => [pkg.name.toLocaleLowerCase("de"), pkg]));
const seenUrls = new Set(packages.flatMap((pkg) => pkg.links.map((link) => link.url)));
let addedLinks = 0;
let duplicateLinks = 0;
export function buildCollectorRows( for (const incomingPackage of incoming) {
tabs: CollectorSourceTab[], let target = packageByName.get(incomingPackage.name.toLocaleLowerCase("de"));
activeTabId: string = tabs[0]?.id ?? "", for (const link of incomingPackage.links) {
query = "" if (seenUrls.has(link.url)) {
): CollectorRow[] { duplicateLinks += 1;
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; continue;
if (!activeTab) { }
return []; if (!target) {
target = { ...incomingPackage, links: [], addedAt: incomingPackage.addedAt };
packages.push(target);
packageByName.set(incomingPackage.name.toLocaleLowerCase("de"), target);
}
target.links.push(link);
target.addedAt = Math.min(target.addedAt, link.addedAt);
seenUrls.add(link.url);
addedLinks += 1;
}
} }
const lines = nonEmptyLines(activeTab);
const normalizedQuery = query.trim().toLocaleLowerCase("de"); return { packages, addedLinks, duplicateLinks };
return lines
.filter((line) => !normalizedQuery || line.value.toLocaleLowerCase("de").includes(normalizedQuery))
.map((line) => ({
id: `${activeTab.id}:${line.originalLineIndex}`,
tabId: activeTab.id,
tabName: activeTab.name,
originalLineIndex: line.originalLineIndex,
lineNumber: line.originalLineIndex + 1,
value: line.value,
linkCount: lines.length
}));
} }
export function buildCollectorViewModel( export function selectCollectorPackageLinks(
tabs: CollectorSourceTab[], current: Set<string>,
activeTabId: string, pkg: CollectorPackage,
selected: boolean
): Set<string> {
const next = new Set(current);
for (const link of pkg.links) {
if (selected) next.add(link.id);
else next.delete(link.id);
}
return next;
}
export function buildCollectorTransferPackages(packages: CollectorPackage[], selectedIds: Set<string>): CollectorPackage[] {
return packages.flatMap((pkg) => {
const links = pkg.links.filter((link) => selectedIds.has(link.id));
return links.length > 0 ? [{ ...pkg, links }] : [];
});
}
export function removeCollectorLinks(packages: CollectorPackage[], removedIds: Set<string>): CollectorPackage[] {
return packages.flatMap((pkg) => {
const links = pkg.links.filter((link) => !removedIds.has(link.id));
return links.length > 0 ? [{ ...pkg, links }] : [];
});
}
function filterCollectorLink(link: CollectorLink, filter: CollectorWorkspaceFilter): boolean {
return filter === "all" || link.availability === filter;
}
function collectorLinkMatchesQuery(link: CollectorLink, query: string): boolean {
if (!query) return true;
return `${link.fileName}\n${link.url}\n${link.hoster}\n${link.status}\n${link.availability}`.toLocaleLowerCase("de").includes(query);
}
export function buildCollectorWorkspaceViewModel(
packages: CollectorPackage[],
filter: CollectorWorkspaceFilter,
query: string, query: string,
busy: boolean, busy: boolean,
selectedIds: string[], selectedIds: string[],
error = "" collapsedPackageIds: string[],
): CollectorViewModel { error = "",
const rows = buildCollectorRows(tabs, activeTabId, query); animationsEnabled = true
): CollectorWorkspaceViewModel {
const selected = new Set(selectedIds);
const collapsed = new Set(collapsedPackageIds);
const normalizedQuery = query.trim().toLocaleLowerCase("de");
const allLinks = packages.flatMap((pkg) => pkg.links);
const countAvailability = (availability: CollectorLink["availability"]): number => allLinks.filter((link) => link.availability === availability).length;
const rows: CollectorWorkspacePackageRow[] = [];
for (const pkg of packages) {
const packageMatches = !normalizedQuery || pkg.name.toLocaleLowerCase("de").includes(normalizedQuery);
const visibleLinks = pkg.links.filter((link) => filterCollectorLink(link, filter)
&& (packageMatches || collectorLinkMatchesQuery(link, normalizedQuery)));
if (visibleLinks.length === 0) continue;
rows.push({
id: pkg.id,
name: pkg.name,
links: visibleLinks,
allLinks: pkg.links,
totalBytes: pkg.links.reduce((sum, link) => sum + (link.fileSizeBytes || 0), 0),
unknownSizeCount: pkg.links.filter((link) => link.fileSizeBytes === null).length,
onlineCount: pkg.links.filter((link) => link.availability === "online").length,
offlineCount: pkg.links.filter((link) => link.availability === "offline").length,
unknownCount: pkg.links.filter((link) => link.availability === "unknown").length,
totalCount: pkg.links.length,
selectedCount: pkg.links.filter((link) => selected.has(link.id)).length,
collapsed: collapsed.has(pkg.id),
addedAt: pkg.addedAt,
hosters: [...new Set(pkg.links.map((link) => link.hoster).filter(Boolean))]
});
}
return { return {
tabs: tabs.map((tab) => ({ packages: rows,
id: tab.id, filters: [
name: tab.name, { id: "all", label: "Alle Links", count: allLinks.length },
linkCount: nonEmptyLines(tab).length { id: "online", label: "Online", count: countAvailability("online") },
})), { id: "unknown", label: "Ungeprüft", count: countAvailability("unknown") },
activeTabId, { id: "offline", label: "Offline", count: countAvailability("offline") }
rows, ],
busy, filter,
query, query,
selectedIds, busy,
error,
empty: rows.length === 0, empty: rows.length === 0,
error totalCount: allLinks.length,
selectedCount: allLinks.filter((link) => selected.has(link.id)).length,
selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id),
animationsEnabled
}; };
} }
+62 -318
View File
@@ -1,318 +1,62 @@
.collector-view { .collector-view { display: grid; grid-template-columns: 230px minmax(0, 1fr); min-width: 0; min-height: 520px; overflow: hidden; }
display: grid; .collector-view-main { display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 0; }
grid-template-columns: 270px minmax(0, 1fr); .collector-sidebar { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 12px; }
min-width: 0; .collector-sidebar-heading { align-items: center; color: var(--ui-text-secondary); display: flex; font-size: 13px; justify-content: space-between; min-height: 28px; }
min-height: 520px; .collector-sidebar-heading span { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; }
overflow: hidden; .collector-sidebar-list { display: flex; flex: 1; flex-direction: column; gap: 4px; min-height: 0; overflow-y: auto; }
} .collector-sidebar-filter { align-items: center; background: transparent; border: 1px solid transparent; border-radius: 6px; color: var(--ui-text-secondary); display: flex; font: inherit; justify-content: space-between; min-height: 36px; padding: 0 10px; text-align: left; width: 100%; }
.collector-sidebar-filter:hover { background: var(--ui-hover); color: var(--ui-text); }
.collector-view-main { .collector-sidebar-filter.is-active { color: var(--ui-text); }
display: grid; .collector-sidebar-filter span:last-child { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; }
grid-template-rows: auto minmax(0, 1fr); .collector-toolbar { --collector-toolbar-action-gap: 4px; gap: var(--collector-toolbar-action-gap); min-width: 0; width: 100%; }
min-width: 0; .collector-toolbar .ui-toolbar-group { gap: var(--collector-toolbar-action-gap); }
} .collector-action { background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 6px; color: var(--ui-text-secondary); font: inherit; height: 36px; padding: 0 12px; white-space: nowrap; }
.collector-action:hover:not(:disabled) { background: var(--ui-hover); color: var(--ui-text); }
.collector-sidebar { .collector-action-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
display: flex; .collector-action-primary:hover:not(:disabled) { background: var(--ui-primary-hover); color: var(--ui-primary-text); }
flex-direction: column; .collector-action-danger:not(:disabled) { border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border)); color: var(--ui-danger-text); }
gap: 8px; .collector-action:disabled { cursor: default; opacity: .45; }
min-width: 0; .collector-content { display: flex; height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
padding: 12px; .collector-table { height: 100%; }
} .collector-table-header { height: 41px; }
.collector-table-header-row, .collector-package-row, .collector-file-row { align-items: center; display: grid; grid-template-columns: 48px minmax(260px, 2.4fr) minmax(110px, .8fr) minmax(90px, .65fr) minmax(115px, .9fr) minmax(130px, 1fr) minmax(150px, 1fr); min-width: 960px; }
.collector-sidebar-heading { .collector-table-header-row { color: var(--ui-text-secondary); font-size: 11px; font-weight: 700; height: 41px; letter-spacing: .04em; text-transform: uppercase; }
align-items: center; .collector-table-header-row > span, .collector-package-row > span, .collector-file-row > span { min-width: 0; padding: 0 12px; }
color: var(--ui-text-secondary); .collector-table-body { overflow: auto; }
display: flex; .collector-package-group { contain-intrinsic-size: 46px 654px; content-visibility: auto; }
font-size: 13px; .collector-package-row { background: color-mix(in srgb, var(--ui-active) 34%, var(--ui-surface)); border-bottom: 1px solid var(--ui-border); color: var(--ui-text-secondary); height: 46px; }
justify-content: space-between; .collector-file-row { border-bottom: 1px solid var(--ui-border); color: var(--ui-text-secondary); height: 40px; }
min-height: 28px; .collector-package-row:hover, .collector-file-row:hover { background: var(--ui-hover); }
} .collector-package-row.is-selected, .collector-file-row.is-selected { background: var(--ui-active); }
.collector-package-items-frame { display: grid; grid-template-rows: 1fr; min-height: 0; opacity: 1; }
.collector-sidebar-heading span, .collector-package-items-frame.is-animated { transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1), opacity 300ms ease; }
.collector-sidebar-count { .collector-package-items-frame.is-collapsed { grid-template-rows: 0fr; opacity: 0; pointer-events: none; }
color: var(--ui-text-muted); .collector-package-items { min-height: 0; overflow: hidden; }
font-variant-numeric: tabular-nums; .collector-column-select { display: grid; place-items: center; }
} .collector-name-cell { align-items: center; display: flex; gap: 8px; min-width: 0; }
.collector-name-cell strong, .collector-name-cell.is-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.collector-sidebar-list { .collector-name-cell small { color: var(--ui-text-muted); white-space: nowrap; }
display: flex; .collector-name-cell.is-file { color: var(--ui-text); padding-left: 36px; }
flex: 1; .collector-collapse-button { align-items: center; background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 5px; color: var(--ui-text-secondary); display: inline-flex; flex: 0 0 28px; height: 28px; justify-content: center; }
flex-direction: column; .collector-collapse-button:hover { background: var(--ui-hover); color: var(--ui-text); }
gap: 4px; .collector-link-state { border-radius: 50%; flex: 0 0 8px; height: 8px; }
min-height: 0; .collector-link-state.is-online { background: var(--ui-success); }
overflow-y: auto; .collector-link-state.is-offline { background: var(--ui-danger); }
} .collector-link-state.is-unknown { background: var(--ui-text-muted); }
.collector-size-cell, .collector-added-cell { font-variant-numeric: tabular-nums; white-space: nowrap; }
.collector-sidebar-item { .collector-hoster-cell { align-items: center; display: flex; gap: 5px; }
align-items: stretch; .collector-hoster-cell span { color: var(--ui-text); font-weight: 600; }
border: 1px solid transparent; .collector-status-cell, .collector-added-cell { color: var(--ui-text-muted); }
border-radius: 6px; .collector-availability-cell { font-weight: 600; white-space: nowrap; }
display: flex; .collector-availability-cell.is-online { color: var(--ui-success); }
min-height: 36px; .collector-availability-cell.is-offline { color: var(--ui-danger); }
} .collector-availability-cell.is-unknown { color: var(--ui-text-muted); }
.collector-table-error .ui-data-table-empty-title { color: var(--ui-danger); }
.collector-sidebar-item:hover { .collector-input-label { display: grid; gap: 8px; }
background: var(--ui-hover); .collector-input-label > span { color: var(--ui-text-secondary); font-size: 13px; font-weight: 600; }
} .collector-input { background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 6px; color: var(--ui-text); font: inherit; line-height: 1.5; min-height: 240px; padding: 12px; resize: vertical; user-select: text; width: 100%; }
.collector-dialog-primary, .collector-dialog-secondary { border: 1px solid var(--ui-border); border-radius: 6px; font: inherit; height: 36px; padding: 0 14px; }
.collector-sidebar-item.is-active { .collector-dialog-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
background: transparent; .collector-dialog-secondary { background: var(--ui-modal-secondary); color: var(--ui-text-secondary); }
border-color: transparent; @media (max-width: 1366px) { .collector-view { grid-template-columns: 56px minmax(0, 1fr); } .collector-sidebar { overflow: hidden; } .collector-sidebar-filter { padding: 0 7px; } .collector-sidebar-filter span:first-child { overflow: hidden; text-overflow: ellipsis; } .collector-action { padding: 0 9px; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 44px minmax(220px, 2.2fr) minmax(98px, .8fr) minmax(78px, .65fr) minmax(100px, .9fr) minmax(112px, 1fr) minmax(132px, 1fr); min-width: 820px; } }
} @media (max-width: 1120px) { .collector-toolbar { flex-wrap: wrap; } .collector-toolbar .ui-toolbar-search { flex: 1 0 100%; width: 100%; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, .8fr) minmax(72px, .65fr) minmax(94px, .9fr) minmax(106px, 1fr) minmax(124px, 1fr); min-width: 760px; } }
.collector-sidebar-select,
.collector-sidebar-remove,
.collector-sidebar-add {
background: transparent;
border: 0;
color: var(--ui-text-secondary);
font: inherit;
}
.collector-sidebar-select {
align-items: center;
display: flex;
flex: 1;
gap: 8px;
justify-content: space-between;
min-width: 0;
padding: 0 8px;
text-align: left;
}
.collector-sidebar-select span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-sidebar-remove {
border-left: 1px solid var(--ui-border);
min-width: 32px;
}
.collector-sidebar-add {
border: 1px solid var(--ui-border);
border-radius: 6px;
min-height: 36px;
padding: 0 10px;
text-align: left;
}
.collector-sidebar-add:hover,
.collector-sidebar-remove:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-toolbar {
min-width: 0;
width: 100%;
}
.collector-action {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text-secondary);
font: inherit;
height: 36px;
padding: 0 12px;
white-space: nowrap;
}
.collector-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-action-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: var(--ui-primary-text);
}
.collector-action-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
color: var(--ui-primary-text);
}
.collector-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger-text);
}
.collector-action:disabled {
cursor: default;
opacity: 0.45;
}
.collector-content {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.collector-table {
height: 100%;
}
.collector-table-header-row,
.collector-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
min-width: 760px;
}
.collector-table-header {
height: 41px;
}
.collector-table-header-row {
color: var(--ui-text-secondary);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.collector-table-header-row > span,
.collector-row > span {
min-width: 0;
padding: 0 12px;
}
.collector-table-body {
overflow: auto;
}
.collector-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.collector-row:hover {
background: var(--ui-hover);
}
.collector-row.is-selected {
background: var(--ui-active);
}
.collector-column-select {
display: grid;
place-items: center;
}
.collector-row-source {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-row-value {
color: var(--ui-text);
overflow: hidden;
text-overflow: ellipsis;
user-select: text;
white-space: nowrap;
}
.collector-row-line,
.collector-row-status {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.collector-input-label {
display: grid;
gap: 8px;
}
.collector-input-label > span {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
}
.collector-input {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text);
font: inherit;
line-height: 1.5;
min-height: 240px;
padding: 12px;
resize: vertical;
user-select: text;
width: 100%;
}
.collector-dialog-primary,
.collector-dialog-secondary {
border: 1px solid var(--ui-border);
border-radius: 6px;
font: inherit;
height: 36px;
padding: 0 14px;
}
.collector-dialog-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: var(--ui-primary-text);
}
.collector-dialog-secondary {
background: var(--ui-modal-secondary);
color: var(--ui-text-secondary);
}
@media (max-width: 1366px) {
.collector-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.collector-sidebar {
overflow: hidden;
}
.collector-action {
padding: 0 9px;
}
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
min-width: 660px;
}
}
@media (max-width: 1120px) {
.collector-toolbar {
flex-wrap: wrap;
}
.collector-toolbar .ui-toolbar-search {
flex: 1 0 100%;
width: 100%;
}
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
min-width: 610px;
}
}
+86
View File
@@ -0,0 +1,86 @@
export type CollectorAvailability = "online" | "offline" | "unknown";
export type CollectorLinkStatus = "ready" | "offline" | "unknown";
export interface CollectorLink {
id: string;
url: string;
fileName: string;
fileSizeBytes: number | null;
hoster: string;
availability: CollectorAvailability;
status: CollectorLinkStatus;
addedAt: number;
}
export interface CollectorPackage {
id: string;
name: string;
links: CollectorLink[];
addedAt: number;
}
export interface CollectorInspectionRequest {
rawText: string;
addedAt: number;
}
export interface CollectorInspectionResult {
packages: CollectorPackage[];
invalidCount: number;
duplicateCount: number;
}
export interface CollectorContainerInspectionRequest {
filePaths: string[];
addedAt: number;
}
function validAddedAt(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
export function validateCollectorInspectionRequest(value: unknown): CollectorInspectionRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Linksammler-Payload ist ungültig");
}
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => key !== "rawText" && key !== "addedAt")
|| typeof raw.rawText !== "string"
|| raw.rawText.length > 2_000_000
|| !validAddedAt(raw.addedAt)) {
throw new Error("Linksammler-Payload ist ungültig");
}
return { rawText: raw.rawText, addedAt: raw.addedAt };
}
export function validateCollectorContainerInspectionRequest(filePaths: unknown, addedAt: unknown): CollectorContainerInspectionRequest {
if (!Array.isArray(filePaths)
|| filePaths.length === 0
|| filePaths.length > 100
|| !validAddedAt(addedAt)
|| filePaths.some((entry) => typeof entry !== "string"
|| entry.length === 0
|| entry.length > 32767
|| !/^(?:[a-z]:[\\/]|\\\\|\/)/i.test(entry)
|| !entry.toLowerCase().endsWith(".dlc"))) {
throw new Error("Container-Payload ist ungültig");
}
return { filePaths: [...filePaths], addedAt };
}
function collectorMarkerValue(value: string): string {
return String(value || "").replace(/[\r\n]+/g, " ").trim();
}
export function serializeCollectorPackages(packages: CollectorPackage[]): string {
const lines: string[] = [];
for (const pkg of packages) {
if (pkg.links.length === 0) continue;
lines.push(`# Package: ${collectorMarkerValue(pkg.name)}`);
for (const link of pkg.links) {
if (link.fileName) lines.push(`# File: ${collectorMarkerValue(link.fileName)}`);
lines.push(link.url);
}
}
return lines.join("\n");
}
+2
View File
@@ -14,6 +14,8 @@ export const IPC_CHANNELS = {
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
ADD_LINKS: "queue:add-links", ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers", ADD_CONTAINERS: "queue:add-containers",
INSPECT_COLLECTOR_TEXT: "collector:inspect-text",
INSPECT_COLLECTOR_CONTAINERS: "collector:inspect-containers",
GET_START_CONFLICTS: "queue:get-start-conflicts", GET_START_CONFLICTS: "queue:get-start-conflicts",
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
CLEAR_ALL: "queue:clear-all", CLEAR_ALL: "queue:clear-all",
+3
View File
@@ -34,6 +34,7 @@ import type {
UpdateInstallResult UpdateInstallResult
} from "./types"; } from "./types";
import { isRealDebridWebAccountId } from "./real-debrid-accounts"; import { isRealDebridWebAccountId } from "./real-debrid-accounts";
import type { CollectorInspectionRequest, CollectorInspectionResult } from "./collector";
export interface RealDebridLoginRequest { export interface RealDebridLoginRequest {
accountId: string; accountId: string;
@@ -79,6 +80,8 @@ export interface ElectronApi {
deleteAccount: (command: AccountDeleteCommand) => Promise<AccountCommandResult>; deleteAccount: (command: AccountDeleteCommand) => Promise<AccountCommandResult>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
inspectCollectorText: (request: CollectorInspectionRequest) => Promise<CollectorInspectionResult>;
inspectCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>;
getStartConflicts: () => Promise<StartConflictEntry[]>; getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>; resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
clearAll: () => Promise<void>; clearAll: () => Promise<void>;
+13
View File
@@ -109,4 +109,17 @@ describe("account preload contract", () => {
expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST); expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST);
expect(result).toEqual({ passwords }); expect(result).toEqual({ passwords });
}); });
it("forwards collector text and container inspection without adding downloads", async () => {
const result = { packages: [], invalidCount: 0, duplicateCount: 0 };
electron.invoke.mockResolvedValue(result);
await electron.api?.inspectCollectorText({ rawText: "https://1fichier.com/?abc12345", addedAt: 1234 });
await electron.api?.inspectCollectorContainers(["C:\\Imports\\sample.dlc"], 5678);
expect(electron.invoke.mock.calls).toEqual([
[IPC_CHANNELS.INSPECT_COLLECTOR_TEXT, { rawText: "https://1fichier.com/?abc12345", addedAt: 1234 }],
[IPC_CHANNELS.INSPECT_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 5678]
]);
});
}); });
+7 -5
View File
@@ -18,12 +18,14 @@ describe("desktop shell", () => {
expect(source).toContain("Maskierte Kennung kopiert"); expect(source).toContain("Maskierte Kennung kopiert");
}); });
it("confirms before removing a collector tab", () => { it("removes collector links only after the exact download transfer succeeds", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const removal = source.slice(source.indexOf("const removeCollectorTab"), source.indexOf("const openCollectorInput")); const start = source.indexOf("const transferCollectorPackages");
const transfer = source.slice(start, source.indexOf("const onImportDlc =", start));
expect(removal).toContain("askConfirmPrompt"); expect(transfer).toContain("await window.rd.addLinks");
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("planCollectorTabRemoval")); expect(transfer).toContain("result.addedLinks !== transferredIds.size");
expect(transfer.indexOf("await window.rd.addLinks")).toBeLessThan(transfer.indexOf("setCollectorPackages"));
}); });
it("confirms before removing selected collector links", () => { it("confirms before removing selected collector links", () => {
@@ -31,7 +33,7 @@ describe("desktop shell", () => {
const removal = source.slice(source.indexOf("const removeSelectedCollectorRows"), source.indexOf("const onPackageStartEdit")); const removal = source.slice(source.indexOf("const removeSelectedCollectorRows"), source.indexOf("const onPackageStartEdit"));
expect(removal).toContain("askConfirmPrompt"); expect(removal).toContain("askConfirmPrompt");
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("setCollectorTabs")); expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("setCollectorPackages"));
expect(removal).toContain('title: "Ausgewählte Links löschen"'); expect(removal).toContain('title: "Ausgewählte Links löschen"');
}); });
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import {
inferCollectorPackageName,
inspectCollectorText,
serializeCollectorPackages
} from "../src/main/collector-inspection";
const sbsLinks = [
"https://1fichier.com/?82xiit09yax8npoh1qt8",
"https://1fichier.com/?gr4ry0k18p3sg74vupfp",
"https://1fichier.com/?bui5smo6gsxxelz49ukl",
"https://1fichier.com/?9d3udo3rr96f8xi2xj74",
"https://1fichier.com/?lqetjbffrw58zrxsrz00",
"https://1fichier.com/?0cgbn7se8l4as8sopr9u",
"https://1fichier.com/?dufuuwir5skm055penoo",
"https://1fichier.com/?p2njgtuhtuzm20vwp4n5",
"https://1fichier.com/?ogqhzkqpj2ugmm9evc11",
"https://1fichier.com/?ktymbac2nt78o5zsi3z5",
"https://1fichier.com/?q51ktq0jb7fmez42n3zs",
"https://1fichier.com/?oo7p0wdnapdix2dvt6d0",
"https://1fichier.com/?lv2s37fkloo7wgjwq05r",
"https://1fichier.com/?fnczcnoljqxgny9q7fxt",
"https://1fichier.com/?pbg9n0rrqk3rpnflqbe8",
"https://1fichier.com/?cgmdxz11f2gd9u7by9tg"
];
describe("collector inspection", () => {
it("groups the real SBS14HD multipart shape into one package with exact metadata", async () => {
const metadata = new Map(sbsLinks.map((link, index) => [link, {
online: true,
fileName: `SBS14HD.part${String(index + 1).padStart(2, "0")}.rar`,
fileSizeBytes: index === 15 ? 373_517_856 : 471_859_200,
accessRestricted: false
}]));
const result = await inspectCollectorText({ rawText: sbsLinks.join("\n"), addedAt: 1_777_777_777_000 }, defaultSettings(), {
checkOneFichier: async () => metadata,
createId: (() => {
let value = 0;
return (prefix) => `${prefix}-${++value}`;
})()
});
expect(result.invalidCount).toBe(0);
expect(result.duplicateCount).toBe(0);
expect(result.packages).toHaveLength(1);
expect(result.packages[0].name).toBe("SBS14HD");
expect(result.packages[0].links).toHaveLength(16);
expect(result.packages[0].links.map((link) => link.fileName)).toEqual(
Array.from({ length: 16 }, (_unused, index) => `SBS14HD.part${String(index + 1).padStart(2, "0")}.rar`)
);
expect(result.packages[0].links.reduce((sum, link) => sum + (link.fileSizeBytes || 0), 0)).toBe(7_451_405_856);
expect(result.packages[0].links.every((link) => link.hoster === "1fichier" && link.availability === "online" && link.status === "ready")).toBe(true);
expect(result.packages[0].addedAt).toBe(1_777_777_777_000);
});
it("keeps explicit packages authoritative while removing duplicate URLs", async () => {
const rawText = [
"# Package: Staffel A",
"# File: episode.part01.rar",
"https://example.com/a",
"https://example.com/a",
"# Package: Staffel B",
"https://example.com/b"
].join("\n");
const result = await inspectCollectorText({ rawText, addedAt: 2000 }, defaultSettings(), {
resolveFilenames: async () => new Map([["https://example.com/b", "episode.part02.rar"]])
});
expect(result.packages.map((pkg) => pkg.name)).toEqual(["Staffel A", "Staffel B"]);
expect(result.packages.map((pkg) => pkg.links.length)).toEqual([1, 1]);
expect(result.packages[0].links[0].fileName).toBe("episode.part01.rar");
expect(result.packages[1].links[0].fileName).toBe("episode.part02.rar");
expect(result.duplicateCount).toBe(1);
});
it("retains offline and unknown links as visible collector entries", async () => {
const offline = "https://1fichier.com/?offline123";
const unknown = "https://unknown.example/resource";
const result = await inspectCollectorText({ rawText: `${offline}\n${unknown}`, addedAt: 3000 }, defaultSettings(), {
checkOneFichier: async () => new Map([[offline, {
online: false,
fileName: "",
fileSizeBytes: null,
accessRestricted: false
}]]),
resolveFilenames: async () => new Map()
});
const links = result.packages.flatMap((pkg) => pkg.links);
expect(links.find((link) => link.url === offline)).toEqual(expect.objectContaining({
availability: "offline",
status: "offline"
}));
expect(links.find((link) => link.url === unknown)).toEqual(expect.objectContaining({
availability: "unknown",
status: "unknown"
}));
});
it("infers archive package names and serializes inspected metadata for the existing queue parser", () => {
expect(inferCollectorPackageName("SBS14HD.part01.rar", "1fichier")).toBe("SBS14HD");
expect(inferCollectorPackageName("Archive.7z.001", "1fichier")).toBe("Archive");
expect(inferCollectorPackageName("Show.r00", "1fichier")).toBe("Show");
const serialized = serializeCollectorPackages([{
id: "package-1",
name: "SBS14HD",
addedAt: 4000,
links: [{
id: "link-1",
url: sbsLinks[0],
fileName: "SBS14HD.part01.rar",
fileSizeBytes: 471_859_200,
hoster: "1fichier",
availability: "online",
status: "ready",
addedAt: 4000
}]
}]);
expect(serialized).toBe(`# Package: SBS14HD\n# File: SBS14HD.part01.rar\n${sbsLinks[0]}`);
});
});
+133 -256
View File
@@ -2,33 +2,22 @@ import { readFileSync } from "node:fs";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { CollectorPackage } from "../src/shared/collector";
import { import {
mergeCollectorDraftText, buildCollectorTransferPackages,
planCollectorTabRemoval, buildCollectorWorkspaceViewModel,
planCollectorTextReplacement mergeCollectorPackages,
} from "../src/renderer/App"; removeCollectorLinks,
import { selectCollectorPackageLinks
buildCollectorRows,
buildCollectorViewModel,
type CollectorSourceTab
} from "../src/renderer/views/collector/collector-model"; } from "../src/renderer/views/collector/collector-model";
import { import { CollectorContent, CollectorInputDialog, CollectorSidebar, CollectorToolbar, CollectorView, type CollectorViewActions } from "../src/renderer/views/collector/CollectorView";
CollectorInputDialog,
CollectorContent,
CollectorSidebar,
CollectorToolbar,
CollectorView,
type CollectorViewActions
} from "../src/renderer/views/collector/CollectorView";
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void { function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) { if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit)); node.forEach((child) => visitElements(child, visit));
return; return;
} }
if (!isValidElement(node)) { if (!isValidElement(node)) return;
return;
}
visit(node); visit(node);
visitElements(node.props.children, visit); visitElements(node.props.children, visit);
visitElements(node.props.actions, visit); visitElements(node.props.actions, visit);
@@ -37,13 +26,9 @@ function visitElements(node: ReactNode, visit: (element: ReactElement) => void):
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement { function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null; let result: ReactElement | null = null;
visitElements(node, (element) => { visitElements(node, (element) => {
if (!result && predicate(element)) { if (!result && predicate(element)) result = element;
result = element;
}
}); });
if (!result) { if (!result) throw new Error("Element not found");
throw new Error("Element not found");
}
return result; return result;
} }
@@ -53,274 +38,166 @@ function findButton(node: ReactNode, label: string): ReactElement {
function createActions(overrides: Partial<CollectorViewActions> = {}): CollectorViewActions { function createActions(overrides: Partial<CollectorViewActions> = {}): CollectorViewActions {
return { return {
onTabSelect: () => {}, onFilterChange: () => {},
onTabAdd: () => {},
onTabRemove: () => {},
onOpenInput: () => {}, onOpenInput: () => {},
onImportDlc: () => {}, onImportDlc: () => {},
onImportFile: () => {}, onImportFile: () => {},
onExportQueue: () => {}, onSubmitSelected: () => {},
onSubmit: () => {}, onSubmitAll: () => {},
onQueryChange: () => {}, onQueryChange: () => {},
onSelectionChange: () => {}, onLinkSelectionChange: () => {},
onPackageSelectionChange: () => {},
onPackageCollapseChange: () => {},
onRemoveSelected: () => {}, onRemoveSelected: () => {},
...overrides ...overrides
}; };
} }
const populatedTabs: CollectorSourceTab[] = [ const packages: CollectorPackage[] = [{
{ id: "package-sbs",
id: "tab-a", name: "SBS14HD",
name: "Sammlung A", addedAt: 1000,
text: "https://example.test/a\n\n https://example.test/b " links: [
} { id: "link-1", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1000 },
]; { id: "link-2", url: "https://1fichier.com/?two22222", fileName: "SBS14HD.part02.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1000 }
]
}, {
id: "package-mixed",
name: "Mixed",
addedAt: 2000,
links: [
{ id: "link-3", url: "https://example.test/unknown", fileName: "unknown.bin", fileSizeBytes: null, hoster: "example", availability: "unknown", status: "unknown", addedAt: 2000 },
{ id: "link-4", url: "https://example.test/offline", fileName: "offline.bin", fileSizeBytes: null, hoster: "example", availability: "offline", status: "offline", addedAt: 2000 }
]
}];
describe("collector model", () => { describe("collector workspace model", () => {
it("derives stable rows from non-empty raw lines without validating or regrouping them", () => { it("merges packages by name while deduplicating URLs", () => {
const rows = buildCollectorRows(populatedTabs, "tab-a", ""); const incoming: CollectorPackage[] = [{
id: "incoming",
name: "SBS14HD",
addedAt: 3000,
links: [
{ ...packages[0].links[0], id: "duplicate" },
{ id: "link-5", url: "https://1fichier.com/?three333", fileName: "SBS14HD.part03.rar", fileSizeBytes: 10, hoster: "1fichier", availability: "online", status: "ready", addedAt: 3000 }
]
}];
const result = mergeCollectorPackages(packages, incoming);
expect(rows).toHaveLength(2); expect(result.addedLinks).toBe(1);
expect(rows.map((row) => row.id)).toEqual(["tab-a:0", "tab-a:2"]); expect(result.duplicateLinks).toBe(1);
expect(rows.map((row) => row.originalLineIndex)).toEqual([0, 2]); expect(result.packages[0].id).toBe("package-sbs");
expect(rows.map((row) => row.value)).toEqual([ expect(result.packages[0].links.map((link) => link.id)).toEqual(["link-1", "link-2", "link-5"]);
"https://example.test/a", });
"https://example.test/b"
it("supports whole-package selection, partial transfers and partial removal", () => {
const selected = selectCollectorPackageLinks(new Set(["link-3"]), packages[0], true);
expect([...selected].sort()).toEqual(["link-1", "link-2", "link-3"]);
expect(buildCollectorTransferPackages(packages, new Set(["link-2", "link-3"])).map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([
["SBS14HD", ["link-2"]], ["Mixed", ["link-3"]]
]);
expect(removeCollectorLinks(packages, new Set(["link-2", "link-3"])).map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([
["SBS14HD", ["link-1"]], ["Mixed", ["link-4"]]
]); ]);
expect(rows[0].linkCount).toBe(2);
expect(rows[1].linkCount).toBe(2);
}); });
it("filters presentation rows while keeping source counts and original line identities", () => { it("derives aggregates, filters and search once for the view", () => {
const model = buildCollectorViewModel(populatedTabs, "tab-a", "EXAMPLE.TEST/B", false, ["tab-a:0"]); const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], ["package-mixed"], "", true);
expect(model.rows.map((row) => row.id)).toEqual(["tab-a:2"]); expect(model.packages).toHaveLength(1);
expect(model.tabs).toEqual([{ id: "tab-a", name: "Sammlung A", linkCount: 2 }]); expect(model.packages[0]).toEqual(expect.objectContaining({ totalBytes: 943_718_400, unknownSizeCount: 0, onlineCount: 2, totalCount: 2, selectedCount: 1, collapsed: false }));
expect(model.selectedIds).toEqual(["tab-a:0"]); expect(model.packages[0].links.map((link) => link.id)).toEqual(["link-2"]);
expect(model.empty).toBe(false); expect(model.filters).toEqual([
}); { id: "all", label: "Alle Links", count: 4 }, { id: "online", label: "Online", count: 2 }, { id: "unknown", label: "Ungeprüft", count: 1 }, { id: "offline", label: "Offline", count: 1 }
]);
it("preserves clipboard and drop appends that arrive while an input draft is open", () => {
expect(mergeCollectorDraftText(
"https://example.test/old",
"https://example.test/old\nhttps://example.test/clipboard",
"https://example.test/edited"
)).toBe("https://example.test/edited\nhttps://example.test/clipboard");
expect(mergeCollectorDraftText("old", "old", "edited")).toBe("edited");
});
it("moves the active identity to an existing neighbor before later appends arrive", () => {
const tabs: CollectorSourceTab[] = [
{ id: "tab-a", name: "Sammlung A", text: "a" },
{ id: "tab-b", name: "Sammlung B", text: "b" },
{ id: "tab-c", name: "Sammlung C", text: "c" }
];
expect(planCollectorTabRemoval(tabs, "tab-b", "tab-b")).toEqual({
tabs: [tabs[0], tabs[2]],
activeTabId: "tab-a"
});
expect(planCollectorTabRemoval(tabs, "tab-c", "tab-a")).toEqual({
tabs: [tabs[1], tabs[2]],
activeTabId: "tab-c"
});
});
it("invalidates positional row selection whenever raw text is replaced", () => {
const tabs: CollectorSourceTab[] = [
{ id: "tab-a", name: "Sammlung A", text: "old-a\nold-b" },
{ id: "tab-b", name: "Sammlung B", text: "untouched" }
];
expect(planCollectorTextReplacement(tabs, "tab-a", "new-a")).toEqual({
tabs: [
{ id: "tab-a", name: "Sammlung A", text: "new-a" },
tabs[1]
],
selectedIds: []
});
}); });
}); });
describe("CollectorView", () => { describe("CollectorView", () => {
it("marks collections for one measured vertical selection indicator", () => { it("renders expandable package and file rows with preview columns", () => {
const model = buildCollectorViewModel([ const html = renderToStaticMarkup(<CollectorView actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />);
{ id: "tab-a", name: "Sammlung A", text: "https://example.test/a" }, for (const heading of ["Name", "Größe", "Hoster", "Status", "Verfügbarkeit", "Hinzugefügt"]) expect(html).toContain(`>${heading}<`);
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" } expect(html).toContain("SBS14HD");
], "tab-b", "", false, []); expect(html).toContain("SBS14HD.part01.rar");
const html = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />); expect(html).toContain("SBS14HD.part02.rar");
expect(html).toContain("2/2 online");
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical"); expect(html).toContain("aria-label=\"SBS14HD einklappen\"");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2); expect(html).not.toContain("URL oder Rohzeile");
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1); expect(html).not.toContain(">Zeile<");
expect(html).not.toContain(">Lokal<");
}); });
it("keeps empty, busy and error states inside the same table body", () => { it("renders collapsed packages without child rows", () => {
const empty = renderToStaticMarkup( const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], ["package-sbs"], "", false)} />);
<CollectorView expect(html).toContain("aria-label=\"SBS14HD ausklappen\"");
actions={createActions()} expect(html).not.toContain("SBS14HD.part01.rar");
model={buildCollectorViewModel([{ id: "tab-a", name: "Sammlung A", text: "" }], "tab-a", "", false, [])}
/>
);
const busy = renderToStaticMarkup(
<CollectorView
actions={createActions()}
model={{ ...buildCollectorViewModel([], "", "", true, []), error: "" }}
/>
);
const failed = renderToStaticMarkup(
<CollectorView
actions={createActions()}
model={{ ...buildCollectorViewModel([], "", "", false, []), error: "Import fehlgeschlagen" }}
/>
);
for (const [html, state] of [
[empty, "Noch keine Links"],
[busy, "Links werden verarbeitet"],
[failed, "Import fehlgeschlagen"]
]) {
expect(html.indexOf(state)).toBeGreaterThan(html.indexOf("data-visual-region=\"collector-table-body\""));
}
expect(empty).toContain("data-visual-region=\"collector-empty-state\"");
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
}); });
it("renders compact occupied rows and removes the empty marker", () => { it("offers selected and all transfer actions", () => {
const html = renderToStaticMarkup( let selected = 0;
<CollectorView let all = 0;
actions={createActions()} const toolbar = CollectorToolbar({ actions: createActions({ onSubmitSelected: () => { selected += 1; }, onSubmitAll: () => { all += 1; } }), model: buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true) });
model={buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])} findButton(toolbar, "Auswahl übergeben (1)").props.onClick();
/> findButton(toolbar, "Alle übergeben (4)").props.onClick();
); expect(selected).toBe(1);
expect(all).toBe(1);
expect(html.match(/class=\"collector-row(?: is-selected)?\"/g)).toHaveLength(2);
expect(html).not.toContain("data-visual-region=\"collector-empty-state\"");
expect(html).toContain("data-visual-region=\"collector-sidebar\"");
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
expect(html).toContain("data-visual-region=\"collector-table-body\"");
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
}); });
it("gives every row checkbox a unique accessible name with its link and collection", () => { it("renders accessible mixed package selection", () => {
const content = CollectorContent({ const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true)} />);
actions: createActions(), expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\"");
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, []) expect(html).toContain("aria-checked=\"mixed\"");
});
const labels: string[] = [];
visitElements(content, (element) => {
if (element.type === "input" && element.props.type === "checkbox") {
labels.push(element.props["aria-label"]);
}
});
expect(labels).toEqual([
"https://example.test/a aus Sammlung A, Zeile 1 auswählen",
"https://example.test/b aus Sammlung A, Zeile 3 auswählen"
]);
expect(new Set(labels).size).toBe(labels.length);
}); });
it("uses a high-contrast table heading token in both themes", () => { it("routes status filters and search independently", () => {
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); let filter = "";
expect(css).toMatch(/\.collector-table-header-row\s*{[^}]*color:\s*var\(--ui-text-secondary\);/s);
});
it("uses the semantic danger text token for the removal action", () => {
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
expect(css).toMatch(/\.collector-action-danger:not\(:disabled\)\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
});
it("disables queue submission only when the active collection has no links", () => {
const emptyActive = CollectorToolbar({
actions: createActions(),
model: buildCollectorViewModel([
{ id: "tab-a", name: "Sammlung A", text: "" },
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
], "tab-a", "", false, [])
});
const filteredActive = CollectorToolbar({
actions: createActions(),
model: buildCollectorViewModel(populatedTabs, "tab-a", "kein-treffer", false, [])
});
expect(findButton(emptyActive, "An Downloads übergeben").props.disabled).toBe(true);
expect(findButton(filteredActive, "An Downloads übergeben").props.disabled).toBe(false);
});
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
let inputOpens = 0;
let queueSubmits = 0;
let query = ""; let query = "";
let selected = ""; const model = buildCollectorWorkspaceViewModel(packages, "online", "", false, [], [], "", true);
let removals = 0; const actions = createActions({ onFilterChange: (value) => { filter = value; }, onQueryChange: (value) => { query = value; } });
const actions = createActions({ const sidebar = CollectorSidebar({ actions, model });
onOpenInput: () => { inputOpens += 1; }, findElement(sidebar, (element) => element.type === "button" && element.props["aria-current"] === "page").props.onClick();
onSubmit: () => { queueSubmits += 1; }, const toolbar = CollectorToolbar({ actions, model });
onQueryChange: (value) => { query = value; }, findElement(toolbar, (element) => element.props.label === "Links durchsuchen").props.onChange({ target: { value: "part02" } });
onSelectionChange: (rowId) => { selected = rowId; }, expect(filter).toBe("online");
onRemoveSelected: () => { removals += 1; } expect(query).toBe("part02");
});
const toolbar = CollectorToolbar({
actions,
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
});
const content = CollectorContent({
actions,
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
});
findButton(toolbar, "Links hinzufügen").props.onClick();
expect(inputOpens).toBe(1);
expect(queueSubmits).toBe(0);
findButton(toolbar, "An Downloads übergeben").props.onClick();
expect(queueSubmits).toBe(1);
const search = findElement(toolbar, (element) => element.props.label === "Links durchsuchen");
search.props.onChange({ target: { value: "release" } });
expect(query).toBe("release");
const checkbox = findElement(content, (element) => element.type === "input" && element.props.type === "checkbox");
checkbox.props.onChange();
findButton(toolbar, "Auswahl entfernen").props.onClick();
expect(selected).toBe("tab-a:0");
expect(removals).toBe(1);
expect(queueSubmits).toBe(1);
}); });
it("names the input dialog and commits only through the local draft callback", () => { it("keeps busy, error and empty states inside the table body", () => {
const actions = createActions();
const empty = renderToStaticMarkup(<CollectorContent actions={actions} model={buildCollectorWorkspaceViewModel([], "all", "", false, [], [], "", true)} />);
const busy = renderToStaticMarkup(<CollectorContent actions={actions} model={buildCollectorWorkspaceViewModel([], "all", "", true, [], [], "", true)} />);
const failed = renderToStaticMarkup(<CollectorContent actions={actions} model={buildCollectorWorkspaceViewModel([], "all", "", false, [], [], "Import fehlgeschlagen", true)} />);
expect(empty).toContain("Noch keine Links");
expect(busy).toContain("Links werden analysiert");
expect(failed).toContain("Import fehlgeschlagen");
});
it("uses an analysis dialog instead of a raw tab editor", () => {
let value = ""; let value = "";
let commits = 0; let commits = 0;
const dialog = CollectorInputDialog({ const dialog = CollectorInputDialog({ open: true, value, onChange: (next) => { value = next; }, onClose: () => {}, onCommit: () => { commits += 1; } });
open: true,
tabName: "Sammlung A",
value,
onChange: (next) => { value = next; },
onClose: () => {},
onCommit: () => { commits += 1; }
});
const html = renderToStaticMarkup(dialog); const html = renderToStaticMarkup(dialog);
expect(html).toContain("Links werden geprüft und automatisch zu Downloadpaketen gruppiert.");
expect(html).toContain("role=\"dialog\""); expect(html).toContain("Analysieren");
expect(html).toContain("aria-label=\"Links\""); findElement(dialog, (element) => element.type === "textarea").props.onChange({ target: { value: "https://1fichier.com/?abc" } });
expect(html).toContain("Links hinzufügen"); findButton(dialog, "Analysieren").props.onClick();
expect(html).toContain("Übernehmen"); expect(value).toBe("https://1fichier.com/?abc");
const textbox = findElement(dialog, (element) => element.type === "textarea" && element.props["aria-label"] === "Links");
textbox.props.onChange({ target: { value: "https://example.test/new" } });
findButton(dialog, "Übernehmen").props.onClick();
expect(value).toBe("https://example.test/new");
expect(commits).toBe(1); expect(commits).toBe(1);
}); });
it("moves the search field onto a separate compact row instead of overlapping actions", () => { it("uses aligned responsive package grids and content visibility", () => {
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
expect(css).toMatch(/\.collector-table-header-row, \.collector-package-row, \.collector-file-row\s*\{[^}]*grid-template-columns:/s);
expect(css).toMatch(/\.collector-package-group\s*\{[^}]*content-visibility:\s*auto;/s);
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s); expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s);
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar \.ui-toolbar-search\s*\{[^}]*flex:\s*1 0 100%;[^}]*width:\s*100%;/s); });
it("uses one consistent gap across collector toolbar groups", () => {
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
const toolbarGap = css.match(/\.collector-toolbar\s*\{[^}]*gap:\s*([^;]+);/s)?.[1]?.trim();
const groupGap = css.match(/\.collector-toolbar \.ui-toolbar-group\s*\{[^}]*gap:\s*([^;]+);/s)?.[1]?.trim();
expect(toolbarGap).toBeTruthy();
expect(groupGap).toBe(toolbarGap);
}); });
}); });
+1 -1
View File
@@ -630,7 +630,7 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => {
expect(hosters).toEqual(["1fichier", "1fichier", "1fichier", "1fichier"]); expect(hosters).toEqual(["1fichier", "1fichier", "1fichier", "1fichier"]);
expect(new Set(hosters).size).toBe(1); expect(new Set(hosters).size).toBe(1);
expect(formatHosterLabel(hosters[1])).toEqual({ compact: "1F", title: "1Fichier" }); expect(formatHosterLabel(hosters[1])).toEqual({ compact: "1Fichier", title: "1Fichier", iconSrc: "./provider-icons/onefichier.png" });
}); });
it("removes duplicated access-mode wording from service labels", () => { it("removes duplicated access-mode wording from service labels", () => {
+5
View File
@@ -63,6 +63,11 @@ describe("renderer localization", () => {
expect(translateUiText("Einträge: 2", "en")).toBe("Entries: 2"); expect(translateUiText("Einträge: 2", "en")).toBe("Entries: 2");
expect(translateUiText("Sichtbar: 2", "en")).toBe("Visible: 2"); expect(translateUiText("Sichtbar: 2", "en")).toBe("Visible: 2");
expect(translateUiText("Ausgewählt: 0", "en")).toBe("Selected: 0"); expect(translateUiText("Ausgewählt: 0", "en")).toBe("Selected: 0");
expect(translateUiText("Auswahl übergeben (3)", "en")).toBe("Send selection (3)");
expect(translateUiText("Alle übergeben (16)", "en")).toBe("Send all (16)");
expect(translateUiText("Paket SBS14HD auswählen", "en")).toBe("Select package SBS14HD");
expect(translateUiText("16 Dateien", "en")).toBe("16 files");
expect(translateUiText("15/16 geprüft", "en")).toBe("15/16 checked");
expect(translateUiText("2 pro Seite", "en")).toBe("2 per page"); expect(translateUiText("2 pro Seite", "en")).toBe("2 per page");
expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: "); expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: ");
expect(translateUiText(" pro Seite", "en")).toBe(" per page"); expect(translateUiText(" pro Seite", "en")).toBe(" per page");
+22
View File
@@ -3,6 +3,7 @@ import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { assertTrustedIpcSender } from "../src/main/ipc-security"; import { assertTrustedIpcSender } from "../src/main/ipc-security";
import { validateRealDebridLoginRequest } from "../src/shared/preload-api"; import { validateRealDebridLoginRequest } from "../src/shared/preload-api";
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../src/shared/collector";
function eventFor(url: string) { function eventFor(url: string) {
return { return {
@@ -25,6 +26,27 @@ describe("ipc-security", () => {
expect(() => validateRealDebridLoginRequest({ accountId: "rdw_existing", dailyLimitBytes: 1 })).toThrow(/Account-Payload/i); expect(() => validateRealDebridLoginRequest({ accountId: "rdw_existing", dailyLimitBytes: 1 })).toThrow(/Account-Payload/i);
}); });
it("accepts only bounded collector inspection payloads", () => {
expect(validateCollectorInspectionRequest({ rawText: "https://1fichier.com/?abc12345", addedAt: 1234 })).toEqual({
rawText: "https://1fichier.com/?abc12345",
addedAt: 1234
});
expect(() => validateCollectorInspectionRequest({ rawText: "x", addedAt: 1, token: "secret" })).toThrow(/Linksammler-Payload/i);
expect(() => validateCollectorInspectionRequest({ rawText: "x".repeat(2_000_001), addedAt: 1 })).toThrow(/Linksammler-Payload/i);
expect(() => validateCollectorInspectionRequest({ rawText: "x", addedAt: -1 })).toThrow(/Linksammler-Payload/i);
expect(() => validateCollectorInspectionRequest({ rawText: "x", addedAt: Number.MAX_SAFE_INTEGER + 1 })).toThrow(/Linksammler-Payload/i);
});
it("accepts only bounded absolute collector container paths", () => {
expect(validateCollectorContainerInspectionRequest(["C:\\Imports\\one.dlc", "D:\\two.dlc"], 1234)).toEqual({
filePaths: ["C:\\Imports\\one.dlc", "D:\\two.dlc"],
addedAt: 1234
});
expect(() => validateCollectorContainerInspectionRequest(["relative.dlc"], 1)).toThrow(/Container-Payload/i);
expect(() => validateCollectorContainerInspectionRequest(["C:\\Imports\\one.txt"], 1)).toThrow(/Container-Payload/i);
expect(() => validateCollectorContainerInspectionRequest(Array.from({ length: 101 }, (_unused, index) => `C:\\${index}.dlc`), 1)).toThrow(/Container-Payload/i);
});
it("accepts IPC from the configured Vite development renderer origin", () => { it("accepts IPC from the configured Vite development renderer origin", () => {
expect(() => assertTrustedIpcSender(eventFor("http://localhost:5180/settings"), { expect(() => assertTrustedIpcSender(eventFor("http://localhost:5180/settings"), {
isPackaged: false, isPackaged: false,
+1
View File
@@ -75,6 +75,7 @@ describe("global Escape selection routing", () => {
expect(api.resolveEscapeSelectionScope?.("settings", "accounts", "INPUT", "text")).toBeNull(); expect(api.resolveEscapeSelectionScope?.("settings", "accounts", "INPUT", "text")).toBeNull();
expect(api.resolveEscapeSelectionScope?.("downloads", "allgemein", "DIV")).toBe("downloads"); expect(api.resolveEscapeSelectionScope?.("downloads", "allgemein", "DIV")).toBe("downloads");
expect(api.resolveEscapeSelectionScope?.("history", "accounts", "DIV")).toBe("history"); expect(api.resolveEscapeSelectionScope?.("history", "accounts", "DIV")).toBe("history");
expect(api.resolveEscapeSelectionScope?.("collector", "accounts", "DIV")).toBe("collector");
}); });
it("releases the focused account row when Escape leaves account selection", () => { it("releases the focused account row when Escape leaves account selection", () => {
+4 -4
View File
@@ -30,7 +30,7 @@
"interactions": [ "interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" }, { "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" }, { "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" } { "type": "click", "role": "button", "name": "Analysieren" }
], ],
"assertions": [ "assertions": [
{ "type": "active-view", "value": "collector" }, { "type": "active-view", "value": "collector" },
@@ -228,7 +228,7 @@
"interactions": [ "interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" }, { "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" }, { "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" } { "type": "click", "role": "button", "name": "Analysieren" }
], ],
"assertions": [ "assertions": [
{ "type": "active-view", "value": "collector" }, { "type": "active-view", "value": "collector" },
@@ -284,7 +284,7 @@
"interactions": [ "interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" }, { "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" }, { "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" } { "type": "click", "role": "button", "name": "Analysieren" }
], ],
"assertions": [ "assertions": [
{ "type": "active-view", "value": "collector" }, { "type": "active-view", "value": "collector" },
@@ -300,7 +300,7 @@
"interactions": [ "interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" }, { "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" }, { "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" } { "type": "click", "role": "button", "name": "Analysieren" }
], ],
"assertions": [ "assertions": [
{ "type": "active-view", "value": "collector" }, { "type": "active-view", "value": "collector" },
+26
View File
@@ -9,6 +9,7 @@ import type {
import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts";
import { createRendererState } from "../../src/main/renderer-state"; import { createRendererState } from "../../src/main/renderer-state";
import type { CollectorInspectionResult } from "../../src/shared/collector";
export const VISUAL_SCENARIOS = ["empty", "dense", "update"] as const; export const VISUAL_SCENARIOS = ["empty", "dense", "update"] as const;
@@ -16,12 +17,35 @@ export type VisualScenario = (typeof VISUAL_SCENARIOS)[number];
export interface VisualFixture { export interface VisualFixture {
snapshot: UiSnapshot; snapshot: UiSnapshot;
collector: CollectorInspectionResult;
history: HistoryEntry[]; history: HistoryEntry[];
update: UpdateCheckResult; update: UpdateCheckResult;
traceConfig: SupportTraceConfig; traceConfig: SupportTraceConfig;
remoteDiagnostics: RemoteDiagnosticsInfo; remoteDiagnostics: RemoteDiagnosticsInfo;
} }
function createCollectorInspection(): CollectorInspectionResult {
return {
invalidCount: 0,
duplicateCount: 0,
packages: [{
id: "visual-collector-package-sbs14hd",
name: "SBS14HD",
addedAt: VISUAL_NOW_MS,
links: Array.from({ length: 16 }, (_unused, index) => ({
id: `visual-collector-link-${index + 1}`,
url: `https://1fichier.com/?visual${String(index + 1).padStart(2, "0")}`,
fileName: `SBS14HD.part${String(index + 1).padStart(2, "0")}.rar`,
fileSizeBytes: index === 15 ? 373_517_856 : 471_859_200,
hoster: "1fichier",
availability: "online" as const,
status: "ready" as const,
addedAt: VISUAL_NOW_MS
}))
}]
};
}
export interface VisualClockTarget { export interface VisualClockTarget {
setInterval: (handler: TimerHandler, timeout?: number, ...arguments_: unknown[]) => number; setInterval: (handler: TimerHandler, timeout?: number, ...arguments_: unknown[]) => number;
} }
@@ -634,6 +658,7 @@ export function createVisualFixture(scenario: VisualScenario): VisualFixture {
if (scenario === "empty") { if (scenario === "empty") {
return { return {
snapshot: createEmptySnapshot(), snapshot: createEmptySnapshot(),
collector: createCollectorInspection(),
history: [], history: [],
update: createUpdate(false), update: createUpdate(false),
traceConfig: createTraceConfig(), traceConfig: createTraceConfig(),
@@ -643,6 +668,7 @@ export function createVisualFixture(scenario: VisualScenario): VisualFixture {
return { return {
snapshot: createDenseSnapshot(), snapshot: createDenseSnapshot(),
collector: createCollectorInspection(),
history: createDenseHistory(), history: createDenseHistory(),
update: createUpdate(scenario === "update"), update: createUpdate(scenario === "update"),
traceConfig: createTraceConfig(), traceConfig: createTraceConfig(),
+2
View File
@@ -59,6 +59,8 @@ export function createVisualElectronApi(
deleteAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), deleteAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),
addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }), addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }),
addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }), addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }),
inspectCollectorText: async () => clone(fixture.collector),
inspectCollectorContainers: async () => clone(fixture.collector),
getStartConflicts: async () => [], getStartConflicts: async () => [],
resolveStartConflict: async (_packageId, policy) => ({ resolveStartConflict: async (_packageId, policy) => ({
skipped: policy === "skip", skipped: policy === "skip",