PLWL/src/fsutil.js
2026-10-07 08:26:57 +02:00

313 lines
8.2 KiB
JavaScript

'use strict'
const fs = require('fs')
const fsp = require('fs').promises
const path = require('path')
// accessSync instead of existsSync: same answer, one syscall fewer.
function pathExists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
function dedupeDir(base, name) {
const candidate = path.join(base, name)
if (!fs.existsSync(candidate)) return candidate
let n = 2
while (fs.existsSync(path.join(base, `${name} (${n})`))) n++
return path.join(base, `${name} (${n})`)
}
// Events are rate limited to ~10/s; `force` pushes the final value through.
const PROGRESS_MIN_MS = 100
function progressGate(emit) {
let last = 0
let pending = null
let timer = null
const flush = () => {
timer = null
if (!pending) return
last = Date.now()
const data = pending
pending = null
emit(data)
}
const push = (data, force) => {
pending = data
const wait = PROGRESS_MIN_MS - (Date.now() - last)
if (force || wait <= 0) {
if (timer) clearTimeout(timer)
flush()
} else if (!timer) {
timer = setTimeout(flush, wait)
if (timer.unref) timer.unref()
}
}
push.done = () => {
if (timer) clearTimeout(timer)
flush()
}
return push
}
// Run `fn` over `items` with at most `limit` in flight, results in order.
async function mapLimit(items, limit, fn) {
const out = new Array(items.length)
let next = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i], i)
}
})
await Promise.all(workers)
return out
}
// What a tree copy will move. Symlinks are recreated, not read. `skipDir`
// leaves out one directory name (redistributables).
async function treeSize(root) {
let files = 0
let bytes = 0
// a symlinked root is the whole tree; never measure through it
let rootStat = null
try {
rootStat = await fsp.lstat(root)
} catch {
return { files, bytes }
}
if (!rootStat.isDirectory()) return { files: 1, bytes: rootStat.isSymbolicLink() ? 0 : rootStat.size }
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) {
files++
} else if (entry.isDirectory()) {
await walk(full)
} else if (entry.isFile()) {
files++
try {
bytes += (await fsp.stat(full)).size
} catch {}
}
}
}
await walk(root)
return { files, bytes }
}
// Copy a tree with symlinks recreated and permissions carried (copyFile
// drops both); reports cumulative { done, bytes } through onProgress.
async function copyTree(src, dst, onProgress) {
let done = 0
let bytes = 0
const gate = onProgress ? progressGate(onProgress) : null
const tick = () => {
if (gate) gate({ done, bytes }, false)
}
const walk = async (from, to) => {
let entries
try {
entries = await fsp.readdir(from, { withFileTypes: true })
} catch {
return
}
await fsp.mkdir(to, { recursive: true })
for (const entry of entries) {
const s = path.join(from, entry.name)
const d = path.join(to, entry.name)
if (entry.isSymbolicLink()) {
let target = null
try {
target = await fsp.readlink(s)
} catch {}
if (!target) continue
try {
await fsp.symlink(target, d)
} catch {}
done++
tick()
} else if (entry.isDirectory()) {
await walk(s, d)
} else if (entry.isFile()) {
let size = 0
try {
size = (await fsp.stat(s)).size
} catch {}
try {
await fsp.copyFile(s, d)
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
} catch {}
done++
bytes += size
tick()
}
}
}
await walk(src, dst)
if (gate) gate({ done, bytes }, true)
return { done, bytes }
}
// Imported trees come from media that lose permissions (archives, FAT, a
// read-only backup drive), and copyFile keeps whatever the source had: every
// node gets owner rwx and `x` mirrored from `r`, so the app can run its own
// binaries and write its own files.
async function chmodTree(root) {
const desired = mode => mode | 0o700 | ((mode & 0o444) >> 2)
let files = 0
let dirs = 0
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
await walk(full)
} else if (!entry.isFile()) {
continue
}
try {
const before = (await fsp.stat(full)).mode & 0o7777
const after = desired(before)
if (after !== before) {
await fsp.chmod(full, after)
if (entry.isDirectory()) dirs++
else files++
}
} catch {}
}
}
await walk(root)
return { files, dirs }
}
// One file, streamed, with byte progress: copyFile says nothing until a
// multi-gigabyte copy is over. The bytes land on a .part name and are renamed
// at the end, so nothing can ever read a half-written document.
async function copyFileProgress(src, dst, onProgress) {
const stat = await fsp.stat(src)
if (!stat.isFile()) throw new Error('not a file: ' + src)
const gate = onProgress ? progressGate(onProgress) : null
await fsp.mkdir(path.dirname(dst), { recursive: true })
const part = dst + '.part'
let bytes = 0
try {
await new Promise((resolve, reject) => {
const input = fs.createReadStream(src)
const output = fs.createWriteStream(part)
const fail = err => {
input.destroy()
output.destroy()
reject(err)
}
input.on('error', fail)
output.on('error', fail)
input.on('data', chunk => {
bytes += chunk.length
if (gate) gate({ done: 0, bytes }, false)
})
output.on('finish', resolve)
input.pipe(output)
})
await fsp.chmod(part, stat.mode & 0o777)
await fsp.rename(part, dst)
} catch (err) {
try {
await fsp.rm(part, { force: true })
} catch {}
throw err
}
if (gate) {
gate({ done: 1, bytes: stat.size }, true)
gate.done()
}
return { files: 1, bytes: stat.size }
}
// Delete deepest-first with progress through onProgress; symlinks are
// unlinked, never followed.
async function removeTree(root, onProgress) {
let files = 0
let dirs = 0
let bytes = 0
const gate = onProgress ? progressGate(onProgress) : null
const tick = () => {
if (gate) gate({ files, dirs, bytes }, false)
}
// a symlinked root is removed as itself, never walked through
let rootStat = null
try {
rootStat = await fsp.lstat(root)
} catch {
return { files, dirs, bytes }
}
if (!rootStat.isDirectory()) {
files = 1
bytes = rootStat.isSymbolicLink() ? 0 : rootStat.size
try {
await fsp.unlink(root)
} catch {}
if (gate) gate({ files, dirs, bytes }, true)
return { files, dirs, bytes }
}
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return false
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(full)
try {
await fsp.rmdir(full)
} catch {}
dirs++
tick()
continue
}
let size = 0
try {
const st = await fsp.lstat(full)
size = st.isFile() ? st.size : 0
} catch {}
try {
await fsp.unlink(full)
} catch {}
files++
bytes += size
tick()
}
try {
await fsp.rmdir(dir)
dirs++
} catch {}
return true
}
await walk(root)
if (gate) gate({ files, dirs, bytes }, true)
return { files, dirs, bytes }
}
module.exports = { pathExists, dedupeDir, copyTree, copyFileProgress, treeSize, removeTree, chmodTree, progressGate, mapLimit }