Information Technology Grimoire

Version .0.0.1

IT Notes from various projects because I forget, and hopefully they help you too.

Python3 outliner for note taking

I needed something to take private notes and create docx files out of it.

Demo

Demo

Github

https://github.com/rubysash/outliner.py

Python Source Code

import ttkbootstrap as ttk
from ttkbootstrap import Style
from tkinter import messagebox
from tkinter.filedialog import asksaveasfilename, askopenfilename
import tkinter as tk
import tkinter.font as tkFont  # Import the font module
from docx import Document
from docx.shared import Pt, Inches, RGBColor
import sqlite3
import json

# Application Defaults
THEME = (
    "darkly"  # cosmo, litera, minty, pulse, sandstone, solar, superhero, flatly, darkly
)
VERSION = "0.9"
DB_NAME = "outline.db"  # default db it will look for or create
GLOBAL_FONT_FAMILY = "Helvetica"  # Set the global font family
GLOBAL_FONT_SIZE = 12  # Set the global font size
GLOBAL_FONT = (GLOBAL_FONT_FAMILY, GLOBAL_FONT_SIZE)

# DOCX Exports
DOC_FONT = "Helvetica"
H1_SIZE = 18
H2_SIZE = 15
H3_SIZE = 12
H4_SIZE = 10
P_SIZE = 10
INDENT_SIZE = 0.25


