| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- import prettyBytes from 'pretty-bytes'
- import ProgressBar from 'progress'
- const PROGRESS_BAR_FORMAT = '[:bar]'
- const PROGRESS_BAR_WIDTH = 24
- function clamp(value, min, max) {
- return Math.min(Math.max(value, min), max)
- }
- export function formatPercent(value) {
- return `${clamp(value, 0, 100).toFixed(1)}%`
- }
- export function formatEta(seconds) {
- if (!Number.isFinite(seconds) || seconds < 0) {
- return '--:--'
- }
- const roundedSeconds = Math.ceil(seconds)
- const hours = Math.floor(roundedSeconds / 3600)
- const minutes = Math.floor((roundedSeconds % 3600) / 60)
- const secs = roundedSeconds % 60
- if (hours > 0) {
- return [ hours, minutes, secs ].map((value) => String(value).padStart(2, '0')).join(':')
- }
- return `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
- }
- export function truncateFileName(fileName, maxLength = 36) {
- if (fileName.length <= maxLength) {
- return fileName
- }
- if (maxLength <= 3) {
- return fileName.slice(0, maxLength)
- }
- const extensionIndex = fileName.lastIndexOf('.')
- const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ''
- const suffixLength = clamp(extension.length + 10, 8, maxLength - 3)
- const prefixLength = Math.max(maxLength - suffixLength - 3, 1)
- return `${fileName.slice(0, prefixLength)}...${fileName.slice(-suffixLength)}`
- }
- export function formatProgressMessage(snapshot) {
- return (
- `progress ${formatPercent(snapshot.totalPercent)} ` +
- `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +
- `current ${formatPercent(snapshot.currentFilePercent)} ` +
- `speed ${snapshot.speedLabel} ` +
- `eta ${snapshot.etaLabel} ` +
- `${snapshot.transferredLabel}/${snapshot.totalLabel} ` +
- `${snapshot.displayFileName}`
- )
- }
- export function fitFileNameToWidth(fileName, availableWidth, fallbackMaxLength = 36) {
- if (!Number.isFinite(availableWidth)) {
- return fileName
- }
- if (availableWidth <= 0) {
- return truncateFileName(fileName, Math.max(fallbackMaxLength, 8))
- }
- if (fileName.length <= availableWidth) {
- return fileName
- }
- return truncateFileName(fileName, Math.max(Math.floor(availableWidth), 8))
- }
- export function formatTtyProgressLines(snapshot, barText, columns) {
- const firstLine = `upload ${barText} ${formatPercent(snapshot.totalPercent)} ${snapshot.transferredLabel}/${snapshot.totalLabel}`
- const secondLinePrefix =
- `file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +
- `current ${formatPercent(snapshot.currentFilePercent)} ` +
- `speed ${snapshot.speedLabel} ` +
- `eta ${snapshot.etaLabel} `
- const displayFileName = fitFileNameToWidth(
- snapshot.currentFileName || snapshot.displayFileName,
- typeof columns === 'number' ? columns - secondLinePrefix.length : undefined,
- )
- return [
- firstLine,
- `${secondLinePrefix}${displayFileName}`,
- ]
- }
- function buildSnapshot(state, now) {
- const elapsedSeconds =
- state.startedAt === null ? 0 : Math.max((now() - state.startedAt) / 1000, 0)
- const speedBytesPerSecond =
- elapsedSeconds > 0 ? state.totalUploadedBytes / elapsedSeconds : 0
- const remainingBytes = Math.max(state.totalBytes - state.totalUploadedBytes, 0)
- const etaSeconds =
- remainingBytes === 0 ? 0 : speedBytesPerSecond > 0 ? remainingBytes / speedBytesPerSecond : null
- const totalPercent =
- state.totalBytes === 0 ? (state.finished ? 100 : 0) : (state.totalUploadedBytes / state.totalBytes) * 100
- const currentFilePercent =
- state.currentFileSize === 0
- ? state.currentFileComplete
- ? 100
- : 0
- : (state.currentFileBytes / state.currentFileSize) * 100
- return {
- currentFileBytes: state.currentFileBytes,
- currentFileIndex: state.currentFileIndex,
- currentFileName: state.currentFileName,
- currentFilePercent: clamp(currentFilePercent, 0, 100),
- currentFileSize: state.currentFileSize,
- displayFileName: state.currentFileName || '-',
- etaLabel: formatEta(etaSeconds),
- etaSeconds,
- speedBytesPerSecond,
- speedLabel: `${prettyBytes(speedBytesPerSecond)}/s`,
- totalBytes: state.totalBytes,
- totalFiles: state.totalFiles,
- totalPercent: clamp(totalPercent, 0, 100),
- totalUploadedBytes: state.totalUploadedBytes,
- totalLabel: prettyBytes(state.totalBytes),
- transferredLabel: prettyBytes(state.totalUploadedBytes),
- }
- }
- function createCaptureStream(columns = 120) {
- let buffer = ''
- return {
- clearBuffer() {
- buffer = ''
- },
- clearLine() {},
- columns,
- cursorTo() {
- buffer = ''
- },
- isTTY: true,
- moveCursor() {},
- write(chunk) {
- buffer += chunk
- return true
- },
- get value() {
- return buffer
- },
- }
- }
- export function createUploadProgressTracker({
- totalBytes,
- totalFiles,
- isTTY = Boolean(process.stdout.isTTY),
- log = console.log,
- now = () => Date.now(),
- percentStep = 5,
- ProgressBarClass = ProgressBar,
- stream = process.stdout,
- throttleMs = 1000,
- } = {}) {
- const state = {
- currentFileBytes: 0,
- currentFileComplete: false,
- currentFileIndex: 0,
- currentFileName: '',
- currentFileSize: 0,
- finished: false,
- startedAt: null,
- totalBytes,
- totalFiles,
- totalUploadedBytes: 0,
- }
- let lastLoggedAt = -Infinity
- let lastLoggedBucket = -1
- let hasRendered = false
- const progressTotal = Math.max(totalBytes, 1)
- const barCaptureStream = createCaptureStream()
- const bar =
- isTTY && totalFiles > 0
- ? new ProgressBarClass(PROGRESS_BAR_FORMAT, {
- clear: false,
- complete: '=',
- incomplete: ' ',
- renderThrottle: 100,
- stream: barCaptureStream,
- total: progressTotal,
- width: PROGRESS_BAR_WIDTH,
- })
- : null
- function safeClearLine(direction = 0) {
- stream.clearLine?.(direction)
- }
- function safeCursorTo(column = 0) {
- stream.cursorTo?.(column)
- }
- function safeMoveCursor(dx = 0, dy = 0) {
- stream.moveCursor?.(dx, dy)
- }
- function getBarText(snapshot, force = false) {
- if (!bar) {
- return ''
- }
- const ratio = progressTotal > 0 ? clamp(snapshot.totalUploadedBytes / progressTotal, 0, 1) : 0
- barCaptureStream.clearBuffer()
- if (force) {
- bar.update(ratio)
- bar.render(undefined, true)
- } else {
- bar.update(ratio)
- }
- return barCaptureStream.value || bar.lastDraw || '[]'
- }
- function clearTTYRender() {
- if (!hasRendered || !stream.isTTY) {
- return
- }
- safeClearLine(0)
- safeCursorTo(0)
- safeMoveCursor(0, -1)
- safeClearLine(0)
- safeCursorTo(0)
- }
- function renderTTY(force = false) {
- const snapshot = getSnapshot()
- const barText = getBarText(snapshot, force)
- const [ firstLine, secondLine ] = formatTtyProgressLines(snapshot, barText, stream.columns)
- if (hasRendered) {
- clearTTYRender()
- }
- stream.write(firstLine)
- safeClearLine(1)
- stream.write('\n')
- stream.write(secondLine)
- safeClearLine(1)
- hasRendered = true
- return snapshot
- }
- function terminateTTYRender() {
- if (!hasRendered || !stream.isTTY) {
- return
- }
- stream.write('\n')
- }
- function ensureStarted() {
- if (state.startedAt === null) {
- state.startedAt = now()
- }
- }
- function getSnapshot() {
- return buildSnapshot(state, now)
- }
- function logSnapshot(force = false) {
- const snapshot = getSnapshot()
- const currentBucket =
- percentStep > 0 ? Math.floor(snapshot.totalPercent / percentStep) : Number.POSITIVE_INFINITY
- if (
- !force &&
- now() - lastLoggedAt < throttleMs &&
- currentBucket <= lastLoggedBucket
- ) {
- return snapshot
- }
- lastLoggedAt = now()
- lastLoggedBucket = currentBucket
- log(formatProgressMessage(snapshot))
- return snapshot
- }
- function render(force = false) {
- if (bar) {
- return renderTTY(force)
- }
- return logSnapshot(force)
- }
- function advance(deltaBytes) {
- if (deltaBytes <= 0) {
- return getSnapshot()
- }
- ensureStarted()
- const remainingFile = Math.max(state.currentFileSize - state.currentFileBytes, 0)
- const remainingTotal = Math.max(state.totalBytes - state.totalUploadedBytes, 0)
- const safeDelta = Math.min(deltaBytes, remainingFile, remainingTotal)
- if (safeDelta <= 0) {
- return getSnapshot()
- }
- state.currentFileBytes += safeDelta
- state.totalUploadedBytes += safeDelta
- return render()
- }
- function startFile(file, fileIndex) {
- ensureStarted()
- state.currentFileBytes = 0
- state.currentFileComplete = false
- state.currentFileIndex = fileIndex
- state.currentFileName = file.name
- state.currentFileSize = file.size
- return render(true)
- }
- function completeFile() {
- const remainingBytes = Math.max(state.currentFileSize - state.currentFileBytes, 0)
- if (remainingBytes > 0) {
- advance(remainingBytes)
- }
- state.currentFileComplete = true
- return render(true)
- }
- function resetCurrentFile() {
- state.totalUploadedBytes = clamp(
- state.totalUploadedBytes - state.currentFileBytes,
- 0,
- Math.max(state.totalBytes, 0),
- )
- state.currentFileBytes = 0
- state.currentFileComplete = false
- return render(true)
- }
- function finish() {
- state.finished = true
- if (bar) {
- const snapshot = renderTTY(true)
- terminateTTYRender()
- return snapshot
- }
- return logSnapshot(true)
- }
- function fail(fileName = state.currentFileName) {
- const snapshot = getSnapshot()
- if (bar) {
- renderTTY(true)
- terminateTTYRender()
- }
- log(
- `upload failed at file ${snapshot.currentFileIndex}/${snapshot.totalFiles} ` +
- `${truncateFileName(fileName || snapshot.currentFileName || '-')} ` +
- `(${formatPercent(snapshot.currentFilePercent)} current, ` +
- `${formatPercent(snapshot.totalPercent)} total, ` +
- `${snapshot.speedLabel}, eta ${snapshot.etaLabel}, ` +
- `${snapshot.transferredLabel}/${snapshot.totalLabel})`,
- )
- }
- function interrupt(message) {
- if (bar && hasRendered) {
- clearTTYRender()
- stream.write(message)
- stream.write('\n')
- renderTTY(true)
- return
- }
- log(message)
- }
- return {
- advance,
- completeFile,
- fail,
- finish,
- getSnapshot,
- interrupt,
- resetCurrentFile,
- startFile,
- }
- }
|