fix(downloads): harden filenames and localized surfaces

Bound generated filenames to cross-platform component limits, preserve extensions, and keep duplicate renames unique. Correct light-theme compositing tokens and enforce Persian date formatting while preserving Hebrew locale formatting.

Fixes #29
Refs #31
This commit is contained in:
NimBold
2026-07-30 02:48:29 +03:30
parent 4df6315be1
commit 4e504b8e74
10 changed files with 356 additions and 33 deletions
+1 -4
View File
@@ -30,12 +30,9 @@ This release focuses on smoother queue control, a more comfortable interface, an
- Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state. - Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state.
- Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the users settings. - Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the users settings.
- Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together. - Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together.
- Automatically shorten media filenames that exceed the cross-platform per-file-name limit, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
- Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification. - Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification.
### Known limitation
- Very long media-generated filenames can still exceed an operating systems filename limit. For [#29](https://github.com/nimbold/Firelink/issues/29), the current workaround is to shorten the filename in **Properties** before starting the download.
## [1.2.0] - 2026-07-22 ## [1.2.0] - 2026-07-22
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension. This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
+61 -1
View File
@@ -10,6 +10,9 @@ struct DownloadOwnershipRecord {
} }
pub fn canonical_download_filename(filename: &str) -> String { pub fn canonical_download_filename(filename: &str) -> String {
const MAX_FILENAME_BYTES: usize = 255;
const TRUNCATION_MARKER: &str = "";
let leaf = filename.replace('\\', "/"); let leaf = filename.replace('\\', "/");
let leaf = Path::new(&leaf) let leaf = Path::new(&leaf)
.file_name() .file_name()
@@ -31,7 +34,7 @@ pub fn canonical_download_filename(filename: &str) -> String {
}) })
.collect::<String>(); .collect::<String>();
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']); let sanitized = sanitized.trim().trim_end_matches(['.', ' ']);
if sanitized.is_empty() || matches!(sanitized, "." | "..") { let canonical = if sanitized.is_empty() || matches!(sanitized, "." | "..") {
"download".to_string() "download".to_string()
} else if crate::platform::is_windows_reserved_filename(sanitized) { } else if crate::platform::is_windows_reserved_filename(sanitized) {
let path = Path::new(sanitized); let path = Path::new(sanitized);
@@ -45,7 +48,46 @@ pub fn canonical_download_filename(filename: &str) -> String {
} }
} else { } else {
sanitized.to_string() sanitized.to_string()
};
if canonical.len() <= MAX_FILENAME_BYTES {
return canonical;
} }
let extension = Path::new(&canonical)
.extension()
.and_then(|value| value.to_str())
.map(|value| format!(".{value}"))
.unwrap_or_default();
let base = canonical
.strip_suffix(&extension)
.unwrap_or(canonical.as_str());
let base_budget = MAX_FILENAME_BYTES
.saturating_sub(extension.len())
.saturating_sub(TRUNCATION_MARKER.len());
if base_budget == 0 {
return truncate_utf8_to_bytes(&canonical, MAX_FILENAME_BYTES);
}
format!(
"{}{}{}",
truncate_utf8_to_bytes(base, base_budget),
TRUNCATION_MARKER,
extension
)
}
fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
let mut end = 0;
for (index, character) in value.char_indices() {
let next = index + character.len_utf8();
if next > max_bytes {
break;
}
end = next;
}
value[..end].to_string()
} }
pub fn expected_primary_path( pub fn expected_primary_path(
@@ -256,6 +298,24 @@ mod tests {
assert_eq!(canonical_download_filename("lpt9"), "lpt9-"); assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
} }
#[test]
fn truncates_long_filenames_by_utf8_bytes_and_preserves_extension() {
let filename = canonical_download_filename(&format!("{}.mp4", "title ".repeat(100)));
assert!(filename.len() <= 255);
assert!(filename.ends_with(".mp4"));
assert!(filename.contains('…'));
}
#[test]
fn truncates_multibyte_filenames_at_character_boundaries() {
let filename = canonical_download_filename(&format!("{}.mkv", "😀".repeat(100)));
assert!(filename.len() <= 255);
assert!(filename.ends_with(".mkv"));
assert!(!filename.contains('\u{fffd}'));
}
#[test] #[test]
fn malformed_legacy_download_does_not_block_valid_ownership_records() { fn malformed_legacy_download_does_not_block_valid_ownership_records() {
let valid = json!({ let valid = json!({
+2 -4
View File
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import { import {
expandTilde, expandTilde,
@@ -1078,8 +1078,6 @@ export const AddDownloadsModal = () => {
); );
let count = 1; let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
let newName = finalFile; let newName = finalFile;
let exists = true; let exists = true;
const batchTargets: Array<{ location: string; fileName: string }> = []; const batchTargets: Array<{ location: string; fileName: string }> = [];
@@ -1098,7 +1096,7 @@ export const AddDownloadsModal = () => {
} }
while (exists && count < 1000) { while (exists && count < 1000) {
newName = `${base} (${count})${ext}`; newName = downloadFileNameWithSuffix(finalFile, ` (${count})`);
let storeHas = false; let storeHas = false;
const currentSettings = useSettingsStore.getState(); const currentSettings = useSettingsStore.getState();
for (const download of useDownloadStore.getState().downloads) { for (const download of useDownloadStore.getState().downloads) {
+6 -5
View File
@@ -22,7 +22,8 @@
--bg-input: 0 0% 100%; --bg-input: 0 0% 100%;
--border-modal: 0 0% 85%; --border-modal: 0 0% 85%;
--surface-raised: 0 0% 100%; --surface-raised: 0 0% 100%;
--surface-overlay: 0 0% 100% / 0.95; /* Keep this token alpha-free because some consumers apply their own /alpha. */
--surface-overlay: 0 0% 100%;
--shadow-color: 220 10% 20% / 0.1; --shadow-color: 220 10% 20% / 0.1;
--sidebar-shell-bg: 0 0% 92%; --sidebar-shell-bg: 0 0% 92%;
--sidebar-panel-bg: 0 0% 96%; --sidebar-panel-bg: 0 0% 96%;
@@ -66,7 +67,7 @@
--bg-input: 0 0% 100%; --bg-input: 0 0% 100%;
--border-modal: 0 0% 85%; --border-modal: 0 0% 85%;
--surface-raised: 0 0% 100%; --surface-raised: 0 0% 100%;
--surface-overlay: 0 0% 100% / 0.95; --surface-overlay: 0 0% 100%;
--shadow-color: 220 10% 20% / 0.1; --shadow-color: 220 10% 20% / 0.1;
--sidebar-shell-bg: 0 0% 92%; --sidebar-shell-bg: 0 0% 92%;
--sidebar-panel-bg: 0 0% 96%; --sidebar-panel-bg: 0 0% 96%;
@@ -911,7 +912,7 @@ html[data-list-density="relaxed"] {
.add-download-playlist-media-type-option.is-selected { .add-download-playlist-media-type-option.is-selected {
background: hsl(var(--surface-raised) / 0.9); background: hsl(var(--surface-raised) / 0.9);
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35); box-shadow: 0 1px 2px hsl(var(--shadow-color));
color: hsl(var(--text-primary)); color: hsl(var(--text-primary));
} }
@@ -2508,7 +2509,7 @@ html[data-list-density="relaxed"] {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid hsl(var(--border-color)); border: 1px solid hsl(var(--border-color));
border-bottom-color: hsl(var(--border-color) / 0.72); border-bottom-color: hsl(var(--border-color));
border-radius: 4px; border-radius: 4px;
background: hsl(var(--item-hover)); background: hsl(var(--item-hover));
color: hsl(var(--text-secondary)); color: hsl(var(--text-secondary));
@@ -3106,7 +3107,7 @@ body.is-queue-dragging * {
.add-download-nested-fields { .add-download-nested-fields {
padding-inline-start: 1.25rem; padding-inline-start: 1.25rem;
border-inline-start: 2px solid hsl(var(--border-modal) / 0.5); border-inline-start: 2px solid hsl(var(--border-modal));
} }
.add-download-advanced-fields { .add-download-advanced-fields {
+24 -1
View File
@@ -3,6 +3,7 @@ import { dispatchItem, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimat
import { useDownloadProgressStore } from './downloadProgressStore'; import { useDownloadProgressStore } from './downloadProgressStore';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc'; import * as ipc from '../ipc';
import { MAX_DOWNLOAD_FILENAME_BYTES } from '../utils/downloads';
vi.mock('../ipc', () => ({ vi.mock('../ipc', () => ({
invokeCommand: vi.fn(), invokeCommand: vi.fn(),
@@ -113,6 +114,27 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2); expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
}); });
it('normalizes an overlong filename edited in Properties before persisting it', async () => {
useDownloadStore.setState({
downloads: [{
id: 'properties-long-name',
url: 'https://example.com/video',
fileName: 'video.mp4',
status: 'ready',
category: 'Movies',
dateAdded: ''
}] as any[]
});
await useDownloadStore.getState().applyProperties('properties-long-name', {
fileName: `${'title '.repeat(100)}.mp4`
});
const fileName = useDownloadStore.getState().downloads[0].fileName;
expect(new TextEncoder().encode(fileName).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(fileName.endsWith('.mp4')).toBe(true);
});
it('replaces stale media intent when an appended handoff reuses a URL', () => { it('replaces stale media intent when an appended handoff reuses a URL', () => {
useDownloadStore.getState().openAddModalWithUrls( useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/file.bin', '', '', '', '', true 'https://example.com/file.bin', '', '', '', '', true
@@ -1563,7 +1585,7 @@ describe('useDownloadStore', () => {
})]; })];
} }
if (cmd === 'enqueue_many') { if (cmd === 'enqueue_many') {
return [{ id: 'startup-accepted', success: true, filename: 'file.bin' }]; return [{ id: 'startup-accepted', success: true, filename: 'normalized.bin' }];
} }
if (cmd === 'get_pending_order') throw new Error('queue state unavailable'); if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
if (cmd === 'resume_download') return true; if (cmd === 'resume_download') return true;
@@ -1575,6 +1597,7 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true); expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({ expect(useDownloadStore.getState().downloads[0]).toMatchObject({
fileName: 'normalized.bin',
status: 'queued', status: 'queued',
hasBeenDispatched: true hasBeenDispatched: true
}); });
+33 -11
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue'; import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore'; import { useDownloadProgressStore } from './downloadProgressStore';
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { import {
resolveCategoryDestination resolveCategoryDestination
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
@@ -809,13 +809,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const state = get(); const state = get();
const item = state.downloads.find(d => d.id === id); const item = state.downloads.find(d => d.id === id);
if (!item) return; if (!item) return;
const normalizedUpdates = updates.fileName === undefined
? updates
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
throw new Error(i18n.t($ => $.downloadTable.transferActive)); throw new Error(i18n.t($ => $.downloadTable.transferActive));
} }
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') { if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates); state.updateDownload(id, normalizedUpdates);
return; return;
} }
@@ -828,7 +831,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
state.unregisterBackendIds([id]); state.unregisterBackendIds([id]);
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) })); set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
} }
state.updateDownload(id, updates); state.updateDownload(id, normalizedUpdates);
if (isRegistered || wasDispatching) { if (isRegistered || wasDispatching) {
const dispatched = await dispatchItemInternal(id); const dispatched = await dispatchItemInternal(id);
if (dispatched) { if (dispatched) {
@@ -847,7 +850,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} }
state.unregisterBackendIds([id]); state.unregisterBackendIds([id]);
} }
state.updateDownload(id, updates); state.updateDownload(id, normalizedUpdates);
} }
}; };
@@ -1306,21 +1309,25 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
addDownload: async (item, action) => { addDownload: async (item, action) => {
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
const destPath = await effectiveDestinationForItem(item, settings); const normalizedItem = {
...item,
fileName: canonicalizeDownloadFileName(item.fileName)
};
const destPath = await effectiveDestinationForItem(normalizedItem, settings);
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID; const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
const queueItems = get().downloads.filter(download => const queueItems = get().downloads.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId (download.queueId || MAIN_QUEUE_ID) === queueId
); );
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const queuePosition = maxPos + 1; const queuePosition = maxPos + 1;
const { sizeBytes, ...downloadDraft } = item; const { sizeBytes, ...downloadDraft } = normalizedItem;
const ownedItem: DownloadItem = { const ownedItem: DownloadItem = {
...downloadDraft, ...downloadDraft,
totalBytes: item.totalBytes ?? sizeBytes, totalBytes: normalizedItem.totalBytes ?? sizeBytes,
totalIsEstimate: item.totalIsEstimate ?? ( totalIsEstimate: normalizedItem.totalIsEstimate ?? (
item.isMedia === true && item.size?.trim().startsWith('~') normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~')
), ),
connections: resolveDownloadConnections(item.connections, settings.perServerConnections), connections: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections),
destination: destPath, destination: destPath,
status: action.type === 'add-to-queue' ? 'staged' : 'ready', status: action.type === 'add-to-queue' ? 'staged' : 'ready',
queueId, queueId,
@@ -2055,6 +2062,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
.filter(result => !result.success) .filter(result => !result.success)
.map(result => [result.id, result.error || 'Backend rejected the queued download.']) .map(result => [result.id, result.error || 'Backend rejected the queued download.'])
); );
const acceptedFilenames = new Map(
results
.filter(result => result.success && Boolean(result.filename))
.map(result => [result.id, result.filename!])
);
const acceptedIdSet = new Set(registeredIds); const acceptedIdSet = new Set(registeredIds);
const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation])); const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation]));
@@ -2089,7 +2101,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
lastError: failedErrors.get(download.id) lastError: failedErrors.get(download.id)
} }
: liveAcceptedIds.has(download.id) : liveAcceptedIds.has(download.id)
? { ...download, hasBeenDispatched: true, lastError: undefined } ? {
...download,
...(acceptedFilenames.has(download.id)
? {
fileName: acceptedFilenames.get(download.id),
category: categoryForFileName(acceptedFilenames.get(download.id)!)
}
: {}),
hasBeenDispatched: true,
lastError: undefined
}
: download : download
) )
}; };
+37 -5
View File
@@ -17,13 +17,45 @@ describe('date/time formatting', () => {
}); });
it('supports the opt-in Persian and Hebrew calendars', () => { it('supports the opt-in Persian and Hebrew calendars', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' }; const hebrewOptions: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe( expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian' })).toBe('۱۴۰۵/۰۱/۰۱');
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant) expect(formatDateTime(instant, {
locale: 'fa',
calendar: 'persian',
options: { dateStyle: 'medium', timeStyle: 'short' }
})).toContain('۱۴۰۵/۰۱/۰۱');
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options: hebrewOptions })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', hebrewOptions).format(instant)
); );
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe( });
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
it('keeps Persian dates zero-padded when the caller requests weekday or time', () => {
const formatted = formatDateTime(instant, {
locale: 'fa',
calendar: 'persian',
options: {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
});
expect(formatted).toContain('۱۴۰۵/۰۱/۰۱');
});
it('does not drop Persian time-only Intl fields', () => {
const formatted = formatDateTime(
new Date('2026-03-21T14:30:00.123Z'),
{
locale: 'en',
calendar: 'persian',
options: { fractionalSecondDigits: 3 } as Intl.DateTimeFormatOptions
}
); );
expect(formatted).toContain('123');
}); });
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => { it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
+101
View File
@@ -28,6 +28,100 @@ const localeWithCalendar = (locale: string | null | undefined, calendar: Calenda
const dateFromInput = (value: DateTimeInput): Date => const dateFromInput = (value: DateTimeInput): Date =>
value instanceof Date ? new Date(value.getTime()) : new Date(value); value instanceof Date ? new Date(value.getTime()) : new Date(value);
const DATE_OPTION_KEYS = new Set([
'dateStyle',
'era',
'month',
'day',
'year',
'weekday'
]);
const TIME_OPTION_KEYS = [
'hour',
'hour12',
'hourCycle',
'minute',
'second',
'timeZoneName',
'dayPeriod',
'fractionalSecondDigits'
] as const;
const persianDateParts = (
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions
): string => {
const formatter = new Intl.DateTimeFormat(locale, {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const values = new Map(formatter.formatToParts(date)
.filter(part => part.type === 'year' || part.type === 'month' || part.type === 'day')
.map(part => [part.type, part.value]));
const year = values.get('year');
const month = values.get('month');
const day = values.get('day');
if (!year || !month || !day) return formatter.format(date);
return `${year}/${month}/${day}`;
};
const formatPersianDateTime = (
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions
): string => {
const hasDateOptions = Object.keys(options).some(key => DATE_OPTION_KEYS.has(key));
const hasTimeOptions = options.timeStyle !== undefined ||
TIME_OPTION_KEYS.some(key => (options as unknown as Record<string, unknown>)[key] !== undefined);
const includeDate = Object.keys(options).length === 0 || hasDateOptions;
const parts: string[] = [];
if (includeDate) {
let dateText = persianDateParts(date, locale, options);
if (options.weekday) {
const weekday = new Intl.DateTimeFormat(locale, {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone,
weekday: options.weekday
}).format(date);
dateText = `${weekday}, ${dateText}`;
}
parts.push(dateText);
}
if (hasTimeOptions) {
const timeOptions: Intl.DateTimeFormatOptions = {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone
};
for (const key of TIME_OPTION_KEYS) {
const value = (options as unknown as Record<string, unknown>)[key];
if (value !== undefined) Object.assign(timeOptions, { [key]: value });
}
if (options.timeStyle) {
timeOptions.hour = 'numeric';
timeOptions.minute = '2-digit';
if (options.timeStyle === 'medium' || options.timeStyle === 'long' || options.timeStyle === 'full') {
timeOptions.second = '2-digit';
}
if (options.timeStyle === 'long' || options.timeStyle === 'full') {
timeOptions.timeZoneName = options.timeStyle === 'full' ? 'long' : 'short';
}
}
parts.push(new Intl.DateTimeFormat(locale, timeOptions).format(date));
}
return parts.join(', ');
};
/** /**
* Format a user-facing timestamp with an explicit calendar. Gregorian is * Format a user-facing timestamp with an explicit calendar. Gregorian is
* passed explicitly because some localized browser defaults use a regional * passed explicitly because some localized browser defaults use a regional
@@ -46,6 +140,13 @@ export const formatDateTime = (
const options = config.options ?? {}; const options = config.options ?? {};
try { try {
if (calendar === 'persian') {
return formatPersianDateTime(
date,
localeWithCalendar(config.locale, calendar),
options
);
}
return new Intl.DateTimeFormat( return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, calendar), localeWithCalendar(config.locale, calendar),
options options
+26
View File
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem'; import type { DownloadItem } from '../bindings/DownloadItem';
import { import {
downloadFileNamesMatch, downloadFileNamesMatch,
downloadFileNameWithSuffix,
downloadMediaKindsMatch, downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
redactDownloadForPersistence, redactDownloadForPersistence,
resolveDownloadConnections resolveDownloadConnections
} from './downloads'; } from './downloads';
@@ -63,6 +66,29 @@ describe('download connection resolution', () => {
}); });
describe('download filename matching', () => { describe('download filename matching', () => {
it('truncates long names by UTF-8 bytes while preserving the extension', () => {
const filename = canonicalizeDownloadFileName(`${'title '.repeat(100)}.mp4`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mp4')).toBe(true);
expect(filename).toContain('…');
});
it('does not split a multibyte character at the filesystem boundary', () => {
const filename = canonicalizeDownloadFileName(`${'😀'.repeat(100)}.mkv`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mkv')).toBe(true);
expect([...filename].every(character => character !== '\uFFFD')).toBe(true);
});
it('keeps alternate names unique and bounded after long-name truncation', () => {
const filename = downloadFileNameWithSuffix(`${'title '.repeat(100)}.mp4`, ' (1)');
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith(' (1).mp4')).toBe(true);
});
it('matches case and path spelling while preserving the actual filename', () => { it('matches case and path spelling while preserving the actual filename', () => {
expect(downloadFileNamesMatch( expect(downloadFileNamesMatch(
'Media\\Example.Show.S01E01.MKV', 'Media\\Example.Show.S01E01.MKV',
+65 -2
View File
@@ -43,6 +43,27 @@ export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
export const DOWNLOAD_CONNECTIONS_MIN = 1; export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16; export const DOWNLOAD_CONNECTIONS_MAX = 16;
// Keep every filename component within the common cross-platform filesystem
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
// bound is also conservative for Windows filename components.
export const MAX_DOWNLOAD_FILENAME_BYTES = 255;
const FILENAME_TRUNCATION_MARKER = '…';
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length;
const truncateUtf8ToBytes = (value: string, maxBytes: number): string => {
if (maxBytes <= 0) return '';
let bytes = 0;
let result = '';
for (const character of value) {
const characterBytes = utf8ByteLength(character);
if (bytes + characterBytes > maxBytes) break;
result += character;
bytes += characterBytes;
}
return result;
};
/** /**
* Resolve persisted/user-entered connection values before they cross into the * Resolve persisted/user-entered connection values before they cross into the
* backend. Older rows may omit the value, while malformed rows can contain * backend. Older rows may omit the value, while malformed rows can contain
@@ -126,10 +147,52 @@ export const fileNameFromUrl = (rawUrl: string): string => {
export const canonicalizeDownloadFileName = (fileName: string): string => { export const canonicalizeDownloadFileName = (fileName: string): string => {
const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download'; const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download';
const sanitized = leaf const sanitized = leaf
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-') .replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
.trim() .trim()
.replace(/[. ]+$/g, ''); .replace(/[. ]+$/g, '');
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download'; const canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
if (utf8ByteLength(canonical) <= MAX_DOWNLOAD_FILENAME_BYTES) return canonical;
const extensionStart = canonical.lastIndexOf('.');
const hasExtension = extensionStart > 0;
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
const extension = hasExtension ? canonical.slice(extensionStart) : '';
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
- utf8ByteLength(extension)
- utf8ByteLength(FILENAME_TRUNCATION_MARKER);
if (baseBudget <= 0) {
return truncateUtf8ToBytes(canonical, MAX_DOWNLOAD_FILENAME_BYTES);
}
return `${truncateUtf8ToBytes(base, baseBudget)}${FILENAME_TRUNCATION_MARKER}${extension}`;
};
/**
* Create a deterministic alternate filename without exceeding the same
* component limit as canonicalizeDownloadFileName. The suffix is intended for
* trusted generated values such as " (1)".
*/
export const downloadFileNameWithSuffix = (fileName: string, suffix: string): string => {
const canonical = canonicalizeDownloadFileName(fileName);
const safeSuffix = suffix
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
.replace(/[. ]+$/g, '');
if (!safeSuffix.trim()) return canonical;
const extensionStart = canonical.lastIndexOf('.');
const hasExtension = extensionStart > 0;
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
const extension = hasExtension ? canonical.slice(extensionStart) : '';
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
- utf8ByteLength(safeSuffix)
- utf8ByteLength(extension);
if (baseBudget <= 0) {
return truncateUtf8ToBytes(`${base}${safeSuffix}${extension}`, MAX_DOWNLOAD_FILENAME_BYTES);
}
return `${truncateUtf8ToBytes(base, baseBudget)}${safeSuffix}${extension}`;
}; };
/** /**