4
\$\begingroup\$

This is an interactive script for selecting and reading the manual pages from the terminal command line. This is the 3rd version of this script. If you want to see the evolution of this script, follow the links for version 1 and version 2.

In previous versions of this script I had mentioned wanting to make a database of the commands which have manual pages associated with them. Rather than listing all of the commands available to the user, I thought it would be better to present the user with a menu of commands that have documented manual pages while leaving off the commands that are undocumented.

In this version I have implemented that idea. I will admit that my method seems a little hacky. Although it does work, I am wondering if there is a way it could work better.

#!/usr/local/bin/bash

usage() {
    cat << EOF

Usage: pages [-u][-h]
    -u Update the database
    -h Display this help text

EOF
}


## Mac OS X command to completely clear the screen
## Does not allow scrolling up beyond what was cleared
## Not really necessary but it makes things look nice

clear() {
    osascript -e \
    'set theApp to (get the path to the frontmost application) as text
    set this_app to the name of application theApp
    activate application this_app
    tell application "System Events" to keystroke "k" using command down'   
}


## Random string generator for temporary files
## I cannot take credit for this :)

chars=( {a..z} {A..Z} {0..9} )
rand_string() {
    local c=$1 ret=
    while((c--)); do
        ret+=${chars[$((RANDOM%${#chars[@]}))]}
    done
    printf '%s\n' "$ret"
}

tmp_file="$HOME/.$(rand_string 10)"
database="$HOME/.database"


## Create a database of commands that have a manual page associated with them
## Commands that do not have a manual page will not be listed in the database

update() {
    touch $database

    ## ls ${PATH//:/ } is used for getting a list of all available commands 
    ## as well as listing the directories in which those commands are located.
    ## If there is a better way please let me know.

    for item in $(ls ${PATH//:/ }); do

        ## Directories are listed with a colon at the end
        ## For example - /usr/bin:
        ## To put some seperation between directories and commands,
        ## insert a newline before listing a new directory in the database

        if [[ $item =~ ':' ]]; then                 
            echo -e "\n$item" >> $tmp_file
        elif [[ $(man $item 2>&1) != "No manual entry for $item" ]]; then
            echo $item >> $tmp_file
        fi
    done

    ## Remove the empty new line at the beginning of the database
    sed '1{/^$/d;}' $tmp_file > $database

    ## Delete the temporary file
    rm $tmp_file 
}


## A spinner to use when updating the database
## This will only be used in conjunction with the update function
## So the update function is nested inside the spin function

spin() {
    while true; do 
        for c in / - \\ \|; do 
            printf 'Creating a database.. %s\r' "$c"; sleep .1
        done
    done & update
    { printf '\n'; kill $! && wait $!; } 2>/dev/null    
}

## Menu creation
page_menu() {
    ## If a database does not exist then create one
    [[ -f $database ]] || spin
    ## Declare a new associative array
    declare -A dirs=()
    ## Loop thru all lines and populate the array
    while read -r; do
        ## Check for empty lines
        [[ -z $REPLY ]] && continue
        if [[ $REPLY == *: ]]; then
            d="$REPLY"
        else
            ## Append newline + current line into array entry
            dirs["$d"]+=$'\n'"$REPLY"
        fi
    done < $database
    ## Clear the screen.. if that wasn't obvious :)
    clear

    ## The menu is contained within 2 `while true` loops to allow for 
    ## breaking out of one loop to return to the previous screen

    while true
    do

        ## Display the main menu with options for:
        ## selecting a directory
        ## exiting the script
        ## or updating the database

        printf 'Manual Pages: main menu\n¯¯¯¯¯¯¯¯¯¯¯¯\n'
        PS3=$'\n(Q)uit\n(U)pdate\n\nMake your selection: '
        ## Present the 1st menu as a single column
        COLUMNS=20
        select dir in "${!dirs[@]}"
        do
            ## Clears the screen.. have you forgotten already? :D
            clear
            case $REPLY in
            ## User may choose to update the database (Main menu)
            [uU]) spin; break;;
            ## User may choose one of the listed directories (Main menu)
            [0-9]) 
            while true
            do  

                ## Display the submenu with options for:
                ## selecting a command to view the associated manual page
                ## going back to the previous menu
                ## or exiting the script

                printf "Manual Pages - $dir\n------------\n"
                PS3=$'\n(B)ack to main menu\n(Q)uit\n\nMake your selection: '
                # Display as multi column output
                cols=$(tput cols)
                COLUMNS=$cols
                select d in $(printf '%s%s\n' "${dirs[$dir]}")
                do
                    case $REPLY in 
                    ## User may choose to view a manual page
                    [0-9]*) man "$d"; clear; break 1;;
                    ## User may choose to go back to the previous menu
                    [bB]) clear; break 2;;                  
                    ## User may choose to exit the script
                    [qQ]) clear; printf 'Thanks for stopping by..\nHave a great day!\n'; exit;;                 
                    ## Any other choice and the script will exit with error code 1
                    *) exit 1;;
                    esac
                done
            done
            break;;
            ## User may chooose to exit the script (Main menu) 
            [qQ]) printf 'Thanks for stopping by..\nHave a great day!\n'; exit;;    
            ## Any other choice and the script will exit with error code 1 (Main menu)
            *) usage; exit 1;;
            esac
        done    
    done    
}

