mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 03:59:09 +00:00
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import { join, homeDir } from '@tauri-apps/api/path';
|
|
import type { DownloadCategory } from '../bindings/DownloadCategory';
|
|
|
|
export const expandTilde = async (path: string): Promise<string> => {
|
|
if (path.startsWith('~/') || path.startsWith('~\\')) {
|
|
const home = await homeDir();
|
|
return join(home, path.slice(2));
|
|
}
|
|
if (path === '~') {
|
|
return homeDir();
|
|
}
|
|
return path;
|
|
};
|
|
|
|
export const DOWNLOAD_CATEGORIES: DownloadCategory[] = [
|
|
'Musics',
|
|
'Movies',
|
|
'Compressed',
|
|
'Documents',
|
|
'Pictures',
|
|
'Applications',
|
|
'Other'
|
|
];
|
|
|
|
export const DEFAULT_CATEGORY_SUBFOLDERS: Record<DownloadCategory, string> = {
|
|
Musics: 'Musics',
|
|
Movies: 'Movies',
|
|
Compressed: 'Compressed',
|
|
Documents: 'Documents',
|
|
Pictures: 'Pictures',
|
|
Applications: 'Applications',
|
|
Other: 'Other'
|
|
};
|
|
|
|
export interface DownloadLocationSettings {
|
|
baseDownloadFolder: string;
|
|
categorySubfoldersEnabled: boolean;
|
|
categorySubfolders: Record<string, string>;
|
|
categoryDirectoryOverrides: Record<string, string>;
|
|
}
|
|
|
|
interface LegacyDownloadLocationSettings {
|
|
baseDownloadFolder?: unknown;
|
|
categorySubfoldersEnabled?: unknown;
|
|
categorySubfolders?: unknown;
|
|
categoryDirectoryOverrides?: unknown;
|
|
defaultDownloadPath?: unknown;
|
|
downloadDirectories?: unknown;
|
|
}
|
|
|
|
export interface AddWindowLocationSuggestion {
|
|
path: string;
|
|
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,
|
|
lastUsedDownloadDirectory: string | null
|
|
): AddWindowLocationSuggestion => {
|
|
const rememberedPath = rememberLastUsedDownloadDirectory
|
|
? lastUsedDownloadDirectory?.trim()
|
|
: undefined;
|
|
const basePath = baseDownloadFolder.trim() || '~/Downloads';
|
|
return {
|
|
path: rememberedPath || basePath,
|
|
isManual: Boolean(rememberedPath)
|
|
};
|
|
};
|
|
|
|
const stringRecord = (value: unknown): Record<string, string> => {
|
|
if (!value || typeof value !== 'object') return {};
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
.map(([key, path]) => [key, path.trim()])
|
|
);
|
|
};
|
|
|
|
const hasOwn = (value: Record<string, string>, key: string): boolean =>
|
|
Object.prototype.hasOwnProperty.call(value, key);
|
|
|
|
const normalizedForComparison = (value: string): string =>
|
|
value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
|
|
const legacyDerivedPath = (base: string, subfolder: string): string =>
|
|
[normalizedForComparison(base), subfolder.replace(/^[\\/]+|[\\/]+$/g, '')]
|
|
.filter(Boolean)
|
|
.join('/');
|
|
|
|
const isWindowsLikePath = (value: string): boolean =>
|
|
/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.includes('\\');
|
|
|
|
export const formatDerivedCategoryPath = (base: string, subfolder: string): string => {
|
|
const trimmedBase = base.trim() || '~/Downloads';
|
|
const relative = subfolder.replace(/^[\\/]+|[\\/]+$/g, '');
|
|
if (!relative) return trimmedBase.replace(/[\\/]+$/, '');
|
|
const separator = isWindowsLikePath(trimmedBase) ? '\\' : '/';
|
|
return `${trimmedBase.replace(/[\\/]+$/, '')}${separator}${relative}`;
|
|
};
|
|
|
|
export const subfolderFromDerivedCategoryPath = (
|
|
value: string,
|
|
base: string
|
|
): string | null => {
|
|
const normalizedValue = normalizedForComparison(value.trim());
|
|
const normalizedBase = normalizedForComparison((base.trim() || '~/Downloads'));
|
|
const isWindows = isWindowsLikePath(base);
|
|
const comparableValue = isWindows ? normalizedValue.toLocaleLowerCase() : normalizedValue;
|
|
const comparableBase = isWindows ? normalizedBase.toLocaleLowerCase() : normalizedBase;
|
|
|
|
if (comparableValue === comparableBase) return '';
|
|
if (!comparableValue.startsWith(`${comparableBase}/`)) return null;
|
|
return normalizedValue.slice(normalizedBase.length + 1);
|
|
};
|
|
|
|
export const normalizeCategorySubfolder = (
|
|
value: string,
|
|
fallback: string
|
|
): string => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return '';
|
|
|
|
const parts = value
|
|
.trim()
|
|
.replace(/\\/g, '/')
|
|
.split('/')
|
|
.filter(part => part && part !== '.' && part !== '..' && !part.endsWith(':'));
|
|
return parts.join('/') || fallback;
|
|
};
|
|
|
|
export const normalizeDownloadLocationSettings = (
|
|
value: LegacyDownloadLocationSettings
|
|
): DownloadLocationSettings => {
|
|
const baseDownloadFolder =
|
|
(typeof value.baseDownloadFolder === 'string' && value.baseDownloadFolder.trim()) ||
|
|
(typeof value.defaultDownloadPath === 'string' && value.defaultDownloadPath.trim()) ||
|
|
'~/Downloads';
|
|
const categorySubfoldersEnabled = value.categorySubfoldersEnabled !== false;
|
|
const persistedSubfolders = stringRecord(value.categorySubfolders);
|
|
const categorySubfolders = Object.fromEntries(
|
|
DOWNLOAD_CATEGORIES.map(category => {
|
|
const persistedValue = hasOwn(persistedSubfolders, category)
|
|
? persistedSubfolders[category]
|
|
: DEFAULT_CATEGORY_SUBFOLDERS[category];
|
|
return [
|
|
category,
|
|
normalizeCategorySubfolder(persistedValue, DEFAULT_CATEGORY_SUBFOLDERS[category])
|
|
];
|
|
})
|
|
);
|
|
const categoryDirectoryOverrides = stringRecord(value.categoryDirectoryOverrides);
|
|
const legacyDirectories = stringRecord(value.downloadDirectories);
|
|
const legacyAliases: Record<DownloadCategory, string> = {
|
|
Musics: 'Audio',
|
|
Movies: 'Video',
|
|
Compressed: 'Archives',
|
|
Documents: 'Documents',
|
|
Pictures: 'Images',
|
|
Applications: 'Apps',
|
|
Other: 'Other'
|
|
};
|
|
|
|
for (const category of DOWNLOAD_CATEGORIES) {
|
|
const legacyDirectory =
|
|
legacyDirectories[category] || legacyDirectories[legacyAliases[category]];
|
|
if (categoryDirectoryOverrides[category] || !legacyDirectory) continue;
|
|
const expected = legacyDerivedPath(baseDownloadFolder, categorySubfolders[category]);
|
|
if (normalizedForComparison(legacyDirectory) !== expected) {
|
|
categoryDirectoryOverrides[category] = legacyDirectory;
|
|
}
|
|
}
|
|
|
|
return {
|
|
baseDownloadFolder,
|
|
categorySubfoldersEnabled,
|
|
categorySubfolders,
|
|
categoryDirectoryOverrides
|
|
};
|
|
};
|
|
|
|
export const resolveCategoryDestination = async (
|
|
settings: DownloadLocationSettings,
|
|
category: DownloadCategory
|
|
): Promise<string> => {
|
|
const base = settings.baseDownloadFolder.trim() || '~/Downloads';
|
|
const expandedBase = await expandTilde(base);
|
|
if (!settings.categorySubfoldersEnabled) return expandedBase;
|
|
|
|
const override = settings.categoryDirectoryOverrides[category]?.trim();
|
|
if (override) return expandTilde(override);
|
|
|
|
const persistedValue = hasOwn(settings.categorySubfolders, category)
|
|
? settings.categorySubfolders[category]
|
|
: DEFAULT_CATEGORY_SUBFOLDERS[category];
|
|
const subfolder = normalizeCategorySubfolder(
|
|
persistedValue,
|
|
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
|
);
|
|
if (!subfolder) return expandedBase;
|
|
return join(expandedBase, subfolder);
|
|
};
|
|
|
|
export const resolveDownloadFilePath = async (
|
|
destination: string,
|
|
fileName: string
|
|
): Promise<string> => {
|
|
const expandedDest = await expandTilde(destination);
|
|
return join(expandedDest, fileName);
|
|
};
|
|
|
|
export const downloadLocationEquals = (
|
|
leftDirectory: string,
|
|
leftFileName: string,
|
|
rightDirectory: string,
|
|
rightFileName: string,
|
|
os: string
|
|
): boolean => {
|
|
const normalize = (value: string) => {
|
|
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
return os === 'windows'
|
|
? normalized.toLocaleLowerCase()
|
|
: normalized;
|
|
};
|
|
return normalize(`${leftDirectory}/${leftFileName}`)
|
|
=== normalize(`${rightDirectory}/${rightFileName}`);
|
|
};
|