TL;DR

This Python app gives you a fast, point-and-click way to view and edit Autodesk Inventor file metadata (.ipt, .iam, .idw, .dwg) in bulk. It:

  • Scans a folder for Inventor files
  • Reads key properties (Part Number, Title, Subject, Comments, Revision, Date Checked)
  • Lets you edit most fields inline in a table UI (Dear PyGui)
  • Writes updates back to the native Inventor files via COM
  • Auto-updates Date Checked when anything changes
  • Exports a Markdown “Part Registry” table for your notes/wiki It keeps Inventor automation safely tucked in a dedicated COM thread, so the UI stays snappy and doesn’t deadlock.

Prototype bike frame

What problems this solves

  • Manual property edits are slow: Opening files one by one in Inventor is painful.
  • Inconsistent metadata: Revision/Title/etc. drift over time.
  • Status reporting: You need a tidy Markdown table to drop into docs or Notion/Wiki. This tool puts all that into a single, friendly window.

Requirements (the “yes, it’s Windows” part)

  • Windows (COM + win32com.client)
  • Autodesk Inventor installed (so COM automation works)
  • Python packages:
    • dearpygui (UI)
    • pywin32 (win32com.client + pythoncom)
    • tkinter (built into most Python on Windows, used to get screen size and file dialog)

High-level architecture

  1. UI (Dear PyGui): Presents a table for inline editing, folder selection, and buttons (Reload, Apply Changes, Close).
  2. COM Worker Thread: Runs Inventor automation in its own STA (single-threaded COM apartment). The UI posts jobs to it via a thread-safe queue; results come back through concurrent.futures.Future.
  3. File/Metadata Layer: Opens each file through Inventor, reads/writes property sets, and closes files cleanly.
  4. Markdown Export: Serializes the current in-memory rows to a nice Git-friendly table.

Configuration knobs (top of the file)

  • FOLDER: Default scan location for CAD files.
  • MD_OUT: Where the Markdown registry gets saved.
  • EXTS: Which file types to include.
  • COLUMNS: Display order for the table columns.
  • EDITABLE: Which columns the user can change in the UI. Change these to fit your project structure or add more properties.

The Inventor COM helpers (read/write properties)

Opening Inventor

def open_inventor(): inv = win32.Dispatch("Inventor.Application") inv.Visible = True return inv We spin up (or connect to) Inventor and make it visible. Visibility helps for debugging and makes COM behave a bit more predictably.

Reading properties

def get_prop(doc, set_name, prop_name): # Returns "" if the property is missing Looks up a property inside a Property Set (e.g., "Inventor Summary Information", "Design Tracking Properties"). All reads are safe: errors become empty strings so a missing property won’t crash the run.

Writing properties

def set_prop(doc, set_name, prop_name, value): # Silently no-ops on failure Updates a property if it exists. If not, it fails quietly (by design—no hard stop in batch jobs).

Reading a file’s metadata

def read_metadata(inv, path: Path): ...

  • Opens the document via Inventor (Documents.Open) without activating the window
  • Reads: Revision, Title, Subject, Part Number, Comments, Date Checked
  • Normalizes Date Checked to YYYY-MM-DD when possible
  • Always closes the document in a finally block

Applying updates

def apply_updates(inv, rows): ... For each row:

  • Open the document
  • Compare each editable property; only write if changed
  • If anything changed, set "Date Checked" to today (YYYY-MM-DD)
  • Save and close

Data model: what is a “row”?

A row is a plain dict like: { "FilePath": "D:/.../part.ipt", "FileName": "part.ipt", "PartNumber": "OMG-001", "Title": "Handlebar Clamp", "Subject": "Front assembly", "Comments": "Updated for Rev B", "Revision": "B", "DateChecked": "2025-09-23", } These power both the UI table and the Markdown export.

The UI (Dear PyGui)

Table layout

