Creating Obsidian tables of content

Maintaining Tables of Contents in Obsidian with a Bash Script

When viewing longer Markdown notes in Obsidian, tables of content (TOC) help a lot with navigation. There is a handful of community plugins to help with TOC generation, but I have two issues with them:

  1. It creates a dependency on code whose developer may lose interest and eventually abandon the project. At least one dynamic TOC plugin has suffered this fate.
  2. All of the TOC plugins have the same visual result. When you navigate to a note, Obsidian places the focus at the top of the note, beneath the frontmatter. That’s fine unless the content starts with a TOC markup block, in which case it’s not the TOC itself that is displayed, but the markup for the TOC plugin itself as depicted in the image below.

For me the solution was to write a script that scans the vault looking for this pair of markers:

<!--ts-->
<!--te-->

that define the start and end for the TOC block. When these markers are found, the script inserts a TOC between the markers. The downside of course is that the markers, which are just raw HTML comments in the Markdown document are still visible when you’re in Edit mode. But it’s still better than not seeing the actual TOC at all when you navigate to a note.

Technical Details

Finding Files that Need TOC Updates

The script begins by using the find command to locate recently modified Markdown files in your Obsidian vault. It specifically looks for files modified within the last minute, making it efficient for running as a background process. This approach ensures that TOCs are updated only in notes you’ve recently worked on, rather than scanning your entire vault every time.

find_cmd="find \"$VAULT_DIR\" \
  -path \"*/$EXCLUDE_DIRS\" -prune \
  -o -type f \
  -name \"*.md\" \
  -newermt \"1 minute ago\" \
  -print"
  

For each file, the script:

  1. Checks if valid TOC markers exist outside of code blocks
  2. Counts the headings in the document
  3. Compares the current TOC entries against the document’s headings
  4. Updates the TOC if:
    • The TOC is empty but headings exist
    • The heading content has changed
    • The file was modified since the last run

This strategy minimizes unnecessary updates and ensures TOCs stay in sync with your content as you write and edit.

Handling Code Blocks and Marker Detection

One of the challenges with this approach is distinguishing between actual TOC markers and examples of markers included in code blocks. For instance, if you’re writing documentation about the TOC script itself, you might include the TOC markers in a code example.

The script solves this by carefully tracking when it’s inside or outside of code blocks:

