Build a Local Recipe Vault on Fedora

Build a Fedora desktop app that OCRs and saves recipes locally.

Introduction

30 Second Summary

A printed recipe can carry years of family history. One spill or misplaced page can make that recipe hard to recover.

In this project, you will build a private recipe vault for Fedora Linux that converts a printed recipe photo into editable text with local OCR. Your corrected recipes will return with their source photos after you close and reopen the app.

What You'll Build

Picture reopening your Fedora desktop window to find a scanned recipe waiting in the library with its full text beside the original photo.

By the end of this project, you'll have:

  • A two-tab recipe window where typed recipe details become a formatted preview before you save them.
  • A persistent recipe library that brings saved titles and full details back after an app restart.
  • A photo-backed recipe record with a thumbnail, editable OCR text, and a copied source image that remains available after restart.
  • Secret Mission: A text filter that narrows your library by title, category, ingredients, or instructions.

Are there any prerequisites?

You need a Fedora Linux 45 desktop plus one clear photo of a printed English recipe. The setup step installs every other required tool.

Before We Start

A clear photo of a printed English recipe gives your Recipe Vault a meaningful test case. This checkpoint locks in the recipe you want to preserve before any setup begins.

Prepare Fedora for Local OCR

Your chosen recipe photo needs a local toolchain before it can become an editable draft. Fedora must be able to open a desktop window.

Tesseract OCR must read English text from the image. This step prepares those system tools plus an isolated Python environment for the image packages.

In this step, get ready to:
  • Install Fedora packages for the desktop app.
  • Create an isolated project workspace with pinned dependencies.
  • Verify the Python interface plus the English OCR engine.
Install the Fedora toolchain

DNF5 manages native software on Fedora Linux. These packages provide the desktop toolkit plus the OCR engine that Python uses later.

Python works with Tkinter to create the application window. The English language pack gives Tesseract the data it needs to recognise the printed recipe.

  • Press the Super key to open Fedora's application search.
  • Type Fedora Terminal into the search field.
  • Select Fedora Terminal from the search results.
  • Check the current Python version by running this command:
python3 --version

What does this command check?

The --version option prints the version connected to the python3 command. Fedora Linux 45 provides Python 3.15 through its package repositories.

The installation changes system packages, so Fedora may request your login password. Your keystrokes remain hidden while you type it.

DNF5 also pauses for approval after showing the planned transaction. Review that summary before allowing the installation to continue.

✔️ I see the required version

Python 3.15 is ready. The remaining packages add Tkinter, Tesseract, English OCR data, pip, plus GNOME Text Editor.

  • Install the complete Fedora package set by running this command:
  • Enter your Fedora login password if Fedora requests authentication.
  • Confirm the transaction when DNF5 requests approval.
sudo dnf5 install python3 python3-pip python3-tkinter tesseract tesseract-langpack-eng gnome-text-editor

What does this install?

  • The Python packages provide the interpreter, pip, plus Tkinter.
  • The Tesseract packages provide the OCR engine plus English recognition data.
  • GNOME Text Editor provides a local editor for the project files.

The transaction should complete without skipped packages.

ⓧ I see an older version

The current interpreter is older than Python 3.15. Fedora Linux 45 provides the required package family through DNF5.

  • Install the Fedora Linux 45 package set by running this command:
  • Enter your Fedora login password if Fedora requests authentication.
  • Confirm the transaction when DNF5 requests approval.
sudo dnf5 install python3 python3-pip python3-tkinter tesseract tesseract-langpack-eng gnome-text-editor

What does this install?

  • The Python packages provide the Fedora Linux 45 interpreter, pip, plus Tkinter.
  • The Tesseract packages provide the OCR engine plus English recognition data.
  • GNOME Text Editor provides a local editor for the project files.

The transaction should complete without skipped packages.

ⓧ Command not found

The Python command is missing from your current Fedora installation. DNF5 installs it with the rest of the project toolchain.

  • Install the complete Fedora package set by running this command:
  • Enter your Fedora login password if Fedora requests authentication.
  • Confirm the transaction when DNF5 requests approval.
sudo dnf5 install python3 python3-pip python3-tkinter tesseract tesseract-langpack-eng gnome-text-editor

What does this install?

  • The Python packages provide the interpreter, pip, plus Tkinter.
  • The Tesseract packages provide the OCR engine plus English recognition data.
  • GNOME Text Editor provides a local editor for the project files.

The transaction should complete without skipped packages.

  • Confirm Fedora now uses the required Python version by running the check again:
python3 --version

What confirms success?

The version check reads the installed interpreter directly. It gives you an immediate signal that the Python package is available.

You should see Python 3.15 in the output.

Package installation not completing?

  • Check that Fedora has an internet connection before repeating the installation.
  • Review the transaction output for a package that Fedora skipped.
  • Confirm your account can approve administrator actions on this computer.

Ask for help with the exact terminal output: Why is my Fedora DNF5 package installation failing?

Create the isolated project workspace

A Python virtual environment keeps this project's direct packages inside its own folder. This protects Fedora's system Python from project-specific package changes.

  • Create the ~/Projects/local-recipe-vault directory by running these commands:
mkdir -p ~/Projects/local-recipe-vault
cd ~/Projects/local-recipe-vault

What do these commands do?

  • The mkdir command creates the named project path.
  • The -p option also creates the missing parent directory.
  • The cd command moves your terminal into the new project directory.
  • Confirm your terminal is in the project directory by running:
pwd

What does this command show?

The pwd command prints the complete path of your current directory. This confirms where the next files are created.

You should see a path that ends with /Projects/local-recipe-vault.

  • Create the .venv virtual environment by running this command:
python3 -m venv .venv

What does this command create?

The venv module creates an isolated Python installation inside .venv. Packages installed after activation stay inside this project.

  • Activate the new virtual environment by running this command:
source .venv/bin/activate

What does activation change?

Activation updates the current shell so python points to the interpreter inside .venv. Your shell prompt also gains the environment name.

The project now has an isolated Python home. Next, its dependency file records the exact package versions.

  • Press the Super key to open Fedora's application search.
  • Type GNOME Text Editor into the search field.
  • Select GNOME Text Editor from the search results.
  • Use the new-document control in the header bar to create a blank document.
  • Paste these exact dependency pins into the blank document:
pillow==13.0.0
pytesseract==0.3.13

Why pin these packages?

  • Pillow 13.0.0 opens recipe images plus creates thumbnails.
  • pytesseract 0.3.13 connects Python to the installed Tesseract engine.
  • Exact pins keep future installations on the same tested versions.
  • Use the editor's save control in the header bar.
  • Name the document requirements.txt.
  • Choose ~/Projects/local-recipe-vault as the save destination.
  • Switch back to Fedora Terminal.
  • Confirm the saved dependency pins by running this command:
cat requirements.txt

What does this command prove?

The cat command prints the saved file. Seeing both pins proves that requirements.txt exists in the current project directory.

You should see the Pillow pin on the first line. The pytesseract pin should appear on the second line.

✔️ Awesome, I've got everything!

Great. Confirm that requirements.txt is saved before continuing.

ⓧ I'd like to double check the full code

Compare your complete requirements.txt file with this reference:

pillow==13.0.0
pytesseract==0.3.13

Dependency file missing from the terminal?

  • Confirm GNOME Text Editor saved the file as requirements.txt.
  • Confirm the save destination is ~/Projects/local-recipe-vault.
  • Confirm the terminal path ends with /Projects/local-recipe-vault.

Ask for help locating the file: Why can my terminal not find requirements.txt?

Install packages and verify the toolchain

The system packages provide the native desktop plus OCR capabilities. The virtual environment now needs the two Python packages that connect your future app to recipe images.

  • Install the pinned dependencies into the active virtual environment by running:
python3 -m pip install -r requirements.txt

What does this command install?

The -r option tells pip to read package requirements from requirements.txt. The active environment keeps those installations inside .venv.

The terminal should report successful installations for Pillow 13.0.0 plus pytesseract 0.3.13.

Python packages not installing?

  • Confirm your shell prompt shows the activated virtual environment.
  • Confirm requirements.txt contains both exact pins.
  • Check your internet connection before repeating the installation.

Ask for help with the installation output: Why is pip failing inside my virtual environment?

Before you run the final checks, what evidence do you expect from Python, Tkinter, plus Tesseract?

  • Check which Python executable the active environment uses by running:
which python

What does this path confirm?

The which command locates the executable used by your current shell. A path inside .venv confirms that the isolated interpreter is active.

The output should end with .venv/bin/python.

  • Launch the Tkinter demonstration window by running:
python3 -m tkinter

What does the demonstration prove?

The demonstration uses the installed Tkinter module to create a real desktop window. Seeing that window proves Python can reach Fedora's graphical toolkit.

You should see a small Tk demonstration window. This is your first visible proof that the future recipe interface can open.

  • Close the Tk demonstration window to return control to Fedora Terminal.
  • Verify the OCR engine plus its installed languages by running these commands:
tesseract --version
tesseract --list-langs

What do these checks prove?

  • The first command reports the installed Tesseract engine version.
  • The second command lists the OCR language data available to that engine.
  • The eng entry confirms that Tesseract can process your printed English recipe.

You should see Tesseract 5.5.3 in the version output. You should also see eng in the language list.

One of the final checks failing?

  • Confirm the virtual environment remains active if the Python path points outside .venv.
  • Repeat the Fedora package installation if the Tk demonstration cannot open.
  • Repeat the Fedora package installation if the English language entry is missing.

Ask for help with the failing check: Help me diagnose my Fedora OCR setup checks.

