#!/usr/bin/env bash
#
# ctct - a simple console contact manager
#
# Copyright 2015 - 2018 Einhard Leichtfuß
#
# ctct is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ctct is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with ctct. If not, see .
#
typeset -a fallback_editor default_editor
typeset -a input_program output_program visual_program
## DEFAULT SETTINGS:
sysconfdir="@sysconfdir_expanded@"
user_config_file="@default_user_config_file@"
datadir="@default_datadir@"
fallback_editor=("@default_fallback_editor@")
default_editor=() # none - use $EDITOR
input_program=("@default_input_program@")
output_program=("@default_output_program@")
visual_program=("@default_visual_program@")
confirm_deletion=@default_confirm_deletion@
confirm_default_yes=@default_confirm_default_yes@
## USER SETTINGS:
test -f "$sysconfdir/ctct_config" \
&& source "$sysconfdir/ctct_config"
test -f "$user_config_file" \
&& source "$user_config_file"
## CONSTANTS:
exec_name="${0##*/}"
RET_SUCCESS=@ret_success@
RET_FAILURE=@ret_failure@
RET_BADSYNTAX=@ret_badsyntax@
RET_ERROR=@ret_error@
# Expand non matching globs to the empty string instead of themselves.
shopt -s nullglob
function print_help() {
cat << EOF
usage:
$exec_name - show contact
$exec_name -l - list all entries
$exec_name -s [...] - search by name
$exec_name -S [...] - search by data
$exec_name -e - edit / create entry
$exec_name -d [...] - delete entries
$exec_name --rename - rename entry
$exec_name --version - show version information
$exec_name -h - print this help
EOF
}
function cleanup()
{
test -v tmp_file && test -f "$tmp_file" && rm -f "$tmp_file"
test -v tmp_dir && test -d "$tmp_dir" && rm -rf "$tmp_dir"
}
trap cleanup EXIT
function main()
{
local ret
if ! test -d "$datadir" && ! mkdir "$datadir"
then
print_error "$exec_name: $datadir could not be created, quitting..."
return $RET_ERROR
fi
test $# -eq 0 && print_help && return $RET_BADSYNTAX
if [ "$1" = "-h" ] || ( [[ "--help" =~ ^"$1" ]] && [ ${#1} -ge 3 ] )
then
print_help; return $RET_SUCCESS
elif [[ "--version" =~ "$1" ]] && [ ${#1} -ge 3 ]
then
print_version; return $RET_SUCCESS
elif [ "$1" = "-l" ] || \
( [[ "--list-all" =~ ^"$1" ]] && [ ${#1} -ge 3 ] )
then
list_all; return $?
elif [ "$1" = "-s" ] || \
( [[ "--search-by-name" =~ ^"$1" ]] && [ ${#1} -ge 13 ] )
then
shift; search_by_name "Found:" "$@"; return $?
elif [ "$1" = "-S" ] || \
( [[ "--search-by-data" =~ ^"$1" ]] && [ ${#1} -ge 13 ] )
then
shift; search_by_data "Found:" "$@"; return $?
elif [ "$1" = "-e" ] || ( [[ "--edit" =~ ^"$1" ]] && [ ${#1} -ge 3 ] )
then
test $# -lt 2 && print_error "$exec_name: no entry specified." \
&& return $RET_BADSYNTAX
edit_contact "$2"; return $?
elif [ "$1" = "-d" ] || \
( [[ "--delete" =~ ^"$1" ]] && [ ${#1} -ge 3 ] )
then
test $# -lt 2 && print_error "$exec_name: no entry to be deleted." \
&& return $RET_BADSYNTAX
shift; delete_file "$@"; return $?
elif [[ "--rename" =~ ^"$1" ]] && [ ${#1} -ge 3 ]
then
( test $# -lt 2 && print_error "$exec_name: no entry specified." ) || \
( test $# -lt 3 && print_error "$exec_name: no new name specified." ) \
&& return $RET_BADSYNTAX
rename_contact "$2" "$3"; return $?
elif [[ "$1" == '--' ]]
then
shift 1
test $# -eq 0 && print_help && return $RET_BADSYNTAX
elif [[ "$1" =~ ^- ]]
then
print_error "$exec_name: unknown option '$1'"; return $RET_BADSYNTAX
fi
display_exact "$1"
ret=$?
if [ $ret -ne $RET_SUCCESS ]
then
if [ $ret -eq $RET_BADSYNTAX ]
then
return $RET_BADSYNTAX
fi
if ! search_by_name "Did you mean:" "$@"
then
print_msg "No match found."
fi
return $RET_FAILURE
fi
return $RET_SUCCESS
}
# $1: message
function print_msg()
{
printf "%s\n" "$@"
}
# $1: error message
function print_error()
{
printf "%s\n" "$@" >&2
}
# $1: contact name
function display_exact()
{
local file
check_syntax "$1" || return $RET_BADSYNTAX
file="$datadir/$(get_filename "$1")" || return $RET_FAILURE
if [ "$visual_program" = "cat" ] && [ ${#visual_program[@]} -eq 1 ]
then
"${output_program[@]}" < "$file" || return $RET_ERROR
elif [ "$output_program" = "cat" ] && [ ${#output_program[@]} -eq 1 ]
then
"${visual_program[@]}" < "$file" || return $RET_ERROR
else
"${output_program[@]}" < "$file" | "${visual_program[@]}"
[[ ${PIPESTATUS[0]} -eq 0 || ${PIPESTATUS[1]} -eq 0 ]] \
|| return $RET_ERROR
fi
return $RET_SUCCESS
}
# $1: initial success message
# ${@:2}: search-patterns
function search_by_name()
{
local found msg name bool file pattern
msg="$1"
shift 1
found=false
# disallow '.' in any pattern
# - any pattern should be either part of the first or the last name
# else the reverse order (last.first) would have to be checked as well
if [[ "$*" =~ \. ]]
then
return $RET_FAILURE
fi
for name in $(list_all || return $RET_ERROR)
do
bool=true
for pattern in "${@,,}"
do
if ! [[ "${name,,}" =~ "$pattern" ]]
then
bool=false
break
fi
done
if $bool
then
! $found && print_msg "$msg" && found=true
print_msg " $name"
fi
done
$found && return $RET_SUCCESS || return $RET_FAILURE
}
# $1: initial success message
# ${@:2}: regular expressions
function search_by_data()
{
local msg files regexp ret
msg="$1"
shift 1
# Change to datadir to be able to treat file names and contact names
# equally.
cd "$datadir"
files=( $(list_all || return $RET_ERROR) )
if [ "$output_program" = "cat" ] && [ ${#output_program[@]} -eq 1 ]
then
unset tmp_dir
else
tmp_dir="$(mktemp -d)"
for file in ${files[@]}
do
"${output_program[@]}" < $file > "${tmp_dir}/${file}" \
|| return $RET_ERROR
done
cd "$tmp_dir"
fi
for regexp in "${@,,}"
do
files=( $(grep -Eil "$regexp" ${files[@]}) )
ret=$?
[ $ret -eq 1 ] && return $RET_FAILURE
[ $ret -gt 1 ] && return $RET_ERROR
done
if test -n "$tmp_dir"
then
rm -rf "$tmp_dir" && unset tmp_dir || return $RET_ERROR
fi
print_msg "$msg"
printf " %s\n" ${files[@]}
return $RET_SUCCESS
}
# $1: [full_path]
function list_all()
{
local fmt
[[ "$1" == 'full_path' ]] && fmt='%p\n' || fmt='%f\n'
# Only list regular files with valid names.
find "$datadir" -maxdepth 1 -type f -regextype posix-extended -regex \
'.*/[[:alpha:]]+([-_][[:alpha:]]+)*(\.[[:alpha:]]+([-_][[:alpha:]]+)*)?' \
-printf "$fmt"
[ $? -eq 0 ] && return $RET_SUCCESS || return $RET_ERROR
}
# $1: contact name
function edit_contact()
{
local file new
typeset -a editor
check_syntax "$1" || return $RET_BADSYNTAX
new=false
if ! file="$datadir/$(get_filename "$1")"
then
if check_non_existance "$1"
then
file="$datadir/$1"
new=true
else
return $RET_FAILURE
fi
fi
if test -n "${default_editor[*]}"
then
editor=( "${default_editor[@]}" )
elif test -n "$EDITOR" && which "${EDITOR% *}" >/dev/null 2>&1
then
editor=( $EDITOR )
else
editor=( "${fallback_editor[@]}" )
fi
if [ "$input_program" = "cat" ] && [ ${#input_program[@]} -eq 1 ] \
&& [ "$output_program" = "cat" ] && [ ${#output_program[@]} -eq 1 ]
then
if $new
then
# vim does not save an empty file.
touch "$file" || return $RET_ERROR
fi
"${editor[@]}" "$file" || return $RET_ERROR
else
tmp_file="$(mktemp || return $RET_ERROR)"
chmod 600 "$tmp_file" || return $RET_ERROR
if ! $new
then
"${output_program[@]}" < "$file" > "$tmp_file" || return $RET_ERROR
fi
"${editor[@]}" "$tmp_file" || return $RET_ERROR
"${input_program[@]}" < "$tmp_file" > "$file" || return $RET_ERROR
rm -f "$tmp_file" && unset tmp_file || return $RET_ERROR
fi
return $RET_SUCCESS
}
# $1: old name
# $2: new name
function rename_contact()
{
local file
check_syntax "$1" || return $RET_BADSYNTAX
check_syntax "$2" || return $RET_BADSYNTAX
if file="$datadir/$(get_filename "$1")"
then
if get_filename "$2" > /dev/null
then
print_error "$exec_name: Entry \"$2\" does already exist."
return $RET_FAILURE
elif ! check_non_existance "$2"
then
return $RET_FAILURE
else
mv "$file" "$datadir/$2" || return $RET_ERROR
fi
else
print_error "$exec_name: Entry \"$1\" does not exist."
return $RET_FAILURE
fi
}
# $@: names of contacts to be deleted
function delete_file()
{
local name file str
typeset -i i=0
typeset -a files
# check for existance of all to be deleted files
for name in "$@"
do
check_syntax "$@" || return $RET_BADSYNTAX
if file="$(get_filename "$name")"
then
files[i++]="$file"
else
print_error "$exec_name: Contact \"$1\" does not exist, aborting..."
return $RET_FAILURE
fi
done
if $confirm_deletion
then
# Prepare confirmation.
if test $# -eq 1; then
str="Do you really want to delete the entry ${files[0]}"
else
print_msg "Entries to be deleted:"
for file in "${files[@]}"; do
print_msg " $file"
done
str="Are you sure to delete all these entries"
fi
# Confirm and delete.
if confirm "$str"; then
for file in "${files[@]}"; do
rm "$datadir/$file" || return $RET_ERROR
done
fi
else # simply delete
for file in "${files[@]}"; do
rm "$datadir/$file" || return $RET_ERROR
done
fi
return $RET_SUCCESS
}
# For dot separated names (both parts interchangeable), get the permutation
# on disk (e.g. last.first -> first.last).
# $1: contact name
function get_filename()
{
local rev
if test -f "$datadir/$1"; then
printf "%s\n" "$1"
elif [[ "$1" =~ \. ]]
then
rev=( ${1/\./ } )
rev="${rev[1]}.${rev[0]}"
if test -f "$datadir/$rev"; then
printf "%s\n" "$rev"
else
return $RET_FAILURE
fi
else
return $RET_FAILURE
fi
return $RET_SUCCESS
}
# $1: confirmation string
function confirm()
{
local str="$1 " var
if $confirm_default_yes; then
str+="[Y/n]? "
else
str+="[y/N]? "
fi
printf "%s" "$str"
read var
if test -z "$var"; then
$confirm_default_yes && return $RET_SUCCESS || return $RET_FAILURE
fi
var="${var,,}"
if test "$var" = "y" -o "$var" = "yes"; then
return $RET_SUCCESS
else
return $RET_FAILURE
fi
}
# $1: filename to be checked
# $2: [silent]
function check_syntax()
{
if [[ "$1" =~ \
^[[:alpha:]]+([-_][[:alpha:]]+)*(\.[[:alpha:]]+([-_][[:alpha:]]+)*)?$ ]]
then
return $RET_SUCCESS
else
if [[ "$2" != 'silent' ]]
then
print_error "$exec_name: invalid name \"$1\"" \
" An entry name may only contain letters," \
" '-' and '_' as separators and exactly one dot ('.')"
fi
return $RET_FAILURE
fi
}
# Only use this when $1 is assured not to be a regular file.
# $1: filename (relative to datadir)
function check_non_existance()
{
if test -e "$datadir/$1"
then
print_error "$exec_name: file \"$1\" exists in $datadir," \
"however is not a regular file."
return $RET_FAILURE
else
return $RET_SUCCESS
fi
}
function print_version() {
cat << EOF
ctct - Version @PACKAGE_VERSION@
Copyright 2015 - 2018 Einhard Leichtfuß.
License AGPLv3+: GNU Affero General Public License, version 3 or later
-
This is free software: You are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
EOF
}
main "$@"
# vim: ts=2 sw=2