Files
nicholaswilde_recipes/scripts/add_recipe_size.py
2026-07-02 20:55:35 -07:00

288 lines
9.6 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
################################################################################
#
# add_recipe_size.py
# ----------------
# Add file size metadata to recipes
#
# @author nιcнolaѕ wιlde, 0x08b7d7a3
# @date 13 Jun 2026
# @version 0.1.0
#
################################################################################
import sys
import os
import re
def parse_qty_val(qty_str):
qty_str = qty_str.strip()
# Mixed fraction "1 1/2"
m_mixed = re.match(r'^(\d+)\s+(\d+)/(\d+)$', qty_str)
if m_mixed:
return float(m_mixed.group(1)) + float(m_mixed.group(2)) / float(m_mixed.group(3))
# Simple fraction "1/2"
m_frac = re.match(r'^(\d+)/(\d+)$', qty_str)
if m_frac:
return float(m_frac.group(1)) / float(m_frac.group(2))
# Decimal or integer
try:
return float(qty_str)
except ValueError:
return None
def format_qty(val):
if val == 0:
return ""
if val.is_integer():
return str(int(val))
# Check common fractions
fracs = {0.25: "1/4", 0.5: "1/2", 0.75: "3/4", 0.33: "1/3", 0.333: "1/3", 0.66: "2/3", 0.667: "2/3", 0.125: "1/8", 0.375: "3/8", 0.625: "5/8", 0.875: "7/8"}
rem = val - int(val)
for f_val, f_str in fracs.items():
if abs(rem - f_val) < 0.01:
if int(val) > 0:
return f"{int(val)} {f_str}"
else:
return f_str
# Otherwise format as decimal, rounding to 2 decimal places
return f"{val:.2f}".rstrip('0').rstrip('.')
def scale_ingredient_line(line, factor):
# Keep the leading indentation, the dash, and the optional emoji
m_prefix = re.match(r'^(\s*-\s*)(:\w+:\s*)?', line)
if not m_prefix:
return line # Not a standard ingredient bullet point
prefix = m_prefix.group(0)
rest = line[len(prefix):]
# 1. Scale the weight in parentheses if present, e.g. "(113 g)" or "(113g)"
def scale_weight(match):
num_str = match.group(1)
val = parse_qty_val(num_str)
if val is not None:
scaled_val = val * factor
# round to integer for grams
return f"({int(round(scaled_val))} g)"
return match.group(0)
rest = re.sub(r'\((\d+(?:\.\d+)?)\s*g\)', scale_weight, rest)
# 2. Scale the quantity at the start of the rest of the text
# mixed fraction, simple fraction, decimal, or integer
m_qty = re.match(r'^(\d+\s+\d+/\d+|\d+/\d+|\d+\.\d+|\d+)\b', rest)
if m_qty:
qty_str = m_qty.group(1)
val = parse_qty_val(qty_str)
if val is not None:
scaled_val = val * factor
scaled_str = format_qty(scaled_val)
rest = scaled_str + rest[len(qty_str):]
return prefix + rest
def extract_and_remove_serves(content):
lines = content.splitlines()
table_start = -1
table_end = -1
for i in range(min(30, len(lines))):
if lines[i].strip().startswith("|") and ":" in lines[i]:
if i > 0 and lines[i-1].strip().startswith("|") and "Total Time" in lines[i-1]:
table_start = i-1
j = i
while j < len(lines) and lines[j].strip().startswith("|"):
j += 1
table_end = j
break
original_serves = None
if table_start != -1:
table_lines = lines[table_start:table_end]
headers = [h.strip() for h in table_lines[0].split("|")[1:-1]]
alignments = [a.strip() for a in table_lines[1].split("|")[1:-1]]
values = [v.strip() for v in table_lines[2].split("|")[1:-1]]
serves_idx = -1
for idx, h in enumerate(headers):
if "Serves" in h or "fork_and_knife_with_plate" in h:
serves_idx = idx
break
if serves_idx != -1:
original_serves = values[serves_idx]
new_headers = [headers[idx] for idx in range(len(headers)) if idx != serves_idx]
new_alignments = [alignments[idx] for idx in range(len(alignments)) if idx != serves_idx]
new_values = [values[idx] for idx in range(len(values)) if idx != serves_idx]
new_table_lines = [
"| " + " | ".join(new_headers) + " |",
"| " + " | ".join(new_alignments) + " |",
"| " + " | ".join(new_values) + " |"
]
lines[table_start:table_end] = new_table_lines
content = "\n".join(lines)
return content, original_serves
def find_ingredients_range(lines):
start_idx = -1
end_idx = -1
for idx, line in enumerate(lines):
if re.match(r'^##\s+.*Ingredients', line, re.IGNORECASE):
start_idx = idx
for j in range(idx + 1, len(lines)):
if lines[j].startswith("##"):
end_idx = j
break
if end_idx == -1:
end_idx = len(lines)
break
return start_idx, end_idx
def add_size_to_existing_tabs(ing_lines, factor, new_tab_name):
first_tab_lines = []
inside_first_tab = False
for line in ing_lines:
if line.strip().startswith('=== "'):
if not first_tab_lines:
inside_first_tab = True
continue
else:
break
if inside_first_tab:
if line.strip() == "" or line.startswith(" "):
first_tab_lines.append(line)
else:
break
# Strip leading and trailing blank lines
while first_tab_lines and first_tab_lines[0].strip() == "":
first_tab_lines.pop(0)
while first_tab_lines and first_tab_lines[-1].strip() == "":
first_tab_lines.pop()
scaled_lines = []
for line in first_tab_lines:
if line.startswith(" -"):
clean_line = line[4:]
scaled_clean = scale_ingredient_line(clean_line, factor)
scaled_lines.append(" " + scaled_clean)
else:
scaled_lines.append(line)
last_idx = len(ing_lines) - 1
while last_idx >= 0 and ing_lines[last_idx].strip() == "":
last_idx -= 1
new_tab_block = [
f'=== "{new_tab_name}"\n',
"\n"
] + [line + "\n" if not line.endswith("\n") else line for line in scaled_lines]
result = ing_lines[:last_idx + 1] + ["\n"] + new_tab_block + ing_lines[last_idx + 1:]
return result
def convert_plain_list_to_tabs(ing_lines, factor, new_tab_name, original_serves):
original_list_lines = []
header_lines = []
trailing_lines = []
inside_list = False
for line in ing_lines:
if line.strip().startswith("-") or line.strip().startswith("*"):
inside_list = True
original_list_lines.append(line)
else:
if not inside_list:
header_lines.append(line)
else:
trailing_lines.append(line)
if original_serves:
if original_serves.isdigit():
original_tab_name = f"Serves {original_serves}"
else:
original_tab_name = original_serves.title()
else:
original_tab_name = "Original"
scaled_list_lines = []
for line in original_list_lines:
m_indent = re.match(r'^(\s*)', line)
indent = m_indent.group(1) if m_indent else ""
clean_line = line[len(indent):]
scaled_clean = scale_ingredient_line(clean_line, factor)
scaled_list_lines.append(" " + indent + scaled_clean)
formatted_original_lines = []
for line in original_list_lines:
m_indent = re.match(r'^(\s*)', line)
indent = m_indent.group(1) if m_indent else ""
clean_line = line[len(indent):]
formatted_original_lines.append(" " + indent + clean_line)
result = []
result.extend(header_lines)
result.append("\n")
result.append(f'=== "{original_tab_name}"\n')
result.append("\n")
result.extend(formatted_original_lines)
result.append("\n")
result.append(f'=== "{new_tab_name}"\n')
result.append("\n")
result.extend(scaled_list_lines)
result.extend(trailing_lines)
return result
def main():
if len(sys.argv) < 4:
print("Usage: add_recipe_size.py <recipe.md> <scale_factor> <tab_name>")
sys.exit(1)
md_path = sys.argv[1]
if not os.path.exists(md_path):
print(f"Error: {md_path} not found")
sys.exit(1)
try:
factor = float(sys.argv[2])
except ValueError:
print(f"Error: scale_factor must be a float, got {sys.argv[2]}")
sys.exit(1)
new_tab_name = sys.argv[3]
with open(md_path, "r", encoding="utf-8") as f:
content = f.read()
content, original_serves = extract_and_remove_serves(content)
lines = content.splitlines(keepends=True)
start_idx, end_idx = find_ingredients_range(lines)
if start_idx == -1:
print("Error: Ingredients section not found in file.")
sys.exit(1)
ing_lines = lines[start_idx:end_idx]
has_tabs = any('=== "' in line for line in ing_lines)
if has_tabs:
updated_ing_lines = add_size_to_existing_tabs(ing_lines, factor, new_tab_name)
else:
updated_ing_lines = convert_plain_list_to_tabs(ing_lines, factor, new_tab_name, original_serves)
lines[start_idx:end_idx] = updated_ing_lines
with open(md_path, "w", encoding="utf-8") as f:
f.writelines(lines)
print(f"Successfully added size '{new_tab_name}' (factor {factor}) to {md_path}")
if __name__ == "__main__":
main()