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

@@ -44,6 +44,11 @@
<label class="checkbox">
<input id="use-wget" type="checkbox" /> Use wget if available
</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 class="panel-body">

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

View File

@@ -7,4 +7,5 @@ contextBridge.exposeInMainWorld('oddl', {
listRemoteDir: (url) => ipcRenderer.invoke('list-remote-dir', url),
downloadItem: (payload) => ipcRenderer.invoke('download-item', payload),
onLog: (callback) => ipcRenderer.on('log', (_event, message) => callback(message)),
onProgress: (callback) => ipcRenderer.on('progress', (_event, payload) => callback(payload)),
});

View File

@@ -4,6 +4,12 @@ const state = {
localEntries: [],
remoteEntries: [],
hasWget: false,
remoteTree: {
rootUrl: '',
currentUrl: '',
nodes: new Map(),
},
transfer: null,
};
const el = {
@@ -18,6 +24,10 @@ const el = {
localListBody: document.querySelector('#local-list-body'),
downloadBtn: document.querySelector('#download-btn'),
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'),
};
@@ -34,6 +44,65 @@ function formatBytes(size) {
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) {
const line = document.createElement('div');
line.textContent = message;
@@ -69,10 +138,13 @@ function renderRemote() {
row.className = 'row list-row';
const name = document.createElement('div');
name.className = 'name';
name.textContent = entry.isDir ? entry.name : entry.name;
name.textContent = entry.name;
const size = document.createElement('div');
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(size);
row.addEventListener('dblclick', async () => {
@@ -91,49 +163,146 @@ function renderRemote() {
});
}
function renderRemoteTree() {
el.remoteTree.innerHTML = '';
if (!state.remoteUrl) return;
function ensureRemoteNode(url, name) {
const normalized = normalizeDirUrl(url);
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;
try {
urlObj = new URL(state.remoteUrl);
urlObj = new URL(url);
} catch (err) {
return;
}
const rootUrl = `${urlObj.origin}/`;
state.remoteTree.rootUrl = rootUrl;
const root = ensureRemoteNode(rootUrl, `${urlObj.origin}/`);
const segments = urlObj.pathname.split('/').filter(Boolean);
const nodes = [];
nodes.push({ name: `${urlObj.origin}/`, url: `${urlObj.origin}/`, depth: 0, current: segments.length === 0 });
let parent = root;
let currentPath = '/';
segments.forEach((seg, index) => {
root.expanded = true;
segments.forEach((seg) => {
currentPath += `${seg}/`;
nodes.push({
name: `${seg}/`,
url: `${urlObj.origin}${currentPath}`,
depth: index + 1,
current: index === segments.length - 1,
});
const nodeUrl = `${urlObj.origin}${currentPath}`;
const node = ensureRemoteNode(nodeUrl, `${seg}/`);
linkChild(parent, node);
parent.expanded = true;
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) => {
nodes.push({
name: entry.name,
url: entry.href,
depth: segments.length + 1,
current: false,
});
const child = ensureRemoteNode(entry.href, entry.name);
linkChild(parent, child);
});
nodes.forEach((node) => {
const row = document.createElement('div');
row.className = `tree-row${node.current ? ' current' : ''}`;
row.textContent = node.name;
row.style.paddingLeft = `${node.depth * 16}px`;
row.addEventListener('click', async () => {
state.remoteUrl = node.url;
el.remoteInput.value = state.remoteUrl;
await refreshRemote();
});
el.remoteTree.appendChild(row);
parent.loaded = true;
parent.loading = false;
}
async function loadNodeChildren(node) {
if (node.loading) return;
node.loading = true;
renderRemoteTree();
let entries = [];
try {
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() {
@@ -146,9 +315,11 @@ async function refreshLocal() {
async function refreshRemote() {
if (!state.remoteUrl) return;
state.remoteUrl = normalizeDirUrl(state.remoteUrl);
const entries = await window.oddl.listRemoteDir(state.remoteUrl);
state.remoteEntries = entries;
renderRemote();
syncTreeWithCurrent(state.remoteUrl, entries);
renderRemoteTree();
}
@@ -182,10 +353,24 @@ async function init() {
el.useWget.checked = false;
}
const home = await window.oddl.selectLocalDir();
if (home) {
state.localDir = home;
await refreshLocal();
const savedDir = localStorage.getItem('oddl.localDir');
if (savedDir) {
state.localDir = savedDir;
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) => {
@@ -201,6 +386,7 @@ el.localBrowse.addEventListener('click', async () => {
const dir = await window.oddl.selectLocalDir();
if (!dir) return;
state.localDir = dir;
localStorage.setItem('oddl.localDir', dir);
await refreshLocal();
});
@@ -235,5 +421,155 @@ el.downloadBtn.addEventListener('click', async () => {
});
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));

View File

@@ -123,25 +123,48 @@ body {
overflow: auto;
}
.tree-row {
.tree-node {
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.tree-row:hover {
.tree-node:hover {
background: #eef2f6;
}
.tree-row.current {
.tree-node.current {
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 {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.path-row input {
@@ -178,13 +201,14 @@ body {
border-radius: 6px;
background: #ffffff;
flex-shrink: 0;
padding: 8px 16px;
}
#log-list {
padding: 12px 16px;
font-family: Consolas, monospace;
font-size: 12px;
height: 300px;
height: 200px;
overflow-y: auto;
}
@@ -195,6 +219,48 @@ body {
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) {
.grid {
grid-template-columns: 1fr;