Blog Post base32 ID Cleanup

July 2026

The IDs for posts on this site are generated by using base32b32 encoding to convert timestamps into strings of text. The idea being to get shorter IDs that still sort in chronological order. For example, this timestamp:

2026-07-20T13:27:00-04:00

Turns into this snippet:

01n5wpk4

It works great, as long as you do the encoding properly. It turns out, Dear Reader, that I did not do it properly.

This is the process I wrote to fix them. It does two things:

Rust

./Cargo.toml
[package]
name = "base32_timestamp_cleanup"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0.104"
base32 = "0.5.1"
chrono = "0.4.45"
regex = "1.13.1"
walkdir = "2.5.0"
./src/main.rs
use anyhow::Result;
use base32::Alphabet;
use chrono::{DateTime, FixedOffset};
use regex::Regex;
use std::{fs, path::PathBuf};
use walkdir::WalkDir;

fn main() -> Result<()> {
  update_file_ids()?;
  make_blog_folders()?;
  println!("done.");
  Ok(())
}

fn make_blog_folders() -> Result<()> {
  let base_dir = "../../../blog";
  let id_matcher = Regex::new(r#"id\s+=\s+"(..)/(..)/(..)/(..)""#)?;
  let files = get_files_with_extensions(
    &PathBuf::from(base_dir),
    &vec!["html"],
  );
  for f in files {
    let content = fs::read_to_string(&f)?;
    if let Some((_, [f1, f2, f3, f4])) = id_matcher
      .captures_iter(&content)
      .next()
      .map(|c| c.extract())
    {
      let new_folder =
        PathBuf::from(base_dir).join(f1).join(f2).join(f3).join(f4);
      dbg!(&new_folder);
      // NOTE: Uncomment this line to make the
      // dirs. It should be idempotent, but
      // there's no need to test that.
      // std::fs::create_dir_all(new_folder)?;
    }
  }
  Ok(())
}

fn update_file_ids() -> Result<()> {
  let files = get_files_with_extensions(
    &PathBuf::from("../../.."),
    &vec!["html"],
  );
  let date_matcher = Regex::new(
    r"created\s+=\s+(\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d-\d\d:\d\d)",
  )?;
  let id_matcher = Regex::new(r#"id\s+=\s+"(../../../..)""#)?;
  for f in files {
    let content = fs::read_to_string(&f)?;
    if let Some((_, [date])) = date_matcher
      .captures_iter(&content)
      .next()
      .map(|c| c.extract())
    {
      let dt = DateTime::parse_from_rfc3339(date)?;
      let encoded = encode_base32_datetime(&dt);
      let new_id = format!(
        "{}/{}/{}/{}",
        &encoded[0..=1],
        &encoded[2..=3],
        &encoded[4..=5],
        &encoded[6..=7],
      );
      dbg!(&new_id);
      if let Some((_, [old_id])) = id_matcher
        .captures_iter(&content)
        .next()
        .map(|c| c.extract())
      {
        dbg!(&old_id);
        // NOTE: Uncomment these lines to do the replacement.
        // It should be safe to do multiple times, but
        // there's no reason to test that.
        // let new_content = content.replace(old_id, &new_id);
        // fs::write(&f, new_content)?;
      }
    }
  }
  Ok(())
}

fn encode_base32_datetime(
  datetime: &DateTime<FixedOffset>
) -> String {
  let bytes = &datetime.timestamp().to_be_bytes()[3..8];
  base32::encode(Alphabet::Crockford, bytes).to_lowercase()
}

fn get_files_with_extensions(
  dir: &PathBuf,
  extensions: &Vec<&str>,
) -> Vec<PathBuf> {
  WalkDir::new(dir)
    .into_iter()
    .filter_map(|e| {
      let path = e.as_ref().unwrap().path();
      match path.extension() {
        Some(ext) => {
          if extensions.contains(&ext.to_str().unwrap()) {
            Some(path.to_path_buf())
          } else {
            None
          }
        }
        None => None,
      }
    })
    .collect()
}

Measure Twice

I checked the original method I was using to make sure things were sorting properly. Unfortunately, I didn't check all the cases I needed to. The issue only shows up when there are numbers in the output string. The encoding should have put numbers before letters. It didn't. They were reversed.

I considered not bothering with the update. It doesn't matter for the site itself. The only place it makes a difference is when I'm editing files. It's easier to find them when they are sorted by date. The goal of the site builder I'm working on is to use it for the next 20 years. Spending the time on the correction is an easy investment given that perspective.

-a

Endnotes

  • I moved the pages to new locations that match their updated IDs. I didn't bother setting up redirects. Incoming links that point to the old locations will get a Page Not Found error. I don't expect that to be enough of a problem that it's worth setting up redirects for.

Footnotes

  • b32 base32 is a process that takes an input and coverts it into a string of characters that can be shorter than the original. For example, the timestamps I use look like this:
id = "01/n5/wp/k4"