From 14525fa4c30358a3939b055728d95f75fae860bf Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 26 Aug 2026 13:52:49 +0200 Subject: [PATCH] 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. --- src/main/app-controller.ts | 30 +- src/main/collector-inspection.ts | 246 ++++++ src/main/main.ts | 27 +- src/preload/preload.ts | 20 +- src/renderer/App.tsx | 563 ++++++------- src/renderer/collector-drop.ts | 39 + src/renderer/collector-enrichment.ts | 35 + src/renderer/selection.ts | 4 +- .../views/collector/CollectorView.tsx | 501 +++++++----- .../views/collector/collector-model.ts | 349 ++++++-- src/renderer/views/collector/collector.css | 770 +++++++++++------- src/shared/collector.ts | 156 ++++ src/shared/ipc.ts | 9 +- src/shared/preload-api.ts | 15 +- tests/account-preload.test.ts | 29 +- tests/app-shell.test.tsx | 14 +- tests/collector-drop.test.ts | 73 ++ tests/collector-enrichment.test.ts | 37 + tests/collector-inspection.test.ts | 160 ++++ tests/collector-view.test.tsx | 662 ++++++++------- tests/selection.test.ts | 1 + tests/visual/mock-electron-api.ts | 4 + 22 files changed, 2534 insertions(+), 1210 deletions(-) create mode 100644 src/main/collector-inspection.ts create mode 100644 src/renderer/collector-drop.ts create mode 100644 src/renderer/collector-enrichment.ts create mode 100644 src/shared/collector.ts create mode 100644 tests/collector-drop.test.ts create mode 100644 tests/collector-enrichment.test.ts create mode 100644 tests/collector-inspection.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 919cbd4..56825c6 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -44,7 +44,13 @@ import type { RealDebridLoginRequest } from "../shared/preload-api"; import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer"; 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 { AllDebridWebFallback } from "./all-debrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web"; @@ -970,7 +976,7 @@ export class AppController { return result; } - public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { + public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); if (parsed.length === 0) { this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", { @@ -984,10 +990,22 @@ export class AppController { addedLinks: result.addedLinks, requestedPackages: parsed.length }); - return { ...result, invalidCount: 0 }; - } - - public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { + return { ...result, invalidCount: 0 }; + } + + public prepareCollectorText(request: CollectorTextPreparationRequest): CollectorInspectionResult { + return prepareCollectorText(request); + } + + public prepareCollectorContainers(filePaths: string[], addedAt: number): Promise { + return prepareCollectorContainers(filePaths, addedAt); + } + + public enrichCollectorPackages(request: CollectorEnrichmentRequest): Promise { + return enrichCollectorPackages(request, this.settings); + } + + public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { const packages = await importDlcContainers(filePaths); const merged: ParsedPackageInput[] = packages.map((pkg) => ({ name: pkg.name, diff --git a/src/main/collector-inspection.ts b/src/main/collector-inspection.ts new file mode 100644 index 0000000..2823ff2 --- /dev/null +++ b/src/main/collector-inspection.ts @@ -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>; + checkRapidgator?: typeof checkRapidgatorOnline; + resolveFilenames?: (links: string[]) => Promise>; + 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(); + 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(); + 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 = {} +): Promise { + const importContainers = dependencies.importContainers ?? importDlcContainers; + const packages = await importContainers(filePaths); + return { packages: preparePackages(packages, addedAt, "explicit"), invalidCount: 0, duplicateCount: 0 }; +} + +async function runWithConcurrency(items: T[], limit: number, worker: (item: T) => Promise): Promise { + 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(); + 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 { + 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()) + : Promise.resolve(new Map()); + 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()) + : Promise.resolve(new Map()); + 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 }; diff --git a/src/main/main.ts b/src/main/main.ts index 0ffa9da..7a0a148 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -26,6 +26,11 @@ import { migrateProductUserDataDirectory } from "./storage"; import { forceDarkNativeTheme } from "./native-theme"; import { validateClipboardWriteText } from "./clipboard-write"; import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler"; +import { + validateCollectorContainerPreparationRequest, + validateCollectorEnrichmentRequest, + validateCollectorTextPreparationRequest +} from "../shared/collector"; forceDarkNativeTheme(nativeTheme); @@ -552,12 +557,22 @@ function registerIpcHandlers(): void { } return controller.addLinks(payload); }); - handleTrusted(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { - const validPaths = validateStringArray(filePaths ?? [], "filePaths"); - const safePaths = validPaths.filter((p) => path.isAbsolute(p)); - return controller.addContainers(safePaths); - }); - handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); + handleTrusted(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { + const validPaths = validateStringArray(filePaths ?? [], "filePaths"); + const safePaths = validPaths.filter((p) => path.isAbsolute(p)); + 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.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { validateString(packageId, "packageId"); validateString(policy, "policy"); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 8e9555b..21fa0e5 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import { AddLinksPayload, AccountCheckScope, @@ -31,6 +31,11 @@ import { UpdateInstallProgress } from "../shared/types"; import type { RealDebridLoginRequest } from "../shared/preload-api"; +import type { + CollectorEnrichmentRequest, + CollectorInspectionResult, + CollectorTextPreparationRequest +} from "../shared/collector"; import { IPC_CHANNELS } from "../shared/ipc"; import { ElectronApi } from "../shared/preload-api"; @@ -49,9 +54,16 @@ const api: ElectronApi = { deleteAccount: (command: AccountDeleteCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.DELETE_ACCOUNT, command), addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), - addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => - ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), - getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), + addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => + ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), + prepareCollectorText: (request: CollectorTextPreparationRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, request), + prepareCollectorContainers: (filePaths: string[], addedAt: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, filePaths, addedAt), + enrichCollectorPackages: (request: CollectorEnrichmentRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, request), + getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file), + getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), clearAll: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 967b960..9a03f9d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -61,9 +61,17 @@ import { Dialog } from "./ui/Dialog"; import { Icon } from "./ui/Icon"; import { Toast } from "./ui/Toast"; 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 { - buildCollectorViewModel, - type CollectorSourceTab + buildCollectorTransferPackages, + buildCollectorWorkspaceViewModel, + mergeCollectorEnrichment, + mergeCollectorPackages, + removeCollectorLinks, + selectCollectorPackageLinks, + type CollectorWorkspaceFilter } from "./views/collector/collector-model"; import { CollectorContent, @@ -140,65 +148,10 @@ import { } from "./views/settings/SettingsView"; type Tab = MainView; - -type CollectorTab = CollectorSourceTab; interface CollectorInputState { - tabId: string; - tabName: string; - baseText: 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 { entry: StartConflictEntry; @@ -1434,9 +1387,7 @@ 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)}`; } @@ -1735,16 +1686,17 @@ export function App(): ReactElement { const [providerDropTarget, setProviderDropTarget] = useState(null); const [editingPackageId, setEditingPackageId] = useState(null); const [editingName, setEditingName] = useState(""); - const [collectorTabs, setCollectorTabs] = useState([ - { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } - ]); - const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); + const [collectorPackages, setCollectorPackages] = useState([]); + const [collectorFilter, setCollectorFilter] = useState("all"); const [collectorQuery, setCollectorQuery] = useState(""); - const [selectedCollectorRowIds, setSelectedCollectorRowIds] = useState>(() => new Set()); + const [selectedCollectorLinkIds, setSelectedCollectorLinkIds] = useState>(() => new Set()); + const [collapsedCollectorPackageIds, setCollapsedCollectorPackageIds] = useState>(() => new Set()); + const [collectorAnalyzingCount, setCollectorAnalyzingCount] = useState(0); const [collectorError, setCollectorError] = useState(""); const [collectorInput, setCollectorInput] = useState(null); - const collectorTabsRef = useRef(collectorTabs); - const activeCollectorTabRef = useRef(activeCollectorTab); + const collectorPackagesRef = useRef(collectorPackages); + const collectorEnrichmentGenerationsRef = useRef(new Map()); + const importCollectorTextRef = useRef<(rawText: string) => Promise>(() => Promise.resolve()); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); const serverPackageOrderRef = useRef([]); @@ -1864,14 +1816,16 @@ export function App(): ReactElement { columnOrderPersistenceRef.current?.enqueue(order); }, []); - const collectorViewModel = useMemo(() => buildCollectorViewModel( - collectorTabs, - activeCollectorTab, + const collectorViewModel = useMemo(() => buildCollectorWorkspaceViewModel( + collectorPackages, + collectorFilter, collectorQuery, - actionBusy, - [...selectedCollectorRowIds], - collectorError - ), [actionBusy, activeCollectorTab, collectorError, collectorQuery, collectorTabs, selectedCollectorRowIds]); + collectorAnalyzingCount > 0, + [...selectedCollectorLinkIds], + [...collapsedCollectorPackageIds], + collectorError, + snapshot.settings.animatePackageDisclosure + ), [collapsedCollectorPackageIds, collectorAnalyzingCount, collectorError, collectorFilter, collectorPackages, collectorQuery, selectedCollectorLinkIds, snapshot.settings.animatePackageDisclosure]); const historyViewModel = useMemo(() => buildHistoryViewModel( historyEntries, @@ -1890,13 +1844,7 @@ export function App(): ReactElement { [runtimeNow, snapshot, statisticsRange] ); - useEffect(() => { - activeCollectorTabRef.current = activeCollectorTab; - }, [activeCollectorTab]); - - useEffect(() => { - collectorTabsRef.current = collectorTabs; - }, [collectorTabs]); + collectorPackagesRef.current = collectorPackages; useEffect(() => { activeTabRef.current = tab; @@ -2235,16 +2183,11 @@ export function App(): ReactElement { latestStateRef.current = null; } }, flushDelay); - }); - unsubClipboard = window.rd.onClipboardDetected((links) => { - showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); - setCollectorTabs((prev) => { - 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); - }); - }); + }); + unsubClipboard = window.rd.onClipboardDetected((links) => { + showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); + void importCollectorTextRef.current(links.join("\n")); + }); unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => { if (!mountedRef.current) { return; @@ -3626,49 +3569,144 @@ export function App(): ReactElement { } }; - const onAddLinks = async (): Promise => { - setCollectorError(""); - await performQuickAction(async () => { - const activeId = activeCollectorTabRef.current; - const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0]; - const rawText = active?.text ?? ""; - const persisted = await persistDraftSettings(); - const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); - const 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 mergeCollectorResult = (result: CollectorInspectionResult, enrichment = false): void => { + setCollectorPackages((current) => { + const merged = enrichment + ? mergeCollectorEnrichment(current, result.packages) + : mergeCollectorPackages(current, result.packages); + collectorPackagesRef.current = merged.packages; + return merged.packages; }); }; - const onImportDlc = async (): Promise => { + 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 => { + if (!rawText.trim()) { + showToast("Keine Links eingegeben", 2200); + return; + } setCollectorError(""); + 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 => { + 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 => { + const files = await window.rd.pickContainers(); + if (files.length > 0) { + await importCollectorContainers(files); + } + }; + + const submitCollectorPackages = async (packages: CollectorPackage[]): Promise => { + 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 files = await window.rd.pickContainers(); - if (files.length === 0) { return; } - await persistDraftSettings(); - const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); - const result = await window.rd.addContainers(files); - if (result.addedLinks > 0) { - showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); - if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden"); - showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const result = await window.rd.addLinks({ rawText: serializeCollectorPackages(transferable), packageName: "" }); + if (result.addedLinks !== linkIds.size) { + showToast(`${result.addedLinks} von ${linkIds.size} Link(s) übergeben; Sammlung bleibt erhalten`, 3200); + return; + } + setCollectorPackages((current) => { + 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) => { - setCollectorError(`Fehler beim DLC-Import: ${String(error)}`); - showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); + const message = `Übergabe fehlgeschlagen: ${String(error)}`; + setCollectorError(message); + showToast(message, 2800); }); - }; + }; const onExportPackageSelection = async (packageIds: string[]): Promise => { closeMenus(); @@ -3702,61 +3740,43 @@ export function App(): ReactElement { dragOverRef.current = false; setDragOver(false); const hasFiles = event.dataTransfer.types.includes("Files"); - const hasUri = event.dataTransfer.types.includes("text/uri-list"); - if (!hasFiles && !hasUri) { return; } - 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 importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name)); - const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; - if (dlc.length > 0) { - setCollectorError(""); - await performQuickAction(async () => { - await persistDraftSettings(); - const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); - const result = await window.rd.addContainers(dlc); - if (result.addedLinks > 0) { - showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); - if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden"); + const hasUri = event.dataTransfer.types.includes("text/uri-list"); + if (!hasFiles && !hasUri) { return; } + const files = Array.from(event.dataTransfer.files ?? []) as File[]; + const hasDlc = files.some((file) => file.name.toLowerCase().endsWith(".dlc")); + const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name)); + const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; + if (hasDlc) { + try { + const mode = tabRef.current === "collector" ? "collector" : "downloads"; + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const routed = await routeDroppedDlcFiles(files, mode, window.rd.getPathForDroppedFile, { + addContainers: window.rd.addContainers, + 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); } + } else if (routed.kind === "downloads") { showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); } - }, (error) => { - setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`); + } catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); - }); + } } else if (importFiles.length > 0) { - setCollectorError(""); - await performQuickAction(async () => { - await persistDraftSettings(); - const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); - let addedPackages = 0; - let addedLinks = 0; - for (const file of importFiles) { - const text = await file.text(); - const result = await window.rd.importQueue(text); - addedPackages += result.addedPackages; - addedLinks += result.addedLinks; - } - if (addedLinks > 0) { - showToast(`Importiert: ${addedPackages} Paket(e), ${addedLinks} Link(s)`); - if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - setCollectorError("Keine gültigen Links in den Import-Dateien gefunden"); - showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000); - } - }, (error) => { - setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`); + try { + const text = (await Promise.all(importFiles.map((file) => file.text()))).join("\n"); + await importCollectorText(text); + } catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); - }); - } else if (droppedText.trim()) { - const activeCollectorId = activeCollectorTabRef.current; - 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"); - } - }; + } + } else if (droppedText.trim()) { + await importCollectorText(droppedText); + } + }; const onExportQueue = async (): Promise => { await performQuickAction(async () => { @@ -3797,28 +3817,18 @@ export function App(): ReactElement { input.onchange = async () => { clearImportQueueFocusListener(); const file = input.files?.[0]; - if (!file) { - releasePickerBusy(); - return; - } - releasePickerBusy(); - await performQuickAction(async () => { - await persistDraftSettings(); - const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); - const text = await file.text(); - const result = await window.rd.importQueue(text); - if (result.addedLinks > 0) { - showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); - if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - setCollectorError("Keine gültigen Links in der Datei gefunden"); - showToast("Keine gültigen Links in der Datei gefunden", 3000); - } - }, (error) => { - setCollectorError(`Import fehlgeschlagen: ${String(error)}`); + if (!file) { + releasePickerBusy(); + return; + } + releasePickerBusy(); + try { + const text = await file.text(); + await importCollectorText(text); + } catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); - }); - }; + } + }; clearImportQueueFocusListener(); importQueueFocusHandlerRef.current = onWindowFocus; @@ -3922,112 +3932,57 @@ export function App(): ReactElement { }); }, [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 activeId = activeCollectorTabRef.current; - const active = collectorTabsRef.current.find((entry) => entry.id === activeId) ?? collectorTabsRef.current[0]; - if (!active) { - return; - } setCollectorError(""); - setCollectorInput({ - tabId: active.id, - tabName: active.name, - baseText: active.text, - draft: active.text - }); + setCollectorInput({ draft: "" }); }; const commitCollectorInput = (): void => { if (!collectorInput) { return; } - const input = collectorInput; - setCollectorTabs((prev) => { - const currentText = prev.find((entry) => entry.id === input.tabId)?.text ?? input.baseText; - const text = mergeCollectorDraftText(input.baseText, currentText, input.draft); - return planCollectorTextReplacement(prev, input.tabId, text).tabs; - }); - setSelectedCollectorRowIds(new Set()); + const draft = collectorInput.draft; setCollectorInput(null); - setCollectorError(""); + void importCollectorText(draft); }; - const toggleCollectorRowSelection = (rowId: string): void => { - setSelectedCollectorRowIds((prev) => { + const setCollectorLinkSelection = (linkId: string, selected: boolean): void => { + setSelectedCollectorLinkIds((prev) => { const next = new Set(prev); - if (next.has(rowId)) { - next.delete(rowId); - } else { - next.add(rowId); - } + if (selected) next.add(linkId); + else next.delete(linkId); return next; }); }; - const removeSelectedCollectorRows = (): void => { - if (selectedCollectorRowIds.size === 0) { - return; + const setCollectorPackageSelection = (packageId: string, selected: boolean): void => { + const pkg = collectorPackagesRef.current.find((entry) => entry.id === packageId); + if (pkg) { + setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, pkg, selected)); } - const activeId = activeCollectorTabRef.current; - const indexes = new Set(); - for (const rowId of selectedCollectorRowIds) { - const separator = rowId.lastIndexOf(":"); - if (separator <= 0 || rowId.slice(0, separator) !== activeId) { - continue; - } - const index = Number(rowId.slice(separator + 1)); - if (Number.isInteger(index) && index >= 0) { - indexes.add(index); - } - } - if (indexes.size === 0) { + }; + + const toggleCollectorPackageCollapse = (packageId: string): void => { + setCollapsedCollectorPackageIds((current) => { + const next = new Set(current); + if (next.has(packageId)) next.delete(packageId); + else next.add(packageId); + return next; + }); + }; + + const toggleAllCollectorPackages = (): void => { + 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; } + const removedIds = new Set(selectedCollectorLinkIds); void askConfirmPrompt({ 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.", @@ -4037,10 +3992,12 @@ export function App(): ReactElement { if (!confirmed) { return; } - setCollectorTabs((prev) => prev.map((entry) => entry.id === activeId - ? { ...entry, text: entry.text.split(/\r?\n/).filter((_line, index) => !indexes.has(index)).join("\n") } - : entry)); - setSelectedCollectorRowIds(new Set()); + setCollectorPackages((current) => { + const next = removeCollectorLinks(current, removedIds); + collectorPackagesRef.current = next; + return next; + }); + setSelectedCollectorLinkIds(new Set()); setCollectorError(""); }); }; @@ -4509,6 +4466,7 @@ export function App(): ReactElement { if (selectionScope) { if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return; if (selectionScope === "downloads") setSelectedIds(new Set()); + else if (selectionScope === "collector") setSelectedCollectorLinkIds(new Set()); else if (selectionScope === "history") setSelectedHistoryIds(new Set()); else if (selectedAccountRowKeys.size > 0) { setSelectedAccountRowKeys(new Set()); @@ -5281,22 +5239,20 @@ export function App(): ReactElement { } }; const collectorActions: CollectorViewActions = { - onTabSelect: (tabId) => { - activeCollectorTabRef.current = tabId; - setActiveCollectorTab(tabId); - setSelectedCollectorRowIds(new Set()); - setCollectorError(""); - }, - onTabAdd: addCollectorTab, - onTabRemove: removeCollectorTab, + onFilterChange: setCollectorFilter, onOpenInput: openCollectorInput, onImportDlc: () => { void onImportDlc(); }, onImportFile: () => { void onImportQueue(); }, - onExportQueue: () => { void onExportQueue(); }, - onSubmit: () => { void onAddLinks(); }, + onSubmitSelected: () => { + void submitCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, selectedCollectorLinkIds)); + }, + onSubmitAll: () => { void submitCollectorPackages(collectorPackagesRef.current); }, onQueryChange: setCollectorQuery, - onSelectionChange: toggleCollectorRowSelection, - onRemoveSelected: removeSelectedCollectorRows + onLinkSelectionChange: setCollectorLinkSelection, + onPackageSelectionChange: setCollectorPackageSelection, + onPackageCollapseChange: toggleCollectorPackageCollapse, + onToggleAllPackages: toggleAllCollectorPackages, + onRemoveSelected: removeSelectedCollectorLinks }; const settingsFormModel = useMemo(() => buildSettingsFormViewModel({ @@ -6266,9 +6222,9 @@ export function App(): ReactElement { ) : tab === "collector" ? ( <> - Sammlungen: {collectorViewModel.tabs.length} - Links: {collectorViewModel.tabs.reduce((sum, entry) => sum + entry.linkCount, 0)} - Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"} + Pakete: {collectorPackages.length} + Links: {collectorViewModel.totalCount} + Ausgewählt: {collectorViewModel.selectedCount} ) : tab === "history" ? ( <> @@ -6908,7 +6864,6 @@ export function App(): ReactElement { onClose={() => setCollectorInput(null)} onCommit={commitCollectorInput} open - tabName={collectorInput.tabName} value={collectorInput.draft} /> ) : null} diff --git a/src/renderer/collector-drop.ts b/src/renderer/collector-drop.ts new file mode 100644 index 0000000..0b39eca --- /dev/null +++ b/src/renderer/collector-drop.ts @@ -0,0 +1,39 @@ +export type CollectorDlcDropMode = "downloads" | "collector"; + +export function resolveDroppedDlcPaths( + files: ReadonlyArray, + 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( + files: ReadonlyArray, + mode: CollectorDlcDropMode, + getPathForFile: (file: File) => string, + dependencies: { + addContainers: (filePaths: string[]) => Promise; + inspectContainers: (filePaths: string[], addedAt: number) => Promise; + }, + 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) }; +} diff --git a/src/renderer/collector-enrichment.ts b/src/renderer/collector-enrichment.ts new file mode 100644 index 0000000..8e0e8a8 --- /dev/null +++ b/src/renderer/collector-enrichment.ts @@ -0,0 +1,35 @@ +import type { CollectorPackage } from "../shared/collector"; + +export type CollectorEnrichmentGenerationSnapshot = Map; + +function collectorUrlKey(url: string): string { + return url.trim(); +} + +export function beginCollectorEnrichment( + packages: CollectorPackage[], + current: Map +): CollectorEnrichmentGenerationSnapshot { + const snapshot = new Map(); + 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 +): 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 }] : []; + }); +} diff --git a/src/renderer/selection.ts b/src/renderer/selection.ts index 4e403be..a756bc7 100644 --- a/src/renderer/selection.ts +++ b/src/renderer/selection.ts @@ -18,11 +18,11 @@ export function resolveEscapeSelectionScope( settingsSection: string, tagName: string, inputType = "" -): "downloads" | "history" | "accounts" | null { +): "downloads" | "collector" | "history" | "accounts" | null { if (!shouldClearDownloadSelectionOnEscape(tagName, inputType)) { return null; } - if (view === "downloads" || view === "history") { + if (view === "downloads" || view === "collector" || view === "history") { return view; } return view === "settings" && settingsSection === "accounts" ? "accounts" : null; diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index 7ec5acd..b134457 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -1,210 +1,303 @@ -import type { ChangeEvent, ReactElement } from "react"; -import { - DataTable, - DataTableBody, - DataTableEmpty, - DataTableHeader -} from "../../ui/DataTable"; -import { Dialog } from "../../ui/Dialog"; -import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar"; +import type { ChangeEvent, ReactElement } from "react"; +import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format"; +import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable"; +import { Dialog } from "../../ui/Dialog"; import { SlidingSelection } from "../../ui/SlidingSelection"; -import type { CollectorViewModel } from "./collector-model"; -import "./collector.css"; - -export interface CollectorViewActions { - onTabSelect: (tabId: string) => void; - onTabAdd: () => void; - onTabRemove: (tabId: string) => void; - onOpenInput: () => void; - onImportDlc: () => void; - onImportFile: () => void; - onExportQueue: () => void; - onSubmit: () => void; - onQueryChange: (value: string) => void; - onSelectionChange: (rowId: string) => void; - onRemoveSelected: () => void; -} - -export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content"; - -export interface CollectorViewProps { - model: CollectorViewModel; - actions: CollectorViewActions; - region?: CollectorViewRegion; -} - -export interface CollectorInputDialogProps { - open: boolean; - tabName: string; - value: string; - onChange: (value: string) => void; - onClose: () => void; - onCommit: () => void; -} - +import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar"; +import type { + CollectorWorkspaceFilter, + CollectorWorkspacePackageRow, + CollectorWorkspaceViewModel +} from "./collector-model"; +import "./collector.css"; + +export interface CollectorViewActions { + onFilterChange: (filter: CollectorWorkspaceFilter) => void; + onOpenInput: () => void; + onImportDlc: () => void; + onImportFile: () => void; + onSubmitSelected: () => void; + onSubmitAll: () => void; + onQueryChange: (value: string) => void; + onLinkSelectionChange: (linkId: string, selected: boolean) => void; + onPackageSelectionChange: (packageId: string, selected: boolean) => void; + onPackageCollapseChange: (packageId: string) => void; + onToggleAllPackages: () => void; + onRemoveSelected: () => void; +} + +export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content"; + +export interface CollectorViewProps { + model: CollectorWorkspaceViewModel; + actions: CollectorViewActions; + region?: CollectorViewRegion; +} + +export interface CollectorInputDialogProps { + open: boolean; + tabName?: string; + value: string; + onChange: (value: string) => void; + onClose: () => 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 +): Set { + return packageIds.some((packageId) => !collapsedPackageIds.has(packageId)) + ? new Set(packageIds) + : new Set(); +} + +function CollectorHosterLabel({ hoster }: { hoster: ReturnType }): ReactElement { + return ( + + {hoster.iconSrc ? ( + <> + { + event.currentTarget.hidden = true; + event.currentTarget.nextElementSibling?.removeAttribute("hidden"); + }} + src={hoster.iconSrc} + /> + + + ) : hoster.compact} + + ); +} + export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement { return ( -
-
- Sammlungen - {model.tabs.length} -
- - {model.tabs.map((tab) => ( -
- - {model.tabs.length > 1 ? ( - - ) : null} -
- ))} +
+
Status{model.totalCount}
+ + {model.filters.map((filter) => ( + + ))} - -
- ); -} - +
+ ); +} + export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement { - const activeTab = model.tabs.find((tab) => tab.id === model.activeTabId) ?? model.tabs[0]; return ( - - - - - - - - - - - - actions.onQueryChange(event.target.value)} - placeholder="Links durchsuchen" - value={model.query} - /> - - ); -} - -export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement { - const selected = new Set(model.selectedIds); - return ( -
- - -
- - Sammlung - URL oder Rohzeile - Zeile - Status -
-
- - {model.busy ? ( - - ) : model.error ? ( - - ) : model.empty ? ( - - ) : ( - model.rows.map((row) => ( -
- - actions.onSelectionChange(row.id)} - type="checkbox" - /> - - {row.tabName} - {row.value} - {row.lineNumber} - Lokal -
- )) - )} -
-
-
- ); -} - -export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement { - if (region === "sidebar") { - return ; - } - if (region === "toolbar") { - return ; - } - if (region === "content") { - return ; - } - return ( -
- -
- - -
-
- ); -} - -export function CollectorInputDialog({ - open, - tabName, - value, - onChange, - onClose, - onCommit -}: CollectorInputDialogProps): ReactElement | null { - return ( - - - - - )} - description={`Links für ${tabName} lokal erfassen.`} - onClose={onClose} - open={open} - size="wide" - title="Links hinzufügen" - > -