TL;DR

Deze Python-app geeft je een snelle, point-and-click manier om metadata van Autodesk Inventor-bestanden (.ipt, .iam, .idw, .dwg) bulkmatig te bekijken en te bewerken. Het:

  • Scant een map op Inventor-bestanden
  • Leest kernvelden (Part Number, Title, Subject, Comments, Revision, Date Checked)
  • Laat je de meeste velden inline bewerken in een tabel-UI (Dear PyGui)
  • Schrijft updates terug naar de native Inventor-bestanden via COM
  • Update Date Checked automatisch wanneer er iets wijzigt
  • Exporteert een Markdown “Part Registry”-tabel voor je notities/wiki Inventor-automatisering draait veilig in een dedicated COM-thread, zodat de UI vlot blijft en niet vastloopt. Prototype bike frame

Welke problemen dit oplost

  • Handmatig eigenschappen aanpassen is traag: Bestanden één voor één in Inventor openen is pijnlijk.
  • Inconsistente metadata: Revision/Title/etc. lopen na verloop van tijd uiteen.
  • Statusrapportage: Je wilt een nette Markdown-tabel om in docs of Notion/Wiki te plakken. Deze tool stopt dat allemaal in één, vriendelijke window.

Vereisten (de “ja, het is Windows”-sectie)

  • Windows (COM + win32com.client)
  • Autodesk Inventor geïnstalleerd (zodat COM-automatisering werkt)
  • Python-pakketten:
    • dearpygui (UI)
    • pywin32 (win32com.client + pythoncom)
    • tkinter (meestal ingebouwd in Python op Windows; gebruikt voor schermgrootte en bestandsdialoog)

Architectuur op hoofdlijnen

  1. UI (Dear PyGui): Toont een tabel voor inline bewerken, mapselectie en knoppen (Reload, Apply Changes, Close).
  2. COM-werkthread: Draait Inventor-automatisering in een eigen STA (single-threaded COM-apartment). De UI post jobs via een thread-veilige queue; resultaten komen terug via concurrent.futures.Future.
  3. Bestand/metadata-laag: Opent elk bestand via Inventor, leest/schrijft property sets en sluit netjes af.
  4. Markdown-export: Serialiseert de huidige in-memory rijen naar een mooie Git-vriendelijke tabel.

Configuratieknoppen (boven in het bestand)

  • FOLDER: Standaard scanlocatie voor CAD-bestanden.
  • MD_OUT: Waar het Markdown-register wordt weggeschreven.
  • EXTS: Welke bestandstypen worden meegenomen.
  • COLUMNS: Weergavevolgorde van de tabelkolommen.
  • EDITABLE: Welke kolommen de gebruiker in de UI kan wijzigen. Pas deze aan voor jouw projectstructuur of voeg extra properties toe.

De Inventor-COM-helpers (lezen/schrijven van properties)

Inventor openen

def open_inventor(): inv = win32.Dispatch("Inventor.Application") inv.Visible = True return inv
We starten (of verbinden met) Inventor en maken het venster zichtbaar. Zichtbaarheid helpt bij debuggen en maakt COM vaak voorspelbaarder.

Properties lezen

def get_prop(doc, set_name, prop_name): # Geeft "" terug als de property ontbreekt
Zoekt een property binnen een Property Set (bijv. "Inventor Summary Information", "Design Tracking Properties"). Alle reads zijn safe: fouten worden lege strings, zodat een ontbrekende property de run niet laat crashen.

Properties schrijven

def set_prop(doc, set_name, prop_name, value): # Doet stilletjes niets bij falen
Werkt een property bij als die bestaat. Zo niet, dan faalt het geruisloos (bewust—geen harde stop in batchjobs).

Metadata van één bestand lezen

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

  • Opent het document via Inventor (Documents.Open) zonder het venster te activeren
  • Leest: Revision, Title, Subject, Part Number, Comments, Date Checked
  • Normaliseert Date Checked naar YYYY-MM-DD wanneer mogelijk
  • Sluit het document altijd in een finally-blok

Updates toepassen

def apply_updates(inv, rows): ...
Voor elke rij:

  • Open het document
  • Vergelijk elke bewerkbare property; schrijf alleen bij verschil
  • Als er iets is gewijzigd, zet "Date Checked" op vandaag (YYYY-MM-DD)
  • Sla op en sluit

