I have an old shell script which needs to be moved to bash. This script prints progress of some activity and waits for user's commands. If no action is taken by user for 15 seconds screen is redrawn with new progress and timer starts again. Here's my problem:
I am trying to use read -t 15 myVar - this way after 15 seconds of waiting loop will be restarted. There is however a scenario which brings me a problem:
screen redrawn and script waits for input (prints 'Enter command:')
user enters foo but doesn't press enter
after 15 seconds screen is again redrawn and script waits for input - note, that foo is not displayed anywhere on the screen (prints 'Enter command:')
user enters bar and presses enter
At this moment variable $myVar holds 'foobar'.
What do I need? I am looking for a way to find the first string typed by user, so I could redisplay it after refreshing status. This way user will see:
Enter command: foo
On Solaris I could use stty -pendin to save input into some sort of a buffer, and after refresh run stty pendin to get this input from buffer and print it on a screen.
Is there a Linux equivalent to stty pendin feature? Or maybe you know some bash solution to my problem?
I guess one way would be to manually accumulate the user input ... use read with -n1 so that it returns after every character, then you can add it to your string. To prevent excessive drawing you would have to calculate how many remaining seconds are on the 15 second clock ...
Addendum:
For your comment about space/return - you basically want to use an IFS without the characters you want, such as space
example:
XIFS="${IFS}" # backup IFS
IFS=$'\r' # use a character your user is not likely to enter (or just the same but w/o the space)
# Enter will look like en empty string
$ read -n1 X; echo --; echo -n "${X}" | od -tx1
--
0000000
# space will be presented as a character
$ read -n1 X; echo --; echo -n "${X}" | od -tx1
--
0000000 20
0000001
# after you are all done, you probably wantto restore the IFS
IFS="${XIFS}"
Expanding on what #nhed was saying, perhaps something like this:
#!/bin/bash
limit=5
draw_screen () {
clear;
echo "Elapsed time: $SECONDS" # simulate progress indicator
printf 'Enter command: %s' "$1"
}
increment=$limit
str=
end=0
while ! [ $end -eq 1 ] ; do
draw_screen "$str"
while [ $SECONDS -lt $limit ] && [ $end -eq 0 ] ; do
c=
IFS= read -t $limit -r -n 1 -d '' c
if [ "$c" = $'\n' ] ; then
end=1
fi
str="${str}${c}"
done
let limit+=increment
done
str="${str%$'\n'}" # strip trailing newline
echo "input was: '$str'"
The solution is not ideal:
You can sometimes be typing in the middle of the loop and mess up input
You can't edit anything nicely (but this is fixable with a lot more work)
But maybe it's enough for you.
If you have Bash 4:
read -p 'Enter something here: ' -r -t 15 -e -i "$myVar" myVar
The -e turns on readline support for the user's text entry. The -i uses the following text as the default contents of the input buffer which it displays to the user. The following text in this case is the previous contents of the variable you're reading into.
Demonstration:
$ myVar='this is some text' # simulate previous entry
$ read -p 'Enter something here: ' -r -t 15 -e -i "$myVar" myVar
Enter something here: this is some text[]
Where [] represents the cursor. The user will be able to backspace and correct the previous text, if needed.
OK, I think I have the solution. I took nhed's proposition and worked a bit on it :)
The main code prints some status and waits for input:
while :
do
# print the progress on the screen
echo -n "Enter command: "
tput sc # save the cursor position
echo -n "$tmpBuffer" # this is buffer which holds typed text (no 'ENTER' key yet)
waitForUserInput
read arguments <<< $(echo $mainBuffer) # this buffer is set when user presses 'ENTER'
mainBuffer="" # don't forget to clear it after reading
# now do the action requested in $arguments
done
Function waitForUserInput waits 10 seconds for a keypress. If nothing typed - exits, but already entered keys are saved in a buffer. If key is pressed, it is parsed (added to buffer, or removed from buffer in case of backspace). On Enter buffer is saved to another buffer, from which it is read for further processing:
function waitForUserInput {
saveIFS=$IFS # save current IFS
IFS="" # change IFS to empty string, so 'ENTER' key can be read
while :
do
read -t10 -n1 char
if (( $? == 0 ))
then
# user pressed something, so parse it
case $char in
$'\b')
# remove last char from string with sed or perl
# move cursor to saved position with 'tput rc'
echo -n "$tmpBuffer"
;;
"")
# empty string is 'ENTER'
# so copy tmpBuffer to mainBuffer
# clear tmpBuffer and return to main loop
IFS=$saveIFS
return 0
;;
*)
# any other char - add it to buffer
tmpBuffer=$tmpBuffer$char
;;
esac
else
# timeout occured, so return to main function
IFS=$saveIFS
return 1
fi
done
}
Thanks to all of you for your help!
Related
If you enter a file called 'quit' the script exits. If you enter any other text file, it will return only the lines in the file that start with 'b' or 'B'. Here is my code so far.
first part of script
second part of script
I'm not sure what I am doing wrong. For reference, I have two text files that I am using. One is blank and is named 'quit' and the other is a random text file with words starting with 'b', 'B' and others that don't start with B. The script should print an asterisk every two seconds. Upon pressing CTRL-C, you are then prompted to enter a file. The script will exit after inputting the 'quit' file or it will display every line starting with 'b' or 'B' upon entering the random word file.
#!/bin/bash
trap f1 INT
f1 ()
{
read -p "Enter a filename [enter quit to quit]: " z
cat $z | while read -r line
do
if ((z == "quit"))
then
exit
fi
if [ $z == "^b"* ]
then
echo "$line"
fi
done
}
while true
do
sleep 2
echo -e "*\c"
done
When you pipe into a while loop (cat file.txt | while ....), you create a subshell. When you call exit within the subshell, only the subshell exits. The original shell still.... continues on comfortably. Provide the input via redirection to avoid the subshell in this case.
Redirection:
# This avoids a subshell.
while read line; do
declare -p line
done < file.txt
# file.txt redirects directly into the while loop
# This avoids a subshell also.
while read line; do
declare -p line
done < <(echo hello; echo world)
# Process substitution rather than a file. Just some extra salt to tickle your brain.
Lastly, depending on your comfort with some other bash utilities, you can avoid the loop entirely. sed does waay more than just substituting letters or phrases. -n as a sed argument means "don't print the lines all the time, only print lines when I tell you to". '/pattern/ means that you'll only do the following sed action if the line matches the pattern (in our case, the pattern is /^b/). After the pattern, you could do a sed command like s/from/to/ which is the s command that prints. Or you could do a different command: p, which means "just print the line that's being processed" (the line that matched the pattern).
#!/bin/bash
trap f1 INT
f1 () {
read -p "Enter a filename [enter quit to quit]: " z
# Exit if the user typed "quit".
[ "$z" == "quit" ] && exit
# Print all the lines that start with "b"
sed -n '/^b/p' < "$z"
}
# ... other code
#!/bin/bash
#if there are no args supplied exit with 1
if [ "$#" -eq 0 ]; then
echo "Unfortunately you have not passed any parameter"
exit 1
fi
#loop over each argument
for arg in "$#"
do
if [ -f arg ]; then
echo "$arg is a file."
#iterates over the files stated in arguments and reads them $
cat $arg | while read line;
do
#should access only first line of the file
if [ head -n 1 "$arg" ]; then
process line
echo "Script has ran successfully!"
exit 0
#should access only last line of the file
elif [ tail -n 1 "$arg" ]; then
process line
echo "Script has ran successfully!"
exit 0
#if it accesses any other line of the file
else
echo "We only process the first and the last line of the file."
fi
done
else
exit 2
fi
done
#function to process the passed string and decode it in base64
process() {
string_to_decode = "$1"
echo "$string_to_decode = " | base64 --decode
}
Basically what I want this script to do is to loop over the arguments passed to the script and then if it's a file then call the function that decodes in base64 but just on the first and the last line of the chosen file. Unfortunately when I run it even with calling a right file it does nothing. I think it might be encountering problems with the if [ head -n 1 "$arg" ]; then part of the code. Any ideas?
EDIT: So I understood that I am actually just extracting first line over and over again without really comparing it to anything. So I tried changing the if conditional of the code to this:
first_line = $(head -n 1 "$arg")
last_line = $(tail -n 1 "$arg")
if [ first_line == line ]; then
process line
echo "Script has ran successfully!"
exit 0
#should access only last line of the file
elif [ last_line == line ]; then
process line
echo "Script has ran successfully!"
exit 0
My goal is to iterate through files for example one is looking like this:
MTAxLmdvdi51awo=
MTBkb3duaW5nc3RyZWV0Lmdvdi51awo=
MXZhbGUuZ292LnVrCg==
And to decode the first and the last line of each file.
To decode the first and last line of each file given to your script, use this:
#! /bin/bash
for file in "$#"; do
[ -f "$file" ] || exit 2
head -n1 "$file" | base64 --decode
tail -n2 "$file" | base64 --decode
done
Yea, as the others already said the true goal of the script isn't really clear. That said, i imagine every variation of what you may have wanted to do would be covered by something like:
#!/bin/bash
process() {
encoded="$1";
decoded="$( echo "${encoded}" | base64 --decode )";
echo " Value ${encoded} was decoded into ${decoded}";
}
(( $# )) || {
echo "Unfortunately you have not passed any parameter";
exit 1;
};
while (( $# )) ; do
arg="$1"; shift;
if [[ -f "${arg}" ]] ; then
echo "${arg} is a file.";
else
exit 2;
fi;
content_of_first_line="$( head -n 1 "${arg}" )";
echo "Content of first line: ${content_of_first_line}";
process "${content_of_first_line}";
content_of_last_line="$( tail -n 1 "${arg}" )";
echo "Content of last line: ${content_of_last_line}";
process "${content_of_last_line}";
line=""; linenumber=0;
while IFS="" read -r line; do
(( linenumber++ ));
echo "Iterating over all lines. Line ${linenumber}: ${line}";
process "${line}";
done < "${arg}";
done;
some additions you may find useful:
If the script is invoked with multiple filenames, lets say 4 different filenames, and the second file does not exist (but the others do),
do you really want the script to: process the first file, then notice that the second file doesnt exist, and exit at that point ? without processing the (potentially valid) third and fourth file ?
replacing the line:
exit 2;
with
continue;
would make it skip any invalid filenames, and still process valid ones that come after.
Also, within your process function, directly after the line:
decoded="$( echo "${encoded}" | base64 --decode )";
you could check if the decoding was successful before echoing whatever the resulting garbage may be if the line wasnt valid base64.
if [[ "$?" -eq 0 ]] ; then
echo " Value ${encoded} was decoded into ${decoded}";
else
echo " Garbage.";
fi;
--
To answer your followup question about the IFS/read-construct, it is a mixture of a few components:
read -r line
reads a single line from the input (-r tells it not to do any funky backslash escaping magic).
while ... ; do ... done ;
This while loop surrounds the read statement, so that we keep repeating the process of reading one line, until we run out.
< "${arg}";
This feeds the content of filename $arg into the entire block of code as input (so this becomes the source that the read statement reads from)
IFS=""
This tells the read statement to use an empty value instead of the real build-in IFS value (the internal field separator). Its generally a good idea to do this for every read statement, unless you have a usecase that requires splitting the line into multiple fields.
If instead of
IFS="" read -r line
you were to use
IFS=":" read -r username _ uid gid _ homedir shell
and read from /etc/passwd which has lines such as:
root:x:0:0:root:/root:/bin/bash
apache:x:48:48:Apache:/usr/share/httpd:/sbin/nologin
then that IFS value would allow it to load those values into the right variables (in other words, it would split on ":")
The default value for IFS is inherited from your shell, and it usually contains the space and the TAB character and maybe some other stuff. When you only read into one single variable ($line, in your case). IFS isn't applied but when you ever change a read statement and add another variable, word splitting starts taking effect and the lack of a local IFS= value will make the exact same script behave very different in different situations. As such it tends to be a good habbit to control it at all times.
The same goes for quoting your variables like "$arg" or "${arg}" , instead of $arg . It doesn't matter when ARG="hello"; but once the value starts containing spaces suddenly all sorts of things can act different; suprises are never a good thing.
I have a Bash script where I want to count how many things were done when looping through a file. The count seems to work within the loop but after it the variable seems reset.
nKeys=0
cat afile | while read -r line
do
#...do stuff
let nKeys=nKeys+1
# this will print 1,2,..., etc as expected
echo Done entry $nKeys
done
# PROBLEM: this always prints "... 0 keys"
echo Finished writing $destFile, $nKeys keys
The output of the above is something alone the lines of:
Done entry 1
Done entry 2
Finished writing /blah, 0 keys
The output I want is:
Done entry 1
Done entry 2
Finished writing /blah, 2 keys
I am not quite sure why nKeys is 0 after the loop :( I assume it's something basic but damned if I can spot it despite looking at http://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-7.html and other resources.
Fingers crossed someone else can look at it and go "well duh! You have to ..."!
In the just-released Bash 4.2, you can do this to prevent creating a subshell:
shopt -s lastpipe
Also, as you'll probably see at the link Ignacio provided, you have a Useless Use of cat.
while read -r line
do
...
done < afile
As mentioned in the accepted answer, this happens because pipes spawn separate subprocesses. To avoid this, command grouping has been the best option for me. That is, doing everything after the pipe in a subshell.
nKeys=0
cat afile |
{
while read -r line
do
#...do stuff
let nKeys=nKeys+1
# this will print 1,2,..., etc as expected
echo Done entry $nKeys
done
# PROBLEM: this always prints "... 0 keys"
echo Finished writing $destFile, $nKeys keys
}
Now it will report the value of $nKeys "correctly" (i.e. what you wish).
I arrived at the desired result in the following way without using pipes or here documents
#!/bin/sh
counter=0
string="apple orange mango egg indian"
str_len=${#string}
while [ $str_len -ne 0 ]
do
c=${string:0:1}
if [[ "$c" = [aeiou] ]]
then
echo -n "vowel : "
echo "- $c"
counter=$(( $counter + 1 ))
fi
string=${string:1}
str_len=${#string}
done
printf "The number of vowels in the given string are : %s "$counter
echo
I'm trying to write a script where I want to make a countdown of three seconds (probably with the sleep command), but every second I want to display the text "break".
After every second, I want to display a countdown next to the word "break", but the line of text should stay in place. The word "break" must stay in one place; only the number should change.
In bash, you can use a for loop, and the character \r to move the cursor back the the beginning of the line:
for ((i=3; i; --i)) ; do
printf 'break %d\r' $i
sleep 1
done
You can use ANSI terminal control sequences. Like this:
# Save the cursor position
echo -en "\033[s";
for i in {3..1} ; do
echo -n "break $i"
sleep 1
# Restore the cursor position
echo -en "\033[u";
done
# Print newline at the end
echo
If you want the last break 1 to disappear from screen then change the last line to
# Clear the line at the end
echo -en "\033[K"
I have bash script like the following:
#!/bin/bash
echo "Please enter your username";
read username;
echo "Please enter your password";
read password;
I want that when the user types the password on the terminal, it should not be displayed (or something like *******) should be displayed). How do I achieve this?
Just supply -s to your read call like so:
$ read -s PASSWORD
$ echo $PASSWORD
Update
In case you want to get fancy by outputting an * for each character they type, you can do something like this (using andreas' read -s solution):
unset password;
while IFS= read -r -s -n1 pass; do
if [[ -z $pass ]]; then
echo
break
else
echo -n '*'
password+=$pass
fi
done
Without being fancy
echo "Please enter your username";
read username;
echo "Please enter your password";
stty -echo
read password;
stty echo
you can use stty to disable echo
this solution works without bash or certain features from read
stty_orig=$(stty -g)
stty -echo
read password
stty $stty_orig
If you use this in a shell script then also set an exit handler which restores echo:
#! /bin/sh
stty_orig=$(stty -g)
trap "stty ${stty_orig}" EXIT
stty -echo
...
this is to make sure echo is restored regardless of how the script exits. otherwise the echo will stay off if the script errors out.
to turn echo back on manually type the following command
stty echo
you will have to type blindly because you do not see what you type.
i suggest to press ctrl+c first to clear anything else you might have typed before.
trivia
echo means to echo your typed input back to your screen.
this is from the time we worked on teletypewriters (that is what the tty means). a teletypewriter is like a typewriter but connected to another teletypewriter or computer. typically via telephone cable.
the workflow on a teletypewriter is roughly as follows: you type in your command (or message for the other side). then the teletypewriter will print the response from the other side.
when you work on a teletypewriter you see your input as you type. this is because the teletypewriter is also a typewriter and as such prints the characters as you press them.
when teletypewriters where replaced by screens there was no longer a typewriter which types your input. instead we had to deliberate code an "echo" function which prints your input as you type.
i do not know whether stty -echo also disabled printing on a teletypewriter.
see here for a teletypewriter in action: https://www.youtube.com/watch?v=2XLZ4Z8LpEE (first part is restoration. action starting at about 12 minutes in)
more teletypewriter restoration: https://www.youtube.com/playlist?list=PL-_93BVApb5-9eQLTCk9xx16RAGEYHH1q
Here's a variation on #SiegeX's excellent *-printing solution for bash with support for backspace added; this allows the user to correct their entry with the backspace key (delete key on a Mac), as is typically supported by password prompts:
#!/usr/bin/env bash
password=''
while IFS= read -r -s -n1 char; do
[[ -z $char ]] && { printf '\n' >/dev/tty; break; } # ENTER pressed; output \n and break.
if [[ $char == $'\x7f' ]]; then # backspace was pressed
# Remove last char from output variable.
[[ -n $password ]] && password=${password%?}
# Erase '*' to the left.
printf '\b \b' >/dev/tty
else
# Add typed char to output variable.
password+=$char
# Print '*' in its stead.
printf '*' >/dev/tty
fi
done
Note:
As for why pressing backspace records character code 0x7f: "In modern systems, the backspace key is often mapped to the delete character (0x7f in ASCII or Unicode)" https://en.wikipedia.org/wiki/Backspace
\b \b is needed to give the appearance of deleting the character to the left; just using \b moves the cursor to the left, but leaves the character intact (nondestructive backspace). By printing a space and moving back again, the character appears to have been erased (thanks, The "backspace" escape character '\b': unexpected behavior?).
In a POSIX-only shell (e.g., sh on Debian and Ubuntu, where sh is dash), use the stty -echo approach (which is suboptimal, because it prints nothing), because the read builtin will not support the -s and -n options.
A bit different from (but mostly like) #lesmana's answer
stty -echo
read password
stty echo
simply: hide echo
do your stuff
show echo
I always like to use Ansi escape characters:
echo -e "Enter your password: \x1B[8m"
echo -e "\x1B[0m"
8m makes text invisible and 0m resets text to "normal." The -e makes Ansi escapes possible.
The only caveat is that you can still copy and paste the text that is there, so you probably shouldn't use this if you really want security.
It just lets people not look at your passwords when you type them in. Just don't leave your computer on afterwards. :)
NOTE:
The above is platform independent as long as it supports Ansi escape sequences.
However, for another Unix solution, you could simply tell read to not echo the characters...
printf "password: "
let pass $(read -s)
printf "\nhey everyone, the password the user just entered is $pass\n"
Here is a variation of #SiegeX's answer which works with traditional Bourne shell (which has no support for += assignments).
password=''
while IFS= read -r -s -n1 pass; do
if [ -z "$pass" ]; then
echo
break
else
printf '*'
password="$password$pass"
fi
done
Get Username and password
Make it more clear to read but put it on a better position over the screen
#!/bin/bash
clear
echo
echo
echo
counter=0
unset username
prompt=" Enter Username:"
while IFS= read -p "$prompt" -r -s -n 1 char
do
if [[ $char == $'\0' ]]; then
break
elif [ $char == $'\x08' ] && [ $counter -gt 0 ]; then
prompt=$'\b \b'
username="${username%?}"
counter=$((counter-1))
elif [ $char == $'\x08' ] && [ $counter -lt 1 ]; then
prompt=''
continue
else
counter=$((counter+1))
prompt="$char"
username+="$char"
fi
done
echo
unset password
prompt=" Enter Password:"
while IFS= read -p "$prompt" -r -s -n 1 char
do
if [[ $char == $'\0' ]]; then
break
elif [ $char == $'\x08' ] && [ $counter -gt 0 ]; then
prompt=$'\b \b'
password="${password%?}"
counter=$((counter-1))
elif [ $char == $'\x08' ] && [ $counter -lt 1 ]; then
echo
prompt=" Enter Password:"
continue
else
counter=$((counter+1))
prompt='*'
password+="$char"
fi
done
A variation on both #SiegeX and #mklement0's excellent contributions: mask user input; handle backspacing; but only backspace for the length of what the user has input (so we're not wiping out other characters on the same line) and handle control characters, etc... This solution was found here after so much digging!
#!/bin/bash
#
# Read and echo a password, echoing responsive 'stars' for input characters
# Also handles: backspaces, deleted and ^U (kill-line) control-chars
#
unset PWORD
PWORD=
echo -n 'password: ' 1>&2
while true; do
IFS= read -r -N1 -s char
# Note a NULL will return a empty string
# Convert users key press to hexadecimal character code
code=$(printf '%02x' "'$char") # EOL (empty char) -> 00
case "$code" in
''|0a|0d) break ;; # Exit EOF, Linefeed or Return
08|7f) # backspace or delete
if [ -n "$PWORD" ]; then
PWORD="$( echo "$PWORD" | sed 's/.$//' )"
echo -n $'\b \b' 1>&2
fi
;;
15) # ^U or kill line
echo -n "$PWORD" | sed 's/./\cH \cH/g' >&2
PWORD=''
;;
[01]?) ;; # Ignore ALL other control characters
*) PWORD="$PWORD$char"
echo -n '*' 1>&2
;;
esac
done
echo
echo $PWORD