#! /bin/bash
# bash-notes © 2023 by danix is licensed under CC BY-NC 4.0.
# To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/4.0/
# to debug the script run it like:
# DEBUG=true notes.sh ...
# and check /tmp/debug_bash-notes.log
if [[ $DEBUG == true ]]; then
exec 5> /tmp/debug_bash-notes.log
BASH_XTRACEFD="5"
PS4='$LINENO: '
set -x
fi
PID=$$
BASENAME=$( basename "$0" )
NOW=$(date +%s)
VERSION="0.4git"
DBVERSION=${VERSION}_${NOW}
set_defaults() {
# Binaries to use
JQ=${JQ:-/usr/bin/jq}
EDITOR=${EDITOR:-/usr/bin/vim}
TERMINAL=${TERMINAL:-/usr/bin/alacritty}
# Git binary only used if $USEGIT is true - See below
GIT=${GIT:-/usr/bin/git}
# add options for your terminal. Remember to add the last option to execute
# your editor program, otherwise the script will fail.
# see example in the addnote function
TERM_OPTS="--class notes --title notes -e "
# Setting PAGER here overrides whatever is set in your default shell
# comment this option to use your default pager if set in your shell.
PAGER=${PAGER:-/usr/bin/more}
# set this to true to have output in plain text
# or use the -p option on the command line before every other option
PLAIN=false
# base directory for program files
BASEDIR=${BASEDIR:-~/.local/share/bash-notes}
# notes database in json format
DB=${BASEDIR}/db.json
# directory containing the actual notes
NOTESDIR=${BASEDIR}/notes
### GIT SUPPORT
# If you want to store your notes in a git repository set this to true
USEGIT=true
# Address of your remote repository. Without this GIT will refuse to work
GITREMOTE=${GITREMOTE:-""}
# How long should we wait (in seconds) between sync on the git remote. Default 3600 (1 hour)
GITSYNCDELAY=${GITSYNCDELAY:-"3600"}
# The name of this client. If left empty, defaults to the output of hostname
GITCLIENT=${GITCLIENT:-""}
} # end set_defaults, do not change this line.
set_defaults
# Do not edit below this point
RCFILE=${RCFILE:-~/.config/bash-notes.rc}
TMPDB=/tmp/db.json
if [ ! -x "$JQ" ]; then
echo "jq not found in your PATH"
echo "install jq to continue"
exit 1
fi
# IMPORT USER DEFINED OPTIONS IF ANY
if [[ -f $RCFILE ]]; then
# shellcheck disable=SC1090
source "$RCFILE"
fi
# We prevent the program from running more than one instance:
PIDFILE=/var/tmp/$(basename "$0" .sh).pid
# Make sure the PID file is removed when we kill the process
trap 'rm -f $PIDFILE; exit 1' TERM INT
if [[ -r $PIDFILE ]]; then
# PIDFILE exists, so I guess there's already an instance running
# let's kill it and run again
# shellcheck disable=SC2046,SC2086
kill -s 15 $(cat $PIDFILE) > /dev/null 2>&1
# should already be deleted by trap, but just to be sure
rm "$PIDFILE"
fi
# create PIDFILE
echo $PID > "$PIDFILE"
# Export config to file
export_config() {
if [ -r ${RCFILE} ]; then
echo "Backing up current '${RCFILE}'...."
mv -f ${RCFILE} ${RCFILE}.$(date +%Y%m%d_%H%M)
fi
echo "Writing '${RCFILE}'...."
sed -n '/^set_defaults() {/,/^} # end set_defaults, do not change this line./p' $0 \
| grep -v set_defaults \
| sed -e 's/^\([^=]*\)=\${\1:-\([^}]*\)}/\1=\2/' \
> ${RCFILE}
if [ -r ${RCFILE} ]; then
echo "Taking no further action."
exit 0
else
echo "Could not write '${RCFILE}'...!"
exit 1
fi
}
# we should expand on this function to add a sample note and explain a little bit
# how the program works.
firstrun() {
[ -f $RCFILE ] && RC=$RCFILE || RC="none"
clear
echo "${BASENAME} configuration:
base directory: ${BASEDIR}/
notes archive: ${NOTESDIR}/
notes database: ${DB}
rc file: $RC
text editor: ${EDITOR}
terminal: ${TERMINAL}
jq executable: ${JQ}
"
echo "Now I'll create the needed files and directories."
read -r -p "Do you wish to continue? (y/N) " ANSWER
case $ANSWER in
y|Y )
mkdir -p $NOTESDIR
cat << __EOL__ > ${DB}
{
"params": {
"version": "${VERSION}",
"dbversion": "${DBVERSION}"
},
"git": {
"lastpull": ""
},
"notes": []
}
__EOL__
echo; echo "All done, you can now write your first note."
;;
* )
echo "No changes made. Exiting"
exit
;;
esac
}
# check for notes dir existance and create it in case it doesn't exists
if [[ ! -d $NOTESDIR ]]; then
# we don't have a directory. FIRST RUN?
firstrun
fi
# check if input is a number, returns false or the number itself
check_noteID() {
IN=$1
case $IN in
''|*[!0-9]*)
false
;;
*)
echo "$IN"
;;
esac
}
helptext() {
echo "Usage:"
echo " $0 [PARAMS] [note ID]..."
echo ""
echo "${BASENAME} parameters are:"
echo -e " -h | --help\t\t\t: This help text"
echo -e " -p | --plain\t\t\t: Output is in plain text"
echo -e "\t\t\t\t (without this option the output is formatted)"
echo -e "\t\t\t\t (this option must precede all others)"
echo -e " -l | --list\t\t\t: List existing notes"
echo -e " -a | --add=[\"
\"]\t: Add new note"
echo -e " -e | --edit=[]\t\t: Edit note"
echo -e " -d | --delete=[ | all] : Delete single note or all notes at once"
echo -e " -s | --show=[]\t\t: Display note using your favourite PAGER"
echo -e " -r | --restore=[]\t: Restore a previous backup from dir"
echo -e " -v | --version\t\t: Print version"
echo -e " --userconf\t\t\t: Export User config file"
echo -e " --backup []\t\t: Backup your data in your destination folder"
echo -e " --showconf\t\t\t: Display running options"
echo -e " --sync\t\t\t: Sync notes to git repository"
echo ""
echo -e "if a non option is passed and is a valid note ID, the note will be displayed."
}
configtext() {
[ $USEGIT ] && GITUSE="enabled" || GITUSE="disabled"
if [ -n $GITCLIENT ]; then
CLIENTGIT="$( hostname )"
else
CLIENTGIT="$GITCLIENT"
fi
clear
echo -e "${BASENAME} configuration is:"
echo -e "\tbase directory: ${BASEDIR}/"
echo -e "\tnotes archive: ${NOTESDIR}/"
echo -e "\tnotes database: ${DB}"
echo -e "\trc file: $RCFILE"
echo -e "\tdebug file: /tmp/debug_bash-note.log"
echo
echo -e "\ttext editor: ${EDITOR}"
echo -e "\tterminal: ${TERMINAL}"
echo -e "\tjq executable: ${JQ}"
echo -e "\tPAGER: ${PAGER}"
echo
echo -e "\tGIT: ${GITUSE} - ${GIT}"
echo -e "\tGIT remote: ${GITREMOTE}"
echo -e "\tGIT sync delay: ${GITSYNCDELAY}"
echo -e "\tGIT client name: ${CLIENTGIT}"
}
# this function returns a random 2 words title
random_title() {
# Constants
X=0
DICT=/usr/share/dict/words
OUTPUT=""
# total number of non-random words available
COUNT=$(cat $DICT | wc -l)
# while loop to generate random words
while [ "$X" -lt 2 ]
do
RAND=$(od -N3 -An -i /dev/urandom | awk -v f=0 -v r="$COUNT" '{printf "%i\n", f + r * $1 / 16777216}')
OUTPUT+="$(sed `echo $RAND`"q;d" $DICT)"
(("X = X + 1"))
[[ $X -eq 1 ]] && OUTPUT+=" "
done
echo $OUTPUT
}
# check if GITCLIENT has been set or set it to the output of hostname
if [ -z "$GITCLIENT" ]; then
GITCLIENT=$( hostname )
fi
# returns true if the argument provided directory is a git repository
is_git_repo() {
DIR=$1
if [[ -d $DIR ]]; then
cd $DIR
if git rev-parse 2>/dev/null; then
true
else
false
fi
fi
}
# sync local repository to remote
# accepts -f parameter to skip last sync check
gitsync() {
FORCE=$1
if [[ $USEGIT && -n $GITREMOTE ]]; then
[ $PLAIN == false ] && echo "Syncing notes with git on remote \"$GITREMOTE\""
NOWSYNC=$(date +%s)
if [[ $FORCE == "-f" ]]; then
$JQ --arg n "$NOWSYNC" '.git["lastpull"] = $n' "$DB" > $TMPDB
mv $TMPDB $DB
cd $BASEDIR
[ $PLAIN == false ] && $GIT pull || $GIT pull -q
else
# LASTSYNC is the last time we synced to the remote, or 0 if it's the first time.
LASTSYNC=$($JQ -r '.git["lastpull"] // 0' "$DB")
SYNCDIFF=$(( ${NOWSYNC} - ${LASTSYNC} ))
if (( $SYNCDIFF > $GITSYNCDELAY )); then
#more than our delay time has passed. We can sync again.
$JQ --arg n "$NOWSYNC" '.git["lastpull"] = $n' "$DB" > $TMPDB
mv $TMPDB $DB
cd $BASEDIR
[ $PLAIN == false ] && $GIT pull || $GIT pull -q
else
# Last synced less than $GITSYNCDELAY seconds ago. We shall wait
[ $PLAIN == false ] && echo "Last synced less than $GITSYNCDELAY seconds ago. We shall wait"
fi
fi
else
# no git, so we just keep going
true
fi
}
# add note to git and push it to remote
gitadd() {
if [[ $USEGIT && -n $GITREMOTE ]]; then
[ $PLAIN == false ] && echo "Adding note to remote \"$GITREMOTE\""
cd $BASEDIR
$GIT add .
$GIT commit -m "$(basename $0) - adding note from ${GITCLIENT}"
$GIT push origin master
else
# no git, so we just keep going
true
fi
}
# edited note added to git and pushed it to remote
gitedit() {
if [[ $USEGIT && -n $GITREMOTE ]]; then
[ $PLAIN == false ] && echo "Editing note on remote \"$GITREMOTE\""
cd $BASEDIR
$GIT add .
$GIT commit -m "$(basename $0) - ${GITCLIENT} note edited."
$GIT push origin master
else
# no git, so we just keep going
true
fi
}
# add note to git and push it to remote
gitremove() {
NOTE=$1
FILE=$2
if [[ $USEGIT && -n $GITREMOTE ]]; then
[ $PLAIN == false ] && echo "Deleting notes from remote \"$GITREMOTE\""
if [ "all" == $NOTE ];then
echo "Deleting all notes"
cd $BASEDIR
$GIT rm notes/*
$GIT commit -m "$(basename $0) - ${GITCLIENT} removing all notes."
$GIT push origin master
else
local OK=$(check_noteID "$NOTE")
if [[ "$OK" ]]; then
echo "Deleting note ID ${NOTE}"
cd $BASEDIR
$GIT rm notes/${FILE}
$GIT add .
$GIT commit -m "$(basename $0) - ${GITCLIENT} removing note ID ${NOTE}."
$GIT push origin master
fi
fi
else
# no git, so we just keep going
true
fi
}
# check for USEGIT and subsequent variables
if [[ $USEGIT && -n $GITREMOTE ]]; then
# GIT is a go.
if ! is_git_repo $BASEDIR; then
# initializing git repository
cd $BASEDIR
$GIT init
echo "adding all files to git"
$GIT add .
$GIT commit -m "$(basename $0) - initial commit from ${GITCLIENT}"
$GIT remote add origin $GITREMOTE
$GIT push -u origin master
fi
elif [[ $USEGIT && -z $GITREMOTE ]]; then
echo "GITREMOTE variable not set. reverting USEGIT to false"
USEGIT=false
fi
addnote() {
# attempt syncing before adding a note
gitsync -f
# remove eventually existing temp DB file
if [[ -f $TMPDB ]]; then
rm $TMPDB
fi
RTITLE=$(random_title)
[[ -z "$1" ]] && NOTETITLE="$RTITLE" || NOTETITLE="$1"
echo "adding new note - \"$NOTETITLE\""
# shellcheck disable=SC2086
LASTID=$($JQ '.notes[-1].id // 0 | tonumber' $DB)
# [ "" == $LASTID ] && LASTID=0
NOTEID=$(( LASTID + 1 ))
# shellcheck disable=SC2086
touch ${NOTESDIR}/${NOW}
# shellcheck disable=SC2016
$JQ --arg i "$NOTEID" --arg t "$NOTETITLE" --arg f "$NOW" '.notes += [{"id": $i, "title": $t, "file": $f}]' "$DB" > $TMPDB
# shellcheck disable=SC2086
mv $TMPDB $DB
# example for alacritty:
# alacritty --class notes --title notes -e /usr/bin/vim ...
# shellcheck disable=SC2086,SC2091
$(${TERMINAL} ${TERM_OPTS} ${EDITOR} ${NOTESDIR}/${NOW})
# add note to git and push to remote
gitadd
}
backup_data() {
BACKUPDIR="$1"
echo "backing up data in $BACKUPDIR"
if [ -d $BACKUPDIR ]; then
if [ $(/bin/ls -A $BACKUPDIR) ]; then
echo "$BACKUPDIR is not empty. Cannot continue"
exit
else
echo "$BACKUPDIR is ok. Continuing!"
fi
else
# BACKUPDIR doesn't exists
echo "$BACKUPDIR doesn't exists"
read -r -p "Do you want me to create it for you? (y/N) " ANSWER
case $ANSWER in
y|Y )
mkdir -p $BACKUPDIR
;;
* )
echo "No changes made. Exiting"
exit
;;
esac
fi
# ok, we have a backup directory
if [ -r $RCFILE ]; then
BCKUP_COMM=$(rsync -avz --progress ${RCFILE}* ${BASEDIR}/ ${BACKUPDIR})
else
BCKUP_COMM=$(rsync -avz --progress ${BASEDIR}/ ${BACKUPDIR})
fi
# run the command
if [ "$BCKUP_COMM" ]; then
echo -e "All files backed up."
echo -e "BACKUP directory:\t$BACKUPDIR"
tree $BACKUPDIR | $PAGER
echo; echo "BACKUP COMPLETED"
fi
}
backup_restore() {
BACKUPDIR="$1"
echo "restoring backup from $BACKUPDIR"
echo "This will overwrite all your notes and configurations with the backup."
read -r -p "Do you want to continue? (y/N) " ANSWER
case $ANSWER in
y|Y )
# restoring rc file
BACKUPRC=$(basename $RCFILE)
if [ -r ${BACKUPDIR}/${BACKUPRC} ]; then
if [ -r ${RCFILE} ]; then
echo "Backing up current '${RCFILE}'...."
mv -f ${RCFILE} ${RCFILE}.$(date +%Y%m%d_%H%M)
fi
cp --verbose ${BACKUPDIR}/${BACKUPRC} $RCFILE
fi
# restoring notes directory
if [ -d $BACKUPDIR/notes ]; then
if [ $(/bin/ls -A $NOTESDIR) ]; then
rm --verbose $NOTESDIR/*
fi
cp -r --verbose $BACKUPDIR/notes $BASEDIR
fi
# restoring database
BACKUPDB=$(basename $DB)
if [ -f ${BACKUPDIR}/${BACKUPDB} ]; then
if [ -r ${DB} ]; then
echo "Backing up current '${DB}'...."
mv -f ${DB} ${DB}.$(date +%Y%m%d_%H%M)
fi
cp --verbose ${BACKUPDIR}/${BACKUPDB} $DB
fi
# restoring git repo subdirectory
if [ -d $BACKUPDIR/.git ]; then
if [ /bin/ls -A ${BASEDIR}/.git ]; then
rm -rf ${BASEDIR}/.git
fi
cp -r --verbose ${BACKUPDIR}/.git ${BASEDIR}/
fi
;;
* )
echo "No changes made. Exiting"
exit
;;
esac
}
editnote() {
NOTE=$1
# shellcheck disable=SC2155
local OK=$(check_noteID "$NOTE")
if [ ! "$OK" ]; then
echo "invalid note \"$NOTE\""
echo "Use the note ID that you can fetch after listing your notes"
exit 1
fi
# shellcheck disable=SC2016,SC2086
TITLE=$($JQ --arg i $OK '.notes[] | select(.id == $i) | .title' $DB)
# shellcheck disable=SC2016,SC2086
FILE=$($JQ -r --arg i $OK '.notes[] | select(.id == $i) | .file' $DB)
if [ "$TITLE" ]; then
echo "editing note $TITLE"
# shellcheck disable=SC2086,SC2091
$(${TERMINAL} ${TERM_OPTS} ${EDITOR} ${NOTESDIR}/${FILE})
gitedit
else
echo "note not found"
exit 1
fi
}
listnotes() {
# attempt syncing before listing all notes
gitsync
# [ $PLAIN == true ] && echo "output is plain text" || echo "output is colored"
if [[ $(ls -A "$NOTESDIR") ]]; then
if [ $PLAIN == false ]; then
echo "listing all notes"
echo ""
fi
[ $PLAIN == false ] && echo "[ID] [TITLE] [CREATED]"
for i in "${NOTESDIR}"/*; do
# shellcheck disable=SC2155
local fname=$(basename $i)
DATE=$(date -d @${fname} +"%d/%m/%Y %R %z%Z")
# shellcheck disable=SC2016,SC2086
TITLE=$($JQ -r --arg z $(basename $i) '.notes[] | select(.file == $z) | .title' $DB)
# shellcheck disable=SC2016,SC2086
ID=$($JQ -r --arg z $(basename $i) '.notes[] | select(.file == $z) | .id' $DB)
[ $PLAIN == false ] && echo "[${ID}] ${TITLE} ${DATE}" || echo "${ID} - ${TITLE} - ${DATE}"
done
else
echo "no notes yet. You can add your first one with: ${BASENAME} -a \"your note title\""
fi
}
rmnote() {
# remove eventually existing temp DB file
if [[ -f $TMPDB ]]; then
rm $TMPDB
fi
NOTE=$1
if [ "all" == "$NOTE" ]; then
echo "You're going to delete all notes."
read -r -p "Do you wish to continue? (y/N) " ANSWER
case $ANSWER in
y|Y )
# shellcheck disable=SC2086
$JQ 'del(.notes[])' $DB > $TMPDB
# shellcheck disable=SC2086
mv $TMPDB $DB
# shellcheck disable=SC2086
rm $NOTESDIR/*
gitremove "all"
echo "Deleted all notes"
;;
* )
echo "Aborting, no notes were deleted."
exit 1
;;
esac
else
# shellcheck disable=SC2155
local OK=$(check_noteID "$NOTE")
if [ ! "$OK" ]; then
echo "invalid note \"$NOTE\""
echo "Use the note ID that you can fetch after listing your notes"
sleep 1
exit 1
fi
# shellcheck disable=SC2016,SC2086
TITLE=$($JQ --arg i $OK '.notes[] | select(.id == $i) | .title' $DB)
# shellcheck disable=SC2016,SC2086
FILE=$($JQ -r --arg i $OK '.notes[] | select(.id == $i) | .file' $DB)
if [ "$TITLE" ]; then
# shellcheck disable=SC2016,SC2086
$JQ -r --arg i $OK 'del(.notes[] | select(.id == $i))' $DB > $TMPDB
# shellcheck disable=SC2086
mv $TMPDB $DB
rm $NOTESDIR/$FILE
gitremove $OK $FILE
echo "Deleted note $TITLE"
sleep 1
exit
else
echo "note not found"
sleep 1
exit 1
fi
fi
}
shownote() {
NOTE=$1
# shellcheck disable=SC2155
local OK=$(check_noteID "$NOTE")
if [ ! "$OK" ]; then
echo "invalid note \"$NOTE\""
echo "Use the note ID that you can fetch after listing your notes"
exit 1
fi
FILE=$($JQ -r --arg i $OK '.notes[] | select(.id == $i) | .file' $DB)
if [ "$FILE" ]; then
$PAGER ${NOTESDIR}/${FILE}
fi
}
# shellcheck disable=SC2006
GOPT=$(getopt -o hvplr:a:e:d:s: --long help,version,list,plain,userconf,showconf,sync,restore:,backup:,add:,edit:,delete:,show: -n 'bash-notes' -- "$@")
# shellcheck disable=SC2181
if [ $? != 0 ] ; then helptext >&2 ; exit 1 ; fi
# Note the quotes around `$GOPT': they are essential!
eval set -- "$GOPT"
unset GOPT
while true; do
case "$1" in
-h | --help )
helptext
exit
;;
-v | --version )
echo $BASENAME v${VERSION}
exit
;;
-p | --plain )
PLAIN=true
shift
;;
-l | --list )
listnotes
exit
;;
-a | --add )
shift 2
addnote "$TITLE"
exit
;;
-e | --edit )
shift 2
editnote "$NOTE"
exit
;;
-d | --delete )
shift 2
rmnote "$NOTE"
exit
;;
-s | --show )
shift 2
shownote "$NOTE"
exit
;;
-r | --restore )
shift 2
backup_restore $RDIR
exit
;;
--sync )
# I'm forcing it because if you run it manually, chances are that you need to.
gitsync -f
shift
exit
;;
--userconf )
export_config
# shellcheck disable=SC2317
echo "config exported to \"$RCFILE\""
# shellcheck disable=SC2317
exit
;;
--showconf )
configtext
exit
;;
--backup )
shift 2
backup_data $BDIR
exit
;;
-- )
shift
break
;;
* )
break
;;
esac
done
for arg; do
if [ $(check_noteID $arg) ]; then
shownote $arg
else
helptext
exit
fi
done