364 lines
21 KiB
Python
364 lines
21 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, ""))
|
|
|
|
# --- NEW CODE ---
|
|
# Add a trace to the StringVar. Whenever its value is written,
|
|
# the _on_selected_file_change callback will be executed.
|
|
self.selected_file.trace_add("write", self._on_selected_file_change)
|
|
|
|
self.active_profile_name = StringVar() # Initialized empty
|
|
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() # This will set 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 _on_selected_file_change(self, *args):
|
|
"""
|
|
Callback function that is triggered whenever the self.selected_file
|
|
StringVar is written to. It ensures the output paths are updated.
|
|
"""
|
|
self._update_output_paths()
|
|
|
|
def run_app():
|
|
root = tb.Window(themename="sandstone")
|
|
app = MarkdownConverterApp(root)
|
|
root.mainloop() |