Recipe Cost Estimator

import tkinter as tk

from tkinter import ttk, messagebox, filedialog

import pandas as pd

import re

import os

import requests

from bs4 import BeautifulSoup

import webbrowser

from io import StringIO


# -------------------------

# Configuration & Helpers

# -------------------------


PRICE_DB = "prices.csv"  # local prices DB: columns -> item,price,unit (unit: kg or piece)

DEFAULT_PRICE_DATA = """item,price,unit

flour,40,kg

sugar,55,kg

milk,60,l

egg,6,piece

butter,450,kg

salt,20,kg

olive oil,600,litre

rice,60,kg

onion,40,kg

garlic,400,kg

"""


# Unit conversions to grams or ml (approximate / generic)

# you can refine per-ingredient or add density based conversions

UNIT_TO_GRAMS = {

    "g": 1,

    "gram": 1,

    "grams": 1,

    "kg": 1000,

    "kilogram": 1000,

    "kilograms": 1000,

    "mg": 0.001,

    "lb": 453.592,

    "oz": 28.3495,

    # common kitchen volume to grams approximations (generic)

    "cup": 240,       # ml ~ grams for water-like density; adapt per-ingredient

    "cups": 240,

    "tbsp": 15,

    "tablespoon": 15,

    "tsp": 5,

    "teaspoon": 5,

    "ml": 1,          # ml ~ grams for water-like density

    "l": 1000,

    "litre": 1000,

    "liter": 1000,

    "piece": None,    # piece handled separately

    "pcs": None,

    "pc": None

}


# Normalize function

def normalize_item_name(name):

    return re.sub(r'[^a-z0-9 ]', '', name.lower()).strip()


# -------------------------

# Price DB functions

# -------------------------

def ensure_price_db_exists():

    if not os.path.exists(PRICE_DB):

        with open(PRICE_DB, "w", encoding="utf-8") as f:

            f.write(DEFAULT_PRICE_DATA)


def load_price_db():

    ensure_price_db_exists()

    df = pd.read_csv(PRICE_DB)

    # normalize item column to match lookups

    df['item_norm'] = df['item'].apply(lambda x: normalize_item_name(str(x)))

    return df


def save_price_db(df):

    df = df.copy()

    if 'item_norm' in df.columns:

        df = df.drop(columns=['item_norm'])

    df.to_csv(PRICE_DB, index=False)


# -------------------------

# Parsing ingredient lines

# -------------------------

# Accept lines like:

# 2 cups flour

# 150 g sugar

# 1.5 tbsp olive oil

# 3 eggs

ING_LINE_REGEX = re.compile(r'^\s*(?P<qty>[\d/.]+)\s*(?P<unit>[a-zA-Z]+)?\s*(?P<item>.+)$')


def parse_quantity(q):

    """Parse numeric quantities (support fractions like 1/2)."""

    try:

        if '/' in q:

            parts = q.split('/')

            if len(parts) == 2:

                return float(parts[0]) / float(parts[1])

        return float(q)

    except Exception:

        return None


def parse_ingredient_line(line):

    m = ING_LINE_REGEX.match(line)

    if not m:

        return None

    qty_raw = m.group('qty')

    unit = m.group('unit') or ""

    item = m.group('item')

    qty = parse_quantity(qty_raw)

    if qty is None:

        return None

    return {"qty": qty, "unit": unit.lower(), "item": item.strip()}


# -------------------------

# Cost calculation

# -------------------------

