Restore package-based link collector without blocking DLC drops
Reintroduce expandable collector packages, background metadata enrichment, filters, selection, and controlled queue transfer. Keep DLC files dropped outside the collector on the direct addContainers path without settings persistence or metadata waits. Protect collector state with stable URL identities, non-degrading metadata merges, and per-URL generations so stale enrichment responses cannot overwrite newer data or resurrect removed links.
This commit is contained in:
@@ -45,6 +45,12 @@ 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 { enrichCollectorPackages, prepareCollectorContainers, prepareCollectorText } from "./collector-inspection";
|
||||||
|
import type {
|
||||||
|
CollectorEnrichmentRequest,
|
||||||
|
CollectorInspectionResult,
|
||||||
|
CollectorTextPreparationRequest
|
||||||
|
} 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";
|
||||||
@@ -987,6 +993,18 @@ export class AppController {
|
|||||||
return { ...result, invalidCount: 0 };
|
return { ...result, invalidCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public prepareCollectorText(request: CollectorTextPreparationRequest): CollectorInspectionResult {
|
||||||
|
return prepareCollectorText(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepareCollectorContainers(filePaths: string[], addedAt: number): Promise<CollectorInspectionResult> {
|
||||||
|
return prepareCollectorContainers(filePaths, addedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enrichCollectorPackages(request: CollectorEnrichmentRequest): Promise<CollectorInspectionResult> {
|
||||||
|
return enrichCollectorPackages(request, this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
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) => ({
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
CollectorEnrichmentRequest,
|
||||||
|
CollectorInspectionResult,
|
||||||
|
CollectorLink,
|
||||||
|
CollectorPackage,
|
||||||
|
CollectorTextPreparationRequest
|
||||||
|
} from "../shared/collector";
|
||||||
|
import { serializeCollectorPackages } from "../shared/collector";
|
||||||
|
import { extractHosterFromUrl } from "../shared/hoster";
|
||||||
|
import type { AppSettings, ParsedPackageInput } from "../shared/types";
|
||||||
|
import {
|
||||||
|
checkDdownloadOnline,
|
||||||
|
checkOneFichierLinks,
|
||||||
|
checkRapidgatorOnline,
|
||||||
|
DebridService,
|
||||||
|
isDdownloadLink,
|
||||||
|
isOneFichierLink,
|
||||||
|
type OneFichierCheckResult
|
||||||
|
} from "./debrid";
|
||||||
|
import { importDlcContainers } from "./container";
|
||||||
|
import { parseCollectorInput } from "./link-parser";
|
||||||
|
import { filenameFromUrl, isHttpLink, looksLikeOpaqueFilename, sanitizeFilename } from "./utils";
|
||||||
|
|
||||||
|
export interface CollectorInspectionDependencies {
|
||||||
|
checkDdownload?: typeof checkDdownloadOnline;
|
||||||
|
checkOneFichier?: (links: string[]) => Promise<Map<string, OneFichierCheckResult>>;
|
||||||
|
checkRapidgator?: typeof checkRapidgatorOnline;
|
||||||
|
resolveFilenames?: (links: string[]) => Promise<Map<string, string>>;
|
||||||
|
importContainers?: typeof importDlcContainers;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedSourceLink {
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
explicitFileName: boolean;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableId(prefix: "package" | "link", value: string): string {
|
||||||
|
return `${prefix}-${crypto.createHash("sha256").update(value).digest("hex").slice(0, 24)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 packageId(links: CollectorLink[]): string {
|
||||||
|
return stableId("package", links.map((link) => link.url).sort().join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparePackages(packages: ParsedPackageInput[], addedAt: number, nameSource: CollectorPackage["nameSource"]): CollectorPackage[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const prepared: CollectorPackage[] = [];
|
||||||
|
for (const pkg of packages) {
|
||||||
|
const links: CollectorLink[] = [];
|
||||||
|
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 explicitFileName = sanitizeFilename(String(pkg.fileNames?.[index] || "").trim());
|
||||||
|
const fileName = explicitFileName || filenameFromUrl(url);
|
||||||
|
links.push({
|
||||||
|
id: stableId("link", url),
|
||||||
|
url,
|
||||||
|
fileName,
|
||||||
|
fileSizeBytes: null,
|
||||||
|
hoster: extractHosterFromUrl(url),
|
||||||
|
availability: "unknown",
|
||||||
|
status: explicitFileName || (fileName && !looksLikeOpaqueFilename(fileName)) ? "ready" : "unknown",
|
||||||
|
addedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (links.length === 0) continue;
|
||||||
|
const name = sanitizeFilename(pkg.name || inferCollectorPackageName(links[0].fileName, links[0].hoster));
|
||||||
|
prepared.push({ id: packageId(links), name, nameSource, links, addedAt });
|
||||||
|
}
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareCollectorText(request: CollectorTextPreparationRequest): CollectorInspectionResult {
|
||||||
|
const parsed = parseCollectorInput(request.rawText, "");
|
||||||
|
const nameSource = /^#\s*package\s*:/im.test(request.rawText) ? "explicit" : "inferred";
|
||||||
|
return {
|
||||||
|
packages: preparePackages(parsed, request.addedAt, nameSource),
|
||||||
|
...countInputLines(request.rawText)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareCollectorContainers(
|
||||||
|
filePaths: string[],
|
||||||
|
addedAt: number,
|
||||||
|
dependencies: Pick<CollectorInspectionDependencies, "importContainers"> = {}
|
||||||
|
): Promise<CollectorInspectionResult> {
|
||||||
|
const importContainers = dependencies.importContainers ?? importDlcContainers;
|
||||||
|
const packages = await importContainers(filePaths);
|
||||||
|
return { packages: preparePackages(packages, addedAt, "explicit"), invalidCount: 0, duplicateCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 regroupEnrichedPackages(packages: CollectorPackage[]): CollectorPackage[] {
|
||||||
|
const explicit = packages.filter((pkg) => pkg.nameSource === "explicit").map((pkg) => ({
|
||||||
|
...pkg,
|
||||||
|
links: pkg.links.map((link) => ({ ...link }))
|
||||||
|
}));
|
||||||
|
const groups = new Map<string, CollectorLink[]>();
|
||||||
|
for (const link of packages.filter((pkg) => pkg.nameSource === "inferred").flatMap((pkg) => pkg.links)) {
|
||||||
|
const name = inferCollectorPackageName(link.fileName, link.hoster);
|
||||||
|
const key = name.toLocaleLowerCase("de");
|
||||||
|
const links = groups.get(key) ?? [];
|
||||||
|
links.push({ ...link });
|
||||||
|
groups.set(key, links);
|
||||||
|
}
|
||||||
|
const inferred = Array.from(groups.entries()).map(([key, links]) => {
|
||||||
|
const name = inferCollectorPackageName(links[0].fileName, links[0].hoster);
|
||||||
|
return {
|
||||||
|
id: packageId(links),
|
||||||
|
name: name || key,
|
||||||
|
nameSource: "inferred" as const,
|
||||||
|
links,
|
||||||
|
addedAt: Math.min(...links.map((link) => link.addedAt))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [...explicit, ...inferred];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichCollectorPackages(
|
||||||
|
request: CollectorEnrichmentRequest,
|
||||||
|
settings: AppSettings,
|
||||||
|
dependencies: CollectorInspectionDependencies = {}
|
||||||
|
): Promise<CollectorInspectionResult> {
|
||||||
|
const packages = request.packages.map((pkg) => ({ ...pkg, links: pkg.links.map((link) => ({ ...link })) }));
|
||||||
|
const linksByUrl = new Map(packages.flatMap((pkg) => pkg.links).map((link) => [link.url, link]));
|
||||||
|
const urls = [...linksByUrl.keys()];
|
||||||
|
const oneFichierLinks = urls.filter(isOneFichierLink);
|
||||||
|
const rapidgatorLinks = urls.filter((url) => extractHosterFromUrl(url) === "rapidgator");
|
||||||
|
const ddownloadLinks = urls.filter(isDdownloadLink);
|
||||||
|
const genericLinks = urls.filter((url) => {
|
||||||
|
const link = linksByUrl.get(url);
|
||||||
|
return Boolean(link && looksLikeOpaqueFilename(link.fileName)
|
||||||
|
&& !oneFichierLinks.includes(url)
|
||||||
|
&& !rapidgatorLinks.includes(url)
|
||||||
|
&& !ddownloadLinks.includes(url));
|
||||||
|
});
|
||||||
|
const checkOneFichier = dependencies.checkOneFichier ?? checkOneFichierLinks;
|
||||||
|
const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline;
|
||||||
|
const checkDdownload = dependencies.checkDdownload ?? checkDdownloadOnline;
|
||||||
|
const resolveFilenames = dependencies.resolveFilenames ?? ((links) => new DebridService(settings).resolveFilenames(links));
|
||||||
|
const oneFichierPromise = oneFichierLinks.length > 0
|
||||||
|
? checkOneFichier(oneFichierLinks).catch(() => new Map<string, OneFichierCheckResult>())
|
||||||
|
: Promise.resolve(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 ddownloadPromise = runWithConcurrency(ddownloadLinks, 4, async (url) => {
|
||||||
|
const result = await checkDdownload(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 = genericLinks.length > 0
|
||||||
|
? resolveFilenames(genericLinks).catch(() => new Map<string, string>())
|
||||||
|
: Promise.resolve(new Map<string, string>());
|
||||||
|
const [oneFichierResults, genericResults] = await Promise.all([
|
||||||
|
oneFichierPromise,
|
||||||
|
genericPromise,
|
||||||
|
rapidgatorPromise,
|
||||||
|
ddownloadPromise
|
||||||
|
]).then(([oneFichier, generic]) => [oneFichier, 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: regroupEnrichedPackages(packages), invalidCount: 0, duplicateCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { serializeCollectorPackages };
|
||||||
@@ -26,6 +26,11 @@ import { migrateProductUserDataDirectory } from "./storage";
|
|||||||
import { forceDarkNativeTheme } from "./native-theme";
|
import { forceDarkNativeTheme } from "./native-theme";
|
||||||
import { validateClipboardWriteText } from "./clipboard-write";
|
import { validateClipboardWriteText } from "./clipboard-write";
|
||||||
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
|
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
|
||||||
|
import {
|
||||||
|
validateCollectorContainerPreparationRequest,
|
||||||
|
validateCollectorEnrichmentRequest,
|
||||||
|
validateCollectorTextPreparationRequest
|
||||||
|
} from "../shared/collector";
|
||||||
|
|
||||||
forceDarkNativeTheme(nativeTheme);
|
forceDarkNativeTheme(nativeTheme);
|
||||||
|
|
||||||
@@ -557,6 +562,16 @@ 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.PREPARE_COLLECTOR_TEXT, (_event: IpcMainInvokeEvent, value: unknown) => {
|
||||||
|
return controller.prepareCollectorText(validateCollectorTextPreparationRequest(value));
|
||||||
|
});
|
||||||
|
handleTrusted(IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, (_event: IpcMainInvokeEvent, filePaths: unknown, addedAt: unknown) => {
|
||||||
|
const request = validateCollectorContainerPreparationRequest({ filePaths, addedAt });
|
||||||
|
return controller.prepareCollectorContainers(request.filePaths, request.addedAt);
|
||||||
|
});
|
||||||
|
handleTrusted(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, (_event: IpcMainInvokeEvent, value: unknown) => {
|
||||||
|
return controller.enrichCollectorPackages(validateCollectorEnrichmentRequest(value));
|
||||||
|
});
|
||||||
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");
|
||||||
|
|||||||
+13
-1
@@ -1,4 +1,4 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AccountCheckScope,
|
AccountCheckScope,
|
||||||
@@ -31,6 +31,11 @@ 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 {
|
||||||
|
CollectorEnrichmentRequest,
|
||||||
|
CollectorInspectionResult,
|
||||||
|
CollectorTextPreparationRequest
|
||||||
|
} 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 +56,13 @@ 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),
|
||||||
|
prepareCollectorText: (request: CollectorTextPreparationRequest): Promise<CollectorInspectionResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, request),
|
||||||
|
prepareCollectorContainers: (filePaths: string[], addedAt: number): Promise<CollectorInspectionResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, filePaths, addedAt),
|
||||||
|
enrichCollectorPackages: (request: CollectorEnrichmentRequest): Promise<CollectorInspectionResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, request),
|
||||||
|
getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file),
|
||||||
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),
|
||||||
|
|||||||
+235
-280
@@ -61,9 +61,17 @@ 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 { LinkAddressesDialog } from "./ui/LinkAddressesDialog";
|
import { LinkAddressesDialog } from "./ui/LinkAddressesDialog";
|
||||||
|
import { serializeCollectorPackages, type CollectorInspectionResult, type CollectorPackage } from "../shared/collector";
|
||||||
|
import { routeDroppedDlcFiles } from "./collector-drop";
|
||||||
|
import { beginCollectorEnrichment, filterCurrentCollectorEnrichment } from "./collector-enrichment";
|
||||||
import {
|
import {
|
||||||
buildCollectorViewModel,
|
buildCollectorTransferPackages,
|
||||||
type CollectorSourceTab
|
buildCollectorWorkspaceViewModel,
|
||||||
|
mergeCollectorEnrichment,
|
||||||
|
mergeCollectorPackages,
|
||||||
|
removeCollectorLinks,
|
||||||
|
selectCollectorPackageLinks,
|
||||||
|
type CollectorWorkspaceFilter
|
||||||
} from "./views/collector/collector-model";
|
} from "./views/collector/collector-model";
|
||||||
import {
|
import {
|
||||||
CollectorContent,
|
CollectorContent,
|
||||||
@@ -141,64 +149,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;
|
||||||
@@ -1434,8 +1387,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)}`;
|
||||||
}
|
}
|
||||||
@@ -1735,16 +1686,17 @@ 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 [collectorAnalyzingCount, setCollectorAnalyzingCount] = useState(0);
|
||||||
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 collectorEnrichmentGenerationsRef = useRef(new Map<string, number>());
|
||||||
|
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
|
||||||
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[]>([]);
|
||||||
@@ -1864,14 +1816,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,
|
collectorAnalyzingCount > 0,
|
||||||
[...selectedCollectorRowIds],
|
[...selectedCollectorLinkIds],
|
||||||
collectorError
|
[...collapsedCollectorPackageIds],
|
||||||
), [actionBusy, activeCollectorTab, collectorError, collectorQuery, collectorTabs, selectedCollectorRowIds]);
|
collectorError,
|
||||||
|
snapshot.settings.animatePackageDisclosure
|
||||||
|
), [collapsedCollectorPackageIds, collectorAnalyzingCount, collectorError, collectorFilter, collectorPackages, collectorQuery, selectedCollectorLinkIds, snapshot.settings.animatePackageDisclosure]);
|
||||||
|
|
||||||
const historyViewModel = useMemo(() => buildHistoryViewModel(
|
const historyViewModel = useMemo(() => buildHistoryViewModel(
|
||||||
historyEntries,
|
historyEntries,
|
||||||
@@ -1890,13 +1844,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;
|
||||||
@@ -2238,12 +2186,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 importCollectorTextRef.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) {
|
||||||
@@ -3626,47 +3569,142 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddLinks = async (): Promise<void> => {
|
const mergeCollectorResult = (result: CollectorInspectionResult, enrichment = false): void => {
|
||||||
setCollectorError("");
|
setCollectorPackages((current) => {
|
||||||
await performQuickAction(async () => {
|
const merged = enrichment
|
||||||
const activeId = activeCollectorTabRef.current;
|
? mergeCollectorEnrichment(current, result.packages)
|
||||||
const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0];
|
: mergeCollectorPackages(current, result.packages);
|
||||||
const rawText = active?.text ?? "";
|
collectorPackagesRef.current = merged.packages;
|
||||||
const persisted = await persistDraftSettings();
|
return merged.packages;
|
||||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
|
||||||
const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName });
|
|
||||||
if (result.addedLinks > 0) {
|
|
||||||
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
|
|
||||||
setCollectorTabs((prev) => planCollectorTextReplacement(prev, activeId, "").tabs);
|
|
||||||
setSelectedCollectorRowIds(new Set());
|
|
||||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
|
||||||
} else {
|
|
||||||
showToast("Keine gültigen Links gefunden");
|
|
||||||
}
|
|
||||||
}, (error) => {
|
|
||||||
setCollectorError(`Fehler beim Hinzufügen: ${String(error)}`);
|
|
||||||
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onImportDlc = async (): Promise<void> => {
|
const enrichCollectorResult = (packages: CollectorPackage[]): void => {
|
||||||
|
if (packages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const generations = beginCollectorEnrichment(packages, collectorEnrichmentGenerationsRef.current);
|
||||||
|
setCollectorAnalyzingCount((current) => current + 1);
|
||||||
|
void window.rd.enrichCollectorPackages({ packages }).then((result) => {
|
||||||
|
mergeCollectorResult({
|
||||||
|
...result,
|
||||||
|
packages: filterCurrentCollectorEnrichment(
|
||||||
|
result.packages,
|
||||||
|
generations,
|
||||||
|
collectorEnrichmentGenerationsRef.current
|
||||||
|
)
|
||||||
|
}, true);
|
||||||
|
}).catch((error) => {
|
||||||
|
if (filterCurrentCollectorEnrichment(packages, generations, collectorEnrichmentGenerationsRef.current).length > 0) {
|
||||||
|
setCollectorError(`Metadatenprüfung fehlgeschlagen: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
setCollectorAnalyzingCount((current) => Math.max(0, current - 1));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const importCollectorText = async (rawText: string): Promise<void> => {
|
||||||
|
if (!rawText.trim()) {
|
||||||
|
showToast("Keine Links eingegeben", 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCollectorError("");
|
setCollectorError("");
|
||||||
await performQuickAction(async () => {
|
setCollectorAnalyzingCount((current) => current + 1);
|
||||||
|
try {
|
||||||
|
const prepared = await window.rd.prepareCollectorText({ rawText, addedAt: Date.now() });
|
||||||
|
if (prepared.packages.length === 0) {
|
||||||
|
const message = "Keine gültigen Links gefunden";
|
||||||
|
setCollectorError(message);
|
||||||
|
showToast(message, 2600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mergeCollectorResult(prepared);
|
||||||
|
setCollectorFilter("all");
|
||||||
|
setTab("collector");
|
||||||
|
const linkCount = prepared.packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||||
|
showToast(`${prepared.packages.length} Paket(e), ${linkCount} Link(s) gesammelt`);
|
||||||
|
enrichCollectorResult(prepared.packages);
|
||||||
|
} catch (error) {
|
||||||
|
const message = `Links konnten nicht vorbereitet werden: ${String(error)}`;
|
||||||
|
setCollectorError(message);
|
||||||
|
showToast(message, 2800);
|
||||||
|
} finally {
|
||||||
|
setCollectorAnalyzingCount((current) => Math.max(0, current - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
importCollectorTextRef.current = importCollectorText;
|
||||||
|
|
||||||
|
const importCollectorContainers = async (filePaths: string[]): Promise<CollectorInspectionResult | null> => {
|
||||||
|
if (filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setCollectorError("");
|
||||||
|
setCollectorAnalyzingCount((current) => current + 1);
|
||||||
|
try {
|
||||||
|
const prepared = await window.rd.prepareCollectorContainers(filePaths, Date.now());
|
||||||
|
if (prepared.packages.length === 0) {
|
||||||
|
const message = "Keine gültigen Links in den DLC-Dateien gefunden";
|
||||||
|
setCollectorError(message);
|
||||||
|
showToast(message, 3000);
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
|
mergeCollectorResult(prepared);
|
||||||
|
setCollectorFilter("all");
|
||||||
|
setTab("collector");
|
||||||
|
const linkCount = prepared.packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||||
|
showToast(`DLC gesammelt: ${prepared.packages.length} Paket(e), ${linkCount} Link(s)`);
|
||||||
|
enrichCollectorResult(prepared.packages);
|
||||||
|
return prepared;
|
||||||
|
} catch (error) {
|
||||||
|
const message = `Fehler beim DLC-Import: ${String(error)}`;
|
||||||
|
setCollectorError(message);
|
||||||
|
showToast(message, 2800);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setCollectorAnalyzingCount((current) => Math.max(0, current - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImportDlc = async (): Promise<void> => {
|
||||||
const files = await window.rd.pickContainers();
|
const files = await window.rd.pickContainers();
|
||||||
if (files.length === 0) { return; }
|
if (files.length > 0) {
|
||||||
await persistDraftSettings();
|
await importCollectorContainers(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCollectorPackages = 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 linkIds = new Set(transferable.flatMap((pkg) => pkg.links.map((link) => link.id)));
|
||||||
|
if (linkIds.size === 0) {
|
||||||
|
showToast("Keine übertragbaren Links ausgewählt", 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await performQuickAction(async () => {
|
||||||
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.addContainers(files);
|
const result = await window.rd.addLinks({ rawText: serializeCollectorPackages(transferable), packageName: "" });
|
||||||
if (result.addedLinks > 0) {
|
if (result.addedLinks !== linkIds.size) {
|
||||||
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
showToast(`${result.addedLinks} von ${linkIds.size} Link(s) übergeben; Sammlung bleibt erhalten`, 3200);
|
||||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
return;
|
||||||
} else {
|
}
|
||||||
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
|
setCollectorPackages((current) => {
|
||||||
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
const next = removeCollectorLinks(current, linkIds);
|
||||||
|
collectorPackagesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedCollectorLinkIds((current) => new Set([...current].filter((id) => !linkIds.has(id))));
|
||||||
|
setTab("downloads");
|
||||||
|
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) übergeben`);
|
||||||
|
if (snapshotRef.current.settings.collapseNewPackages) {
|
||||||
|
await collapseNewPackages(existingIds);
|
||||||
}
|
}
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
setCollectorError(`Fehler beim DLC-Import: ${String(error)}`);
|
const message = `Übergabe fehlgeschlagen: ${String(error)}`;
|
||||||
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
|
setCollectorError(message);
|
||||||
|
showToast(message, 2800);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3705,56 +3743,38 @@ export function App(): ReactElement {
|
|||||||
const hasUri = event.dataTransfer.types.includes("text/uri-list");
|
const hasUri = event.dataTransfer.types.includes("text/uri-list");
|
||||||
if (!hasFiles && !hasUri) { return; }
|
if (!hasFiles && !hasUri) { return; }
|
||||||
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
||||||
const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v);
|
const hasDlc = files.some((file) => file.name.toLowerCase().endsWith(".dlc"));
|
||||||
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 (hasDlc) {
|
||||||
setCollectorError("");
|
try {
|
||||||
await performQuickAction(async () => {
|
const mode = tabRef.current === "collector" ? "collector" : "downloads";
|
||||||
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.addContainers(dlc);
|
const routed = await routeDroppedDlcFiles(files, mode, window.rd.getPathForDroppedFile, {
|
||||||
if (result.addedLinks > 0) {
|
addContainers: window.rd.addContainers,
|
||||||
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
inspectContainers: (filePaths) => importCollectorContainers(filePaths)
|
||||||
|
});
|
||||||
|
if (routed.kind === "empty") {
|
||||||
|
showToast("DLC-Dateipfad konnte nicht gelesen werden", 2800);
|
||||||
|
} else if (routed.kind === "downloads" && routed.result.addedLinks > 0) {
|
||||||
|
setTab("downloads");
|
||||||
|
showToast(`Drag-and-Drop: ${routed.result.addedPackages} Paket(e), ${routed.result.addedLinks} Link(s)`);
|
||||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
||||||
} else {
|
} else if (routed.kind === "downloads") {
|
||||||
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
|
|
||||||
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
||||||
}
|
}
|
||||||
}, (error) => {
|
} catch (error) {
|
||||||
setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`);
|
|
||||||
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
||||||
});
|
}
|
||||||
} else if (importFiles.length > 0) {
|
} else if (importFiles.length > 0) {
|
||||||
setCollectorError("");
|
try {
|
||||||
await performQuickAction(async () => {
|
const text = (await Promise.all(importFiles.map((file) => file.text()))).join("\n");
|
||||||
await persistDraftSettings();
|
await importCollectorText(text);
|
||||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
} catch (error) {
|
||||||
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);
|
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
||||||
});
|
}
|
||||||
} else if (droppedText.trim()) {
|
} else if (droppedText.trim()) {
|
||||||
const activeCollectorId = activeCollectorTabRef.current;
|
await importCollectorText(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");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3802,22 +3822,12 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
releasePickerBusy();
|
releasePickerBusy();
|
||||||
await performQuickAction(async () => {
|
try {
|
||||||
await persistDraftSettings();
|
|
||||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const result = await window.rd.importQueue(text);
|
await importCollectorText(text);
|
||||||
if (result.addedLinks > 0) {
|
} catch (error) {
|
||||||
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);
|
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
clearImportQueueFocusListener();
|
clearImportQueueFocusListener();
|
||||||
@@ -3922,112 +3932,57 @@ 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 input = collectorInput;
|
const draft = collectorInput.draft;
|
||||||
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 importCollectorText(draft);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCollectorRowSelection = (rowId: string): void => {
|
const setCollectorLinkSelection = (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 removeSelectedCollectorRows = (): void => {
|
const setCollectorPackageSelection = (packageId: string, selected: boolean): void => {
|
||||||
if (selectedCollectorRowIds.size === 0) {
|
const pkg = collectorPackagesRef.current.find((entry) => entry.id === packageId);
|
||||||
return;
|
if (pkg) {
|
||||||
|
setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, pkg, selected));
|
||||||
}
|
}
|
||||||
const activeId = activeCollectorTabRef.current;
|
};
|
||||||
const indexes = new Set<number>();
|
|
||||||
for (const rowId of selectedCollectorRowIds) {
|
const toggleCollectorPackageCollapse = (packageId: string): void => {
|
||||||
const separator = rowId.lastIndexOf(":");
|
setCollapsedCollectorPackageIds((current) => {
|
||||||
if (separator <= 0 || rowId.slice(0, separator) !== activeId) {
|
const next = new Set(current);
|
||||||
continue;
|
if (next.has(packageId)) next.delete(packageId);
|
||||||
}
|
else next.add(packageId);
|
||||||
const index = Number(rowId.slice(separator + 1));
|
return next;
|
||||||
if (Number.isInteger(index) && index >= 0) {
|
});
|
||||||
indexes.add(index);
|
};
|
||||||
}
|
|
||||||
}
|
const toggleAllCollectorPackages = (): void => {
|
||||||
if (indexes.size === 0) {
|
const packageIds = collectorPackagesRef.current.map((pkg) => pkg.id);
|
||||||
|
setCollapsedCollectorPackageIds((current) => packageIds.some((id) => !current.has(id))
|
||||||
|
? new Set(packageIds)
|
||||||
|
: new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSelectedCollectorLinks = (): void => {
|
||||||
|
if (selectedCollectorLinkIds.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const removedIds = new Set(selectedCollectorLinkIds);
|
||||||
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.",
|
||||||
@@ -4037,10 +3992,12 @@ export function App(): ReactElement {
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCollectorTabs((prev) => prev.map((entry) => entry.id === activeId
|
setCollectorPackages((current) => {
|
||||||
? { ...entry, text: entry.text.split(/\r?\n/).filter((_line, index) => !indexes.has(index)).join("\n") }
|
const next = removeCollectorLinks(current, removedIds);
|
||||||
: entry));
|
collectorPackagesRef.current = next;
|
||||||
setSelectedCollectorRowIds(new Set());
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedCollectorLinkIds(new Set());
|
||||||
setCollectorError("");
|
setCollectorError("");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -4509,6 +4466,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());
|
||||||
@@ -5281,22 +5239,20 @@ 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: () => {
|
||||||
onSubmit: () => { void onAddLinks(); },
|
void submitCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, selectedCollectorLinkIds));
|
||||||
|
},
|
||||||
|
onSubmitAll: () => { void submitCollectorPackages(collectorPackagesRef.current); },
|
||||||
onQueryChange: setCollectorQuery,
|
onQueryChange: setCollectorQuery,
|
||||||
onSelectionChange: toggleCollectorRowSelection,
|
onLinkSelectionChange: setCollectorLinkSelection,
|
||||||
onRemoveSelected: removeSelectedCollectorRows
|
onPackageSelectionChange: setCollectorPackageSelection,
|
||||||
|
onPackageCollapseChange: toggleCollectorPackageCollapse,
|
||||||
|
onToggleAllPackages: toggleAllCollectorPackages,
|
||||||
|
onRemoveSelected: removeSelectedCollectorLinks
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsFormModel = useMemo<SettingsFormViewModel>(() => buildSettingsFormViewModel({
|
const settingsFormModel = useMemo<SettingsFormViewModel>(() => buildSettingsFormViewModel({
|
||||||
@@ -6266,9 +6222,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>Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"}</span>
|
<span>Ausgewählt: {collectorViewModel.selectedCount}</span>
|
||||||
</>
|
</>
|
||||||
) : tab === "history" ? (
|
) : tab === "history" ? (
|
||||||
<>
|
<>
|
||||||
@@ -6908,7 +6864,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}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type CollectorDlcDropMode = "downloads" | "collector";
|
||||||
|
|
||||||
|
export function resolveDroppedDlcPaths(
|
||||||
|
files: ReadonlyArray<File>,
|
||||||
|
getPathForFile: (file: File) => string
|
||||||
|
): string[] {
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.name.toLowerCase().endsWith(".dlc")) continue;
|
||||||
|
try {
|
||||||
|
const filePath = String(getPathForFile(file) || "").trim();
|
||||||
|
if (filePath) paths.push(filePath);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function routeDroppedDlcFiles<TDownload, TCollector>(
|
||||||
|
files: ReadonlyArray<File>,
|
||||||
|
mode: CollectorDlcDropMode,
|
||||||
|
getPathForFile: (file: File) => string,
|
||||||
|
dependencies: {
|
||||||
|
addContainers: (filePaths: string[]) => Promise<TDownload>;
|
||||||
|
inspectContainers: (filePaths: string[], addedAt: number) => Promise<TCollector>;
|
||||||
|
},
|
||||||
|
addedAt = Date.now()
|
||||||
|
): Promise<
|
||||||
|
| { kind: "empty" }
|
||||||
|
| { kind: "downloads"; result: TDownload }
|
||||||
|
| { kind: "collector"; result: TCollector }
|
||||||
|
> {
|
||||||
|
const filePaths = resolveDroppedDlcPaths(files, getPathForFile);
|
||||||
|
if (filePaths.length === 0) return { kind: "empty" };
|
||||||
|
if (mode === "downloads") {
|
||||||
|
return { kind: "downloads", result: await dependencies.addContainers(filePaths) };
|
||||||
|
}
|
||||||
|
return { kind: "collector", result: await dependencies.inspectContainers(filePaths, addedAt) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { CollectorPackage } from "../shared/collector";
|
||||||
|
|
||||||
|
export type CollectorEnrichmentGenerationSnapshot = Map<string, number>;
|
||||||
|
|
||||||
|
function collectorUrlKey(url: string): string {
|
||||||
|
return url.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginCollectorEnrichment(
|
||||||
|
packages: CollectorPackage[],
|
||||||
|
current: Map<string, number>
|
||||||
|
): CollectorEnrichmentGenerationSnapshot {
|
||||||
|
const snapshot = new Map<string, number>();
|
||||||
|
for (const link of packages.flatMap((pkg) => pkg.links)) {
|
||||||
|
const url = collectorUrlKey(link.url);
|
||||||
|
const generation = (current.get(url) ?? 0) + 1;
|
||||||
|
current.set(url, generation);
|
||||||
|
snapshot.set(url, generation);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCurrentCollectorEnrichment(
|
||||||
|
packages: CollectorPackage[],
|
||||||
|
requested: CollectorEnrichmentGenerationSnapshot,
|
||||||
|
current: ReadonlyMap<string, number>
|
||||||
|
): CollectorPackage[] {
|
||||||
|
return packages.flatMap((pkg) => {
|
||||||
|
const links = pkg.links.filter((link) => {
|
||||||
|
const url = collectorUrlKey(link.url);
|
||||||
|
return requested.get(url) === current.get(url);
|
||||||
|
});
|
||||||
|
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 === "collector" || view === "history") {
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
return view === "settings" && settingsSection === "accounts" ? "accounts" : null;
|
return view === "settings" && settingsSection === "accounts" ? "accounts" : null;
|
||||||
|
|||||||
@@ -1,149 +1,255 @@
|
|||||||
import type { ChangeEvent, ReactElement } from "react";
|
import type { ChangeEvent, ReactElement } from "react";
|
||||||
import {
|
import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format";
|
||||||
DataTable,
|
import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/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 { SlidingSelection } from "../../ui/SlidingSelection";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
import type { CollectorViewModel } from "./collector-model";
|
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||||
|
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;
|
||||||
|
onToggleAllPackages: () => 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.reduce((count, link) => count + (link.status === "ready" ? 1 : 0), 0);
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function availabilityClass(row: CollectorWorkspacePackageRow): string {
|
||||||
|
if (row.offlineCount === row.totalCount) return "offline";
|
||||||
|
if (row.onlineCount === row.totalCount) return "online";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkStatus(status: "ready" | "offline" | "unknown"): string {
|
||||||
|
if (status === "ready") return "Bereit";
|
||||||
|
if (status === "offline") return "Offline";
|
||||||
|
return "Ungeprüft";
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkAvailability(availability: "online" | "offline" | "unknown"): string {
|
||||||
|
if (availability === "online") return "Online";
|
||||||
|
if (availability === "offline") return "Offline";
|
||||||
|
return "Ungeprüft";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleAllCollectorPackageIds(
|
||||||
|
packageIds: readonly string[],
|
||||||
|
collapsedPackageIds: ReadonlySet<string>
|
||||||
|
): Set<string> {
|
||||||
|
return packageIds.some((packageId) => !collapsedPackageIds.has(packageId))
|
||||||
|
? new Set(packageIds)
|
||||||
|
: new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectorHosterLabel({ hoster }: { hoster: ReturnType<typeof formatHosterLabel> }): ReactElement {
|
||||||
|
return (
|
||||||
|
<span className="collector-hoster-label" title={hoster.title}>
|
||||||
|
{hoster.iconSrc ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="collector-hoster-icon"
|
||||||
|
data-hoster={hoster.title.toLowerCase()}
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.hidden = true;
|
||||||
|
event.currentTarget.nextElementSibling?.removeAttribute("hidden");
|
||||||
|
}}
|
||||||
|
src={hoster.iconSrc}
|
||||||
|
/>
|
||||||
|
<span hidden>{hoster.compact}</span>
|
||||||
|
</>
|
||||||
|
) : hoster.compact}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
<SlidingSelection activeKey={model.activeTabId} axis="vertical" className="collector-sidebar-list">
|
|
||||||
{model.tabs.map((tab) => (
|
|
||||||
<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
|
<button
|
||||||
aria-current={tab.id === model.activeTabId ? "page" : undefined}
|
aria-current={filter.id === model.filter ? "page" : undefined}
|
||||||
className="collector-sidebar-select"
|
className={`collector-sidebar-filter${filter.id === model.filter ? " is-active" : ""}`}
|
||||||
onClick={() => actions.onTabSelect(tab.id)}
|
data-sliding-selection-active={filter.id === model.filter}
|
||||||
|
data-sliding-selection-item="true"
|
||||||
|
key={filter.id}
|
||||||
|
onClick={() => actions.onFilterChange(filter.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{tab.name}</span>
|
<span>{filter.label}</span><span>{filter.count}</span>
|
||||||
<span className="collector-sidebar-count">{tab.linkCount}</span>
|
|
||||||
</button>
|
</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">
|
||||||
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
<button className="collector-action collector-action-primary" onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
|
<button className="collector-action" 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" 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.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.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.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||||
|
</ToolbarGroup>
|
||||||
|
<ToolbarGroup className="collector-toolbar-tail" label="Suche und Paketdarstellung">
|
||||||
|
<ToolbarSearch label="Links durchsuchen" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} />
|
||||||
|
<button className="collector-action" disabled={model.totalCount === 0} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
<ToolbarSearch
|
|
||||||
label="Links durchsuchen"
|
|
||||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
|
||||||
placeholder="Links durchsuchen"
|
|
||||||
value={model.query}
|
|
||||||
/>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollectorPackageGroup({ row, model, actions, selected }: {
|
||||||
|
row: CollectorWorkspacePackageRow;
|
||||||
|
model: CollectorWorkspaceViewModel;
|
||||||
|
actions: CollectorViewActions;
|
||||||
|
selected: ReadonlySet<string>;
|
||||||
|
}): ReactElement {
|
||||||
|
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 (
|
||||||
|
<div className={`collector-package-group${row.collapsed ? " is-collapsed" : ""}${model.animationsEnabled ? " is-motion-enabled" : ""}`} role="rowgroup">
|
||||||
|
<div className={`collector-package-row${row.selectedCount > 0 ? " is-selected" : ""}`} role="row">
|
||||||
|
<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>
|
||||||
|
<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 className="collector-size-cell" role="cell">{packageSize(row)}</span>
|
||||||
|
<span className="collector-hoster-cell" role="cell">
|
||||||
|
{row.hosters.map(formatHosterLabel).map((hoster) => <CollectorHosterLabel hoster={hoster} key={hoster.title} />)}
|
||||||
|
</span>
|
||||||
|
<span className="collector-status-cell" role="cell">{packageStatus(row)}</span>
|
||||||
|
<span className={`collector-availability-cell is-${availabilityClass(row)}`} role="cell">{packageAvailability(row)}</span>
|
||||||
|
<span className="collector-added-cell" role="cell">{formatDateTime(row.addedAt)}</span>
|
||||||
|
</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"><CollectorHosterLabel hoster={hoster} /></span>
|
||||||
|
<span className="collector-status-cell" role="cell">{linkStatus(link.status)}</span>
|
||||||
|
<span className={`collector-availability-cell is-${link.availability}`} role="cell">{linkAvailability(link.availability)}</span>
|
||||||
|
<span className="collector-added-cell" role="cell">{formatDateTime(link.addedAt)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
||||||
const selected = new Set(model.selectedIds);
|
const selected = new Set(model.selectedIds);
|
||||||
return (
|
return (
|
||||||
<section className="collector-content" aria-label="Gesammelte Links">
|
<section className="collector-content" aria-label="Gesammelte Downloadpakete">
|
||||||
<DataTable className="collector-table" label="Gesammelte Links">
|
<DataTable className="collector-table" label="Gesammelte Downloadpakete">
|
||||||
<DataTableHeader className="collector-table-header">
|
<DataTableHeader className="collector-table-header">
|
||||||
<div className="collector-table-header-row" role="row">
|
<div className="collector-table-header-row" role="row">
|
||||||
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
|
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
|
||||||
<span role="columnheader">Sammlung</span>
|
<span role="columnheader">Name</span>
|
||||||
<span role="columnheader">URL oder Rohzeile</span>
|
<span role="columnheader">Größe</span>
|
||||||
<span role="columnheader">Zeile</span>
|
<span role="columnheader">Hoster</span>
|
||||||
<span role="columnheader">Status</span>
|
<span role="columnheader">Status</span>
|
||||||
|
<span role="columnheader">Verfügbarkeit</span>
|
||||||
|
<span role="columnheader">Hinzugefügt</span>
|
||||||
</div>
|
</div>
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
||||||
{model.busy ? (
|
{model.analyzing ? <div aria-live="polite" className="collector-background-state" role="status"><span />Analyse läuft im Hintergrund</div> : null}
|
||||||
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
|
{model.error ? <div aria-live="polite" className="collector-background-error" role="status">{model.error}</div> : null}
|
||||||
) : model.error ? (
|
{model.empty ? (
|
||||||
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
|
|
||||||
) : model.empty ? (
|
|
||||||
<DataTableEmpty
|
<DataTableEmpty
|
||||||
data-visual-region="collector-empty-state"
|
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."}
|
description={model.query || model.filter !== "all" ? "Passe Suche oder Statusfilter an." : model.analyzing ? "Die ersten Links erscheinen sofort nach dem Import." : "Füge Links hinzu, um Pakete vor dem Download zu prüfen."}
|
||||||
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
|
title={model.query || model.filter !== "all" ? "Keine passenden Links" : model.analyzing ? "Links werden vorbereitet" : "Noch keine Links"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : model.packages.map((row) => <CollectorPackageGroup actions={actions} key={row.id} model={model} row={row} selected={selected} />)}
|
||||||
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>
|
</DataTableBody>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
@@ -151,15 +257,9 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
||||||
if (region === "sidebar") {
|
if (region === "sidebar") return <CollectorSidebar actions={actions} model={model} />;
|
||||||
return <CollectorSidebar actions={actions} model={model} />;
|
if (region === "toolbar") return <CollectorToolbar actions={actions} model={model} />;
|
||||||
}
|
if (region === "content") return <CollectorContent actions={actions} model={model} />;
|
||||||
if (region === "toolbar") {
|
|
||||||
return <CollectorToolbar actions={actions} model={model} />;
|
|
||||||
}
|
|
||||||
if (region === "content") {
|
|
||||||
return <CollectorContent actions={actions} model={model} />;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="collector-view">
|
<div className="collector-view">
|
||||||
<CollectorSidebar actions={actions} model={model} />
|
<CollectorSidebar actions={actions} model={model} />
|
||||||
@@ -171,23 +271,16 @@ export function CollectorView({ model, actions, region = "all" }: CollectorViewP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectorInputDialog({
|
export function CollectorInputDialog({ open, value, onChange, onClose, onCommit }: CollectorInputDialogProps): ReactElement | null {
|
||||||
open,
|
|
||||||
tabName,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onClose,
|
|
||||||
onCommit
|
|
||||||
}: CollectorInputDialogProps): ReactElement | null {
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
actions={(
|
actions={(
|
||||||
<>
|
<>
|
||||||
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
|
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
|
||||||
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
|
<button className="collector-dialog-primary" onClick={onCommit} type="button">Hinzufügen</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
description={`Links für ${tabName} lokal erfassen.`}
|
description="Links erscheinen sofort und werden anschließend im Hintergrund geprüft."
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open={open}
|
open={open}
|
||||||
size="wide"
|
size="wide"
|
||||||
@@ -200,7 +293,7 @@ export function CollectorInputDialog({
|
|||||||
autoFocus
|
autoFocus
|
||||||
className="collector-input"
|
className="collector-input"
|
||||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
|
||||||
placeholder="Eine URL oder Rohzeile pro Zeile"
|
placeholder="Eine URL pro Zeile"
|
||||||
rows={12}
|
rows={12}
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,88 +1,295 @@
|
|||||||
export interface CollectorSourceTab {
|
import type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||||
|
|
||||||
|
export type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||||
|
export type CollectorWorkspaceFilter = "all" | CollectorAvailability;
|
||||||
|
|
||||||
|
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[];
|
analyzing: 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 interface CollectorMergeResult {
|
||||||
return tab.text
|
packages: CollectorPackage[];
|
||||||
.split(/\r?\n/)
|
addedLinks: number;
|
||||||
.map((value, originalLineIndex) => ({ originalLineIndex, value: value.trim() }))
|
duplicateLinks: number;
|
||||||
.filter((line) => line.value.length > 0);
|
enrichedLinks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCollectorRows(
|
function collectorUrlKey(url: string): string {
|
||||||
tabs: CollectorSourceTab[],
|
return url.trim();
|
||||||
activeTabId: string = tabs[0]?.id ?? "",
|
|
||||||
query = ""
|
|
||||||
): CollectorRow[] {
|
|
||||||
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
|
|
||||||
if (!activeTab) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const lines = nonEmptyLines(activeTab);
|
|
||||||
const normalizedQuery = query.trim().toLocaleLowerCase("de");
|
|
||||||
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(
|
function sameCollectorMetadata(left: CollectorLink, right: CollectorLink): boolean {
|
||||||
tabs: CollectorSourceTab[],
|
return left.fileName === right.fileName
|
||||||
activeTabId: string,
|
&& left.fileSizeBytes === right.fileSizeBytes
|
||||||
query: string,
|
&& left.hoster === right.hoster
|
||||||
busy: boolean,
|
&& left.availability === right.availability
|
||||||
selectedIds: string[],
|
&& left.status === right.status;
|
||||||
error = ""
|
}
|
||||||
): CollectorViewModel {
|
|
||||||
const rows = buildCollectorRows(tabs, activeTabId, query);
|
function incomingCollectorMetadataDegrades(existing: CollectorLink, incoming: CollectorLink): boolean {
|
||||||
|
return (existing.status !== "unknown" && incoming.status === "unknown")
|
||||||
|
|| (existing.availability !== "unknown" && incoming.availability === "unknown")
|
||||||
|
|| (existing.fileSizeBytes !== null && incoming.fileSizeBytes === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCollectorLinkMetadata(existing: CollectorLink, incoming: CollectorLink): CollectorLink {
|
||||||
|
const preserveKnownName = existing.status === "ready" && incoming.status === "unknown";
|
||||||
return {
|
return {
|
||||||
tabs: tabs.map((tab) => ({
|
...existing,
|
||||||
id: tab.id,
|
...incoming,
|
||||||
name: tab.name,
|
id: existing.id,
|
||||||
linkCount: nonEmptyLines(tab).length
|
url: existing.url,
|
||||||
})),
|
fileName: preserveKnownName ? existing.fileName : (incoming.fileName || existing.fileName),
|
||||||
activeTabId,
|
fileSizeBytes: incoming.fileSizeBytes ?? existing.fileSizeBytes,
|
||||||
rows,
|
hoster: incoming.hoster || existing.hoster,
|
||||||
busy,
|
availability: incoming.availability === "unknown" ? existing.availability : incoming.availability,
|
||||||
|
status: incoming.status === "unknown" ? existing.status : incoming.status,
|
||||||
|
addedAt: Math.min(existing.addedAt, incoming.addedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageNameKey(name: string): string {
|
||||||
|
return name.trim().toLocaleLowerCase("de");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCollectorPackages(current: CollectorPackage[], incoming: CollectorPackage[]): CollectorMergeResult {
|
||||||
|
const packages = current.map((pkg) => ({ ...pkg, links: pkg.links.map((link) => ({ ...link })) }));
|
||||||
|
const packageByName = new Map(packages.map((pkg) => [packageNameKey(pkg.name), pkg]));
|
||||||
|
const existingByUrl = new Map<string, { pkg: CollectorPackage; link: CollectorLink }>();
|
||||||
|
for (const pkg of packages) {
|
||||||
|
for (const link of pkg.links) existingByUrl.set(collectorUrlKey(link.url), { pkg, link });
|
||||||
|
}
|
||||||
|
const replacements = new Map<CollectorLink, CollectorLink>();
|
||||||
|
const movedLinks = new Set<CollectorLink>();
|
||||||
|
const appendedLinks = new Map<CollectorPackage, CollectorLink[]>();
|
||||||
|
const appendToPackage = (pkg: CollectorPackage, link: CollectorLink): void => {
|
||||||
|
const links = appendedLinks.get(pkg);
|
||||||
|
if (links) links.push(link);
|
||||||
|
else appendedLinks.set(pkg, [link]);
|
||||||
|
};
|
||||||
|
const incomingUrls = new Set<string>();
|
||||||
|
let addedLinks = 0;
|
||||||
|
let duplicateLinks = 0;
|
||||||
|
let enrichedLinks = 0;
|
||||||
|
|
||||||
|
for (const incomingPackage of incoming) {
|
||||||
|
for (const incomingLink of incomingPackage.links) {
|
||||||
|
const urlKey = collectorUrlKey(incomingLink.url);
|
||||||
|
if (!urlKey || incomingUrls.has(urlKey)) {
|
||||||
|
duplicateLinks += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
incomingUrls.add(urlKey);
|
||||||
|
const existing = existingByUrl.get(urlKey);
|
||||||
|
const preserveExplicitPackage = existing?.pkg.nameSource === "explicit" && incomingPackage.nameSource === "inferred";
|
||||||
|
const preserveRicherPackage = Boolean(existing
|
||||||
|
&& incomingPackage.nameSource === "inferred"
|
||||||
|
&& incomingCollectorMetadataDegrades(existing.link, incomingLink));
|
||||||
|
const preserveExistingPackage = preserveExplicitPackage || preserveRicherPackage;
|
||||||
|
const incomingPackageKey = packageNameKey(preserveExistingPackage && existing ? existing.pkg.name : incomingPackage.name);
|
||||||
|
const upgradePackageIdentity = existing?.pkg.nameSource === "inferred" && incomingPackage.nameSource === "explicit";
|
||||||
|
if (existing
|
||||||
|
&& packageNameKey(existing.pkg.name) === incomingPackageKey
|
||||||
|
&& !upgradePackageIdentity
|
||||||
|
&& sameCollectorMetadata(existing.link, incomingLink)) {
|
||||||
|
duplicateLinks += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = packageByName.get(incomingPackageKey);
|
||||||
|
if (!target) {
|
||||||
|
target = {
|
||||||
|
...incomingPackage,
|
||||||
|
name: preserveExistingPackage && existing ? existing.pkg.name : incomingPackage.name,
|
||||||
|
nameSource: preserveExistingPackage && existing ? existing.pkg.nameSource : incomingPackage.nameSource,
|
||||||
|
links: [],
|
||||||
|
addedAt: incomingPackage.addedAt
|
||||||
|
};
|
||||||
|
packages.push(target);
|
||||||
|
packageByName.set(incomingPackageKey, target);
|
||||||
|
}
|
||||||
|
if (incomingPackage.nameSource === "explicit") target.nameSource = "explicit";
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const enriched = mergeCollectorLinkMetadata(existing.link, incomingLink);
|
||||||
|
if (target === existing.pkg) replacements.set(existing.link, enriched);
|
||||||
|
else {
|
||||||
|
movedLinks.add(existing.link);
|
||||||
|
appendToPackage(target, enriched);
|
||||||
|
}
|
||||||
|
target.addedAt = Math.min(target.addedAt, enriched.addedAt);
|
||||||
|
existingByUrl.set(urlKey, { pkg: target, link: enriched });
|
||||||
|
enrichedLinks += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const added = { ...incomingLink };
|
||||||
|
appendToPackage(target, added);
|
||||||
|
target.addedAt = Math.min(target.addedAt, added.addedAt);
|
||||||
|
existingByUrl.set(urlKey, { pkg: target, link: added });
|
||||||
|
addedLinks += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packages: packages.flatMap((pkg) => {
|
||||||
|
const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]);
|
||||||
|
links.push(...(appendedLinks.get(pkg) ?? []));
|
||||||
|
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||||
|
}),
|
||||||
|
addedLinks,
|
||||||
|
duplicateLinks,
|
||||||
|
enrichedLinks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCollectorEnrichment(current: CollectorPackage[], incoming: CollectorPackage[]): CollectorMergeResult {
|
||||||
|
const currentUrls = new Set(current.flatMap((pkg) => pkg.links.map((link) => collectorUrlKey(link.url))));
|
||||||
|
const retained = incoming.flatMap((pkg) => {
|
||||||
|
const links = pkg.links.filter((link) => currentUrls.has(collectorUrlKey(link.url)));
|
||||||
|
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||||
|
});
|
||||||
|
return mergeCollectorPackages(current, retained);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectCollectorPackageLinks(current: Set<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,
|
||||||
|
analyzing: boolean,
|
||||||
|
selectedIds: string[],
|
||||||
|
collapsedPackageIds: string[],
|
||||||
|
error = "",
|
||||||
|
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 availabilityCounts: Record<CollectorAvailability, number> = { online: 0, unknown: 0, offline: 0 };
|
||||||
|
for (const link of allLinks) availabilityCounts[link.availability] += 1;
|
||||||
|
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;
|
||||||
|
let totalBytes = 0;
|
||||||
|
let unknownSizeCount = 0;
|
||||||
|
let onlineCount = 0;
|
||||||
|
let offlineCount = 0;
|
||||||
|
let unknownCount = 0;
|
||||||
|
let selectedCount = 0;
|
||||||
|
const hosters = new Set<string>();
|
||||||
|
for (const link of pkg.links) {
|
||||||
|
if (link.fileSizeBytes === null) unknownSizeCount += 1;
|
||||||
|
else totalBytes += link.fileSizeBytes;
|
||||||
|
if (link.availability === "online") onlineCount += 1;
|
||||||
|
else if (link.availability === "offline") offlineCount += 1;
|
||||||
|
else unknownCount += 1;
|
||||||
|
if (selected.has(link.id)) selectedCount += 1;
|
||||||
|
if (link.hoster) hosters.add(link.hoster);
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
id: pkg.id,
|
||||||
|
name: pkg.name,
|
||||||
|
links: visibleLinks,
|
||||||
|
allLinks: pkg.links,
|
||||||
|
totalBytes,
|
||||||
|
unknownSizeCount,
|
||||||
|
onlineCount,
|
||||||
|
offlineCount,
|
||||||
|
unknownCount,
|
||||||
|
totalCount: pkg.links.length,
|
||||||
|
selectedCount,
|
||||||
|
collapsed: collapsed.has(pkg.id),
|
||||||
|
addedAt: pkg.addedAt,
|
||||||
|
hosters: [...hosters]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packages: rows,
|
||||||
|
filters: [
|
||||||
|
{ id: "all", label: "Alle", count: allLinks.length },
|
||||||
|
{ id: "online", label: "Online", count: availabilityCounts.online },
|
||||||
|
{ id: "unknown", label: "Ungeprüft", count: availabilityCounts.unknown },
|
||||||
|
{ id: "offline", label: "Offline", count: availabilityCounts.offline }
|
||||||
|
],
|
||||||
|
filter,
|
||||||
query,
|
query,
|
||||||
selectedIds,
|
analyzing,
|
||||||
|
error,
|
||||||
empty: rows.length === 0,
|
empty: rows.length === 0,
|
||||||
error
|
totalCount: allLinks.length,
|
||||||
|
selectedCount: allLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0),
|
||||||
|
selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id),
|
||||||
|
animationsEnabled
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
.collector-view {
|
.collector-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 270px minmax(0, 1fr);
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
min-width: 0;
|
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +29,7 @@
|
|||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-heading span,
|
.collector-sidebar-heading span {
|
||||||
.collector-sidebar-count {
|
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -44,73 +43,55 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-item {
|
.collector-sidebar-filter {
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-item:hover {
|
|
||||||
background: var(--ui-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-item.is-active {
|
|
||||||
background: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-select,
|
|
||||||
.collector-sidebar-remove,
|
|
||||||
.collector-sidebar-add {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-select {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
font: inherit;
|
||||||
gap: 8px;
|
|
||||||
justify-content: space-between;
|
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;
|
min-height: 36px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-add:hover,
|
.collector-sidebar-filter:hover {
|
||||||
.collector-sidebar-remove:hover {
|
|
||||||
background: var(--ui-hover);
|
background: var(--ui-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-filter.is-active {
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-filter span:last-child {
|
||||||
|
color: var(--ui-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.collector-toolbar {
|
.collector-toolbar {
|
||||||
|
--collector-toolbar-action-gap: 8px;
|
||||||
|
gap: var(--collector-toolbar-action-gap);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collector-toolbar .ui-toolbar-group {
|
||||||
|
gap: var(--collector-toolbar-action-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-toolbar-tail {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-toolbar-tail .ui-toolbar-search {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.collector-action {
|
.collector-action {
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
@@ -160,18 +141,60 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-table-header-row,
|
.collector-background-state,
|
||||||
.collector-row {
|
.collector-background-error {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
backdrop-filter: blur(8px);
|
||||||
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
|
border: 1px solid var(--ui-border);
|
||||||
min-width: 760px;
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 6px 8px 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-background-state {
|
||||||
|
background: color-mix(in srgb, var(--ui-surface) 88%, transparent);
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-background-state span {
|
||||||
|
animation: collector-analysis-pulse 1s ease-in-out infinite alternate;
|
||||||
|
background: var(--ui-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 7px;
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-background-error {
|
||||||
|
background: color-mix(in srgb, var(--ui-danger) 16%, var(--ui-surface));
|
||||||
|
color: var(--ui-danger-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collector-analysis-pulse {
|
||||||
|
from { opacity: 0.35; }
|
||||||
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-table-header {
|
.collector-table-header {
|
||||||
height: 41px;
|
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, 0.8fr) minmax(90px, 0.65fr) minmax(115px, 0.9fr) minmax(130px, 1fr) minmax(150px, 1fr);
|
||||||
|
min-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
.collector-table-header-row {
|
.collector-table-header-row {
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -182,7 +205,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.collector-table-header-row > span,
|
.collector-table-header-row > span,
|
||||||
.collector-row > span {
|
.collector-package-row > span,
|
||||||
|
.collector-file-row > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
@@ -191,50 +215,177 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-row {
|
.collector-package-group {
|
||||||
border-bottom: 1px solid var(--ui-border);
|
contain-intrinsic-size: 46px 654px;
|
||||||
color: var(--ui-text-secondary);
|
content-visibility: auto;
|
||||||
height: 48px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-row:hover {
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-file-row {
|
||||||
|
border-bottom: 1px solid var(--ui-border);
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-package-row:hover,
|
||||||
|
.collector-file-row:hover {
|
||||||
background: var(--ui-hover);
|
background: var(--ui-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-row.is-selected {
|
.collector-package-row.is-selected,
|
||||||
|
.collector-file-row.is-selected {
|
||||||
background: var(--ui-active);
|
background: var(--ui-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collector-package-items-frame {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-package-items-frame.is-animated {
|
||||||
|
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-package-items-frame.is-collapsed {
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-package-items {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.collector-column-select {
|
.collector-column-select {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-row-source {
|
.collector-name-cell {
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-name-cell strong,
|
||||||
|
.collector-name-cell.is-file {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-row-value {
|
.collector-name-cell small {
|
||||||
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);
|
color: var(--ui-text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-table-error .ui-data-table-empty-title {
|
.collector-name-cell.is-file {
|
||||||
|
color: var(--ui-text);
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-collapse-button:hover {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-link-state {
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-link-state.is-online {
|
||||||
|
background: var(--ui-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-hoster-cell {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-hoster-cell span {
|
||||||
|
color: var(--ui-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-hoster-label {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-hoster-icon {
|
||||||
|
display: block;
|
||||||
|
height: 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-hoster-icon[data-hoster="rapidgator"] {
|
||||||
|
transform: translateY(-4px) scale(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-status-cell,
|
||||||
|
.collector-added-cell {
|
||||||
|
color: var(--ui-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-availability-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-availability-cell.is-online {
|
||||||
|
color: var(--ui-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-availability-cell.is-offline {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collector-availability-cell.is-unknown {
|
||||||
|
color: var(--ui-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.collector-input-label {
|
.collector-input-label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -280,6 +431,12 @@
|
|||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.collector-background-state span {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1366px) {
|
@media (max-width: 1366px) {
|
||||||
.collector-view {
|
.collector-view {
|
||||||
grid-template-columns: 56px minmax(0, 1fr);
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
@@ -289,14 +446,24 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-filter {
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-filter span:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.collector-action {
|
.collector-action {
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-table-header-row,
|
.collector-table-header-row,
|
||||||
.collector-row {
|
.collector-package-row,
|
||||||
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
|
.collector-file-row {
|
||||||
min-width: 660px;
|
grid-template-columns: 44px minmax(220px, 2.2fr) minmax(98px, 0.8fr) minmax(78px, 0.65fr) minmax(100px, 0.9fr) minmax(112px, 1fr) minmax(132px, 1fr);
|
||||||
|
min-width: 820px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,14 +472,19 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-toolbar .ui-toolbar-search {
|
.collector-toolbar-tail {
|
||||||
flex: 1 0 100%;
|
flex: 1 0 100%;
|
||||||
width: 100%;
|
}
|
||||||
|
|
||||||
|
.collector-toolbar-tail .ui-toolbar-search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-table-header-row,
|
.collector-table-header-row,
|
||||||
.collector-row {
|
.collector-package-row,
|
||||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
.collector-file-row {
|
||||||
min-width: 610px;
|
grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, 0.8fr) minmax(72px, 0.65fr) minmax(94px, 0.9fr) minmax(106px, 1fr) minmax(124px, 1fr);
|
||||||
|
min-width: 760px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
export type CollectorAvailability = "online" | "offline" | "unknown";
|
||||||
|
export type CollectorLinkStatus = "ready" | "offline" | "unknown";
|
||||||
|
export type CollectorPackageNameSource = "explicit" | "inferred";
|
||||||
|
|
||||||
|
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;
|
||||||
|
nameSource: CollectorPackageNameSource;
|
||||||
|
links: CollectorLink[];
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorTextPreparationRequest {
|
||||||
|
rawText: string;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorContainerPreparationRequest {
|
||||||
|
filePaths: string[];
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorEnrichmentRequest {
|
||||||
|
packages: CollectorPackage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorInspectionResult {
|
||||||
|
packages: CollectorPackage[];
|
||||||
|
invalidCount: number;
|
||||||
|
duplicateCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validAddedAt(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteDlcPath(value: unknown): value is string {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& value.length > 0
|
||||||
|
&& value.length <= 32767
|
||||||
|
&& /^(?:[a-z]:[\\/]|\\\\|\/)/i.test(value)
|
||||||
|
&& value.toLowerCase().endsWith(".dlc");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCollectorTextPreparationRequest(value: unknown): CollectorTextPreparationRequest {
|
||||||
|
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"
|
||||||
|
|| new TextEncoder().encode(raw.rawText).byteLength > 2_000_000
|
||||||
|
|| !validAddedAt(raw.addedAt)) {
|
||||||
|
throw new Error("Linksammler-Payload ist ungültig");
|
||||||
|
}
|
||||||
|
return { rawText: raw.rawText, addedAt: raw.addedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCollectorContainerPreparationRequest(value: unknown): CollectorContainerPreparationRequest {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("Container-Payload ist ungültig");
|
||||||
|
}
|
||||||
|
const raw = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(raw).some((key) => key !== "filePaths" && key !== "addedAt")
|
||||||
|
|| !Array.isArray(raw.filePaths)
|
||||||
|
|| raw.filePaths.length === 0
|
||||||
|
|| raw.filePaths.length > 100
|
||||||
|
|| raw.filePaths.some((entry) => !isAbsoluteDlcPath(entry))
|
||||||
|
|| !validAddedAt(raw.addedAt)) {
|
||||||
|
throw new Error("Container-Payload ist ungültig");
|
||||||
|
}
|
||||||
|
return { filePaths: [...raw.filePaths] as string[], addedAt: raw.addedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCollectorLink(value: unknown): value is CollectorLink {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const raw = value as Record<string, unknown>;
|
||||||
|
return Object.keys(raw).every((key) => [
|
||||||
|
"id", "url", "fileName", "fileSizeBytes", "hoster", "availability", "status", "addedAt"
|
||||||
|
].includes(key))
|
||||||
|
&& typeof raw.id === "string"
|
||||||
|
&& raw.id.length > 0
|
||||||
|
&& raw.id.length <= 160
|
||||||
|
&& typeof raw.url === "string"
|
||||||
|
&& raw.url.length <= 32767
|
||||||
|
&& /^https?:\/\/[^\s]+$/i.test(raw.url)
|
||||||
|
&& typeof raw.fileName === "string"
|
||||||
|
&& raw.fileName.length <= 1024
|
||||||
|
&& (raw.fileSizeBytes === null || (typeof raw.fileSizeBytes === "number" && Number.isSafeInteger(raw.fileSizeBytes) && raw.fileSizeBytes >= 0))
|
||||||
|
&& typeof raw.hoster === "string"
|
||||||
|
&& raw.hoster.length <= 255
|
||||||
|
&& (raw.availability === "online" || raw.availability === "offline" || raw.availability === "unknown")
|
||||||
|
&& (raw.status === "ready" || raw.status === "offline" || raw.status === "unknown")
|
||||||
|
&& validAddedAt(raw.addedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCollectorPackage(value: unknown): value is CollectorPackage {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const raw = value as Record<string, unknown>;
|
||||||
|
return Object.keys(raw).every((key) => ["id", "name", "nameSource", "links", "addedAt"].includes(key))
|
||||||
|
&& typeof raw.id === "string"
|
||||||
|
&& raw.id.length > 0
|
||||||
|
&& raw.id.length <= 160
|
||||||
|
&& typeof raw.name === "string"
|
||||||
|
&& raw.name.length > 0
|
||||||
|
&& raw.name.length <= 1024
|
||||||
|
&& (raw.nameSource === "explicit" || raw.nameSource === "inferred")
|
||||||
|
&& Array.isArray(raw.links)
|
||||||
|
&& raw.links.length > 0
|
||||||
|
&& raw.links.every(validCollectorLink)
|
||||||
|
&& validAddedAt(raw.addedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnrichmentRequest {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("Linksammler-Anreicherung ist ungültig");
|
||||||
|
}
|
||||||
|
const raw = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(raw).some((key) => key !== "packages")
|
||||||
|
|| !Array.isArray(raw.packages)
|
||||||
|
|| raw.packages.length === 0
|
||||||
|
|| raw.packages.length > 2_000
|
||||||
|
|| raw.packages.some((entry) => !validCollectorPackage(entry))
|
||||||
|
|| raw.packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000) {
|
||||||
|
throw new Error("Linksammler-Anreicherung ist ungültig");
|
||||||
|
}
|
||||||
|
return { packages: structuredClone(raw.packages) as CollectorPackage[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ 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",
|
||||||
|
PREPARE_COLLECTOR_TEXT: "collector:prepare-text",
|
||||||
|
PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers",
|
||||||
|
ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages",
|
||||||
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",
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ import type {
|
|||||||
UpdateInstallResult
|
UpdateInstallResult
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { isRealDebridWebAccountId } from "./real-debrid-accounts";
|
import { isRealDebridWebAccountId } from "./real-debrid-accounts";
|
||||||
|
import type {
|
||||||
|
CollectorEnrichmentRequest,
|
||||||
|
CollectorInspectionResult,
|
||||||
|
CollectorTextPreparationRequest
|
||||||
|
} from "./collector";
|
||||||
|
|
||||||
export interface RealDebridLoginRequest {
|
export interface RealDebridLoginRequest {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -79,6 +84,10 @@ 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 }>;
|
||||||
|
prepareCollectorText: (request: CollectorTextPreparationRequest) => Promise<CollectorInspectionResult>;
|
||||||
|
prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>;
|
||||||
|
enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>;
|
||||||
|
getPathForDroppedFile: (file: File) => string;
|
||||||
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>;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { ElectronApi } from "../src/shared/preload-api";
|
|||||||
|
|
||||||
const electron = vi.hoisted(() => ({
|
const electron = vi.hoisted(() => ({
|
||||||
api: undefined as ElectronApi | undefined,
|
api: undefined as ElectronApi | undefined,
|
||||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined)
|
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
|
||||||
|
getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc")
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("electron", () => ({
|
vi.mock("electron", () => ({
|
||||||
@@ -18,7 +19,8 @@ vi.mock("electron", () => ({
|
|||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
removeListener: vi.fn(),
|
removeListener: vi.fn(),
|
||||||
send: vi.fn()
|
send: vi.fn()
|
||||||
}
|
},
|
||||||
|
webUtils: { getPathForFile: electron.getPathForFile }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("account preload contract", () => {
|
describe("account preload contract", () => {
|
||||||
@@ -109,4 +111,27 @@ 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("exposes separate collector preparation and enrichment channels", async () => {
|
||||||
|
const textRequest = { rawText: "https://example.com/file", addedAt: 1234 };
|
||||||
|
const packages = [{ id: "package", name: "Paket", nameSource: "inferred" as const, addedAt: 1234, links: [] }];
|
||||||
|
|
||||||
|
await electron.api?.prepareCollectorText(textRequest);
|
||||||
|
await electron.api?.prepareCollectorContainers(["C:\\Imports\\sample.dlc"], 2345);
|
||||||
|
await electron.api?.enrichCollectorPackages({ packages });
|
||||||
|
|
||||||
|
expect(electron.invoke.mock.calls).toEqual([
|
||||||
|
[IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, textRequest],
|
||||||
|
[IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 2345],
|
||||||
|
[IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { packages }]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves dropped files through Electron webUtils without IPC", () => {
|
||||||
|
const file = { name: "dropped.dlc" } as File;
|
||||||
|
|
||||||
|
expect(electron.api?.getPathForDroppedFile(file)).toBe("C:\\Imports\\dropped.dlc");
|
||||||
|
expect(electron.getPathForFile).toHaveBeenCalledWith(file);
|
||||||
|
expect(electron.invoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,20 +20,12 @@ describe("desktop shell", () => {
|
|||||||
expect(source).toContain("Maskierte Kennung kopiert");
|
expect(source).toContain("Maskierte Kennung kopiert");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("confirms before removing a collector tab", () => {
|
|
||||||
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"));
|
|
||||||
|
|
||||||
expect(removal).toContain("askConfirmPrompt");
|
|
||||||
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("planCollectorTabRemoval"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirms before removing selected collector links", () => {
|
it("confirms before removing selected collector links", () => {
|
||||||
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 removeSelectedCollectorRows"), source.indexOf("const onPackageStartEdit"));
|
const removal = source.slice(source.indexOf("const removeSelectedCollectorLinks"), 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"');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +54,7 @@ describe("desktop shell", () => {
|
|||||||
|
|
||||||
it("redraws the header speed sparkline on the same 750 ms cadence", () => {
|
it("redraws the header speed sparkline on the same 750 ms cadence", () => {
|
||||||
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 sparklineBlock = source.slice(source.indexOf("const DownloadSpeedSparkline"), source.indexOf("const initialCollectorTabs"));
|
const sparklineBlock = source.slice(source.indexOf("const DownloadSpeedSparkline"), source.indexOf("function createScheduleId"));
|
||||||
|
|
||||||
expect(sparklineBlock).toContain("window.setInterval(tick, 750)");
|
expect(sparklineBlock).toContain("window.setInterval(tick, 750)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { resolveDroppedDlcPaths, routeDroppedDlcFiles } from "../src/renderer/collector-drop";
|
||||||
|
|
||||||
|
describe("collector DLC drop routing", () => {
|
||||||
|
it("resolves native DLC paths without using File.path", () => {
|
||||||
|
const first = { name: "first.dlc" } as File;
|
||||||
|
const ignored = { name: "notes.txt" } as File;
|
||||||
|
const second = { name: "SECOND.DLC" } as File;
|
||||||
|
const getPath = vi.fn((file: File) => file === first ? "C:\\Drops\\first.dlc" : "C:\\Drops\\second.dlc");
|
||||||
|
|
||||||
|
expect(resolveDroppedDlcPaths([first, ignored, second], getPath)).toEqual([
|
||||||
|
"C:\\Drops\\first.dlc",
|
||||||
|
"C:\\Drops\\second.dlc"
|
||||||
|
]);
|
||||||
|
expect(getPath.mock.calls.map(([file]) => file)).toEqual([first, second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends Downloads drops directly to addContainers and never to an inspector", async () => {
|
||||||
|
const file = { name: "queue.dlc" } as File;
|
||||||
|
const addContainers = vi.fn(async () => ({ addedPackages: 2, addedLinks: 16 }));
|
||||||
|
const inspectContainers = vi.fn(async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }));
|
||||||
|
|
||||||
|
await expect(routeDroppedDlcFiles([file], "downloads", () => "C:\\Drops\\queue.dlc", {
|
||||||
|
addContainers,
|
||||||
|
inspectContainers
|
||||||
|
})).resolves.toEqual({
|
||||||
|
kind: "downloads",
|
||||||
|
result: { addedPackages: 2, addedLinks: 16 }
|
||||||
|
});
|
||||||
|
expect(addContainers).toHaveBeenCalledTimes(1);
|
||||||
|
expect(inspectContainers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends Collector drops only to structure inspection", async () => {
|
||||||
|
const file = { name: "preview.dlc" } as File;
|
||||||
|
const addContainers = vi.fn(async () => ({ addedPackages: 1, addedLinks: 1 }));
|
||||||
|
const structure = { packages: [{ id: "package-1", name: "Serie", links: [], addedAt: 1000 }], invalidCount: 0, duplicateCount: 0 };
|
||||||
|
const inspectContainers = vi.fn(async () => structure);
|
||||||
|
|
||||||
|
await expect(routeDroppedDlcFiles([file], "collector", () => "C:\\Drops\\preview.dlc", {
|
||||||
|
addContainers,
|
||||||
|
inspectContainers
|
||||||
|
}, 1000)).resolves.toEqual({ kind: "collector", result: structure });
|
||||||
|
expect(inspectContainers).toHaveBeenCalledWith(["C:\\Drops\\preview.dlc"], 1000);
|
||||||
|
expect(addContainers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a hanging Collector inspection block a Downloads drop", async () => {
|
||||||
|
const collectorFile = { name: "slow.dlc" } as File;
|
||||||
|
const downloadFile = { name: "fast.dlc" } as File;
|
||||||
|
const inspectContainers = vi.fn(() => new Promise<never>(() => {}));
|
||||||
|
const addContainers = vi.fn(async () => ({ addedPackages: 1, addedLinks: 8 }));
|
||||||
|
|
||||||
|
void routeDroppedDlcFiles([collectorFile], "collector", () => "C:\\Drops\\slow.dlc", { addContainers, inspectContainers }, 1000);
|
||||||
|
await expect(routeDroppedDlcFiles([downloadFile], "downloads", () => "C:\\Drops\\fast.dlc", {
|
||||||
|
addContainers,
|
||||||
|
inspectContainers
|
||||||
|
})).resolves.toEqual({
|
||||||
|
kind: "downloads",
|
||||||
|
result: { addedPackages: 1, addedLinks: 8 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a controlled empty result when no native DLC path is available", async () => {
|
||||||
|
const file = { name: "missing.dlc" } as File;
|
||||||
|
const addContainers = vi.fn();
|
||||||
|
const inspectContainers = vi.fn();
|
||||||
|
|
||||||
|
await expect(routeDroppedDlcFiles([file], "downloads", () => "", { addContainers, inspectContainers })).resolves.toEqual({ kind: "empty" });
|
||||||
|
expect(addContainers).not.toHaveBeenCalled();
|
||||||
|
expect(inspectContainers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
beginCollectorEnrichment,
|
||||||
|
filterCurrentCollectorEnrichment
|
||||||
|
} from "../src/renderer/collector-enrichment";
|
||||||
|
import type { CollectorPackage } from "../src/shared/collector";
|
||||||
|
|
||||||
|
function collectorPackage(url: string, status: "ready" | "offline" | "unknown" = "unknown"): CollectorPackage {
|
||||||
|
return {
|
||||||
|
id: `package-${url}`,
|
||||||
|
name: "Paket",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 1,
|
||||||
|
links: [{
|
||||||
|
id: `link-${url}`,
|
||||||
|
url,
|
||||||
|
fileName: "download.bin",
|
||||||
|
fileSizeBytes: null,
|
||||||
|
hoster: "example",
|
||||||
|
availability: status === "ready" ? "online" : status,
|
||||||
|
status,
|
||||||
|
addedAt: 1
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("collector enrichment generations", () => {
|
||||||
|
it("rejects an older response after the same URL starts a newer enrichment", () => {
|
||||||
|
const current = new Map<string, number>();
|
||||||
|
const packages = [collectorPackage("https://example.test/file")];
|
||||||
|
const first = beginCollectorEnrichment(packages, current);
|
||||||
|
const second = beginCollectorEnrichment(packages, current);
|
||||||
|
|
||||||
|
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "offline")], first, current)).toEqual([]);
|
||||||
|
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "ready")], second, current)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import {
|
||||||
|
enrichCollectorPackages,
|
||||||
|
prepareCollectorContainers,
|
||||||
|
prepareCollectorText
|
||||||
|
} from "../src/main/collector-inspection";
|
||||||
|
import {
|
||||||
|
validateCollectorContainerPreparationRequest,
|
||||||
|
validateCollectorEnrichmentRequest,
|
||||||
|
validateCollectorTextPreparationRequest
|
||||||
|
} from "../src/shared/collector";
|
||||||
|
|
||||||
|
describe("collector preparation", () => {
|
||||||
|
it("returns a stable package skeleton without requesting metadata", () => {
|
||||||
|
const fetchRequest = vi.spyOn(globalThis, "fetch");
|
||||||
|
const rawText = [
|
||||||
|
"# Package: Staffel A",
|
||||||
|
"# File: episode.part01.rar",
|
||||||
|
"https://example.com/a",
|
||||||
|
"https://example.com/a",
|
||||||
|
"invalid"
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const first = prepareCollectorText({ rawText, addedAt: 1_000 });
|
||||||
|
const second = prepareCollectorText({ rawText, addedAt: 2_000 });
|
||||||
|
|
||||||
|
expect(fetchRequest).not.toHaveBeenCalled();
|
||||||
|
expect(first.invalidCount).toBe(1);
|
||||||
|
expect(first.duplicateCount).toBe(1);
|
||||||
|
expect(first.packages).toHaveLength(1);
|
||||||
|
expect(first.packages[0]).toEqual(expect.objectContaining({
|
||||||
|
name: "Staffel A",
|
||||||
|
nameSource: "explicit"
|
||||||
|
}));
|
||||||
|
expect(first.packages[0].links[0]).toEqual(expect.objectContaining({
|
||||||
|
url: "https://example.com/a",
|
||||||
|
fileName: "episode.part01.rar",
|
||||||
|
availability: "unknown",
|
||||||
|
status: "ready"
|
||||||
|
}));
|
||||||
|
expect(second.packages[0].id).toBe(first.packages[0].id);
|
||||||
|
expect(second.packages[0].links[0].id).toBe(first.packages[0].links[0].id);
|
||||||
|
fetchRequest.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts selected DLC files into a skeleton without metadata enrichment", async () => {
|
||||||
|
const importContainers = vi.fn(async () => [{
|
||||||
|
name: "DLC Paket",
|
||||||
|
links: ["https://1fichier.com/?abc123def456ghi789jk"],
|
||||||
|
fileNames: ["episode.part01.rar"]
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const result = await prepareCollectorContainers(
|
||||||
|
["C:\\Imports\\sample.dlc"],
|
||||||
|
3_000,
|
||||||
|
{ importContainers }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(importContainers).toHaveBeenCalledWith(["C:\\Imports\\sample.dlc"]);
|
||||||
|
expect(result.packages[0]).toEqual(expect.objectContaining({
|
||||||
|
name: "DLC Paket",
|
||||||
|
nameSource: "explicit"
|
||||||
|
}));
|
||||||
|
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({
|
||||||
|
fileName: "episode.part01.rar",
|
||||||
|
availability: "unknown",
|
||||||
|
status: "ready"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized text and invalid container paths", () => {
|
||||||
|
expect(() => validateCollectorTextPreparationRequest({ rawText: "x".repeat(2_000_001), addedAt: 1 })).toThrow(/ungültig/i);
|
||||||
|
expect(() => validateCollectorTextPreparationRequest({ rawText: "ä".repeat(1_100_000), addedAt: 1 })).toThrow(/ungültig/i);
|
||||||
|
expect(() => validateCollectorContainerPreparationRequest({
|
||||||
|
filePaths: Array.from({ length: 101 }, (_, index) => `C:\\Imports\\${index}.dlc`),
|
||||||
|
addedAt: 1
|
||||||
|
})).toThrow(/ungültig/i);
|
||||||
|
expect(() => validateCollectorContainerPreparationRequest({ filePaths: ["relative.dlc"], addedAt: 1 })).toThrow(/ungültig/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collector enrichment", () => {
|
||||||
|
it("updates known links by URL while preserving their stable ids", async () => {
|
||||||
|
const prepared = prepareCollectorText({
|
||||||
|
rawText: "https://1fichier.com/?abc123def456ghi789jk",
|
||||||
|
addedAt: 4_000
|
||||||
|
});
|
||||||
|
const linkBefore = prepared.packages[0].links[0];
|
||||||
|
|
||||||
|
const result = await enrichCollectorPackages(
|
||||||
|
{ packages: prepared.packages },
|
||||||
|
defaultSettings(),
|
||||||
|
{
|
||||||
|
checkOneFichier: async () => new Map([[linkBefore.url, {
|
||||||
|
online: true,
|
||||||
|
fileName: "Show.S01E01.part01.rar",
|
||||||
|
fileSizeBytes: 471_859_200,
|
||||||
|
accessRestricted: false
|
||||||
|
}]])
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.packages).toHaveLength(1);
|
||||||
|
expect(result.packages[0].name).toBe("Show.S01E01");
|
||||||
|
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({
|
||||||
|
id: linkBefore.id,
|
||||||
|
url: linkBefore.url,
|
||||||
|
fileName: "Show.S01E01.part01.rar",
|
||||||
|
fileSizeBytes: 471_859_200,
|
||||||
|
availability: "online",
|
||||||
|
status: "ready"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs independent enrichments concurrently instead of serializing them globally", async () => {
|
||||||
|
const first = prepareCollectorText({ rawText: "https://1fichier.com/?first", addedAt: 5_000 });
|
||||||
|
const second = prepareCollectorText({ rawText: "https://1fichier.com/?second", addedAt: 6_000 });
|
||||||
|
const started: string[] = [];
|
||||||
|
const resolvers: Array<() => void> = [];
|
||||||
|
const checkOneFichier = async (links: string[]) => {
|
||||||
|
started.push(links[0]);
|
||||||
|
await new Promise<void>((resolve) => resolvers.push(resolve));
|
||||||
|
return new Map();
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstRun = enrichCollectorPackages({ packages: first.packages }, defaultSettings(), { checkOneFichier });
|
||||||
|
const secondRun = enrichCollectorPackages({ packages: second.packages }, defaultSettings(), { checkOneFichier });
|
||||||
|
await vi.waitFor(() => expect(started).toHaveLength(2));
|
||||||
|
resolvers.forEach((resolve) => resolve());
|
||||||
|
await Promise.all([firstRun, secondRun]);
|
||||||
|
|
||||||
|
expect(started).toEqual([
|
||||||
|
"https://1fichier.com/?first",
|
||||||
|
"https://1fichier.com/?second"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects enrichment payloads that do not contain prepared absolute links", () => {
|
||||||
|
expect(() => validateCollectorEnrichmentRequest({ packages: [] })).toThrow(/ungültig/i);
|
||||||
|
expect(() => validateCollectorEnrichmentRequest({
|
||||||
|
packages: [{
|
||||||
|
id: "package",
|
||||||
|
name: "Paket",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 1,
|
||||||
|
links: [{
|
||||||
|
id: "link",
|
||||||
|
url: "relative",
|
||||||
|
fileName: "",
|
||||||
|
fileSizeBytes: null,
|
||||||
|
hoster: "",
|
||||||
|
availability: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
addedAt: 1
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})).toThrow(/ungültig/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
+311
-239
@@ -3,21 +3,21 @@ 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 {
|
import {
|
||||||
mergeCollectorDraftText,
|
buildCollectorTransferPackages,
|
||||||
planCollectorTabRemoval,
|
buildCollectorWorkspaceViewModel,
|
||||||
planCollectorTextReplacement
|
mergeCollectorEnrichment,
|
||||||
} from "../src/renderer/App";
|
mergeCollectorPackages,
|
||||||
import {
|
removeCollectorLinks,
|
||||||
buildCollectorRows,
|
selectCollectorPackageLinks,
|
||||||
buildCollectorViewModel,
|
type CollectorPackage
|
||||||
type CollectorSourceTab
|
|
||||||
} from "../src/renderer/views/collector/collector-model";
|
} from "../src/renderer/views/collector/collector-model";
|
||||||
import {
|
import {
|
||||||
CollectorInputDialog,
|
|
||||||
CollectorContent,
|
CollectorContent,
|
||||||
|
CollectorInputDialog,
|
||||||
CollectorSidebar,
|
CollectorSidebar,
|
||||||
CollectorToolbar,
|
CollectorToolbar,
|
||||||
CollectorView,
|
CollectorView,
|
||||||
|
toggleAllCollectorPackageIds,
|
||||||
type CollectorViewActions
|
type CollectorViewActions
|
||||||
} from "../src/renderer/views/collector/CollectorView";
|
} from "../src/renderer/views/collector/CollectorView";
|
||||||
|
|
||||||
@@ -26,9 +26,7 @@ function visitElements(node: ReactNode, visit: (element: ReactElement) => void):
|
|||||||
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 +35,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 +47,352 @@ 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: () => {},
|
||||||
|
onToggleAllPackages: () => {},
|
||||||
onRemoveSelected: () => {},
|
onRemoveSelected: () => {},
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const populatedTabs: CollectorSourceTab[] = [
|
const packages: CollectorPackage[] = [{
|
||||||
{
|
id: "package-sbs",
|
||||||
id: "tab-a",
|
name: "SBS14HD",
|
||||||
name: "Sammlung A",
|
nameSource: "inferred",
|
||||||
text: "https://example.test/a\n\n https://example.test/b "
|
addedAt: 1_000,
|
||||||
}
|
links: [
|
||||||
];
|
{ id: "link-1", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1_000 },
|
||||||
|
{ id: "link-2", url: "https://1fichier.com/?two22222", fileName: "SBS14HD.part02.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1_000 }
|
||||||
|
]
|
||||||
|
}, {
|
||||||
|
id: "package-mixed",
|
||||||
|
name: "Mixed",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 2_000,
|
||||||
|
links: [
|
||||||
|
{ id: "link-3", url: "https://example.test/unknown", fileName: "download.bin", fileSizeBytes: null, hoster: "example", availability: "unknown", status: "unknown", addedAt: 2_000 },
|
||||||
|
{ id: "link-4", url: "https://example.test/offline", fileName: "offline.bin", fileSizeBytes: null, hoster: "example", availability: "offline", status: "offline", addedAt: 2_000 }
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
|
||||||
describe("collector model", () => {
|
describe("collector workspace model", () => {
|
||||||
it("derives stable rows from non-empty raw lines without validating or regrouping them", () => {
|
it("merges late enrichment by URL without duplicates and moves the link into its resolved package", () => {
|
||||||
const rows = buildCollectorRows(populatedTabs, "tab-a", "");
|
const initial: CollectorPackage[] = [{
|
||||||
|
id: "pending",
|
||||||
|
name: "Unsortiert",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 1_000,
|
||||||
|
links: [{ id: "stable-link", url: "https://1fichier.com/?one11111", fileName: "download.bin", fileSizeBytes: null, hoster: "1fichier", availability: "unknown", status: "unknown", addedAt: 1_000 }]
|
||||||
|
}];
|
||||||
|
const enriched: CollectorPackage[] = [{
|
||||||
|
id: "resolved",
|
||||||
|
name: "SBS14HD",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 2_000,
|
||||||
|
links: [{ id: "replacement-id", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 2_000 }]
|
||||||
|
}];
|
||||||
|
|
||||||
expect(rows).toHaveLength(2);
|
const result = mergeCollectorPackages(initial, enriched);
|
||||||
expect(rows.map((row) => row.id)).toEqual(["tab-a:0", "tab-a:2"]);
|
|
||||||
expect(rows.map((row) => row.originalLineIndex)).toEqual([0, 2]);
|
expect(result).toEqual(expect.objectContaining({ addedLinks: 0, duplicateLinks: 0, enrichedLinks: 1 }));
|
||||||
expect(rows.map((row) => row.value)).toEqual([
|
expect(result.packages).toHaveLength(1);
|
||||||
"https://example.test/a",
|
expect(result.packages[0].name).toBe("SBS14HD");
|
||||||
"https://example.test/b"
|
expect(result.packages[0].links).toEqual([expect.objectContaining({
|
||||||
|
id: "stable-link",
|
||||||
|
fileName: "SBS14HD.part01.rar",
|
||||||
|
fileSizeBytes: 471_859_200,
|
||||||
|
availability: "online",
|
||||||
|
status: "ready",
|
||||||
|
addedAt: 1_000
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates repeated incoming URLs while preserving distinct links", () => {
|
||||||
|
const incoming: CollectorPackage[] = [{
|
||||||
|
id: "incoming",
|
||||||
|
name: "SBS14HD",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 3_000,
|
||||||
|
links: [
|
||||||
|
{ ...packages[0].links[0], id: "duplicate" },
|
||||||
|
{ ...packages[0].links[0], id: "duplicate-again" },
|
||||||
|
{ id: "link-5", url: "https://1fichier.com/?three333", fileName: "SBS14HD.part03.rar", fileSizeBytes: 10, hoster: "1fichier", availability: "online", status: "ready", addedAt: 3_000 }
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
const result = mergeCollectorPackages(packages, incoming);
|
||||||
|
|
||||||
|
expect(result.addedLinks).toBe(1);
|
||||||
|
expect(result.duplicateLinks).toBe(2);
|
||||||
|
expect(result.packages[0].links.map((link) => link.id)).toEqual(["link-1", "link-2", "link-5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore links removed while background enrichment is running", () => {
|
||||||
|
const current = [{ ...packages[0], links: [packages[0].links[0]] }];
|
||||||
|
const incoming = [{ ...packages[0], links: packages[0].links.map((link) => ({ ...link, status: "ready" as const })) }];
|
||||||
|
|
||||||
|
expect(mergeCollectorEnrichment(current, incoming).packages[0].links.map((link) => link.id)).toEqual(["link-1"]);
|
||||||
|
expect(mergeCollectorEnrichment([], incoming).packages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replace known metadata with a repeated unknown skeleton", () => {
|
||||||
|
const current = [{ ...packages[0], links: [packages[0].links[0]] }];
|
||||||
|
const incoming: CollectorPackage[] = [{
|
||||||
|
id: "pending",
|
||||||
|
name: "1Fichier",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 4_000,
|
||||||
|
links: [{
|
||||||
|
...packages[0].links[0],
|
||||||
|
id: "replacement",
|
||||||
|
fileName: "download.bin",
|
||||||
|
fileSizeBytes: null,
|
||||||
|
availability: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
addedAt: 4_000
|
||||||
|
}]
|
||||||
|
}];
|
||||||
|
|
||||||
|
const result = mergeCollectorPackages(current, incoming);
|
||||||
|
|
||||||
|
expect(result.packages).toHaveLength(1);
|
||||||
|
expect(result.packages[0].name).toBe("SBS14HD");
|
||||||
|
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({
|
||||||
|
id: "link-1",
|
||||||
|
fileName: "SBS14HD.part01.rar",
|
||||||
|
fileSizeBytes: 471_859_200,
|
||||||
|
availability: "online",
|
||||||
|
status: "ready",
|
||||||
|
addedAt: 1_000
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a resolved package name even when link metadata was already complete", () => {
|
||||||
|
const current: CollectorPackage[] = [{
|
||||||
|
id: "pending",
|
||||||
|
name: "Unsortiert",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 1_000,
|
||||||
|
links: [{ ...packages[0].links[0], id: "stable-link" }]
|
||||||
|
}];
|
||||||
|
const incoming: CollectorPackage[] = [{
|
||||||
|
id: "resolved",
|
||||||
|
name: "SBS14HD",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 2_000,
|
||||||
|
links: [{ ...packages[0].links[0], id: "replacement-link" }]
|
||||||
|
}];
|
||||||
|
|
||||||
|
const result = mergeCollectorPackages(current, incoming);
|
||||||
|
|
||||||
|
expect(result.enrichedLinks).toBe(1);
|
||||||
|
expect(result.duplicateLinks).toBe(0);
|
||||||
|
expect(result.packages.map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([
|
||||||
|
["SBS14HD", ["stable-link"]]
|
||||||
]);
|
]);
|
||||||
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("keeps explicit package names authoritative over inferred enrichment", () => {
|
||||||
const model = buildCollectorViewModel(populatedTabs, "tab-a", "EXAMPLE.TEST/B", false, ["tab-a:0"]);
|
const current: CollectorPackage[] = [{
|
||||||
|
id: "explicit",
|
||||||
|
name: "Meine Staffel",
|
||||||
|
nameSource: "explicit",
|
||||||
|
addedAt: 1_000,
|
||||||
|
links: [{ ...packages[0].links[0], id: "stable-link", availability: "unknown", status: "unknown" }]
|
||||||
|
}];
|
||||||
|
const incoming: CollectorPackage[] = [{
|
||||||
|
id: "inferred",
|
||||||
|
name: "SBS14HD",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 2_000,
|
||||||
|
links: [{ ...packages[0].links[0], id: "replacement-link" }]
|
||||||
|
}];
|
||||||
|
|
||||||
expect(model.rows.map((row) => row.id)).toEqual(["tab-a:2"]);
|
const result = mergeCollectorPackages(current, incoming);
|
||||||
expect(model.tabs).toEqual([{ id: "tab-a", name: "Sammlung A", linkCount: 2 }]);
|
|
||||||
expect(model.selectedIds).toEqual(["tab-a:0"]);
|
expect(result.packages).toHaveLength(1);
|
||||||
|
expect(result.packages[0]).toEqual(expect.objectContaining({ name: "Meine Staffel", nameSource: "explicit" }));
|
||||||
|
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({ id: "stable-link", availability: "online", status: "ready" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades an inferred package identity when explicit metadata arrives", () => {
|
||||||
|
const current: CollectorPackage[] = [{
|
||||||
|
id: "inferred",
|
||||||
|
name: "SBS14HD",
|
||||||
|
nameSource: "inferred",
|
||||||
|
addedAt: 1_000,
|
||||||
|
links: [{ ...packages[0].links[0], id: "stable-link", availability: "unknown", status: "unknown" }]
|
||||||
|
}];
|
||||||
|
const incoming: CollectorPackage[] = [{
|
||||||
|
id: "explicit",
|
||||||
|
name: "SBS14HD",
|
||||||
|
nameSource: "explicit",
|
||||||
|
addedAt: 2_000,
|
||||||
|
links: [{ ...packages[0].links[0], id: "replacement-link" }]
|
||||||
|
}];
|
||||||
|
|
||||||
|
const result = mergeCollectorPackages(current, incoming);
|
||||||
|
|
||||||
|
expect(result.packages[0].nameSource).toBe("explicit");
|
||||||
|
});
|
||||||
|
|
||||||
|
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"]]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives aggregates, filters and search once for the view", () => {
|
||||||
|
const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], ["package-mixed"], "", true);
|
||||||
|
|
||||||
|
expect(model.packages).toHaveLength(1);
|
||||||
|
expect(model.packages[0]).toEqual(expect.objectContaining({ totalBytes: 943_718_400, unknownSizeCount: 0, onlineCount: 2, totalCount: 2, selectedCount: 1, collapsed: false }));
|
||||||
|
expect(model.packages[0].links.map((link) => link.id)).toEqual(["link-2"]);
|
||||||
|
expect(model.filters).toEqual([
|
||||||
|
{ id: "all", label: "Alle", count: 4 },
|
||||||
|
{ id: "online", label: "Online", count: 2 },
|
||||||
|
{ id: "unknown", label: "Ungeprüft", count: 1 },
|
||||||
|
{ id: "offline", label: "Offline", count: 1 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps initially unknown links visible while background analysis runs", () => {
|
||||||
|
const model = buildCollectorWorkspaceViewModel([packages[1]], "all", "", true, [], [], "", true);
|
||||||
|
|
||||||
|
expect(model.analyzing).toBe(true);
|
||||||
expect(model.empty).toBe(false);
|
expect(model.empty).toBe(false);
|
||||||
|
expect(model.packages[0].links.map((link) => link.fileName)).toContain("download.bin");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves clipboard and drop appends that arrive while an input draft is open", () => {
|
it("toggles all package identities independent of active filters", () => {
|
||||||
expect(mergeCollectorDraftText(
|
const packageIds = packages.map((pkg) => pkg.id);
|
||||||
"https://example.test/old",
|
expect([...toggleAllCollectorPackageIds(packageIds, new Set())].sort()).toEqual(["package-mixed", "package-sbs"]);
|
||||||
"https://example.test/old\nhttps://example.test/clipboard",
|
expect([...toggleAllCollectorPackageIds(packageIds, new Set(["package-sbs"]))].sort()).toEqual(["package-mixed", "package-sbs"]);
|
||||||
"https://example.test/edited"
|
expect([...toggleAllCollectorPackageIds(packageIds, new Set(packageIds))]).toEqual([]);
|
||||||
)).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" },
|
|
||||||
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
|
|
||||||
], "tab-b", "", false, []);
|
|
||||||
const html = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />);
|
|
||||||
|
|
||||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
for (const heading of ["Name", "Größe", "Hoster", "Status", "Verfügbarkeit", "Hinzugefügt"]) expect(html).toContain(`>${heading}<`);
|
||||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2);
|
expect(html).toContain("SBS14HD");
|
||||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
expect(html).toContain("SBS14HD.part01.rar");
|
||||||
|
expect(html).toContain("SBS14HD.part02.rar");
|
||||||
|
expect(html).toContain("2/2 online");
|
||||||
|
expect(html).toContain("aria-label=\"SBS14HD einklappen\"");
|
||||||
|
expect(html).not.toContain("URL oder Rohzeile");
|
||||||
|
expect(html).not.toContain(">Zeile<");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps empty, busy and error states inside the same table body", () => {
|
it("keeps rows and actions available during background analysis", () => {
|
||||||
const empty = renderToStaticMarkup(
|
const model = buildCollectorWorkspaceViewModel(packages, "all", "", true, ["link-1"], [], "", true);
|
||||||
<CollectorView
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />);
|
||||||
actions={createActions()}
|
const toolbar = CollectorToolbar({ actions: createActions(), model });
|
||||||
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 [
|
expect(html).toContain("Analyse läuft im Hintergrund");
|
||||||
[empty, "Noch keine Links"],
|
expect(html).toContain("SBS14HD.part01.rar");
|
||||||
[busy, "Links werden verarbeitet"],
|
expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false);
|
||||||
[failed, "Import fehlgeschlagen"]
|
expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false);
|
||||||
]) {
|
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
|
||||||
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("renders known hosters as icons with their full name as tooltip", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />);
|
||||||
<CollectorView
|
|
||||||
actions={createActions()}
|
|
||||||
model={buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(html.match(/class=\"collector-row(?: is-selected)?\"/g)).toHaveLength(2);
|
expect(html).toContain('class="collector-hoster-label" title="1Fichier"');
|
||||||
expect(html).not.toContain("data-visual-region=\"collector-empty-state\"");
|
expect(html).toContain('class="collector-hoster-icon" data-hoster="1fichier" src="./provider-icons/onefichier.png"');
|
||||||
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 collapsed packages without child rows when animations are disabled", () => {
|
||||||
const content = CollectorContent({
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], ["package-sbs"], "", false)} />);
|
||||||
actions: createActions(),
|
expect(html).toContain("aria-label=\"SBS14HD ausklappen\"");
|
||||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])
|
expect(html).not.toContain("SBS14HD.part01.rar");
|
||||||
});
|
|
||||||
const labels: string[] = [];
|
|
||||||
visitElements(content, (element) => {
|
|
||||||
if (element.type === "input" && element.props.type === "checkbox") {
|
|
||||||
labels.push(element.props["aria-label"]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(labels).toEqual([
|
it("keeps the animated disclosure frame mounted for compact packages", () => {
|
||||||
"https://example.test/a aus Sammlung A, Zeile 1 auswählen",
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], ["package-sbs"], "", true)} />);
|
||||||
"https://example.test/b aus Sammlung A, Zeile 3 auswählen"
|
expect(html).toContain("collector-package-items-frame is-collapsed is-animated");
|
||||||
]);
|
expect(html).toContain("SBS14HD.part01.rar");
|
||||||
expect(new Set(labels).size).toBe(labels.length);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a high-contrast table heading token in both themes", () => {
|
it("offers selected and all transfer actions", () => {
|
||||||
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
let selected = 0;
|
||||||
|
let all = 0;
|
||||||
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 selected = "";
|
|
||||||
let removals = 0;
|
|
||||||
const actions = createActions({
|
|
||||||
onOpenInput: () => { inputOpens += 1; },
|
|
||||||
onSubmit: () => { queueSubmits += 1; },
|
|
||||||
onQueryChange: (value) => { query = value; },
|
|
||||||
onSelectionChange: (rowId) => { selected = rowId; },
|
|
||||||
onRemoveSelected: () => { removals += 1; }
|
|
||||||
});
|
|
||||||
const toolbar = CollectorToolbar({
|
const toolbar = CollectorToolbar({
|
||||||
actions,
|
actions: createActions({ onSubmitSelected: () => { selected += 1; }, onSubmitAll: () => { all += 1; } }),
|
||||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
model: buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true)
|
||||||
});
|
|
||||||
const content = CollectorContent({
|
|
||||||
actions,
|
|
||||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
|
||||||
});
|
});
|
||||||
|
|
||||||
findButton(toolbar, "Links hinzufügen").props.onClick();
|
findButton(toolbar, "Auswahl übergeben (1)").props.onClick();
|
||||||
expect(inputOpens).toBe(1);
|
findButton(toolbar, "Alle übergeben (4)").props.onClick();
|
||||||
expect(queueSubmits).toBe(0);
|
expect(selected).toBe(1);
|
||||||
|
expect(all).toBe(1);
|
||||||
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("renders accessible mixed package selection", () => {
|
||||||
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true)} />);
|
||||||
|
expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\"");
|
||||||
|
expect(html).toContain("aria-checked=\"mixed\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes status filters and search independently", () => {
|
||||||
|
let filter = "";
|
||||||
|
let query = "";
|
||||||
|
const model = buildCollectorWorkspaceViewModel(packages, "online", "", false, [], [], "", true);
|
||||||
|
const actions = createActions({ onFilterChange: (value) => { filter = value; }, onQueryChange: (value) => { query = value; } });
|
||||||
|
const sidebar = CollectorSidebar({ actions, model });
|
||||||
|
findElement(sidebar, (element) => element.type === "button" && element.props["aria-current"] === "page").props.onClick();
|
||||||
|
const toolbar = CollectorToolbar({ actions, model });
|
||||||
|
findElement(toolbar, (element) => element.props.label === "Links durchsuchen").props.onChange({ target: { value: "part02" } });
|
||||||
|
expect(filter).toBe("online");
|
||||||
|
expect(query).toBe("part02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps errors visible without replacing existing packages", () => {
|
||||||
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "Ein Link konnte nicht geprüft werden", true)} />);
|
||||||
|
expect(html).toContain("Ein Link konnte nicht geprüft werden");
|
||||||
|
expect(html).toContain("SBS14HD.part01.rar");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 erscheinen sofort und werden anschließend im Hintergrund geprüft.");
|
||||||
expect(html).toContain("role=\"dialog\"");
|
expect(html).toContain("Hinzufügen");
|
||||||
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, "Hinzufügen").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,\s*\.collector-package-row,\s*\.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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe("global Escape selection routing", () => {
|
|||||||
expect(api.resolveEscapeSelectionScope?.("settings", "accounts", "BODY")).toBe("accounts");
|
expect(api.resolveEscapeSelectionScope?.("settings", "accounts", "BODY")).toBe("accounts");
|
||||||
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?.("collector", "allgemein", "DIV")).toBe("collector");
|
||||||
expect(api.resolveEscapeSelectionScope?.("history", "accounts", "DIV")).toBe("history");
|
expect(api.resolveEscapeSelectionScope?.("history", "accounts", "DIV")).toBe("history");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ 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 }),
|
||||||
|
prepareCollectorText: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
|
||||||
|
prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
|
||||||
|
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
|
||||||
|
getPathForDroppedFile: () => "",
|
||||||
getStartConflicts: async () => [],
|
getStartConflicts: async () => [],
|
||||||
resolveStartConflict: async (_packageId, policy) => ({
|
resolveStartConflict: async (_packageId, policy) => ({
|
||||||
skipped: policy === "skip",
|
skipped: policy === "skip",
|
||||||
|
|||||||
Reference in New Issue
Block a user