That is the local toolchain ready. Your computer can now open Python desktop windows plus recognise English recipe text without sending the photo elsewhere.

Next, you will turn this foundation into a two-tab Recipe Vault with a form you can preview.

Build the Recipe Form

Your Fedora workspace is ready to run a Tkinter desktop app. The recipe vault now needs a window where you can enter a recipe and see those values together.

A working form leaves one important question about widget state. What happens to the entered recipe after the window closes? You will build the form first and test that question at the end of this step.

In this step, get ready to:
  • Create a desktop window with Add Recipe and Recipe Library tabs.
  • Collect recipe details and show them in a formatted preview.
  • Restart the app to test whether the entered values return.
Create the two-tab window

The root window holds every part of the interface. A notebook divides that window into tabs so adding a recipe and browsing the library have separate spaces.

  • Click the Fedora desktop's Activities area in the top bar.
  • Type GNOME Text Editor into the search field.
  • Select GNOME Text Editor from the search results.
  • Open the application menu in GNOME Text Editor.
  • Select Save As.
  • Choose the local-recipe-vault folder inside your Projects folder.
  • Enter app.py as the file name.
  • Select Save to create the file.
  • Build the root window and its two tabs by pasting this first section into app.py:
import tkinter as tk
from tkinter import filedialog, messagebox, ttk


class RecipeVault(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Local Recipe Vault")
        self.geometry("1100x720")
        self.minsize(900, 620)

        self.build_interface()

    def build_interface(self):
        heading = ttk.Label(self, text="Local Recipe Vault", padding=(16, 12))
        heading.pack(anchor="w")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(expand=True, fill="both", padx=16, pady=(0, 16))

        self.add_tab = ttk.Frame(self.notebook, padding=14)
        self.library_tab = ttk.Frame(self.notebook, padding=14)
        self.notebook.add(self.add_tab, text="Add Recipe")
        self.notebook.add(self.library_tab, text="Recipe Library")

        self.build_add_tab()
        self.build_library_tab()

What does this code do?

  • RecipeVault inherits from tk.Tk so each instance becomes a desktop root window.
  • The window settings give the vault its title and starting dimensions.
  • The ttk.Notebook holds the Add Recipe and Recipe Library tabs.
  • The final two method calls keep each tab's layout in a separate section of the class.
  • Save app.py in GNOME Text Editor.
  • Confirm the editor tab displays app.py as the saved file name.

Does the first section look misaligned?

  • Check that each method is indented four spaces inside RecipeVault.
  • Check that each method body is indented another four spaces.

Help me check the indentation in my RecipeVault class.

The tab-building methods create each tab's starting layout. The library remains a placeholder in this step because the app does not have saved records yet.

  • Complete the initial class by pasting this code directly below the build_interface method:
    def build_add_tab(self):
        self.add_tab.columnconfigure(0, weight=3)
        self.add_tab.columnconfigure(1, weight=2)
        self.add_tab.rowconfigure(0, weight=1)

        self.title_var = tk.StringVar()
        self.category_var = tk.StringVar()

    def build_library_tab(self):
        self.library_tab.columnconfigure(0, weight=2)
        self.library_tab.columnconfigure(1, weight=3)
        self.library_tab.rowconfigure(0, weight=1)

        list_panel = ttk.Frame(self.library_tab, padding=(0, 0, 12, 0))
        list_panel.grid(row=0, column=0, sticky="nsew")
        list_panel.columnconfigure(0, weight=1)
        list_panel.rowconfigure(0, weight=1)

        self.recipe_list = tk.Listbox(list_panel, exportselection=False)
        self.recipe_list.grid(row=0, column=0, sticky="nsew")

        detail_panel = ttk.LabelFrame(self.library_tab, text="Recipe Details", padding=10)
        detail_panel.grid(row=0, column=1, sticky="nsew")
        detail_panel.columnconfigure(0, weight=1)
        detail_panel.rowconfigure(0, weight=3)

        self.detail_text = tk.Text(detail_panel, wrap="word", state="disabled")
        self.detail_text.grid(row=0, column=0, sticky="nsew")


if __name__ == "__main__":
    app = RecipeVault()
    app.mainloop()

How does the shell work?

  • StringVar objects provide live text values for the title and category fields you add next.
  • The library uses a Listbox for recipe names and a disabled Text widget for selected recipe details.
  • The main guard creates RecipeVault and starts its event loop when you run app.py.
  • Save app.py.
  • Return to the activated virtual environment in Fedora Terminal.
  • Open the initial window by running:
python3 app.py

You should see Local Recipe Vault in a desktop window. The Add Recipe and Recipe Library tabs should both be selectable.

That is your first visible app result. The notebook now gives each recipe workflow its own part of the window.

  • Select Recipe Library to confirm the empty list area and Recipe Details panel are visible.
  • Close the window using its title-bar close control.

Does the window fail to open?

  • Check that the final main guard starts at the far left of app.py.
  • Check that build_add_tab and build_library_tab align with build_interface inside RecipeVault.
  • Confirm your Fedora Terminal prompt still shows the activated .venv environment.

Help me find why my Tkinter window does not open.

Build the recipe form

The Add Recipe tab needs short fields for its title and category. A resizable form frame keeps those fields aligned when the window changes size.

  • Return to app.py in GNOME Text Editor.
  • Find the current build_add_tab method.
  • Replace that method with this expanded version:
    def build_add_tab(self):
        self.add_tab.columnconfigure(0, weight=3)
        self.add_tab.columnconfigure(1, weight=2)
        self.add_tab.rowconfigure(0, weight=1)

        form = ttk.Frame(self.add_tab, padding=(0, 0, 14, 0))
        form.grid(row=0, column=0, sticky="nsew")
        form.columnconfigure(1, weight=1)
        form.rowconfigure(3, weight=1)
        form.rowconfigure(5, weight=1)

        self.title_var = tk.StringVar()
        self.category_var = tk.StringVar()

        ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.title_var).grid(
            row=0, column=1, sticky="ew", padx=(10, 0), pady=4
        )

        ttk.Label(form, text="Category").grid(row=1, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.category_var).grid(
            row=1, column=1, sticky="ew", padx=(10, 0), pady=4
        )

What does this form section do?

  • The form frame occupies the left side of the Add Recipe tab.
  • Each Entry connects to a StringVar so the app can retrieve its current text.
  • The grid weights let the input fields expand with the window.
  • Save app.py.
  • Return to Fedora Terminal.
  • Run the updated window with:
python3 app.py

You should see Title and Category fields at the top of the Add Recipe tab.

  • Close the window using its title-bar close control.

Are the short fields missing?

  • Confirm the form code remains inside build_add_tab.
  • Check that the Title and Category labels use the form variable as their parent.

Help me fix the missing Title and Category fields.

Ingredients and instructions need larger text areas because each value can span several lines. Text widgets provide that space while preserving line breaks for the preview.

  • Return to app.py in GNOME Text Editor.
  • Find the Category entry at the end of build_add_tab.
  • Add the multiline recipe fields directly below it:
        ttk.Label(form, text="Ingredients").grid(
            row=2, column=0, columnspan=2, sticky="w", pady=(10, 4)
        )
        self.ingredients_text = tk.Text(form, height=9, wrap="word")
        self.ingredients_text.grid(
            row=3, column=0, columnspan=2, sticky="nsew", pady=(0, 8)
        )

        ttk.Label(form, text="Instructions").grid(
            row=4, column=0, columnspan=2, sticky="w", pady=(6, 4)
        )
        self.instructions_text = tk.Text(form, height=9, wrap="word")
        self.instructions_text.grid(
            row=5, column=0, columnspan=2, sticky="nsew", pady=(0, 10)
        )

Why use Text widgets?

  • The Ingredients and Instructions widgets accept multiple lines of recipe text.
  • Word wrapping keeps long sentences inside the visible form area.
  • The weighted grid rows give both text areas more room when the window grows.
  • Save app.py.
  • Return to Fedora Terminal.
  • Run the expanded form with:
python3 app.py

You should see large Ingredients and Instructions areas below the two short fields.

  • Close the window using its title-bar close control.

Do the text areas overlap?

  • Check that Ingredients uses grid row 3.
  • Check that Instructions uses grid row 5.
  • Confirm both Text widgets use columnspan 2.

Help me correct the recipe form layout.

A preview turns the current widget values into one readable recipe. The form_values method gathers those values before preview_recipe formats them for a message box.

  • Return to app.py in GNOME Text Editor.
  • Find the end of build_add_tab immediately above build_library_tab.
  • Add the controls and preview methods at that location:
        buttons = ttk.Frame(form)
        buttons.grid(row=6, column=0, columnspan=2, sticky="ew")
        ttk.Button(buttons, text="Preview Recipe", command=self.preview_recipe).pack(side="left")
        ttk.Button(buttons, text="Save Recipe").pack(side="left", padx=8)
        ttk.Button(buttons, text="Clear").pack(side="left")

    def form_values(self):
        return {
            "title": self.title_var.get().strip(),
            "category": self.category_var.get().strip(),
            "ingredients": self.ingredients_text.get("1.0", "end").strip(),
            "instructions": self.instructions_text.get("1.0", "end").strip(),
        }

    def preview_recipe(self):
        recipe = self.form_values()
        preview = (
            f"{recipe['title'] or 'Untitled recipe'}\n"
            f"Category: {recipe['category'] or 'Uncategorized'}\n\n"
            f"Ingredients\n{recipe['ingredients'] or 'None entered'}\n\n"
            f"Instructions\n{recipe['instructions'] or 'None entered'}"
        )
        messagebox.showinfo("Recipe Preview", preview, parent=self)

