#!/usr/bin/env bash # # ctct - a simple console contact manager # # Copyright 2015 - 2019 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 $VISUAL / $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: typeset -r exec_name="${0##*/}" typeset -r RET_SUCCESS=@ret_success@ typeset -r RET_FAILURE=@ret_failure@ typeset -r RET_BADSYNTAX=@ret_badsyntax@ typeset -r 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]} -ne 0 || ${PIPESTATUS[1]} -ne 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" -mindepth 1 -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 "$VISUAL" then editor=( $VISUAL ) elif test -n "$EDITOR" 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 - 2019 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 "$@" # vi: ts=2 sw=2