diff --git a/notestorm b/notestorm index 465deee..2c27965 100755 --- a/notestorm +++ b/notestorm @@ -32,6 +32,55 @@ unset modified # Functions section +check_dependencies() { + # Check for required dependencies + local missing_deps=() + + # Critical dependencies + if ! command -v dialog &> /dev/null; then + missing_deps+=("dialog") + fi + + if ! command -v markdown &> /dev/null; then + missing_deps+=("markdown (discount package)") + fi + + # Check for at least one pager + if ! command -v w3m &> /dev/null && \ + ! command -v elinks &> /dev/null && \ + ! command -v lynx &> /dev/null; then + missing_deps+=("w3m, elinks, or lynx") + fi + + # Optional but recommended + if ! command -v gpg &> /dev/null; then + echo "Warning: GPG not found. Note encryption will not be available." >&2 + fi + + if ! command -v git &> /dev/null; then + echo "Warning: Git not found. Git backup functionality will not be available." >&2 + fi + + if ! command -v zip &> /dev/null; then + echo "Warning: Zip not found. Zip backup functionality will not be available." >&2 + fi + + if ! command -v unzip &> /dev/null; then + echo "Warning: Unzip not found. Zip restore functionality will not be available." >&2 + fi + + # Report missing critical dependencies + if [[ ${#missing_deps[@]} -gt 0 ]]; then + echo "Error: Missing required dependencies:" >&2 + for dep in "${missing_deps[@]}"; do + echo " - $dep" >&2 + done + echo >&2 + echo "Please install the missing dependencies and try again." >&2 + exit 1 + fi +} + cleanup() { if [[ ${#modified} -lt 1 ]]; then exit 0 @@ -139,12 +188,36 @@ add_note() { decrypt_note() { #returns the note to its unencrypted state. - if gpg -o "${1%.gpg}" -d "$1" ; then + local output_file="${1%.gpg}" + + # Check if output file already exists + if [[ -f "$output_file" ]]; then + if [[ "$(yesno "$(gettext "File already exists. Overwrite?")")" != "Yes" ]]; then + return + fi + fi + + # Attempt decryption with better error handling + local gpg_error + if gpg_error=$(gpg -o "$output_file" -d "$1" 2>&1); then rm -f "$1" - infobox "$(gettext "Note decrypted and saved as: ") \"${1%.gpg}\"" + infobox "$(gettext "Note decrypted and saved as: ") \"$output_file\"" + modified="note decrypted" return fi - infobox "$(gettext "Decryption failed.")" + + # Provide more specific error messages + case "$gpg_error" in + *"Bad passphrase"*|*"bad passphrase"*) + infobox "$(gettext "Decryption failed: Incorrect passphrase.")" + ;; + *"No such file"*) + infobox "$(gettext "Decryption failed: File not found.")" + ;; + *) + infobox "$(gettext "Decryption failed: ") $gpg_error" + ;; + esac } delete_note() { @@ -178,9 +251,16 @@ display_note() { exit 0 fi if [[ "${1##*.}" == "gpg" ]]; then - gpg -d "$1" | markdown | eval "$pager" + # Handle GPG decryption errors gracefully + if ! gpg -d "$1" 2>/dev/null | markdown | "${pager[@]}"; then + infobox "$(gettext "Failed to decrypt or display note. Check your passphrase and try again.")" + return 1 + fi else - markdown "$1" | eval "$pager" + if ! markdown "$1" | "${pager[@]}"; then + infobox "$(gettext "Failed to display note. Check if file exists and markdown is installed.")" + return 1 + fi fi } @@ -201,20 +281,42 @@ encrypt_note() { if [[ $# -ne 1 ]]; then return fi + + # Validate input file exists + if [[ ! -f "$1" ]]; then + infobox "$(gettext "Error: Source file does not exist.")" + return + fi + # Get a human readable name for the new note. local noteName="$(inputbox "$(gettext "Please enter a descriptive name for the encrypted note:")")" if [[ -z "$noteName" ]]; then # No name supplied, so return return fi + + # Sanitize filename - remove dangerous characters + noteName="${noteName//[^a-zA-Z0-9._-]/_}" + local output_file="${1%/*}/${noteName%.md*}.md.gpg" + + # Check if output file already exists + if [[ -f "$output_file" ]]; then + if [[ "$(yesno "$(gettext "Encrypted file already exists. Overwrite?")")" != "Yes" ]]; then + return + fi + fi + # Encrypt the given file and if successful, remove the original. - if gpg -o "${1%/*}/${noteName%.md*}.md.gpg" -c "$1" ; then + local gpg_error + if gpg_error=$(gpg -o "$output_file" -c "$1" 2>&1); then rm -f "$1" - infobox "$(gettext "Note encrypted.")" + infobox "$(gettext "Note encrypted as: ") \"$output_file\"" modified="encrypted note added" return fi - infobox "$(gettext "Encryption failed.")" + + # Provide specific error message + infobox "$(gettext "Encryption failed: ") $gpg_error" } list_notes() { @@ -234,11 +336,27 @@ initialize_git() { fi message="$(gettext "Please enter the url to your git repository for notes.")" local gitURL="$(inputbox "$message")" + + # Enhanced URL validation if [[ ${#gitURL} -lt 3 ]]; then message="$(gettext "Invalid URL detected, exiting.")" infobox "$message" exit 1 fi + + # Check for valid git URL patterns and sanitize + if [[ ! "$gitURL" =~ ^(https?://|git@|ssh://|file://|/) ]]; then + message="$(gettext "Invalid git URL format. Must start with https://, git@, ssh://, file://, or / for local paths.")" + infobox "$message" + exit 1 + fi + + # Prevent command injection in URL + if [[ "$gitURL" == *";"* ]] || [[ "$gitURL" == *'`'* ]] || [[ "$gitURL" == *'$'* ]] || [[ "$gitURL" == *'('* ]] || [[ "$gitURL" == *')'* ]]; then + message="$(gettext "Invalid characters detected in URL.")" + infobox "$message" + exit 1 + fi { git -C "$xdgPath/notestorm/notes" init git -C "$xdgPath/notestorm/notes" remote add origin "$gitURL" git -C "$xdgPath/notestorm/notes" add -A @@ -262,29 +380,204 @@ original_note() { fi } +backup_notes() { + # Create a zip backup of all notes with optional password + # Usage: backup_notes [password] + local password="$1" + local backup_file="$HOME/Documents/notestorm-$(date +%Y-%m-%d).zip" + + + # Check if zip command exists + if ! command -v zip &> /dev/null; then + infobox "$(gettext "Error: zip command not found. Please install zip package.")" + return 1 + fi + + # Create Documents directory if it doesn't exist + mkdir -p "$HOME/Documents" + + # Check if backup file already exists + if [[ -f "$backup_file" ]]; then + if [[ "$(yesno "$(gettext "Backup file already exists. Overwrite?")")" != "Yes" ]]; then + return 0 + fi + fi + + # Create backup with or without password + local zip_cmd="zip -r" + if [[ -n "$password" ]]; then + zip_cmd="$zip_cmd -P \"$password\"" + fi + + # Change to parent directory to avoid including full path in zip + local current_dir="$(pwd)" + cd "$xdgPath" + + if [[ -n "$password" ]]; then + zip -r -P "$password" "$backup_file" "notestorm/notes" 2>/dev/null + else + zip -r "$backup_file" "notestorm/notes" 2>/dev/null + fi + + local zip_result=$? + cd "$current_dir" + + if [[ $zip_result -eq 0 ]]; then + infobox "$(gettext "Backup created successfully:") $backup_file" + return 0 + else + infobox "$(gettext "Backup failed.")" + return 1 + fi +} + +restore_notes() { + # Restore notes from a zip backup + # Usage: restore_notes [zip_file] + local zip_file="$1" + + # Check if unzip command exists + if ! command -v unzip &> /dev/null; then + infobox "$(gettext "Error: unzip command not found. Please install unzip package.")" + return 1 + fi + + # If no file specified, let user choose + if [[ -z "$zip_file" ]]; then + zip_file="$(inputbox "$(gettext "Enter path to backup zip file:")" "$HOME/Documents/")" + if [[ -z "$zip_file" ]]; then + return 0 + fi + fi + + # Check if zip file exists + if [[ ! -f "$zip_file" ]]; then + infobox "$(gettext "Error: Backup file not found:") $zip_file" + return 1 + fi + + # Warn about overwriting existing notes + if [[ -d "$xdgPath/notestorm/notes" ]] && [[ -n "$(ls -A "$xdgPath/notestorm/notes" 2>/dev/null)" ]]; then + if [[ "$(yesno "$(gettext "This will overwrite existing notes. Continue?")")" != "Yes" ]]; then + return 0 + fi + + # Backup existing notes first + local backup_existing="$xdgPath/notestorm/notes-backup-$(date +%Y-%m-%d-%H%M%S)" + mv "$xdgPath/notestorm/notes" "$backup_existing" + infobox "$(gettext "Existing notes backed up to:") $backup_existing" + fi + + # Create notes directory + mkdir -p "$xdgPath/notestorm" + + # Extract the backup + if unzip -o "$zip_file" -d "$xdgPath" 2>/dev/null; then + infobox "$(gettext "Notes restored successfully from:") $zip_file" + return 0 + else + infobox "$(gettext "Restore failed. Check if the file is a valid zip or if password is required.")" + return 1 + fi +} + +claude_note() { + # Create a note using Claude Code with the provided prompt + # Usage: claude_note "prompt text" + local prompt="$1" + + # Check if prompt is provided + if [[ -z "$prompt" ]]; then + gettext "Error: No prompt provided." + echo + exit 1 + fi + + # Check if claude command is available + if ! command -v claude &> /dev/null; then + echo "$(gettext "Error: claude command not found. Please install Claude Code.")" + echo + echo "$(gettext "Installation instructions:")" + echo "$(gettext "- Arch Linux: Install from AUR with 'yay -S claude-code' or 'paru -S claude-code'")" + echo "$(gettext "- Debian/Ubuntu: Install Node.js then run 'npm install -g @anthropic-ai/claude-code'")" + echo "$(gettext "- Generic: See https://docs.anthropic.com/en/docs/claude-code/quickstart")" + echo + exit 1 + fi + + # Generate note name based on timestamp + local noteName=$(date '+%s') + while [ -f "${xdgPath}/notestorm/notes/${noteName}.md" ]; do + ((noteName++)) + done + + # Enhance the prompt to ensure proper markdown formatting and direct content + local enhanced_prompt="Create a markdown-formatted note with the following content: ${prompt} + +Please format your response as a proper markdown document that can be saved directly as a .md file. Start with a clear title using # header syntax. Provide only the note content itself, not meta-commentary about creating the note. The note should be similar in style to a CLI reference with clear sections and practical information." + + # Call claude and capture output + local claude_output + if ! claude_output=$(claude -p "$enhanced_prompt" 2>&1); then + gettext "Error: Failed to get response from Claude Code: " + echo "$claude_output" + exit 1 + fi + + # Write the output to the note file + echo "$claude_output" > "${xdgPath}/notestorm/notes/${noteName}.md" + + # Try to extract a meaningful name from the first line + local first_line="$(head -n 1 "${xdgPath}/notestorm/notes/${noteName}.md" | head -c 100)" + # Remove markdown headers and clean up the name + first_line="${first_line#\# }" + first_line="${first_line#\#\# }" + first_line="${first_line#\#\#\# }" + first_line="${first_line//[[:space:]]/_}" + first_line="${first_line//[^a-zA-Z0-9._-]/_}" + + # Rename if we got a meaningful name and it doesn't conflict + if [[ -n "$first_line" && "$first_line" != "_" && ! -e "${xdgPath}/notestorm/notes/${first_line}.md" ]]; then + mv "${xdgPath}/notestorm/notes/${noteName}.md" "${xdgPath}/notestorm/notes/${first_line}.md" + noteName="${first_line}" + fi + + modified="note added" + gettext "Claude note created: " + echo "${noteName}.md" +} + # Configuration section # Available arguments in both long and short versions stored in associative array. declare -A argList=( [g]="git" [l]="list" - [n]="new") + [n]="new" + [r]="restore" + [z]="zip" + [C]="claude") # Make the args a continuous string. -short="${!argList[*]}" -short="${short// /}" -long="${argList[*]}" -long="${long// /}" +# Build short options manually to handle optional arguments +short="glnr:z:C:" +long="git,list,new,restore:,zip:,claude:" editor="${EDITOR:-nano}" messageTimeout=${messageTimeout:-1} -# Set pager +# Set pager - using arrays to prevent command injection if command -v w3m &> /dev/null ; then - pager="w3m -T text/html" + pager=("w3m" "-T" "text/html") elif command -v elinks &> /dev/null ; then - pager="elinks -force-html" + pager=("elinks" "-force-html") elif command -v lynx &> /dev/null ; then - pager="lynx -force_html" + pager=("lynx" "-force_html") else - pager="${PAGER:-more}" + # For PAGER, we need to handle it carefully since it could be a complex command + if [[ -n "${PAGER}" ]]; then + # Split PAGER on whitespace - this is a compromise for compatibility + read -ra pager <<< "${PAGER}" + else + pager=("more") + fi fi xdgPath="${XDG_CONFIG_HOME:-$HOME/.config}" # set up the notes directory @@ -308,6 +601,9 @@ fi # Code section +# Check dependencies before proceeding +check_dependencies + # If the only arg is a number, display that note, then exit. if [[ "$*" =~ [0-9]+ ]]; then display_note $* @@ -316,13 +612,17 @@ fi # Parse non-numeric command line args. if ! options=$(getopt -o "$short" -l "$long" -n "notestorm" -- "$@"); then gettext -e "Usage: notestorm launch interactive session.\n" + gettext -e -- "-C or --claude \"prompt\" create a note using Claude Code with the provided prompt.\n" gettext -e -- "-g or --git set up backups to git. Requires existing git repository with branch name \"master\".\n" gettext -e -- "-n or --new add a new note without opening an interactive session.\n" + gettext -e -- "-r or --restore [file] restore notes from a zip backup file.\n" + gettext -e -- "-z or --zip password create a zip backup of all notes with password. Use empty string \"\" for no password.\n" echo gettext -e "You can use markdown syntax in notes.\n" gettext -e "Notes are named numerically. they can be renamed and will still show up so long as they end with a .md extension.\n" gettext -e "To get a numbered list, instead of the interactive menu, use the -l option.\n" gettext -e "To show a note without using the interactive interface and pager, give the notes number as the only argument.\n" + gettext -e "Zip backups are saved to ~/Documents/notestorm-YYYY-MM-DD.zip\n" gettext "Notes are saved in " echo "${xdgPath}/notestorm/notes" exit 1 @@ -335,6 +635,15 @@ while [ $# -gt 0 ]; do "-g"|"--git") initialize_git;; "-l"|"--list") list_notes;; "-n"|"--new") add_note;; + "-r"|"--restore") + shift + restore_notes "$1";; + "-z"|"--zip") + shift + backup_notes "$1";; + "-C"|"--claude") + shift + claude_note "$1";; esac shift done