✨feature: Add folder creation for recursive transfers and transfer progress indicator
This commit is contained in:
@@ -44,6 +44,11 @@
|
|||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input id="use-wget" type="checkbox" /> Use wget if available
|
<input id="use-wget" type="checkbox" /> Use wget if available
|
||||||
</label>
|
</label>
|
||||||
|
<div class="progress" id="transfer-progress" aria-hidden="true">
|
||||||
|
<div class="progress-bar" id="transfer-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-meta" id="transfer-meta"></div>
|
||||||
|
<div class="progress-meta secondary" id="transfer-meta-secondary"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
|||||||
157
src/main.js
157
src/main.js
@@ -43,6 +43,12 @@ function logToRenderer(message) {
|
|||||||
windows[0].webContents.send('log', 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) {
|
function listLocalDir(dirPath) {
|
||||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
return entries.map((entry) => {
|
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) {
|
function parseApacheIndex(html, baseUrl) {
|
||||||
const results = [];
|
const results = [];
|
||||||
const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i);
|
const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i);
|
||||||
@@ -113,20 +139,43 @@ function parseApacheIndex(html, baseUrl) {
|
|||||||
name = anchorText;
|
name = anchorText;
|
||||||
}
|
}
|
||||||
let size = '-';
|
let size = '-';
|
||||||
|
let sizeBytes = null;
|
||||||
const rest = line.slice(match.index + match[0].length).trim();
|
const rest = line.slice(match.index + match[0].length).trim();
|
||||||
if (rest) {
|
if (rest) {
|
||||||
const tokens = rest.split(/\s+/);
|
const tokens = rest.split(/\s+/);
|
||||||
const sizeToken = tokens[tokens.length - 1];
|
const sizeToken = tokens[tokens.length - 1];
|
||||||
if (sizeToken && sizeToken !== '-') {
|
if (sizeToken && sizeToken !== '-') {
|
||||||
size = sizeToken;
|
size = sizeToken;
|
||||||
|
sizeBytes = parseSizeToken(sizeToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.push({ name, href: fullUrl, isDir, size });
|
results.push({ name, href: fullUrl, isDir, size, sizeBytes });
|
||||||
}
|
}
|
||||||
return results;
|
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;
|
const client = url.startsWith('https') ? https : http;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const fileStream = fs.createWriteStream(destPath);
|
const fileStream = fs.createWriteStream(destPath);
|
||||||
@@ -136,7 +185,23 @@ function downloadFile(url, destPath) {
|
|||||||
res.resume();
|
res.resume();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const totalBytes = Number(res.headers['content-length']) || null;
|
||||||
|
let downloaded = 0;
|
||||||
|
let lastEmit = Date.now();
|
||||||
|
onProgress?.({ phase: 'start', totalBytes });
|
||||||
res.pipe(fileStream);
|
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.on('finish', () => {
|
||||||
fileStream.close(resolve);
|
fileStream.close(resolve);
|
||||||
});
|
});
|
||||||
@@ -147,19 +212,60 @@ function downloadFile(url, destPath) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFolderRecursive(baseUrl, destDir) {
|
function getRemoteBasename(url) {
|
||||||
const html = await fetchUrl(baseUrl);
|
try {
|
||||||
const items = parseApacheIndex(html, baseUrl);
|
const urlObj = new URL(url);
|
||||||
for (const item of items) {
|
let pathname = decodeURIComponent(urlObj.pathname);
|
||||||
const targetPath = path.join(destDir, item.name.replace(/\/$/, ''));
|
if (pathname.endsWith('/')) {
|
||||||
if (item.isDir) {
|
pathname = pathname.slice(0, -1);
|
||||||
fs.mkdirSync(targetPath, { recursive: true });
|
|
||||||
logToRenderer(`Entering ${item.href}`);
|
|
||||||
await downloadFolderRecursive(item.href, targetPath);
|
|
||||||
} else {
|
|
||||||
logToRenderer(`Downloading ${item.href}`);
|
|
||||||
await downloadFile(item.href, targetPath);
|
|
||||||
}
|
}
|
||||||
|
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 });
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
}
|
}
|
||||||
if (isDir) {
|
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) {
|
if (useWget && state.hasWget) {
|
||||||
logToRenderer(`wget: ${url}`);
|
logToRenderer(`wget: ${url}`);
|
||||||
await downloadWithWget(url, destDir);
|
progressToRenderer({ phase: 'start', mode: 'wget', fileName: folderName });
|
||||||
|
await downloadWithWget(url, targetDir);
|
||||||
|
progressToRenderer({ phase: 'done' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await downloadFolderRecursive(url, destDir);
|
await downloadFolderWithPlan(url, targetDir);
|
||||||
|
progressToRenderer({ phase: 'done' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fileName = path.basename(new URL(url).pathname);
|
const fileName = path.basename(new URL(url).pathname);
|
||||||
const destPath = path.join(destDir, fileName);
|
const destPath = path.join(destDir, fileName);
|
||||||
logToRenderer(`Downloading ${url}`);
|
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' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ contextBridge.exposeInMainWorld('oddl', {
|
|||||||
listRemoteDir: (url) => ipcRenderer.invoke('list-remote-dir', url),
|
listRemoteDir: (url) => ipcRenderer.invoke('list-remote-dir', url),
|
||||||
downloadItem: (payload) => ipcRenderer.invoke('download-item', payload),
|
downloadItem: (payload) => ipcRenderer.invoke('download-item', payload),
|
||||||
onLog: (callback) => ipcRenderer.on('log', (_event, message) => callback(message)),
|
onLog: (callback) => ipcRenderer.on('log', (_event, message) => callback(message)),
|
||||||
|
onProgress: (callback) => ipcRenderer.on('progress', (_event, payload) => callback(payload)),
|
||||||
});
|
});
|
||||||
410
src/renderer.js
410
src/renderer.js
@@ -4,6 +4,12 @@ const state = {
|
|||||||
localEntries: [],
|
localEntries: [],
|
||||||
remoteEntries: [],
|
remoteEntries: [],
|
||||||
hasWget: false,
|
hasWget: false,
|
||||||
|
remoteTree: {
|
||||||
|
rootUrl: '',
|
||||||
|
currentUrl: '',
|
||||||
|
nodes: new Map(),
|
||||||
|
},
|
||||||
|
transfer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
@@ -18,6 +24,10 @@ const el = {
|
|||||||
localListBody: document.querySelector('#local-list-body'),
|
localListBody: document.querySelector('#local-list-body'),
|
||||||
downloadBtn: document.querySelector('#download-btn'),
|
downloadBtn: document.querySelector('#download-btn'),
|
||||||
useWget: document.querySelector('#use-wget'),
|
useWget: document.querySelector('#use-wget'),
|
||||||
|
progress: document.querySelector('#transfer-progress'),
|
||||||
|
progressBar: document.querySelector('#transfer-bar'),
|
||||||
|
progressMeta: document.querySelector('#transfer-meta'),
|
||||||
|
progressMetaSecondary: document.querySelector('#transfer-meta-secondary'),
|
||||||
logList: document.querySelector('#log-list'),
|
logList: document.querySelector('#log-list'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +44,65 @@ function formatBytes(size) {
|
|||||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds || !Number.isFinite(seconds)) return '';
|
||||||
|
const rounded = Math.max(0, Math.round(seconds));
|
||||||
|
const mins = Math.floor(rounded / 60);
|
||||||
|
const secs = rounded % 60;
|
||||||
|
if (mins > 0) return `${mins}m ${secs}s`;
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProgress({ percent, label, indeterminate }) {
|
||||||
|
el.progress.classList.add('active');
|
||||||
|
el.progress.classList.toggle('indeterminate', Boolean(indeterminate));
|
||||||
|
el.progressBar.style.width = indeterminate ? '40%' : `${percent}%`;
|
||||||
|
el.progressMeta.textContent = label || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSecondaryProgress(text) {
|
||||||
|
el.progressMetaSecondary.textContent = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideProgress() {
|
||||||
|
el.progress.classList.remove('active', 'indeterminate');
|
||||||
|
el.progressBar.style.width = '0%';
|
||||||
|
el.progressMeta.textContent = '';
|
||||||
|
el.progressMetaSecondary.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSizeToBytes(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 normalizeDirUrl(url) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
if (!urlObj.pathname.endsWith('/')) {
|
||||||
|
urlObj.pathname += '/';
|
||||||
|
}
|
||||||
|
return urlObj.toString();
|
||||||
|
} catch (err) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
const line = document.createElement('div');
|
const line = document.createElement('div');
|
||||||
line.textContent = message;
|
line.textContent = message;
|
||||||
@@ -69,10 +138,13 @@ function renderRemote() {
|
|||||||
row.className = 'row list-row';
|
row.className = 'row list-row';
|
||||||
const name = document.createElement('div');
|
const name = document.createElement('div');
|
||||||
name.className = 'name';
|
name.className = 'name';
|
||||||
name.textContent = entry.isDir ? entry.name : entry.name;
|
name.textContent = entry.name;
|
||||||
const size = document.createElement('div');
|
const size = document.createElement('div');
|
||||||
size.className = 'size';
|
size.className = 'size';
|
||||||
size.textContent = entry.isDir ? '-' : entry.size || '-';
|
const sizeBytes = entry.sizeBytes !== undefined && entry.sizeBytes !== null
|
||||||
|
? entry.sizeBytes
|
||||||
|
: parseSizeToBytes(entry.size);
|
||||||
|
size.textContent = entry.isDir ? '-' : formatBytes(sizeBytes);
|
||||||
row.appendChild(name);
|
row.appendChild(name);
|
||||||
row.appendChild(size);
|
row.appendChild(size);
|
||||||
row.addEventListener('dblclick', async () => {
|
row.addEventListener('dblclick', async () => {
|
||||||
@@ -91,49 +163,146 @@ function renderRemote() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRemoteTree() {
|
function ensureRemoteNode(url, name) {
|
||||||
el.remoteTree.innerHTML = '';
|
const normalized = normalizeDirUrl(url);
|
||||||
if (!state.remoteUrl) return;
|
let node = state.remoteTree.nodes.get(normalized);
|
||||||
|
if (!node) {
|
||||||
|
node = {
|
||||||
|
url: normalized,
|
||||||
|
name: name || normalized,
|
||||||
|
parentUrl: null,
|
||||||
|
children: [],
|
||||||
|
expanded: false,
|
||||||
|
loaded: false,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
state.remoteTree.nodes.set(normalized, node);
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
node.name = name;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkChild(parent, child) {
|
||||||
|
if (!parent.children.includes(child.url)) {
|
||||||
|
parent.children.push(child.url);
|
||||||
|
}
|
||||||
|
child.parentUrl = parent.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTreeWithCurrent(url, entries) {
|
||||||
|
if (!url) return;
|
||||||
let urlObj;
|
let urlObj;
|
||||||
try {
|
try {
|
||||||
urlObj = new URL(state.remoteUrl);
|
urlObj = new URL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rootUrl = `${urlObj.origin}/`;
|
||||||
|
state.remoteTree.rootUrl = rootUrl;
|
||||||
|
const root = ensureRemoteNode(rootUrl, `${urlObj.origin}/`);
|
||||||
const segments = urlObj.pathname.split('/').filter(Boolean);
|
const segments = urlObj.pathname.split('/').filter(Boolean);
|
||||||
const nodes = [];
|
let parent = root;
|
||||||
nodes.push({ name: `${urlObj.origin}/`, url: `${urlObj.origin}/`, depth: 0, current: segments.length === 0 });
|
|
||||||
let currentPath = '/';
|
let currentPath = '/';
|
||||||
segments.forEach((seg, index) => {
|
root.expanded = true;
|
||||||
|
segments.forEach((seg) => {
|
||||||
currentPath += `${seg}/`;
|
currentPath += `${seg}/`;
|
||||||
nodes.push({
|
const nodeUrl = `${urlObj.origin}${currentPath}`;
|
||||||
name: `${seg}/`,
|
const node = ensureRemoteNode(nodeUrl, `${seg}/`);
|
||||||
url: `${urlObj.origin}${currentPath}`,
|
linkChild(parent, node);
|
||||||
depth: index + 1,
|
parent.expanded = true;
|
||||||
current: index === segments.length - 1,
|
parent = node;
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const childDirs = state.remoteEntries.filter((entry) => entry.isDir);
|
parent.expanded = true;
|
||||||
|
state.remoteTree.currentUrl = parent.url;
|
||||||
|
|
||||||
|
const childDirs = entries.filter((entry) => entry.isDir);
|
||||||
|
parent.children = [];
|
||||||
childDirs.forEach((entry) => {
|
childDirs.forEach((entry) => {
|
||||||
nodes.push({
|
const child = ensureRemoteNode(entry.href, entry.name);
|
||||||
name: entry.name,
|
linkChild(parent, child);
|
||||||
url: entry.href,
|
|
||||||
depth: segments.length + 1,
|
|
||||||
current: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
nodes.forEach((node) => {
|
parent.loaded = true;
|
||||||
const row = document.createElement('div');
|
parent.loading = false;
|
||||||
row.className = `tree-row${node.current ? ' current' : ''}`;
|
}
|
||||||
row.textContent = node.name;
|
|
||||||
row.style.paddingLeft = `${node.depth * 16}px`;
|
async function loadNodeChildren(node) {
|
||||||
row.addEventListener('click', async () => {
|
if (node.loading) return;
|
||||||
state.remoteUrl = node.url;
|
node.loading = true;
|
||||||
el.remoteInput.value = state.remoteUrl;
|
renderRemoteTree();
|
||||||
await refreshRemote();
|
let entries = [];
|
||||||
});
|
try {
|
||||||
el.remoteTree.appendChild(row);
|
entries = await window.oddl.listRemoteDir(node.url);
|
||||||
|
} catch (err) {
|
||||||
|
log(err.message);
|
||||||
|
node.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.children = [];
|
||||||
|
entries.filter((entry) => entry.isDir).forEach((entry) => {
|
||||||
|
const child = ensureRemoteNode(entry.href, entry.name);
|
||||||
|
linkChild(node, child);
|
||||||
});
|
});
|
||||||
|
node.loaded = true;
|
||||||
|
node.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTreeNode(node, depth, container) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const current = node.url === state.remoteTree.currentUrl;
|
||||||
|
row.className = `tree-node${current ? ' current' : ''}`;
|
||||||
|
row.style.paddingLeft = `${depth * 16}px`;
|
||||||
|
|
||||||
|
const toggle = document.createElement('span');
|
||||||
|
toggle.className = 'tree-toggle';
|
||||||
|
const hasChildren = node.children.length > 0 || !node.loaded;
|
||||||
|
toggle.textContent = hasChildren ? (node.expanded ? 'v' : '>') : '';
|
||||||
|
toggle.addEventListener('click', async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!hasChildren && node.loaded) return;
|
||||||
|
node.expanded = !node.expanded;
|
||||||
|
if (node.expanded && !node.loaded) {
|
||||||
|
await loadNodeChildren(node);
|
||||||
|
}
|
||||||
|
renderRemoteTree();
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'tree-label';
|
||||||
|
label.textContent = node.name;
|
||||||
|
label.addEventListener('click', async () => {
|
||||||
|
state.remoteUrl = node.url;
|
||||||
|
el.remoteInput.value = state.remoteUrl;
|
||||||
|
await refreshRemote();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(label);
|
||||||
|
container.appendChild(row);
|
||||||
|
|
||||||
|
if (node.loading) {
|
||||||
|
const loadingRow = document.createElement('div');
|
||||||
|
loadingRow.className = 'tree-loading';
|
||||||
|
loadingRow.textContent = 'loading...';
|
||||||
|
loadingRow.style.paddingLeft = `${(depth + 1) * 16}px`;
|
||||||
|
container.appendChild(loadingRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.expanded) {
|
||||||
|
const children = node.children
|
||||||
|
.map((url) => state.remoteTree.nodes.get(url))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
children.forEach((child) => renderTreeNode(child, depth + 1, container));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRemoteTree() {
|
||||||
|
el.remoteTree.innerHTML = '';
|
||||||
|
const root = state.remoteTree.nodes.get(state.remoteTree.rootUrl);
|
||||||
|
if (!root) return;
|
||||||
|
renderTreeNode(root, 0, el.remoteTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshLocal() {
|
async function refreshLocal() {
|
||||||
@@ -146,9 +315,11 @@ async function refreshLocal() {
|
|||||||
|
|
||||||
async function refreshRemote() {
|
async function refreshRemote() {
|
||||||
if (!state.remoteUrl) return;
|
if (!state.remoteUrl) return;
|
||||||
|
state.remoteUrl = normalizeDirUrl(state.remoteUrl);
|
||||||
const entries = await window.oddl.listRemoteDir(state.remoteUrl);
|
const entries = await window.oddl.listRemoteDir(state.remoteUrl);
|
||||||
state.remoteEntries = entries;
|
state.remoteEntries = entries;
|
||||||
renderRemote();
|
renderRemote();
|
||||||
|
syncTreeWithCurrent(state.remoteUrl, entries);
|
||||||
renderRemoteTree();
|
renderRemoteTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +353,24 @@ async function init() {
|
|||||||
el.useWget.checked = false;
|
el.useWget.checked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const home = await window.oddl.selectLocalDir();
|
const savedDir = localStorage.getItem('oddl.localDir');
|
||||||
if (home) {
|
if (savedDir) {
|
||||||
state.localDir = home;
|
state.localDir = savedDir;
|
||||||
await refreshLocal();
|
try {
|
||||||
|
await refreshLocal();
|
||||||
|
} catch (err) {
|
||||||
|
log('Saved local folder missing, please select a new folder.');
|
||||||
|
state.localDir = '';
|
||||||
|
localStorage.removeItem('oddl.localDir');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!state.localDir) {
|
||||||
|
const home = await window.oddl.selectLocalDir();
|
||||||
|
if (home) {
|
||||||
|
state.localDir = home;
|
||||||
|
localStorage.setItem('oddl.localDir', home);
|
||||||
|
await refreshLocal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.col-resizer').forEach((resizer) => {
|
document.querySelectorAll('.col-resizer').forEach((resizer) => {
|
||||||
@@ -201,6 +386,7 @@ el.localBrowse.addEventListener('click', async () => {
|
|||||||
const dir = await window.oddl.selectLocalDir();
|
const dir = await window.oddl.selectLocalDir();
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
state.localDir = dir;
|
state.localDir = dir;
|
||||||
|
localStorage.setItem('oddl.localDir', dir);
|
||||||
await refreshLocal();
|
await refreshLocal();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,5 +421,155 @@ el.downloadBtn.addEventListener('click', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.oddl.onLog((message) => log(message));
|
window.oddl.onLog((message) => log(message));
|
||||||
|
window.oddl.onProgress((payload) => {
|
||||||
|
if (payload.phase === 'scan-start') {
|
||||||
|
state.progressMode = 'aggregate';
|
||||||
|
showProgress({ percent: 100, label: 'Scanning remote folders...', indeterminate: true });
|
||||||
|
setSecondaryProgress('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'scan-done') {
|
||||||
|
const count = payload.fileCount || 0;
|
||||||
|
showProgress({ percent: 0, label: `Found ${count} files`, indeterminate: false });
|
||||||
|
setSecondaryProgress('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'aggregate-start') {
|
||||||
|
state.progressMode = 'aggregate';
|
||||||
|
if (payload.totalBytes) {
|
||||||
|
showProgress({
|
||||||
|
percent: 0,
|
||||||
|
label: `0 / ${formatBytes(payload.totalBytes)}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showProgress({ percent: 100, label: 'Downloading...', indeterminate: true });
|
||||||
|
}
|
||||||
|
setSecondaryProgress('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'aggregate-progress') {
|
||||||
|
const downloaded = payload.downloadedBytes || 0;
|
||||||
|
const total = payload.totalBytes || 0;
|
||||||
|
if (total > 0) {
|
||||||
|
const percent = Math.min(100, Math.round((downloaded / total) * 100));
|
||||||
|
const label = `${formatBytes(downloaded)} / ${formatBytes(total)}`;
|
||||||
|
showProgress({ percent, label });
|
||||||
|
} else {
|
||||||
|
showProgress({ percent: 100, label: `${formatBytes(downloaded)} downloaded`, indeterminate: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'start') {
|
||||||
|
state.progressMode = 'single';
|
||||||
|
if (payload.mode === 'wget') {
|
||||||
|
showProgress({
|
||||||
|
percent: 100,
|
||||||
|
label: `wget running ${payload.fileName ? `(${payload.fileName})` : ''}`,
|
||||||
|
indeterminate: true,
|
||||||
|
});
|
||||||
|
setSecondaryProgress('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.transfer = {
|
||||||
|
fileName: payload.fileName || '',
|
||||||
|
startTime: Date.now(),
|
||||||
|
lastBytes: 0,
|
||||||
|
lastTime: Date.now(),
|
||||||
|
};
|
||||||
|
showProgress({ percent: 0, label: `Starting ${payload.fileName || 'download'}` });
|
||||||
|
setSecondaryProgress('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'progress') {
|
||||||
|
const downloaded = payload.downloadedBytes || 0;
|
||||||
|
const total = payload.totalBytes || 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const transfer = state.transfer || { lastBytes: 0, lastTime: now };
|
||||||
|
const deltaBytes = Math.max(0, downloaded - transfer.lastBytes);
|
||||||
|
const deltaTime = Math.max(0.5, (now - transfer.lastTime) / 1000);
|
||||||
|
const speed = deltaBytes / deltaTime;
|
||||||
|
transfer.lastBytes = downloaded;
|
||||||
|
transfer.lastTime = now;
|
||||||
|
state.transfer = transfer;
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
const percent = Math.min(100, Math.round((downloaded / total) * 100));
|
||||||
|
const remaining = Math.max(0, total - downloaded);
|
||||||
|
const eta = speed > 0 ? remaining / speed : null;
|
||||||
|
const label = `${formatBytes(downloaded)} / ${formatBytes(total)}${eta ? ` • ETA ${formatDuration(eta)}` : ''}`;
|
||||||
|
showProgress({ percent, label });
|
||||||
|
} else {
|
||||||
|
showProgress({
|
||||||
|
percent: 100,
|
||||||
|
label: `${formatBytes(downloaded)} downloaded`,
|
||||||
|
indeterminate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'file-start') {
|
||||||
|
state.transfer = {
|
||||||
|
fileName: payload.fileName || '',
|
||||||
|
startTime: Date.now(),
|
||||||
|
lastBytes: 0,
|
||||||
|
lastTime: Date.now(),
|
||||||
|
};
|
||||||
|
if (state.progressMode === 'aggregate') {
|
||||||
|
setSecondaryProgress(`Downloading ${payload.fileName || 'file'}`);
|
||||||
|
} else {
|
||||||
|
showProgress({ percent: 0, label: `Downloading ${payload.fileName || 'file'}` });
|
||||||
|
setSecondaryProgress('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'file-progress') {
|
||||||
|
const downloaded = payload.downloadedBytes || 0;
|
||||||
|
const total = payload.totalBytes || 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const transfer = state.transfer || { lastBytes: 0, lastTime: now };
|
||||||
|
const deltaBytes = Math.max(0, downloaded - transfer.lastBytes);
|
||||||
|
const deltaTime = Math.max(0.5, (now - transfer.lastTime) / 1000);
|
||||||
|
const speed = deltaBytes / deltaTime;
|
||||||
|
transfer.lastBytes = downloaded;
|
||||||
|
transfer.lastTime = now;
|
||||||
|
state.transfer = transfer;
|
||||||
|
if (total > 0) {
|
||||||
|
const percent = Math.min(100, Math.round((downloaded / total) * 100));
|
||||||
|
const remaining = Math.max(0, total - downloaded);
|
||||||
|
const eta = speed > 0 ? remaining / speed : null;
|
||||||
|
const label = `${payload.fileName || 'file'} • ${formatBytes(downloaded)} / ${formatBytes(total)}${eta ? ` • ETA ${formatDuration(eta)}` : ''}`;
|
||||||
|
if (state.progressMode === 'aggregate') {
|
||||||
|
setSecondaryProgress(label);
|
||||||
|
} else {
|
||||||
|
showProgress({ percent, label });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const label = `${payload.fileName || 'file'} • ${formatBytes(downloaded)} downloaded`;
|
||||||
|
if (state.progressMode === 'aggregate') {
|
||||||
|
setSecondaryProgress(label);
|
||||||
|
} else {
|
||||||
|
showProgress({
|
||||||
|
percent: 100,
|
||||||
|
label,
|
||||||
|
indeterminate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'file-done') {
|
||||||
|
if (state.progressMode === 'aggregate') {
|
||||||
|
setSecondaryProgress(`Finished ${payload.fileName || 'file'}`);
|
||||||
|
} else {
|
||||||
|
showProgress({ percent: 100, label: `Finished ${payload.fileName || 'file'}` });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.phase === 'done') {
|
||||||
|
state.transfer = null;
|
||||||
|
state.progressMode = null;
|
||||||
|
hideProgress();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
init().catch((err) => log(err.message));
|
init().catch((err) => log(err.message));
|
||||||
|
|||||||
@@ -123,25 +123,48 @@ body {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-row {
|
.tree-node {
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-row:hover {
|
.tree-node:hover {
|
||||||
background: #eef2f6;
|
background: #eef2f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-row.current {
|
.tree-node.current {
|
||||||
background: #dbe7ff;
|
background: #dbe7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
color: #5a6475;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-loading {
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6a7282;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.path-row {
|
.path-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-row input {
|
.path-row input {
|
||||||
@@ -178,13 +201,14 @@ body {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#log-list {
|
#log-list {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-family: Consolas, monospace;
|
font-family: Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
height: 300px;
|
height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +219,48 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e6e9ef;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, #5c87ff, #7aa2ff);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress.indeterminate .progress-bar {
|
||||||
|
width: 40%;
|
||||||
|
position: absolute;
|
||||||
|
animation: progress-slide 1s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4f5765;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta.secondary {
|
||||||
|
color: #6a7282;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-slide {
|
||||||
|
0% { left: -40%; }
|
||||||
|
100% { left: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user