Datamodel: wat is een “rij”?

Een rij is een eenvoudige dict zoals:
{ "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", }
Deze sturen zowel de UI-tabel als de Markdown-export.

De UI (Dear PyGui)

Tabelopmaak

Kolommen krijgen vaste beginbreedtes voor leesbaarheid:

  • FileName (alleen-lezen)
  • PartNumber (bewerkbaar)
  • Title (bewerkbaar)
  • Subject (bewerkbaar)
  • Comments (bewerkbaar)
  • Revision (bewerkbaar via een button)
  • DateChecked (alleen-lezen, automatisch beheerd; weergegeven als button voor rechts uitlijnen) Waarom buttons voor Revision en DateChecked?
    Omdat je in Dear PyGui tekst makkelijk rechts kunt uitlijnen met een custom theme op buttons. Voor consistentie renderen zowel bewerkbare als read-only cellen in die kolommen via buttons die aan right_align_theme zijn gebonden. Voor Revision gebeurt het echte bewerken via inline input (de button-click toont nu enkel een statushint dat het veld auto-managed is; de daadwerkelijke waarde wordt bijgewerkt door Apply).

Inline bewerkgedrag

  • Voor tekstvelden (PartNumber, Title, Subject, Comments) bewerk je direct in de tabel.
  • De statusregel vertelt wat er veranderde.
  • Revision en DateChecked zijn automatisch beheerd:
    • Revision is bewerkbaar in de tabel via de input (de button is hier puur UI-aankleding).
    • Date Checked wordt automatisch gezet wanneer tijdens Apply een property wijzigt.

Mapselectie & herladen

  • “Browse” opent een mapdialoog; “Reload” scant en vult de tabel opnieuw door de COM-worker load_rows te laten uitvoeren.

Apply & Save

  • Apply Changes:
    • Stuurt de huidige in-memory rijen naar de COM-worker, die wijzigingen naar elk bestand schrijft.
    • Laadt opnieuw vanaf schijf (zodat je de definitieve waarheid ziet).
    • Exporteert automatisch Markdown naar MD_OUT.
  • Close: Stopt de Dear PyGui-loop en start de cleanup.

De COM-worker (waarom een aparte thread?)

COM + GUI op dezelfde thread is een recept voor deadlocks en “Application Not Responding”. Dus we:

  • Starten een daemon-thread (COMWorker)
  • Initialiseren COM expliciet met pythoncom.CoInitialize() binnen die thread
  • Maken/ openen de Inventor-applicatie daar één keer
  • Wisselen werk uit via een queue.Queue() met (op, args, future)
  • Ondersteunen twee operaties:
    • "load_rows" → scant map en leest metadata
    • "apply_updates" → schrijft wijzigingen terug naar bestanden
  • Sluiten Inventor netjes met Quit() en doen CoUninitialize() bij shutdown De UI-thread raakt COM nooit direct aan—alleen de worker. Deze scheiding houdt de app responsief en COM-correct.

Markdown-export

def export_markdown(rows, md_path): ...

  • Maakt de bovenliggende map aan indien nodig
  • Escapet pipes en nieuwe regels, zodat de tabel correct rendert
  • Schrijft een simpele Git-vriendelijke tabel die je in documentatie kunt plakken Voorbeeldkop:
    | File Name | Part Number | Title | Subject | Comments | Revision | Date Checked | |---|---|---|---|---|---|---|

Opstart- & window-handigheidjes

  • Gebruikt tkinter om schermgrootte te detecteren en het venster gecentreerd op 50% van breedte/hoogte te tonen (comment zegt 80%, code geeft nu 50%).
  • Haalt window-chrome weg (no_title_bar=True, no_resize=True, no_move=True) voor een “panel”-gevoel.
  • Custom theme lijnt de kolommen Revision en Date Checked rechts uit met transparante buttons.

Let op: In de docstring staat “Neem 80% van schermgrootte”, maar de code vermenigvuldigt met 0.5. Pas aan als je echt 80% wilt.

