diff --git a/CHANGELOG.md b/CHANGELOG.md index 6727055..118d075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ All notable changes to Multi-Debrid Downloader are documented in this file. - Resolve original 1Fichier filenames, exact sizes, and availability in batches before downloads start. - Keep resolved filenames when a debrid provider returns only a generic `download.bin` name. - Group supported 1Fichier mirror domains under one hoster identity. +- Display the full `1Fichier` hoster name consistently in the link collector and Downloads. + +### Link collector + +- Rebuilt the link collector as a package-oriented preview with expandable file rows, resolved metadata, availability filters, stable selection, and selected or complete transfer to Downloads. +- Route pasted links, clipboard detections, text files, drag-and-drop, and DLC containers through inspection before they enter the download queue. ## [2.0.54] - 2026-08-21 diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index e4d3b84..4f75299 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -44,7 +44,9 @@ 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 { inspectCollectorPackages, inspectCollectorText } from "./collector-inspection"; +import type { CollectorInspectionRequest, CollectorInspectionResult } from "../shared/collector"; import { configureLogger, flushLoggerSync, getLogFilePath, logger } from "./logger"; import { AllDebridWebFallback } from "./all-debrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web"; @@ -909,7 +911,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", { @@ -924,9 +926,18 @@ export class AppController { requestedPackages: parsed.length }); return { ...result, invalidCount: 0 }; - } - - public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { + } + + public inspectCollectorText(request: CollectorInspectionRequest): Promise { + return inspectCollectorText(request, this.settings); + } + + public async inspectCollectorContainers(filePaths: string[], addedAt: number): Promise { + const packages = await importDlcContainers(filePaths); + return inspectCollectorPackages(packages, this.settings, addedAt, {}, true); + } + + 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..3083a36 --- /dev/null +++ b/src/main/collector-inspection.ts @@ -0,0 +1,219 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import { serializeCollectorPackages } from "../shared/collector"; +import type { CollectorInspectionRequest, CollectorInspectionResult, CollectorLink, CollectorPackage } from "../shared/collector"; +import type { AppSettings, ParsedPackageInput } from "../shared/types"; +import { extractHosterFromUrl } from "../shared/hoster"; +import { checkOneFichierLinks, checkRapidgatorOnline, DebridService, isOneFichierLink, type OneFichierCheckResult } from "./debrid"; +import { parseCollectorInput } from "./link-parser"; +import { filenameFromUrl, isHttpLink, looksLikeOpaqueFilename, sanitizeFilename } from "./utils"; + +interface CollectorInspectionDependencies { + checkOneFichier?: (links: string[]) => Promise>; + checkRapidgator?: typeof checkRapidgatorOnline; + resolveFilenames?: (links: string[]) => Promise>; + createId?: (prefix: "package" | "link") => string; +} + +interface SourceLink { + url: string; + fileName: string; + packageName: string; + explicitFileName: boolean; +} + +function readableHosterName(hoster: string): string { + if (hoster === "1fichier") return "1Fichier"; + if (hoster === "rapidgator") return "RapidGator"; + if (hoster === "ddownload") return "DDownload"; + return hoster ? hoster.charAt(0).toLocaleUpperCase("de") + hoster.slice(1) : "Gesammelte Links"; +} + +export function inferCollectorPackageName(fileName: string, hoster: string): string { + const safeName = sanitizeFilename(fileName || ""); + if (!safeName || looksLikeOpaqueFilename(safeName)) { + return readableHosterName(hoster); + } + const patterns = [ + /^(.*)\.part\d+\.rar$/i, + /^(.*)\.pa?r?t?\.?\d+.*?\.rar$/i, + /^(.*)\.r\d{2,3}$/i, + /^(.*)\.(?:7z|zip)\.\d{3}$/i, + /^(.*)\.part\d+$/i + ]; + for (const pattern of patterns) { + const match = safeName.match(pattern); + if (match?.[1]?.trim()) { + return sanitizeFilename(match[1]); + } + } + const stem = path.parse(safeName).name.trim(); + return sanitizeFilename(stem || readableHosterName(hoster)); +} + +async function runWithConcurrency(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 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 flattenPackages(packages: ParsedPackageInput[]): SourceLink[] { + const seen = new Set(); + const links: SourceLink[] = []; + for (const pkg of packages) { + for (let index = 0; index < pkg.links.length; index += 1) { + const url = String(pkg.links[index] || "").trim(); + if (!url || seen.has(url)) { + continue; + } + seen.add(url); + const explicitName = String(pkg.fileNames?.[index] || "").trim(); + links.push({ + url, + fileName: explicitName ? sanitizeFilename(explicitName) : filenameFromUrl(url), + packageName: sanitizeFilename(pkg.name), + explicitFileName: Boolean(explicitName) + }); + } + } + return links; +} + +function groupLinks( + links: CollectorLink[], + sourceLinks: SourceLink[], + preservePackageNames: boolean, + addedAt: number, + createId: (prefix: "package" | "link") => string +): CollectorPackage[] { + const sourceByUrl = new Map(sourceLinks.map((link) => [link.url, link])); + const packageByKey = new Map(); + const packages: CollectorPackage[] = []; + for (const link of links) { + const source = sourceByUrl.get(link.url); + const name = preservePackageNames && source?.packageName + ? source.packageName + : inferCollectorPackageName(link.fileName, link.hoster); + const key = name.toLocaleLowerCase("de"); + let pkg = packageByKey.get(key); + if (!pkg) { + pkg = { id: createId("package"), name, links: [], addedAt }; + packageByKey.set(key, pkg); + packages.push(pkg); + } + pkg.links.push(link); + } + return packages; +} + +export async function inspectCollectorPackages( + packages: ParsedPackageInput[], + settings: AppSettings, + addedAt: number, + dependencies: CollectorInspectionDependencies = {}, + preservePackageNames = true +): Promise { + const createId = dependencies.createId ?? ((prefix) => `${prefix}-${crypto.randomUUID()}`); + const sourceLinks = flattenPackages(packages); + const linksByUrl = new Map(); + for (const source of sourceLinks) { + const hoster = extractHosterFromUrl(source.url); + linksByUrl.set(source.url, { + id: createId("link"), + url: source.url, + fileName: source.fileName, + fileSizeBytes: null, + hoster, + availability: "unknown", + status: source.explicitFileName ? "ready" : "unknown", + addedAt + }); + } + + const oneFichierLinks = sourceLinks.map((link) => link.url).filter(isOneFichierLink); + const rapidgatorLinks = sourceLinks.map((link) => link.url).filter((url) => extractHosterFromUrl(url) === "rapidgator"); + const genericLinks = sourceLinks + .filter((source) => !source.explicitFileName && looksLikeOpaqueFilename(source.fileName)) + .map((source) => source.url) + .filter((url) => !oneFichierLinks.includes(url) && !rapidgatorLinks.includes(url)); + + const checkOneFichier = dependencies.checkOneFichier ?? checkOneFichierLinks; + const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline; + const resolveFilenames = dependencies.resolveFilenames ?? ((urls) => new DebridService(settings).resolveFilenames(urls)); + + const oneFichierPromise = checkOneFichier(oneFichierLinks).catch(() => new Map()); + const rapidgatorPromise = runWithConcurrency(rapidgatorLinks, 8, async (url) => { + const result = await checkRapidgator(url).catch(() => null); + const link = linksByUrl.get(url); + if (!link || !result) return; + link.availability = result.online ? "online" : "offline"; + link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; + if (result.fileName) link.fileName = sanitizeFilename(result.fileName); + if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; + }); + const genericPromise = resolveFilenames(genericLinks).catch(() => new Map()); + + const [oneFichierResults, genericResults] = await Promise.all([oneFichierPromise, genericPromise, rapidgatorPromise]).then(([one, generic]) => [one, generic] as const); + for (const [url, result] of oneFichierResults) { + const link = linksByUrl.get(url); + if (!link) continue; + link.availability = result.online ? "online" : "offline"; + link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; + if (result.fileName) link.fileName = sanitizeFilename(result.fileName); + if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; + } + for (const [url, fileName] of genericResults) { + const link = linksByUrl.get(url); + if (!link || !fileName) continue; + link.fileName = sanitizeFilename(fileName); + link.status = "ready"; + } + + return { + packages: groupLinks(Array.from(linksByUrl.values()), sourceLinks, preservePackageNames, addedAt, createId), + invalidCount: 0, + duplicateCount: 0 + }; +} + +export async function inspectCollectorText( + request: CollectorInspectionRequest, + settings: AppSettings, + dependencies: CollectorInspectionDependencies = {} +): Promise { + const counts = countInputLines(request.rawText); + const parsed = parseCollectorInput(request.rawText, ""); + const preservePackageNames = /^#\s*package\s*:/im.test(request.rawText); + const result = await inspectCollectorPackages(parsed, settings, request.addedAt, dependencies, preservePackageNames); + return { ...result, ...counts }; +} + +export { serializeCollectorPackages }; diff --git a/src/main/main.ts b/src/main/main.ts index 5af8909..44151a3 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -23,6 +23,7 @@ import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EX import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { validateRealDebridLoginRequest } from "../shared/preload-api"; import { migrateProductUserDataDirectory } from "./storage"; +import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -471,11 +472,22 @@ function registerIpcHandlers(): void { } return controller.addLinks(payload); }); - handleTrusted(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { + 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); - }); + return controller.addContainers(safePaths); + }); + handleTrusted(IPC_CHANNELS.INSPECT_COLLECTOR_TEXT, (_event: IpcMainInvokeEvent, rawRequest: unknown) => { + return controller.inspectCollectorText(validateCollectorInspectionRequest(rawRequest)); + }); + handleTrusted(IPC_CHANNELS.INSPECT_COLLECTOR_CONTAINERS, (_event: IpcMainInvokeEvent, rawPaths: unknown, rawAddedAt: unknown) => { + const request = validateCollectorContainerInspectionRequest(rawPaths, rawAddedAt); + const safePaths = request.filePaths.filter((filePath) => path.isAbsolute(filePath)); + if (safePaths.length !== request.filePaths.length) { + throw new Error("Container-Payload ist ungültig"); + } + return controller.inspectCollectorContainers(safePaths, request.addedAt); + }); handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { validateString(packageId, "packageId"); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 8e9555b..66bae99 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -31,6 +31,7 @@ import { UpdateInstallProgress } from "../shared/types"; import type { RealDebridLoginRequest } from "../shared/preload-api"; +import type { CollectorInspectionRequest, CollectorInspectionResult } from "../shared/collector"; import { IPC_CHANNELS } from "../shared/ipc"; import { ElectronApi } from "../shared/preload-api"; @@ -49,8 +50,12 @@ 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), + addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => + ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), + inspectCollectorText: (request: CollectorInspectionRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.INSPECT_COLLECTOR_TEXT, request), + inspectCollectorContainers: (filePaths: string[], addedAt: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.INSPECT_COLLECTOR_CONTAINERS, filePaths, addedAt), getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2c735a2..93558a2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,6 +2,8 @@ import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect import { flushSync } from "react-dom"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import type { RealDebridLoginRequest } from "../shared/preload-api"; +import { serializeCollectorPackages } from "../shared/collector"; +import type { CollectorInspectionResult, CollectorPackage } from "../shared/collector"; import type { AccountCreateCommand, AllDebridHostInfo, @@ -61,8 +63,12 @@ import { Dialog } from "./ui/Dialog"; import { Icon } from "./ui/Icon"; import { Toast } from "./ui/Toast"; import { - buildCollectorViewModel, - type CollectorSourceTab + buildCollectorTransferPackages, + buildCollectorWorkspaceViewModel, + mergeCollectorPackages, + removeCollectorLinks, + selectCollectorPackageLinks, + type CollectorWorkspaceFilter } from "./views/collector/collector-model"; import { CollectorContent, @@ -138,64 +144,9 @@ import { 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; @@ -1375,9 +1326,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)}`; } @@ -1556,16 +1505,18 @@ 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 [collectorBusy, setCollectorBusy] = useState(false); const [collectorError, setCollectorError] = useState(""); const [collectorInput, setCollectorInput] = useState(null); - const collectorTabsRef = useRef(collectorTabs); - const activeCollectorTabRef = useRef(activeCollectorTab); + const collectorPackagesRef = useRef(collectorPackages); + const inspectCollectorTextRef = useRef<(rawText: string) => Promise>(() => Promise.resolve()); + const collectorInspectionQueueRef = useRef>(Promise.resolve()); + const collectorInspectionPendingRef = useRef(0); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); const serverPackageOrderRef = useRef([]); @@ -1680,14 +1631,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]); + collectorBusy || actionBusy, + [...selectedCollectorLinkIds], + [...collapsedCollectorPackageIds], + collectorError, + snapshot.settings.animatePackageDisclosure + ), [actionBusy, collapsedCollectorPackageIds, collectorBusy, collectorError, collectorFilter, collectorPackages, collectorQuery, selectedCollectorLinkIds, snapshot.settings.animatePackageDisclosure]); const historyViewModel = useMemo(() => buildHistoryViewModel( historyEntries, @@ -1706,13 +1659,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; @@ -2046,15 +1993,10 @@ export function App(): ReactElement { } }, 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 inspectCollectorTextRef.current(links.join("\n")); + }); unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => { if (!mountedRef.current) { return; @@ -3463,49 +3405,74 @@ export function App(): ReactElement { } }; - const onAddLinks = async (): Promise => { + const enqueueCollectorInspection = (inspect: () => Promise): Promise => { + collectorInspectionPendingRef.current += 1; + setCollectorBusy(true); setCollectorError(""); + const run = collectorInspectionQueueRef.current.catch(() => undefined).then(async () => { + const result = await inspect(); + const incomingCount = result.packages.reduce((sum, pkg) => sum + pkg.links.length, 0); + setCollectorPackages((current) => mergeCollectorPackages(current, result.packages).packages); + setCollectorFilter("all"); + setTab("collector"); + const ignored = result.invalidCount + result.duplicateCount; + showToast(`${incomingCount} Link(s) analysiert${ignored > 0 ? ` · ${ignored} ignoriert` : ""}`, 2600); + }).catch((error) => { + setCollectorError(`Analyse fehlgeschlagen: ${String(error)}`); + showToast(`Analyse fehlgeschlagen: ${String(error)}`, 3000); + }).finally(() => { + collectorInspectionPendingRef.current = Math.max(0, collectorInspectionPendingRef.current - 1); + if (collectorInspectionPendingRef.current === 0) setCollectorBusy(false); + }); + collectorInspectionQueueRef.current = run; + return run; + }; + + const inspectCollectorRawText = (rawText: string): Promise => { + if (!rawText.trim()) { + showToast("Keine Links gefunden", 2200); + return Promise.resolve(); + } + return enqueueCollectorInspection(() => window.rd.inspectCollectorText({ rawText, addedAt: Date.now() })); + }; + inspectCollectorTextRef.current = inspectCollectorRawText; + + const transferCollectorPackages = async (packages: CollectorPackage[]): Promise => { + const transferable = packages.flatMap((pkg) => { + const links = pkg.links.filter((link) => link.availability !== "offline"); + return links.length > 0 ? [{ ...pkg, links }] : []; + }); + const transferredIds = new Set(transferable.flatMap((pkg) => pkg.links.map((link) => link.id))); + if (transferredIds.size === 0) { + showToast("Keine übertragbaren Links ausgewählt", 2400); + return; + } await performQuickAction(async () => { - 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"); - } + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const result = await window.rd.addLinks({ rawText: serializeCollectorPackages(transferable) }); + if (result.addedLinks !== transferredIds.size) { + throw new Error(`Nur ${result.addedLinks} von ${transferredIds.size} Links wurden übernommen`); + } + setCollectorPackages((current) => removeCollectorLinks(current, transferredIds)); + setSelectedCollectorLinkIds((current) => { + const next = new Set(current); + for (const id of transferredIds) next.delete(id); + return next; + }); + showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) an Downloads übergeben`); + if (snapshotRef.current.settings.collapseNewPackages) await collapseNewPackages(existingIds); }, (error) => { - setCollectorError(`Fehler beim Hinzufügen: ${String(error)}`); - showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); + setCollectorError(`Übergabe fehlgeschlagen: ${String(error)}`); + showToast(`Übergabe fehlgeschlagen: ${String(error)}`, 3000); }); }; const onImportDlc = async (): Promise => { setCollectorError(""); - 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); - } - }, (error) => { - setCollectorError(`Fehler beim DLC-Import: ${String(error)}`); - showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); - }); - }; + const files = await window.rd.pickContainers(); + if (files.length === 0) return; + await enqueueCollectorInspection(() => window.rd.inspectCollectorContainers(files, Date.now())); + }; const onExportPackageSelection = async (packageIds: string[]): Promise => { closeMenus(); @@ -3544,55 +3511,15 @@ export function App(): ReactElement { 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") || ""; + 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"); - showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); - } - }, (error) => { - setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`); - showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); - }); + await enqueueCollectorInspection(() => window.rd.inspectCollectorContainers(dlc, Date.now())); } 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)}`); - 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"); - } + const importedText = (await Promise.all(importFiles.map((file) => file.text()))).join("\n"); + await inspectCollectorRawText(importedText); + } else if (droppedText.trim()) { + await inspectCollectorRawText(droppedText); + } }; const onExportQueue = async (): Promise => { @@ -3634,28 +3561,13 @@ 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)}`); - showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); - }); - }; + if (!file) { + releasePickerBusy(); + return; + } + releasePickerBusy(); + await inspectCollectorRawText(await file.text()); + }; clearImportQueueFocusListener(); importQueueFocusHandlerRef.current = onWindowFocus; @@ -3759,112 +3671,35 @@ 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()); + if (!collectorInput) return; + const rawText = collectorInput.draft; setCollectorInput(null); - setCollectorError(""); + void inspectCollectorRawText(rawText); }; - const toggleCollectorRowSelection = (rowId: string): void => { - setSelectedCollectorRowIds((prev) => { + const toggleCollectorLinkSelection = (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 toggleCollectorPackageSelection = (packageId: string, selected: boolean): void => { + const pkg = collectorPackagesRef.current.find((entry) => entry.id === packageId); + if (!pkg) return; + setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, pkg, selected)); + }; + const removeSelectedCollectorRows = (): void => { - if (selectedCollectorRowIds.size === 0) { - return; - } - 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) { - return; - } + if (selectedCollectorLinkIds.size === 0) return; 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.", @@ -3874,10 +3709,8 @@ 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) => removeCollectorLinks(current, selectedCollectorLinkIds)); + setSelectedCollectorLinkIds(new Set()); setCollectorError(""); }); }; @@ -4346,6 +4179,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()); @@ -4368,7 +4202,7 @@ export function App(): ReactElement { window.addEventListener("keydown", onKey); window.addEventListener("mousedown", onDown); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); }; - }, [requestDeleteSelection, selectedAccountRowKeys, selectedIds, settingsSubTab]); + }, [requestDeleteSelection, selectedAccountRowKeys, selectedCollectorLinkIds, selectedIds, settingsSubTab]); const onExportBackup = async (): Promise => { closeMenus(); @@ -5067,21 +4901,21 @@ 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 transferCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, selectedCollectorLinkIds)); }, + onSubmitAll: () => { void transferCollectorPackages(collectorPackagesRef.current); }, onQueryChange: setCollectorQuery, - onSelectionChange: toggleCollectorRowSelection, + onLinkSelectionChange: toggleCollectorLinkSelection, + onPackageSelectionChange: toggleCollectorPackageSelection, + onPackageCollapseChange: (packageId) => setCollapsedCollectorPackageIds((current) => { + const next = new Set(current); + if (next.has(packageId)) next.delete(packageId); + else next.add(packageId); + return next; + }), onRemoveSelected: removeSelectedCollectorRows }; @@ -6044,8 +5878,9 @@ export function App(): ReactElement { ) : tab === "collector" ? ( <> - Sammlungen: {collectorViewModel.tabs.length} - Links: {collectorViewModel.tabs.reduce((sum, entry) => sum + entry.linkCount, 0)} + Pakete: {collectorPackages.length} + Links: {collectorViewModel.totalCount} + Ausgewählt: {collectorViewModel.selectedCount} Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"} ) : tab === "history" ? ( @@ -6683,7 +6518,6 @@ export function App(): ReactElement { onClose={() => setCollectorInput(null)} onCommit={commitCollectorInput} open - tabName={collectorInput.tabName} value={collectorInput.draft} /> ) : null} diff --git a/src/renderer/download-format.ts b/src/renderer/download-format.ts index 250def9..acdf626 100644 --- a/src/renderer/download-format.ts +++ b/src/renderer/download-format.ts @@ -56,7 +56,7 @@ export function formatHosterLabel(hoster: string): { compact: string; title: str const normalized = hoster.trim().toLowerCase(); if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator }; if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload }; - if (normalized === "1fichier") return { compact: "1F", title: "1Fichier" }; + if (normalized === "1fichier") return { compact: "1Fichier", title: "1Fichier", iconSrc: hosterIconSources.onefichier }; return { compact: hoster, title: hoster }; } diff --git a/src/renderer/hoster-icons.ts b/src/renderer/hoster-icons.ts index bd80f7e..bd79dd0 100644 --- a/src/renderer/hoster-icons.ts +++ b/src/renderer/hoster-icons.ts @@ -1,4 +1,5 @@ export const hosterIconSources: Readonly> = { rapidgator: "data:image/x-icon;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////", + onefichier: "./provider-icons/onefichier.png", ddownload: "./provider-icons/ddownload.ico" }; diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index d07aff7..5c9640f 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -70,6 +70,10 @@ const pairs = [ ["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"], ["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"], ["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"], + ["Linksammler-Filter", "Link collector filters"], ["Alle Links", "All links"], ["Downloads übergeben", "Send to downloads"], ["Gesammelte Downloadpakete", "Collected download packages"], ["Hinzugefügt", "Added"], + ["Name, URL oder Hoster", "Name, URL or hoster"], ["Links werden analysiert", "Analyzing links"], ["Dateinamen, Größen und Verfügbarkeit werden geprüft.", "Checking filenames, sizes, and availability."], + ["Bereits gesammelte Pakete bleiben unverändert.", "Already collected packages remain unchanged."], ["Füge Links hinzu, um Pakete vor dem Download zu prüfen.", "Add links to inspect packages before downloading."], + ["Links werden geprüft und automatisch zu Downloadpaketen gruppiert.", "Links are inspected and automatically grouped into download packages."], ["Analysieren", "Analyze"], ["Eine URL pro Zeile", "One URL per line"], ["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Ausgewählte Links löschen", "Delete selected links"], ["Links löschen", "Delete links"], ["Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", "The selected links will be removed from the collection. This action cannot be undone."], ["Gesammelte Links", "Collected links"], ["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."], ["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"], @@ -319,6 +323,14 @@ function translateDynamic(value: string, language: AppLanguage): string { if (capture) return `Capture links locally for ${capture[1]}.`; const collectorSelection = value.match(/^(.+) aus (.+), Zeile (\d+) auswählen$/); if (collectorSelection) return `Select ${collectorSelection[1]} from ${collectorSelection[2]}, line ${collectorSelection[3]}`; + const collectorTransfer = value.match(/^(Auswahl|Alle) übergeben \((\d+)\)$/); + if (collectorTransfer) return `${collectorTransfer[1] === "Auswahl" ? "Send selection" : "Send all"} (${collectorTransfer[2]})`; + const collectorPackageSelect = value.match(/^Paket (.+) auswählen$/); + if (collectorPackageSelect) return `Select package ${collectorPackageSelect[1]}`; + const collectorFiles = value.match(/^(\d+) Dateien$/); + if (collectorFiles) return `${collectorFiles[1]} files`; + const collectorChecked = value.match(/^(\d+)\/(\d+) geprüft$/); + if (collectorChecked) return `${collectorChecked[1]}/${collectorChecked[2]} checked`; const copy = value.match(/^([\s\S]+?)\s+Klicken zum Kopieren$/); if (copy) return `Click to copy ${copy[1]}`; const cancelledPart = value.match(/^· (\d+) abgebrochen$/); @@ -530,6 +542,14 @@ function translateDynamic(value: string, language: AppLanguage): string { if (pageStatus) return `Seite ${pageStatus[1]} von ${pageStatus[2]}`; const collectorSelection = value.match(/^Select (.+) from (.+), line (\d+)$/); if (collectorSelection) return `${collectorSelection[1]} aus ${collectorSelection[2]}, Zeile ${collectorSelection[3]} auswählen`; + const collectorTransfer = value.match(/^Send (selection|all) \((\d+)\)$/); + if (collectorTransfer) return `${collectorTransfer[1] === "selection" ? "Auswahl" : "Alle"} übergeben (${collectorTransfer[2]})`; + const collectorPackageSelect = value.match(/^Select package (.+)$/); + if (collectorPackageSelect) return `Paket ${collectorPackageSelect[1]} auswählen`; + const collectorFiles = value.match(/^(\d+) files$/); + if (collectorFiles) return `${collectorFiles[1]} Dateien`; + const collectorChecked = value.match(/^(\d+)\/(\d+) checked$/); + if (collectorChecked) return `${collectorChecked[1]}/${collectorChecked[2]} geprüft`; const moveColumnDirection = value.match(/^Move (.+) (left|right)$/); if (moveColumnDirection) return `${enToDe.get(moveColumnDirection[1]) ?? moveColumnDirection[1]} nach ${moveColumnDirection[2] === "left" ? "links" : "rechts"} verschieben`; const moveColumn = value.match(/^Move (.+)$/); diff --git a/src/renderer/selection.ts b/src/renderer/selection.ts index 4e403be..cd38524 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 === "history" || view === "collector") { 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..6af8c45 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -1,210 +1,149 @@ -import type { ChangeEvent, ReactElement } from "react"; -import { - DataTable, - DataTableBody, - DataTableEmpty, - DataTableHeader -} from "../../ui/DataTable"; -import { Dialog } from "../../ui/Dialog"; +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 { 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 { formatDateTime, formatHosterLabel, humanSize } from "../../download-format"; +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; + 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.filter((link) => link.status === "ready").length; + if (row.offlineCount === row.totalCount) return "Offline"; + if (ready === row.totalCount) return "Bereit"; + if (ready > 0 || row.onlineCount > 0) return `${ready}/${row.totalCount} geprüft`; + return "Ungeprüft"; +} + +function packageAvailability(row: CollectorWorkspacePackageRow): string { + if (row.onlineCount === row.totalCount) return `${row.onlineCount}/${row.totalCount} online`; + if (row.offlineCount === row.totalCount) return "Offline"; + if (row.onlineCount > 0) return `${row.onlineCount}/${row.totalCount} online`; + return "Ungeprüft"; +} + +function packageSize(row: CollectorWorkspacePackageRow): string { + if (row.totalBytes <= 0) return "Unbekannt"; + return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`; +} + export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement { 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" - > -