Quick script to refresh a Hugo Post Date

This is a short 🩳; a note that doesn’t merit a full post. See all shorts here.

I tend to go back to old, unpublished posts every now and restart my work on them. They’re just resting, hiding behind the -D flag of hugo server.

But when I want to publish said post, I want the date to be the current date, not the creation date. So I wrote a script to update the date of a post to the current date.

Here’s the script in action:

❯ go run main.go -file ../../content/posts/example.md -verbose
12:16PM DBG Current frontmatter Date=2024-04-27T12:10:21+03:00
12:16PM DBG After editing frontmatter date=2024-04-27T12:16:14+03:00
12:16PM INF Refreshed by diff=5m53s
12:16PM INF Successfully updated the date! πŸŽ‰ post=../../content/posts/example.md
-date: "2023-12-10T21:33:29+02:00"
+date: "2024-04-27T12:16:14+03:00"

Here’s the code:

// A short script to update the date in the frontmatter of a Hugo post.
// The date is updated to the current date and time.
// Run this script with `go run scripts/refresh-post-date/main.go -file path/to/post.md`,
// and you'll see:
//   > ❯ go run main.go -file path/to/post.md
//   > 12:10PM INF Refreshed by diff=1m55s
//   > 12:10PM INF Successfully updated the date! πŸŽ‰ post=path/to/post.md
// You can add the `-verbose` flag to see more detailed logs.

package main

import (
	"errors"
	"flag"
	"os"
	"strings"
	"time"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	"gopkg.in/yaml.v3"
)

const (
	FrontmatterSeparatorLength = 3
	HugoTimeFormatAsGoFormat   = "2006-01-02T15:04:05-07:00"
)

func parseFrontmatter(fullPost []byte) ([]byte, error) {
	fullPostAsString := string(fullPost)
	// The frontmatter is between two lines of `---`
	startIndex := strings.Index(fullPostAsString, "---")
	if startIndex == -1 {
		return nil, errors.New("failed to find the start of the frontmatter")
	}
	endIndex := strings.Index(fullPostAsString[startIndex+FrontmatterSeparatorLength:], "---")
	if endIndex == -1 {
		return nil, errors.New("failed to find the end of the frontmatter")
	}
	endIndex += startIndex + FrontmatterSeparatorLength

	return []byte(fullPostAsString[startIndex:endIndex]), nil
}

func parseFrontmatterFromFullPostToMap(fullPost []byte) (map[string]any, error) {
	frontmatterMap := make(map[string]any)
	frontmatterContent, err := parseFrontmatter(fullPost)
	if err != nil {
		return nil, err
	}

	yamlErr := yaml.Unmarshal(frontmatterContent, &frontmatterMap)
	if yamlErr != nil {
		return nil, errors.Join(yamlErr)
	}

	return frontmatterMap, nil
}

func main() {
	log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) //nolint:exhaustruct

	filePath := flag.String("file", "", "File path")
	verbose := flag.Bool("verbose", false, "Verbose output")
	flag.Parse()

	if *filePath == "" {
		flag.Usage()
		log.Fatal().Msg("Please provide a file path")
	}
	if *verbose {
		log.Logger = log.Level(zerolog.DebugLevel)
	} else {
		log.Logger = log.Level(zerolog.InfoLevel)
	}

	refreshDateInPost(*filePath)
}

// refreshDateInPost updates the date in the frontmatter of a Hugo post.
//
// Expects frontmatter in YAML format and between `---` separators.
// The date is updated to the current date and time, so make sure you're not in
// the middle of editing the post when running this script.
// See https://gohugo.io/content-management/front-matter/ for more information.
func refreshDateInPost(hugoPostFilePath string) {
	content, err := os.ReadFile(hugoPostFilePath)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to read file")
	}
	currentFrontmatter, err := parseFrontmatterFromFullPostToMap(content)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to parse frontmatter")
	}

	currentDate := currentFrontmatter["date"]
	log.Debug().Any("Date", currentDate).Msg("Current frontmatter")

	newDate := time.Now().Format(HugoTimeFormatAsGoFormat)
	currentFrontmatter["date"] = newDate
	log.Debug().Any("date", currentFrontmatter["date"]).Msg("After editing frontmatter")

	oldDateTime, err := time.Parse(HugoTimeFormatAsGoFormat, currentDate.(string))
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to parse old date")
	}
	newDateTime, err := time.Parse(HugoTimeFormatAsGoFormat, newDate)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to parse new date")
	}
	diff := newDateTime.Sub(oldDateTime)
	log.Info().Str("diff", diff.String()).Msg("Refreshed by")

	newFrontmatter, err := yaml.Marshal(currentFrontmatter)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to marshal frontmatter")
	}

	oldFrontmatter, err := parseFrontmatter(content)
	if err != nil {
		log.Error().Err(err).Msg("Failed to parse frontmatter")
		os.Exit(1)
	}
	newContent := strings.Replace(string(content), string(oldFrontmatter), "---\n"+string(newFrontmatter), 1)

	err = os.WriteFile(hugoPostFilePath, []byte(newContent), 0o644) //nolint:gosec,gomnd
	if err != nil {
		log.Error().Err(err).Msg("Failed to write to file")
		os.Exit(1)
	}

	log.Info().Str("post", hugoPostFilePath).Msg("Successfully updated the date! πŸŽ‰")
}

And here’s a link to a gist as well.

Now, I can finally go to that old post I wanted to write and start working on it. …What was it about again? πŸ€”

A rant about Go’s Time formatting

Go time formats bite me on the ass, AGAIN. grumble

I’ve proposed to support letter-based formats, such as “yyyy-mm-dd HH:MM:SS” in time.Parse. For more context listen to this Cup o’ Go episode where I talk about it.

What happened this time?

  1. Trying to write some code to mess around with my Hugo blog.
  2. Find this post and pull request from Jamie Tanna.
  3. OK, so I need ISO 8061 time format. SURELY I won’t have to write one out, right? Writing time.I in the IDE and waiting for the ISO to show up in the autocomplete…
  4. OK, nothing… Let’s look it up online
  5. Debug some more, and discover I need an even more custom format (need to add a : in the timezone)
  6. Cry and copy-paste the string from StackOverflow since there’s no chance I’ll be able to write it first try.

Ended up with

const (
  HugoTimeFormatAsGoFormat   = "2006-01-02T15:04:05-07:00"
)