How does the preview work?

  • form_values removes surrounding whitespace from all four inputs.
  • preview_recipe adds readable headings and fallback text around the collected values.
  • Preview Recipe calls preview_recipe when selected.
  • The message box keeps the preview attached to the RecipeVault window.
  • Save app.py.
  • Return to Fedora Terminal.
  • Run the completed form with:
python3 app.py
  • Enter Tomato Soup in the Title field.
  • Enter Soup in the Category field.
  • Enter tomatoes and vegetable stock in the Ingredients area.
  • Enter Simmer until smooth. in the Instructions area.
  • Select Preview Recipe.

You should see a Recipe Preview message box containing the title, category, ingredients, and instructions you entered.

The full form now works inside one running process. Your widget values are being collected and formatted correctly.

Does the preview show fallback text?

  • Confirm you entered text into the fields before selecting Preview Recipe.
  • Check that each key in form_values matches the key used in preview_recipe.
  • Confirm preview_recipe aligns with form_values inside RecipeVault.

Help me fix the values missing from my recipe preview.

✔️ Awesome, I've got everything!

Your recipe form is saved. Keep the current window open for the restart test.

ⓧ I'd like to double check the full code

Compare your app.py with this complete version for the current step.

import tkinter as tk
from tkinter import filedialog, messagebox, ttk


class RecipeVault(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Local Recipe Vault")
        self.geometry("1100x720")
        self.minsize(900, 620)

        self.build_interface()

    def build_interface(self):
        heading = ttk.Label(self, text="Local Recipe Vault", padding=(16, 12))
        heading.pack(anchor="w")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(expand=True, fill="both", padx=16, pady=(0, 16))

        self.add_tab = ttk.Frame(self.notebook, padding=14)
        self.library_tab = ttk.Frame(self.notebook, padding=14)
        self.notebook.add(self.add_tab, text="Add Recipe")
        self.notebook.add(self.library_tab, text="Recipe Library")

        self.build_add_tab()
        self.build_library_tab()

    def build_add_tab(self):
        self.add_tab.columnconfigure(0, weight=3)
        self.add_tab.columnconfigure(1, weight=2)
        self.add_tab.rowconfigure(0, weight=1)

        form = ttk.Frame(self.add_tab, padding=(0, 0, 14, 0))
        form.grid(row=0, column=0, sticky="nsew")
        form.columnconfigure(1, weight=1)
        form.rowconfigure(3, weight=1)
        form.rowconfigure(5, weight=1)

        self.title_var = tk.StringVar()
        self.category_var = tk.StringVar()

        ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.title_var).grid(
            row=0, column=1, sticky="ew", padx=(10, 0), pady=4
        )

        ttk.Label(form, text="Category").grid(row=1, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.category_var).grid(
            row=1, column=1, sticky="ew", padx=(10, 0), pady=4
        )

        ttk.Label(form, text="Ingredients").grid(
            row=2, column=0, columnspan=2, sticky="w", pady=(10, 4)
        )
        self.ingredients_text = tk.Text(form, height=9, wrap="word")
        self.ingredients_text.grid(
            row=3, column=0, columnspan=2, sticky="nsew", pady=(0, 8)
        )

        ttk.Label(form, text="Instructions").grid(
            row=4, column=0, columnspan=2, sticky="w", pady=(6, 4)
        )
        self.instructions_text = tk.Text(form, height=9, wrap="word")
        self.instructions_text.grid(
            row=5, column=0, columnspan=2, sticky="nsew", pady=(0, 10)
        )

        buttons = ttk.Frame(form)
        buttons.grid(row=6, column=0, columnspan=2, sticky="ew")
        ttk.Button(buttons, text="Preview Recipe", command=self.preview_recipe).pack(side="left")
        ttk.Button(buttons, text="Save Recipe").pack(side="left", padx=8)
        ttk.Button(buttons, text="Clear").pack(side="left")

    def build_library_tab(self):
        self.library_tab.columnconfigure(0, weight=2)
        self.library_tab.columnconfigure(1, weight=3)
        self.library_tab.rowconfigure(0, weight=1)

        list_panel = ttk.Frame(self.library_tab, padding=(0, 0, 12, 0))
        list_panel.grid(row=0, column=0, sticky="nsew")
        list_panel.columnconfigure(0, weight=1)
        list_panel.rowconfigure(0, weight=1)

        self.recipe_list = tk.Listbox(list_panel, exportselection=False)
        self.recipe_list.grid(row=0, column=0, sticky="nsew")

        detail_panel = ttk.LabelFrame(self.library_tab, text="Recipe Details", padding=10)
        detail_panel.grid(row=0, column=1, sticky="nsew")
        detail_panel.columnconfigure(0, weight=1)
        detail_panel.rowconfigure(0, weight=3)

        self.detail_text = tk.Text(detail_panel, wrap="word", state="disabled")
        self.detail_text.grid(row=0, column=0, sticky="nsew")

    def form_values(self):
        return {
            "title": self.title_var.get().strip(),
            "category": self.category_var.get().strip(),
            "ingredients": self.ingredients_text.get("1.0", "end").strip(),
            "instructions": self.instructions_text.get("1.0", "end").strip(),
        }

    def preview_recipe(self):
        recipe = self.form_values()
        preview = (
            f"{recipe['title'] or 'Untitled recipe'}\n"
            f"Category: {recipe['category'] or 'Uncategorized'}\n\n"
            f"Ingredients\n{recipe['ingredients'] or 'None entered'}\n\n"
            f"Instructions\n{recipe['instructions'] or 'None entered'}"
        )
        messagebox.showinfo("Recipe Preview", preview, parent=self)


if __name__ == "__main__":
    app = RecipeVault()
    app.mainloop()
Test what survives a restart

The preview proves the form can read its current widgets. Restarting the process tests whether those same values exist beyond the current window.

  • Close the Recipe Preview message box.
  • Close the Local Recipe Vault window.

Before you run the app again, do you think the typed recipe will return after the window restarts? Hold your prediction for the next check.

  • Restart the recipe vault by running:
python3 app.py

You should see the same two-tab window with blank Title, Category, Ingredients, and Instructions fields. The preview worked during the previous process, but its widget values disappeared when that process ended.

What did the restart prove?

The current form keeps recipe text in the running application's memory. Closing the window ends that process and removes its temporary widget values.

A durable recipe library needs storage outside the live widgets. The restart gives you a clear test for the persistence work that comes next.

  • Close the restarted Local Recipe Vault window.

You now have a runnable recipe form and direct evidence of its memory gap. Next, you will use SQLite to make each saved recipe return after the app restarts.

Save Recipes with SQLite

Your Tkinter form can preview a recipe while the app is running. Closing the window erases those widget values because they exist only inside that process.

SQLite stores each recipe as a row inside a local database file. This step connects your form to that database so saved recipes return after a restart.

In this step, get ready to:
  • Create the local recipe database and its table.
  • Save form values with parameterized SQL.
  • Reload saved recipes after restarting the app.
Create the database foundation

The database needs a stable location beside app.py. A startup connection can then create recipes.db automatically when the app runs.

  • Switch back to app.py in GNOME Text Editor.
  • Replace the import section at the top of the file with this database-ready import group and path configuration:
import sqlite3
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk


BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "recipes.db"

What does this setup do?

  • The sqlite3 module provides the database connection and query tools.
  • Path resolves the folder containing app.py.
  • DB_PATH keeps the database at ~/Projects/local-recipe-vault/recipes.db.
  • Save app.py.
  • Confirm the new imports work by running the app from the activated terminal:
python3 app.py

What should you see?

You should see the same two-tab Local Recipe Vault window. This confirms the database modules load without a startup error.

  • Close the app using the window's title-bar close control.
  • In __init__(), find the existing self.build_interface() line.
  • Replace that line with this database startup block:
        self.connection = sqlite3.connect(DB_PATH)
        self.connection.row_factory = sqlite3.Row

        self.recipe_rows = []

        self.create_database()
        self.build_interface()

How does the connection work?

  • sqlite3.connect(DB_PATH) opens recipes.db. SQLite creates the file when it does not exist.
  • sqlite3.Row lets later methods read columns through names such as title.
  • recipe_rows keeps the current query results aligned with the visible list.
  • Add create_database() directly above build_interface() by pasting this method:
    def create_database(self):
        self.connection.execute(
            """
            CREATE TABLE IF NOT EXISTS recipes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                category TEXT NOT NULL DEFAULT '',
                ingredients TEXT NOT NULL DEFAULT '',
                instructions TEXT NOT NULL DEFAULT '',
                photo_path TEXT NOT NULL DEFAULT ''
            )
            """
        )
        self.connection.commit()

What does the table store?

  • id gives every recipe its own automatically increasing number.
  • title, category, ingredients, and instructions hold the typed recipe data.
  • photo_path starts as an empty value for typed recipes.
  • commit() saves the table creation to disk.

Before you run the app, do you expect the database setup to change the window or create storage behind it?

  • Save app.py.
  • Create the database by running the app:
python3 app.py

What should you see?

You should see the existing recipe window without a database error. The startup process now creates recipes.db with an empty recipes table behind the interface.

  • Close the app using the window's title-bar close control.
  • In __init__(), add this protocol line directly below self.build_interface().
        self.protocol("WM_DELETE_WINDOW", self.close_app)

Why register a close handler?