def compute_cost_for_row(row, prices_df):

    """

    row: dict with keys qty, unit, item

    prices_df: DataFrame with columns item, price, unit

    returns (cost, price_used, reason)

    """

    item_norm = normalize_item_name(row['item'])

    # find best match in prices

    match = prices_df[prices_df['item_norm'] == item_norm]

    if match.empty:

        # try partial matching (contains)

        match = prices_df[prices_df['item_norm'].str.contains(item_norm.split()[0])]


    if not match.empty:

        # pick first match

        m = match.iloc[0]

        price = float(m['price'])

        price_unit = str(m['unit']).lower()

        # If price_unit is kg, convert qty/unit to kg

        if row['unit'] in UNIT_TO_GRAMS and UNIT_TO_GRAMS[row['unit']] is not None:

            grams = row['qty'] * UNIT_TO_GRAMS[row['unit']]

            if price_unit in ('kg', 'kilogram', 'kilograms'):

                kg = grams / 1000.0

                cost = kg * price

                return cost, price, f"price per kg ({price_unit})"

            elif price_unit in ('l','litre','liter','ml'):

                # assume ml ~ grams for simplicity

                liters = grams / 1000.0

                cost = liters * price

                return cost, price, f"price per litre ({price_unit})"

            elif price_unit in ('g','gram','grams'):

                cost = (grams/1.0) * price

                return cost, price, f"price per gram ({price_unit})"

            else:

                # unknown price unit, fallback

                return None, price, f"unknown price unit: {price_unit}"

        else:

            # piece-like units

            if price_unit in ('piece','pc','pcs'):

                # qty is number of pieces

                cost = row['qty'] * price

                return cost, price, "price per piece"

            elif price_unit in ('kg','kilogram','kg'):

                # if unit is piece but price is per kg, we cannot convert; ask user

                return None, price, "needs manual conversion (piece vs kg)"

            else:

                return None, price, f"unhandled conversion: {row['unit']} -> {price_unit}"

    else:

        return None, None, "no price found"


# -------------------------

# Online scraping stub (adapt for your local grocery site)

# -------------------------

def fetch_online_price_stub(item_name):

    """

    Placeholder demo using BeautifulSoup.

    This function is intentionally generic and will NOT work out-of-the-box for a real grocery site.

    To adapt:

      1. Choose a grocery site (that permits scraping).

      2. Inspect search result HTML and find the selector that contains price.

      3. Update SEARCH_URL and selector below.

      4. Handle pagination / blocking / headers and respect robots.txt.


    Example (pseudo):

    SEARCH_URL = f"https://yourgrocer.example/search?q={item_name_encoded}"

    r = requests.get(SEARCH_URL, headers={...})

    soup = BeautifulSoup(r.text, "html.parser")

    price_tag = soup.select_one(".price-class")

    price_text = price_tag.get_text()

    parse price_text to float and return.


    For demo: we'll return None and a helpful search url so the user can confirm manually.

    """

    # Provide a helpful web search link so user can check and paste price

    query = requests.utils.requote_uri(item_name + " price")

    search_url = f"https://www.google.com/search?q={query}"

    return None, f"Please check prices manually: {search_url}"


# -------------------------

# GUI Application

# -------------------------

