""" SaveUtils.py - core logic for the SaveUtils FreeCAD addon. """ import re import os import datetime import FreeCAD import FreeCADGui from PySide import QtCore, QtGui # Imported only from InitGui.py, i.e. always in a GUI session — see the note # there about why FreeCAD.GuiUp must not gate these imports. QT_TRANSLATE_NOOP = FreeCAD.Qt.QT_TRANSLATE_NOOP FreeCAD.Console.PrintLog("=== SaveUtils: SaveUtils.py loading ===\n") # --------------------------------------------------------------------------- # Regex patterns # --------------------------------------------------------------------------- _TS_PATTERN = re.compile(r"-\d{8}-\d{6}\.FCStd$", re.IGNORECASE) # Unbounded digit count, so counters that grow past -99 keep incrementing # instead of colliding on the same name _INC_SCAN_PATTERN = re.compile(r"-(\d+)\.FCStd$", re.IGNORECASE) _EXT_PATTERN = re.compile(r"\.FCStd$", re.IGNORECASE) def _strip_fcstd(path): return _EXT_PATTERN.sub("", path) def _active_document(): doc = FreeCAD.ActiveDocument if doc is None: QtGui.QMessageBox.warning( FreeCADGui.getMainWindow(), "SaveUtils", "No active document." ) return doc def _ask_filename(title, current=""): """Ask for a base filename. Returns the raw path, or None if cancelled.""" start = current or os.path.expanduser("~/untitled.FCStd") parent = FreeCADGui.getMainWindow() flt = "FreeCAD files (*.FCStd)" # We derive a different name from whatever is picked, so Qt's overwrite # confirmation would be warning about a file we are never going to write. try: dont_confirm = QtGui.QFileDialog.Option.DontConfirmOverwrite path, _ = QtGui.QFileDialog.getSaveFileName( parent, title, start, flt, "", dont_confirm ) except Exception: path, _ = QtGui.QFileDialog.getSaveFileName(parent, title, start, flt) return path or None def _error(exc): QtGui.QMessageBox.critical( FreeCADGui.getMainWindow(), "SaveUtils error", f"Could not save file:\n{exc}", ) return False def _clear_modified(doc): """Clear the GUI's unsaved-changes marker — the * in the title bar. The asterisk is driven by the Gui document's own modified flag, which does not reliably follow an App-level saveAs(). Only ever called straight after a successful save, when there genuinely are no unsaved changes left. """ try: gui_doc = FreeCADGui.getDocument(doc.Name) except Exception: return try: if getattr(gui_doc, "Modified", False): gui_doc.Modified = False except Exception: FreeCAD.Console.PrintLog( "=== SaveUtils: could not clear the modified flag ===\n" ) def _save_as(new_path): """Save the active document as `new_path` and keep working in it.""" doc = FreeCAD.ActiveDocument try: doc.saveAs(new_path) except Exception as exc: return _error(exc) _clear_modified(doc) FreeCAD.Console.PrintMessage(f"SaveUtils: saved as '{new_path}'\n") return True def _timestamp_base(path): """Strip a trailing -YYYYMMDD-HHMMSS and the .FCStd extension.""" if _TS_PATTERN.search(path): return _TS_PATTERN.sub("", path) return _strip_fcstd(path) def _increment_base(path): """Strip a trailing -NN and the .FCStd extension.""" # A -YYYYMMDD-HHMMSS tail also ends in digits; keep it whole rather than # incrementing the time portion into a bogus timestamp if not _TS_PATTERN.search(path): m = _INC_SCAN_PATTERN.search(path) if m: return path[: m.start()] return _strip_fcstd(path) def _next_increment(base): """Return the lowest unused -NN counter for `base` (path without .FCStd).""" directory = os.path.dirname(base) or "." stem = os.path.basename(base) highest = 0 try: entries = os.listdir(directory) except OSError: entries = [] prefix = f"{stem.lower()}-" for name in entries: if not name.lower().startswith(prefix): continue m = _INC_SCAN_PATTERN.search(name) # Only count siblings whose counter directly follows the stem, so # "design-detail-01.FCStd" is not mistaken for a "design" increment. if m and m.start() == len(stem): highest = max(highest, int(m.group(1))) return highest + 1 def _timestamp_name(base): stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") return f"{base}-{stamp}.FCStd" def _increment_name(base): return f"{base}-{_next_increment(base):02d}.FCStd" # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- def _save_derived(prompt, title, strip_tail, make_name): """Save the document under a name derived from a base, and switch to it. `strip_tail` removes any existing suffix so repeated use does not stack them up; `make_name` builds the new filename. The base comes from the open document, or from a file dialog when `prompt` is set — or when the document has never been saved and so has no name to derive from. """ doc = _active_document() if doc is None: return path = doc.FileName if prompt or not path: path = _ask_filename(title, path) if path is None: return _save_as(make_name(strip_tail(path))) class CmdSaveTimestamp: """File > Save with Timestamp — auto-named from the open document.""" def GetResources(self): return { "MenuText": "Save with Timestamp", "ToolTip": "Save as -YYYYMMDD-HHMMSS.FCStd and keep working in it", "Pixmap": "", } def IsActive(self): return FreeCAD.ActiveDocument is not None def Activated(self): _save_derived( False, "Save with Timestamp", _timestamp_base, _timestamp_name ) class CmdSaveIncrement: """File > Save Increment — auto-named from the open document.""" def GetResources(self): return { "MenuText": "Save Increment", "ToolTip": "Save as the next -NN.FCStd and keep working in it", "Pixmap": "", } def IsActive(self): return FreeCAD.ActiveDocument is not None def Activated(self): _save_derived(False, "Save Increment", _increment_base, _increment_name) class CmdSaveAsTimestamp: """File > Save As with Timestamp — base filename chosen in a dialog.""" def GetResources(self): return { "MenuText": "Save As with Timestamp", "ToolTip": "Choose a base filename, then save as -YYYYMMDD-HHMMSS.FCStd", "Pixmap": "", } def IsActive(self): return FreeCAD.ActiveDocument is not None def Activated(self): _save_derived( True, "Save As with Timestamp – choose base filename", _timestamp_base, _timestamp_name, ) class CmdSaveAsIncrement: """File > Save As Increment — base filename chosen in a dialog.""" def GetResources(self): return { "MenuText": "Save As Increment", "ToolTip": "Choose a base filename, then save as the next -NN.FCStd", "Pixmap": "", } def IsActive(self): return FreeCAD.ActiveDocument is not None def Activated(self): _save_derived( True, "Save As Increment – choose base filename", _increment_base, _increment_name, ) # --------------------------------------------------------------------------- # Menu injection # --------------------------------------------------------------------------- _commands_registered = False def install_safely(): """Entry point for InitGui.py. Called from a QTimer slot, so an escaping exception would propagate into Qt's event loop. Report it and carry on instead. """ try: install() except Exception as exc: FreeCAD.Console.PrintError(f"=== SaveUtils install error: {exc} ===\n") def install(): """Register the commands and put them in the File menu. Safe to call again.""" global _commands_registered FreeCAD.Console.PrintLog("=== SaveUtils: install() called ===\n") if not _commands_registered: # Command names are kept as they were so existing keyboard shortcuts # still resolve, even though the classes behind them were renamed. FreeCADGui.addCommand("SaveUtils_SaveTimestamp", CmdSaveTimestamp()) FreeCADGui.addCommand("SaveUtils_SaveIncrement", CmdSaveIncrement()) FreeCADGui.addCommand("SaveUtils_Timestamp", CmdSaveAsTimestamp()) FreeCADGui.addCommand("SaveUtils_Increment", CmdSaveAsIncrement()) _commands_registered = True _inject_menu() # Menu text, tooltip and command name for each item, in display order _ITEMS = [ ( "Save with Timestamp", "Save as -YYYYMMDD-HHMMSS.FCStd and keep working in it", "SaveUtils_SaveTimestamp", ), ( "Save Increment", "Save as the next -NN.FCStd and keep working in it", "SaveUtils_SaveIncrement", ), ( "Save As with Timestamp", "Choose a base filename, then save as -YYYYMMDD-HHMMSS.FCStd", "SaveUtils_Timestamp", ), ( "Save As Increment", "Choose a base filename, then save as the next -NN.FCStd", "SaveUtils_Increment", ), ] _ITEM_TEXTS = {text for text, _, _ in _ITEMS} def _alive(obj): """True if the C++ half of a PySide wrapper still exists. Deliberately catches everything: this is only ever used to decide whether touching `obj` is safe, so it must not be able to raise on its own. """ if obj is None: return False try: obj.objectName() return True except Exception: return False def _addr(obj): """Identity of the underlying C++ object, so recreations are visible.""" if obj is None: return "None" try: import shiboken6 return hex(shiboken6.getCppPointer(obj)[0]) except Exception: try: return f"py:{id(obj):x}" except Exception: return "?" def report(): """Diagnostics. Run from FreeCAD's Python console: import SaveUtils; SaveUtils.report() Everything is collected before anything is printed: writing to the Report view can pump the event loop, and FreeCAD may destroy menus while it does, which would invalidate the wrappers mid-report. """ lines = [] try: mw = FreeCADGui.getMainWindow() lines.append(f"commands registered : {_commands_registered}") lines.append(f"main window : {_addr(mw)} alive={_alive(mw)}") if not _alive(mw): return _emit(lines) menubar = mw.menuBar() lines.append( f"menu bar : {_addr(menubar)} alive={_alive(menubar)} " f"visible={menubar.isVisible()} native={menubar.isNativeMenuBar()}" ) lines.append("menu bar entries : text | objectName | visible | addr") for action in menubar.actions(): try: menu = action.menu() if menu is None: lines.append(f" (no submenu) {action.text()!r}") continue lines.append( f" {action.text()!r} | {menu.objectName()!r} | " f"{action.isVisible()} | {_addr(menu)}" ) except RuntimeError: lines.append(" ") file_menu = _find_file_menu(menubar) lines.append( f"resolved File menu : {_addr(file_menu)} alive={_alive(file_menu)}" ) lines.append( f"hooked menu : {_addr(_hooked_menu)} " f"alive={_alive(_hooked_menu)} same={_hooked_menu is file_menu}" ) lines.append( f"our actions : {len(_our_actions)} tracked, " f"{sum(1 for a in _our_actions if _alive(a))} alive" ) lines.append(f"Show-event watcher : {'installed' if _watcher else 'NOT installed'}") if _alive(file_menu): lines.append("File menu contents :") for act in file_menu.actions(): try: mark = " <-- ours" if act.text() in _ITEM_TEXTS else "" label = "--separator--" if act.isSeparator() else act.text() lines.append(f" {label!r} visible={act.isVisible()}{mark}") except RuntimeError: lines.append(" ") # Every QMenu anywhere under the main window that looks like a File menu. # If the one shown on screen is not the one we resolved, it shows up here. try: menu_cls = None for action in menubar.actions(): try: if action.menu() is not None: menu_cls = type(action.menu()) break except RuntimeError: continue candidates = mw.findChildren(menu_cls) if menu_cls else [] lines.append(f"all QMenus under mw : {len(candidates)}") for m in candidates: try: if "file" in m.objectName().replace("&", "").strip().lower(): parent = m.parent() lines.append( f" {m.objectName()!r} addr={_addr(m)} " f"actions={len(m.actions())} " f"parent={type(parent).__name__ if parent else None}" ) except RuntimeError: lines.append(" ") except Exception as exc: lines.append(f"child scan failed : {exc!r}") except Exception as exc: lines.append(f"report failed: {exc!r}") return _emit(lines) def _emit(lines): text = "\n".join(["=== SaveUtils report ==="] + lines) # print() lands in the Python console, where whoever ran report() is looking print(text) return None def force(): """Re-run installation right now, then report. For use from the console.""" install_safely() return report() def _menu_entries(menubar): """[(action, menu, objectName, text)] for every live menu in the menu bar. FreeCAD tears menus down and rebuilds them on workbench switches, so the menu bar can hand back wrappers whose underlying C++ QMenu is already gone. Touching one of those raises RuntimeError rather than returning None. """ entries = [] for action in menubar.actions(): try: menu = action.menu() if menu is None: continue entries.append( ( action, menu, menu.objectName().replace("&", "").strip().lower(), action.text().replace("&", "").strip().lower(), ) ) except RuntimeError: continue return entries def _find_file_menu(menubar): # Match on objectName first (most reliable), then on display text matches = [e for e in _menu_entries(menubar) if e[2] == "file" or e[3] == "file"] if not matches: return None # A rebuild can leave a detached File menu behind. Injecting into that one # succeeds silently and shows nothing, so prefer a menu the user can open. for action, menu, _, _ in matches: if action.isVisible() and _alive(menu): return menu menu = matches[0][1] return menu if _alive(menu) else None _our_actions = [] def _add_items(file_menu): """Put the SaveUtils entries into `file_menu`. No-op if they are there.""" global _our_actions existing = {act.text() for act in file_menu.actions()} if existing & _ITEM_TEXTS: return False # A rebuild can strip our items while leaving the separator we added, so # clear out whatever is left of the last injection before adding a new one. for act in _our_actions: try: file_menu.removeAction(act) except RuntimeError: pass _our_actions = [] new_actions = [] for text, tip, command in _ITEMS: action = QtGui.QAction(text, file_menu) action.setToolTip(tip) # Bind the command name per-iteration so every lambda keeps its own action.triggered.connect( lambda checked=False, cmd=command: FreeCADGui.runCommand(cmd) ) new_actions.append(action) # Insert before the first separator insert_before = next((a for a in file_menu.actions() if a.isSeparator()), None) if insert_before: for action in new_actions: file_menu.insertAction(insert_before, action) new_actions.append(file_menu.insertSeparator(insert_before)) else: new_actions.append(file_menu.addSeparator()) for action in new_actions[:-1]: file_menu.addAction(action) _our_actions = [a for a in new_actions if a is not None] return True def _is_file_menu(obj): """True if `obj` is a QMenu that FreeCAD built as the File menu.""" try: # QMenu, not merely any QObject that happens to be named "file" if not hasattr(obj, "insertSeparator") or not hasattr(obj, "actions"): return False return obj.objectName().replace("&", "").strip().lower() == "file" except Exception: return False class _MenuWatcher(QtCore.QObject): """Injects the entries whenever a File menu is about to be shown. FreeCAD destroys and recreates the File QMenu, which takes any aboutToShow connection — and every action we previously inserted — with it. Filtering Show events at application level catches whichever menu object exists at the moment it is displayed, so recreation stops mattering. """ def eventFilter(self, obj, event): try: if event.type() == _SHOW_EVENT and _is_file_menu(obj): _add_items(obj) except Exception: pass # Never consume the event return False try: _SHOW_EVENT = QtCore.QEvent.Type.Show except AttributeError: # older bindings expose the enum unscoped _SHOW_EVENT = QtCore.QEvent.Show _watcher = None def _install_watcher(): """Install the app-wide Show filter once.""" global _watcher if _watcher is not None: return # QCoreApplication lives in QtCore, so this works regardless of how the # PySide shim splits QtGui/QtWidgets across Qt versions. app = QtCore.QCoreApplication.instance() if app is None: return _watcher = _MenuWatcher() app.installEventFilter(_watcher) FreeCAD.Console.PrintLog("=== SaveUtils: menu watcher installed ===\n") _hooked_menu = None def _on_file_menu_about_to_show(): """Re-add the entries just before the menu is displayed. FreeCAD's MenuManager rebuilds the File menu on workbench switches and drops any action it does not own, so a one-shot injection does not survive. Doing it here means the items are present whenever the menu is actually opened. """ global _hooked_menu try: if _hooked_menu is not None: _add_items(_hooked_menu) except RuntimeError: _hooked_menu = None def _inject_menu(): global _hooked_menu # Works even if the File menu below is never found or is later replaced _install_watcher() mw = FreeCADGui.getMainWindow() if mw is None: FreeCAD.Console.PrintError("=== SaveUtils: main window not found ===\n") return menubar = mw.menuBar() file_menu = _find_file_menu(menubar) if file_menu is None: FreeCAD.Console.PrintError("=== SaveUtils: File menu not found ===\n") found = [(e[3], e[2]) for e in _menu_entries(menubar)] FreeCAD.Console.PrintMessage(f"=== SaveUtils: menus found: {found} ===\n") return try: if _hooked_menu is not file_menu: file_menu.aboutToShow.connect(_on_file_menu_about_to_show) _hooked_menu = file_menu added = _add_items(file_menu) except RuntimeError as exc: # File menu was destroyed underneath us mid-rebuild; the next workbench # activation will inject into the replacement. FreeCAD.Console.PrintLog(f"=== SaveUtils: menu went away ({exc}) ===\n") _hooked_menu = None return FreeCAD.Console.PrintLog( "=== SaveUtils: menu items injected ===\n" if added else "=== SaveUtils: already installed, skipping ===\n" )