The window protocol sends title-bar close requests to close_app(). This gives the app one place to close its database connection before the window disappears.

  • Add close_app() directly above the final if __name__ == "__main__": block:
    def close_app(self):
        self.connection.close()
        self.destroy()

What happens during closing?

  • connection.close() releases the open database connection.
  • destroy() closes the desktop window after the connection is released.
  • Save app.py.
  • Check the startup and close path by running the app:
python3 app.py

What should you see?

You should see the recipe window open normally. Closing it through the title bar should return you to the terminal without an application error.

Does the app stop during startup?

  • Check that DB_PATH appears below BASE_DIR at the top of app.py.
  • Check that create_database() is indented inside RecipeVault.
  • Check that the closing protocol references self.close_app without parentheses.

Help me diagnose my SQLite startup code.

Build the durable library

The database can now hold rows. The Recipe Library needs a list tied to those rows plus a detail panel that displays the selected recipe.

  • Find the existing build_library_tab() method in app.py.
  • Replace the entire method with this library layout:
    def build_library_tab(self):
        self.library_tab.columnconfigure(0, weight=2)
        self.library_tab.columnconfigure(1, weight=3)
        self.library_tab.rowconfigure(0, weight=1)

        list_panel = ttk.Frame(self.library_tab, padding=(0, 0, 12, 0))
        list_panel.grid(row=0, column=0, sticky="nsew")
        list_panel.columnconfigure(0, weight=1)
        list_panel.rowconfigure(0, weight=1)

        self.recipe_list = tk.Listbox(list_panel, exportselection=False)
        self.recipe_list.grid(row=0, column=0, sticky="nsew")
        scrollbar = ttk.Scrollbar(list_panel, orient="vertical", command=self.recipe_list.yview)
        scrollbar.grid(row=0, column=1, sticky="ns")
        self.recipe_list.configure(yscrollcommand=scrollbar.set)
        ttk.Button(list_panel, text="Show Selected Recipe", command=self.show_selected_recipe).grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))

        detail_panel = ttk.LabelFrame(self.library_tab, text="Recipe Details", padding=10)
        detail_panel.grid(row=0, column=1, sticky="nsew")
        detail_panel.columnconfigure(0, weight=1)
        detail_panel.rowconfigure(0, weight=3)

        self.detail_text = tk.Text(detail_panel, wrap="word", state="disabled")
        self.detail_text.grid(row=0, column=0, sticky="nsew")

How is the library arranged?

  • recipe_list displays one title and category for each database row.
  • The scrollbar keeps longer collections usable.
  • detail_text displays the complete recipe while staying read-only.
  • Add these selection and detail methods below load_recipes() when that method is added later, or place them above close_app() now:
    def show_selected_recipe(self):
        selected = self.recipe_list.curselection()
        if not selected:
            return

        row = self.recipe_rows[selected[0]]
        details = (
            f"{row['title']}\n"
            f"Category: {row['category'] or 'Uncategorized'}\n\n"
            f"Ingredients\n{row['ingredients'] or 'None entered'}\n\n"
            f"Instructions\n{row['instructions'] or 'None entered'}"
        )
        self.set_detail_text(details)

    def set_detail_text(self, text):
        self.detail_text.configure(state="normal")
        self.detail_text.delete("1.0", "end")
        self.detail_text.insert("1.0", text)
        self.detail_text.configure(state="disabled")

How does selection become detail text?

  • curselection() returns the position selected in the visible list.
  • recipe_rows maps that position back to the matching database row.
  • set_detail_text() briefly enables the text widget so the selected recipe can replace its contents.
  • Save app.py.
  • Check the new library layout by running the app:
python3 app.py

What should you see?

Select the Recipe Library tab. You should see a list panel with a scrollbar beside a larger Recipe Details panel.

  • Close the app using the window's title-bar close control.
  • Add save_recipe() below preview_recipe() in app.py.
    def save_recipe(self):
        recipe = self.form_values()
        if not recipe["title"]:
            messagebox.showwarning("Missing title", "Enter a recipe title before saving.", parent=self)
            return
        if not recipe["ingredients"] and not recipe["instructions"]:
            messagebox.showwarning("Missing recipe text", "Enter ingredients or instructions before saving.", parent=self)
            return

        photo_path = ""

        self.connection.execute(
            """
            INSERT INTO recipes (title, category, ingredients, instructions, photo_path)
            VALUES (?, ?, ?, ?, ?)
            """,
            (recipe["title"], recipe["category"], recipe["ingredients"], recipe["instructions"], photo_path),
        )
        self.connection.commit()
        messagebox.showinfo("Recipe saved", f"Saved {recipe['title']} to your vault.", parent=self)
        self.clear_form()
        self.load_recipes()
        self.notebook.select(self.library_tab)

How does saving work?

  • The first two checks prevent a recipe with no title or usable recipe text from being saved.
  • The five ? placeholders keep the SQL statement separate from the recipe values.
  • commit() writes the new row to recipes.db.
  • The final calls clear the form, refresh the library, and select the Recipe Library tab.
  • Save app.py.
  • Confirm the new method loads without a syntax error by running the app:
python3 app.py

What should you see?

You should see the Local Recipe Vault window open normally. This confirms Python can load the new save method.

  • Close the app using the window's title-bar close control.
  • Add clear_form() directly below save_recipe().
    def clear_form(self):
        self.title_var.set("")
        self.category_var.set("")
        self.ingredients_text.delete("1.0", "end")
        self.instructions_text.delete("1.0", "end")

What does clearing reset?

The method resets both single-line variables. It also deletes the contents of both multi-line text widgets.

  • In build_add_tab(), replace the existing button definitions with these connected controls:
        ttk.Button(buttons, text="Preview Recipe", command=self.preview_recipe).pack(side="left")
        ttk.Button(buttons, text="Save Recipe", command=self.save_recipe).pack(side="left", padx=8)
        ttk.Button(buttons, text="Clear", command=self.clear_form).pack(side="left")

How do the controls reach the methods?

Each button stores a method as its command. Clicking the button calls that method through Tkinter's event loop.

  • Save app.py.
  • Test the connected Clear control by running the app:
python3 app.py

Check the Clear control

You should see the form open with all three controls available. The next actions confirm that Clear reaches clear_form().

  • Enter Temporary Recipe in the Title field.
  • Enter Temporary ingredient in the Ingredients box.
  • Click Clear.

You should see both test values disappear from the form. The form can now reset itself after a successful save.

  • Close the app using the window's title-bar close control.
  • Add load_recipes() directly below clear_form().
    def load_recipes(self):
        self.recipe_rows = list(
            self.connection.execute(
                """
                SELECT id, title, category, ingredients, instructions, photo_path
                FROM recipes
                ORDER BY title COLLATE NOCASE
                """
            )
        )
        self.recipe_list.delete(0, "end")
        for row in self.recipe_rows:
            category = row["category"] or "Uncategorized"
            self.recipe_list.insert("end", f"{row['title']} | {category}")

        if self.recipe_rows:
            self.recipe_list.selection_set(0)
            self.recipe_list.activate(0)
            self.show_selected_recipe()
        else:
            self.set_detail_text("No saved recipes yet.")

How does loading rebuild the library?

  • The query reads every recipe in case-insensitive title order.
  • The loop adds each title and category to the visible list.
  • The first row becomes selected automatically when recipes exist.
  • An empty database displays No saved recipes yet. in the detail panel.
  • In __init__(), find these two lines:
        self.create_database()
        self.build_interface()

Where does loading belong?

The table and widgets must exist before recipes can be loaded. These two lines establish both prerequisites.

  • Add self.load_recipes() below self.build_interface() so the startup section looks like this:
        self.create_database()
        self.build_interface()
        self.load_recipes()
        self.protocol("WM_DELETE_WINDOW", self.close_app)

What is the startup order?

  • create_database() guarantees the table exists.
  • build_interface() creates the list and detail widgets.
  • load_recipes() fills those widgets from disk.
  • The closing protocol remains last so the connection is released when the window closes.

Before you run the app, what message do you expect in the detail panel while the database has no saved recipes?

  • Save app.py.
  • Check the empty database state by running the app:
python3 app.py

What should you see?

Select the Recipe Library tab. You should see an empty list plus No saved recipes yet. in the Recipe Details panel.

Does the library fail to load?

  • Check that self.load_recipes() comes after self.build_interface() in __init__().
  • Check that recipe_list and detail_text are created inside build_library_tab().
  • Check that all SQL column names match the table definition.

Help me fix my Recipe Library loading code.

Prove the recipe survives a restart

A successful save inside one process proves that the insert works. A restart proves the recipe lives in recipes.db instead of temporary widget memory.

  • Close the app using the window's title-bar close control.
  • Compare your completed app.py with the persistence version below if you want a final code check.

✔️ Awesome, I've got everything!

Great. Save app.py before testing the complete persistence path.

ⓧ I'd like to double check the full code

Compare your file with this complete SQLite persistence version:

import sqlite3
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk


BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "recipes.db"


