From 85a9a2fa9f8a6c4c0cdaccaffd5223a7f02684f3 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 9 Mar 2026 04:11:18 +0100 Subject: [PATCH] Add package and item link export --- README.md | 29 ++++++- src/main/app-controller.ts | 35 +++++++- src/main/download-manager.ts | 61 +++++++++----- src/main/link-export.ts | 116 +++++++++++++++++++++++++ src/main/link-parser.ts | 34 ++++++-- src/main/main.ts | 34 ++++++++ src/main/utils.ts | 27 +++++- src/preload/preload.ts | 2 + src/renderer/App.tsx | 78 +++++++++++++++-- src/shared/ipc.ts | 2 + src/shared/preload-api.ts | 2 + tests/download-manager.test.ts | 34 ++++++++ tests/link-export.test.ts | 149 +++++++++++++++++++++++++++++++++ tests/link-parser.test.ts | 12 +++ tests/utils.test.ts | 22 +++++ 15 files changed, 598 insertions(+), 39 deletions(-) create mode 100644 src/main/link-export.ts create mode 100644 tests/link-export.test.ts diff --git a/README.md b/README.md index d97e3c7..a2ded6b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Desktop downloader for Windows with package-based queue management, multi-provid - Package-based queue with item status, retries, ETA, speed, provider, and account label. - Start, pause, stop, cancel, reset, rename, and delete for packages and items. - Ctrl+Click multi-select and bulk actions. -- Queue import/export. +- Queue backup import/export as JSON. +- Context-menu export for selected packages or selected items as structured TXT re-import files. - Duplicate handling when adding links: keep, skip, or overwrite. - Optional start scheduling for a specific time. - Session recovery after restart with optional auto-resume. @@ -45,9 +46,10 @@ Desktop downloader for Windows with package-based queue management, multi-provid ### Link collection - Paste links directly into the collector. +- Import `.txt` export files that preserve package names and optional per-file names. - Clipboard watcher with automatic link detection. - `.dlc` import via file picker and drag-and-drop. -- Drag-and-drop of plain links and supported container files. +- Drag-and-drop of plain links, `.txt` export files, and supported container files. ### Provider routing and fallback @@ -163,6 +165,29 @@ npm run dev 5. Start the queue and monitor downloads, extraction, and provider status. 6. Review history and statistics after completion. +## Link export format + +Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package. + +Example: + +```txt +# rd-link-export: 1 +# package: Dave Staffel 1 +# file: Dave.S01E01.rar +https://example.com/e01 +# file: Dave.S01E02.rar +https://example.com/e02 +``` + +Supported import sources: + +- collector text input +- `Datei importieren` +- drag-and-drop of `.txt` and `.json` + +The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints. + ## Project structure - `src/main` - Electron main process, download engine, provider clients, updater, storage diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index fd66e60..6d547ac 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -38,6 +38,7 @@ import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-ser import { encryptBackup, decryptBackup } from "./backup-crypto"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getDebugSetupCheck } from "./debug-setup"; +import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; @@ -460,12 +461,44 @@ export class AppController { this.manager.togglePackage(packageId); } + public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { + const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []); + this.audit("INFO", "Paket-Auswahl exportiert", { + packageCount: selection.packageCount, + linkCount: selection.linkCount, + packageIds + }); + return { + text: serializeLinkExportText(selection.packages), + defaultFileName: selection.defaultFileName, + packageCount: selection.packageCount, + linkCount: selection.linkCount + }; + } + + public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { + const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds); + this.audit("INFO", "Item-Auswahl exportiert", { + packageCount: selection.packageCount, + linkCount: selection.linkCount, + itemIds + }); + return { + text: serializeLinkExportText(selection.packages), + defaultFileName: selection.defaultFileName, + packageCount: selection.packageCount, + linkCount: selection.linkCount + }; + } + public exportQueue(): string { return this.manager.exportQueue(); } public importQueue(json: string): { addedPackages: number; addedLinks: number } { - return this.manager.importQueue(json); + const result = this.manager.importQueue(json); + this.audit("INFO", "Import-Datei verarbeitet", result); + return result; } public getSessionStats(): SessionStats { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index ba560d8..2229672 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -30,6 +30,7 @@ import { isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants"; +import { parseCollectorInput } from "./link-parser"; // Reference counter for NODE_TLS_REJECT_UNAUTHORIZED to avoid race conditions // when multiple parallel downloads need TLS verification disabled (e.g. DDownload). @@ -1793,11 +1794,13 @@ export class DownloadManager extends EventEmitter { if (!pkg) { return null; } + const entries = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter((item): item is DownloadItem => Boolean(item && item.url)); return { name: pkg.name, - links: pkg.itemIds - .map((itemId) => this.session.items[itemId]?.url) - .filter(Boolean) + links: entries.map((item) => item.url), + fileNames: entries.map((item) => item.fileName || "") }; }).filter(Boolean) }; @@ -1805,26 +1808,44 @@ export class DownloadManager extends EventEmitter { } public importQueue(json: string): { addedPackages: number; addedLinks: number } { - let data: { packages?: Array<{ name: string; links: string[] }> }; - try { - data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[] }> }; - } catch { - throw new Error("Ungultige Queue-Datei (JSON)"); + const trimmed = String(json || "").trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + let data: { packages?: Array<{ name: string; links: string[]; fileNames?: string[] }> }; + try { + data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[]; fileNames?: string[] }> }; + } catch { + throw new Error("Ungultige Queue-Datei (JSON)"); + } + if (!Array.isArray(data.packages)) { + return { addedPackages: 0, addedLinks: 0 }; + } + const inputs: ParsedPackageInput[] = data.packages + .map((pkg) => { + const name = typeof pkg?.name === "string" ? pkg.name : ""; + const linksRaw = Array.isArray(pkg?.links) ? pkg.links : []; + const fileNamesRaw = Array.isArray(pkg?.fileNames) ? pkg.fileNames : []; + const entries = linksRaw + .map((link, index) => ({ + link: typeof link === "string" ? link.trim() : "", + fileName: typeof fileNamesRaw[index] === "string" ? fileNamesRaw[index].trim() : "" + })) + .filter((entry) => entry.link.length > 0); + const links = entries.map((entry) => entry.link); + const fileNames = entries.map((entry) => entry.fileName); + return { + name, + links, + ...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {}) + }; + }) + .filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0); + return this.addPackages(inputs); } - if (!Array.isArray(data.packages)) { + + const inputs = parseCollectorInput(json, ""); + if (inputs.length === 0) { return { addedPackages: 0, addedLinks: 0 }; } - const inputs: ParsedPackageInput[] = data.packages - .map((pkg) => { - const name = typeof pkg?.name === "string" ? pkg.name : ""; - const linksRaw = Array.isArray(pkg?.links) ? pkg.links : []; - const links = linksRaw - .filter((link) => typeof link === "string") - .map((link) => link.trim()) - .filter(Boolean); - return { name, links }; - }) - .filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0); return this.addPackages(inputs); } diff --git a/src/main/link-export.ts b/src/main/link-export.ts new file mode 100644 index 0000000..bd7ead5 --- /dev/null +++ b/src/main/link-export.ts @@ -0,0 +1,116 @@ +import type { ParsedPackageInput, UiSnapshot } from "../shared/types"; +import { sanitizeFilename } from "./utils"; + +export type LinkExportSelection = { + packages: ParsedPackageInput[]; + packageCount: number; + linkCount: number; + defaultFileName: string; +}; + +function formatTimestampForFileName(date: Date): string { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + const s = String(date.getSeconds()).padStart(2, "0"); + return `${y}-${mo}-${d}_${h}-${mi}-${s}`; +} + +function buildDefaultFileName(packages: ParsedPackageInput[]): string { + if (packages.length === 1) { + const only = packages[0]; + if (only.links.length === 1) { + const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export"); + return `${itemName}.txt`; + } + return `${sanitizeFilename(only.name || "paket-export")}.txt`; + } + return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`; +} + +export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection { + const selectedPackageIds = new Set(packageIds); + const selectedItemIds = new Set(itemIds); + const packages: ParsedPackageInput[] = []; + + for (const packageId of snapshot.session.packageOrder) { + const pkg = snapshot.session.packages[packageId]; + if (!pkg) { + continue; + } + + const useWholePackage = selectedPackageIds.has(packageId); + const relevantItemIds = useWholePackage + ? pkg.itemIds + : pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId)); + + if (relevantItemIds.length === 0) { + continue; + } + + const links: string[] = []; + const fileNames: string[] = []; + for (const itemId of relevantItemIds) { + const item = snapshot.session.items[itemId]; + if (!item || !String(item.url || "").trim()) { + continue; + } + links.push(String(item.url).trim()); + const rawFileName = String(item.fileName || "").trim(); + fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : ""); + } + + if (links.length === 0) { + continue; + } + + const exportEntry: ParsedPackageInput = { + name: sanitizeFilename(pkg.name || "Paket"), + links + }; + if (fileNames.some((fileName) => fileName.length > 0)) { + exportEntry.fileNames = fileNames; + } + packages.push(exportEntry); + } + + const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0); + return { + packages, + packageCount: packages.length, + linkCount, + defaultFileName: buildDefaultFileName(packages) + }; +} + +export function serializeLinkExportText(packages: ParsedPackageInput[]): string { + const lines: string[] = [ + "# rd-link-export: 1", + "# Re-import in Real-Debrid-Downloader keeps package names and optional file names.", + "" + ]; + + for (const pkg of packages) { + if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) { + continue; + } + lines.push(`# package: ${sanitizeFilename(pkg.name)}`); + for (let index = 0; index < pkg.links.length; index += 1) { + const link = String(pkg.links[index] || "").trim(); + if (!link) { + continue; + } + const rawFileName = String(pkg.fileNames?.[index] || "").trim(); + const fileName = rawFileName ? sanitizeFilename(rawFileName) : ""; + if (fileName) { + lines.push(`# file: ${fileName}`); + } + lines.push(link); + } + lines.push(""); + } + + return `${lines.join("\n").trim()}\n`; +} diff --git a/src/main/link-parser.ts b/src/main/link-parser.ts index c70fb7b..4acf89e 100644 --- a/src/main/link-parser.ts +++ b/src/main/link-parser.ts @@ -2,19 +2,35 @@ import { ParsedPackageInput } from "../shared/types"; import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils"; export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] { - const grouped = new Map(); + const grouped = new Map }>(); for (const pkg of packages) { const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links)); - const list = grouped.get(name) ?? []; - for (const link of pkg.links) { - list.push(link); + const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map() }; + for (let index = 0; index < pkg.links.length; index += 1) { + const link = String(pkg.links[index] || "").trim(); + if (!link) { + continue; + } + if (!current.links.includes(link)) { + current.links.push(link); + } + const rawFileName = String(pkg.fileNames?.[index] || "").trim(); + const fileName = rawFileName ? sanitizeFilename(rawFileName) : ""; + if (fileName && !current.fileNameByLink.has(link)) { + current.fileNameByLink.set(link, fileName); + } } - grouped.set(name, list); + grouped.set(name, current); } - return Array.from(grouped.entries()).map(([name, links]) => ({ - name, - links: uniquePreserveOrder(links) - })); + return Array.from(grouped.entries()).map(([name, entry]) => { + const links = uniquePreserveOrder(entry.links); + const fileNames = links.map((link) => entry.fileNameByLink.get(link) || ""); + return { + name, + links, + ...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {}) + }; + }); } export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] { diff --git a/src/main/main.ts b/src/main/main.ts index ed10fd6..3c032ba 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -383,6 +383,40 @@ function registerIpcHandlers(): void { validateString(packageId, "packageId"); return controller.togglePackage(packageId); }); + ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => { + const validPackageIds = validateStringArray(packageIds ?? [], "packageIds"); + const exported = controller.exportPackageSelection(validPackageIds); + if (exported.packageCount === 0 || exported.linkCount === 0) { + return { saved: false, packageCount: 0, linkCount: 0 }; + } + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Link Export", extensions: ["txt"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; + } + await fs.promises.writeFile(result.filePath, exported.text, "utf8"); + return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; + }); + ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => { + const validItemIds = validateStringArray(itemIds ?? [], "itemIds"); + const exported = controller.exportItemSelection(validItemIds); + if (exported.packageCount === 0 || exported.linkCount === 0) { + return { saved: false, packageCount: 0, linkCount: 0 }; + } + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Link Export", extensions: ["txt"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; + } + await fs.promises.writeFile(result.filePath, exported.text, "utf8"); + return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; + }); ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.retryExtraction(packageId); diff --git a/src/main/utils.ts b/src/main/utils.ts index 6994ade..3ab0045 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -201,19 +201,31 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName: const packages: ParsedPackageInput[] = []; let currentName = String(defaultPackageName || "").trim(); let currentLinks: string[] = []; + let currentFileNames: string[] = []; + let pendingFileName = ""; const flush = (): void => { const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line))); if (links.length > 0) { const normalizedCurrentName = String(currentName || "").trim(); - packages.push({ + const fileNames = links.map((link) => { + const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link); + return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : ""; + }); + const nextPackage: ParsedPackageInput = { name: normalizedCurrentName ? sanitizeFilename(normalizedCurrentName) : inferPackageNameFromLinks(links), links - }); + }; + if (fileNames.some((fileName) => fileName.trim().length > 0)) { + nextPackage.fileNames = fileNames; + } + packages.push(nextPackage); } currentLinks = []; + currentFileNames = []; + pendingFileName = ""; }; for (const line of lines) { @@ -225,9 +237,20 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName: if (marker) { flush(); currentName = String(marker[1] || "").trim(); + pendingFileName = ""; + continue; + } + const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i); + if (fileMarker) { + pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim()); + continue; + } + if (!isHttpLink(text)) { continue; } currentLinks.push(text); + currentFileNames.push(pendingFileName); + pendingFileName = ""; } flush(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index f7e125e..6859e62 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -44,6 +44,8 @@ const api: ElectronApi = { reorderPackages: (packageIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), removeItem: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), togglePackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId), + exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds), + exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds), exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b99d5d6..0ab494d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2642,6 +2642,30 @@ export function App(): ReactElement { }); }; + const onExportPackageSelection = async (packageIds: string[]): Promise => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.exportPackageSelection(packageIds); + if (result.saved) { + showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800); + } + }, (error) => { + showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + + const onExportItemSelection = async (itemIds: string[]): Promise => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.exportItemSelection(itemIds); + if (result.saved) { + showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800); + } + }, (error) => { + showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + onImportDlcRef.current = onImportDlc; const onDrop = async (event: DragEvent): Promise => { @@ -2654,6 +2678,7 @@ export function App(): ReactElement { 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) { await performQuickAction(async () => { @@ -2669,6 +2694,27 @@ export function App(): ReactElement { }, (error) => { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }); + } else if (importFiles.length > 0) { + 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 { + showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000); + } + }, (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 @@ -2699,7 +2745,7 @@ export function App(): ReactElement { const input = document.createElement("input"); input.type = "file"; - input.accept = ".json"; + input.accept = ".json,.txt"; const releasePickerBusy = (): void => { actionBusyRef.current = false; @@ -2722,9 +2768,16 @@ export function App(): ReactElement { } 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); - showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (result.addedLinks > 0) { + showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + } else { + showToast("Keine gültigen Links in der Datei gefunden", 3000); + } }, (error) => { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }); @@ -3688,6 +3741,9 @@ export function App(): ReactElement { Text mit Links analysieren Strg+L + - + @@ -4014,7 +4070,7 @@ export function App(): ReactElement { value={currentCollectorTab.text} onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))} onDragOver={(e) => e.preventDefault()} - placeholder={"# package: Release-Name\nhttps://...\nhttps://...\n\nLinks oder .dlc Dateien hier ablegen"} + placeholder={"# package: Release-Name\n# file: Folge 01.rar\nhttps://...\nhttps://...\n\nLinks, .dlc oder Export-Dateien hier ablegen"} /> @@ -5309,7 +5365,7 @@ export function App(): ReactElement { )} {statusToast &&
{statusToast}
} - {dragOver &&
Links oder .dlc Dateien hier ablegen
} + {dragOver &&
Links, .dlc oder Export-Dateien hier ablegen
} {contextMenu && (() => { const multi = selectedIds.size > 1; const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]); @@ -5332,6 +5388,18 @@ export function App(): ReactElement {
+ {hasPackages && !contextMenu.itemId && ( + + )} + {contextMenu.itemId && ( + + )} {hasPackages && !contextMenu.itemId && (