# Check for code block start/end
if [[ "$line" =~ ^[[:space:]]*(\`\`\`|\~\~\~) ]]; then
    # Extract the fence type (``` or ~~~)
    fence_match="${BASH_REMATCH[1]}"
    
    if [[ "$in_code_block" == "false" ]]; then
        in_code_block=true
        code_fence="$fence_match"
    elif [[ "$line" == *"$code_fence"* ]]; then
        in_code_block=false
        code_fence=""
    fi
}

When analyzing a file, the script maintains state variables to track:

  • Whether we’re currently inside a code block
  • The type of code fence used (``` or ~~~)
  • Whether we’re in YAML frontmatter

This context-aware parsing ensures that TOC markers inside code blocks are completely ignored. This is crucial because it prevents the script from incorrectly modifying code examples or documentation that happens to contain the markers.

Usage

Basic Usage

The script can be run directly from the command line:

./obsidian_toc_monitor.sh /path/to/obsidian/vault

You can also specify which directories to exclude:

./obsidian_toc_monitor.sh --exclude ".obsidian,Templates,Attachments" /path/to/vault

Automating TOC Updates

There are several ways to automate the script depending on your operating system:

On macOS:

  1. Using launchd - Create a launch agent to run the script periodically:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.obsidian.tocmonitor</string>
    <key>ProgramArguments</key>
    <array>
        <string>/path/to/obsidian_toc_monitor.sh</string>
        <string>/path/to/obsidian/vault</string>
    </array>
    <key>StartInterval</key>
    <integer>60</integer>
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>

Save this to ~/Library/LaunchAgents/com.user.obsidian.tocmonitor.plist and load it with:

launchctl load ~/Library/LaunchAgents/com.user.obsidian.tocmonitor.plist
  1. Using cron - Add an entry to your crontab:
* * * * * /path/to/obsidian_toc_monitor.sh /path/to/obsidian/vault

On Linux:

  1. Using systemd - Create a user service:
[Unit]
Description=Obsidian TOC Monitor

[Service]
Type=simple
ExecStart=/path/to/obsidian_toc_monitor.sh /path/to/obsidian/vault
Restart=always
RestartSec=60

[Install]
WantedBy=default.target

Save this to ~/.config/systemd/user/obsidian-toc.service and enable it:

systemctl --user enable --now obsidian-toc.service
  1. Using cron (same as macOS)

Adding TOC Markers to Your Notes

To prepare a note for TOC generation, simply add these markers where you want the TOC to appear:

# My Note Title

<!--ts-->
<!--te-->

## First Section
Content here...


The script will detect these markers and insert a formatted TOC.

You get the benefits of a dynamic TOC without visual issues.

Script code

#!/bin/bash

# Default configuration
LOG_FILE="$HOME/Library/Logs/obsidian_toc_monitor.log"
LAST_RUN_FILE="/tmp/obsidian_toc_monitor_lastrun"
EXCLUDE_DIRS=".obsidian pdf Templates Attachments Meta"
DEBUG="${DEBUG:-false}"
VERBOSE="${VERBOSE:-true}"  # Set to true to show terminal output

# Global counters
FILE_COUNT=0
MODIFIED_COUNT=0

# Color codes for better terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
RESET='\033[0m'

# Usage information
show_usage() {
    echo "Usage: $(basename "$0") [OPTIONS] VAULT_DIRECTORY"
    echo ""
    echo "Options:"
    echo "  -h, --help            Show this help message and exit"
    echo "  -q, --quiet           Run in quiet mode (no terminal output)"
    echo "  -d, --debug           Enable debug logging"
    echo "  -e, --exclude DIRS    Comma-separated list of directories to exclude (default: $EXCLUDE_DIRS)"
    echo "  -l, --log FILE        Path to log file (default: $LOG_FILE)"
    echo ""
    echo "Example:"
    echo "  $(basename "$0") /path/to/obsidian/vault"
    echo "  $(basename "$0") --exclude '.obsidian,pdf,Templates' /path/to/vault"
}

# Parse command line arguments
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
    case $1 in
        -h|--help)
            show_usage
            exit 0
            ;;
        -q|--quiet)
            VERBOSE=false
            shift
            ;;
        -d|--debug)
            DEBUG=true
            shift
            ;;
        -e|--exclude)
            EXCLUDE_DIRS="$2"
            shift 2
            ;;
        -l|--log)
            LOG_FILE="$2"
            shift 2
            ;;
        -*|--*)
            echo "Error: Unknown option $1"
            show_usage
            exit 1
            ;;
        *)
            POSITIONAL_ARGS+=("$1")
            shift
            ;;
    esac
done
set -- "${POSITIONAL_ARGS[@]}"

# Check if vault directory was provided
if [ $# -lt 1 ]; then
    echo "Error: No vault directory specified"
    show_usage
    exit 1
fi

# Set the vault directory
VAULT_DIR="$1"

# Check if vault directory exists
if [ ! -d "$VAULT_DIR" ]; then
    echo "Error: Vault directory does not exist: $VAULT_DIR"
    exit 1
fi

# Ensure log directory exists
mkdir -p "$(dirname "$LOG_FILE")"

# Touch the last run file if it doesn't exist
[ ! -f "$LAST_RUN_FILE" ] && touch "$LAST_RUN_FILE"

# Logging functions
log_info() {
    echo "[INFO] $1" >> "$LOG_FILE"
    if [[ "$VERBOSE" == "true" ]]; then
        echo -e "${GREEN}[INFO]${RESET} $1"
    fi
}

log_error() {
    echo "[ERROR] $1" >> "$LOG_FILE"
    if [[ "$VERBOSE" == "true" ]]; then
        echo -e "${RED}[ERROR]${RESET} $1"
    fi
}

log_debug() {
    if [[ "$DEBUG" == "true" ]]; then
        echo "[DEBUG] $1" >> "$LOG_FILE"
        echo -e "${BLUE}[DEBUG]${RESET} $1"
    fi
}

generate_toc() {
    local FILE_PATH="$1"
    local TEMP_FILE="$2"
    
    # First add the TOC heading
    echo "## Table of contents" > "$TEMP_FILE"
    echo "" >> "$TEMP_FILE"  # Add blank line after the heading
    
    # Find headings and generate TOC
    local min_level=6
    declare -a heading_lines
    local in_code_block=false
    local code_fence=""
    local in_frontmatter=false
    
    while IFS= read -r line; do
        # Handle YAML frontmatter
        if [[ "$line" == "---" ]]; then
            if [[ "$in_frontmatter" == "false" ]]; then
                in_frontmatter=true
            else
                in_frontmatter=false
            fi
            continue
        fi
        
        # Skip processing if in frontmatter
        if [[ "$in_frontmatter" == "true" ]]; then
            continue
        fi
        
        # Skip processing the TOC heading itself
        if [[ "$line" == "## Table of contents" ]]; then
            continue
        fi
        
        # Check for code block start/end
        if [[ "$line" =~ ^[[:space:]]*(\`\`\`|\~\~\~) ]]; then
            # Extract the fence type (``` or ~~~)
            fence_match="${BASH_REMATCH[1]}"
            
            if [[ "$in_code_block" == "false" ]]; then
                in_code_block=true
                code_fence="$fence_match"
            elif [[ "$line" == *"$code_fence"* ]]; then
                in_code_block=false
                code_fence=""
            fi
            continue
        fi
        
        # Only process headings if we're not inside a code block
        if [[ "$in_code_block" == "false" ]]; then
            if [[ "$line" =~ ^[[:space:]]*(#{1,6})[[:space:]]+(.*[^[:space:]]) ]]; then
                # The heading marker is captured in BASH_REMATCH[1]
                local marker="${BASH_REMATCH[1]}"
                local level=${#marker}
                # The heading text is captured in BASH_REMATCH[2]
                local title="${BASH_REMATCH[2]}"
                
                # Update min level if needed
                if [[ $level -lt $min_level ]]; then
                    min_level=$level
                fi
                
                # Store heading info for later processing
                heading_lines+=("$level|$title")
                
                if [[ "$VERBOSE" == "true" ]]; then
                    echo -e "    ${CYAN}→ Level $level:${RESET} $title"
                fi
            fi
        fi
    done < "$FILE_PATH"
    
    # Generate TOC entries
    for entry in "${heading_lines[@]}"; do
        IFS='|' read -r level title <<< "$entry"
        
        # Clean markdown formatting
        clean_title="$title"
        clean_title="${clean_title//\*\*/}"  # Remove bold
        clean_title="${clean_title//\*/}"    # Remove italic
        clean_title="${clean_title//\`/}"    # Remove code
        
        # Calculate relative indent level
        rel_level=$((level - min_level))
        
        if [[ $rel_level -eq 0 ]]; then
            echo "* [[#$clean_title]]" >> "$TEMP_FILE"
        else
            indent=$(printf "%$((rel_level * 4))s" "")
            echo "${indent}- [[#$clean_title]]" >> "$TEMP_FILE"
        fi
    done
    
    # Return the number of headings found
    echo ${#heading_lines[@]}
}

update_toc() {
    local FILE_PATH="$1"
    local START_LINE="$2"
    local END_LINE="$3"
    local was_updated=false
    
    log_debug "Updating TOC for $FILE_PATH (markers at lines $START_LINE and $END_LINE)"
    
    # Create temporary files
    local TEMP_TOC=$(mktemp)
    local TEMP_FILE=$(mktemp)
    
    # Generate the TOC
    if [[ "$VERBOSE" == "true" ]]; then
        echo -e "  ${YELLOW}Parsing headings...${RESET}"
    fi
    
    local heading_count=$(generate_toc "$FILE_PATH" "$TEMP_TOC")
    
    if [[ "$VERBOSE" == "true" ]]; then
        echo -e "  ${YELLOW}Generating TOC with $heading_count headings...${RESET}"
    fi
    
    # Extract current TOC content for comparison
    local current_toc=$(sed -n "${START_LINE},${END_LINE}p" "$FILE_PATH")
    
    # Create updated file with new TOC
    {
        # Copy lines before TOC
        sed -n "1,$((START_LINE-1))p" "$FILE_PATH"
        
        # Copy start marker line
        sed -n "${START_LINE}p" "$FILE_PATH"
        
        # Insert new TOC content
        cat "$TEMP_TOC"
        
        # Copy end marker line
        sed -n "${END_LINE}p" "$FILE_PATH"
        
        # Copy lines after TOC
        sed -n "$((END_LINE+1)),\$p" "$FILE_PATH"
    } > "$TEMP_FILE"
    
    # Extract new TOC content for comparison
    local new_toc=$(sed -n "${START_LINE},$((START_LINE+1+$(wc -l < "$TEMP_TOC")+1))p" "$TEMP_FILE")
    
    # Compare TOCs to see if an update is needed
    if ! diff -q <(echo "$current_toc") <(echo "$new_toc") >/dev/null; then
        # TOC content has changed - update the file
        if [[ "$VERBOSE" == "true" ]]; then
            echo -e "  ${GREEN}✓ Updating TOC with $heading_count entries${RESET}"
        fi
        
        # Move updated file into place
        mv "$TEMP_FILE" "$FILE_PATH"
        
        # Add a small delay and touch the file to ensure changes are detected
        sleep 0.1
        touch "$FILE_PATH"
        
        log_info "TOC updated for: $FILE_PATH"
        was_updated=true
        
        # Increment global counter 
        ((MODIFIED_COUNT++))
    else
        if [[ "$VERBOSE" == "true" ]]; then
            echo -e "  ${GREEN}✓ TOC already up to date${RESET}"
        fi
        log_debug "No TOC changes needed for: $FILE_PATH"
    fi
    
    # Clean up temporary files
    rm -f "$TEMP_TOC" "$TEMP_FILE" 2>/dev/null
    
    return $([ "$was_updated" = true ] && echo 0 || echo 1)
}

find_valid_toc_markers() {
    local FILE_PATH="$1"
    local START_LINE_VAR="$2"
    local END_LINE_VAR="$3"
    
    # Use grep to find potential marker line numbers
    local potential_start_markers=$(grep -n "<!-- *ts *-->" "$FILE_PATH" | cut -d':' -f1)
    local potential_end_markers=$(grep -n "<!-- *te *-->" "$FILE_PATH" | cut -d':' -f1)
    
    # If no potential markers found at all, return false
    if [[ -z "$potential_start_markers" || -z "$potential_end_markers" ]]; then
        return 1
    fi
    
    # Parse the file line by line to verify markers are outside code blocks
    local line_num=0
    local in_code_block=false
    local code_fence=""
    local in_frontmatter=false
    local valid_start_line=""
    local valid_end_line=""
    
    while IFS= read -r line; do
        ((line_num++))
        
        # Handle YAML frontmatter
        if [[ "$line" == "---" ]]; then
            if [[ "$in_frontmatter" == "false" ]]; then
                in_frontmatter=true
            else
                in_frontmatter=false
            fi
            continue
        fi
        
        # Skip processing if in frontmatter
        if [[ "$in_frontmatter" == "true" ]]; then
            continue
        fi
        
        # Check for code block start/end
        if [[ "$line" =~ ^[[:space:]]*(\`\`\`|\~\~\~) ]]; then
            # Extract the fence type (``` or ~~~)
            fence_match="${BASH_REMATCH[1]}"
            
            if [[ "$in_code_block" == "false" ]]; then
                in_code_block=true
                code_fence="$fence_match"
                log_debug "Line $line_num: Code block starts"
            elif [[ "$line" == *"$code_fence"* ]]; then
                in_code_block=false
                code_fence=""
                log_debug "Line $line_num: Code block ends"
            fi
            continue
        fi
        
        # Only check for TOC markers if we're not inside a code block
        if [[ "$in_code_block" == "false" ]]; then
            # Check if current line is in our potential marker list
            if echo "$potential_start_markers" | grep -q "^$line_num$"; then
                valid_start_line=$line_num
                log_debug "Line $line_num: Valid start marker found"
            fi
            
            if echo "$potential_end_markers" | grep -q "^$line_num$"; then
                valid_end_line=$line_num
                log_debug "Line $line_num: Valid end marker found"
            fi
        fi
    done < "$FILE_PATH"
    
    # Only succeed if we found valid markers outside code blocks
    if [[ -z "$valid_start_line" || -z "$valid_end_line" ]]; then
        return 1
    fi
    
    # Set the output variables
    eval "$START_LINE_VAR=$valid_start_line"
    eval "$END_LINE_VAR=$valid_end_line"
    return 0
}

process_file() {
    local FILE_PATH="$1"
    local update_needed=false
    
    # Skip if file doesn't exist or isn't a regular file
    if [[ ! -f "$FILE_PATH" ]]; then
        return 1
    fi
    
    # Skip non-markdown files
    if [[ ! "$FILE_PATH" =~ \.(md|markdown)$ ]]; then
        return 1
    fi
    
    # Increment global counter
    ((FILE_COUNT++))
    
    log_debug "Checking file: $FILE_PATH"
    if [[ "$VERBOSE" == "true" ]]; then
        filename=$(basename "$FILE_PATH")
        echo -e "${YELLOW}Checking:${RESET} $filename"
    fi
    
    # Find valid TOC markers (outside code blocks)
    local start_line=""
    local end_line=""
    if ! find_valid_toc_markers "$FILE_PATH" start_line end_line; then
        log_debug "No valid TOC markers found in file - $FILE_PATH"
        if [[ "$VERBOSE" == "true" ]]; then
            echo -e "  ${YELLOW}→ No valid TOC markers found${RESET}"
        fi
        return 1
    fi
    
    log_debug "Found valid TOC markers at lines $start_line and $end_line"
    
    # Count the number of headings in the file, excluding those in code blocks
    heading_count=0
    in_code=false
    code_fence=""
    in_front=false
    declare -a heading_lines
    
    while IFS= read -r line; do
        # Handle YAML frontmatter
        if [[ "$line" == "---" ]]; then
            if [[ "$in_front" == "false" ]]; then
                in_front=true
            else
                in_front=false
            fi
            continue
        fi
        
        if [[ "$in_front" == "true" ]]; then
            continue
        fi
        
        # Check for code block start/end
        if [[ "$line" =~ ^[[:space:]]*(\`\`\`|\~\~\~) ]]; then
            fence="${BASH_REMATCH[1]}"
            if [[ "$in_code" == "false" ]]; then
                in_code=true
                code_fence="$fence"
            elif [[ "$line" == *"$code_fence"* ]]; then
                in_code=false
                code_fence=""
            fi
            continue
        fi
        
        # Only count headings outside code blocks
        if [[ "$in_code" == "false" && "$line" =~ ^[[:space:]]*(#{1,6})[[:space:]]+(.*[^[:space:]]) ]]; then
            # Skip TOC heading
            if [[ "$line" =~ "Table of contents" ]]; then
                continue
            fi
            
            # Extract heading level and text
            level=${#BASH_REMATCH[1]}
            title="${BASH_REMATCH[2]}"
            
            # Store heading info for later comparison
            heading_lines+=("$title")
            ((heading_count++))
        fi
    done < "$FILE_PATH"
    
    log_debug "Found $heading_count headings in file (excluding code blocks)"
    
    # Extract TOC content (between markers)
    local toc_content=$(sed -n "$((start_line+1)),$((end_line-1))p" "$FILE_PATH")
    
    # Extract TOC entry texts
    declare -a toc_texts
    while IFS= read -r line; do
        if [[ "$line" =~ \[\[#(.*)\]\] ]]; then
            toc_text="${BASH_REMATCH[1]}"
            toc_texts+=("$toc_text")
            log_debug "TOC entry: $toc_text"
        fi
    done < <(echo "$toc_content")
    
    # Count TOC entries
    toc_entries=${#toc_texts[@]}
    log_debug "TOC has $toc_entries entries"
    
    # Check if any heading text has changed
    headings_changed=false
    if [[ ${#toc_texts[@]} -eq ${#heading_lines[@]} ]]; then
        for ((i=0; i<${#heading_lines[@]}; i++)); do
            # Clean heading text for comparison
            clean_heading="${heading_lines[$i]}"
            clean_heading="${clean_heading//\*\*/}"
            clean_heading="${clean_heading//\*/}"
            clean_heading="${clean_heading//\`/}"
            
            if [[ "${toc_texts[$i]}" != "$clean_heading" ]]; then
                log_debug "Heading text changed: '${toc_texts[$i]}' → '$clean_heading'"
                headings_changed=true
                break
            fi
        done
    else
        log_debug "Different number of headings (TOC: ${#toc_texts[@]}, Doc: ${#heading_lines[@]})"
        headings_changed=true
    fi
    
    # Get the last modified time of the file
    file_mtime=$(stat -f "%m" "$FILE_PATH" 2>/dev/null || stat -c "%Y" "$FILE_PATH")
    last_run_time=$(stat -f "%m" "$LAST_RUN_FILE" 2>/dev/null || stat -c "%Y" "$LAST_RUN_FILE")
    
    # Decide if update is needed
    if [[ "$toc_entries" -eq 0 || "$headings_changed" == "true" || "$file_mtime" -lt "$last_run_time" ]]; then
        reason=""
        if [[ "$toc_entries" -eq 0 ]]; then
            reason="TOC has no entries"
        elif [[ "$headings_changed" == "true" ]]; then
            reason="Heading content has changed"
        else
            reason="File was modified by user"
        fi
        
        log_info "Processing file: $FILE_PATH ($reason)"
        update_toc "$FILE_PATH" "$start_line" "$end_line"
        return $?
    else
        log_debug "TOC is up to date (entries: $toc_entries, headings: $heading_count)"
        if [[ "$VERBOSE" == "true" ]]; then
            echo -e "  ${GREEN}✓ TOC already up to date${RESET}"
        fi
        return 1
    fi
}

# Main script starts here
log_info "Starting TOC monitor run on vault: $VAULT_DIR"

# Convert exclude dirs from comma-separated to space-separated if needed
EXCLUDE_DIRS="${EXCLUDE_DIRS//,/ }"

# Build the find command with exclude directories
find_cmd="find \"$VAULT_DIR\""
for dir in $EXCLUDE_DIRS; do
    find_cmd="$find_cmd -path \"*/$dir\" -prune -o"
done
find_cmd="$find_cmd -type f -name \"*.md\" -newermt \"1 minute ago\" -print"

log_debug "Find command: $find_cmd"

if [[ "$VERBOSE" == "true" ]]; then
    echo -e "\n${MAGENTA}====================================================${RESET}"
    echo -e "${MAGENTA}  OBSIDIAN TOC MONITOR - STARTING RUN $(date '+%H:%M:%S')${RESET}"
    echo -e "${MAGENTA}====================================================${RESET}\n"
    echo -e "${CYAN}Checking for recently modified Markdown files in:${RESET}"
    echo -e "${CYAN}$VAULT_DIR${RESET}\n"
    
    if [[ -n "$EXCLUDE_DIRS" ]]; then
        echo -e "${CYAN}Excluding directories:${RESET} $EXCLUDE_DIRS\n"
    fi
fi

# Get the list of files to process
FILE_LIST=$(eval "$find_cmd")

# Process each file
if [[ -n "$FILE_LIST" ]]; then
    while IFS= read -r file; do
        process_file "$file"
    done <<< "$FILE_LIST"
else
    log_info "No recently modified files found"
    if [[ "$VERBOSE" == "true" ]]; then
        echo -e "${YELLOW}No recently modified files found${RESET}"
    fi
fi

# After processing all files, update the last run timestamp
touch "$LAST_RUN_FILE"

# Show summary
if [[ "$VERBOSE" == "true" ]]; then
    echo -e "\n${MAGENTA}====================================================${RESET}"
    if [[ $MODIFIED_COUNT -gt 0 ]]; then
        echo -e "${GREEN}  ✓ Updated $MODIFIED_COUNT TOCs out of $FILE_COUNT files checked${RESET}"
    else
        echo -e "${GREEN}  ✓ No updates needed. Checked $FILE_COUNT files.${RESET}"
    fi
    echo -e "${MAGENTA}====================================================${RESET}\n"
fi

log_info "TOC monitor run completed. Updated $MODIFIED_COUNT files."
exit 0

How I rid my life of social media

If social media is working for you and you don’t care about the moral implications of using social media, then this post isn’t for you.

On the other hand, if the MAGA shift of social media, the love fest between Zuck, Musk, and Tr*mp and their slimey ilk makes you feel a little cringey. Or if you realize that you’re wasting countless minutes of your one wild and precious life, then this may be for you. Fair warning, it gets pretty technical; so stop wherever you want. It takes little more than a decision and a healthy dose of willpower. But if you want to block social media and cast it into the fires of Mt. Doom, here’s how.

macOS
If you run Windows, some of this won’t apply. If you run any of the various flavours of Linux, some will not apply either; but in the case of Linux you probably already know what you’re doing. So, some of the technical stuff will be macOS-only.

Step 1 - I made the the decision to leave social media

This is a consequential decision, one that you should consider carefully. I quit for several reasons:

  • Privacy, security and the basic human freedom to be left alone - As Shoshana Zuboff details in “The Age of Surveillance Capitalism”, companies like Meta have transformed our personal experiences into raw material for behavioral data markets. Mark Zuckerberg once said, “Senator, we run ads,” when questioned about Facebook’s business model, but this barely scratches the surface. These platforms operate within a far-reaching system that thrives on extracting, repurposing, and commodifying the “digital exhaust” of your browsing activities, turning you into a predictable and manipulable product.
  • Time - Do you know that negative feeling that arises when you find yourself scrolling social media? Some would call it “cringey”; and it’s something between shame and regret. When I really began to pay attention to my feelings while scrolling social media, I found that the overall feelings that it left were negative, mostly regret over having used my time in this way.
  • The moral dimension - You are supporting billionaires owners like Mark Zuckerberg (Meta/Facebook/Instagram/WhatsApp) and Elon Musk (Twitter/X) who appear to be amoral accumulators of wealth and power. Both have aligned themselves with an anti-democratic, autocratic political movement. I’m not fine with that.

I had long ago quit Twitter when even before the first Tr*mp Administration and before Musk purchased it, when it had already become a cesspool of extremism. And when Instagram became something more than a place to share interesting pictures, it didn’t hold much appeal to me. Remember how upset people became when they announced plans to show videos on Instagram? How times change…

Step 2 - I decided to just leave

I just left a post on Facebook about Zuckerberg’s new-found love of Tr*mp and asked “Who all’s OK with this?” then I signed off. It has been a week. People who matter to me and to whom I matter will know how to contact me. I suppose I will circle back and start deleting content so Meta has less to mine. But it’s too much trouble right now. When I eventually do that, I’ll leave a pinned post with my contact information and basically saying: “Hi, I’m not here any longer; if you ever want to reach me again, copy down this contact information.” After that, I’ll wait a few weeks then I’ll burn it down.

But you may want to do it differently and make a more graceful exit.

Step 3 - I blocked the hell out of social media

Here’s where it gets a little technical.

Blocked social media locally on macOS

I use Little Snitch to observe traffic in and out of my desktop macOS machines; and a few years ago I began building rules in a group called “Block Social Spies” that block outgoing traffic to Facebook and other social media sites. When I wanted to browse Facebook, I would have to manually switch groups to deactivate these rules. It created just a little barrier to prevent me from wasting too much time on these sites.

But the desktop solution only worked, well, on the desktop. What about the phone?

Deleted the phone apps

Facebook app? Gone. Instagram app? Deleted. Twitter/X app? Buh bye. All of them gone.

But what about the mobile web which on the phone can often still allow some limited access to these social media sites? Keep reading.

Spun up my own DNS server

A DNS server is like a phonebook for the internet. When you type in a website name like “google.com”, the DNS server translates that human-readable domain name into the numerical IP address (like 172.217.3.110) that computers use to find and connect to each other.

Here’s the basic process:

  1. You enter “google.com” in your browser
  2. Your computer asks a DNS server “What’s the IP address for google.com?”
  3. The DNS server looks up that information in its records
  4. It returns the correct IP address to your computer
  5. Your computer can then connect directly to that IP address

Without DNS servers, we’d have to memorize IP addresses for every website we want to visit. DNS servers make the internet much more user-friendly by handling these translations automatically.

Since DNS servers are critical to the functioning of the web, it gives us an opportunity to intervene on what gets looked up. What if we had a DNS server that “forgot” the IP addresses for social media sites, but remembered everything else? What if this DNS server was under our control and not Google’s; so that we could choose what lookups to block and which ones to pass on to the real DNS server? Well, that’s what PiHole does. By running PiHole on a server inside your network (almost any old spare computer will do), you can have your own DNS server to customize however you want. Typically PiHole is used to block tracking and ad delivery sites but you can use it to effectively block social media sites, too. After spinning up your instance of PiHole, you just have to give its IP address as the DNS address on all the devices on your network that you want protected, include, of course your phones.


Through a combination of blocks at both the DNS level and the local network stack on my macOS devices, I have successfully eliminated this surveillance and MAGA menace from my life. I’m not on Bluesky either because I don’t trust them (yet) to not sell-out to capitalist usurpers. We’ll see. Friendship and community should not be held hostage to private surveillance networks.

When will I get to 1,000,000 Anki reviews?

Recently I’ve been wondering how long it would take me to get to 1,000,000 reviews. Right now I’m sitting at between 800,000 and 900,000 reviews and for no other reason than pure ridiculous curiosity I was curious whether I could get SQLite to compute it directly for me. Turns out the answer is “yes, you can.” CAUTION Before you run anything that accesses the Anki database outside of the Anki application itself, you absolutely should backup your database first.

Why I'm quitting Facebook (again)

This isn’t the first time, but I hope it will be the last. Facebook, for me has long been a source of enjoyment and connection. But it also leaves me feeling cringey. So what changed? What changed is that Facebook has gone full-on MAGA and I’m not OK with that: “Meta CEO Mark Zuckerberg met with President-elect Donald Trump on Friday [January 10, 2025] at Mar-a-Lago, two sources familiar tell CNN.

Registering a custom collation to query Anki database

While working on a project that requires querying the Anki database directly outside of the Anki desktop application, I encountered an interesting issue with sqlite3 collations. This is is just a short post about how I went about registering a collation in order to execute SQL queries against the Anki db. CAUTION Before you run anything that accesses the Anki database, you absolutely should backup your database first.

Fix your Anki streak - the script edition

Like many Anki users, I keep track of my streaks because it motivates me to do my reviews each day. But since life gets in the way sometimes, I may miss my reviews in one or more decks. It has been years since I’ve neglected to do all of my reviews; but sometimes I will forget to come back later in the day to finish up some of my decks. Since I like to have a clean review heatmap, I will “fix” my streak in a skipped deck.

An API (sort of) for adding links to ArchiveBox

I use ArchiveBox extensively to save web content that might change or disappear. While a REST API is apparently coming eventually, it doesn’t appear to have been merged into the main fork. So I cobbled together a little application to archive links via a POST request. It takes advantage of the archivebox command line interface. If you are impatient, you can skip to the full source code. Otherwise I’ll describe my setup to provide some context.

A Keyboard Maestro action to save bookmarks to Espial

So this is a little esoteric, but it meets a need I encountered; and it may meet yours if you use Espial, Keyboard Maestro and are on macOS. For several years I’ve been using Espial a bookmark manager that looks and feels like Pinboard, but is both self-hosted and drama-free1. Espial is easy to setup, stores its data in a comprehensible sqlite database and has an API, which comes in handy when it came to solving the problem I encountered.

Louisiana and the Ten Commandments

Recently, the governor of Louisiana signed a bill requiring all public school classrooms in the state to display a poster-sized copy of the Ten Commandments. In the “Beforetimes” (before the current partisan Supreme Court took shape), this would have been struck down immediately as a violation of the Establishment Clause of the First Amendment. This bill is a clear violation of that clause. I imagine that the justices will dance around the cultural and historical significance of the document without stopping to consider the state’s motives in passing this law.

Improving vegetable seed germination with chemical pretreatment

Some vegetable seeds, particularly many exotic chilli pepper varieties and some Asian eggplants are tricky to germinate. After trying the obvious things - cold-induced forced dormancy (cold stratification), abundant moisture, high humidity, and temperatures over 80F, I’ve found that some seeds simply do not germinate with much success at all. But having read a number of articles on this problem, we decided to try an intensive chemical process to see if we could achieve better results.