class RecipeVault(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Local Recipe Vault")
        self.geometry("1100x720")
        self.minsize(900, 620)

        self.connection = sqlite3.connect(DB_PATH)
        self.connection.row_factory = sqlite3.Row

        self.recipe_rows = []

        self.create_database()
        self.build_interface()
        self.load_recipes()
        self.protocol("WM_DELETE_WINDOW", self.close_app)

    def create_database(self):
        self.connection.execute(
            """
            CREATE TABLE IF NOT EXISTS recipes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                category TEXT NOT NULL DEFAULT '',
                ingredients TEXT NOT NULL DEFAULT '',
                instructions TEXT NOT NULL DEFAULT '',
                photo_path TEXT NOT NULL DEFAULT ''
            )
            """
        )
        self.connection.commit()

    def build_interface(self):
        heading = ttk.Label(self, text="Local Recipe Vault", padding=(16, 12))
        heading.pack(anchor="w")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(expand=True, fill="both", padx=16, pady=(0, 16))

        self.add_tab = ttk.Frame(self.notebook, padding=14)
        self.library_tab = ttk.Frame(self.notebook, padding=14)
        self.notebook.add(self.add_tab, text="Add Recipe")
        self.notebook.add(self.library_tab, text="Recipe Library")

        self.build_add_tab()
        self.build_library_tab()

    def build_add_tab(self):
        self.add_tab.columnconfigure(0, weight=3)
        self.add_tab.columnconfigure(1, weight=2)
        self.add_tab.rowconfigure(0, weight=1)

        form = ttk.Frame(self.add_tab, padding=(0, 0, 14, 0))
        form.grid(row=0, column=0, sticky="nsew")
        form.columnconfigure(1, weight=1)
        form.rowconfigure(3, weight=1)
        form.rowconfigure(5, weight=1)

        self.title_var = tk.StringVar()
        self.category_var = tk.StringVar()

        ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.title_var).grid(
            row=0, column=1, sticky="ew", padx=(10, 0), pady=4
        )

        ttk.Label(form, text="Category").grid(row=1, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.category_var).grid(
            row=1, column=1, sticky="ew", padx=(10, 0), pady=4
        )

        ttk.Label(form, text="Ingredients").grid(
            row=2, column=0, columnspan=2, sticky="w", pady=(10, 4)
        )
        self.ingredients_text = tk.Text(form, height=9, wrap="word")
        self.ingredients_text.grid(
            row=3, column=0, columnspan=2, sticky="nsew", pady=(0, 8)
        )

        ttk.Label(form, text="Instructions").grid(
            row=4, column=0, columnspan=2, sticky="w", pady=(6, 4)
        )
        self.instructions_text = tk.Text(form, height=9, wrap="word")
        self.instructions_text.grid(
            row=5, column=0, columnspan=2, sticky="nsew", pady=(0, 10)
        )

        buttons = ttk.Frame(form)
        buttons.grid(row=6, column=0, columnspan=2, sticky="ew")
        ttk.Button(buttons, text="Preview Recipe", command=self.preview_recipe).pack(side="left")
        ttk.Button(buttons, text="Save Recipe", command=self.save_recipe).pack(side="left", padx=8)
        ttk.Button(buttons, text="Clear", command=self.clear_form).pack(side="left")

    def build_library_tab(self):
        self.library_tab.columnconfigure(0, weight=2)
        self.library_tab.columnconfigure(1, weight=3)
        self.library_tab.rowconfigure(0, weight=1)

        list_panel = ttk.Frame(self.library_tab, padding=(0, 0, 12, 0))
        list_panel.grid(row=0, column=0, sticky="nsew")
        list_panel.columnconfigure(0, weight=1)
        list_panel.rowconfigure(0, weight=1)

        self.recipe_list = tk.Listbox(list_panel, exportselection=False)
        self.recipe_list.grid(row=0, column=0, sticky="nsew")
        scrollbar = ttk.Scrollbar(list_panel, orient="vertical", command=self.recipe_list.yview)
        scrollbar.grid(row=0, column=1, sticky="ns")
        self.recipe_list.configure(yscrollcommand=scrollbar.set)
        ttk.Button(list_panel, text="Show Selected Recipe", command=self.show_selected_recipe).grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))

        detail_panel = ttk.LabelFrame(self.library_tab, text="Recipe Details", padding=10)
        detail_panel.grid(row=0, column=1, sticky="nsew")
        detail_panel.columnconfigure(0, weight=1)
        detail_panel.rowconfigure(0, weight=3)

        self.detail_text = tk.Text(detail_panel, wrap="word", state="disabled")
        self.detail_text.grid(row=0, column=0, sticky="nsew")

    def form_values(self):
        return {
            "title": self.title_var.get().strip(),
            "category": self.category_var.get().strip(),
            "ingredients": self.ingredients_text.get("1.0", "end").strip(),
            "instructions": self.instructions_text.get("1.0", "end").strip(),
        }

    def preview_recipe(self):
        recipe = self.form_values()
        preview = (
            f"{recipe['title'] or 'Untitled recipe'}\n"
            f"Category: {recipe['category'] or 'Uncategorized'}\n\n"
            f"Ingredients\n{recipe['ingredients'] or 'None entered'}\n\n"
            f"Instructions\n{recipe['instructions'] or 'None entered'}"
        )
        messagebox.showinfo("Recipe Preview", preview, parent=self)

    def save_recipe(self):
        recipe = self.form_values()
        if not recipe["title"]:
            messagebox.showwarning("Missing title", "Enter a recipe title before saving.", parent=self)
            return
        if not recipe["ingredients"] and not recipe["instructions"]:
            messagebox.showwarning("Missing recipe text", "Enter ingredients or instructions before saving.", parent=self)
            return

        photo_path = ""

        self.connection.execute(
            """
            INSERT INTO recipes (title, category, ingredients, instructions, photo_path)
            VALUES (?, ?, ?, ?, ?)
            """,
            (recipe["title"], recipe["category"], recipe["ingredients"], recipe["instructions"], photo_path),
        )
        self.connection.commit()
        messagebox.showinfo("Recipe saved", f"Saved {recipe['title']} to your vault.", parent=self)
        self.clear_form()
        self.load_recipes()
        self.notebook.select(self.library_tab)

    def clear_form(self):
        self.title_var.set("")
        self.category_var.set("")
        self.ingredients_text.delete("1.0", "end")
        self.instructions_text.delete("1.0", "end")

    def load_recipes(self):
        self.recipe_rows = list(
            self.connection.execute(
                """
                SELECT id, title, category, ingredients, instructions, photo_path
                FROM recipes
                ORDER BY title COLLATE NOCASE
                """
            )
        )
        self.recipe_list.delete(0, "end")
        for row in self.recipe_rows:
            category = row["category"] or "Uncategorized"
            self.recipe_list.insert("end", f"{row['title']} | {category}")

        if self.recipe_rows:
            self.recipe_list.selection_set(0)
            self.recipe_list.activate(0)
            self.show_selected_recipe()
        else:
            self.set_detail_text("No saved recipes yet.")

    def show_selected_recipe(self):
        selected = self.recipe_list.curselection()
        if not selected:
            return

        row = self.recipe_rows[selected[0]]
        details = (
            f"{row['title']}\n"
            f"Category: {row['category'] or 'Uncategorized'}\n\n"
            f"Ingredients\n{row['ingredients'] or 'None entered'}\n\n"
            f"Instructions\n{row['instructions'] or 'None entered'}"
        )
        self.set_detail_text(details)

    def set_detail_text(self, text):
        self.detail_text.configure(state="normal")
        self.detail_text.delete("1.0", "end")
        self.detail_text.insert("1.0", text)
        self.detail_text.configure(state="disabled")

    def close_app(self):
        self.connection.close()
        self.destroy()


if __name__ == "__main__":
    app = RecipeVault()
    app.mainloop()
  • Start the completed app from the activated terminal:
python3 app.py

Prepare the persistence test

You should see the two-tab window with an empty recipe form. You are ready to create the row that will test persistence.

  • Enter Fedora Tomato Soup in the Title field.
  • Enter Soup in the Category field.
  • Enter 2 tomatoes and 1 cup stock in the Ingredients box.
  • Enter Simmer until tender. in the Instructions box.

Before you save, do you expect the recipe to remain on the form or move into the library?

  • Click Save Recipe.

You should see a confirmation for Fedora Tomato Soup. The app should clear the form, select Recipe Library, and display the saved recipe's full details.

Does saving show a warning?

  • Check that the Title field contains a recipe name.
  • Check that either Ingredients or Instructions contains recipe text.
  • Check that the insert has five placeholders plus five matching values.

Help me fix my recipe save operation.

  • Close the app using the window's title-bar close control.

Before you restart, do you think Fedora Tomato Soup will return from the database or disappear with the closed window?

  • Restart the app from the activated terminal:
python3 app.py

What should you see after restarting?

You should see the same two-tab window without re-entering any data. The startup query has already loaded Fedora Tomato Soup from recipes.db.

  • Select the Recipe Library tab.
  • Select Fedora Tomato Soup | Soup in the recipe list.
  • Click Show Selected Recipe.

You should see the saved title, category, ingredients, and instructions in Recipe Details. That restart is the proof that your recipe now lives outside widget memory.

Your Recipe Vault now rebuilds its library from disk every time it opens. Next, you will turn a printed recipe photo into text that can join this durable collection.

Scan Recipe Photos with OCR

Your SQLite vault now keeps typed recipes after the window closes. A printed recipe still takes time to retype by hand.

This step adds local OCR so a photo becomes an editable draft. You will also copy each source photo into the project so it returns with the saved recipe.

In this step, get ready to:
  • Add image state plus a local folder for copied recipe photos.
  • Display a thumbnail plus editable text extracted from a selected image.
  • Save each source photo with its recipe so both survive an application restart.
Prepare the image imports and state

Image handling needs three new pieces of state. The app tracks the selected source path plus separate image references for the Add Recipe and Recipe Library tabs.

  • Switch back to the open app.py file in GNOME Text Editor.
  • Replace the import and path configuration section at the top of app.py with this code:
import shutil
import sqlite3
import tkinter as tk
import uuid
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

