emmercm/igir

View on GitHub
src/console/singleBarFormatted.ts

Summary

Maintainability
C
1 day
Test Coverage
import chalk from 'chalk';
import { MultiBar, Options, Params, SingleBar } from 'cli-progress';
import { linearRegression, linearRegressionLine } from 'simple-statistics';
import stripAnsi from 'strip-ansi';

import ConsolePoly from '../polyfill/consolePoly.js';
import TimePoly from '../polyfill/timePoly.js';
import ProgressBarPayload from './progressBarPayload.js';

/**
 * A wrapper class for a cli-progress {@link SingleBar} that formats the output.
 */
export default class SingleBarFormatted {
  public static readonly MAX_NAME_LENGTH = 35;

  public static readonly BAR_COMPLETE_CHAR = '\u2588';

  public static readonly BAR_IN_PROGRESS_CHAR = '\u2592';

  public static readonly BAR_INCOMPLETE_CHAR = '\u2591';

  private readonly multiBar: MultiBar;

  private readonly singleBar: SingleBar;

  private lastOutput = '';

  private valueTimeBuffer: number[][] = [];

  private lastEtaTime: number = 0;

  private lastEtaValue = 'infinity';

  constructor(multiBar: MultiBar, initialTotal: number, initialPayload: ProgressBarPayload) {
    this.multiBar = multiBar;
    this.singleBar = this.multiBar.create(initialTotal, 0, initialPayload, {
      format: (options, params, payload: ProgressBarPayload): string => {
        const symbolAndName = `${SingleBarFormatted.getSymbolAndName(payload)} | `;

        const progressWrapped = this.getProgress(options, params, payload)
          .split('\n')
          .map((line, idx) => {
            // Wrapping is broken: https://github.com/npkgz/cli-progress/issues/142
            let lineTrimmed = line;
            const maxLineLength = ConsolePoly.consoleWidth() - stripAnsi(symbolAndName).length - 2;
            if (line.length > maxLineLength) {
              lineTrimmed = `${line.slice(0, maxLineLength - 3)}...`;
            }

            if (idx === 0) {
              return lineTrimmed;
            }
            return ' '.repeat(stripAnsi(symbolAndName).length) + lineTrimmed;
          })
          .join('\n\x1b[K');

        this.lastOutput = `${symbolAndName}${progressWrapped}`.trim();
        return (
          this.lastOutput
            // cli-progress doesn't handle multi-line progress bars, collapse to one line. The multi-
            // line message will get logged correctly when the progress bar is frozen & logged.
            .replace(/\n\S*\s+/g, ' ')
        );
      },
    });
  }

  getSingleBar(): SingleBar {
    return this.singleBar;
  }

  getLastOutput(): string {
    return this.lastOutput;
  }

  private static getSymbolAndName(payload: ProgressBarPayload): string {
    const symbol = chalk.bold(payload.symbol ?? '');
    const name = payload.name ?? '';

    const namePadded = `${name} ${'ยท'.repeat(SingleBarFormatted.MAX_NAME_LENGTH)}`.trim();
    const symbolAndName = `${symbol} ${namePadded}`;

    const excessLength =
      stripAnsi(symbolAndName).trimStart().length - SingleBarFormatted.MAX_NAME_LENGTH;
    const nameTrimmed = namePadded.slice(
      0,
      namePadded.length - (excessLength > 0 ? excessLength : 0),
    );

    return `${symbol} ${nameTrimmed}`.trimStart();
  }

  private getProgress(options: Options, params: Params, payload: ProgressBarPayload): string {
    if (payload.finishedMessage) {
      return payload.finishedMessage;
    }

    let progress = SingleBarFormatted.getBar(options, params, payload);
    if (!params.total) {
      return progress;
    }

    progress += ` | ${params.value.toLocaleString()}/${params.total.toLocaleString()}`;

    if (params.value > 0) {
      const eta = this.calculateEta(params);
      if (eta > 0) {
        progress += ` | ETA: ${this.getEtaFormatted(eta)}`;
      }
    }

    if (payload.waitingMessage) {
      progress += ` | ${payload.waitingMessage}`;
    }

    return progress;
  }

