How to add a progress bar to a shell script?

后端 未结 30 2235
情歌与酒
情歌与酒 2020-11-22 05:48

When scripting in bash or any other shell in *NIX, while running a command that will take more than a few seconds, a progress bar is needed.

For example, copying a b

30条回答
  •  抹茶落季
    2020-11-22 06:30

    Haven't seen anything similar and all custom functions here seem to focus on rendering alone so... my very simple POSIX compliant solution below with step by step explanations because this question isn't trivial.

    TL;DR

    Rendering the progress bar is very easy. Estimating how much of it should render is a different matter. This is how to render (animate) the progress bar - you can copy&paste this example to a file and run it:

    #!/bin/sh
    
    BAR='####################'   # this is full bar, e.g. 20 chars
    
    for i in {1..20}; do
        echo -ne "\r${BAR:0:$i}" # print $i chars of $BAR from 0 position
        sleep .1                 # wait 100ms between "frames"
    done
    
    • {1..20} - values from 1 to 20
    • echo -n - print without new line at the end
    • echo -e - interpret special characters while printing
    • "\r" - carriage return, a special char to return to the beginning of the line

    You can make it render any content at any speed so this method is very universal, e.g. often used for visualization of "hacking" in silly movies, no kidding.

    Full answer

    The meat of the problem is how to determine the $i value, i.e. how much of the progress bar to display. In the above example I just let it increment in for loop to illustrate the principle but a real life application would use an infinite loop and calculate the $i variable on each iteration. To make said calculation it needs the following ingredients:

    1. how much work there is to be done
    2. how much work has been done so far

    In case of cp it needs the size of a source file and the size of the target file:

    #!/bin/sh
    
    $src=/path/to/source/file
    $tgt=/path/to/target/file
    
    cp "$src" "$tgt" &                     # the & forks the `cp` process so the rest
                                           # of the code runs without waiting (async)
    
    BAR='####################'
    
    src_size=$(stat -c%s "$src")           # how much there is to do
    
    while true; do
        tgt_size=$(stat -c%s "$tgt")       # how much has been done so far
        i=$(( $tgt_size * 20 / $src_size ))
        echo -ne "\r${BAR:0:$i}"
        if [ $tgt_size == $src_size ]; then
            echo ""                        # add a new line at the end
            break;                         # break the loop
        fi
        sleep .1
    done
    
    • stat - check file stats
    • -c - return formatted value
    • %s - total size

    In case of operations like file unpacking, calculating the source size is slightly more difficult but still as easy as getting the size of an uncompressed file:

    #!/bin/sh
    src_size=$(gzip -l "$src" | tail -n1 | tr -s ' ' | cut -d' ' -f3)
    
    • gzip -l - display info about zip archive
    • tail -n1 - work with 1 line from the bottom
    • tr -s ' ' - translate multiple spaces to one (squeeze them)
    • cut -d' ' -f3 - cut 3rd space-delimited column

    Here's the meat of the problem, though. This solution is less and less general. All calculations of the actual progress are tightly bound to the domain you're trying to visualize, is it a single file operation, a timer countdown, a rising number of files in a directory, operation on multiple files, etc., therefore, it can't be reused. The only reusable part is progress bar rendering. To reuse it you need to abstract it and save in a file (e.g. /usr/lib/progress_bar.sh), then define functions that calculate input values specific to your domain. This is how a generalized code could look like (I also made the $BAR dynamic because people were asking for it, the rest should be clear by now):

    #!/bin/sh
    
    BAR_length=50
    BAR_character='#'
    BAR=$(printf "%${BAR_length}s" | tr ' ' $BAR_character)
    
    work_todo=$(get_work_todo)             # how much there is to do
    
    while true; do
        work_done=$(get_work_done)         # how much has been done so far
        i=$(( $work_done * $BAR_length / $work_todo ))
        echo -ne "\r${BAR:0:$i}"
        if [ $work_done == $work_todo ]; then
            echo ""
            break;
        fi
        sleep .1
    done
    
    • printf - a builtin for printing stuff in a given format
    • printf '%50s' - print nothing, pad it with 50 spaces
    • tr ' ' '#' - translate every space to hash sign

    And this is how you'd use it:

    #!/bin/sh
    
    src=/path/to/source/file
    tgt=/path/to/target/file
    
    function get_work_todo() {
        echo $(stat -c%s "$src")
    }
    
    function get_work_done() {
        [ -e "$tgt" ] &&                   # if target file exists
            echo $(stat -c%s "$tgt") ||    # echo its size, else
            echo 0                         # echo zero
    }
    
    cp "$src" "$tgt" &                     # copy in the background
    
    source /usr/lib/progress_bar.sh        # execute the progress bar
    

    Obviously it can be wrapped in a function, rewritten to work with piped streams, rewritten to other language, whatever's your poison.

提交回复
热议问题