# Bash manual page selection menu (version 3)

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
## 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. ## 1 Answer 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:

## 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
;;
-h|--help)
usage
exit
;;
'')