Columns are created with fixed initial widths for readability:

  • FileName (read-only)
  • PartNumber (editable)
  • Title (editable)
  • Subject (editable)
  • Comments (editable)
  • Revision (editable via a button)
  • DateChecked (read-only, auto-managed; shown as a button for right alignment) Why buttons for Revision and DateChecked?
    Because Dear PyGui makes it easy to right-align button text with a custom theme. For consistency, both editable and read-only cells in those columns render via buttons bound to a right_align_theme. For Revision, edits are handled through inline entry (button click currently just shows a status hint that it’s auto-managed; the actual value is updated by Apply).

Inline editing behavior

  • For text fields (PartNumber, Title, Subject, Comments), edits happen in place.
  • Status line updates tell you what changed.
  • Revision and DateChecked are considered auto-managed:
    • Revision is editable in the table via its input (the button is just a UI affordance here).
    • Date Checked is set automatically when any property is changed during Apply.

Folder selection & reload

  • “Browse” opens a folder dialog; “Reload” scans and repopulates the table by asking the COM worker to load_rows.

Apply & Save

  • Apply Changes:
    • Sends the current in-memory rows to the COM worker, which writes changes to each file.
    • Reloads from disk (to show the final on-disk truth).
    • Automatically exports Markdown to MD_OUT.
  • Close: Ends the Dear PyGui loop and triggers cleanup.

The COM worker (why a separate thread?)

COM + GUI on the same thread is a recipe for deadlocks and “Application Not Responding” grief. So we:

  • Start a daemon thread (COMWorker)
  • Initialize COM explicitly with pythoncom.CoInitialize() inside that thread
  • Create/open the Inventor Application once there
  • Exchange work via a queue.Queue() of (op, args, future)
  • Support two ops:
    • "load_rows" → scans folder and reads metadata
    • "apply_updates" → writes changes back to files
  • Cleanly Quit() Inventor and CoUninitialize() on shutdown The UI thread never touches COM directly—only the worker does. This separation keeps the app responsive and COM-correct.

Markdown export

def export_markdown(rows, md_path): ...

  • Creates parent folder if needed
  • Escapes pipes and newlines so the table renders correctly
  • Writes a simple Git-friendly table you can paste into documentation Example header: | File Name | Part Number | Title | Subject | Comments | Revision | Date Checked | |---|---|---|---|---|---|---|

Startup & window sizing niceties

  • Uses tkinter to detect screen size and position the window center-screen at 50% of width/height (comment says 80%, code currently returns 50%).
  • Removes window chrome (no_title_bar=True, no_resize=True, no_move=True) for a “panel” feel.
  • Custom theme aligns Revision and Date Checked columns to the right with transparent buttons.

Note: The docstring mentions “Get 80% of screen size” but the code multiplies by 0.5. Adjust if you truly want 80%.

Data flow (end-to-end)

  1. Launch app → Start COM worker → Optionally preload default FOLDER.
  2. Reload → Worker reads files → UI table refresh.
  3. Edit inline → Rows mutate in memory.
  4. Apply Changes → Worker writes to Inventor, updates Date Checked, saves → Reload from disk → Export Markdown.
  5. Close → Clean shutdown of worker + COM.

Error handling philosophy

  • Gentle failures: get_prop/set_prop swallow most property errors so a missing field doesn’t nuke your batch.
  • Always close documents: finally blocks try hard to Close(True) to avoid file locks.
  • Status line: Human-readable feedback for most user operations.
  • Worker exceptions: Surface via Future results; user sees a concise message in the status bar.

Customization tips

  • Add a new column/property
    1. Add the name to COLUMNS.
    2. If you want it editable, add to EDITABLE.
    3. Update read_metadata to fetch it (pick the right Property Set + name).
    4. Update apply_updates to write it back when changed.
    5. (Optional) Add to Markdown export order.
  • Change file types
    • Tweak EXTS to include/remove extensions.
  • Default folder & output
    • Set FOLDER and MD_OUT for your project structure.
  • Alignment / Widths
    • Adjust init_width_or_weight per column to fit your data.