import pytesseract
from PIL import Image, ImageTk


BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "recipes.db"
IMAGE_DIR = BASE_DIR / "recipe_images"

What does this setup add?

  • The shutil import provides the file copy operation used when a recipe is saved.
  • The uuid import creates a unique filename for each copied photo.
  • The pytesseract import connects Python to the local Tesseract engine.
  • The Image and ImageTk imports let Pillow open images and display them inside the window.
  • The IMAGE_DIR path keeps copied source photos inside recipe_images.
  • In __init__(), replace the existing connection setup through self.recipe_rows = [] with this expanded block:
        IMAGE_DIR.mkdir(parents=True, exist_ok=True)
        self.connection = sqlite3.connect(DB_PATH)
        self.connection.row_factory = sqlite3.Row

        self.selected_photo = None
        self.add_image_ref = None
        self.library_image_ref = None
        self.recipe_rows = []

Why keep image references?

Tkinter can stop displaying an image when Python no longer holds a reference to it. The two image reference attributes keep both thumbnails visible for as long as the app needs them.

The startup call to IMAGE_DIR.mkdir() creates recipe_images when needed. Repeated launches safely reuse the same folder.

  • Save app.py in GNOME Text Editor.
  • Return to the terminal from earlier.
  • Confirm the expanded startup setup still runs by entering:
python3 app.py

What does this check prove?

The command launches app.py with the activated virtual environment. A normal launch confirms the new imports and image state are valid.

You should see the existing two-tab Recipe Vault. Your saved typed recipe should still appear in the Recipe Library tab.

  • Close the Recipe Vault window.

Does the app stop during startup?

  • Check that the virtual environment from earlier is still active in the terminal.
  • Compare every import name against the code block above.
  • Confirm that pillow==13.0.0 remains in requirements.txt.
  • Confirm that pytesseract==0.3.13 remains in requirements.txt.

Help me diagnose the startup error in my RecipeVault app after adding Pillow and pytesseract imports.

Add the photo panel and editable OCR draft

