mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(downloads): add opt-in batch folders (#27)
This commit is contained in:
+1
-1
Submodule Extensions/Browser updated: 61ca152089...6bf52938c9
@@ -74,6 +74,10 @@ struct ExtensionRequest {
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
#[serde(default)]
|
||||
batch: bool,
|
||||
#[serde(default)]
|
||||
batch_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||
@@ -96,6 +100,8 @@ pub struct ExtensionDownload {
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
media: bool,
|
||||
batch: bool,
|
||||
batch_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn start_server(
|
||||
@@ -428,6 +434,14 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
|
||||
});
|
||||
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
|
||||
let batch = payload.batch && urls.len() >= 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<ExtensionDownload
|
||||
cookies,
|
||||
cookie_scopes,
|
||||
media: payload.media,
|
||||
batch,
|
||||
batch_name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -690,9 +706,9 @@ fn is_allowed_origin(origin: &str) -> 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()));
|
||||
|
||||
@@ -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<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
|
||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, batch: boolean, batch_name: string | null, };
|
||||
|
||||
@@ -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<string | null>(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<string> => {
|
||||
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)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowse}
|
||||
disabled={isSubmitting}
|
||||
className="add-download-button add-download-button-secondary px-3 text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -1559,12 +1643,70 @@ export const AddDownloadsModal = () => {
|
||||
{t($ => $.addDownloads.browse)}
|
||||
</button>
|
||||
</div>
|
||||
{parsedItems.length > 1 && (
|
||||
<div className="mt-3">
|
||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveInDedicatedFolder}
|
||||
onChange={event => {
|
||||
const enabled = event.target.checked;
|
||||
if (enabled && !sanitizeBatchFolderName(dedicatedFolderName)) {
|
||||
dedicatedFolderNameEditedRef.current = false;
|
||||
setDedicatedFolderName(deriveBatchFolderName(
|
||||
pendingAddBatchName,
|
||||
pendingAddReferer,
|
||||
new Date(),
|
||||
parsedItems.map(item => item.file)
|
||||
));
|
||||
}
|
||||
setSaveInDedicatedFolder(enabled);
|
||||
}}
|
||||
className="add-download-checkbox"
|
||||
/>
|
||||
{t($ => $.addDownloads.dedicatedFolder)}
|
||||
</label>
|
||||
{saveInDedicatedFolder && (
|
||||
<>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={dedicatedFolderName}
|
||||
onChange={event => {
|
||||
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}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={commitDedicatedFolderName}
|
||||
disabled={isSubmitting}
|
||||
className="add-download-button add-download-button-secondary px-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label={t($ => $.addDownloads.saveFolderName)}
|
||||
title={t($ => $.addDownloads.saveFolderName)}
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
{t(isSaveLocationManual
|
||||
? $ => $.addDownloads.dedicatedFolderManualDescription
|
||||
: $ => $.addDownloads.dedicatedFolderDescription)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parsedItems.length > 1 && !isSaveLocationManual && (
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
{t($ => $.addDownloads.categoryFolders)}
|
||||
</p>
|
||||
)}
|
||||
{isSaveLocationManual && (
|
||||
{isSaveLocationManual && !saveInDedicatedFolder && (
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
{t($ => $.addDownloads.sharedFolder)}
|
||||
</p>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -440,6 +440,12 @@ const fa = {
|
||||
browse: 'انتخاب',
|
||||
categoryFolders: 'فایلها در پوشههای دسته سازماندهی خواهند شد.',
|
||||
sharedFolder: 'تمام دانلودهای انتخابشده از این پوشه استفاده خواهند کرد.',
|
||||
dedicatedFolder: 'ذخیره در پوشهای جدید',
|
||||
dedicatedFolderName: 'نام پوشه',
|
||||
dedicatedFolderDescription: 'این پوشه را داخل هر پوشه دستهبندی خودکار ایجاد میکند.',
|
||||
dedicatedFolderManualDescription: 'این پوشه را داخل محل ذخیره انتخابشده ایجاد میکند.',
|
||||
saveFolderName: 'ذخیره نام پوشه',
|
||||
dedicatedFolderNameRequired: 'پیش از ذخیره، نام پوشه را وارد کنید.',
|
||||
transferSettings: 'تنظیمات انتقال',
|
||||
connectionsPerFile: 'اتصالات در هر فایل',
|
||||
connectionsPerFileAria: 'اتصالات در هر فایل',
|
||||
|
||||
@@ -440,6 +440,12 @@ const he = {
|
||||
browse: 'עיון',
|
||||
categoryFolders: 'הקבצים יאורגנו בתיקיות קטגוריה.',
|
||||
sharedFolder: 'כל ההורדות שנבחרו ישתמשו בתיקייה זו.',
|
||||
dedicatedFolder: 'שמירה בתיקייה חדשה',
|
||||
dedicatedFolderName: 'שם התיקייה',
|
||||
dedicatedFolderDescription: 'תיקייה זו תיווצר בתוך כל תיקיית קטגוריה אוטומטית.',
|
||||
dedicatedFolderManualDescription: 'תיקייה זו תיווצר בתוך מיקום השמירה שנבחר.',
|
||||
saveFolderName: 'שמירת שם התיקייה',
|
||||
dedicatedFolderNameRequired: 'יש להזין שם תיקייה לפני השמירה.',
|
||||
transferSettings: 'הגדרות העברה',
|
||||
connectionsPerFile: 'חיבורים לקובץ',
|
||||
connectionsPerFileAria: 'חיבורים לקובץ',
|
||||
|
||||
@@ -440,6 +440,12 @@ const ru = {
|
||||
browse: 'Обзор',
|
||||
categoryFolders: 'Файлы будут организованы в папки категорий.',
|
||||
sharedFolder: 'Все выбранные загрузки будут использовать эту папку.',
|
||||
dedicatedFolder: 'Сохранить в новую папку',
|
||||
dedicatedFolderName: 'Имя папки',
|
||||
dedicatedFolderDescription: 'Эта папка будет создана внутри каждой автоматической папки категории.',
|
||||
dedicatedFolderManualDescription: 'Эта папка будет создана внутри выбранного места сохранения.',
|
||||
saveFolderName: 'Сохранить имя папки',
|
||||
dedicatedFolderNameRequired: 'Введите имя папки перед сохранением.',
|
||||
transferSettings: 'Настройки передачи',
|
||||
connectionsPerFile: 'Соединений на файл',
|
||||
connectionsPerFileAria: 'Соединений на файл',
|
||||
|
||||
@@ -440,6 +440,12 @@ const uk = {
|
||||
browse: 'Огляд',
|
||||
categoryFolders: 'Файли будуть організовані в папки категорій.',
|
||||
sharedFolder: 'Усі вибрані завантаження використовуватимуть цю папку.',
|
||||
dedicatedFolder: 'Зберегти в новій папці',
|
||||
dedicatedFolderName: 'Назва папки',
|
||||
dedicatedFolderDescription: 'Цю папку буде створено всередині кожної автоматичної папки категорії.',
|
||||
dedicatedFolderManualDescription: 'Цю папку буде створено всередині вибраного місця збереження.',
|
||||
saveFolderName: 'Зберегти назву папки',
|
||||
dedicatedFolderNameRequired: 'Введіть назву папки перед збереженням.',
|
||||
transferSettings: 'Налаштування передачі',
|
||||
connectionsPerFile: 'З\'єднань на файл',
|
||||
connectionsPerFileAria: 'З\'єднань на файл',
|
||||
|
||||
@@ -440,6 +440,12 @@ const zhCN = {
|
||||
browse: '浏览',
|
||||
categoryFolders: '文件将被组织到类别文件夹中。',
|
||||
sharedFolder: '所有选定的下载都将使用此文件夹。',
|
||||
dedicatedFolder: '保存到新文件夹',
|
||||
dedicatedFolderName: '文件夹名称',
|
||||
dedicatedFolderDescription: '将在每个自动类别文件夹中创建此文件夹。',
|
||||
dedicatedFolderManualDescription: '将在选定的保存位置中创建此文件夹。',
|
||||
saveFolderName: '保存文件夹名称',
|
||||
dedicatedFolderNameRequired: '保存前请输入文件夹名称。',
|
||||
transferSettings: '传输设置',
|
||||
connectionsPerFile: '每个文件的连接数',
|
||||
connectionsPerFileAria: '每个文件的连接数 (Aria)',
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -657,6 +657,8 @@ interface DownloadState {
|
||||
pendingAddHeaders: string;
|
||||
pendingAddCookies: string;
|
||||
pendingAddMediaUrls: string[];
|
||||
pendingAddBatch: boolean;
|
||||
pendingAddBatchName: string;
|
||||
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
||||
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<void>;
|
||||
deleteModalState: DeleteModalState;
|
||||
@@ -918,6 +922,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddBatch: false,
|
||||
pendingAddBatchName: '',
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
selectedPropertiesDownloadId: null,
|
||||
@@ -937,12 +943,24 @@ export const useDownloadStore = create<DownloadState>((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<DownloadState>((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<DownloadState>((set, get) => {
|
||||
pendingAddHeaders: cleanHeaders,
|
||||
pendingAddCookies: cleanCookies,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddBatch: nextBatch,
|
||||
pendingAddBatchName: nextBatchName,
|
||||
pendingAddRequestContexts,
|
||||
pendingAddRequestVersion: requestVersion
|
||||
};
|
||||
@@ -1017,7 +1044,9 @@ export const useDownloadStore = create<DownloadState>((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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<string> => {
|
||||
const root = await expandTilde(destination.trim() || '~/Downloads');
|
||||
const safeFolderName = sanitizeBatchFolderName(folderName);
|
||||
return safeFolderName ? join(root, safeFolderName) : root;
|
||||
};
|
||||
|
||||
export const resolveInitialAddWindowLocation = (
|
||||
baseDownloadFolder: string,
|
||||
rememberLastUsedDownloadDirectory: boolean,
|
||||
|
||||
Reference in New Issue
Block a user