Known limitations / gotchas

  • Inventor must be installed and licensed on the machine running this.
  • Property existence: Some files might lack a property or set—writes are best-effort and silent on failure.
  • Date parsing: Date Checked is normalized if it looks like YYYY-MM-DD ...; otherwise the raw value is kept.
  • UI editing for Revision: The right-aligned button itself is not the input; the input is the adjacent field. The button click simply informs that the field is auto-managed. (You can convert this to a true input if you prefer.)
  • Performance: Very large folders mean lots of COM open/close cycles; consider filtering or batching.

Quickstart

  1. Install deps: pip install dearpygui pywin32
  2. Set FOLDER and MD_OUT at the top of the script.
  3. Run: python inventor_properties_editor.py
  4. Click Reload to load files.
  5. Edit fields inline.
  6. Hit Apply Changes. Your files update and the Markdown registry is written.

FAQ

Q: Why not edit properties without opening each file?
A: Inventor exposes properties through its COM API on opened documents. We open non-visibly and close fast, which is the supported route. Q: Can I keep Inventor hidden?
A: You can toggle inv.Visible = False, but visible is handy for troubleshooting and sometimes stabilizes COM. Q: Can I add custom iProperties?
A: Yes—extend read_metadata / apply_updates with the correct Property Set and name. Common sets:

  • "Inventor Summary Information"
  • "Design Tracking Properties"
  • Your custom set names, if present Q: Can I prevent auto-updating Date Checked?
    A: Remove or tweak the properties_changed logic in apply_updates.

Future nice-to-haves

  • Filter/search bar for large assemblies
  • Progress bar during Apply
  • Multi-folder support and recursion
  • CSV export/import
  • Diff preview (highlight what will change before applying)

Final notes

The heart of this script is separating UI from COM. By letting the COM worker own Inventor and treating UI edits as row diffs, you get a smooth editor that plays nicely with Inventor’s threading model. The Markdown export is the cherry on top for documentation hygiene.

Full script

import os
from pathlib import Path
from datetime import datetime
import dearpygui.dearpygui as dpg
import win32com.client as win32
import pythoncom
from threading import local, Thread
from concurrent.futures import Future
import queue
import tkinter as tk
# --- CONFIG ---
FOLDER = r"D:\OneDrive\001. PROJECTEN\000. KUBUZ\OPENFRAME\CAD"
MD_OUT = r"D:\OneDrive\005. NOTES\OpenFrame\Part Registry.md"
EXTS = {".ipt", ".iam", ".idw", ".dwg"}
COLUMNS = ["FileName", "PartNumber", "Title", "Subject", "Comments", "Revision", "DateChecked"]
EDITABLE = {"PartNumber", "Title", "Subject", "Comments", "Revision"}  # which columns can be edited
# --- Inventor helpers ---
def open_inventor():
    inv = win32.Dispatch("Inventor.Application")
    inv.Visible = True
    return inv
def get_prop(doc, set_name, prop_name):
    try:
        return str(doc.PropertySets.Item(set_name).Item(prop_name).Value)
    except:
        return ""
def set_prop(doc, set_name, prop_name, value):
    try:
        doc.PropertySets.Item(set_name).Item(prop_name).Value = value
    except:
        pass
