Tag: scripting

  • The WP‑CLI Command That Saved Me Hours on URL Restructuring


    How I Restructured My Arabic Learning Site’s URLs Without Losing SEO (Using WP‑CLI & Redirection)

    A few weeks ago I faced a classic content‑site dilemma.
    My website, Lucid Arabic, had around 80 of beginner vocabulary lessons with clean but flat URLs like:

    https://lucidarabic.com/how-to-say-star-in-arabic

    I knew I wanted to expand into intermediate and advanced content, grammar lessons, and exercises. The flat structure just wouldn’t scale. I needed a hierarchy that would make sense for learners and search engines alike:

    https://lucidarabic.com/beginner/vocabulary/how-to-say-star-in-arabic

    The goal was clear, but the path was full of potential SEO landmines. After a lot of planning (and a successful test on a local site), I pulled it off with zero broken links and zero ranking drops. Here’s exactly how I did it, including the WP‑CLI command that saved me hours and the critical step most people overlook.


    The Challenge

    • Old URL pattern: /%postname%/ (e.g., /how-to-say-star-in-arabic/)
    • New URL pattern: /beginner/vocabulary/%postname%/
    • 80+ published posts that needed to move seamlessly
    • No room for 404 errors – existing traffic and search rankings had to be preserved

    I needed a method that would:

    1. Automatically assign the correct category to all existing posts
    2. Generate the new hierarchical URLs on the fly
    3. Create precise 301 redirects from every old URL to its new counterpart
    4. Update the XML sitemap so Google would see the new structure immediately

    WordPress makes steps 1 and 2 easy if you’ve cleaned up your categories first. Step 3 is where WP‑CLI turned a tedious task into a 30‑second job.


    Step 1: Create the New Category Hierarchy

    First, I set up the container categories in Posts → Categories:

    • beginner (slug: beginner)
    • vocabulary (slug: vocabulary) – with beginner as its parent

    This gives the path beginner/vocabulary. I made sure no other posts were using these slugs to avoid conflicts.


    Step 2: The Critical Clean‑up – Assign ONLY the Vocabulary Category

    “If you change the permalink structure while posts still have other categories assigned, those posts will immediately take on URLs that include those old category slugs (e.g., /uncategorized/something/, or /some-other-category/something/). That would create a mess before you even get to the redirect step.”

    This is the step many tutorials skip. WordPress uses the category with the lowest ID (usually the first one created) when building the /%category%/ permalink, unless you use a plugin to choose the primary category. To avoid any guesswork, I made sure every post I wanted to move was assigned only to the Vocabulary category and no other category could hijack the URL.

    How I did it efficiently:

    1. Went to Posts → All Posts
    2. Selected all posts (using the checkbox at the top, then “All X posts” link if needed)
    3. Chose Edit from the Bulk actions dropdown and clicked Apply
    4. In the bulk edit panel, under Categories, I ticked only the “vocabulary” category and un‑ticked any other categories (like “Uncategorized”)
    5. Hit Update

    That guaranteed every single post had exactly one category assignment. No leftovers, no surprise slugs.

    For sites with thousands of posts, you could use a database query or a plugin, but the built‑in bulk edit handled my 80+ posts in seconds.


    Step 3: Switch the Permalink Structure

    With categories now clean, I could safely change the permalink setting.

    1. Went to Settings → Permalinks
    2. Selected Custom Structure and entered: /%category%/%postname%/
    3. Clicked Save Changes

    Instantly, all my post URLs became https://lucidarabic.com/beginner/vocabulary/slug/. The old short URLs started returning 404 errors – completely expected, and completely fixable in the next step.



    Step 4: Generate 301 Redirects in Seconds with WP‑CLI

    wp post list --post_type=post --post_status=publish --format=csv --fields=post_name | tail -n +2 | awk -F, '{print "/" $1 "/,/beginner/vocabulary/" $1 "/"}' > redirects.csv

    It looks dense, but it’s just a chain of three simple commands connected by pipes (|). I’ll walk through each component.


    0. Prerequisites

    • WP‑CLI must be installed on your server (or local environment). Most managed WordPress hosts already have it. You can check by typing wp --info in your terminal.
    • You need SSH access to your server, or you can run it on a local copy of your site (which is safer, and exactly what I did first).
    • Navigate to your WordPress root directory (where wp-config.php lives) before running the command.

    1. wp post list – Extracting the Raw Data

    wp post list --post_type=post --post_status=publish --format=csv --fields=post_name

    This is the WP‑CLI part. It queries the WordPress database for posts and outputs specific information.

    FlagMeaning
    --post_type=postOnly include standard blog posts (exclude pages, attachments, etc.).
    --post_status=publishOnly include published items (no drafts, trashed, or private).
    --format=csvOutput the results as a plain CSV table.
    --fields=post_nameOnly retrieve the post_name field – this is the URL slug.

    Example output of this sub‑command alone:

    post_name
    say-flower-in-arabic
    how-to-say-star-in-arabic
    arabic-word-for-tree
    ...

    The first line is the column header post_name. We need to remove it, which leads to the next command.


    2. tail -n +2 – Removing the Header

    tail -n +2

    tail normally outputs the last lines of a file or input stream. With the -n +2 option, it starts outputting from the second line onward. This effectively drops the first line (the header).

    After this step, the stream becomes:

    say-flower-in-arabic
    how-to-say-star-in-arabic
    arabic-word-for-tree
    ...

    Now we have a clean list of slugs – one per line.


    3. awk – Transforming Each Slug into a CSV Line

    awk -F, '{print "/" $1 "/,/beginner/vocabulary/" $1 "/"}'

    awk is a powerful text processor. Let’s unpack this instruction:

    • -F, sets the field separator to a comma. Although our input has no commas (each line is just a slug), specifying a separator is good practice. Here, $1 represents the entire line because the line contains no comma to split it.
    • '{print ... }' is the action performed on every line.
    • The print statement constructs the exact CSV line we need:
    • "/" adds a leading slash before the slug.
    • $1 inserts the slug itself.
    • "/,/beginner/vocabulary/" adds the comma (CSV delimiter) and the new path prefix.
    • $1 inserts the slug again, after the prefix.
    • "/" adds the trailing slash.

    So, for the input say-flower-in-arabic, the output becomes:

    /say-flower-in-arabic/,/beginner/vocabulary/say-flower-in-arabic/

    And every line is transformed identically.


    4. > redirects.csv – Saving to a File

    The > operator redirects the final output (from awk) into a new file called redirects.csv in the current directory. If the file already exists, it’s overwritten. If you prefer to append, you’d use >>.


    Putting It All Together – A Visual Flow

    [WP Database]
          |
          v
    wp post list …   =>   post_name\nsay-flower-in-arabic\nhow-to-say-star-in-arabic\n…
          |
          v
    tail -n +2       =>   say-flower-in-arabic\nhow-to-say-star-in-arabic\n…
          |
          v
    awk …            =>   /say-flower-in-arabic/,/beginner/vocabulary/say-flower-in-arabic/\n
                          /how-to-say-star-in-arabic/,/beginner/vocabulary/how-to-say-star-in-arabic/\n
                          …
          |
          v
    > redirects.csv   =>   (file saved on disk)

    What the Final CSV Looks Like

    Opening redirects.csv in a text editor, you’ll see lines exactly like:

    /say-flower-in-arabic/,/beginner/vocabulary/say-flower-in-arabic/
    /arabic-word-for-tree/,/beginner/vocabulary/arabic-word-for-tree/
    /how-to-say-star-in-arabic/,/beginner/vocabulary/how-to-say-star-in-arabic/
    /sun-in-arabic/,/beginner/vocabulary/sun-in-arabic/
    …

    Important characteristics:

    • No header row (already stripped).
    • Every line is a valid source/target pair.
    • Both source and target paths are relative and end with a trailing slash – exactly what the Redirection plugin expects.
    • Character encoding is preserved. Slugs that contain percent‑encoded Arabic characters (like %d8%b4) remain untouched, ensuring the redirect matches the actual URL the browser requests.

    Why This Command is So Powerful

    • Zero manual editing: You don’t touch a single URL. The script reads your actual database slugs and builds every mapping automatically.
    • Scalable: Whether you have 10 posts or 10,000, the command runs in the same amount of time (a few seconds).
    • No copy‑paste errors: Manual CSV creation is prone to missing slashes, typos, or duplicate lines. This eliminates human error.
    • Reusable: If you later add an “intermediate/vocabulary” level, you can run the same command with a slightly modified awk pattern.

    Alternatives If You Don’t Have WP‑CLI

    If SSH access or WP‑CLI isn’t available, you can achieve the same result with:

    1. SQL export via phpMyAdmin:
       SELECT CONCAT('/', post_name, '/') AS source,
              CONCAT('/beginner/vocabulary/', post_name, '/') AS target
       FROM wp_posts
       WHERE post_type = 'post' AND post_status = 'publish' AND post_name != '';

    Then export the results as a CSV without headers.

    1. Free export plugin (e.g., WP All Export):
      Export only the post_name field twice, then use spreadsheet formulas to build the source and target columns.

    But WP‑CLI is by far the fastest, and if your host supports it, I highly recommend using it.


    Running It Safely

    I can’t stress enough: run this command on a local/staging copy first. That’s what I did. I cloned my production site to my local machine using Local by Flywheel, executed the WP‑CLI command there, verified the CSV, and then repeated the process on the live server. This gave me absolute confidence that nothing would break.

    After generating the file, I opened it with a plain‑text editor (not Excel, which can alter encodings), checked the first few and last few lines, and then imported it straight into the Redirection plugin.


    The whole process – from running the command to seeing the redirect rules live – took less than five minutes. That single line of code turned what could have been a tedious, error‑prone chore into an automated, reliable solution. It’s now my go‑to technique for any bulk URL restructuring in WordPress.


    Step 5: Import the CSV into the Redirection Plugin

    Back in the WordPress admin:

    1. Installed and activated the free Redirection plugin
    2. Went to Tools → Redirection → Import/Export
    3. Uploaded the CSV, mapped Column 1 to Source URL and Column 2 to Target URL
    4. Clicked Import

    All 80+ redirects were instantly created as 301 (Permanent) rules. I tested a few old URLs in a private browsing window – each one cleanly forwarded to the new hierarchical URL with no lag and no intermediate page.


    Step 6: Update the Sitemap (Don’t Forget This!)

    WordPress generated new URLs, but my XML sitemap (managed by an SEO plugin) still contained the old ones. I needed search engines to discover the new structure quickly.

    I went to my SEO plugin’s settings (I use Rank Math, but Yoast has the same feature) and found the Sitemap Settings. I simply clicked Save Changes or triggered a manual sitemap rebuild. The plugin re‑scanned my posts and updated the sitemap with the new /beginner/vocabulary/ URLs.

    I also submitted the updated sitemap to Google Search Console for good measure.


    Bonus: My Safety Net – Local Testing & Backup

    Before touching the live site, I cloned my entire production site to a local environment (using Local by Flywheel). There I ran through every step – category cleanup, permalink change, WP‑CLI redirect generation, and the Redirection import. I could verify that:

    • The new URLs resolved correctly
    • The old URLs redirected without a hitch
    • No weird 404s appeared
    • My theme and plugins played nicely with the hierarchical permalinks

    Once I was confident, I backed up my production site (both files and database). With the backup securely stored, I repeated the steps on the live server. The whole live migration took about 10 minutes, and because the redirects were already mapped in the CSV, there was zero downtime.


    Key Takeaways

    1. Clean up category assignments before changing permalinks. If you don’t, you’ll end up with unpredictable URLs.
    2. WP‑CLI is your best friend for bulk redirect generation. A single command can save hours of manual CSV editing.
    3. The Redirection plugin makes redirect management painless – and its CSV import works flawlessly.
    4. Always test on a local or staging site first, and have a full backup before making structural changes to a live site.
    5. Update your sitemap immediately so search engines index the new URLs and pass ranking signals through the redirects.

    The result? My Arabic learning site now has a clean, scalable URL structure ready for intermediate and advanced content. Old links still work perfectly, and search engine rankings held steady.

    If you’re facing a similar URL restructuring, don’t fear the change – just plan carefully, let automation do the heavy lifting, and always protect yourself with a backup. And if you have access to the command line, give that WP‑CLI one‑liner a try – you’ll be amazed at how fast it works.


    Have you restructured your WordPress URLs? I’d love to hear about your experience – and any other clever WP‑CLI tricks you’ve used – in the comments below.

  • Using SED to edit a classic book

    Since I discovered that using TTS speeds up my reading of books, I have been using it almost daily for around a decade, and I am very happy with it.

    On my GNU/Linux computer, I use the “Read Aloud, TTS Voice Reader” browser extension to read books.

    Lately, I have developed a growing interest in classic Arabic books about the purification of the soul written by ابن أبي الدنيا Ibn Abi al-Dunya (823–894 CE).

    However, when I downloaded the books and converted them to .txt files for easier use with the extension, a considerable amount of time was lost to the TTS reading the three asterisks and the hadith, chapter, and page numbers, which appear hundreds of times throughout the book.

    See below as an example:


    6 - حَدَّثَنَا أَبُو خَيْثَمَةَ، وَإِسْحَاقُ بْنُ إِسْمَاعِيلَ، قَالَا: حَدَّثَنَا جَرِيرٌ، عَنِ الْأَعْمَشِ، عَنِ الْحَكَمِ بْنِ عُتَيْبَةَ، وَحَبِيبِ بْنِ أَبِي ثَابِتٍ، عَنْ مَيْمُونِ بْنِ أَبِي شَبِيبٍ، عَنْ مُعَاذِ بْنِ جَبَلٍ رَضِيَ اللَّهُ عَنْهُ قَالَ: قُلْتُ: يَا رَسُولَ اللَّهِ، أَنُؤَاخَذُ بِمَا نَقُولُ؟ قَالَ: «ثَكِلَتْكَ أُمُّكَ يَا ابْنَ جَبَلٍ، وَهَلْ يَكُبُّ النَّاسَ فِي النَّارِ عَلَى مَنَاخِرِهِمْ إِلَّا حَصَائِدُ أَلْسِنَتِهِمْ؟» قَالَ حَبِيبٌ فِي هَذَا الْحَدِيثِ: «وَهَلْ تَقُولُ شَيْئًا إِلَّا لَكَ أَوْ عَلَيْكَ»



    * * *



    الحديث: 6 ¦ الجزء: 1 ¦ الصفحة: 46





    * * *



    7 - حَدَّثَنِي حَمْزَةُ بْنُ الْعَبَّاسِ، أَخْبَرَنَا عَبْدَانُ بْنُ عُثْمَانَ، أَخْبَرَنَا عَبْدُ اللَّهِ، أَنَا مَعْمَرٌ، عَنِ الزُّهْرِيِّ، عَنْ عَبْدِ الرَّحْمَنِ بْنِ مَاعِزٍ، عَنْ سُفْيَانَ بْنِ عَبْدِ اللَّهِ الثَّقَفِيِّ قَالَ: قُلْتُ: يَا رَسُولَ اللَّهِ، حَدِّثْنِي بِأَمْرٍ أَعْتَصِمُ بِهِ. قَالَ: " قُلْ: رَبِّيَ اللَّهُ ثُمَّ اسْتَقِمْ ". قَالَ: قُلْتُ: يَا رَسُولَ اللَّهِ، مَا أَخْوَفُ مَا تَخَافُ عَلَيَّ؟ فَأَخَذَ بِلِسَانِهِ ثُمَّ قَالَ: «هَذَا»

    A SED command

    sed '/^\* \* \*$/d; /^الحديث:/d' الصمت_وآداب_اللسان.txt > editedالصمت_وآداب_اللسان.txt

    Explanation:

    • ^\* \* \*$ matches a line that contains exactly * * * (asterisk, space, asterisk, space, asterisk). The asterisks must be escaped with \ because * is a regex special character.
    • /^الحديث:/d deletes lines that start with الحديث:.
    • All other lines (narration chains, texts, and chapter headings) are kept.

    Output


    6 - حَدَّثَنَا أَبُو خَيْثَمَةَ، وَإِسْحَاقُ بْنُ إِسْمَاعِيلَ، قَالَا: حَدَّثَنَا جَرِيرٌ، عَنِ الْأَعْمَشِ، عَنِ الْحَكَمِ بْنِ عُتَيْبَةَ، وَحَبِيبِ بْنِ أَبِي ثَابِتٍ، عَنْ مَيْمُونِ بْنِ أَبِي شَبِيبٍ، عَنْ مُعَاذِ بْنِ جَبَلٍ رَضِيَ اللَّهُ عَنْهُ قَالَ: قُلْتُ: يَا رَسُولَ اللَّهِ، أَنُؤَاخَذُ بِمَا نَقُولُ؟ قَالَ: «ثَكِلَتْكَ أُمُّكَ يَا ابْنَ جَبَلٍ، وَهَلْ يَكُبُّ النَّاسَ فِي النَّارِ عَلَى مَنَاخِرِهِمْ إِلَّا حَصَائِدُ أَلْسِنَتِهِمْ؟» قَالَ حَبِيبٌ فِي هَذَا الْحَدِيثِ: «وَهَلْ تَقُولُ شَيْئًا إِلَّا لَكَ أَوْ عَلَيْكَ»



    7 - حَدَّثَنِي حَمْزَةُ بْنُ الْعَبَّاسِ، أَخْبَرَنَا عَبْدَانُ بْنُ عُثْمَانَ، أَخْبَرَنَا عَبْدُ اللَّهِ، أَنَا مَعْمَرٌ، عَنِ الزُّهْرِيِّ، عَنْ عَبْدِ الرَّحْمَنِ بْنِ مَاعِزٍ، عَنْ سُفْيَانَ بْنِ عَبْدِ اللَّهِ الثَّقَفِيِّ قَالَ: قُلْتُ: يَا رَسُولَ اللَّهِ، حَدِّثْنِي بِأَمْرٍ أَعْتَصِمُ بِهِ. قَالَ: " قُلْ: رَبِّيَ اللَّهُ ثُمَّ اسْتَقِمْ ". قَالَ: قُلْتُ: يَا رَسُولَ اللَّهِ، مَا أَخْوَفُ مَا تَخَافُ عَلَيَّ؟ فَأَخَذَ بِلِسَانِهِ ثُمَّ قَالَ: «هَذَا»

    The whole book has the asterisks separators and the numbers of page, chapter and hadiths deleted in no times not even 2 seconds.

    I ❤ GNU/Linux, FOSS and CLI

  • Level Up Your Bash Scripts: $?, Custom Exit Codes & Fail-Safe Flags

    In our previous post about exit code, we learned why exit 0 and exit 1 are the difference between a script that “smiles through a disaster” and one that actually warns you. But manually writing exit 1 after every command quickly becomes tedious, especially as your scripts grow. How do production-grade scripts handle errors without cluttering every line with checks?

    The answer lies in three Bash superpowers: the $? variable, standardized exit codes, and the set -euo pipefail safety trio. Let’s break them down with real examples.

    By default, Bash is extremely optimistic—it assumes errors are optional, missing variables are fine, and partial success counts as success. While this behavior feels charming in a local terminal, it becomes reckless in automation. Out of the box, Bash will happily execute a command that fails, ignore the failure, and continue as if nothing happened. That’s not resilience; it’s denial. If your script controls anything real, silent failure is the worst possible outcome. The truly dangerous scripts aren’t the ones that crash—they’re the ones that fail halfway, leave systems in weird states, and still exit with code 0. Those scripts pass CI, get promoted, and quietly break production later. Congratulations—you’ve automated uncertainty.


    1. The Hidden Power of $?

    Every command you run in Bash leaves behind a receipt. That receipt is the $? variable. It holds the exit status of the most recently executed command. The catch? It updates after every command, including echo or variable assignments. If you don’t capture it immediately, it’s overwritten and gone forever.

    Watch what happens in this broken example:

    #!/bin/bash
    ls /nonexistent_dir 2>/dev/null
    echo "Exit code was: $?"   # ✅ Prints 2 (directory not found)
    
    echo "Just checking..."    # ⚠️ This runs successfully
    echo "Now $? is: $?"       # ❌ Prints 0! The error receipt was replaced.

    The Fix: Capture Before It Changes
    Always store $? in a named variable the moment you need it:

    #!/bin/bash
    rsync -av /data/ /backup/ 2>/dev/null
    result=$?  # ✅ Save the receipt immediately
    
    if [ $result -ne 0 ]; then
        echo "Rsync failed with exit code: $result"
        exit $result
    fi

    Pro Tip: You can often skip $? entirely. Bash checks exit codes natively in if statements:

    if ! cp critical_config.yml /etc/app/; then
        echo "Config copy failed!"
        exit 1
    fi

    The ! negates the success, so the if block only runs when cp returns non-zero. Cleaner, safer, and less prone to $? overwrites.


    2. Beyond exit 0 & exit 1: Custom Exit Codes

    exit 0 means success. exit 1 means generic failure. But what if your script can fail for five different reasons? Telling a monitoring system or CI/CD pipeline “it failed” isn’t enough. Bash supports exit codes 0–255, and the Linux ecosystem follows established conventions:

    • 0: Success
    • 1: General/catchall error
    • 2: Misuse of shell builtins (wrong flags/arguments)
    • 126: Command found but not executable
    • 127: Command not found
    • 128+N: Script killed by signal N (e.g., 130 = Ctrl+C)

    You’re free to define your own codes for application-specific errors. Just pick numbers that won’t collide with signals (usually 10–99 works well):

    #!/bin/bash
    set -euo pipefail
    
    # Check source directory
    if [ ! -d "/var/app/data" ]; then
        echo "ERROR: Source directory missing"
        exit 10  # Custom: Configuration error
    fi
    
    # Check disk space
    usage=$(df /var/app/data | awk 'NR==2 {print $5}' | tr -d '%')
    if [ "$usage" -gt 90 ]; then
        echo "ERROR: Disk usage at ${usage}%. Backup aborted."
        exit 20  # Custom: Resource constraint
    fi
    
    echo "✅ Backup completed successfully."
    exit 0

    Now, an automation tool reading the exit code knows exactly what went wrong: 10 means fix the config path, 20 means clear disk space. Always document your custom codes in a header comment so your team (or future you) doesn’t have to guess.


    3. The “Fail-Safe” Script: set -euo pipefail

    Manually checking every exit code works for tiny scripts. For anything running on a schedule or in production, it’s fragile. Bash provides built-in safety switches that act as an automatic error net. Place these at the top of your script:

    #!/bin/bash
    set -euo pipefail

    Here’s what each flag does:

    • set -e (errexit): Exits immediately if any command returns non-zero. No more silent failures cascading into data loss.
    • set -u (nounset): Treats unset variables as errors. Prevents typos like $backp_dir from accidentally creating empty folders.
    • set -o pipefail: Changes how pipelines behave. By default, cmd1 | cmd2 | cmd3 only returns the exit code of cmd3. With pipefail, it returns the last non-zero exit code in the chain.

    See the difference in action:

    # Default Bash behavior
    false | echo "Pipe succeeded"
    echo $?  # Prints 0 (echo's success hid false's failure)
    
    # With set -o pipefail enabled
    false | echo "Pipe succeeded"
    echo $?  # Prints 1 (Bash caught the hidden failure)

    What if you expect a command to fail sometimes? You can temporarily disable -e:

    set +e  # Turn off errexit
    ping -c 1 192.168.1.50 || echo "Host unreachable"
    set -e  # Turn protection back on immediately

    🔍 The “Truth Test” for All Three

    Run this in your terminal to see how they work together:

    bash -c 'set -euo pipefail; false | grep "test"; echo "This never prints"'
    echo "Exit: $?"

    Result: The script stops at false | grep, returns 1, and the final echo never runs. The computer sees the truth.

    📝 Summary

    • $? is a temporary receipt. Capture it instantly or let Bash handle it in if statements.
    • Custom exit codes turn “it broke” into actionable diagnostics for humans and machines.
    • set -euo pipefail automates error catching, so you don’t have to manually guard every line.

    Start adding these to your scripts today. Your backups, deployments, and sanity will thank you.

  • Upgrading my rsync script

    Upgrading my rsync script

    A Basic Improvement in My rsync Script

    rsync is used for efficient file synchronization and backup. It copies only the differences between source and destination, saving time and bandwidth.

    And it is preinstalled on Ubuntu.

    I use rsync to back up some of my folders to external storage like this:

    
    
    
    
    

    As you can see, I use && and \.

    • && is used to combine two bash commands and run the second command only if the first command succeeds (exits with status 0).
    • \ is used for line continuation – it tells the shell that the command continues on the next line, making long commands more readable.

    We could use ; instead of &&, but ; would run the second command regardless of whether the first command succeeded or failed. && is safer for backup operations because if the first backup fails, the second won’t run, preventing incomplete or corrupted backups.


    The Improved Script with a Loop

    The problem with my original approach is that I had to manually write a line for each external drive. If I added a new drive, I had to update the script. Also, if a drive wasn’t connected, rsync would still try to run and throw an error.

    Here’s my improved script that automatically handles multiple drives and checks if they’re connected:


    Array definition. Creates an array variable named DRIVES containing four paths (one per drive). The parentheses () define an array, and each quoted string is an element. Using an array allows us to loop through all drives without repeating code.

    The Very Important Symbol: [@]

    The critically important symbol is [@] (at-sign with brackets).


    Why [@] is So Important

    What it does:

    ${DRIVES[@]} expands to all elements of the array DRIVES, with each element treated as a separate word.

    The Danger of NOT using [@]

    If you wrote this incorrectly as:

    for DRIVE in ${DRIVES[@]}; do   # Missing quotes - WRONG!

    Or worse:

    for DRIVE in $DRIVES; do        # Just wrong - treats array as single string

    Here’s what happens with a drive path containing spaces, like "/media/fakhri/SAMSUNG SSD":

    Correct WayIncorrect Way
    "${DRIVES[@]}"${DRIVES[@]} (no quotes)
    SAMSUNG SSD stays as ONE itemSAMSUNG and SSD become TWO separate items
    The script sees: /media/fakhri/SAMSUNG SSDThe script sees:
    1. /media/fakhri/SAMSUNG
    2. SSD (which is not a valid path)

    The Three Array Expansion Options Compared

    SymbolBehaviorWhen to Use
    $DRIVESOnly first element (treats array as scalar)Never for arrays
    ${DRIVES[*]}All elements as single stringWhen you want one combined string
    ${DRIVES[@]}All elements as separate wordsMost common – use in loops
    "${DRIVES[@]}"All elements as separate words, preserving spacesALWAYS USE THIS for paths with spaces

    The Golden Rule

    Always use "${ARRAY[@]}" with quotes when iterating over arrays containing file paths.

    The quotes + [@] combination ensures:

    1. Each array element stays intact (spaces preserved)
    2. Empty elements are preserved
    3. Special characters (like * or ?) are not expanded

    Without this, your backup script will fail silently and try to write to completely wrong locations!

    Every Important Symbol Explained

    Here’s the breakdown of every critical symbol in these lines:

    if [ -d "$DRIVE" ]; then
        # ... backup commands ...
    else
        echo "Skipping $DRIVE (Drive not connected)"
    fi
    done

    if [ -d "$DRIVE" ]; then

    SymbolNameWhat it does
    ifKeywordStarts a conditional statement. If the following command returns true (exit code 0), execute the code between then and else/fi
    [Test command (left bracket)A built-in command that evaluates conditional expressions. Must have spaces around it! [ -d "$DRIVE" ] not [-d "$DRIVE"]
    (space)Space separatorRequired between [ and -d – bash needs spaces to distinguish commands from arguments
    -dFlag (directory test)Tests if the following path exists and is a directory. Returns true (0) if yes, false (1) if not
    (space)Space separatorRequired between -d and the path
    "$DRIVE"Double-quoted variableExpands to the value of DRIVE variable while preserving spaces in the path. Without quotes, a path like /media/fakhri/SAMSUNG SSD would break into two words
    (space)Space separatorRequired between the path and the closing bracket
    ]Closing bracketEnds the test command. Must have a space before it!
    ;Command separatorAllows multiple commands on one line. Here it separates the test command from then
    thenKeywordMarks the beginning of the code block to execute if the if condition is true

    else

    SymbolNameWhat it does
    elseKeywordMarks the alternative code block. Executes if the if condition was false (the drive was NOT a directory)

    echo "Skipping $DRIVE (Drive not connected)"

    SymbolNameWhat it does
    echoCommandPrints text to the terminal
    " "Double quotesEverything inside becomes a single argument to echo, even if it contains spaces or variables. Variables inside ($DRIVE) still expand
    $DRIVEVariable expansionReplaces $DRIVE with its actual value (e.g., /media/fakhri/32Go)
    ()ParenthesesRegular text characters here – just part of the message. Not a command substitution because there’s no $ before them

    fi

    SymbolNameWhat it does
    fiKeywordCloses the if block. It’s “if” spelled backwards. Every if must have a matching fi

    done

    SymbolNameWhat it does
    doneKeywordCloses the for loop. Marks the end of the loop body. Every for must have a matching done

    The Most Critical Symbol: [ ] (Test Command)

    The brackets [ ] are NOT syntax – they are a command!

    Mental model:

    if [ -d "$DRIVE" ]; then

    Is equivalent to:

    if test -d "$DRIVE"; then

    The [ command is just an alias for test that requires a closing ].

    Common Mistakes with [ ]:

    ❌ Wrong✅ CorrectWhy
    [-d "$DRIVE"][ -d "$DRIVE" ]Missing spaces – bash can’t find the [ command
    [$DRIVE][ -n "$DRIVE" ]No flag – what are you testing?
    [ -d $DRIVE ][ -d "$DRIVE" ]No quotes – path with spaces breaks

    Symbol Hierarchy in Context

    if [ -d "$DRIVE" ]; then
    │  │ │ │        │  │
    │  │ │ │        │  └── ends the "then" block start
    │  │ │ │        └── separates test from "then"
    │  │ │ └── variable expands to actual path
    │  │ └── tests if path is a directory
    │  └── starts test command
    └── begins conditional
    
    then
    │
    └── marks true block
    
    else
    │
    └── marks false block
    
    echo "Skipping $DRIVE (Drive not connected)"
    │    │                    │
    │    │                    └── variable inside quotes expands
    │    └── quotes keep everything as one argument
    └── prints to terminal
    
    fi
    │
    └── closes if
    
    done
    │
    └── closes for loop

    Quick Reference Card

    SymbolMeaningRemember by
    ifBegin conditional“if this is true…”
    [Start testLeft bracket opens the test
    -dDirectory check“-d” for “directory”
    $Variable valueDollar = value
    " "Preserve spacesQuotes = togetherness
    ]End testRight bracket closes the test
    ;Command separatorSemicolon = stop then go
    thenTrue branch“then do this…”
    elseFalse branch“otherwise do this…”
    fiEnd if“if” backwards
    doneEnd loop“for…done”

    The most important takeaway: [ ] needs spaces inside and outside, and always quote your variables inside tests!

    Final Note: Use [[ ]] Instead of [ ]

    For Bash scripts, the double-bracket [[ ]] is superior to the single-bracket [ ] used in this article. Unlike [ ] (a command that requires spaces and quoted variables), [[ ]] is a Bash keyword that prevents word splitting and pathname expansion. This means you can write [[ -d $DRIVE ]] without quotes, even if the path contains spaces. [[ ]] also supports pattern matching (== *.txt), regex matching (=~), and natural logical operators (&&, ||). Stick with [ ] only if you need portability to other shells like sh; otherwise, always prefer [[ ]] for cleaner, safer, and more readable conditional tests.

  • Don’t Let Your Script Lie to You: A Guide to Exit Status

    Don’t Let Your Script Lie to You: A Guide to Exit Status

    To see why exit 1 and exit 0 are so important, we have to look at what happens when a script lies to the computer.

    If you omit them, Bash simply reports the exit status of the very last command that ran. This can lead to a “False Success.”

    1. The “Broken” Script (No Exit Codes)

    Save this as broken_backup.sh. Notice there is no exit 1.

    Bash


    2. The “Good” Script (With Exit Codes)

    Save this as good_backup.sh.

    Bash

    3. How to Test Them (The “Truth” Test)

    Run these commands in your terminal one after the other. We will use the && operator, which only runs the second command if the first one reports Success (0).

    Testing the Broken Script:

    Bash

    Result: Even though the script printed “ERROR,” the computer saw the final echo succeeded, so it ran the “SUCCEEDED” message. This is dangerous because a backup could fail and you wouldn’t know!

    Testing the Good Script:

    Bash

    Result: The script stops at exit 1. The computer sees the 1, skips the && part, and triggers the || (failure) part.

    Here are the results as copied from my terminal:

    Why this matters in the real world

    Imagine you have a script that:

    1. Deletes your old files.
    2. Copies your new files (The Backup).
    3. Cleans up the temporary folder.

    If Step 2 (The Backup) fails because the disk is full, but you didn’t write exit 1, the script will continue to Step 3 and delete your only remaining copies, thinking everything is fine!

    Summary: * With exit 1: The script “screams” when there is a problem.

    • Without it: The script “whispers” an error but smiles at the computer, pretending everything is perfect.

    Note: about the use of “bash” at the start of the command below:


    1. Manual Interpreter: Typing bash before your filename manually tells the system to use the Bash program to translate and run your script’s code.

    2. Bypasses Permissions: It allows you to execute a script immediately without needing to set “executable” permissions via chmod +x.

    3. Ensures Compatibility: It guarantees the script runs in the full Bash environment rather than a limited shell (like sh) that might misunderstand your syntax.

    4. Testing Logic: It is the most reliable way to test if your exit 0 and exit 1 codes are working correctly before finalizing the script for automation.