UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

279 lines (278 loc) • 10 kB
import chalk from 'chalk'; import isUnicodeSupported from 'is-unicode-supported'; import { linearRegression, linearRegressionLine } from 'simple-statistics'; import TimePoly from '../polyfill/timePoly.js'; import { LogLevel } from './logLevel.js'; import ProgressBar, { ProgressBarSymbol } from './progressBar.js'; const CHALK_PROGRESS_COMPLETE_DEFAULT = chalk.reset; const CHALK_PROGRESS_IN_PROGRESS = chalk.dim; const CHALK_PROGRESS_INCOMPLETE = chalk.grey; const UNICODE_SUPPORTED = isUnicodeSupported(); const BAR_COMPLETE_CHAR = UNICODE_SUPPORTED ? '■' : '▬'; const BAR_IN_PROGRESS_CHAR = UNICODE_SUPPORTED ? '■' : '▬'; const BAR_INCOMPLETE_CHAR = UNICODE_SUPPORTED ? '■' : '▬'; const DEFAULT_ETA = '--:--:--'; const clamp = (val, min, max) => Math.min(Math.max(val ?? 0, min), max); /** * A single progress bar, to be used within a {@link MultiBar}. */ export default class SingleBar extends ProgressBar { static BAR_SIZE = 30; multiBar; logger; displayDelay; displayCreated; indentSize; symbol; name; showProgressNewline; progressBarSizeMultiplier; progressFormatter; completed; inProgress; total; finishedMessage; lastOutput; valueTimeBuffer = []; lastEtaCalculatedTime = 0; lastEtaCalculated = 0; lastEtaFormatTime = 0; lastEtaFormatted = DEFAULT_ETA; constructor(multiBar, logger, options) { super(); this.multiBar = multiBar; this.logger = logger; if (options?.displayDelay !== undefined) { this.displayDelay = options.displayDelay; this.displayCreated = TimePoly.hrtimeMillis(); } this.indentSize = options?.indentSize ?? 0; this.symbol = options?.symbol; this.name = options?.name; this.showProgressNewline = options?.showProgressNewline ?? true; this.progressBarSizeMultiplier = options?.progressBarSizeMultiplier ?? 1; this.progressFormatter = options?.progressFormatter ?? ((progress) => progress.toLocaleString()); this.completed = options?.completed ?? 0; this.inProgress = options?.inProgress ?? 0; this.total = options?.total ?? 0; this.finishedMessage = options?.finishedMessage; } /** * A child progress bar to this progress bar. */ addChildBar(options) { return this.multiBar.addSingleBar(this.logger, { displayDelay: 2000, indentSize: this.indentSize + (this.symbol?.symbol ? 2 : 0), progressBarSizeMultiplier: this.progressBarSizeMultiplier / 2, showProgressNewline: false, ...options, }, this); } getIndentSize() { return this.indentSize; } getSymbol() { return this.symbol; } setSymbol(symbol) { if (this.symbol === symbol) { return; } this.symbol = symbol; } getName() { return this.name; } setName(name) { if (this.name === name) { return; } this.name = name; } /** * Reset the completed, in-progress, and total values of the progress bar. */ resetProgress(total) { if (this.displayDelay !== undefined) { this.displayCreated = TimePoly.hrtimeMillis(); } this.completed = 0; this.inProgress = 0; this.total = total; this.valueTimeBuffer = []; } /** * Increment the completed count by the given increment (default: 1). */ incrementCompleted(increment = 1) { this.completed += increment; this.inProgress = Math.max(this.inProgress - increment, 0); } setCompleted(completed) { this.completed = completed; } /** * Increment the in-progress count by the given increment (default: 1). */ incrementInProgress(increment = 1) { this.inProgress += increment; } setInProgress(inProgress) { this.inProgress = inProgress; } /** * Increment the total count by the given increment (default: 1). */ incrementTotal(increment = 1) { this.total += increment; } setTotal(total) { this.total = total; } /** * Set the completed value to the total, and store a finished message. */ finish(finishedMessage) { if (this.symbol?.symbol) { this.setSymbol(ProgressBarSymbol.DONE); } if (this.total > 0) { this.setCompleted(this.total); } else { this.setCompleted(1); } this.setInProgress(0); this.finishedMessage = finishedMessage; } setLoggerPrefix(prefix) { this.logger = this.logger.withLoggerPrefix(prefix); } /** * Queue a log message to be printed to the terminal. */ log(logLevel, message) { if (this.logger.getLogLevel() > logLevel && this.logger.getLogLevel() !== LogLevel.ALWAYS) { return; } this.multiBar.log(this.logger.formatMessage(logLevel, message)); } /** * Log this {@link SingleBar}'s last output and freeze it. */ freeze() { this.multiBar.freezeSingleBar(this); } /** * Delete this {@link SingleBar} from the {@link MultiBar}. */ delete() { this.multiBar.removeSingleBar(this); } /** * Return the formatted output of this progress bar. */ format() { if (this.displayDelay !== undefined && (this.completed >= this.total || TimePoly.hrtimeMillis(this.displayCreated) < this.displayDelay)) { return ''; } this.displayDelay = undefined; let output = ' '.repeat(this.indentSize); if (this.symbol?.symbol) { output += this.symbol.color(`${this.symbol.symbol} `); } if (this.finishedMessage) { output += `${this.name} ${CHALK_PROGRESS_IN_PROGRESS('»')} ${this.finishedMessage}`; this.lastOutput = output; return this.lastOutput; } if (!this.showProgressNewline) { output += `${this.getBar()} `; } if (this.name) { output += `${this.name} `; } if (this.showProgressNewline) { output += `\n${' '.repeat(this.indentSize + (this.symbol?.symbol ? 2 : 0))}${this.getBar()} `; } this.lastOutput = output.trimEnd(); return this.lastOutput; } getBar() { let bar = ''; const symbolColor = (this.indentSize === 0 ? this.symbol?.color : undefined) ?? CHALK_PROGRESS_COMPLETE_DEFAULT; const barSize = Math.floor(SingleBar.BAR_SIZE * this.progressBarSizeMultiplier) - this.indentSize - (this.symbol?.symbol ? 2 : 0); const completeSize = this.total > 0 ? Math.floor(clamp(this.completed / this.total, 0, 1) * barSize) : 0; bar += symbolColor(BAR_COMPLETE_CHAR.repeat(Math.max(completeSize, 0))); const inProgressSize = this.total > 0 ? Math.ceil((clamp(this.inProgress, 0, this.total) / this.total) * barSize) : 0; bar += CHALK_PROGRESS_IN_PROGRESS(BAR_IN_PROGRESS_CHAR.repeat(Math.max(inProgressSize, 0))); const incompleteSize = barSize - inProgressSize - completeSize; bar += CHALK_PROGRESS_INCOMPLETE(BAR_INCOMPLETE_CHAR.repeat(Math.max(incompleteSize, 0))); bar += ' '; const formattedCompleted = this.progressFormatter(this.completed); const formattedTotal = this.progressFormatter(this.total); const paddedCompleted = formattedCompleted.padStart(Math.max(formattedTotal.length, this.indentSize > 0 ? 8 : 0), ' '); const paddedTotal = formattedTotal.padEnd(this.indentSize > 0 ? 8 : 0, ' '); bar += `${symbolColor(paddedCompleted)}/${CHALK_PROGRESS_IN_PROGRESS(paddedTotal)} `; if (this.completed > 0 || this.indentSize > 0) { bar += CHALK_PROGRESS_INCOMPLETE(`[${this.getEtaFormatted()}]`); } return bar.trim(); } getEtaFormatted() { if (this.completed === 0) { return DEFAULT_ETA; } const etaSeconds = this.calculateEta(); // Throttle how often the ETA can visually change const elapsedMs = TimePoly.hrtimeMillis(this.lastEtaFormatTime); if (etaSeconds > 60 && elapsedMs < 5000) { return this.lastEtaFormatted; } this.lastEtaFormatTime = TimePoly.hrtimeMillis(); if (Math.floor(etaSeconds) < 0) { this.lastEtaFormatted = DEFAULT_ETA; return this.lastEtaFormatted; } const hours = Math.floor(etaSeconds / 3600); const minutes = Math.floor((etaSeconds % 3600) / 60); const seconds = Math.ceil(etaSeconds) % 60; this.lastEtaFormatted = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; return this.lastEtaFormatted; } calculateEta() { // Throttle how often the ETA is calculated const elapsedMs = TimePoly.hrtimeMillis(this.lastEtaCalculatedTime); if (elapsedMs < 50) { return this.lastEtaCalculated; } this.lastEtaCalculatedTime = TimePoly.hrtimeMillis(); const MAX_BUFFER_SIZE = clamp(Math.floor(this.total / 10), 25, 50); this.valueTimeBuffer = [ ...this.valueTimeBuffer.slice(1 - MAX_BUFFER_SIZE), [this.completed, Date.now()], ]; const doneTime = linearRegressionLine(linearRegression(this.valueTimeBuffer))(this.total); if (Number.isNaN(doneTime)) { // Vertical line return -1; } const remaining = (doneTime - Date.now()) / 1000; if (!Number.isFinite(remaining)) { return -1; } this.lastEtaCalculated = Math.max(remaining, 0); return this.lastEtaCalculated; } getLastOutput() { return this.lastOutput; } }