def read_metadata(inv, path: Path):
    doc = None
    try:
        doc = inv.Documents.Open(str(path), False)
        rev = get_prop(doc, "Inventor Summary Information", "Revision Number")
        title = get_prop(doc, "Inventor Summary Information", "Title")
        subject = get_prop(doc, "Inventor Summary Information", "Subject")
        partnum = get_prop(doc, "Design Tracking Properties", "Part Number")
        comments = get_prop(doc, "Inventor Summary Information", "Comments")
        date_checked_raw = get_prop(doc, "Design Tracking Properties", "Date Checked")
        # Format date to YYYY-MM-DD only
        try:
            if date_checked_raw:
                # Try to parse and reformat the date
                date_obj = datetime.strptime(date_checked_raw.split()[0], "%Y-%m-%d")
                date_checked = date_obj.strftime("%Y-%m-%d")
            else:
                date_checked = ""
        except:
            # If parsing fails, try to extract just the date part
            if date_checked_raw and " " in date_checked_raw:
                date_checked = date_checked_raw.split()[0]
            else:
                date_checked = date_checked_raw or ""
        return {
            "FilePath": str(path),
            "FileName": path.name,
            "PartNumber": partnum,
            "Title": title,
            "Subject": subject,
            "Comments": comments,
            "Revision": rev,
            "DateChecked": date_checked,
        }
    finally:
        try:
            if doc is not None: doc.Close(True)
        except:
            pass
def apply_updates(inv, rows):
    current_date = datetime.now().strftime("%Y-%m-%d")
    for r in rows:
        p = Path(r["FilePath"])
        if not p.exists():
            continue
        doc = None
        try:
            doc = inv.Documents.Open(str(p), False)
            # Track if any properties changed to update Date Checked
            properties_changed = False
            # Props
            if r["Revision"] != get_prop(doc, "Inventor Summary Information", "Revision Number"):
                set_prop(doc, "Inventor Summary Information", "Revision Number", r["Revision"])
                properties_changed = True
            if r["Title"] != get_prop(doc, "Inventor Summary Information", "Title"):
                set_prop(doc, "Inventor Summary Information", "Title", r["Title"])
                properties_changed = True
            if r["Subject"] != get_prop(doc, "Inventor Summary Information", "Subject"):
                set_prop(doc, "Inventor Summary Information", "Subject", r["Subject"])
                properties_changed = True
            if r["PartNumber"] != get_prop(doc, "Design Tracking Properties", "Part Number"):
                set_prop(doc, "Design Tracking Properties", "Part Number", r["PartNumber"])
                properties_changed = True
            if r["Comments"] != get_prop(doc, "Inventor Summary Information", "Comments"):
                set_prop(doc, "Inventor Summary Information", "Comments", r["Comments"])
                properties_changed = True
            # Update Date Checked if any properties changed
            if properties_changed:
                set_prop(doc, "Design Tracking Properties", "Date Checked", current_date)
            doc.Save()
        finally:
            try:
                if doc is not None: doc.Close(True)
            except:
                pass
def export_markdown(rows, md_path):
    os.makedirs(Path(md_path).parent, exist_ok=True)
    def esc(s):
        s = "" if s is None else str(s)
        return s.replace("|", "\\|").replace("\r", " ").replace("\n", " ")
    lines = []
    lines.append("| File Name | Part Number | Title | Subject | Comments | Revision | Date Checked |")
    lines.append("|---|---|---|---|---|---|---|")
    for r in rows:
        lines.append("| " + " | ".join([
            esc(r["FileName"]),
            esc(r["PartNumber"]),
            esc(r["Title"]),
            esc(r["Subject"]),
            esc(r["Comments"]),
            esc(r["Revision"]),
            esc(r["DateChecked"]),
        ]) + " |")
    Path(md_path).write_text("\n".join(lines), encoding="utf-8")
# --- Data ---
def list_inv_files(folder):
    return [p for p in Path(folder).glob("*") if p.is_file() and p.suffix.lower() in EXTS]
def load_rows(inv, folder):
    files = list_inv_files(folder)
    return [read_metadata(inv, p) for p in files]
