#!/usr/bin/env bash # ============================ # Exit Status Codes # ============================ EXIT_SUCCESS=0 EXIT_FAILURE=1 EXIT_DEPENDENCY=2 EXIT_CONVERSION=3 # ============================ # Shell Options # ============================ # Exit immediately if a command exits with a non-zero status, # Treat unset variables as an error, # Prevent errors in a pipeline from being masked set -euo pipefail # ============================ # Color Definitions # ============================ RESET='\033[0m' # No Color BOLD='\033[1m' # Regular Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' # ============================ # Configuration Constants # ============================ # Absolute paths ensure the script can be run from any directory SOURCE_DIR="$HOME/vimwiki" # Source Vimwiki directory TEMP_DIR="/tmp/vimwikihtml" # Temporary HTML output directory CSS_FILE="$HOME/.local/share/nvim/style.css" # Path to the CSS file for styling CONCURRENT_JOBS=4 # Number of concurrent pandoc processes LOG_FILE="$TEMP_DIR/conversion.log" # Log file to track conversions ERROR_LOG_FILE="$TEMP_DIR/error.log" # Log file to track errors VERSION="1.0.0" # Script version # ============================ # Dependencies # ============================ DEPENDENCIES=("pandoc" "sed" "qutebrowser" "find" "rsync" "grep" "mkdir" "basename" "diff") # ============================ # Logging Functions # ============================ # Function to log INFO messages log_info() { local message="$1" echo -e "${BLUE}[INFO]${RESET} $message" echo "[INFO] $(date +"%Y-%m-%d %H:%M:%S") - $message" >> "$LOG_FILE" } # Function to log SUCCESS messages log_success() { local message="$1" echo -e "${GREEN}[SUCCESS]${RESET} $message" echo "[SUCCESS] $(date +"%Y-%m-%d %H:%M:%S") - $message" >> "$LOG_FILE" } # Function to log WARNING messages log_warning() { local message="$1" echo -e "${YELLOW}[WARNING]${RESET} $message" echo "[WARNING] $(date +"%Y-%m-%d %H:%M:%S") - $message" >> "$LOG_FILE" } # Function to log ERROR messages log_error() { local message="$1" echo -e "${RED}[ERROR]${RESET} $message" | tee -a "$ERROR_LOG_FILE" echo "[ERROR] $(date +"%Y-%m-%d %H:%M:%S") - $message" >> "$ERROR_LOG_FILE" } # Function to handle errors with context and exit with specific code handle_error() { local message="$1" local exit_code="${2:-$EXIT_FAILURE}" local func="${FUNCNAME[1]}" local line="${BASH_LINENO[0]}" log_error "In function '$func' at line $line: $message" exit "$exit_code" } # ============================ # Filename Validation Function # ============================ # Function to check if a filename contains only allowed characters is_valid_filename() { local filename="$1" if [[ "$filename" =~ ^[A-Za-z0-9._-]+$ ]]; then return 0 else return 1 fi } # ============================ # Bash Availability Check # ============================ # Function to ensure that Bash is available check_bash() { if ! command -v bash &>/dev/null; then echo -e "${RED}[ERROR]${RESET} Bash is not installed. Please install Bash to run this script." exit "$EXIT_FAILURE" fi } # ============================ # Path Validation Function # ============================ # Function to validate and sanitize input paths validate_paths() { log_info "Validating input paths..." # Check that SOURCE_DIR is an absolute path if [[ "$SOURCE_DIR" != /* ]]; then handle_error "SOURCE_DIR ('$SOURCE_DIR') is not an absolute path." "$EXIT_FAILURE" fi # Check that TEMP_DIR is an absolute path and under /tmp if [[ "$TEMP_DIR" != /tmp/* ]]; then handle_error "TEMP_DIR ('$TEMP_DIR') must be under /tmp." "$EXIT_FAILURE" fi # Check that SOURCE_DIR exists and is a directory if [[ ! -d "$SOURCE_DIR" ]]; then handle_error "SOURCE_DIR ('$SOURCE_DIR') does not exist or is not a directory." "$EXIT_FAILURE" fi # Check if TEMP_DIR exists; if not, it will be created later # If it exists, ensure it's a directory if [[ -e "$TEMP_DIR" && ! -d "$TEMP_DIR" ]]; then handle_error "TEMP_DIR ('$TEMP_DIR') exists but is not a directory." "$EXIT_FAILURE" fi log_success "Input paths are valid." } # ============================ # Function Definitions # ============================ # Function to check if all dependencies are installed check_dependencies() { log_info "Checking for required dependencies..." local missing_dependencies=() for cmd in "${DEPENDENCIES[@]}"; do if ! command -v "$cmd" &>/dev/null; then missing_dependencies+=("$cmd") fi done if [[ ${#missing_dependencies[@]} -ne 0 ]]; then for cmd in "${missing_dependencies[@]}"; do handle_error "Dependency '$cmd' is not installed. Please install it and retry." "$EXIT_DEPENDENCY" done fi log_success "All dependencies are satisfied." } # Function to extract the title from Markdown using YAML frontmatter or fallback to filename extract_title() { local md_file="$1" # Attempt to extract title from YAML frontmatter local title title=$(grep -m1 '^title:' "$md_file" | sed 's/title: //') || true if [[ -z "$title" ]]; then # If no title found, use the filename without extension title=$(basename "$md_file" .md.old) fi echo "$title" } # Function to convert a single Markdown file to HTML atomically convert_md_to_html() { local md_old_file="$1" # Path to the .md.old file # Determine the relative path from TEMP_DIR local relative_path="${md_old_file#$TEMP_DIR/}" # Remove .md.old extension relative_path="${relative_path%.md.old}" # Determine the output HTML file path local html_file="$TEMP_DIR/${relative_path}.html" # Determine the temporary HTML file path local temp_html_file="${html_file}.tmp" # Create the necessary directories for the HTML file mkdir -p "$(dirname "$html_file")" # Extract the title for the HTML document local title title=$(extract_title "$md_old_file") log_info "Converting '$md_old_file' to '$html_file'..." # Use pandoc to convert Markdown to HTML with CSS and metadata if [[ -f "$CSS_FILE" ]]; then if ! pandoc -f markdown -s --css="$CSS_FILE" --metadata title="$title" "$md_old_file" -o "$temp_html_file"; then handle_error "Failed to convert '$md_old_file' to HTML." "$EXIT_CONVERSION" fi else log_warning "CSS file '$CSS_FILE' not found. Skipping CSS for '$md_old_file'." if ! pandoc -f markdown -s --metadata title="$title" "$md_old_file" -o "$temp_html_file"; then handle_error "Failed to convert '$md_old_file' to HTML." "$EXIT_CONVERSION" fi fi # Debug: Print a snippet of the HTML file before running sed log_info "Snippet before sed in '$temp_html_file':" head -n 5 "$temp_html_file" || true echo "..." # Adjust internal href links: # 1. Replace href="path/to/file.md.old" with href="path/to/file.html" # 2. Replace href="path/to/file" with href="path/to/file.html" only if 'file' has no extension log_info "Adjusting links in '$html_file'..." # First, replace links ending with .md.old if ! sed -i -E 's|(href=")([^"#:/]+(/[^"#:/]+)*)\.md\.old(")|\1\2.html\4|g' "$temp_html_file"; then handle_error "Failed to adjust '.md.old' links in '$temp_html_file'." "$EXIT_CONVERSION" fi # Then, replace links without any extension if ! sed -i -E 's|(href=")([^"#:/.]+(/[^"#:/.]+)*)(")|\1\2.html\4|g' "$temp_html_file"; then handle_error "Failed to adjust extensionless links in '$temp_html_file'." "$EXIT_CONVERSION" fi # Adjust src attributes for images to prepend /tmp/vimwikihtml log_info "Adjusting image paths in '$temp_html_file'..." if ! sed -i -E 's|(]*src=")(/[^"]*)(")|\1/tmp/vimwikihtml\2\3|g' "$temp_html_file"; then handle_error "Failed to adjust image paths in '$temp_html_file'." "$EXIT_CONVERSION" fi # Move the temporary HTML file to the final destination atomically mv "$temp_html_file" "$html_file" # Debug: Print a snippet of the HTML file after running sed log_info "Snippet after sed in '$html_file':" head -n 5 "$html_file" || true echo "..." # Log the successful conversion echo "$(date +"%Y-%m-%d %H:%M:%S") - Converted '$md_old_file' to '$html_file'." >> "$LOG_FILE" log_success "Successfully converted '$md_old_file' to '$html_file'." } # Function to synchronize Markdown files and relevant assets to TEMP_DIR synchronize_markdown() { log_info "Synchronizing Markdown files and assets to '$TEMP_DIR'..." # Use rsync to copy only .md, .pdf, and image files, excluding unwanted directories and files rsync -av --delete \ --exclude='*.html' \ --exclude='*.sh' \ --exclude='.git/' \ --exclude='.gitignore' \ --exclude='*.bak' \ --exclude='*.tex' \ --exclude='*.toc' \ --exclude='*.out' \ --include='*/' \ --include='*.md' \ --include='*.pdf' \ --include='*.png' \ --include='*.jpg' \ --include='*.jpeg' \ --include='*.gif' \ --exclude='*' \ "$SOURCE_DIR/" "$TEMP_DIR/" | grep '\.md$' || true log_success "Synchronization completed." } # Function to rename .md files to .md.old in TEMP_DIR rename_md_files() { log_info "Renaming new or modified .md files to .md.old in '$TEMP_DIR'..." # Find all .md files in TEMP_DIR find "$TEMP_DIR" -type f -name '*.md' | while IFS= read -r md_file; do # Determine the corresponding .md.old file md_old_file="${md_file}.old" # Determine the source .md file source_md="$SOURCE_DIR/${md_file#$TEMP_DIR/}" # Check if the .md.old file exists if [[ ! -f "$md_old_file" ]]; then # New file detected, copy to .md.old cp "$source_md" "$md_old_file" log_info "New file detected. Copied '$source_md' to '$md_old_file'." # Convert to HTML convert_md_to_html "$md_old_file" & else # Compare the source .md with the existing .md.old if ! diff -q "$source_md" "$md_old_file" &>/dev/null; then # Files differ, update .md.old and reconvert cp "$source_md" "$md_old_file" log_info "Modified file detected. Updated '$md_old_file' with changes from '$source_md'." # Convert to HTML convert_md_to_html "$md_old_file" & else log_info "No changes detected for '$source_md'. Skipping conversion." fi fi done # Wait for all background conversions to finish wait log_success "Renaming and conversion of new or modified .md files completed." } # Function to handle deletions: Remove .html files corresponding to deleted .md files handle_deletions() { log_info "Handling deletions of Markdown files..." # Find all .md.old files in TEMP_DIR find "$TEMP_DIR" -type f -name '*.md.old' | while IFS= read -r md_old_file; do # Determine the corresponding .md file in SOURCE_DIR source_md="$SOURCE_DIR/${md_old_file#$TEMP_DIR/}" source_md="${source_md%.md.old}.md" # Check if the source .md file exists if [[ ! -f "$source_md" ]]; then # Corresponding .md file has been deleted, remove the .html file html_file="${md_old_file%.md.old}.html" if [[ -f "$html_file" ]]; then rm "$html_file" log_success "Deleted '$html_file' as the source Markdown file no longer exists." # Log the deletion echo "$(date +"%Y-%m-%d %H:%M:%S") - Deleted '$html_file' due to source removal." >> "$LOG_FILE" fi # Remove the .md.old file itself rm "$md_old_file" log_info "Removed obsolete '$md_old_file'." fi done log_success "Deletion handling completed." } # Function to generate index.html specifically generate_index() { local index_md_old="$TEMP_DIR/index.md.old" local index_html="$TEMP_DIR/index.html" if [[ ! -f "$index_md_old" ]]; then handle_error "'index.md.old' not found in '$TEMP_DIR'." "$EXIT_FAILURE" fi log_info "Generating 'index.html' from 'index.md.old'..." # Convert the index.md.old file to HTML convert_md_to_html "$index_md_old" # Ensure index.html exists if [[ ! -f "$index_html" ]]; then handle_error "Failed to generate 'index.html'." "$EXIT_CONVERSION" fi log_success "'index.html' generation completed." } # Function to open index.html in qutebrowser open_browser() { local index_file="$TEMP_DIR/index.html" if [[ -f "$index_file" ]]; then log_info "Opening '$index_file' in qutebrowser..." qutebrowser "$index_file" & log_success "Opened '$index_file' in qutebrowser." else handle_error "'$index_file' does not exist. Please ensure it is generated correctly." "$EXIT_FAILURE" fi } # Function to display usage information usage() { echo -e "${BOLD}Usage:${RESET} $0 [OPTIONS]" echo "" echo "Options:" echo " --index-wiki, -iw Synchronize and convert Vimwiki to HTML, then open index.html in qutebrowser." echo " --help, -h Display this help message." echo " --version, -V Display the script's version." echo "" echo "Examples:" echo " $0 --index-wiki" echo " $0 -iw" echo " $0 --help" echo " $0 -h" echo " $0 --version" echo " $0 -V" } # Function to display version information version_info() { echo -e "${BOLD}Vimwiki HTML Converter${RESET} version ${GREEN}$VERSION${RESET}" } # Function to update synchronization and conversion based on differences update_if_needed() { # Synchronize new and updated files synchronize_markdown # Rename and convert new or modified .md files rename_md_files # Handle deletions handle_deletions # Generate index.html generate_index } # ============================ # Signal Handling Functions # ============================ # Function to handle script termination gracefully cleanup() { log_warning "Script interrupted. Cleaning up..." # Terminate all background jobs jobs -rp | xargs -r kill -TERM 2>/dev/null || true exit "$EXIT_FAILURE" } # Trap SIGINT and SIGTERM signals trap cleanup SIGINT SIGTERM # ============================ # convert_single_file Function # ============================ convert_single_file() { local md_file="$1" # Validate filename local filename filename=$(basename "$md_file") if ! is_valid_filename "$filename"; then log_warning "Skipping file with invalid filename: '$md_file'" return fi # Check if the file exists if [[ ! -f "$md_file" ]]; then handle_error "File '$md_file' does not exist." "$EXIT_FAILURE" fi # Copy the Markdown file to the temporary directory local dest_dir="/tmp/markdowndump/" mkdir -p "$dest_dir" local dest_md_file="$dest_dir$filename" cp "$md_file" "$dest_md_file" log_info "Copied '$md_file' to '$dest_md_file'." # Prepare the paths for HTML conversion local html_file="${dest_md_file%.md}.html" local temp_html_file="${html_file}.tmp" # Extract the title for the HTML document local title title=$(extract_title "$dest_md_file") # Convert Markdown to HTML using pandoc with CSS and metadata if [[ -f "$CSS_FILE" ]]; then if ! pandoc -f markdown -s --css="$CSS_FILE" --metadata title="$title" "$dest_md_file" -o "$temp_html_file"; then handle_error "Failed to convert '$dest_md_file' to HTML." "$EXIT_CONVERSION" fi else log_warning "CSS file '$CSS_FILE' not found. Skipping CSS for '$dest_md_file'." if ! pandoc -f markdown -s --metadata title="$title" "$dest_md_file" -o "$temp_html_file"; then handle_error "Failed to convert '$dest_md_file' to HTML." "$EXIT_CONVERSION" fi fi # Move the temporary HTML file to the final destination mv "$temp_html_file" "$html_file" log_info "Converted Markdown file '$dest_md_file' to HTML '$html_file'." # Open the HTML file in qutebrowser qutebrowser "$html_file" & log_success "Opened '$html_file' in qutebrowser." } # ============================ # Main Script Execution # ============================ main() { # Parse command-line arguments if [[ $# -eq 0 ]]; then log_error "No arguments provided. Use --help for usage information." exit "$EXIT_FAILURE" fi while [[ $# -gt 0 ]]; do case "$1" in --index-wiki|-iw) action="index_wiki" shift ;; --convert|-c) if [[ -z "${2:-}" ]]; then echo -e "${RED}[ERROR]${RESET} --convert flag requires a filename argument." exit "$EXIT_FAILURE" fi convert_single_file "$2" exit "$EXIT_SUCCESS" ;; --help) usage exit "$EXIT_SUCCESS" ;; -h) usage exit "$EXIT_SUCCESS" ;; --version) version_info exit "$EXIT_SUCCESS" ;; -V) version_info exit "$EXIT_SUCCESS" ;; *) log_error "Unknown option: $1. Use --help for usage information." exit "$EXIT_FAILURE" ;; esac done # Execute based on the action case "$action" in index_wiki) check_bash check_dependencies validate_paths # Create TEMP_DIR if it doesn't exist if [[ ! -d "$TEMP_DIR" ]]; then log_info "Temporary directory '$TEMP_DIR' does not exist. Creating and performing full synchronization." mkdir -p "$TEMP_DIR" synchronize_markdown rename_md_files handle_deletions generate_index else log_info "Temporary directory '$TEMP_DIR' already exists. Checking for updates." update_if_needed fi open_browser log_success "All tasks completed successfully." exit "$EXIT_SUCCESS" ;; *) # This should not happen due to earlier checks log_error "Invalid action." "$EXIT_FAILURE" ;; esac } # Invoke the main function main "$@"