✨feature: Add folder creation for recursive transfers and transfer progress indicator
This commit is contained in:
157
src/main.js
157
src/main.js
@@ -43,6 +43,12 @@ function logToRenderer(message) {
|
||||
windows[0].webContents.send('log', message);
|
||||
}
|
||||
|
||||
function progressToRenderer(payload) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (!windows.length) return;
|
||||
windows[0].webContents.send('progress', payload);
|
||||
}
|
||||
|
||||
function listLocalDir(dirPath) {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
return entries.map((entry) => {
|
||||
@@ -84,6 +90,26 @@ function fetchUrl(url) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseSizeToken(token) {
|
||||
if (!token || token === '-') return null;
|
||||
const match = token.trim().match(/^([\d.]+)\s*([KMGTP]?)(?:B)?$/i);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1]);
|
||||
if (Number.isNaN(value)) return null;
|
||||
const unit = match[2] ? match[2].toUpperCase() : '';
|
||||
const multipliers = {
|
||||
'': 1,
|
||||
K: 1024,
|
||||
M: 1024 ** 2,
|
||||
G: 1024 ** 3,
|
||||
T: 1024 ** 4,
|
||||
P: 1024 ** 5,
|
||||
};
|
||||
const multiplier = multipliers[unit];
|
||||
if (!multiplier) return null;
|
||||
return Math.round(value * multiplier);
|
||||
}
|
||||
|
||||
function parseApacheIndex(html, baseUrl) {
|
||||
const results = [];
|
||||
const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i);
|
||||
@@ -113,20 +139,43 @@ function parseApacheIndex(html, baseUrl) {
|
||||
name = anchorText;
|
||||
}
|
||||
let size = '-';
|
||||
let sizeBytes = null;
|
||||
const rest = line.slice(match.index + match[0].length).trim();
|
||||
if (rest) {
|
||||
const tokens = rest.split(/\s+/);
|
||||
const sizeToken = tokens[tokens.length - 1];
|
||||
if (sizeToken && sizeToken !== '-') {
|
||||
size = sizeToken;
|
||||
sizeBytes = parseSizeToken(sizeToken);
|
||||
}
|
||||
}
|
||||
results.push({ name, href: fullUrl, isDir, size });
|
||||
results.push({ name, href: fullUrl, isDir, size, sizeBytes });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function downloadFile(url, destPath) {
|
||||
async function collectRemoteFiles(baseUrl, relDir) {
|
||||
const html = await fetchUrl(baseUrl);
|
||||
const items = parseApacheIndex(html, baseUrl);
|
||||
const files = [];
|
||||
for (const item of items) {
|
||||
const cleanName = item.name.replace(/\/$/, '');
|
||||
const nextRel = relDir ? path.join(relDir, cleanName) : cleanName;
|
||||
if (item.isDir) {
|
||||
const childFiles = await collectRemoteFiles(item.href, nextRel);
|
||||
files.push(...childFiles);
|
||||
} else {
|
||||
files.push({
|
||||
url: item.href,
|
||||
relPath: nextRel,
|
||||
sizeBytes: item.sizeBytes ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function downloadFile(url, destPath, onProgress) {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileStream = fs.createWriteStream(destPath);
|
||||
@@ -136,7 +185,23 @@ function downloadFile(url, destPath) {
|
||||
res.resume();
|
||||
return;
|
||||
}
|
||||
const totalBytes = Number(res.headers['content-length']) || null;
|
||||
let downloaded = 0;
|
||||
let lastEmit = Date.now();
|
||||
onProgress?.({ phase: 'start', totalBytes });
|
||||
res.pipe(fileStream);
|
||||
res.on('data', (chunk) => {
|
||||
downloaded += chunk.length;
|
||||
const now = Date.now();
|
||||
if (onProgress && (now - lastEmit > 200 || downloaded === totalBytes)) {
|
||||
lastEmit = now;
|
||||
onProgress({
|
||||
phase: 'progress',
|
||||
downloadedBytes: downloaded,
|
||||
totalBytes,
|
||||
});
|
||||
}
|
||||
});
|
||||
fileStream.on('finish', () => {
|
||||
fileStream.close(resolve);
|
||||
});
|
||||
@@ -147,19 +212,60 @@ function downloadFile(url, destPath) {
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadFolderRecursive(baseUrl, destDir) {
|
||||
const html = await fetchUrl(baseUrl);
|
||||
const items = parseApacheIndex(html, baseUrl);
|
||||
for (const item of items) {
|
||||
const targetPath = path.join(destDir, item.name.replace(/\/$/, ''));
|
||||
if (item.isDir) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
logToRenderer(`Entering ${item.href}`);
|
||||
await downloadFolderRecursive(item.href, targetPath);
|
||||
} else {
|
||||
logToRenderer(`Downloading ${item.href}`);
|
||||
await downloadFile(item.href, targetPath);
|
||||
function getRemoteBasename(url) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
let pathname = decodeURIComponent(urlObj.pathname);
|
||||
if (pathname.endsWith('/')) {
|
||||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
const name = pathname.split('/').pop();
|
||||
return name || 'download';
|
||||
} catch (err) {
|
||||
return 'download';
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFolderWithPlan(baseUrl, destDir) {
|
||||
progressToRenderer({ phase: 'scan-start' });
|
||||
const files = await collectRemoteFiles(baseUrl, '');
|
||||
progressToRenderer({
|
||||
phase: 'scan-done',
|
||||
fileCount: files.length,
|
||||
});
|
||||
const totalBytes = files.every((file) => typeof file.sizeBytes === 'number')
|
||||
? files.reduce((sum, file) => sum + file.sizeBytes, 0)
|
||||
: null;
|
||||
progressToRenderer({
|
||||
phase: 'aggregate-start',
|
||||
totalBytes,
|
||||
fileCount: files.length,
|
||||
});
|
||||
|
||||
let completedBytes = 0;
|
||||
for (const file of files) {
|
||||
const targetPath = path.join(destDir, file.relPath);
|
||||
const targetDir = path.dirname(targetPath);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
logToRenderer(`Downloading ${file.url}`);
|
||||
progressToRenderer({ phase: 'file-start', fileName: file.relPath });
|
||||
await downloadFile(file.url, targetPath, (progress) => {
|
||||
const current = progress.downloadedBytes || 0;
|
||||
const aggregate = completedBytes + current;
|
||||
progressToRenderer({
|
||||
phase: 'file-progress',
|
||||
fileName: file.relPath,
|
||||
downloadedBytes: current,
|
||||
totalBytes: progress.totalBytes,
|
||||
});
|
||||
progressToRenderer({
|
||||
phase: 'aggregate-progress',
|
||||
downloadedBytes: aggregate,
|
||||
totalBytes,
|
||||
});
|
||||
});
|
||||
completedBytes += file.sizeBytes ?? 0;
|
||||
progressToRenderer({ phase: 'file-done', fileName: file.relPath });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,16 +322,33 @@ ipcMain.handle('download-item', async (_event, payload) => {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
if (isDir) {
|
||||
const folderName = getRemoteBasename(url);
|
||||
const targetDir = path.join(destDir, folderName);
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
if (useWget && state.hasWget) {
|
||||
logToRenderer(`wget: ${url}`);
|
||||
await downloadWithWget(url, destDir);
|
||||
progressToRenderer({ phase: 'start', mode: 'wget', fileName: folderName });
|
||||
await downloadWithWget(url, targetDir);
|
||||
progressToRenderer({ phase: 'done' });
|
||||
return;
|
||||
}
|
||||
await downloadFolderRecursive(url, destDir);
|
||||
await downloadFolderWithPlan(url, targetDir);
|
||||
progressToRenderer({ phase: 'done' });
|
||||
return;
|
||||
}
|
||||
const fileName = path.basename(new URL(url).pathname);
|
||||
const destPath = path.join(destDir, fileName);
|
||||
logToRenderer(`Downloading ${url}`);
|
||||
await downloadFile(url, destPath);
|
||||
progressToRenderer({ phase: 'start', fileName });
|
||||
await downloadFile(url, destPath, (progress) => {
|
||||
progressToRenderer({
|
||||
phase: 'progress',
|
||||
fileName,
|
||||
downloadedBytes: progress.downloadedBytes,
|
||||
totalBytes: progress.totalBytes,
|
||||
});
|
||||
});
|
||||
progressToRenderer({ phase: 'done' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user