# --- DPG UI Helpers ---
DPG_TAGS = {
    "folder_input": "-FOLDER-",
    "folder_dialog": "-FOLDER_DIALOG-",
    "table": "-TABLE-",
    "status": "-STATUS-",
    "edit_FileName": "-FileName-",
    "edit_PartNumber": "-PartNumber-",
    "edit_Title": "-Title-",
    "edit_Subject": "-Subject-",
    "edit_Comments": "-Comments-",
    "edit_Revision": "-Revision-",
    "edit_DateChecked": "-DateChecked-",
}
_rows_state = {
    "rows": [],
    "selected_index": None,
    "worker": None,
}
_thread_local = local()
def _ensure_com_initialized():
    try:
        if not getattr(_thread_local, "com_initialized", False):
            pythoncom.CoInitialize()
            _thread_local.com_initialized = True
    except Exception:
        pass
class COMWorker:
    def __init__(self):
        self._q = queue.Queue()
        self._thread = Thread(target=self._run, daemon=True)
        self._started = False
    def start(self):
        if not self._started:
            self._thread.start()
            self._started = True
    def call(self, op, *args):
        fut = Future()
        self._q.put((op, args, fut))
        return fut.result()
    def stop(self):
        fut = Future()
        try:
            self._q.put(("_stop", (), fut))
            fut.result(timeout=5)
        except Exception:
            pass
    def _run(self):
        inv = None
        pythoncom.CoInitialize()
        try:
            inv = open_inventor()
            while True:
                op, args, fut = self._q.get()
                if op == "_stop":
                    try:
                        fut.set_result(True)
                    except Exception:
                        pass
                    break
                try:
                    if op == "load_rows":
                        folder, = args
                        res = load_rows(inv, folder)
                        fut.set_result(res)
                    elif op == "apply_updates":
                        rows, = args
                        apply_updates(inv, rows)
                        fut.set_result(True)
                    else:
                        fut.set_exception(RuntimeError(f"Unknown op: {op}"))
                except Exception as ex:
                    try:
                        fut.set_exception(ex)
                    except Exception:
                        pass
        except Exception:
            # Swallow worker-level failures; they will surface via Future exceptions
            pass
        finally:
            try:
                if inv is not None:
                    inv.Quit()
            except Exception:
                pass
            try:
                pythoncom.CoUninitialize()
            except Exception:
                pass
def _set_status(text):
    try:
        dpg.set_value(DPG_TAGS["status"], text)
    except Exception:
        pass
def _populate_edit_fields(row_dict):
    dpg.set_value(DPG_TAGS["edit_FileName"], row_dict.get("FileName", ""))
    for c in EDITABLE:
        dpg.set_value(DPG_TAGS[f"edit_{c}"], row_dict.get(c, ""))
def _get_edit_values(base_row):
    updated = dict(base_row)
    for c in EDITABLE:
        updated[c] = dpg.get_value(DPG_TAGS[f"edit_{c}"]) or base_row.get(c, "")
    return updated
def _rebuild_table():
    table_tag = DPG_TAGS["table"]
    # clear existing rows
    children = dpg.get_item_children(table_tag, 1) or []
    for child in children:
        dpg.delete_item(child)
    # add rows
    for idx, r in enumerate(_rows_state["rows"]):
        with dpg.table_row(parent=table_tag):
            # FileName (read-only)
            dpg.add_text(r.get("FileName", ""))
            # Other columns: make editable where allowed
            for c in COLUMNS[1:]:
                if c in EDITABLE:
                    # Right align Revision and DateChecked columns using buttons
                    if c in ["Revision", "DateChecked"]:
                        # Use button with transparent background for right alignment
                        dpg.add_button(label=r.get(c, ""), width=-1, callback=_on_cell_edit, user_data=(idx, c))
                        dpg.bind_item_theme(dpg.last_item(), "right_align_theme")
                    else:
                        dpg.add_input_text(default_value=r.get(c, ""), callback=_on_cell_edit, user_data=(idx, c), width=-1)
                else:
                    # Right align Revision and DateChecked columns for read-only text too
                    if c in ["Revision", "DateChecked"]:
                        # Use button with transparent background for right alignment
                        dpg.add_button(label=r.get(c, ""), width=-1)
                        dpg.bind_item_theme(dpg.last_item(), "right_align_theme")
                    else:
                        dpg.add_text(r.get(c, ""))
