From 621652ae13df1c1b2e3de738b54fb7d935c968c3 Mon Sep 17 00:00:00 2001 From: NimBold Date: Mon, 20 Jul 2026 20:04:07 +0330 Subject: [PATCH] feat(downloads): add opt-in batch folders (#27) --- Extensions/Browser | 2 +- src-tauri/src/extension_server.rs | 80 +++++++++++- src/bindings/ExtensionDownload.ts | 2 +- src/components/AddDownloadsModal.tsx | 182 ++++++++++++++++++++++++--- src/i18n/catalogs/en.ts | 6 + src/i18n/catalogs/fa.ts | 6 + src/i18n/catalogs/he.ts | 6 + src/i18n/catalogs/ru.ts | 6 + src/i18n/catalogs/uk.ts | 6 + src/i18n/catalogs/zh-CN.ts | 6 + src/store/useDownloadStore.test.ts | 74 +++++++++-- src/store/useDownloadStore.ts | 35 +++++- src/utils/downloadLocations.test.ts | 45 +++++++ src/utils/downloadLocations.ts | 87 +++++++++++++ 14 files changed, 503 insertions(+), 40 deletions(-) diff --git a/Extensions/Browser b/Extensions/Browser index 61ca152..6bf5293 160000 --- a/Extensions/Browser +++ b/Extensions/Browser @@ -1 +1 @@ -Subproject commit 61ca15208917cef63b5f3d888fefe7a97ac80fdf +Subproject commit 6bf52938c9692c14a0e69de507e9e5338529a6e6 diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 1211d7d..5940726 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -74,6 +74,10 @@ struct ExtensionRequest { cookie_scopes: Option>, #[serde(default)] media: bool, + #[serde(default)] + batch: bool, + #[serde(default)] + batch_name: Option, } #[derive(Clone, Deserialize, Serialize, TS)] @@ -96,6 +100,8 @@ pub struct ExtensionDownload { cookies: Option, cookie_scopes: Option>, media: bool, + batch: bool, + batch_name: Option, } pub async fn start_server( @@ -428,6 +434,14 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option= 2; + let batch_name = batch + .then(|| payload.batch_name) + .flatten() + .and_then(|value| { + let value = value.trim().to_string(); + (!value.is_empty() && value.chars().count() <= 512).then_some(value) + }); // A multi-URL handoff has no per-URL cookie scope. Keep ordinary // request headers, but drop Cookie headers and the dedicated cookie field // so a legacy or untrusted caller cannot reuse one session across hosts. @@ -468,6 +482,8 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option bool { mod tests { use super::{ acknowledge_extension_download, add_server_identity, claim_request_at, - has_allowed_request_origin, is_valid_client_nonce, - normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope, - ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER, + has_allowed_request_origin, is_valid_client_nonce, normalize_download, + required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest, + MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER, }; use axum::{ http::{HeaderMap, HeaderValue, StatusCode}, @@ -790,6 +806,8 @@ mod tests { cookies: None, cookie_scopes: None, media: true, + batch: false, + batch_name: None, }); assert!(download.is_none()); @@ -808,6 +826,8 @@ mod tests { cookies: None, cookie_scopes: None, media: false, + batch: false, + batch_name: None, }); assert!(download.is_none()); @@ -867,6 +887,8 @@ mod tests { cookies: Some(format!("large={}", "x".repeat(64 * 1024))), cookie_scopes: None, media: true, + batch: false, + batch_name: None, }) .expect("valid media handoff"); @@ -886,6 +908,8 @@ mod tests { cookies: Some("session=browser-cookie-header".to_string()), cookie_scopes: None, media: false, + batch: false, + batch_name: None, }) .expect("valid download handoff"); @@ -920,6 +944,8 @@ mod tests { }, ]), media: false, + batch: false, + batch_name: None, }) .expect("valid download handoff"); @@ -948,6 +974,8 @@ mod tests { cookies: Some("session=secret".to_string()), cookie_scopes: None, media: false, + batch: false, + batch_name: None, }) .expect("valid multi-url handoff"); @@ -955,6 +983,52 @@ mod tests { assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox")); } + #[test] + fn selected_link_batches_preserve_context_only_for_two_or_more_urls() { + let download = normalize_download(ExtensionRequest { + urls: vec![ + "https://example.com/one.zip".to_string(), + "https://example.com/two.zip".to_string(), + ], + referer: Some("https://example.com/gallery".to_string()), + silent: false, + filename: None, + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + batch: true, + batch_name: Some("Example Gallery / Chapter: 1".to_string()), + }) + .expect("valid selected-link batch"); + + assert!(download.batch); + assert_eq!( + download.batch_name.as_deref(), + Some("Example Gallery / Chapter: 1") + ); + } + + #[test] + fn selected_link_batch_context_is_dropped_for_single_urls() { + let download = normalize_download(ExtensionRequest { + urls: vec!["https://example.com/one.zip".to_string()], + referer: Some("https://example.com/gallery".to_string()), + silent: false, + filename: None, + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + batch: true, + batch_name: Some("Example Gallery".to_string()), + }) + .expect("valid single-link handoff"); + + assert!(!download.batch); + assert!(download.batch_name.is_none()); + } + #[test] fn signs_server_proof_with_timestamp_nonce_and_bound_port() { let token = Arc::new(RwLock::new("pairing-token".to_string())); diff --git a/src/bindings/ExtensionDownload.ts b/src/bindings/ExtensionDownload.ts index 24404f8..ac0c762 100644 --- a/src/bindings/ExtensionDownload.ts +++ b/src/bindings/ExtensionDownload.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExtensionCookieScope } from "./ExtensionCookieScope"; -export type ExtensionDownload = { request_id?: string, urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, }; +export type ExtensionDownload = { request_id?: string, urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, batch: boolean, batch_name: string | null, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 50697ff..0cda2b3 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -9,7 +9,7 @@ import { import { useSettingsStore } from '../store/useSettingsStore'; import type { DownloadItem } from '../bindings/DownloadItem'; import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; -import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; +import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; @@ -18,7 +18,10 @@ import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '.. import { expandTilde, resolveCategoryDestination, + deriveBatchFolderName, resolveDownloadFilePath, + resolveSubfolderDestination, + sanitizeBatchFolderName, downloadLocationEquals, resolveInitialAddWindowLocation } from '../utils/downloadLocations'; @@ -127,6 +130,7 @@ export const AddDownloadsModal = () => { pendingAddHeaders, pendingAddCookies, pendingAddMediaUrls, + pendingAddBatchName, pendingAddRequestContexts, pendingAddRequestVersion, toggleAddModal, @@ -165,6 +169,9 @@ export const AddDownloadsModal = () => { // Right Form const [saveLocation, setSaveLocation] = useState(baseDownloadFolder); const [isSaveLocationManual, setIsSaveLocationManual] = useState(false); + const [saveInDedicatedFolder, setSaveInDedicatedFolder] = useState(false); + const [dedicatedFolderName, setDedicatedFolderName] = useState(''); + const dedicatedFolderNameEditedRef = useRef(false); const locationResolutionRequestRef = useRef(0); const folderPickerRequestRef = useRef(0); const pendingLastUsedDownloadDirectoryRef = useRef(null); @@ -257,6 +264,9 @@ export const AddDownloadsModal = () => { ); setSaveLocation(initialLocation.path); setIsSaveLocationManual(initialLocation.isManual); + setSaveInDedicatedFolder(false); + dedicatedFolderNameEditedRef.current = false; + setDedicatedFolderName(deriveBatchFolderName(pendingAddBatchName, pendingAddReferer)); setUrls(initialUrls); setParsedItems([]); setPlaylistExpansions({}); @@ -295,6 +305,7 @@ export const AddDownloadsModal = () => { pendingAddHeaders, pendingAddCookies, pendingAddMediaUrls, + pendingAddBatchName, baseDownloadFolder, rememberLastUsedDownloadDirectory, lastUsedDownloadDirectory, @@ -664,6 +675,22 @@ export const AddDownloadsModal = () => { }); }, [isSaveLocationManual, parsedItems]); + useEffect(() => { + if ( + !isAddModalOpen + || parsedItems.length < 2 + || dedicatedFolderNameEditedRef.current + ) { + return; + } + setDedicatedFolderName(deriveBatchFolderName( + pendingAddBatchName, + pendingAddReferer, + new Date(), + parsedItems.map(item => item.file) + )); + }, [isAddModalOpen, parsedItems, pendingAddBatchName, pendingAddReferer]); + if (!isAddModalOpen) return null; const handleBrowse = async () => { @@ -697,6 +724,35 @@ export const AddDownloadsModal = () => { return resolveCategoryDestination(useSettingsStore.getState(), category); }; + const commitDedicatedFolderName = () => { + const safeName = sanitizeBatchFolderName(dedicatedFolderName); + if (!safeName) { + addToast({ + message: t($ => $.addDownloads.dedicatedFolderNameRequired), + variant: 'error', + isActionable: true + }); + return; + } + dedicatedFolderNameEditedRef.current = true; + setDedicatedFolderName(safeName); + }; + + const destinationForFile = async ( + fileName: string, + finalLocation: string, + useSharedDestination: boolean, + destinationOverride?: string + ): Promise => { + if (destinationOverride) return destinationOverride; + const root = useSharedDestination + ? finalLocation + : await categoryLocationForFile(fileName); + return saveInDedicatedFolder + ? resolveSubfolderDestination(root, dedicatedFolderName) + : root; + }; + const handleAction = async (action: AddDownloadAction) => { if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) { return; @@ -705,6 +761,14 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true }); return; } + if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) { + addToast({ + message: t($ => $.addDownloads.dedicatedFolderNameRequired), + variant: 'error', + isActionable: true + }); + return; + } isSubmittingRef.current = true; setIsSubmitting(true); ++folderPickerRequestRef.current; @@ -717,9 +781,11 @@ export const AddDownloadsModal = () => { for (const [index, item] of parsedItems.entries()) { if (item.selected === false) continue; try { - const suggestedLocation = isSaveLocationManual - ? finalLocation - : await categoryLocationForFile(item.file); + const suggestedLocation = await destinationForFile( + item.file, + finalLocation, + isSaveLocationManual + ); const selected = await open({ directory: true, multiple: false, @@ -761,9 +827,12 @@ export const AddDownloadsModal = () => { let finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); - const itemLocation = useSharedDestination - ? finalLocation - : destinationOverrides[i] || await categoryLocationForFile(finalFile); + const itemLocation = await destinationForFile( + finalFile, + finalLocation, + useSharedDestination, + destinationOverrides[i] + ); const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && d.status !== 'failed' && d.status !== 'completed'); const hasBatchConflict = plannedTargets.some(target => @@ -929,9 +998,12 @@ export const AddDownloadsModal = () => { let finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); - const itemLocation = useSharedDestination - ? finalLocation - : destinationOverrides[idx] || await categoryLocationForFile(finalFile); + const itemLocation = await destinationForFile( + finalFile, + finalLocation, + useSharedDestination, + destinationOverrides[idx] + ); let count = 1; const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; @@ -944,9 +1016,12 @@ export const AddDownloadsModal = () => { const candidateFile = candidate.isMedia ? mediaFileNameForSelectedFormat(candidate.file, candidate) : canonicalizeDownloadFileName(candidate.file); - const candidateLocation = useSharedDestination - ? finalLocation - : destinationOverrides[candidateIndex] || await categoryLocationForFile(candidateFile); + const candidateLocation = await destinationForFile( + candidateFile, + finalLocation, + useSharedDestination, + destinationOverrides[candidateIndex] + ); batchTargets.push({ location: candidateLocation, fileName: candidateFile }); } @@ -1000,9 +1075,12 @@ export const AddDownloadsModal = () => { const finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); - const itemLocation = useSharedDestination - ? finalLocation - : destinationOverrides[idx] || await categoryLocationForFile(finalFile); + const itemLocation = await destinationForFile( + finalFile, + finalLocation, + useSharedDestination, + destinationOverrides[idx] + ); const store = useDownloadStore.getState(); let existingItem = conflict?.existingDownloadId ? store.downloads.find(download => download.id === conflict.existingDownloadId) @@ -1098,9 +1176,14 @@ export const AddDownloadsModal = () => { : undefined, cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined, mirrors: mirrors.trim() || undefined, - destination: useSharedDestination - ? finalLocation - : destinationOverrides[itemIndex], + destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex] + ? await destinationForFile( + finalFile, + finalLocation, + useSharedDestination, + destinationOverrides[itemIndex] + ) + : undefined, isMedia: item.isMedia, resumable: item.resumable, mediaFormatSelector: formatSelector, @@ -1552,6 +1635,7 @@ export const AddDownloadsModal = () => { aria-label={t($ => $.addDownloads.saveLocation)} /> + {parsedItems.length > 1 && ( +
+ + {saveInDedicatedFolder && ( + <> +
+ { + dedicatedFolderNameEditedRef.current = true; + setDedicatedFolderName(event.target.value); + }} + placeholder={t($ => $.addDownloads.dedicatedFolderName)} + aria-label={t($ => $.addDownloads.dedicatedFolderName)} + className="add-download-control flex-1 px-3 py-1.5 text-xs" + disabled={isSubmitting} + /> + +
+

+ {t(isSaveLocationManual + ? $ => $.addDownloads.dedicatedFolderManualDescription + : $ => $.addDownloads.dedicatedFolderDescription)} +

+ + )} +
+ )} {parsedItems.length > 1 && !isSaveLocationManual && (

{t($ => $.addDownloads.categoryFolders)}

)} - {isSaveLocationManual && ( + {isSaveLocationManual && !saveInDedicatedFolder && (

{t($ => $.addDownloads.sharedFolder)}

diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 963748e..7425f83 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -440,6 +440,12 @@ const common = { browse: 'Browse', categoryFolders: 'Files will be organized into category folders.', sharedFolder: 'All selected downloads will use this folder.', + dedicatedFolder: 'Save in a new folder', + dedicatedFolderName: 'Folder name', + dedicatedFolderDescription: 'Creates this folder inside each automatic category folder.', + dedicatedFolderManualDescription: 'Creates this folder inside the selected save location.', + saveFolderName: 'Save folder name', + dedicatedFolderNameRequired: 'Enter a folder name before saving.', transferSettings: 'Transfer Settings', connectionsPerFile: 'Connections per File', connectionsPerFileAria: 'Connections per file', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 70cdccf..da98632 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -440,6 +440,12 @@ const fa = { browse: 'انتخاب', categoryFolders: 'فایل‌ها در پوشه‌های دسته سازماندهی خواهند شد.', sharedFolder: 'تمام دانلودهای انتخاب‌شده از این پوشه استفاده خواهند کرد.', + dedicatedFolder: 'ذخیره در پوشه‌ای جدید', + dedicatedFolderName: 'نام پوشه', + dedicatedFolderDescription: 'این پوشه را داخل هر پوشه دسته‌بندی خودکار ایجاد می‌کند.', + dedicatedFolderManualDescription: 'این پوشه را داخل محل ذخیره انتخاب‌شده ایجاد می‌کند.', + saveFolderName: 'ذخیره نام پوشه', + dedicatedFolderNameRequired: 'پیش از ذخیره، نام پوشه را وارد کنید.', transferSettings: 'تنظیمات انتقال', connectionsPerFile: 'اتصالات در هر فایل', connectionsPerFileAria: 'اتصالات در هر فایل', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index e0e54ae..7061b51 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -440,6 +440,12 @@ const he = { browse: 'עיון', categoryFolders: 'הקבצים יאורגנו בתיקיות קטגוריה.', sharedFolder: 'כל ההורדות שנבחרו ישתמשו בתיקייה זו.', + dedicatedFolder: 'שמירה בתיקייה חדשה', + dedicatedFolderName: 'שם התיקייה', + dedicatedFolderDescription: 'תיקייה זו תיווצר בתוך כל תיקיית קטגוריה אוטומטית.', + dedicatedFolderManualDescription: 'תיקייה זו תיווצר בתוך מיקום השמירה שנבחר.', + saveFolderName: 'שמירת שם התיקייה', + dedicatedFolderNameRequired: 'יש להזין שם תיקייה לפני השמירה.', transferSettings: 'הגדרות העברה', connectionsPerFile: 'חיבורים לקובץ', connectionsPerFileAria: 'חיבורים לקובץ', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 39f7684..85d3785 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -440,6 +440,12 @@ const ru = { browse: 'Обзор', categoryFolders: 'Файлы будут организованы в папки категорий.', sharedFolder: 'Все выбранные загрузки будут использовать эту папку.', + dedicatedFolder: 'Сохранить в новую папку', + dedicatedFolderName: 'Имя папки', + dedicatedFolderDescription: 'Эта папка будет создана внутри каждой автоматической папки категории.', + dedicatedFolderManualDescription: 'Эта папка будет создана внутри выбранного места сохранения.', + saveFolderName: 'Сохранить имя папки', + dedicatedFolderNameRequired: 'Введите имя папки перед сохранением.', transferSettings: 'Настройки передачи', connectionsPerFile: 'Соединений на файл', connectionsPerFileAria: 'Соединений на файл', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index b3191ed..44af931 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -440,6 +440,12 @@ const uk = { browse: 'Огляд', categoryFolders: 'Файли будуть організовані в папки категорій.', sharedFolder: 'Усі вибрані завантаження використовуватимуть цю папку.', + dedicatedFolder: 'Зберегти в новій папці', + dedicatedFolderName: 'Назва папки', + dedicatedFolderDescription: 'Цю папку буде створено всередині кожної автоматичної папки категорії.', + dedicatedFolderManualDescription: 'Цю папку буде створено всередині вибраного місця збереження.', + saveFolderName: 'Зберегти назву папки', + dedicatedFolderNameRequired: 'Введіть назву папки перед збереженням.', transferSettings: 'Налаштування передачі', connectionsPerFile: 'З\'єднань на файл', connectionsPerFileAria: 'З\'єднань на файл', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index f2b407d..7a3ff07 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -440,6 +440,12 @@ const zhCN = { browse: '浏览', categoryFolders: '文件将被组织到类别文件夹中。', sharedFolder: '所有选定的下载都将使用此文件夹。', + dedicatedFolder: '保存到新文件夹', + dedicatedFolderName: '文件夹名称', + dedicatedFolderDescription: '将在每个自动类别文件夹中创建此文件夹。', + dedicatedFolderManualDescription: '将在选定的保存位置中创建此文件夹。', + saveFolderName: '保存文件夹名称', + dedicatedFolderNameRequired: '保存前请输入文件夹名称。', transferSettings: '传输设置', connectionsPerFile: '每个文件的连接数', connectionsPerFileAria: '每个文件的连接数 (Aria)', diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 9b5c43b..44efe55 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -95,6 +95,8 @@ describe('useDownloadStore', () => { pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], + pendingAddBatch: false, + pendingAddBatchName: '', pendingAddRequestContexts: {}, pendingAddRequestVersion: 0, }); @@ -1720,7 +1722,9 @@ describe('useDownloadStore', () => { { url: 'https://mail.google.com/', cookies: 'SID=mail-session' }, { url: 'https://accounts.google.com/', cookies: 'SID=account-session' } ], - media: false + media: false, + batch: false, + batch_name: null }); const state = useDownloadStore.getState(); @@ -1756,7 +1760,9 @@ describe('useDownloadStore', () => { headers: 'User-Agent: Firefox Test', cookies: null, cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); const state = useDownloadStore.getState(); @@ -1777,7 +1783,9 @@ describe('useDownloadStore', () => { headers: 'User-Agent: Test', cookies: 'session=secret', cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); const state = useDownloadStore.getState(); @@ -1790,6 +1798,30 @@ describe('useDownloadStore', () => { expect(state.pendingAddMediaUrls).toEqual([]); }); + it('tracks selected-link batch context without changing ordinary multi-link handoffs', async () => { + await useDownloadStore.getState().handleExtensionDownload({ + urls: ['https://example.com/one.zip', 'https://example.com/two.zip'], + referer: 'https://example.com/gallery', + silent: false, + filename: null, + headers: null, + cookies: null, + cookie_scopes: null, + media: false, + batch: true, + batch_name: 'Example Gallery' + }); + + expect(useDownloadStore.getState().pendingAddBatch).toBe(true); + expect(useDownloadStore.getState().pendingAddBatchName).toBe('Example Gallery'); + + useDownloadStore.getState().toggleAddModal(false); + useDownloadStore.getState().openAddModalWithUrls( + 'https://example.com/one.zip\nhttps://example.com/two.zip' + ); + expect(useDownloadStore.getState().pendingAddBatch).toBe(false); + }); + it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => { await useDownloadStore.getState().handleExtensionDownload({ urls: ['https://first.example/file.zip'], @@ -1799,7 +1831,9 @@ describe('useDownloadStore', () => { headers: 'User-Agent: First Browser', cookies: 'first=session', cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); await useDownloadStore.getState().handleExtensionDownload({ urls: ['https://second.example/file.zip'], @@ -1809,7 +1843,9 @@ describe('useDownloadStore', () => { headers: 'User-Agent: Second Browser', cookies: 'second=session', cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); const state = useDownloadStore.getState(); @@ -1846,7 +1882,9 @@ describe('useDownloadStore', () => { headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`, cookies: `oversized=${'x'.repeat(64 * 1024)}`, cookie_scopes: null, - media: true + media: true, + batch: false, + batch_name: null }); const state = useDownloadStore.getState(); @@ -1866,7 +1904,9 @@ describe('useDownloadStore', () => { headers: null, cookies: 'session=secret', cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret'); @@ -1883,7 +1923,9 @@ describe('useDownloadStore', () => { cookie_scopes: [ { url: 'https://media.example/', cookies: 'session=secret' } ], - media: true + media: true, + batch: false, + batch_name: null }); expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes) @@ -1900,7 +1942,9 @@ describe('useDownloadStore', () => { headers: 'Authorization: secret', cookies: 'session=secret', cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); await useDownloadStore.getState().handleExtensionDownload({ urls: [url], @@ -1910,7 +1954,9 @@ describe('useDownloadStore', () => { headers: null, cookies: null, cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({ @@ -1938,7 +1984,9 @@ describe('useDownloadStore', () => { headers: 'User-Agent: Firefox Test', cookies: 'session=secret', cookie_scopes: null, - media: true + media: true, + batch: false, + batch_name: null }); expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([ @@ -1958,7 +2006,9 @@ describe('useDownloadStore', () => { headers: 'User-Agent: Firefox Test', cookies: null, cookie_scopes: null, - media: false + media: false, + batch: false, + batch_name: null }); expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([]); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 4a3c4a1..2149452 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -657,6 +657,8 @@ interface DownloadState { pendingAddHeaders: string; pendingAddCookies: string; pendingAddMediaUrls: string[]; + pendingAddBatch: boolean; + pendingAddBatchName: string; pendingAddRequestContexts: Record; pendingAddRequestVersion: number; selectedPropertiesDownloadId: string | null; @@ -668,7 +670,9 @@ interface DownloadState { headers?: string | null, cookies?: string | null, media?: boolean, - cookieScopes?: ExtensionCookieScope[] | null + cookieScopes?: ExtensionCookieScope[] | null, + batch?: boolean, + batchName?: string | null ) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise; deleteModalState: DeleteModalState; @@ -918,6 +922,8 @@ export const useDownloadStore = create((set, get) => { pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], + pendingAddBatch: false, + pendingAddBatchName: '', pendingAddRequestContexts: {}, pendingAddRequestVersion: 0, selectedPropertiesDownloadId: null, @@ -937,12 +943,24 @@ export const useDownloadStore = create((set, get) => { pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], + pendingAddBatch: false, + pendingAddBatchName: '', pendingAddRequestContexts: {}, // Invalidate any in-flight Add-modal handoff even when the modal is // opened or closed without URLs. pendingAddRequestVersion: state.pendingAddRequestVersion + 1 })), - openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false, cookieScopes) => set((state) => { + openAddModalWithUrls: ( + urls, + referer, + filename, + headers, + cookies, + media = false, + cookieScopes, + batch = false, + batchName + ) => set((state) => { const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls); const existingUrls = isAppending ? state.pendingAddUrls : ''; const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls; @@ -950,6 +968,13 @@ export const useDownloadStore = create((set, get) => { const cleanFilename = filename?.trim() || ''; const cleanHeaders = headers?.trim() || ''; const cleanCookies = cookies?.trim() || ''; + // Keep the first modal request's grouping decision stable while later + // handoffs append URLs. This avoids moving an already-visible destination + // when a second request races with the user's Add-window setup. + const nextBatch = isAppending ? state.pendingAddBatch : batch; + const nextBatchName = nextBatch + ? (isAppending ? state.pendingAddBatchName : batchName?.trim() || '') + : ''; const cleanCookieScopes = cookieScopes ?.map(scope => ({ url: scope.url.trim(), @@ -994,6 +1019,8 @@ export const useDownloadStore = create((set, get) => { pendingAddHeaders: cleanHeaders, pendingAddCookies: cleanCookies, pendingAddMediaUrls, + pendingAddBatch: nextBatch, + pendingAddBatchName: nextBatchName, pendingAddRequestContexts, pendingAddRequestVersion: requestVersion }; @@ -1017,7 +1044,9 @@ export const useDownloadStore = create((set, get) => { headers, cookies, request.media === true, - request.media === true ? undefined : request.cookie_scopes + request.media === true ? undefined : request.cookie_scopes, + request.batch === true && urls.length >= 2, + request.batch_name ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), diff --git a/src/utils/downloadLocations.test.ts b/src/utils/downloadLocations.test.ts index 946a9ae..67740f4 100644 --- a/src/utils/downloadLocations.test.ts +++ b/src/utils/downloadLocations.test.ts @@ -11,11 +11,14 @@ vi.mock('@tauri-apps/api/path', () => ({ import { downloadLocationEquals, DEFAULT_CATEGORY_SUBFOLDERS, + deriveBatchFolderName, formatDerivedCategoryPath, normalizeCategorySubfolder, normalizeDownloadLocationSettings, resolveInitialAddWindowLocation, resolveCategoryDestination, + resolveSubfolderDestination, + sanitizeBatchFolderName, subfolderFromDerivedCategoryPath } from './downloadLocations'; @@ -44,6 +47,48 @@ describe('download locations', () => { expect(resolveInitialAddWindowLocation(' ', true, null)) .toEqual({ path: '~/Downloads', isManual: false }); }); + + it('derives safe batch folder names from title, referer, and timestamp fallback', () => { + expect(deriveBatchFolderName( + 'Gallery / Chapter: 1', + 'https://example.com/gallery' + )).toBe('Gallery - Chapter- 1'); + expect(deriveBatchFolderName( + 'New Tab', + 'https://example.com/gallery/part-1?token=secret' + )).toBe('example.com-gallery-part-1'); + expect(deriveBatchFolderName( + 'New Tab', + 'https://example.com/gallery', + new Date('2026-07-20T12:34:56.789Z'), + ['example.part1.rar', 'example.part2.rar'] + )).toBe('example'); + expect(deriveBatchFolderName( + 'CON', + null, + new Date('2026-07-20T12:34:56.789Z') + )).toBe('batch-CON'); + expect(deriveBatchFolderName( + '', + null, + new Date('2026-07-20T12:34:56.789Z') + )).toBe('firelink-batch-2026-07-20-12-34-56-789'); + expect(deriveBatchFolderName( + '', + null, + new Date('2026-07-20T12:34:56.789Z'), + ['example.part1.rar', 'example.part2.rar'] + )).toBe('example'); + expect(sanitizeBatchFolderName('../Example: Parts')).toBe('Example- Parts'); + expect(sanitizeBatchFolderName('😀'.repeat(100))).toBe('😀'.repeat(96)); + }); + + it('places the optional folder below an existing category destination', async () => { + expect(await resolveSubfolderDestination( + '/Users/test/Downloads/Compressed', + 'Example: Parts' + )).toBe('/Users/test/Downloads/Compressed/Example- Parts'); + }); beforeEach(() => { vi.clearAllMocks(); }); diff --git a/src/utils/downloadLocations.ts b/src/utils/downloadLocations.ts index 3776417..6fcf42d 100644 --- a/src/utils/downloadLocations.ts +++ b/src/utils/downloadLocations.ts @@ -53,6 +53,93 @@ export interface AddWindowLocationSuggestion { isManual: boolean; } +const MAX_BATCH_FOLDER_NAME_LENGTH = 96; +const WEAK_BATCH_PAGE_TITLES = new Set(['new tab', 'untitled', 'about:blank']); + +const truncateBatchFolderName = (value: string): string => Array.from(value) + .filter(character => { + const codePoint = character.codePointAt(0) || 0; + return codePoint < 0xd800 || codePoint > 0xdfff; + }) + .slice(0, MAX_BATCH_FOLDER_NAME_LENGTH) + .join(''); + +export const sanitizeBatchFolderName = (value: string): string => { + const sanitized = truncateBatchFolderName( + value + .trim() + .replace(/[\u0000-\u001f\u007f]/g, '-') + .replace(/[<>:"/\\|?*]/g, '-') + .replace(/\s+/g, ' ') + .replace(/-+/g, '-') + .replace(/^[ .-]+|[ .-]+$/g, '') + ) + .trim() + .replace(/[ .-]+$/g, ''); + + if (!sanitized || sanitized === '.' || sanitized === '..') return ''; + if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(sanitized)) { + return `batch-${sanitized}`; + } + return sanitized; +}; + +const batchFolderSlugFromReferer = (referer: string): string => { + try { + const url = new URL(referer); + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) return ''; + const path = url.pathname.replace(/^\/+|\/+$/g, ''); + return sanitizeBatchFolderName(`${url.hostname}${path ? `-${path}` : ''}`); + } catch { + return ''; + } +}; + +const batchFolderNameFromFiles = (fileNames: string[]): string => { + const stems = fileNames + .map(fileName => fileName.replace(/\\/g, '/').split('/').pop() || '') + .map(fileName => fileName.replace(/\.[^.]+$/, '')) + .filter(Boolean); + if (stems.length === 0) return ''; + + const partStems = stems.map(stem => stem.replace(/[._ -]?part\s*\d+$/i, '')); + const candidate = partStems.every(stem => stem && stem === partStems[0]) + ? partStems[0] + : stems.length === 1 ? stems[0] : ''; + return candidate ? sanitizeBatchFolderName(candidate) : ''; +}; + +export const deriveBatchFolderName = ( + pageTitle?: string | null, + referer?: string | null, + now = new Date(), + fileNames: string[] = [] +): string => { + const title = pageTitle?.trim() || ''; + if (title && !WEAK_BATCH_PAGE_TITLES.has(title.toLocaleLowerCase())) { + const safeTitle = sanitizeBatchFolderName(title); + if (safeTitle) return safeTitle; + } + + const fileNameSlug = batchFolderNameFromFiles(fileNames); + if (fileNameSlug) return fileNameSlug; + + const refererSlug = batchFolderSlugFromReferer(referer?.trim() || ''); + if (refererSlug) return refererSlug; + + const timestamp = now.toISOString().replace(/[.:]/g, '-').replace('T', '-').replace('Z', ''); + return `firelink-batch-${timestamp}`; +}; + +export const resolveSubfolderDestination = async ( + destination: string, + folderName: string +): Promise => { + const root = await expandTilde(destination.trim() || '~/Downloads'); + const safeFolderName = sanitizeBatchFolderName(folderName); + return safeFolderName ? join(root, safeFolderName) : root; +}; + export const resolveInitialAddWindowLocation = ( baseDownloadFolder: string, rememberLastUsedDownloadDirectory: boolean,