class RecipeCostApp:

    def __init__(self, root):

        self.root = root

        self.root.title("Recipe Cost Estimator")

        self.root.geometry("900x700")


        # Load price DB

        self.prices = load_price_db()


        # Top frame: recipe input

        top = ttk.Frame(root)

        top.pack(fill='both', padx=10, pady=6)


        ttk.Label(top, text="Paste recipe (one ingredient per line):", font=("Arial", 11)).pack(anchor='w')

        self.recipe_text = tk.Text(top, height=10)

        self.recipe_text.pack(fill='x')


        btn_frame = ttk.Frame(top)

        btn_frame.pack(fill='x', pady=6)

        ttk.Button(btn_frame, text="Load Prices CSV", command=self.load_prices_csv).pack(side='left', padx=4)

        ttk.Button(btn_frame, text="Save Prices CSV", command=self.save_prices_csv).pack(side='left', padx=4)

        ttk.Button(btn_frame, text="Estimate Cost", command=self.estimate_cost).pack(side='left', padx=4)

        ttk.Button(btn_frame, text="Clear", command=lambda: self.recipe_text.delete("1.0", "end")).pack(side='left', padx=4)


        # Middle: results table and edit area

        mid = ttk.Frame(root)

        mid.pack(fill='both', expand=True, padx=10, pady=6)


        left_mid = ttk.Frame(mid)

        left_mid.pack(side='left', fill='both', expand=True)


        ttk.Label(left_mid, text="Cost Breakdown:", font=("Arial", 11)).pack(anchor='w')

        self.tree = ttk.Treeview(left_mid, columns=("qty", "unit", "item", "price_used", "cost", "note"), show='headings')

        for c in ("qty", "unit", "item", "price_used", "cost", "note"):

            self.tree.heading(c, text=c.capitalize())

            self.tree.column(c, width=110, anchor='center')

        self.tree.pack(fill='both', expand=True)


        right_mid = ttk.Frame(mid, width=320)

        right_mid.pack(side='right', fill='y', padx=6)


        ttk.Label(right_mid, text="Edit / Add Price (selected item):", font=("Arial", 11)).pack(anchor='w', pady=4)

        ttk.Label(right_mid, text="Item name:").pack(anchor='w')

        self.price_item_entry = ttk.Entry(right_mid)

        self.price_item_entry.pack(fill='x', pady=2)

        ttk.Label(right_mid, text="Price (numeric):").pack(anchor='w')

        self.price_value_entry = ttk.Entry(right_mid)

        self.price_value_entry.pack(fill='x', pady=2)

        ttk.Label(right_mid, text="Unit (kg/piece/litre):").pack(anchor='w')

        self.price_unit_entry = ttk.Entry(right_mid)

        self.price_unit_entry.pack(fill='x', pady=2)


        ttk.Button(right_mid, text="Save Price", command=self.save_price_from_entries).pack(pady=6)

        ttk.Button(right_mid, text="Fetch Online Price (open search)", command=self.fetch_online_for_selected).pack(pady=6)

        ttk.Button(right_mid, text="Export Breakdown CSV", command=self.export_breakdown_csv).pack(pady=6)


        # bottom: totals

        bottom = ttk.Frame(root)

        bottom.pack(fill='x', padx=10, pady=8)

        self.total_label = ttk.Label(bottom, text="Total Cost: ₹0.00", font=("Arial", 12, "bold"))

        self.total_label.pack(anchor='e')


        # Bind selection

        self.tree.bind("<<TreeviewSelect>>", self.on_row_select)


        # internal

        self.last_breakdown = []  # list of dicts


    # -------------------------

    # UI Callbacks

    # -------------------------

    def load_prices_csv(self):

        path = filedialog.askopenfilename(filetypes=[("CSV files", "*.csv"), ("All files", "*.*")])

        if not path:

            return

        try:

            df = pd.read_csv(path)

            if 'item' not in df.columns or 'price' not in df.columns or 'unit' not in df.columns:

                messagebox.showerror("Error", "CSV must have columns: item,price,unit")

                return

            save_price_db(df)

            self.prices = load_price_db()

            messagebox.showinfo("Loaded", f"Prices loaded and saved to {PRICE_DB}")

        except Exception as e:

            messagebox.showerror("Error", f"Failed to load file: {e}")


    def save_prices_csv(self):

        save_price_db(self.prices)

        messagebox.showinfo("Saved", f"Prices saved to {PRICE_DB}")


    def estimate_cost(self):

        # Clear tree

        for r in self.tree.get_children():

            self.tree.delete(r)

        text = self.recipe_text.get("1.0", "end").strip()

        if not text:

            messagebox.showwarning("Input", "Please paste recipe ingredient lines")

            return

        lines = [ln.strip() for ln in text.splitlines() if ln.strip()]

        parsed = []

        for ln in lines:

            p = parse_ingredient_line(ln)

            if p:

                parsed.append(p)

            else:

                parsed.append({"qty": None, "unit": "", "item": ln})

        prices_df = self.prices.copy()

        breakdown = []

        total = 0.0

        for row in parsed:

            if row['qty'] is None:

                cost, price_used, note = None, None, "Could not parse quantity"

            else:

                cost, price_used, note = compute_cost_for_row(row, prices_df)

            if cost is None:

                note = note or "price missing or conversion needed"

                cost_display = ""

            else:

                cost_display = f"{cost:.2f}"

                total += cost

            price_used_display = "" if price_used is None else str(price_used)

            breakdown_entry = {

                "qty": row.get('qty'),

                "unit": row.get('unit'),

                "item": row.get('item'),

                "price_used": price_used_display,

                "cost": cost_display,

                "note": note

            }

            breakdown.append(breakdown_entry)

            self.tree.insert("", "end", values=(breakdown_entry['qty'], breakdown_entry['unit'],

                                                breakdown_entry['item'], breakdown_entry['price_used'],

                                                breakdown_entry['cost'], breakdown_entry['note']))

        self.total_label.config(text=f"Total Cost: ₹{total:.2f}")

        self.last_breakdown = breakdown


    def on_row_select(self, event):

        sel = self.tree.selection()

        if not sel:

            return

        vals = self.tree.item(sel[0], 'values')

        item_name = vals[2]

        # prefill edit fields

        self.price_item_entry.delete(0, 'end')

        self.price_item_entry.insert(0, item_name)

        # try to fill existing price if present

        norm = normalize_item_name(item_name)

        m = self.prices[self.prices['item_norm'] == norm]

        if not m.empty:

            row = m.iloc[0]

            self.price_value_entry.delete(0, 'end')

            self.price_value_entry.insert(0, str(row['price']))

            self.price_unit_entry.delete(0, 'end')

            self.price_unit_entry.insert(0, str(row['unit']))

        else:

            self.price_value_entry.delete(0, 'end')

            self.price_unit_entry.delete(0, 'end')


    def save_price_from_entries(self):

        item = self.price_item_entry.get().strip()

        price = self.price_value_entry.get().strip()

        unit = self.price_unit_entry.get().strip()

        if not item or not price or not unit:

            messagebox.showwarning("Input", "Please supply item, price, and unit")

            return

        try:

            price_f = float(price)

        except:

            messagebox.showerror("Input", "Price must be numeric")

            return

        norm = normalize_item_name(item)

        df = self.prices

        if (df['item_norm'] == norm).any():

            idx = df[df['item_norm'] == norm].index[0]

            df.at[idx, 'price'] = price_f

            df.at[idx, 'unit'] = unit

            df.at[idx, 'item'] = item

            df.at[idx, 'item_norm'] = norm

        else:

            new_row = {'item': item, 'price': price_f, 'unit': unit, 'item_norm': norm}

            self.prices = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True)

        save_price_db(self.prices)

        messagebox.showinfo("Saved", "Price saved to local DB")

        self.estimate_cost()  # refresh


    def fetch_online_for_selected(self):

        sel = self.tree.selection()

        if not sel:

            messagebox.showwarning("Select", "Select an ingredient row first")

            return

        vals = self.tree.item(sel[0], 'values')

        item_name = vals[2]

        price, hint = fetch_online_price_stub(item_name)

        if price is not None:

            # automatically fill fields

            self.price_item_entry.delete(0,'end'); self.price_item_entry.insert(0, item_name)

            self.price_value_entry.delete(0,'end'); self.price_value_entry.insert(0, str(price))

            self.price_unit_entry.delete(0,'end'); self.price_unit_entry.insert(0, 'kg')

            messagebox.showinfo("Fetched", f"Fetched price: {price}")

        else:

            # open browser with search link or show hint

            if hint:

                webbrowser.open(hint)

                messagebox.showinfo("Manual lookup", f"Opened browser to help find price.\n\n{hint}")

            else:

                messagebox.showinfo("No results", "No price found online (please enter manually)")


    def export_breakdown_csv(self):

        if not self.last_breakdown:

            messagebox.showwarning("No data", "Please estimate cost first")

            return

        df = pd.DataFrame(self.last_breakdown)

        path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files","*.csv")])

        if not path:

            return

        df.to_csv(path, index=False)

        messagebox.showinfo("Exported", f"Breakdown exported to {path}")


# -------------------------

# Run

# -------------------------

def main():

    ensure_price_db_exists()

    root = tk.Tk()

    app = RecipeCostApp(root)

    root.mainloop()


if __name__ == "__main__":

    main()