## If an option is supplied, do that option
## otherwise proceed with the page_menu function

while [ "$1" ] || page_menu
do
    case $1 in
    -u|--update)    spin; page_menu;;               
    -h|--help )     usage; exit;;                   
    * )             usage; exit 1
    esac
done

Specifically, this part seems a little hacky to me. The way I'm currently building the database means I have to use a temporary file and insert empty newlines in between each new directory in order to separate the directories.

for item in $(ls ${PATH//:/ }); do
    if [[ $item =~ ':' ]]; then                 
        echo -e "\n$item" >> $tmp_file
    elif [[ $(man $item 2>&1) != "No manual entry for $item" ]]; then
        echo $item >> $tmp_file
    fi
done
sed '1{/^$/d;}' $tmp_file > $database
rm $tmp_file 

I know that there are probably better ways to get the available man pages. But some of those manual pages are not really user oriented. On my system, the manual pages are located at /usr/local/share/man. And the total number of files located there is over 16,000 whereas the total number of man pages on my system currently accessed in this script is 1,720. I'm only trying to access the manual pages that are geared towards the average user. I think I am currently doing that but I just wonder if there is a better way to achieve the same outcome.

Edit: In the submenu portion of the script I originally had the columns set to a specific number. COLUMNS=110. I just realized that I could have tput calculate the columns within the script and set them accordingly.

# Display as multi column output
cols=$(tput cols)
COLUMNS=$cols

This seems much more appealing than hard coding the the column width beforehand. I realize that editing a script after posting is frowned upon but since nobody has commented or critiqued the script so far, I don't see it as being a problem. If it is a problem, I will change it back.

Edit #2 I just found a place in this script where everything breaks. If an invalid option is selected when choosing a directory in the main menu, the script returns an error dirs: bad array subscript. And since this happens within a while true loop, the error is repeated endlessly until Ctrl C is pressed. I know questions about broken code are not appropriate for this site. I am working on figuring out how to fix the error.

\$\endgroup\$
2
\$\begingroup\$

It seems you have installed Bash version 4 and used some of the new features in your script (e.g. associative arrays), so I will not restrain myself with the rein of portable shell programming. Also, I have only tested my solution on the latest openSUSE so it might not work on other operating systems.


Update function is too slow

Your update function takes 200 seconds to run on my computer, and I only have six directories in my PATH and a total of 4923 files in those directories. The culprit for such bad performance lies in this conditional statement:

elif [[ $(man $item 2>&1) != "No manual entry for $item" ]]

where you try to open a command's manual page to test for its existance. A better approach may be to query the manual page database, like thus:

elif whatis $item &>/dev/null

This runs in 10 seconds.

Read into the array, then construct the database file, not the other way round

When you're creating the database file, you already have all the information needed to populate the array, so why do it later line by line? Once you have constructed the array once, writing to and reading from the database file is as easy as typeset -p dirs > "$database" and eval $(cat "$database"). Implementing those features is left as an exercise for the OP, as I didn't include them in my solution below.

Some nitpicking

  • If mktemp is available on your system, you can replace the following lines of code:

    chars=( {a..z} {A..Z} {0..9} )
    rand_string() {
        local c=$1 ret=
        while((c--)); do
            ret+=${chars[$((RANDOM%${#chars[@]}))]}
        done
        printf '%s\n' "$ret"
    }
    
    tmp_file="$HOME/.$(rand_string 10)"
    

    with this one-liner:

    tmp_file="$(mktemp -p "$HOME" .XXXXXXXXXX)"
    
  • In your usage function, you can hard-code the script name or you can write $(basename "${BASH_SOURCE[0]}").

  • ls ${PATH//:/ } won't list the contents of paths whose name contains white spaces.

  • Too many useless comments, like ## Delete the temporary file and ## Clear the screen.. if that wasn't obvious :).


Revamped pages

Compare carefully this version with your source as I didn't explain everything that has changed.

Note: I used the command builtin to tell Bash to always execute ls and sleep as found in the default system path. I did that to expedite my testing, so you may remove them if you wish.

#!/usr/local/bin/bash

usage()
{
    cat << EOF

Usage: $(basename "${BASH_SOURCE[0]}") [-u][-h]
    -u Update the database
    -h Display this help text

EOF
}

## Mac OS X command to completely clear the screen
## Does not allow scrolling up beyond what was cleared
## Not really necessary but it makes things look nice
clear()
{
    osascript -e \
    'set theApp to (get the path to the frontmost application) as text
    set this_app to the name of application theApp
    activate application this_app
    tell application "System Events" to keystroke "k" using command down'
}

## Create a database of commands that have a manual page associated with them
## Commands that do not have a manual page will not be listed in the database
update()
{
    database=() # Always reset the database
    IFS=:
    for path in $PATH
    do
        readarray -t cmds < <(command -p ls -1 $path)
        size=${#cmds[@]} # Save the array size beforehand as we don't want it re-evaluated in each run through the loop
        for (( i=0; i < size; ++i ))
        do
            whatis "${cmds[$i]}" &>/dev/null || unset cmds[$i] # unset reduces the size of the array
        done
        (( ${#cmds[@]} > 0 )) && database[$path]=${cmds[@]} # What might happen if you use ${cmds[*]}? Try creating two files named : and ls in your PATH
    done
    unset IFS
}

## A spinner to use when updating the database
## This will only be used in conjunction with the update function
## So the update function is nested inside the spin function
spin()
{
    while true
    do
        for c in / - \\ \|
        do
            printf 'Creating a database.. %s\r' "$c"
            command -p sleep .1
        done
    done &
    spin_id=$!
    disown
    update
    printf '\n'
    kill $spin_id
}

## Menu creation
page_menu()
{
    (( ${#database[@]} > 0 )) || spin
    (( ${#database[@]} == 0 )) && echo 'No command in your PATH has an associated manual page' && exit 2
    while true
    do
        clear
        printf 'Manual Pages: main menu\n¯¯¯¯¯¯¯¯¯¯¯¯\n'
        PS3=$'\n(Q)uit\n(U)pdate\n\nMake your selection: '
        ## Present the 1st menu as a single column
        COLUMNS=20
        select path in ${!database[@]}
        do
            clear
            case "$REPLY" in
                [uU])
                    spin
                    break
                    ;;
                [0-9]*)
                    (( REPLY > 0 )) && (( REPLY <= ${#database[@]} )) || break
                    while true
                    do
                        clear
                        printf "Manual Pages - $path\n------------\n"
                        PS3=$'\n(B)ack to main menu\n(Q)uit\n\nMake your selection: '
                        # Display as multi column output
                        COLUMNS=$(tput cols)
                        select cmd in ${database[$path]}
                        do
                            case "$REPLY" in
                                [0-9]*)
                                    (( REPLY > 0 )) && (( REPLY <= $(echo "${database[$path]}" | wc -w) )) && man "$cmd"
                                    break
                                    ;;
                                [bB])
                                    break 2
                                    ;;
                                [qQ])
                                    clear
                                    printf 'Thanks for stopping by..\nHave a great day!\n'
                                    exit
                                    ;;
                                *)
                                    exit 1
                                    ;;
                            esac
                        done
                    done
                    break
                    ;;
                [qQ])
                    printf 'Thanks for stopping by..\nHave a great day!\n'
                    exit
                    ;;
                *)
                    usage
                    exit 1
                    ;;
            esac
        done
    done
}

typeset -A database
case "$1" in
    -u|--update)
        spin
        page_menu
        ;;
    -h|--help)
        usage
        exit
        ;;
    '')
        page_menu
        ;;
    *)
        usage
        exit 1
        ;;
esac
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.