def _on_row_select(sender, app_data, user_data):
    try:
        idx = int(user_data)
    except Exception:
        return
    if idx < 0 or idx >= len(_rows_state["rows"]):
        return
    _rows_state["selected_index"] = idx
    _populate_edit_fields(_rows_state["rows"][idx])
    _set_status(f"Selected row {idx+1} of {len(_rows_state['rows'])}")
def _on_cell_edit(sender, app_data, user_data):
    # For buttons, app_data is the button label (current value)
    # For input text, app_data is the new text value
    try:
        idx, col = user_data
    except Exception:
        return
    if idx < 0 or idx >= len(_rows_state["rows"]):
        return
    if col not in EDITABLE:
        return
    # If it's a button (Revision or DateChecked), we need to handle it differently
    if col in ["Revision", "DateChecked"]:
        # For now, just show a message that these fields are auto-managed
        _set_status(f"Row {idx+1} - {col} is auto-managed")
    else:
        # For input text fields
        _rows_state["rows"][idx][col] = app_data or ""
        _set_status(f"Edited row {idx+1} - {col}")
def _on_browse_folder():
    dpg.configure_item(DPG_TAGS["folder_dialog"], show=True)
def _on_folder_chosen(sender, app_data):
    # app_data contains selections dict: {"file_path_name": path}
    try:
        folder = app_data.get("file_path_name", "")
    except Exception:
        folder = ""
    if folder:
        dpg.set_value(DPG_TAGS["folder_input"], folder)
def _on_reload():
    folder = dpg.get_value(DPG_TAGS["folder_input"]) or FOLDER
    if not Path(folder).exists():
        _set_status(f"Folder not found: {folder}")
        return
    try:
        rows = _rows_state["worker"].call("load_rows", folder)
        _rows_state["rows"] = rows
        _rows_state["selected_index"] = None
        _rebuild_table()
        _set_status(f"Loaded {len(_rows_state['rows'])} files from {folder}")
    except Exception as ex:
        _set_status(f"Reload failed: {ex}")
def _on_update_row():
    # No longer needed with inline editing, keep as no-op for compatibility
    _set_status("Rows are edited directly in the table.")
def _on_apply_changes():
    try:
        _rows_state["worker"].call("apply_updates", _rows_state["rows"])
        # Refresh rows from disk
        folder = dpg.get_value(DPG_TAGS["folder_input"]) or FOLDER
        _rows_state["rows"] = _rows_state["worker"].call("load_rows", folder)
        _rows_state["selected_index"] = None
        _rebuild_table()
        # Automatically save markdown after applying changes
        try:
            export_markdown(_rows_state["rows"], MD_OUT)
            _set_status("Changes applied to Inventor files and markdown saved.")
        except Exception as ex:
            _set_status(f"Changes applied to Inventor files, but markdown save failed: {ex}")
    except Exception as ex:
        _set_status(f"Apply failed: {ex}")
def _on_save_markdown():
    try:
        export_markdown(_rows_state["rows"], MD_OUT)
        _set_status(f"Markdown saved: {MD_OUT}")
    except Exception as ex:
        _set_status(f"Save Markdown failed: {ex}")
def _on_exit():
    try:
        if _rows_state.get("worker") is not None:
            _rows_state["worker"].stop()
    except Exception:
        pass
    try:
        pythoncom.CoUninitialize()
    except Exception:
        pass
def get_screen_size():
    """Get screen dimensions and return 80% of screen size"""
    root = tk.Tk()
    screen_width = root.winfo_screenwidth()
    screen_height = root.winfo_screenheight()
    root.destroy()
    # Return 50% of screen size
    return int(screen_width * 0.5), int(screen_height * 0.5)
