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" - > -