class OutLineEditorApp:
    def __init__(self, root):
        # Apply ttkbootstrap theme
        self.style = Style(THEME)  # Use the global THEME
        self.root = root
        self.root.title(f"Outline Editor v{VERSION}")  # Use the global VERSION

        # Set global font scaling using tkinter.font
        default_font = tkFont.nametofont("TkDefaultFont")
        default_font.configure(family=GLOBAL_FONT_FAMILY, size=GLOBAL_FONT_SIZE)

        # Apply the font to Treeview specifically
        tree_font = tkFont.nametofont("TkTextFont")
        tree_font.configure(family=GLOBAL_FONT_FAMILY, size=GLOBAL_FONT_SIZE)

        # Apply the font to Label widgets
        label_font = tkFont.nametofont("TkHeadingFont")
        label_font.configure(family=GLOBAL_FONT_FAMILY, size=GLOBAL_FONT_SIZE)

        # Enforce Treeview row height based on font size
        row_height = int(GLOBAL_FONT_SIZE * 2.2)
        self.root.tk.call(
            "ttk::style", "configure", "Treeview", "-rowheight", row_height
        )

        # Padding constants
        LABEL_PADX = 5
        LABEL_PADY = (5, 5)  # Top padding: 5, bottom padding: 0
        ENTRY_PADY = (5, 5)  # Top padding: 0, bottom padding: 5
        SECTION_PADY = (5, 10)  # Treeview and Notes padding
        BUTTON_PADX = 5
        BUTTON_PADY = (5, 0)  # Button sections padding
        FRAME_PADX = 10
        FRAME_PADY = 10

        # Configure Treeview Frame
        self.tree_frame = ttk.Frame(root, width=500)  # Frame for Treeview
        self.tree_frame.grid(
            row=0, column=0, sticky="nswe", padx=FRAME_PADX, pady=FRAME_PADY
        )
        self.tree_frame.grid_propagate(False)  # Prevent shrinking
        self.tree_frame.grid_rowconfigure(1, weight=1)  # Let treeview expand vertically
        self.tree_frame.grid_columnconfigure(
            0, weight=1
        )  # Allow horizontal expansion within the frame

        # Add Label above Treeview
        ttk.Label(self.tree_frame, text="Your Outline", bootstyle="info").grid(
            row=0, column=0, sticky="w", padx=LABEL_PADX, pady=LABEL_PADY
        )

        # Treeview
        self.tree = ttk.Treeview(
            self.tree_frame, show="tree", bootstyle="info", height=25
        )
        self.tree.grid(
            row=1, column=0, sticky="nsew", padx=LABEL_PADX, pady=SECTION_PADY
        )
        self.tree.bind("<<TreeviewSelect>>", self.load_selected)

        # Make sure the Treeview expands fully within its frame
        self.tree_frame.grid_rowconfigure(1, weight=1)
        self.tree_frame.grid_columnconfigure(0, weight=1)

        # Add Search Entry (Search Bar)
        ttk.Label(
            self.tree_frame, text="Type Exact Search <Enter>", bootstyle="info"
        ).grid(row=2, column=0, sticky="w", padx=LABEL_PADX, pady=LABEL_PADY)
        self.search_entry = ttk.Entry(self.tree_frame, bootstyle="info")
        self.search_entry.grid(
            row=3, column=0, sticky="ew", padx=LABEL_PADX, pady=ENTRY_PADY
        )
        self.search_entry.bind(
            "<Return>", self.execute_search
        )  # Bind Enter key to search

        # Configure Editor Frame
        self.editor_frame = ttk.Frame(root)
        self.editor_frame.grid(
            row=0, column=1, sticky="nswe", padx=FRAME_PADX, pady=FRAME_PADY
        )
        self.editor_frame.grid_rowconfigure(3, weight=1)  # Allow textarea to expand
        self.editor_frame.grid_columnconfigure(
            0, weight=1
        )  # Allow horizontal expansion

        # Add Editor Fields
        ttk.Label(self.editor_frame, text="Title", bootstyle="info").grid(
            row=0, column=0, sticky="w", padx=LABEL_PADX, pady=LABEL_PADY
        )
        self.title_entry = ttk.Entry(self.editor_frame, bootstyle="info")
        self.title_entry.grid(
            row=1, column=0, sticky="ew", padx=LABEL_PADX, pady=ENTRY_PADY
        )

        ttk.Label(
            self.editor_frame, text="Questions Notes and Details", bootstyle="info"
        ).grid(row=2, column=0, sticky="w", padx=LABEL_PADX, pady=LABEL_PADY)
        self.questions_text = tk.Text(self.editor_frame, height=15)
        self.questions_text.grid(
            row=3, column=0, sticky="nswe", padx=LABEL_PADX, pady=SECTION_PADY
        )

        # Create Unified Button Row
        self.outliner_buttons = ttk.Frame(root)
        self.outliner_buttons.grid(
            row=1, column=0, columnspan=2, sticky="ew", padx=FRAME_PADX, pady=FRAME_PADY
        )

        # Add Buttons to Unified Button Row
        for text, command, style in [
            ("H(1)", self.add_header, "primary"),
            ("H(2)", self.add_category, "primary"),
            ("H(3)", self.add_subcategory, "primary"),
            ("H(4)", self.add_subheader, "primary"),
            ("(j) ↑", self.move_up, "secondary"),
            ("(k) ↓", self.move_down, "secondary"),
            ("(D)elete", self.delete_selected, "danger"),
            ("Make DOCX", self.export_to_docx, "success"),
            ("Load JSON", self.load_from_json, "info"),
            ("Load DB", self.load_database_from_file, "info"),
            ("New DB", self.reset_database, "warning"),
        ]:
            ttk.Button(
                self.outliner_buttons, text=text, command=command, bootstyle=style
            ).pack(side=tk.LEFT, padx=BUTTON_PADX, pady=BUTTON_PADY)

        # Configure root grid
        self.root.grid_rowconfigure(0, weight=1)  # Allow row 0 to expand
        self.root.grid_rowconfigure(1, weight=0)  # Make button row static in height
        self.root.grid_columnconfigure(0, minsize=400)  # Set minimum width for Treeview
        self.root.grid_columnconfigure(
            1, weight=1
        )  # Editor frame takes remaining space

        # Set initial window size
        self.root.geometry("900x800")  # Width x Height
        self.root.wm_minsize(825, 600)  # Minimum width and height

        # Database setup
        self.conn = sqlite3.connect(DB_NAME)
        self.cursor = self.conn.cursor()  # Initialize cursor before database methods
        self.setup_database()
        self.initialize_placement()

        self.last_selected_item_id = None

        # Bind focus-out events for auto-saving
        self.title_entry.bind("<FocusOut>", lambda event: self.save_data())
        self.questions_text.bind("<FocusOut>", lambda event: self.save_data())

        # Preload Data
        self.load_from_database()

        # Key Bindings
        self.root.bind_all("<Control-D>", lambda event: self.delete_selected())
        self.root.bind_all("<Control-d>", lambda event: self.delete_selected())
        self.root.bind_all("<Control-j>", lambda event: self.move_up())
        self.root.bind_all("<Control-k>", lambda event: self.move_down())
        self.root.bind_all("<F2>", self.focus_title_entry)
        self.root.bind_all("<Control-Key-1>", lambda event: self.add_header())
        self.root.bind_all("<Control-Key-2>", lambda event: self.add_category())
        self.root.bind_all("<Control-Key-3>", lambda event: self.add_subcategory())
        self.root.bind_all("<Control-Key-4>", lambda event: self.add_subheader())

        self.root.protocol("WM_DELETE_WINDOW", self.on_closing)

    def focus_title_entry(self, event):
        """Move focus to the title entry and position the cursor at the end."""
        self.title_entry.focus_set()  # Focus on the title entry
        self.title_entry.icursor(tk.END)  # Move the cursor to the end of the text

    def execute_search(self, event=None):
        """Filter treeview to show only items that match the search query."""
        query = self.search_entry.get().strip()
        if not query:
            self.load_from_database()  # Reset tree if query is empty
            return

        # Query database for matching titles or questions
        self.cursor.execute(
            """
            WITH RECURSIVE parents AS (
                SELECT id, parent_id, title, questions
                FROM sections
                WHERE title LIKE ? OR questions LIKE ?
                UNION
                SELECT s.id, s.parent_id, s.title, s.questions
                FROM sections s
                INNER JOIN parents p ON s.id = p.parent_id
            )
            SELECT id, parent_id
            FROM parents
            ORDER BY parent_id, id
        """,
            (f"%{query}%", f"%{query}%"),
        )
        matches = self.cursor.fetchall()

        ids_to_show = {row[0] for row in matches}
        parents_to_show = {row[1] for row in matches if row[1] is not None}

        # Generate numbering for all items
        numbering_dict = self.generate_numbering()

        # Clear and repopulate the treeview
        self.tree.delete(*self.tree.get_children())
        self.populate_filtered_tree(None, "", ids_to_show, parents_to_show)

        # Apply consistent numbering
        self.calculate_numbering(numbering_dict)

    def populate_filtered_tree(
        self, parent_id, parent_node, ids_to_show, parents_to_show
    ):
        """Recursively populate the treeview with filtered data."""
        # Query children for the current parent_id
        if parent_id is None:
            self.cursor.execute(
                "SELECT id, title, parent_id FROM sections WHERE parent_id IS NULL"
            )
        else:
            self.cursor.execute(
                "SELECT id, title, parent_id FROM sections WHERE parent_id = ?",
                (parent_id,),
            )

        children = self.cursor.fetchall()

        for child in children:
            if child[0] in ids_to_show or child[0] in parents_to_show:
                node = self.tree.insert(parent_node, "end", child[0], text=child[1])
                self.tree.see(node)  # Ensure the node is visible
                self.populate_filtered_tree(
                    child[0], node, ids_to_show, parents_to_show
                )

    def on_closing(self):
        """Handle window closing event."""
        try:
            self.save_data()  # Save any pending changes
            self.conn.close()  # Close the database connection
            self.root.destroy()
        except Exception as e:
            print(f"Error during closing: {e}")
            self.root.destroy()

    def setup_database(self):
        """Create tables if they do not exist."""
        self.cursor.execute(
            """
        CREATE TABLE IF NOT EXISTS sections (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            parent_id INTEGER,
            title TEXT,
            type TEXT, -- 'header', 'category', 'subcategory', or 'subheader'
            questions TEXT, -- JSON array of questions
            placement INTEGER -- Placement of items within the same parent
        )
        """
        )
        self.conn.commit()

        # Add 'placement' column if it doesn't already exist
        try:
            self.cursor.execute("ALTER TABLE sections ADD COLUMN placement INTEGER")
        except sqlite3.OperationalError:
            pass  # Ignore if the column already exists

    def calculate_numbering(self, numbering_dict):
        """Assign hierarchical numbering to tree nodes based on the provided numbering dictionary."""
        for node in self.tree.get_children():
            self.apply_numbering_recursive(node, numbering_dict)

    def apply_numbering_recursive(self, node, numbering_dict):
        """Apply numbering to a node and its children recursively."""
        node_id = self.get_item_id(node)
        if node_id in numbering_dict:
            logical_title = self.tree.item(node, "text").split(". ", 1)[
                -1
            ]  # Remove existing numbering
            display_title = f"{numbering_dict[node_id]}. {logical_title}"
            self.tree.item(node, text=display_title)

        for child in self.tree.get_children(node):
            self.apply_numbering_recursive(child, numbering_dict)

    def generate_numbering(self):
        """Generate a numbering dictionary for all items based on the database hierarchy."""
        numbering_dict = {}

        def recursive_numbering(parent_id=None, prefix=""):
            # Retrieve children based on parent_id
            self.cursor.execute(
                """
                SELECT id, placement FROM sections
                WHERE parent_id IS ?
                ORDER BY placement
            """,
                (parent_id,),
            )
            children = self.cursor.fetchall()

            for idx, (child_id, _) in enumerate(children, start=1):
                number = f"{prefix}{idx}"
                numbering_dict[child_id] = number
                recursive_numbering(child_id, f"{number}.")

        recursive_numbering()  # Start from the root
        return numbering_dict

    def load_from_database(self):
        """Load data from the database and populate the Treeview."""
        try:
            # Clear the treeview
            self.tree.delete(*self.tree.get_children())

            expanded_items = self.get_expanded_items()

            self.cursor.execute(
                """
                SELECT id, title, type, parent_id, placement
                FROM sections 
                ORDER BY placement, id
            """
            )
            sections = self.cursor.fetchall()

            # Populate the treeview
            def populate_tree(parent_id, parent_node):
                current_level = [s for s in sections if s[3] == parent_id]
                for section in current_level:
                    node = self.tree.insert(
                        parent_node, "end", section[0], text=section[1]
                    )
                    populate_tree(section[0], node)

            numbering_dict = self.generate_numbering()  # Generate numbering dictionary
            populate_tree(None, "")
            self.calculate_numbering(numbering_dict)  # Pass only numbering_dict
            self.restore_expansion_state(expanded_items)
        except Exception as e:
            print(f"Error in load_from_database: {e}")

    def add_header(self):
        previous_selection = self.tree.selection()
        title = f"Header {len(self.tree.get_children()) + 1}"
        self.cursor.execute(
            "INSERT INTO sections (title, type, parent_id) VALUES (?, 'header', NULL)",
            (title,),
        )
        self.conn.commit()
        self.load_from_database()
        if previous_selection:
            self.select_item(previous_selection[0])

    def add_category(self):
        previous_selection = self.tree.selection()
        if (
            not previous_selection
            or self.get_item_type(previous_selection[0]) != "header"
        ):
            messagebox.showerror("Error", "Please select a header to add a category.")
            return
        title = f"Category {len(self.tree.get_children(previous_selection[0])) + 1}"
        parent_id = self.get_item_id(previous_selection[0])
        self.cursor.execute(
            "INSERT INTO sections (title, type, parent_id) VALUES (?, 'category', ?)",
            (title, parent_id),
        )
        self.conn.commit()
        self.load_from_database()
        self.select_item(previous_selection[0])

    def add_subcategory(self):
        previous_selection = self.tree.selection()
        if (
            not previous_selection
            or self.get_item_type(previous_selection[0]) != "category"
        ):
            messagebox.showerror(
                "Error", "Please select a category to add a subcategory."
            )
            return
        title = f"Subcategory {len(self.tree.get_children(previous_selection[0])) + 1}"
        parent_id = self.get_item_id(previous_selection[0])
        self.cursor.execute(
            "INSERT INTO sections (title, type, parent_id) VALUES (?, 'subcategory', ?)",
            (title, parent_id),
        )
        self.conn.commit()
        self.load_from_database()
        self.select_item(previous_selection[0])

    def add_subheader(self):
        """Add a subheader (H4) to the selected subcategory (H3)."""
        selected = self.tree.selection()
        if not selected:
            messagebox.showerror(
                "Error", "246: Please select a subcategory to add a subheader."
            )
            return

        selected_id = selected[0]  # Get the selected item ID

        # Get the type directly from database
        query = "SELECT type FROM sections WHERE id = ?"
        self.cursor.execute(query, (int(selected_id),))  # Explicitly convert to int
        result = self.cursor.fetchone()

        if not result or result[0] != "subcategory":
            messagebox.showerror(
                "Error",
                f"260: Please select a subcategory (H3) to add a subheader. Current type: '{result[0] if result else 'None'}'",
            )
            return

        # Get the next placement value
        self.cursor.execute(
            "SELECT COALESCE(MAX(placement), 0) + 1 FROM sections WHERE parent_id = ?",
            (int(selected_id),),  # Explicitly convert to int
        )
        next_placement = self.cursor.fetchone()[0]

        # Create the title and insert the new subheader
        title = f"Sub Header {next_placement}"
        insert_query = "INSERT INTO sections (title, type, parent_id, placement) VALUES (?, ?, ?, ?)"
        params = (title, "subheader", int(selected_id), next_placement)

        self.cursor.execute(insert_query, params)
        self.conn.commit()

        # Save the expanded state and current selection
        expanded_items = self.get_expanded_items()

        # Refresh the tree
        self.load_from_database()

        # Restore expansion state
        self.restore_expansion_state(expanded_items)

        # Reselect the parent H3
        self.select_item(selected_id)

    def save_data(self):
        """Save data for the currently edited item."""
        if self.last_selected_item_id is None:
            return  # No item to save

        logical_title = (
            self.title_entry.get().strip().split(". ", 1)[-1]
        )  # Remove numbering
        questions = json.dumps(self.questions_text.get(1.0, tk.END).strip().split("\n"))

        # Update the database with the logical title
        self.cursor.execute(
            "UPDATE sections SET title = ?, questions = ? WHERE id = ?",
            (logical_title, questions, self.last_selected_item_id),
        )
        self.conn.commit()

        # Update the TreeView display with the logical title
        if self.tree.exists(self.last_selected_item_id):
            self.tree.item(self.last_selected_item_id, text=logical_title)

        numbering_dict = self.generate_numbering()  # Generate numbering dictionary
        self.calculate_numbering(numbering_dict)  # Pass only numbering_dict

    def delete_selected(self):
        """Deletes the selected item and all its children, ensuring parent restrictions."""
        selected = self.tree.selection()
        if not selected:
            messagebox.showerror("Error", "Please select an item to delete.")
            return

        item_id = self.get_item_id(selected[0])
        item_type = self.get_item_type(selected[0])

        # Check if the item has children
        self.cursor.execute(
            "SELECT COUNT(*) FROM sections WHERE parent_id = ?", (item_id,)
        )
        has_children = self.cursor.fetchone()[0] > 0

        if has_children:
            messagebox.showerror(
                "Error", f"Cannot delete {item_type} with child items."
            )
            return

        # Confirm deletion
        confirm = messagebox.askyesno(
            "Confirm Deletion",
            f"Are you sure you want to delete the selected {item_type}?",
        )
        if confirm:
            self.cursor.execute("DELETE FROM sections WHERE id = ?", (item_id,))
            self.conn.commit()

            # Remove the item from the Treeview
            self.tree.delete(selected[0])

            # Reset the editor and last selected item
            self.last_selected_item_id = None
            self.title_entry.delete(0, tk.END)
            self.questions_text.delete(1.0, tk.END)

            print(f"362->Deleted: {item_type.capitalize()} deleted successfully.")

    def reset_database(self):
        """Prompt for a new database file and reset the Treeview."""
        try:
            new_db_path = asksaveasfilename(
                defaultextension=".db",
                filetypes=[("SQLite Database", "*.db")],
                title="Create New Database File",
            )
            if not new_db_path:
                return  # User cancelled
            self.conn.close()  # Close the current database connection

            # Create a new database and reinitialize
            self.conn = sqlite3.connect(new_db_path)
            self.cursor = self.conn.cursor()
            self.setup_database()
            self.initialize_placement()

            # Reset the Treeview
            self.tree.delete(*self.tree.get_children())
            messagebox.showinfo("Success", f"New database created: {new_db_path}")

        except Exception as e:
            messagebox.showerror(
                "Error", f"An error occurred while resetting the database: {e}"
            )

    def load_database_from_file(self):
        """Load an existing database file and update the Treeview."""
        try:
            db_path = askopenfilename(
                filetypes=[("SQLite Database", "*.db")], title="Select Database File"
            )
            if not db_path:
                return  # User cancelled
            self.conn.close()  # Close the current database connection

            # Open the selected database
            self.conn = sqlite3.connect(db_path)
            self.cursor = self.conn.cursor()

            # Clear the treeview
            self.tree.delete(*self.tree.get_children())

            # Reload the Treeview from the new database
            self.load_from_database()
            messagebox.showinfo("Success", f"Database loaded: {db_path}")

        except sqlite3.DatabaseError:
            messagebox.showerror(
                "Error", "The selected file is not a valid SQLite database."
            )
        except Exception as e:
            messagebox.showerror("Error", f"An unexpected error occurred: {e}")

    def get_expanded_items(self):
        """Get a list of expanded items in the Treeview."""
        expanded_items = []
        for item in self.tree.get_children():
            expanded_items.extend(self.get_expanded_items_recursively(item))
        return expanded_items

    def get_expanded_items_recursively(self, item):
        """Recursively check for expanded items."""
        expanded_items = []
        if self.tree.item(item, "open"):
            expanded_items.append(item)
            for child in self.tree.get_children(item):
                expanded_items.extend(self.get_expanded_items_recursively(child))
        return expanded_items

    def restore_expansion_state(self, expanded_items):
        """Restore the expanded state of items in the Treeview."""
        for item in expanded_items:
            self.tree.item(item, open=True)

    def load_from_json(self):
        """Load JSON from file and populate the database with hierarchical data."""
        # Open a file dialog for selecting a JSON file
        file_path = askopenfilename(
            filetypes=[("JSON Files", "*.json")], title="Select JSON File"
        )
        if not file_path:  # If the user cancels the file dialog
            return

        try:
            # Confirm action with the user
            confirm = messagebox.askyesno(
                "Preload Warning",
                "Loading this JSON will populate the database and may cause duplicates. Do you want to continue?",
            )
            if not confirm:
                return

            # Load the JSON data
            with open(file_path, "r") as file:
                data = json.load(file)

            # Validate JSON structure
            self.validate_json_structure(data)

            # Populate the database
            def insert_section(title, section_type, placement, parent_id=None):
                self.cursor.execute(
                    "INSERT INTO sections (title, type, parent_id, placement) VALUES (?, ?, ?, ?)",
                    (title, section_type, parent_id, placement),
                )
                return self.cursor.lastrowid

            for h1_idx, h1_item in enumerate(data.get("h1", []), start=1):
                h1_id = insert_section(h1_item["name"], "header", h1_idx)
                for h2_idx, h2_item in enumerate(h1_item.get("h2", []), start=1):
                    h2_id = insert_section(h2_item["name"], "category", h2_idx, h1_id)
                    for h3_idx, h3_item in enumerate(h2_item.get("h3", []), start=1):
                        insert_section(h3_item["name"], "subcategory", h3_idx, h2_id)

            self.conn.commit()
            self.load_from_database()
            messagebox.showinfo(
                "Success", f"JSON data successfully loaded from {file_path}."
            )

        except FileNotFoundError:
            messagebox.showerror("Error", f"File not found: {file_path}")
        except json.JSONDecodeError:
            messagebox.showerror(
                "Error", "Invalid JSON format. Please select a valid JSON file."
            )
        except ValueError as ve:
            messagebox.showerror("Error", f"Invalid JSON structure: {ve}")
        except Exception as e:
            messagebox.showerror("Error", f"An unexpected error occurred: {e}")

    def validate_json_structure(self, data):
        """Validate the hierarchical structure of the JSON data."""
        if not isinstance(data, dict) or "h1" not in data:
            raise ValueError("Root JSON must be a dictionary with an 'h1' key.")

        for h1_item in data.get("h1", []):
            if not isinstance(h1_item, dict) or "name" not in h1_item:
                raise ValueError(
                    f"Each 'h1' item must be a dictionary with a 'name'. Invalid item: {h1_item}"
                )

            # h2 is optional, but if it exists, validate it
            if "h2" in h1_item:
                if not isinstance(h1_item["h2"], list):
                    raise ValueError(
                        f"'h2' must be a list in 'h1' item. Invalid 'h1' item: {h1_item}"
                    )

                for h2_item in h1_item["h2"]:
                    if not isinstance(h2_item, dict) or "name" not in h2_item:
                        raise ValueError(
                            f"Each 'h2' item must be a dictionary with a 'name'. Invalid item: {h2_item}"
                        )

                    # h3 is optional, but if it exists, validate it
                    if "h3" in h2_item:
                        if not isinstance(h2_item["h3"], list):
                            raise ValueError(
                                f"'h3' must be a list in 'h2' item. Invalid 'h2' item: {h2_item}"
                            )

                        for h3_item in h2_item["h3"]:
                            if not isinstance(h3_item, dict) or "name" not in h3_item:
                                raise ValueError(
                                    f"Each 'h3' item must be a dictionary with a 'name'. Invalid item: {h3_item}"
                                )

    def initialize_placement(self):
        """Assign default placement for existing rows if placement is NULL."""
        try:
            self.cursor.execute(
                """
            WITH RECURSIVE section_hierarchy(id, parent_id, level) AS (
                SELECT id, parent_id, 0 FROM sections WHERE parent_id IS NULL
                UNION ALL
                SELECT s.id, s.parent_id, h.level + 1
                FROM sections s
                INNER JOIN section_hierarchy h ON s.parent_id = h.id
            )
            SELECT id, ROW_NUMBER() OVER (PARTITION BY parent_id ORDER BY id) AS new_placement
            FROM section_hierarchy
            """
            )
            for row in self.cursor.fetchall():
                self.cursor.execute(
                    "SELECT placement FROM sections WHERE id = ?", (row[0],)
                )
                existing_placement = self.cursor.fetchone()[0]
                if existing_placement is None:
                    self.cursor.execute(
                        "UPDATE sections SET placement = ? WHERE id = ?",
                        (row[1], row[0]),
                    )
            self.conn.commit()
        except Exception as e:
            print(f"Error in initialize_placement: {e}")
            self.conn.rollback()

    def swap_placement(self, item_id1, item_id2):
        """Swap the placement of two items in the database and ensure consistency."""
        try:
            # Get current placements
            self.cursor.execute(
                "SELECT placement FROM sections WHERE id = ?", (item_id1,)
            )
            placement1 = self.cursor.fetchone()[0] or 0  # Handle NULL

            self.cursor.execute(
                "SELECT placement FROM sections WHERE id = ?", (item_id2,)
            )
            placement2 = self.cursor.fetchone()[0] or 0  # Handle NULL

            # Perform the swap
            self.cursor.execute(
                "UPDATE sections SET placement = ? WHERE id = ?", (placement2, item_id1)
            )
            self.cursor.execute(
                "UPDATE sections SET placement = ? WHERE id = ?", (placement1, item_id2)
            )

            # Commit changes
            self.conn.commit()

            # Post-commit verification
            self.cursor.execute(
                "SELECT id, placement FROM sections WHERE id IN (?, ?) ORDER BY id",
                (item_id1, item_id2),
            )
            verification = self.cursor.fetchall()
            # print("Post-commit verification of placements:", verification)

            if not verification or len(verification) != 2:
                raise RuntimeError(
                    "Post-commit verification failed: Expected updated rows not found."
                )

            for row in verification:
                if (row[0] == item_id1 and row[1] != placement2) or (
                    row[0] == item_id2 and row[1] != placement1
                ):
                    raise RuntimeError(
                        "Post-commit verification failed: Placements do not match expected values."
                    )

        except sqlite3.OperationalError as e:
            print(f"Database is locked: {e}")
            self.conn.rollback()
        except Exception as e:
            print(f"Error in swap_placement: {e}")
            self.conn.rollback()

    def fix_placement(self, parent_id):
        """Ensure all children of a parent have sequential placement values."""
        try:
            self.cursor.execute(
                "SELECT id FROM sections WHERE parent_id = ? ORDER BY placement NULLS LAST, id",
                (parent_id,),
            )
            children = self.cursor.fetchall()

            for index, (child_id,) in enumerate(children, start=1):
                self.cursor.execute(
                    "UPDATE sections SET placement = ? WHERE id = ?", (index, child_id)
                )

            self.conn.commit()
        except Exception as e:
            print(f"531: Error in fix_placement: {e}")
            self.conn.rollback()

    def move_up(self):
        selected = self.tree.selection()
        if not selected:
            return

        item_id = self.get_item_id(selected[0])
        parent_id = self.tree.parent(selected[0])
        siblings = self.tree.get_children(parent_id)

        index = siblings.index(selected[0])
        if index > 0:  # If not the first item
            self.fix_placement(parent_id)  # Ensure placements are valid
            self.swap_placement(item_id, self.get_item_id(siblings[index - 1]))

            # Refresh the TreeView
            self.refresh_tree()

            self.select_item(selected[0])

    def move_down(self):
        selected = self.tree.selection()
        if not selected:
            return

        item_id = self.get_item_id(selected[0])
        parent_id = self.tree.parent(selected[0])
        siblings = self.tree.get_children(parent_id)

        # Find the index of the selected item
        index = siblings.index(selected[0])
        if index < len(siblings) - 1:  # If not the last item
            self.fix_placement(parent_id)  # Ensure placements are valid
            self.swap_placement(item_id, self.get_item_id(siblings[index + 1]))

            # Save positions and refresh the tree
            self.refresh_tree()

            # Reselect the moved item
            self.select_item(selected[0])

    def refresh_tree(self):
        """Reload the TreeView to reflect database changes."""
        try:
            expanded_items = self.get_expanded_items()
            self.tree.delete(*self.tree.get_children())
            self.load_from_database()
            self.restore_expansion_state(expanded_items)
        except Exception as e:
            print(f"Error in refresh_tree: {e}")

    def get_item_id(self, node):
        """Get the numeric ID from a tree node ID."""
        try:
            return int(node)
        except (ValueError, TypeError):
            print(f"Warning: Invalid node ID format: {node}")
            return None

    def get_item_type(self, node):
        """Fetch the type of the selected node from the database."""
        try:
            item_id = self.get_item_id(node)
            if item_id is None:
                return None

            self.cursor.execute("SELECT type FROM sections WHERE id = ?", (item_id,))
            result = self.cursor.fetchone()
            return result[0] if result else None
        except Exception as e:
            print(f"Error in get_item_type: {e}")
            return None

    def select_item(self, item_id):
        """Select and focus an item in the treeview."""
        try:
            if self.tree.exists(str(item_id)):
                self.tree.selection_set(str(item_id))
                self.tree.focus(str(item_id))
                self.tree.see(str(item_id))  # Ensure the item is visible
        except Exception as e:
            print(f"Error in select_item: {e}")

    def load_selected(self, event):
        """Load the selected item's data into the editor."""
        self.save_data()  # Save previous item's data

        selected = self.tree.selection()
        if not selected:
            return

        item_id = self.get_item_id(selected[0])
        self.last_selected_item_id = item_id  # Track the current selection

        # Retrieve logical title and questions
        self.cursor.execute(
            "SELECT title, questions FROM sections WHERE id = ?", (item_id,)
        )
        row = self.cursor.fetchone()

        if row:
            logical_title, questions = row

            # Clear current values in the editor
            self.title_entry.delete(0, tk.END)
            self.questions_text.delete(1.0, tk.END)

            # Populate editor fields with logical data
            self.title_entry.insert(0, logical_title)
            if questions:
                self.questions_text.insert(tk.END, "\n".join(json.loads(questions)))

    def export_to_docx(self):
        """creates the docx file based on specs defined"""
        try:
            doc = Document()
            self.cursor.execute(
                "SELECT id, title, type, parent_id, questions, placement FROM sections ORDER BY parent_id, placement"
            )
            sections = self.cursor.fetchall()

            # Add Table of Contents Placeholder
            toc_paragraph = doc.add_paragraph("Table of Contents", style="Heading 1")
            toc_paragraph.add_run("\n(TOC will need to be updated in Word)").italic = (
                True
            )
            doc.add_page_break()  # Add page break after TOC

            def add_custom_heading(doc, text, level):
                """Add a custom heading with specific formatting and indentation."""
                paragraph = doc.add_heading(level=level)
                if len(paragraph.runs) == 0:
                    run = paragraph.add_run()
                else:
                    run = paragraph.runs[0]
                run.text = text
                run.font.name = DOC_FONT
                run.bold = True

                # Apply colors and underline based on level
                if level == 1:  # H1 - Brick Red
                    run.font.size = Pt(H1_SIZE)
                    run.font.color.rgb = RGBColor(178, 34, 34)  # Brick red
                elif level == 2:  # H2 - Navy Blue
                    run.font.size = Pt(H2_SIZE)
                    run.font.color.rgb = RGBColor(0, 0, 128)  # Navy blue
                elif level == 3:  # H3 - Black
                    run.font.size = Pt(H3_SIZE)
                    run.font.color.rgb = RGBColor(0, 0, 0)  # Black
                elif level == 4:  # H4 - Underlined
                    run.font.size = Pt(H4_SIZE)
                    run.font.color.rgb = RGBColor(0, 0, 0)  # Black underline
                    run.underline = True

                # Adjust paragraph indentation
                paragraph.paragraph_format.left_indent = Inches(
                    INDENT_SIZE * (level - 1)
                )  # Incremental indentation
                return (
                    paragraph.paragraph_format.left_indent.inches
                )  # Return the calculated indentation

            def add_custom_paragraph(doc, text, style="Normal", indent=0):
                """Add a custom paragraph with specific formatting."""
                paragraph = doc.add_paragraph(text, style=style)
                paragraph.paragraph_format.left_indent = Inches(indent)
                paragraph.paragraph_format.space_after = Pt(P_SIZE)
                if len(paragraph.runs) == 0:
                    run = paragraph.add_run()
                else:
                    run = paragraph.runs[0]
                run.font.name = DOC_FONT
                run.font.size = Pt(P_SIZE)
                return paragraph

            def add_to_doc(parent_id, level, numbering_prefix="", is_first_h1=True):
                """Recursively add sections and their children to the document with hierarchical numbering."""
                children = [s for s in sections if s[3] == parent_id]

                for idx, section in enumerate(children, start=1):
                    # Generate numbering dynamically
                    number = f"{numbering_prefix}{idx}"
                    title_with_number = f"{number}. {section[1]}"

                    # Add page break before H1 (except the first one)
                    if level == 1 and not is_first_h1:
                        doc.add_page_break()
                    if level == 1:
                        is_first_h1 = (
                            False  # Update the flag after processing the first H1
                        )

                    # Add heading with numbering
                    parent_indent = add_custom_heading(doc, title_with_number, level)

                    # Validate and load questions
                    try:
                        questions = json.loads(section[4]) if section[4] else []
                    except json.JSONDecodeError:
                        questions = []

                    # Add content: bullet points for H3/H4, plain paragraphs otherwise
                    if not questions:
                        add_custom_paragraph(
                            doc,
                            "(No questions added yet)",
                            style="Normal",
                            indent=parent_indent + INDENT_SIZE,
                        )
                    else:
                        for question in questions:
                            if level >= 3:  # Add bullet points for H3 and H4
                                add_custom_paragraph(
                                    doc,
                                    question,
                                    style="Normal",
                                    indent=parent_indent + INDENT_SIZE,
                                )
                            else:  # Add plain paragraphs for H1 and H2
                                add_custom_paragraph(
                                    doc,
                                    question,
                                    style="Normal",
                                    indent=parent_indent + INDENT_SIZE,
                                )

                    # Recurse for children
                    add_to_doc(
                        section[0],
                        level + 1,
                        numbering_prefix=f"{number}.",
                        is_first_h1=is_first_h1,
                    )

            # Start adding sections from the root
            add_to_doc(None, 1)

            # Ask the user for a save location
            file_path = asksaveasfilename(
                defaultextension=".docx",
                filetypes=[("Word Documents", "*.docx")],
                title="Save Document As",
            )
            if not file_path:
                return  # User cancelled the save dialog

            # Save the document
            doc.save(file_path)
            messagebox.showinfo(
                "Exported", f"Document exported successfully to {file_path}."
            )

        except Exception as e:
            messagebox.showerror(
                "Export Failed", f"An error occurred during export:\n{e}"
            )


if __name__ == "__main__":
    root = tk.Tk()
    app = OutLineEditorApp(root)
    root.mainloop()
Last updated on 16 Dec 2023
Published on 16 Dec 2023