SXXXXXXX_MarkdownConverter/markdownconverter/gui/gui.py
2025-06-18 09:19:00 +02:00

351 lines
20 KiB
Python

# markdownconverter/gui/gui.py
import os
import sys
import logging
import re
import subprocess
import tkinter as tk
from datetime import date
from tkinter import simpledialog
from pathlib import Path
import ttkbootstrap as tb
from tkinter.scrolledtext import ScrolledText
from ttkbootstrap.scrolled import ScrolledFrame
from ttkbootstrap.constants import *
from tkinter import filedialog, messagebox, StringVar, BooleanVar
from ..core.core import (
convert_markdown, convert_docx_to_pdf, scan_template_for_placeholders,
ConverterNotFoundError, TemplatePlaceholderError
)
from ..utils.config import (
save_configuration, load_configuration,
KEY_LAST_MARKDOWN, KEY_LAST_PROFILE, KEY_PROFILES, KEY_TEMPLATE_PATH, KEY_VALUES
)
from ..utils.logger import (
setup_basic_logging, add_tkinter_handler,
shutdown_logging_system, get_logger
)
# EditorWindow non viene usato in questo file, ma lo lasciamo per coerenza
# from .editor import EditorWindow
log = get_logger(__name__)
# ... (open_with_default_app, open_output_folder, ProfileManagerWindow sono INVARIATE)
# ... (ti fornisco comunque il codice completo per sicurezza)
def open_with_default_app(filepath: str):
log.info(f"Request to open file at path: '{filepath}'")
p = Path(filepath)
if not p.is_file():
log.warning("Open file requested, but path is invalid or file does not exist.")
messagebox.showwarning("Warning", "No output file or folder to open.")
return
try:
log.info(f"Opening '{p}' with default application.")
if sys.platform == "win32": os.startfile(p)
elif sys.platform == "darwin": subprocess.run(["open", p], check=True)
else: subprocess.run(["xdg-open", p], check=True)
except Exception as e:
log.error(f"Failed to open '{p}': {e}", exc_info=True)
messagebox.showerror("Error", f"Could not open the file/folder:\n{str(e)}")
def open_output_folder(filepath: str):
if not filepath:
log.warning("Open folder requested, but no output file has been generated yet.")
messagebox.showwarning("Warning", "No output file has been generated yet.")
return
folder = Path(filepath).parent
open_with_default_app(str(folder))
class ProfileManagerWindow(tb.Toplevel):
def __init__(self, parent, config_data, app_instance):
super().__init__(master=parent, title="Manage Profiles")
self.geometry("500x400")
self.transient(parent)
self.config_data = config_data
self.app = app_instance
self.selected_profile_name = None
self._setup_widgets()
self._load_profiles()
def _setup_widgets(self):
main_frame = tb.Frame(self, padding=10); main_frame.pack(fill=tk.BOTH, expand=True)
main_frame.rowconfigure(0, weight=1); main_frame.columnconfigure(0, weight=1)
list_frame = tb.Labelframe(main_frame, text="Existing Profiles", padding=5)
list_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
list_frame.rowconfigure(0, weight=1); list_frame.columnconfigure(0, weight=1)
self.profile_listbox = tk.Listbox(list_frame, exportselection=False)
self.profile_listbox.grid(row=0, column=0, sticky="nsew")
scrollbar = tb.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.profile_listbox.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.profile_listbox.config(yscrollcommand=scrollbar.set)
self.profile_listbox.bind("<<ListboxSelect>>", self._on_profile_select)
button_frame = tb.Frame(main_frame); button_frame.grid(row=0, column=1, sticky="ns")
tb.Button(button_frame, text="New...", command=self._new_profile, bootstyle=SUCCESS).pack(fill=tk.X, pady=5)
self.rename_btn = tb.Button(button_frame, text="Rename...", command=self._rename_profile, state=tk.DISABLED)
self.rename_btn.pack(fill=tk.X, pady=5)
self.delete_btn = tb.Button(button_frame, text="Delete", command=self._delete_profile, state=tk.DISABLED, bootstyle=DANGER)
self.delete_btn.pack(fill=tk.X, pady=5)
tb.Button(button_frame, text="Close", command=self.destroy).pack(fill=tk.X, pady=(20, 5))
def _load_profiles(self):
self.profile_listbox.delete(0, tk.END)
for profile_name in sorted(self.config_data[KEY_PROFILES].keys()):
self.profile_listbox.insert(tk.END, profile_name)
self._on_profile_select()
def _on_profile_select(self, event=None):
selection = self.profile_listbox.curselection()
if not selection:
self.selected_profile_name = None
self.rename_btn.config(state=tk.DISABLED); self.delete_btn.config(state=tk.DISABLED)
else:
self.selected_profile_name = self.profile_listbox.get(selection[0])
self.rename_btn.config(state=tk.NORMAL); self.delete_btn.config(state=tk.NORMAL)
def _new_profile(self):
profile_name = simpledialog.askstring("New Profile", "Enter a name for the new profile:", parent=self)
if not profile_name: return
if profile_name in self.config_data[KEY_PROFILES]:
messagebox.showerror("Error", f"A profile named '{profile_name}' already exists.", parent=self); return
template_path = filedialog.askopenfilename(title=f"Select Template for '{profile_name}'", filetypes=[("Word Documents", "*.docx")], parent=self)
if not template_path: return
self.config_data[KEY_PROFILES][profile_name] = {KEY_TEMPLATE_PATH: template_path, KEY_VALUES: {}}
self.app.update_config(self.config_data)
self.app.update_profile_combobox(new_selection=profile_name)
self._load_profiles()
def _rename_profile(self):
if not self.selected_profile_name: return
new_name = simpledialog.askstring("Rename Profile", f"Enter a new name for '{self.selected_profile_name}':", parent=self)
if not new_name or new_name == self.selected_profile_name: return
if new_name in self.config_data[KEY_PROFILES]:
messagebox.showerror("Error", f"A profile named '{new_name}' already exists.", parent=self); return
self.config_data[KEY_PROFILES][new_name] = self.config_data[KEY_PROFILES].pop(self.selected_profile_name)
if self.config_data[KEY_LAST_PROFILE] == self.selected_profile_name:
self.config_data[KEY_LAST_PROFILE] = new_name
self.app.update_config(self.config_data)
self.app.update_profile_combobox(new_selection=new_name)
self._load_profiles()
def _delete_profile(self):
if not self.selected_profile_name: return
if not messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete profile '{self.selected_profile_name}'?", parent=self): return
del self.config_data[KEY_PROFILES][self.selected_profile_name]
if self.config_data[KEY_LAST_PROFILE] == self.selected_profile_name: self.config_data[KEY_LAST_PROFILE] = ""
self.app.update_config(self.config_data)
self.app.update_profile_combobox()
self._load_profiles()
class MarkdownConverterApp:
def __init__(self, root):
self.root = root
self.root.title("Markdown Converter")
self.root.geometry("900x850")
self._setup_logging()
self.config = load_configuration()
self.dynamic_entry_vars = {}
self.loaded_profile_name = ""
self.selected_file = StringVar(value=self.config.get(KEY_LAST_MARKDOWN, ""))
self.active_profile_name = StringVar() # Inizializzata vuota
self.add_toc_var = BooleanVar(value=True)
self.docx_output_path = StringVar()
self.pdf_direct_output_path = StringVar()
self.pdf_from_docx_output_path = StringVar()
self._build_ui()
self.update_profile_combobox() # Questo imposterà active_profile_name
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
def _setup_logging(self):
self.log_config = {"default_root_level": logging.DEBUG, "format": "%(asctime)s [%(levelname)-8s] %(name)-20s: %(message)s", "date_format": "%H:%M:%S", "enable_console": True, "enable_file": True, "file_path": "markdown_converter.log", "colors": {logging.DEBUG: "gray", logging.INFO: "black", logging.WARNING: "orange", logging.ERROR: "red", logging.CRITICAL: "purple"}}
setup_basic_logging(root_tk_instance_for_processor=self.root, logging_config_dict=self.log_config)
def _build_ui(self):
container = tb.Frame(self.root, padding=10); container.pack(fill=tk.BOTH, expand=True)
profile_frame = tb.Labelframe(container, text="Profile & Source File", padding=10)
profile_frame.pack(fill=tk.X, pady=(0, 10)); profile_frame.columnconfigure(1, weight=1)
tb.Label(profile_frame, text="Active Profile:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.profile_combobox = tb.Combobox(profile_frame, textvariable=self.active_profile_name, state="readonly")
self.profile_combobox.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.profile_combobox.bind("<<ComboboxSelected>>", self._on_profile_change)
tb.Button(profile_frame, text="Manage...", command=self.open_profile_manager).grid(row=0, column=2, padx=5, pady=5)
tb.Label(profile_frame, text="Markdown File:").grid(row=1, column=0, padx=5, pady=5, sticky="w")
tb.Entry(profile_frame, textvariable=self.selected_file).grid(row=1, column=1, padx=5, pady=5, sticky="ew")
tb.Button(profile_frame, text="Browse...", command=self.browse_markdown).grid(row=1, column=2, padx=5, pady=5)
self.placeholder_frame = ScrolledFrame(container, autohide=True, bootstyle="round")
self.placeholder_frame.pack(fill=tk.BOTH, expand=True, pady=10)
options_frame = tb.Frame(container); options_frame.pack(fill=tk.X, pady=(0, 10))
tb.Checkbutton(options_frame, text="Add TOC", variable=self.add_toc_var, bootstyle="primary-round-toggle").pack(side=tk.LEFT, padx=(0, 20))
tb.Button(options_frame, text="Open Source Folder", command=lambda: open_output_folder(self.selected_file.get())).pack(side=tk.LEFT, padx=5)
action_frame = tb.Labelframe(container, text="Conversion Actions", padding=10)
action_frame.pack(fill=tk.X, pady=10); action_frame.columnconfigure(1, weight=1)
tb.Button(action_frame, text="MD -> DOCX", command=lambda: self.convert("DOCX"), width=12).grid(row=0, column=0, padx=5, pady=5)
tb.Entry(action_frame, textvariable=self.docx_output_path).grid(row=0, column=1, padx=5, pady=5, sticky="ew")
tb.Button(action_frame, text="Open", command=self.open_docx_output).grid(row=0, column=2, padx=5, pady=5)
tb.Button(action_frame, text="MD -> PDF", command=lambda: self.convert("PDF"), width=12).grid(row=1, column=0, padx=5, pady=5)
tb.Entry(action_frame, textvariable=self.pdf_direct_output_path).grid(row=1, column=1, padx=5, pady=5, sticky="ew")
tb.Button(action_frame, text="Open", command=self.open_pdf_direct_output).grid(row=1, column=2, padx=5, pady=5)
self.docx_to_pdf_btn = tb.Button(action_frame, text="DOCX -> PDF", command=self.convert_from_docx_to_pdf, bootstyle="info", width=12, state=tk.DISABLED)
self.docx_to_pdf_btn.grid(row=2, column=0, padx=5, pady=5)
tb.Entry(action_frame, textvariable=self.pdf_from_docx_output_path).grid(row=2, column=1, padx=5, pady=5, sticky="ew")
tb.Button(action_frame, text="Open", command=self.open_pdf_from_docx_output).grid(row=2, column=2, padx=5, pady=5)
log_frame = tb.Labelframe(container, text="Log Viewer", padding=10)
log_frame.pack(fill=tk.BOTH, expand=True, pady=(10,0))
log_text_widget = ScrolledText(log_frame, wrap=tk.WORD, state=tk.DISABLED, height=10)
log_text_widget.pack(fill=tk.BOTH, expand=True)
add_tkinter_handler(gui_log_widget=log_text_widget, root_tk_instance_for_gui_handler=self.root, logging_config_dict=self.log_config)
def open_docx_output(self): open_with_default_app(self.docx_output_path.get())
def open_pdf_direct_output(self): open_with_default_app(self.pdf_direct_output_path.get())
def open_pdf_from_docx_output(self): open_with_default_app(self.pdf_from_docx_output_path.get())
def update_profile_combobox(self, new_selection=None):
profiles = sorted(self.config[KEY_PROFILES].keys())
self.profile_combobox['values'] = profiles
last_profile = self.config.get(KEY_LAST_PROFILE)
if new_selection and new_selection in profiles:
self.active_profile_name.set(new_selection)
elif last_profile in profiles:
self.active_profile_name.set(last_profile)
else:
self.active_profile_name.set("")
self._load_profile_data()
def open_profile_manager(self):
win = ProfileManagerWindow(self.root, self.config, self)
self.root.wait_window(win)
def _on_profile_change(self, event=None):
self._save_current_values()
self.config[KEY_LAST_PROFILE] = self.active_profile_name.get()
self._load_profile_data()
def _load_profile_data(self):
for widget in self.placeholder_frame.winfo_children(): widget.destroy()
self.dynamic_entry_vars.clear()
profile_name = self.active_profile_name.get()
self.loaded_profile_name = profile_name # Memorizza il profilo caricato
if not profile_name:
tb.Label(self.placeholder_frame, text="No profile selected. Create or select a profile.", bootstyle=INFO).pack(pady=10)
self._update_output_paths(); return
profile_data = self.config[KEY_PROFILES].get(profile_name, {})
template_path = profile_data.get(KEY_TEMPLATE_PATH)
if not template_path or not os.path.exists(template_path):
tb.Label(self.placeholder_frame, text=f"Template file not found:\n{template_path}", bootstyle=WARNING, wraplength=700).pack(pady=10)
return
try:
dynamic, structural = scan_template_for_placeholders(template_path)
if not (dynamic or structural):
tb.Label(self.placeholder_frame, text="No placeholders found in template.", bootstyle=INFO).pack(pady=10); return
placeholder_grid = tb.Frame(self.placeholder_frame); placeholder_grid.pack(fill=tk.X, padx=5, pady=5); placeholder_grid.columnconfigure(1, weight=1)
saved_values = profile_data.get(KEY_VALUES, {})
row = 0
for ph in dynamic:
label_text = ph.replace("%%", "") + ":"
tb.Label(placeholder_grid, text=label_text).grid(row=row, column=0, padx=5, pady=3, sticky="e")
entry_var = StringVar(value=saved_values.get(ph, ""))
entry = tb.Entry(placeholder_grid, textvariable=entry_var); entry.grid(row=row, column=1, columnspan=2, padx=5, pady=3, sticky="ew")
entry_var.trace_add("write", lambda *_, ph_key=ph: self._on_placeholder_change(ph_key))
self.dynamic_entry_vars[ph] = entry_var
row += 1
if structural:
tb.Separator(placeholder_grid).grid(row=row, column=0, columnspan=3, pady=10, sticky="ew"); row += 1
for ph in structural:
label_text = ph.replace("%%", "") + ":"
tb.Label(placeholder_grid, text=label_text).grid(row=row, column=0, padx=5, pady=3, sticky="e")
tb.Entry(placeholder_grid, state="readonly", textvariable=StringVar(value="<Automatic>")).grid(row=row, column=1, padx=5, pady=3, sticky="ew")
tb.Label(placeholder_grid, text="Managed by application", bootstyle=SECONDARY).grid(row=row, column=2, padx=5, pady=3, sticky="w")
row += 1
except (FileNotFoundError, TemplatePlaceholderError) as e:
tb.Label(self.placeholder_frame, text=f"Error: {e}", bootstyle=DANGER, wraplength=700).pack(pady=10)
self._update_output_paths()
def _on_placeholder_change(self, placeholder_key):
if placeholder_key in ["%%DOC_PROJECT%%", "%%DOC_NUMBER%%", "%%DOC_REV%%"]: self._update_output_paths()
def _gather_current_values(self): return {ph: var.get() for ph, var in self.dynamic_entry_vars.items()}
def _save_current_values(self):
profile_to_save = self.loaded_profile_name
if not profile_to_save or profile_to_save not in self.config[KEY_PROFILES]: return
self.config[KEY_PROFILES][profile_to_save][KEY_VALUES] = self._gather_current_values()
log.info(f"Updated values in memory for profile '{profile_to_save}'.")
def _confirm_overwrite(self, filepath: str) -> bool:
if os.path.exists(filepath): return messagebox.askyesno("Confirm Overwrite", f"File exists:\n{filepath}\n\nOverwrite?")
return True
def browse_markdown(self):
path = filedialog.askopenfilename(filetypes=[("Markdown files", "*.md")])
if path: self.selected_file.set(path)
def _update_output_paths(self):
source_path_str = self.selected_file.get()
if not source_path_str: return
output_dir = Path(source_path_str).parent
values = self._gather_current_values()
project = values.get("%%DOC_PROJECT%%", "NoProject"); doc_num = values.get("%%DOC_NUMBER%%", "NoNum"); rev = values.get("%%DOC_REV%%", "NoRev")
today = date.today().strftime("%Y%m%d")
def sanitize(text: str) -> str: return re.sub(r'[\s/\\:*?"<>|]+', '_', text)
docx_fn = f"{sanitize(project)}_SUM_{sanitize(doc_num)}_{sanitize(rev)}_{today}.docx"
self.docx_output_path.set(str(output_dir / docx_fn))
pdf_from_docx_fn = f"{sanitize(project)}_SUM_{sanitize(doc_num)}_{sanitize(rev)}_{today}.pdf"
self.pdf_from_docx_output_path.set(str(output_dir / pdf_from_docx_fn))
pdf_direct_fn = f"{sanitize(project)}_SUM_{today}.pdf"
self.pdf_direct_output_path.set(str(output_dir / pdf_direct_fn))
def convert(self, fmt):
self.docx_to_pdf_btn.config(state=tk.DISABLED)
input_path = self.selected_file.get()
if not input_path: messagebox.showerror("Error", "Please select a Markdown file."); return
profile_name = self.active_profile_name.get()
if not profile_name: messagebox.showerror("Error", "Please select a profile."); return
template_path = self.config[KEY_PROFILES][profile_name].get(KEY_TEMPLATE_PATH)
if fmt == "DOCX" and not template_path: messagebox.showwarning("Warning", "The selected profile has no template associated."); return
output_path = self.docx_output_path.get() if fmt == "DOCX" else self.pdf_direct_output_path.get()
if not output_path: messagebox.showerror("Error", "Please specify a valid output path."); return
if not self._confirm_overwrite(output_path): return
self._save_current_values()
save_configuration(self.config)
metadata = self._gather_current_values()
try:
result_path = convert_markdown(input_path, output_path, fmt, self.add_toc_var.get(), template_path, metadata)
messagebox.showinfo("Success", f"File converted successfully:\n{result_path}")
if fmt == "DOCX": self.docx_to_pdf_btn.config(state=tk.NORMAL)
except Exception as e:
log.critical(f"An error occurred during conversion: {e}", exc_info=True)
messagebox.showerror("Error", f"An error occurred during conversion:\n{str(e)}")
def convert_from_docx_to_pdf(self):
input_path = self.docx_output_path.get()
output_path = self.pdf_from_docx_output_path.get()
if not input_path or not os.path.exists(input_path): messagebox.showerror("Error", "Source DOCX file does not exist. Generate it first."); return
if not output_path: messagebox.showerror("Error", "Please specify a valid output path for the PDF."); return
if not self._confirm_overwrite(output_path): return
try:
pdf_output = convert_docx_to_pdf(input_path, output_path)
messagebox.showinfo("Success", f"DOCX successfully converted to PDF:\n{pdf_output}")
except ConverterNotFoundError as e: messagebox.showerror("Converter Not Found", str(e))
except Exception as e: messagebox.showerror("DOCX to PDF Conversion Error", f"An unexpected error occurred.\nError: {str(e)}")
def update_config(self, new_config):
self.config = new_config
save_configuration(self.config)
def _on_closing(self):
self._save_current_values()
self.config[KEY_LAST_MARKDOWN] = self.selected_file.get()
self.config[KEY_LAST_PROFILE] = self.active_profile_name.get()
save_configuration(self.config)
shutdown_logging_system()
self.root.destroy()
def run_app():
root = tb.Window(themename="sandstone")
app = MarkdownConverterApp(root)
root.mainloop()