Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

On-the-fly data entry with Markdown and Perl

Jerry Stratton, August 26, 2026

List of recipes to try: A list of recipes to try in Mary Margaret McBride’s Encyclopedia of Cooking.; recipes

The problem with this is not that the handwriting is bad on this note (it is).

One of my resolutions this year was to start focusing on the cookbooks I already own rather than acquiring more. Whenever I buy a new cookbook, I make a note of the recipes I’d like to try most and put the list inside the front page. This means that most of my cookbooks have a postcard or notebook sheet listing the recipes I find interesting. When I want to try out a particular cookbook, I am easily reminded of what I thought was interesting about it when I bought it.

But if I want to look for a particular recipe among all of my cookbooks, that becomes an obviously more involved process. So, I decided to convert each of those paper lists into a single searchable file on my computer.

Typing that sort of thing into a database cell by cell is time consuming and annoying. It requires paying attention to the screen rather than just typing. I decided to use a faster format for typing desired recipes book by book, and then write a quick script to convert that fast format into a tab-delimited file for import into a database or spreadsheet.

As with most of my quickie text conversion scripts, that faster format looks a lot like Markdown.

Here’s what the source file ended up looking like:

  • # Australia Jet Age Cookbook
  • 1970
  • *22 Caramel Squares (golden syrup)
  • 23 Heavenly Slices
  • *36 Ginger Beer
  • 15 Fruit Salad Ice Cream
  • # Mirro Dough Mixer & Bread Baking Kit
  • 1975
  • *16 Coconut Coffee Cake
  • 14 Stollen
  • 8 Oatmeal Bread
  • *Cornmeal Bread
  • 6 Health Bread
  • 10 Kolaches
  • *12 Hungarian Coffee Cake
  • *Herb Rolls
  • 15 Apple Cake

That is, I type the book title once, and the year the book was published; then, page numbers also only get typed once. Special recipes are marked with an asterisk at the start of the line. It’s very fast. I was able to do a full bookcase, a half bookcase, and the display shelf by the dinner table, all in just a few days by typing during my spare moments here and there.

The script turns that format into a file more readily readable by apps such as Google’s spreadsheet, or any database program.

RecipePageSpecialCookbookYear
Caramel Squares (golden syrup)221Australia Jet Age Cookbook1970
Heavenly Slices230Australia Jet Age Cookbook1970
Ginger Beer361Australia Jet Age Cookbook1970
Fruit Salad Ice Cream150Australia Jet Age Cookbook1970
Coconut Coffee Cake161Mirro Dough Mixer & Bread Baking Kit1975
Stollen140Mirro Dough Mixer & Bread Baking Kit1975
Oatmeal Bread80Mirro Dough Mixer & Bread Baking Kit1975
Cornmeal Bread81Mirro Dough Mixer & Bread Baking Kit1975
Health Bread60Mirro Dough Mixer & Bread Baking Kit1975
Kolaches100Mirro Dough Mixer & Bread Baking Kit1975
Hungarian Coffee Cake121Mirro Dough Mixer & Bread Baking Kit1975
Herb Rolls121Mirro Dough Mixer & Bread Baking Kit1975
Apple Cake150Mirro Dough Mixer & Bread Baking Kit1975
Google Spreadsheet foods to try import: Result of importing a simple tab-delimited file of recipes to try into Google Spreadsheet.; Google; spreadsheets

Google could be a little smarter about the import, but it worked okay. You’ll need to use View:Freeze to mark the top row as a header row.

The resulting tab-delimited file works well even using the rudimentary JavaScript data display feature built into this website. That script automatically detected the column headers, and you can click on each header to sort by that column.

This is not a difficult script in Perl. It’s all of about twenty lines, depending on how you count them. It’s a very good example of how simple scripting can make life a lot easier, at least when life involves data entry. It’s what I wrote Astounding Scripts to highlight.