The app needs one method to select and read a photo. A second method turns the selected file into a thumbnail that fits the interface.

  • In app.py, locate preview_recipe().
  • Add this new choose_photo() method directly below preview_recipe():
    def choose_photo(self):
        selected = filedialog.askopenfilename(
            parent=self,
            title="Select a printed recipe photo",
            filetypes=(("Image files", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff"), ("All files", "*.*")),
        )
        if not selected:
            return

        self.selected_photo = Path(selected)
        self.ocr_status.set("Reading the photo...")
        self.update_idletasks()

        try:
            self.add_image_ref = self.make_thumbnail(self.selected_photo, self.add_image_label, (360, 220))
            extracted = pytesseract.image_to_string(str(self.selected_photo), lang="eng", config="--psm 6").strip()
        except (OSError, pytesseract.TesseractError) as error:
            self.ocr_status.set("OCR failed. Check the image and Tesseract setup.")
            messagebox.showerror("OCR error", str(error), parent=self)
            return

        self.ocr_text.delete("1.0", "end")
        self.ocr_text.insert("1.0", extracted)
        if extracted:
            self.ocr_status.set("OCR draft ready. Correct it before saving.")
        else:
            self.ocr_status.set("No text found. Try a brighter, straighter photo.")

How does the OCR flow work?

  • The file dialog accepts common photo and scanned-image formats.
  • The selected filename becomes a Path object in self.selected_photo.
  • The image_to_string() call sends the local file to Tesseract OCR.
  • The eng language value selects the installed English recognition data.
  • The --psm 6 configuration treats the recipe as one uniform block of text.
  • The resulting string goes into ocr_text so you can correct recognition mistakes.
  • Add this make_thumbnail() method directly below choose_photo():
    def make_thumbnail(self, image_path, label, size):
        with Image.open(image_path) as image:
            preview = image.copy()
        preview.thumbnail(size)
        image_ref = ImageTk.PhotoImage(preview)
        label.configure(image=image_ref, text="")
        return image_ref

What does the thumbnail helper do?

The helper opens the source image through Pillow. It copies the image before resizing the preview so the original file stays untouched.

The PhotoImage result works with a Tkinter label. Returning the reference lets the caller keep that image visible.

  • Save app.py.
  • Check that both helper methods are valid by running:
python3 app.py

What does this intermediate run prove?

The app constructs the Recipe Vault class with both helper methods available. A normal window launch confirms that the new method definitions have valid Python syntax.

You should see the same working Recipe Vault window. The existing library data should remain available.

  • Close the Recipe Vault window.

Seeing a syntax or indentation error?

  • Place both methods at the same indentation level as preview_recipe().
  • Keep every line inside each method indented by four additional spaces.
  • Check the closing parentheses in the filetypes value.

Help me fix the indentation or syntax in my new choose_photo and make_thumbnail methods.

The methods are ready. The Add Recipe tab now needs a visible panel that calls them.

  • In build_add_tab(), replace its opening layout block through form.grid() with this code:
        self.add_tab.columnconfigure(0, weight=3)
        self.add_tab.columnconfigure(1, weight=2)
        self.add_tab.rowconfigure(0, weight=1)

        form = ttk.Frame(self.add_tab, padding=(0, 0, 14, 0))
        form.grid(row=0, column=0, sticky="nsew")

How does the new layout divide the tab?

Column zero keeps more room for the recipe form. Column one holds the smaller photo and OCR panel.

The frame padding creates space between the two columns. The weighted row lets both sections expand with the window.

  • In the same method, locate the three recipe button definitions.
  • Add this photo panel directly below the Clear button definition:
        photo_panel = ttk.LabelFrame(self.add_tab, text="Recipe Photo and OCR", padding=10)
        photo_panel.grid(row=0, column=1, sticky="nsew")
        photo_panel.columnconfigure(0, weight=1)
        photo_panel.rowconfigure(3, weight=1)

        self.add_image_label = ttk.Label(photo_panel, text="No photo selected", anchor="center", padding=10)
        self.add_image_label.grid(row=0, column=0, sticky="nsew")
        ttk.Button(photo_panel, text="Select Photo and Read Text", command=self.choose_photo).grid(row=1, column=0, sticky="ew", pady=8)

        self.ocr_status = tk.StringVar(value="OCR has not run yet.")
        ttk.Label(photo_panel, textvariable=self.ocr_status, wraplength=360).grid(row=2, column=0, sticky="w", pady=(0, 6))
        self.ocr_text = tk.Text(photo_panel, height=16, wrap="word")
        self.ocr_text.grid(row=3, column=0, sticky="nsew")

What appears in the photo panel?

  • The image label displays the selected photo as a thumbnail.
  • The selection button starts choose_photo().
  • The status label reports whether OCR is waiting or complete.
  • The text widget holds the editable recognition draft.
  • Save app.py.
  • Return to the terminal.

Before you run the app, do you expect the selected photo to produce perfectly formatted text or a draft that needs correction?

  • Launch the updated interface by running:
python3 app.py

What does this run activate?

The command launches the interface with the new panel connected to choose_photo(). Selecting an image now exercises Pillow plus the local Tesseract engine.

You should see the recipe form on the left. You should see the Recipe Photo and OCR panel on the right.

The OCR pass can briefly pause the window while Tesseract reads the image. That pause means the local recognition process is working through the photo.

  • Click Select Photo and Read Text.
  • Select the clear printed recipe photo you prepared earlier.
  • Confirm the image selection in the file dialog.

You should see a thumbnail in the panel. The editable text area should contain an OCR draft or a message indicating that no text was found.

That is the photo-to-text path working. Your recipe stays on the Fedora computer throughout the process.

No useful OCR text?

  • Choose a photo with the recipe page filling most of the frame.
  • Retake the photo with brighter light if shadows cover the printed text.
  • Straighten the page so each printed line runs horizontally.
  • Confirm that the English OCR data from the setup step remains installed.

Help me improve the OCR result from my printed recipe photo.

Save and restore the source photo

The OCR draft is temporary until its useful text reaches the recipe fields. The source image also needs a stable project path so the Recipe Library can display it after a restart.

  • Close the Recipe Vault window.
  • In build_library_tab(), replace the existing detail panel block with this expanded version:
        detail_panel = ttk.LabelFrame(self.library_tab, text="Recipe Details", padding=10)
        detail_panel.grid(row=0, column=1, sticky="nsew")
        detail_panel.columnconfigure(0, weight=1)
        detail_panel.rowconfigure(0, weight=3)
        detail_panel.rowconfigure(1, weight=2)

        self.detail_text = tk.Text(detail_panel, wrap="word", state="disabled")
        self.detail_text.grid(row=0, column=0, sticky="nsew")
        self.library_image_label = ttk.Label(detail_panel, text="No saved photo", anchor="center", padding=10)
        self.library_image_label.grid(row=1, column=0, sticky="nsew", pady=(10, 0))

How does the library panel change?

The recipe text keeps the larger share of the detail panel. A second label beneath it displays the copied source image.

Recipes created before this step show No saved photo because their database rows contain an empty photo path.

  • In show_selected_recipe(), locate self.set_detail_text(details).
  • Add this image restoration block directly below that line:
        if row["photo_path"]:
            photo = BASE_DIR / row["photo_path"]
            if photo.is_file():
                try:
                    self.library_image_ref = self.make_thumbnail(photo, self.library_image_label, (420, 230))
                    return
                except OSError:
                    self.library_image_label.configure(image="", text="Saved photo could not be opened")
                    return

        self.library_image_ref = None
        self.library_image_label.configure(image="", text="No saved photo")

How does a saved photo return?

The database stores a relative path such as a file under recipe_images. Joining that value to BASE_DIR finds the copied image inside this project.

The method checks that the file exists before opening it. A missing or unreadable file produces a clear label inside the library.

  • Save app.py.
  • Check the updated Recipe Library by running:
python3 app.py

What does this library check prove?

The app loads existing rows through the updated detail panel. Typed recipes with blank photo paths should display the fallback label without affecting their saved text.

  • Select the Recipe Library tab.
  • Select a typed recipe saved in the previous step.
  • Click Show Selected Recipe.

You should see the saved recipe details. You should also see No saved photo beneath them.

  • Close the Recipe Vault window.

Does the library image label fail to appear?

  • Confirm that self.library_image_label is created inside build_library_tab().
  • Confirm that the image restoration block sits inside show_selected_recipe().
  • Check that the restoration block comes after self.set_detail_text(details).

Help me fix the saved-photo area in my Recipe Library panel.

The library can now display a stored photo path. The save flow must create that copied file and write its relative path into the existing database row.

  • In save_recipe(), find the existing photo_path = "" line above the database insert.
  • Replace that line with this photo-copy block:
        photo_path = ""
        if self.selected_photo:
            destination = IMAGE_DIR / f"{uuid.uuid4().hex}{self.selected_photo.suffix.lower()}"
            shutil.copy2(self.selected_photo, destination)
            photo_path = str(destination.relative_to(BASE_DIR))

Why copy the selected photo?

The original photo might be moved or deleted later. Copying it into recipe_images gives the saved recipe a stable local source file.

The UUID filename prevents two photos with the same original name from overwriting each other. The database stores a relative path so the whole project folder remains portable.

  • In clear_form(), locate the line that clears instructions_text.
  • Add this photo reset block directly below that line:
        self.ocr_text.delete("1.0", "end")
        self.selected_photo = None
        self.add_image_ref = None
        self.add_image_label.configure(image="", text="No photo selected")
        self.ocr_status.set("OCR has not run yet.")

Why reset the photo state?

Saving one recipe prepares the form for the next recipe. Clearing the path prevents the previous source photo from being copied into a later record.

The image label plus OCR status return to their original messages. The empty draft makes the reset visible immediately.

  • Save app.py.
  • Launch the complete app by running:
python3 app.py

What does the complete run test?

This run connects image selection to OCR. It also connects the selected source path to the existing parameterized recipe insert.

  • Select the Add Recipe tab.
  • Click Select Photo and Read Text.
  • Select your printed recipe photo.
  • Correct recognition mistakes in the OCR draft.
  • Copy the recipe name into the Title field.
  • Enter a category in the Category field.
  • Copy the relevant ingredient lines into the Ingredients field.
  • Copy the relevant method lines into the Instructions field.
Prove the recipe and photo survive a restart
  • Click Save Recipe.

You should see the Recipe Library tab with the saved details and copied source photo. The Add Recipe form has also reset its photo state for the next recipe.

  • Close the Recipe Vault window.

Before you reopen the app, do you expect the saved source photo to return even if the original file is moved later?

  • Reopen the Recipe Vault by running:
python3 app.py

What does the restart verify?

A fresh process rebuilds the library from recipes.db instead of live widget memory. The stored relative path then reloads the copied image from recipe_images.

  • Select the Recipe Library tab.
  • Select the recipe you saved from the photo.
  • Click Show Selected Recipe.

You should see the corrected recipe details and the copied source photo. That proves both the database row and local image file survived the restart.

You have the complete local OCR workflow working. A printed recipe can now become editable text plus a durable library record without leaving your Fedora computer.

Recipe returns without its photo?

  • Confirm that you selected a photo before saving the recipe.
  • Check that photo_path is passed as the fifth value in the existing parameterized insert.
  • Confirm that recipe_images remains inside ~/Projects/local-recipe-vault.
  • Check that show_selected_recipe() joins BASE_DIR to the stored relative path.

Help me diagnose why my saved RecipeVault record returns after restart but its copied photo does not.

Use this comparison to confirm that every OCR and photo-persistence change is in the correct location.

✔️ Awesome, I've got everything!

Your app reads printed recipe photos locally. It also restores copied source images from saved database rows.

ⓧ I'd like to double check the full code

import shutil
import sqlite3
import tkinter as tk
import uuid
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

import pytesseract
from PIL import Image, ImageTk


BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "recipes.db"
IMAGE_DIR = BASE_DIR / "recipe_images"


class RecipeVault(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Local Recipe Vault")
        self.geometry("1100x720")
        self.minsize(900, 620)

        IMAGE_DIR.mkdir(parents=True, exist_ok=True)
        self.connection = sqlite3.connect(DB_PATH)
        self.connection.row_factory = sqlite3.Row

        self.selected_photo = None
        self.add_image_ref = None
        self.library_image_ref = None
        self.recipe_rows = []

        self.create_database()
        self.build_interface()
        self.load_recipes()
        self.protocol("WM_DELETE_WINDOW", self.close_app)

    def create_database(self):
        self.connection.execute(
            """
            CREATE TABLE IF NOT EXISTS recipes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                category TEXT NOT NULL DEFAULT '',
                ingredients TEXT NOT NULL DEFAULT '',
                instructions TEXT NOT NULL DEFAULT '',
                photo_path TEXT NOT NULL DEFAULT ''
            )
            """
        )
        self.connection.commit()

    def build_interface(self):
        heading = ttk.Label(self, text="Local Recipe Vault", padding=(16, 12))
        heading.pack(anchor="w")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(expand=True, fill="both", padx=16, pady=(0, 16))

        self.add_tab = ttk.Frame(self.notebook, padding=14)
        self.library_tab = ttk.Frame(self.notebook, padding=14)
        self.notebook.add(self.add_tab, text="Add Recipe")
        self.notebook.add(self.library_tab, text="Recipe Library")

        self.build_add_tab()
        self.build_library_tab()

    def build_add_tab(self):
        self.add_tab.columnconfigure(0, weight=3)
        self.add_tab.columnconfigure(1, weight=2)
        self.add_tab.rowconfigure(0, weight=1)

        form = ttk.Frame(self.add_tab, padding=(0, 0, 14, 0))
        form.grid(row=0, column=0, sticky="nsew")
        form.columnconfigure(1, weight=1)
        form.rowconfigure(3, weight=1)
        form.rowconfigure(5, weight=1)

        self.title_var = tk.StringVar()
        self.category_var = tk.StringVar()

        ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.title_var).grid(
            row=0, column=1, sticky="ew", padx=(10, 0), pady=4
        )

        ttk.Label(form, text="Category").grid(row=1, column=0, sticky="w", pady=4)
        ttk.Entry(form, textvariable=self.category_var).grid(
            row=1, column=1, sticky="ew", padx=(10, 0), pady=4
        )

        ttk.Label(form, text="Ingredients").grid(
            row=2, column=0, columnspan=2, sticky="w", pady=(10, 4)
        )
        self.ingredients_text = tk.Text(form, height=9, wrap="word")
        self.ingredients_text.grid(
            row=3, column=0, columnspan=2, sticky="nsew", pady=(0, 8)
        )

        ttk.Label(form, text="Instructions").grid(
            row=4, column=0, columnspan=2, sticky="w", pady=(6, 4)
        )
        self.instructions_text = tk.Text(form, height=9, wrap="word")
        self.instructions_text.grid(
            row=5, column=0, columnspan=2, sticky="nsew", pady=(0, 10)
        )

        buttons = ttk.Frame(form)
        buttons.grid(row=6, column=0, columnspan=2, sticky="ew")
        ttk.Button(buttons, text="Preview Recipe", command=self.preview_recipe).pack(side="left")
        ttk.Button(buttons, text="Save Recipe", command=self.save_recipe).pack(side="left", padx=8)
        ttk.Button(buttons, text="Clear", command=self.clear_form).pack(side="left")

        photo_panel = ttk.LabelFrame(self.add_tab, text="Recipe Photo and OCR", padding=10)
        photo_panel.grid(row=0, column=1, sticky="nsew")
        photo_panel.columnconfigure(0, weight=1)
        photo_panel.rowconfigure(3, weight=1)

        self.add_image_label = ttk.Label(photo_panel, text="No photo selected", anchor="center", padding=10)
        self.add_image_label.grid(row=0, column=0, sticky="nsew")
        ttk.Button(photo_panel, text="Select Photo and Read Text", command=self.choose_photo).grid(row=1, column=0, sticky="ew", pady=8)

        self.ocr_status = tk.StringVar(value="OCR has not run yet.")
        ttk.Label(photo_panel, textvariable=self.ocr_status, wraplength=360).grid(row=2, column=0, sticky="w", pady=(0, 6))
        self.ocr_text = tk.Text(photo_panel, height=16, wrap="word")
        self.ocr_text.grid(row=3, column=0, sticky="nsew")

    def build_library_tab(self):
        self.library_tab.columnconfigure(0, weight=2)
        self.library_tab.columnconfigure(1, weight=3)
        self.library_tab.rowconfigure(0, weight=1)

        list_panel = ttk.Frame(self.library_tab, padding=(0, 0, 12, 0))
        list_panel.grid(row=0, column=0, sticky="nsew")
        list_panel.columnconfigure(0, weight=1)
        list_panel.rowconfigure(0, weight=1)

        self.recipe_list = tk.Listbox(list_panel, exportselection=False)
        self.recipe_list.grid(row=0, column=0, sticky="nsew")
        scrollbar = ttk.Scrollbar(list_panel, orient="vertical", command=self.recipe_list.yview)
        scrollbar.grid(row=0, column=1, sticky="ns")
        self.recipe_list.configure(yscrollcommand=scrollbar.set)
        ttk.Button(list_panel, text="Show Selected Recipe", command=self.show_selected_recipe).grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))

        detail_panel = ttk.LabelFrame(self.library_tab, text="Recipe Details", padding=10)
        detail_panel.grid(row=0, column=1, sticky="nsew")
        detail_panel.columnconfigure(0, weight=1)
        detail_panel.rowconfigure(0, weight=3)
        detail_panel.rowconfigure(1, weight=2)

        self.detail_text = tk.Text(detail_panel, wrap="word", state="disabled")
        self.detail_text.grid(row=0, column=0, sticky="nsew")
        self.library_image_label = ttk.Label(detail_panel, text="No saved photo", anchor="center", padding=10)
        self.library_image_label.grid(row=1, column=0, sticky="nsew", pady=(10, 0))

    def form_values(self):
        return {
            "title": self.title_var.get().strip(),
            "category": self.category_var.get().strip(),
            "ingredients": self.ingredients_text.get("1.0", "end").strip(),
            "instructions": self.instructions_text.get("1.0", "end").strip(),
        }

    def preview_recipe(self):
        recipe = self.form_values()
        preview = (
            f"{recipe['title'] or 'Untitled recipe'}\n"
            f"Category: {recipe['category'] or 'Uncategorized'}\n\n"
            f"Ingredients\n{recipe['ingredients'] or 'None entered'}\n\n"
            f"Instructions\n{recipe['instructions'] or 'None entered'}"
        )
        messagebox.showinfo("Recipe Preview", preview, parent=self)

    def choose_photo(self):
        selected = filedialog.askopenfilename(
            parent=self,
            title="Select a printed recipe photo",
            filetypes=(("Image files", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff"), ("All files", "*.*")),
        )
        if not selected:
            return

        self.selected_photo = Path(selected)
        self.ocr_status.set("Reading the photo...")
        self.update_idletasks()

        try:
            self.add_image_ref = self.make_thumbnail(self.selected_photo, self.add_image_label, (360, 220))
            extracted = pytesseract.image_to_string(str(self.selected_photo), lang="eng", config="--psm 6").strip()
        except (OSError, pytesseract.TesseractError) as error:
            self.ocr_status.set("OCR failed. Check the image and Tesseract setup.")
            messagebox.showerror("OCR error", str(error), parent=self)
            return

        self.ocr_text.delete("1.0", "end")
        self.ocr_text.insert("1.0", extracted)
        if extracted:
            self.ocr_status.set("OCR draft ready. Correct it before saving.")
        else:
            self.ocr_status.set("No text found. Try a brighter, straighter photo.")

    def make_thumbnail(self, image_path, label, size):
        with Image.open(image_path) as image:
            preview = image.copy()
        preview.thumbnail(size)
        image_ref = ImageTk.PhotoImage(preview)
        label.configure(image=image_ref, text="")
        return image_ref

    def save_recipe(self):
        recipe = self.form_values()
        if not recipe["title"]:
            messagebox.showwarning("Missing title", "Enter a recipe title before saving.", parent=self)
            return
        if not recipe["ingredients"] and not recipe["instructions"]:
            messagebox.showwarning("Missing recipe text", "Enter ingredients or instructions before saving.", parent=self)
            return

        photo_path = ""
        if self.selected_photo:
            destination = IMAGE_DIR / f"{uuid.uuid4().hex}{self.selected_photo.suffix.lower()}"
            shutil.copy2(self.selected_photo, destination)
            photo_path = str(destination.relative_to(BASE_DIR))

        self.connection.execute(
            """
            INSERT INTO recipes (title, category, ingredients, instructions, photo_path)
            VALUES (?, ?, ?, ?, ?)
            """,
            (recipe["title"], recipe["category"], recipe["ingredients"], recipe["instructions"], photo_path),
        )
        self.connection.commit()
        messagebox.showinfo("Recipe saved", f"Saved {recipe['title']} to your vault.", parent=self)
        self.clear_form()
        self.load_recipes()
        self.notebook.select(self.library_tab)

    def clear_form(self):
        self.title_var.set("")
        self.category_var.set("")
        self.ingredients_text.delete("1.0", "end")
        self.instructions_text.delete("1.0", "end")
        self.ocr_text.delete("1.0", "end")
        self.selected_photo = None
        self.add_image_ref = None
        self.add_image_label.configure(image="", text="No photo selected")
        self.ocr_status.set("OCR has not run yet.")

    def load_recipes(self):
        self.recipe_rows = list(
            self.connection.execute(
                """
                SELECT id, title, category, ingredients, instructions, photo_path
                FROM recipes
                ORDER BY title COLLATE NOCASE
                """
            )
        )
        self.recipe_list.delete(0, "end")
        for row in self.recipe_rows:
            category = row["category"] or "Uncategorized"
            self.recipe_list.insert("end", f"{row['title']} | {category}")

        if self.recipe_rows:
            self.recipe_list.selection_set(0)
            self.recipe_list.activate(0)
            self.show_selected_recipe()
        else:
            self.set_detail_text("No saved recipes yet.")
            self.library_image_ref = None
            self.library_image_label.configure(image="", text="No saved photo")

    def show_selected_recipe(self):
        selected = self.recipe_list.curselection()
        if not selected:
            return

        row = self.recipe_rows[selected[0]]
        details = (
            f"{row['title']}\n"
            f"Category: {row['category'] or 'Uncategorized'}\n\n"
            f"Ingredients\n{row['ingredients'] or 'None entered'}\n\n"
            f"Instructions\n{row['instructions'] or 'None entered'}"
        )
        self.set_detail_text(details)

        if row["photo_path"]:
            photo = BASE_DIR / row["photo_path"]
            if photo.is_file():
                try:
                    self.library_image_ref = self.make_thumbnail(photo, self.library_image_label, (420, 230))
                    return
                except OSError:
                    self.library_image_label.configure(image="", text="Saved photo could not be opened")
                    return

        self.library_image_ref = None
        self.library_image_label.configure(image="", text="No saved photo")

    def set_detail_text(self, text):
        self.detail_text.configure(state="normal")
        self.detail_text.delete("1.0", "end")
        self.detail_text.insert("1.0", text)
        self.detail_text.configure(state="disabled")

    def close_app(self):
        self.connection.close()
        self.destroy()


if __name__ == "__main__":
    app = RecipeVault()
    app.mainloop()

How to use this reference

Compare the full file with your saved app.py. Pay close attention to method order plus indentation inside the RecipeVault class.

The reference still loads every recipe in case-insensitive title order. Search filtering belongs to the optional Secret Mission.

Secret mission

Filter the Recipe Library

Your saved recipe collection becomes harder to scan as it grows. Add a parameterized text filter that finds matching recipes without changing the rows stored in your local database.

Clean Up Your Resources

Clean Up Your Resources

All processing and storage stay on your Fedora computer, so there are no ongoing usage charges. Choose whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Your app.py desktop application built with Tkinter.
  • Your requirements.txt dependency pins.
  • Your .venv Python virtual environment.
  • Your recipes.db SQLite database with the saved recipe rows.
  • Your recipe_images directory with copied OCR source photos.

Keep everything running

No action needed. Choose this if you are still adding recipes or improving the filter.

  • Close the Local Recipe Vault window when you finish using it.
  • Leave ~/Projects/local-recipe-vault in place.
  • Keep the installed Fedora packages available for this project.

Your recipes and copied photos remain available for your next session.

Pause - I'll come back to this later

Stop the application process to free up memory while keeping every recipe and copied photo on disk.

  • Close the Local Recipe Vault window.
  • Return to Fedora Terminal.
  • Deactivate the virtual environment by running this command:
deactivate

What does this command do?

The command leaves the isolated Python environment. It does not remove any project files.

Your recipes.db database and recipe_images directory remain untouched.

Delete - I don't want to use this again

Remove all project resources if you want to start fresh. Deleting the folder is permanent, so the path check below keeps the cleanup focused on this project.

  • Close the Local Recipe Vault window.
  • In Fedora's file manager, open the Projects folder inside your home directory.
  • Confirm that local-recipe-vault contains app.py.
  • Return to Fedora Terminal.
  • Deactivate the virtual environment by running this command:
deactivate

What does this command do?

The command exits .venv before you remove its directory.

  • Delete the complete project folder by running this command:
rm -rf ~/Projects/local-recipe-vault

What does this delete?

  • The command removes only ~/Projects/local-recipe-vault.
  • The removal includes app.py.
  • The removal includes recipes.db.
  • The removal includes recipe_images.
  • Return to the Projects folder in your file manager.
  • Confirm that local-recipe-vault no longer appears.

The Fedora packages remain installed because other local projects may use them.

Nice Work!

Nice Work!

You did it! Your Fedora desktop now runs a private recipe vault for printed recipes. Each photo becomes a saved local record you can find again.

You've learned how to:

  • Built a two-tab Tkinter interface for entering recipe details. Added a formatted preview that responds to the current form values.
  • Stored complete recipes in SQLite with parameterized SQL. Reloaded saved records after closing the application.
  • Used Pillow to preview recipe photos. Used Tesseract OCR to turn printed text into an editable draft. Preserved each source photo beside its saved recipe.
  • Completed the optional Secret Mission by adding a four-field search across the recipe library. Confirmed Show All restores the complete library without changing stored rows.

Ready to quiz yourself?