def center_window(window_width, window_height):
    """Calculate position to center window on screen"""
    root = tk.Tk()
    screen_width = root.winfo_screenwidth()
    screen_height = root.winfo_screenheight()
    root.destroy()
    # Calculate center position
    x = (screen_width - window_width) // 2
    y = (screen_height - window_height) // 2
    return x, y
def main():
    # Start COM worker thread that owns Inventor COM apartment
    worker = COMWorker()
    worker.start()
    _rows_state["worker"] = worker
    # Preload
    if Path(FOLDER).exists():
        try:
            _rows_state["rows"] = worker.call("load_rows", FOLDER)
        except Exception as ex:
            _set_status(f"Initial load failed: {ex}")
    else:
        _set_status(f"Folder not found: {FOLDER}")
    dpg.create_context()
    # Create theme for right-aligned buttons
    with dpg.theme(tag="right_align_theme"):
        with dpg.theme_component(dpg.mvButton):
            dpg.add_theme_style(dpg.mvStyleVar_ButtonTextAlign, 1.0, 0.5)  # Right-align text
            dpg.add_theme_color(dpg.mvThemeCol_Button, (0, 0, 0, 0))       # Transparent background
            dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (0, 0, 0, 0))
            dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (0, 0, 0, 0))
    # Get 80% of screen size and center position
    window_width, window_height = get_screen_size()
    window_x, window_y = center_window(window_width, window_height)
    with dpg.window(label="Inventor Properties Editor", width=window_width, height=window_height, no_collapse=True, no_title_bar=True, no_resize=True, no_move=True) as main_window:
        with dpg.group(horizontal=True):
            dpg.add_text("Folder:")
            dpg.add_input_text(tag=DPG_TAGS["folder_input"], default_value=FOLDER, width=700)
            dpg.add_button(label="Browse", callback=_on_browse_folder)
            dpg.add_button(label="Reload", callback=_on_reload)
        dpg.add_spacer(height=5)
        with dpg.table(tag=DPG_TAGS["table"], header_row=True, borders_innerH=True, borders_innerV=True, borders_outerH=True, borders_outerV=True, resizable=True, policy=dpg.mvTable_SizingFixedFit, scrollY=True, height=600):
            # columns with specific widths
            dpg.add_table_column(label="FileName", init_width_or_weight=150)
            dpg.add_table_column(label="PartNumber", init_width_or_weight=150)
            dpg.add_table_column(label="Title", init_width_or_weight=300)
            dpg.add_table_column(label="Subject", init_width_or_weight=200)
            dpg.add_table_column(label="Comments", init_width_or_weight=100)
            dpg.add_table_column(label="Revision", init_width_or_weight=70)
            dpg.add_table_column(label="DateChecked", init_width_or_weight=130)
        dpg.add_spacer(height=5)
        with dpg.group(horizontal=True):
            dpg.add_button(label="Apply Changes", callback=_on_apply_changes)
            dpg.add_button(label="Close", callback=lambda: dpg.stop_dearpygui())
        dpg.add_spacer(height=5)
        dpg.add_text("", tag=DPG_TAGS["status"])  # status line
    # Folder dialog
    with dpg.file_dialog(directory_selector=True, show=False, callback=_on_folder_chosen, tag=DPG_TAGS["folder_dialog"], width=700, height=400):
        dpg.add_file_extension(".*", color=(255, 255, 255, 255))
    dpg.create_viewport(title="Inventor Properties Editor", width=window_width, height=window_height, x_pos=window_x, y_pos=window_y)
    dpg.setup_dearpygui()
    dpg.set_exit_callback(_on_exit)
    dpg.show_viewport()
    dpg.set_primary_window(main_window, True)
    # Populate table and clear edit fields
    _rebuild_table()
    if _rows_state["rows"]:
        _set_status(f"Loaded {len(_rows_state['rows'])} files from {FOLDER}")
    dpg.start_dearpygui()
    dpg.destroy_context()
if __name__ == "__main__":
    main()