[toggle code]

  • #!/usr/bin/perl
  • #convert a cookbook-format list into a tab-delimited file
  • #Jerry Stratton astoundingscripts.com
  • use open qw(:std :encoding(UTF-8));
  • print "Recipe\tPage\tSpecial\tCookbook\tYear\n";
  • while (<>) {
    • chomp;
    • next if m/^$/;
    • if (m/^# (.*)$/) {
      • $title = $1;
      • $year = '';
      • $page = '';
      • next;
    • } elsif (m/^([12][7890][0-9]{2})$/) {
      • $year = $1;
      • next;
    • }
    • $special = s/^\*// ? 1 : 0;
    • $page = $1 if s/^([0-9A.]+) //;
    • die("No title for line $_\n") if $title eq '';
    • die("No year for line $_\n") if $year eq '';
    • print "$_\t$page\t$special\t$title\t$year\n";
  • }
Peach Walnut Bread: Banana Walnut Bread from The Common Ground Dessert Cookbook, of Brattleboro, Vermont, using the Peach variation.; bread; walnuts; peaches; Brattleboro, Vermont

This peach walnut bread from The Common Ground Dessert Cookbook is a good example of why I need to start looking at my existing cookbooks instead of buying new ones!

The first line of code, use open qw(:std :endcoding(UTF-8));, sets up UTF-8 support. It tells Perl that any incoming text contains UTF-8 characters. I usually use UTF-8 in whatever text editor I’m writing in. If you don’t know what you’re using, you’re probably using it, too. It’s the most common method of supporting accents and other special characters.

It’s the difference between seeing “Souffle de Grùyère” in your database or “Souffle de Grùyère”.

As you can see from the sample table, each record contains five pieces: the title of the cookbook, the year of the cookbook, the title of each recipe, and the page the recipe is on. The fifth piece of data comes from the asterisk that is sometimes at the beginning of a line, such as for the Australian Caramel Squares or the Ginger Beer. The asterisk is for a “special” recipe, one I really want to try.

The first line prints out the column names: Recipe, Page, Special, Cookbook, and Year. Most, if not all, database or spreadsheet import functions will recognize the first line as column names and name your columns, or fields, appropriately and automatically from that first line.

Then the script starts looping. That is, it repeats for every line in the file or files given to it on the command line.

  • notesToDB Recipes.txt > RecipeData.csv