  private calculateEta(params: Params): number {
    const clamp = (val: number, min: number, max: number): number =>
      Math.min(Math.max(val, min), max);
    const MAX_BUFFER_SIZE = clamp(Math.floor(params.total / 10), 25, 50);

    this.valueTimeBuffer = [
      ...this.valueTimeBuffer.slice(1 - MAX_BUFFER_SIZE),
      [params.value, Date.now()],
    ];

    const doneTime = linearRegressionLine(linearRegression(this.valueTimeBuffer))(params.total);
    if (Number.isNaN(doneTime)) {
      // Vertical line
      return -1;
    }
    const remaining = (doneTime - Date.now()) / 1000;
    if (!Number.isFinite(remaining)) {
      return -1;
    }
    return Math.max(remaining, 0);
  }

  private static getBar(options: Options, params: Params, payload: ProgressBarPayload): string {
    const barSize = options.barsize ?? 0;
    if (barSize <= 0) {
      return '';
    }

    const clamp = (val: number | undefined, min: number, max: number): number =>
      Math.min(Math.max(val ?? 0, min), max);
    const completeSize = Math.floor(clamp(params.progress, 0, 1) * barSize);
    const inProgressSize =
      params.total > 0
        ? Math.ceil((clamp(payload.inProgress, 0, params.total) / params.total) * barSize)
        : 0;
    const incompleteSize = barSize - inProgressSize - completeSize;

    return (
      SingleBarFormatted.BAR_COMPLETE_CHAR.repeat(Math.max(completeSize, 0)) +
      SingleBarFormatted.BAR_IN_PROGRESS_CHAR.repeat(Math.max(inProgressSize, 0)) +
      SingleBarFormatted.BAR_INCOMPLETE_CHAR.repeat(Math.max(incompleteSize, 0))
    );
  }

  private getEtaFormatted(etaSeconds: number): string {
    // Rate limit how often the ETA can change
    //  Update only every 5s if the ETA is >60s
    const elapsedMs = TimePoly.hrtimeMillis(this.lastEtaTime);
    if (etaSeconds > 60 && elapsedMs < 5000) {
      return this.lastEtaValue;
    }
    this.lastEtaTime = TimePoly.hrtimeMillis();

    if (etaSeconds < 0) {
      this.lastEtaValue = 'infinity';
      return this.lastEtaValue;
    }

    const etaSecondsInt = Math.ceil(etaSeconds);
    const secondsRounded = 5 * Math.round(etaSecondsInt / 5);
    if (secondsRounded >= 86_400) {
      this.lastEtaValue = SingleBarFormatted.getEtaFormattedDays(secondsRounded);
    } else if (secondsRounded >= 3600) {
      this.lastEtaValue = SingleBarFormatted.getEtaFormattedHours(secondsRounded);
    } else if (secondsRounded >= 60) {
      this.lastEtaValue = SingleBarFormatted.getEtaFormattedMinutes(secondsRounded);
    } else if (etaSecondsInt >= 10) {
      this.lastEtaValue = `${secondsRounded}s`;
    } else {
      this.lastEtaValue = `${etaSecondsInt}s`;
    }
    return this.lastEtaValue;
  }

  private static getEtaFormattedDays(secondsRounded: number): string {
    const hours = Math.floor((secondsRounded % 86_400) / 3600);
    if (hours > 0) {
      return `${Math.floor(secondsRounded / 86_400)}d${hours}h`;
    }
    return `${Math.floor(secondsRounded / 86_400)}d`;
  }

  private static getEtaFormattedHours(secondsRounded: number): string {
    const minutes = Math.floor((secondsRounded % 3600) / 60);
    if (minutes > 0) {
      return `${Math.floor(secondsRounded / 3600)}h${minutes}m`;
    }
    return `${Math.floor(secondsRounded / 3600)}h`;
  }

  private static getEtaFormattedMinutes(secondsRounded: number): string {
    const seconds = secondsRounded % 60;
    if (seconds > 0) {
      return `${Math.floor(secondsRounded / 60)}m${seconds}s`;
    }
    return `${Math.floor(secondsRounded / 60)}m`;
  }
}