feature: Add folder creation for recursive transfers and transfer progress indicator

This commit is contained in:
Keith Solomon
2026-01-03 19:06:28 -06:00
parent 318ef5f9b2
commit 6164906d7a
5 changed files with 590 additions and 59 deletions

View File

@@ -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' });
});