The code while (<>) { begins the loop. The <> means to pull from standard input, which is likely to be the file of recipes I’ve typed in. It tells Perl to automatically start reading any files given to the script on the command line.

Spanish Potatoes: Potatoes, Spanish-Style, from the 1959 Mary Margaret McBride’s Encyclopedia of Cooking.; Spanish; potatoes; Mary Margaret McBride’s Encyclopedia of Cooking

These very simple “Spanish-style” potatoes from Mary Margaret McBride’s Encyclopedia of Cooking are another good reason.

The chomp cuts off the carriage return at the end of every line.

If a line is empty, the code next if m/^$/; detects that and skips to the next line of the file. This will become more clear as the rest of the script is explained, but ^ matches the beginning of a line, the $ matches the end, and that’s all that matches: a beginning and an end with nothing in between.

Usually there’s an empty line before each new book, purely for readability.

Is it special?

As the script loops through each line, the only value it resets on every loop is whether or not the recipe is special.

  • $special = s/^\*// ? 1 : 0;

Whether the asterisk is there or not, $special gets a new value every pass through the loop. This line can be rewritten in English as:

$special = 1 if the regular expression matches or 0 if it doesn’t;

That is, in pseudo-pseudocode,

$value = test ? value if test is positive : value if test is negative;

In this case, the “test” is s/^\*//. This is a regular expression, something Perl is well suited for. For most uses, there are two types of regular expressions in Perl, a match and a substitution. This line begins with an ’s’, so it’s a substitution. The format is:

  1. An ’s’ to say that this is a substitution.
  2. A slash.
  3. The regular expression, in this case ^\*.
  4. Another slash.
  5. The replacement. If something matches the regular expression, this is what it gets replaced—that is, substituted—with.
  6. A final slash.

The ^ in the regular expression matches the beginning of a line. The asterisk is a special character in a regular expression, so if I actually want to match an asterisk I need to prefix it with a backslash. Thus, \* matches an asterisk.

For any line with an asterisk at the beginning of the line, this regular expression matches.

The substitution, in this case, is empty: there is nothing between the second and third slashes. When that regular expression matches—an asterisk at the beginning of a line—it is replaced with nothing. “*36 Ginger Beer” becomes “36 Ginger Beer”. “*Cornmeal Bread” becomes “Cornmeal Bread”.

Yogurt Apple Pie: Yogurt Apple Pie, from Heininger and Malinowski’s 1979 Creative Natural Cooking.; pie; apples; yogurt

This Yogurt Apple Pie has been on my “short” list since 2023.

The asterisk is removed.

But if an asterisk was removed, that means the regular expression matched, and that means the variable $special is set to 1. If an asterisk was not removed, that means the regular expression did not match, and $special is set to 0.

What page is it on?

The line that sets $page is a little different from the line that sets $special. When I have more than one recipe per page, I only need to type the page number for the first recipe. That means a different kind of test is needed. While I want to remove the page number from the recipe title if there is a page number, I do not want to modify any existing page number if there isn’t a new number.

  • $page = $1 if s/^([0-9A.]+) //;

This line of code only assigns a value to $page if the regular expression matches. The regular expression s/^([0-9A.]+) // is another substitution. It, also, is anchored to the beginning of the line by way of the ‘^’. In a regular expression, a set of parentheses remembers that part of the match for later.

Here, the parentheses surround [0-9A.]+. The characters between square brackets are a set of possible matches. The set matched here are any digit from 0 to 9 along with a capital A and a period.

The dash in “0-9” means any character from zero to nine.1

The reason I want periods (and the letter A) to match is that some books (mainly the El Molino Best cookbook) use letters for numbering similar recipes. Some (such as collections of multiple books) restart page counts partway through, so that there is a page number 5 in section 1, and it resets to a page number 1 in section 2, and so on. I use a period for those: 2.5 is section 2, page 5.2

The + symbol in a regular expression means, “at least one of the previous character, up to any amount”. From that set, any amount of digits, A, and a period, will match.

Finally, the final character in the regular expression is a space. There must be a space after the page number and before the recipe title. This line will turn “36 Ginger Beer” into “Ginger Beer”, remembering “36” as the page number. It will leave “Cornmeal Bread” alone, and leave the page number what it was before (in this case, it will be the 8 from the Oatmeal Bread above it).

Biscuits de Savoye with Sesame Frosting: Thomas Jefferson’s Biscuits de Savoye with brown sugar-sesame seed frosting.; Thomas Jefferson; sesame; cake

I needed a frosting for cupcakes I made for my Independence Day food post, and found a Harvest Walnut Frosting I’d been wanting to make.

The page number, if there is one, will be in $1, that is, in the “first” match. You can have more than one set of parentheses in a regular expression, and each will be placed, in order, in $1, $2, $3, etc.

What is the book title?

Every recipe must appear in a “book” and each book must have a year. I enforce that with these lines:

  • die("No title for line $_\n") if $title eq '';
  • die("No year for line $_\n") if $year eq '';

If either $title or $year are empty, the script dies with an error.

Page numbers aren’t necessary. Only book title, year, and recipe title are necessary. Some pamphlets or sliders don’t have page numbers or even recipe numbers.

The script recognizes a title as a line that begins with a single pound sign and a space:

  • if (m/^# (.*)$/) {

This is a match regular expression. It begins with the letter ‘m’. The format for a match is the same as the format for a substitution, except that it doesn’t need the replacement.

  1. An ’m’ to say that this is a match.
  2. A slash.
  3. The regular expression, in this case ^# (.*)$.
  4. A final slash.

Like all of the regular expressions in this script, it is anchored to the beginning of the line. It is also anchored to the end of the line: the dollar sign means “the end of the line”. So ^# (.*)$ matches if the entire line matches # (.*).

The line must begin with a pound sign (‘#’) and a space. The parentheses will remember whatever matches .*. A period in a regular expression is any character. It doesn’t matter what the character is, it will match. The asterisk—remember that the asterisk has a special meaning?—matches any number of the previous character. You will see .* a lot in regular expressions. It means any amount of any character. “Any amount” can mean zero, or one, or more than one.

Spanish Soup with Olive Butter: Spanish Soup with Olive Butter, from the 1916 Royal Baking Powder book, Table & Kitchen.; Spanish; soups and stews; olives; cabbage; beans

This 1916 Spanish Soup with Olive Butter from Table & Kitchen has been on my list for a while, too.

In other words, whatever follows a pound sign and a space, this script will interpret as the title of a new cookbook. It will even match nothing as the title, but the die command that checks that $title isn’t empty will catch that. This ensures that I don’t get distracted and forget to type the title after typing pound space.

Once the script detects the title of a new book, everything gets reset. It doesn’t make sense to maintain page numbers across different books. It could make sense to enter the books in chronological order so that the year might match, but I didn’t do it that way. For one, I often forget to type the year, and resetting it gives me a reminder that I forgot. The script will die with an error if there is no year.

The next command in Perl skips the rest of the loop and goes back to the top for the next line of text.

What is the book’s year?

The script detects any line with four numbers and nothing else as a year, if the first digit in the number is a 1 or a 2 and the second digit is a 7, 8, 9, or 0.

Thus, it will recognize 1700 as a year, and it will recognize 2025 as a year. It will not recognize 2125 as a year (there’s a 1 in the second spot and it needs to be a 7, 8, 9, or 0). Nor will it recognize 3832 as a year (there’s a 3 in the first spot, and it needs to be a 1 or a 2).

In other words, only numbers from 1000 to 1099 and 1700 to 2999 match as a year.

I have to allow the 1700s because the earliest cookbook in my collection is the 1796 American Cookery. The only reason it goes all the way to 2999 is to match the high end of the 1800s and the 1900s. If I live to 2999 I’ll be very impressed. And probably no longer using this script in any case. Similarly, that 1000 through 1099 match is a side effect of being in a new century with a new first digit. I want to match a one at the beginning for the 1700s, 1800s, and 1900s; and I want to match a zero in the second digit for the 2000s.

There are ways to avoid those spurious matches, but they’re not worth the trouble in this case.

Here’s the regular expression that handles those rules:

  • } elsif (m/^([12][7890][0-9]{2})$/) {
Douglas Adams on programming: “I … am rarely happier than when spending an entire day programming my computer to perform automatically a task that it would otherwise take me a good ten seconds to do by hand. Ten seconds, I tell myself, is ten seconds. Time is valuable and ten seconds’ worth of it is well worth the investment of a day’s happy activity working out a way of saving it.”; Douglas Adams; programming; saving time; programming for all

I often quote Douglas Adams’s joke on the tradeoff between time saved by programming and time used by programming. In this case, the tradeoff was so lopsided it was a no-brainer even without the joke.

The ^ requires that the regular expression match at the beginning of the line. The $ at the end requires that it match at the end of the line. This means that there can be no extraneous text. The text must be those specific four digits, with nothing else on the line.

Square brackets, remember, are a set of characters. The [12] matches a one or a two. The [7890] matches a 7, 8, 9, or 0. This matches the 1700s, the 1800s, the 1900s and the 2000s, as well as some centuries I’m not likely to need: the 1000s, the 2700s, the 2800s, and the 2900s. The [0-9]{2} matches exactly two digits. Any number from 00 to 99 will match the final two digits of the regular expression.

Thus, that regular expression will match only four digit numbers that begin with a one or a two, continue with a seven, eight, nine, or zero, and end with exactly two of any number from 0 to 9.

And then next skips the rest of the loop, because this is a year and not a recipe.

Print the tab delimited data

At the end of every loop, unless a next command skipped over it, the record is printed:

  • print "$_\t$page\t$special\t$title\t$year\n";

The code $_ means “whatever remains in the line at this point”. We’ve already removed any asterisk and any page number, so the recipe title is all that remains in the line. The code \t means a tab. And $page, $special, $title, and $year are the variables that contain those pieces of data.

The title and year of the cookbook are maintained until the next Markdown headline and the next appearance of a year. Any page number is maintained until either a new page number, or a new book title.

This is a basic script for making it easier to enter lots of data quickly. The only thing special about this script is that there is absolutely nothing special about it. It’s just the basic kind of filter that makes data entry easier and more reliable at the same time. The same technique can be used with any data entry where it’s easier to type freeform than to type record by record.

Which it usually is.

  1. If I also want to match a dash in a square bracket set, the dash has to be the last character in the set. Otherwise, it looks like a range. So, if I wanted all of the digits plus a dash to match, I would use [0-9-].

  2. I originally used a dash to denote section-page. However, sorting pages numerically is a useful thing to be able to do. Dashes turn the page number into a string, which will sort alphabetically. Periods turn the page number into a number with decimals, which will sort numerically.

  1. <- macOS parent mailboxes