Datastroom (end-to-end)

  1. App starten → COM-worker starten → Optioneel standaard FOLDER preloaden.
  2. Reload → Worker leest bestanden → UI-tabel ververst.
  3. Inline bewerken → Rijen wijzigen in memory.
  4. Apply Changes → Worker schrijft naar Inventor, werkt Date Checked bij, slaat op → Herlaadt vanaf schijf → Exporteert Markdown.
  5. Close → Schone shutdown van worker + COM.

Foutafhandelingsfilosofie

  • Zachte fouten: get_prop/set_prop slikken de meeste property-fouten in zodat een ontbrekend veld je batch niet omver blaast.
  • Altijd documenten sluiten: finally-blokken proberen hard om Close(True) te doen om file locks te voorkomen.
  • Statusregel: Menselijke feedback bij de meeste gebruikersacties.
  • Worker-exceptions: Komen via Future-resultaten naar boven; de gebruiker ziet een compacte melding in de statusbalk.

Tips voor maatwerk

  • Nieuwe kolom/property toevoegen
    1. Voeg de naam toe aan COLUMNS.
    2. Wil je ’m bewerkbaar, voeg toe aan EDITABLE.
    3. Update read_metadata om ’m op te halen (kies de juiste Property Set + naam).
    4. Update apply_updates om ’m terug te schrijven bij wijzigingen.
    5. (Optioneel) Voeg toe aan de exportvolgorde voor Markdown.
  • Bestandstypen wijzigen
    • Pas EXTS aan om extensies toe te voegen/te verwijderen.
  • Standaardmap & output
    • Zet FOLDER en MD_OUT passend bij jouw projectstructuur.
  • Uitlijning / breedtes
    • Tweak init_width_or_weight per kolom voor jouw data.

Bekende beperkingen / aandachtspunten

  • Inventor moet geïnstalleerd en gelicenseerd zijn op de machine die dit draait.
  • Property-bestaan: Sommige bestanden missen een property of set—writes zijn best-effort en stil bij falen.
  • Datum-parsing: Date Checked wordt genormaliseerd als het eruitziet als YYYY-MM-DD ...; anders blijft de ruwe waarde behouden.
  • UI-bewerking van Revision: De rechts uitgelijnde button is niet de invoer; de invoer is het aangrenzende veld. De button-click meldt enkel dat het veld automatisch wordt beheerd. (Je kunt dit ombouwen naar echte input als je wilt.)
  • Performance: Héél grote mappen betekenen veel COM open/close-cycli; overweeg filteren of batchen.

Snelstart

  1. Installeer dependencies:
    pip install dearpygui pywin32
  2. Zet FOLDER en MD_OUT bovenaan het script.
  3. Run:
    python inventor_properties_editor.py
  4. Klik Reload om bestanden te laden.
  5. Bewerk velden inline.
  6. Klik Apply Changes. Je bestanden worden bijgewerkt en het Markdown-register wordt geschreven.

FAQ

V: Waarom niet properties bewerken zonder elk bestand te openen?
A: Inventor stelt properties via zijn COM-API beschikbaar op geopende documenten. We openen onzichtbaar en sluiten snel, wat de ondersteunde route is. V: Kan ik Inventor verborgen houden?
A: Je kunt inv.Visible = False zetten, maar zichtbaar is handig voor troubleshooting en stabiliseert COM soms. V: Kan ik custom iProperties toevoegen?
A: Ja—breid read_metadata / apply_updates uit met de juiste Property Set en naam. Veelgebruikte sets:

  • "Inventor Summary Information"
  • "Design Tracking Properties"
  • Je eigen setnamen, indien aanwezig V: Kan ik het automatisch bijwerken van Date Checked uitschakelen?
    A: Verwijder of pas de properties_changed-logica aan in apply_updates.

Toekomstige nice-to-haves

  • Filter/zoekbalk voor grote assemblies
  • Voortgangsbalk tijdens Apply
  • Multi-map-ondersteuning en recursie
  • CSV-export/import
  • Diff-preview (highlight wat zal veranderen vóór Apply)

Slotopmerking

De kern van dit script is het scheiden van UI en COM. Door de COM-worker eigenaar te maken van Inventor en UI-edits te behandelen als row diffs, krijg je een soepele editor die netjes omgaat met Inventors threading-model. De Markdown-export is de kers op de taart voor documentatiehygiëne.

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\OMG-Bikes\CAD"
MD_OUT = r"D:\OneDrive\005. NOTES\OMG Bikes\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()