649 lines · 21.2 KB
Raw Download
1
"""
2
SaveUtils.py  -  core logic for the SaveUtils FreeCAD addon.
3
"""
4
5
import re
6
import os
7
import datetime
8
import FreeCAD
9
import FreeCADGui
10
from PySide import QtCore, QtGui
11
12
# Imported only from InitGui.py, i.e. always in a GUI session — see the note
13
# there about why FreeCAD.GuiUp must not gate these imports.
14
15
QT_TRANSLATE_NOOP = FreeCAD.Qt.QT_TRANSLATE_NOOP
16
17
FreeCAD.Console.PrintLog("=== SaveUtils: SaveUtils.py loading ===\n")
18
19
# ---------------------------------------------------------------------------
20
# Regex patterns
21
# ---------------------------------------------------------------------------
22
_TS_PATTERN = re.compile(r"-\d{8}-\d{6}\.FCStd$", re.IGNORECASE)
23
# Unbounded digit count, so counters that grow past -99 keep incrementing
24
# instead of colliding on the same name
25
_INC_SCAN_PATTERN = re.compile(r"-(\d+)\.FCStd$", re.IGNORECASE)
26
_EXT_PATTERN = re.compile(r"\.FCStd$", re.IGNORECASE)
27
28
29
def _strip_fcstd(path):
30
    return _EXT_PATTERN.sub("", path)
31
32
33
def _active_document():
34
    doc = FreeCAD.ActiveDocument
35
    if doc is None:
36
        QtGui.QMessageBox.warning(
37
            FreeCADGui.getMainWindow(), "SaveUtils", "No active document."
38
        )
39
    return doc
40
41
42
def _ask_filename(title, current=""):
43
    """Ask for a base filename. Returns the raw path, or None if cancelled."""
44
    start = current or os.path.expanduser("~/untitled.FCStd")
45
    parent = FreeCADGui.getMainWindow()
46
    flt = "FreeCAD files (*.FCStd)"
47
    # We derive a different name from whatever is picked, so Qt's overwrite
48
    # confirmation would be warning about a file we are never going to write.
49
    try:
50
        dont_confirm = QtGui.QFileDialog.Option.DontConfirmOverwrite
51
        path, _ = QtGui.QFileDialog.getSaveFileName(
52
            parent, title, start, flt, "", dont_confirm
53
        )
54
    except Exception:
55
        path, _ = QtGui.QFileDialog.getSaveFileName(parent, title, start, flt)
56
    return path or None
57
58
59
def _error(exc):
60
    QtGui.QMessageBox.critical(
61
        FreeCADGui.getMainWindow(),
62
        "SaveUtils error",
63
        f"Could not save file:\n{exc}",
64
    )
65
    return False
66
67
68
def _clear_modified(doc):
69
    """Clear the GUI's unsaved-changes marker — the * in the title bar.
70
71
    The asterisk is driven by the Gui document's own modified flag, which does
72
    not reliably follow an App-level saveAs(). Only ever called straight after a
73
    successful save, when there genuinely are no unsaved changes left.
74
    """
75
    try:
76
        gui_doc = FreeCADGui.getDocument(doc.Name)
77
    except Exception:
78
        return
79
    try:
80
        if getattr(gui_doc, "Modified", False):
81
            gui_doc.Modified = False
82
    except Exception:
83
        FreeCAD.Console.PrintLog(
84
            "=== SaveUtils: could not clear the modified flag ===\n"
85
        )
86
87
88
def _save_as(new_path):
89
    """Save the active document as `new_path` and keep working in it."""
90
    doc = FreeCAD.ActiveDocument
91
    try:
92
        doc.saveAs(new_path)
93
    except Exception as exc:
94
        return _error(exc)
95
    _clear_modified(doc)
96
    FreeCAD.Console.PrintMessage(f"SaveUtils: saved as '{new_path}'\n")
97
    return True
98
99
100
def _timestamp_base(path):
101
    """Strip a trailing -YYYYMMDD-HHMMSS and the .FCStd extension."""
102
    if _TS_PATTERN.search(path):
103
        return _TS_PATTERN.sub("", path)
104
    return _strip_fcstd(path)
105
106
107
def _increment_base(path):
108
    """Strip a trailing -NN and the .FCStd extension."""
109
    # A -YYYYMMDD-HHMMSS tail also ends in digits; keep it whole rather than
110
    # incrementing the time portion into a bogus timestamp
111
    if not _TS_PATTERN.search(path):
112
        m = _INC_SCAN_PATTERN.search(path)
113
        if m:
114
            return path[: m.start()]
115
    return _strip_fcstd(path)
116
117
118
def _next_increment(base):
119
    """Return the lowest unused -NN counter for `base` (path without .FCStd)."""
120
    directory = os.path.dirname(base) or "."
121
    stem = os.path.basename(base)
122
    highest = 0
123
    try:
124
        entries = os.listdir(directory)
125
    except OSError:
126
        entries = []
127
    prefix = f"{stem.lower()}-"
128
    for name in entries:
129
        if not name.lower().startswith(prefix):
130
            continue
131
        m = _INC_SCAN_PATTERN.search(name)
132
        # Only count siblings whose counter directly follows the stem, so
133
        # "design-detail-01.FCStd" is not mistaken for a "design" increment.
134
        if m and m.start() == len(stem):
135
            highest = max(highest, int(m.group(1)))
136
    return highest + 1
137
138
139
def _timestamp_name(base):
140
    stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
141
    return f"{base}-{stamp}.FCStd"
142
143
144
def _increment_name(base):
145
    return f"{base}-{_next_increment(base):02d}.FCStd"
146
147
148
# ---------------------------------------------------------------------------
149
# Commands
150
# ---------------------------------------------------------------------------
151
152
153
def _save_derived(prompt, title, strip_tail, make_name):
154
    """Save the document under a name derived from a base, and switch to it.
155
156
    `strip_tail` removes any existing suffix so repeated use does not stack them
157
    up; `make_name` builds the new filename. The base comes from the open
158
    document, or from a file dialog when `prompt` is set — or when the document
159
    has never been saved and so has no name to derive from.
160
    """
161
    doc = _active_document()
162
    if doc is None:
163
        return
164
165
    path = doc.FileName
166
    if prompt or not path:
167
        path = _ask_filename(title, path)
168
        if path is None:
169
            return
170
171
    _save_as(make_name(strip_tail(path)))
172
173
174
class CmdSaveTimestamp:
175
    """File > Save with Timestamp — auto-named from the open document."""
176
177
    def GetResources(self):
178
        return {
179
            "MenuText": "Save with Timestamp",
180
            "ToolTip": "Save as <name>-YYYYMMDD-HHMMSS.FCStd and keep working in it",
181
            "Pixmap": "",
182
        }
183
184
    def IsActive(self):
185
        return FreeCAD.ActiveDocument is not None
186
187
    def Activated(self):
188
        _save_derived(
189
            False, "Save with Timestamp", _timestamp_base, _timestamp_name
190
        )
191
192
193
class CmdSaveIncrement:
194
    """File > Save Increment — auto-named from the open document."""
195
196
    def GetResources(self):
197
        return {
198
            "MenuText": "Save Increment",
199
            "ToolTip": "Save as the next <name>-NN.FCStd and keep working in it",
200
            "Pixmap": "",
201
        }
202
203
    def IsActive(self):
204
        return FreeCAD.ActiveDocument is not None
205
206
    def Activated(self):
207
        _save_derived(False, "Save Increment", _increment_base, _increment_name)
208
209
210
class CmdSaveAsTimestamp:
211
    """File > Save As with Timestamp — base filename chosen in a dialog."""
212
213
    def GetResources(self):
214
        return {
215
            "MenuText": "Save As with Timestamp",
216
            "ToolTip": "Choose a base filename, then save as <base>-YYYYMMDD-HHMMSS.FCStd",
217
            "Pixmap": "",
218
        }
219
220
    def IsActive(self):
221
        return FreeCAD.ActiveDocument is not None
222
223
    def Activated(self):
224
        _save_derived(
225
            True,
226
            "Save As with Timestamp – choose base filename",
227
            _timestamp_base,
228
            _timestamp_name,
229
        )
230
231
232
class CmdSaveAsIncrement:
233
    """File > Save As Increment — base filename chosen in a dialog."""
234
235
    def GetResources(self):
236
        return {
237
            "MenuText": "Save As Increment",
238
            "ToolTip": "Choose a base filename, then save as the next <base>-NN.FCStd",
239
            "Pixmap": "",
240
        }
241
242
    def IsActive(self):
243
        return FreeCAD.ActiveDocument is not None
244
245
    def Activated(self):
246
        _save_derived(
247
            True,
248
            "Save As Increment – choose base filename",
249
            _increment_base,
250
            _increment_name,
251
        )
252
253
254
# ---------------------------------------------------------------------------
255
# Menu injection
256
# ---------------------------------------------------------------------------
257
258
259
_commands_registered = False
260
261
262
def install_safely():
263
    """Entry point for InitGui.py.
264
265
    Called from a QTimer slot, so an escaping exception would propagate into
266
    Qt's event loop. Report it and carry on instead.
267
    """
268
    try:
269
        install()
270
    except Exception as exc:
271
        FreeCAD.Console.PrintError(f"=== SaveUtils install error: {exc} ===\n")
272
273
274
def install():
275
    """Register the commands and put them in the File menu. Safe to call again."""
276
    global _commands_registered
277
278
    FreeCAD.Console.PrintLog("=== SaveUtils: install() called ===\n")
279
280
    if not _commands_registered:
281
        # Command names are kept as they were so existing keyboard shortcuts
282
        # still resolve, even though the classes behind them were renamed.
283
        FreeCADGui.addCommand("SaveUtils_SaveTimestamp", CmdSaveTimestamp())
284
        FreeCADGui.addCommand("SaveUtils_SaveIncrement", CmdSaveIncrement())
285
        FreeCADGui.addCommand("SaveUtils_Timestamp", CmdSaveAsTimestamp())
286
        FreeCADGui.addCommand("SaveUtils_Increment", CmdSaveAsIncrement())
287
        _commands_registered = True
288
289
    _inject_menu()
290
291
292
# Menu text, tooltip and command name for each item, in display order
293
_ITEMS = [
294
    (
295
        "Save with Timestamp",
296
        "Save as <name>-YYYYMMDD-HHMMSS.FCStd and keep working in it",
297
        "SaveUtils_SaveTimestamp",
298
    ),
299
    (
300
        "Save Increment",
301
        "Save as the next <name>-NN.FCStd and keep working in it",
302
        "SaveUtils_SaveIncrement",
303
    ),
304
    (
305
        "Save As with Timestamp",
306
        "Choose a base filename, then save as <base>-YYYYMMDD-HHMMSS.FCStd",
307
        "SaveUtils_Timestamp",
308
    ),
309
    (
310
        "Save As Increment",
311
        "Choose a base filename, then save as the next <base>-NN.FCStd",
312
        "SaveUtils_Increment",
313
    ),
314
]
315
316
_ITEM_TEXTS = {text for text, _, _ in _ITEMS}
317
318
319
def _alive(obj):
320
    """True if the C++ half of a PySide wrapper still exists.
321
322
    Deliberately catches everything: this is only ever used to decide whether
323
    touching `obj` is safe, so it must not be able to raise on its own.
324
    """
325
    if obj is None:
326
        return False
327
    try:
328
        obj.objectName()
329
        return True
330
    except Exception:
331
        return False
332
333
334
def _addr(obj):
335
    """Identity of the underlying C++ object, so recreations are visible."""
336
    if obj is None:
337
        return "None"
338
    try:
339
        import shiboken6
340
341
        return hex(shiboken6.getCppPointer(obj)[0])
342
    except Exception:
343
        try:
344
            return f"py:{id(obj):x}"
345
        except Exception:
346
            return "?"
347
348
349
def report():
350
    """Diagnostics. Run from FreeCAD's Python console:
351
352
        import SaveUtils; SaveUtils.report()
353
354
    Everything is collected before anything is printed: writing to the Report
355
    view can pump the event loop, and FreeCAD may destroy menus while it does,
356
    which would invalidate the wrappers mid-report.
357
    """
358
    lines = []
359
    try:
360
        mw = FreeCADGui.getMainWindow()
361
        lines.append(f"commands registered : {_commands_registered}")
362
        lines.append(f"main window         : {_addr(mw)} alive={_alive(mw)}")
363
        if not _alive(mw):
364
            return _emit(lines)
365
366
        menubar = mw.menuBar()
367
        lines.append(
368
            f"menu bar            : {_addr(menubar)} alive={_alive(menubar)} "
369
            f"visible={menubar.isVisible()} native={menubar.isNativeMenuBar()}"
370
        )
371
372
        lines.append("menu bar entries    : text | objectName | visible | addr")
373
        for action in menubar.actions():
374
            try:
375
                menu = action.menu()
376
                if menu is None:
377
                    lines.append(f"    (no submenu) {action.text()!r}")
378
                    continue
379
                lines.append(
380
                    f"    {action.text()!r} | {menu.objectName()!r} | "
381
                    f"{action.isVisible()} | {_addr(menu)}"
382
                )
383
            except RuntimeError:
384
                lines.append("    <dead QMenu wrapper>")
385
386
        file_menu = _find_file_menu(menubar)
387
        lines.append(
388
            f"resolved File menu  : {_addr(file_menu)} alive={_alive(file_menu)}"
389
        )
390
        lines.append(
391
            f"hooked menu         : {_addr(_hooked_menu)} "
392
            f"alive={_alive(_hooked_menu)} same={_hooked_menu is file_menu}"
393
        )
394
        lines.append(
395
            f"our actions         : {len(_our_actions)} tracked, "
396
            f"{sum(1 for a in _our_actions if _alive(a))} alive"
397
        )
398
        lines.append(f"Show-event watcher  : {'installed' if _watcher else 'NOT installed'}")
399
400
        if _alive(file_menu):
401
            lines.append("File menu contents  :")
402
            for act in file_menu.actions():
403
                try:
404
                    mark = "   <-- ours" if act.text() in _ITEM_TEXTS else ""
405
                    label = "--separator--" if act.isSeparator() else act.text()
406
                    lines.append(f"    {label!r} visible={act.isVisible()}{mark}")
407
                except RuntimeError:
408
                    lines.append("    <dead QAction wrapper>")
409
410
        # Every QMenu anywhere under the main window that looks like a File menu.
411
        # If the one shown on screen is not the one we resolved, it shows up here.
412
        try:
413
            menu_cls = None
414
            for action in menubar.actions():
415
                try:
416
                    if action.menu() is not None:
417
                        menu_cls = type(action.menu())
418
                        break
419
                except RuntimeError:
420
                    continue
421
            candidates = mw.findChildren(menu_cls) if menu_cls else []
422
            lines.append(f"all QMenus under mw : {len(candidates)}")
423
            for m in candidates:
424
                try:
425
                    if "file" in m.objectName().replace("&", "").strip().lower():
426
                        parent = m.parent()
427
                        lines.append(
428
                            f"    {m.objectName()!r} addr={_addr(m)} "
429
                            f"actions={len(m.actions())} "
430
                            f"parent={type(parent).__name__ if parent else None}"
431
                        )
432
                except RuntimeError:
433
                    lines.append("    <dead QMenu wrapper>")
434
        except Exception as exc:
435
            lines.append(f"child scan failed   : {exc!r}")
436
    except Exception as exc:
437
        lines.append(f"report failed: {exc!r}")
438
439
    return _emit(lines)
440
441
442
def _emit(lines):
443
    text = "\n".join(["=== SaveUtils report ==="] + lines)
444
    # print() lands in the Python console, where whoever ran report() is looking
445
    print(text)
446
    return None
447
448
449
def force():
450
    """Re-run installation right now, then report. For use from the console."""
451
    install_safely()
452
    return report()
453
454
455
def _menu_entries(menubar):
456
    """[(action, menu, objectName, text)] for every live menu in the menu bar.
457
458
    FreeCAD tears menus down and rebuilds them on workbench switches, so the menu
459
    bar can hand back wrappers whose underlying C++ QMenu is already gone.
460
    Touching one of those raises RuntimeError rather than returning None.
461
    """
462
    entries = []
463
    for action in menubar.actions():
464
        try:
465
            menu = action.menu()
466
            if menu is None:
467
                continue
468
            entries.append(
469
                (
470
                    action,
471
                    menu,
472
                    menu.objectName().replace("&", "").strip().lower(),
473
                    action.text().replace("&", "").strip().lower(),
474
                )
475
            )
476
        except RuntimeError:
477
            continue
478
    return entries
479
480
481
def _find_file_menu(menubar):
482
    # Match on objectName first (most reliable), then on display text
483
    matches = [e for e in _menu_entries(menubar) if e[2] == "file" or e[3] == "file"]
484
    if not matches:
485
        return None
486
    # A rebuild can leave a detached File menu behind. Injecting into that one
487
    # succeeds silently and shows nothing, so prefer a menu the user can open.
488
    for action, menu, _, _ in matches:
489
        if action.isVisible() and _alive(menu):
490
            return menu
491
    menu = matches[0][1]
492
    return menu if _alive(menu) else None
493
494
495
_our_actions = []
496
497
498
def _add_items(file_menu):
499
    """Put the SaveUtils entries into `file_menu`. No-op if they are there."""
500
    global _our_actions
501
502
    existing = {act.text() for act in file_menu.actions()}
503
    if existing & _ITEM_TEXTS:
504
        return False
505
506
    # A rebuild can strip our items while leaving the separator we added, so
507
    # clear out whatever is left of the last injection before adding a new one.
508
    for act in _our_actions:
509
        try:
510
            file_menu.removeAction(act)
511
        except RuntimeError:
512
            pass
513
    _our_actions = []
514
515
    new_actions = []
516
    for text, tip, command in _ITEMS:
517
        action = QtGui.QAction(text, file_menu)
518
        action.setToolTip(tip)
519
        # Bind the command name per-iteration so every lambda keeps its own
520
        action.triggered.connect(
521
            lambda checked=False, cmd=command: FreeCADGui.runCommand(cmd)
522
        )
523
        new_actions.append(action)
524
525
    # Insert before the first separator
526
    insert_before = next((a for a in file_menu.actions() if a.isSeparator()), None)
527
528
    if insert_before:
529
        for action in new_actions:
530
            file_menu.insertAction(insert_before, action)
531
        new_actions.append(file_menu.insertSeparator(insert_before))
532
    else:
533
        new_actions.append(file_menu.addSeparator())
534
        for action in new_actions[:-1]:
535
            file_menu.addAction(action)
536
537
    _our_actions = [a for a in new_actions if a is not None]
538
    return True
539
540
541
def _is_file_menu(obj):
542
    """True if `obj` is a QMenu that FreeCAD built as the File menu."""
543
    try:
544
        # QMenu, not merely any QObject that happens to be named "file"
545
        if not hasattr(obj, "insertSeparator") or not hasattr(obj, "actions"):
546
            return False
547
        return obj.objectName().replace("&", "").strip().lower() == "file"
548
    except Exception:
549
        return False
550
551
552
class _MenuWatcher(QtCore.QObject):
553
    """Injects the entries whenever a File menu is about to be shown.
554
555
    FreeCAD destroys and recreates the File QMenu, which takes any aboutToShow
556
    connection — and every action we previously inserted — with it. Filtering
557
    Show events at application level catches whichever menu object exists at
558
    the moment it is displayed, so recreation stops mattering.
559
    """
560
561
    def eventFilter(self, obj, event):
562
        try:
563
            if event.type() == _SHOW_EVENT and _is_file_menu(obj):
564
                _add_items(obj)
565
        except Exception:
566
            pass
567
        # Never consume the event
568
        return False
569
570
571
try:
572
    _SHOW_EVENT = QtCore.QEvent.Type.Show
573
except AttributeError:  # older bindings expose the enum unscoped
574
    _SHOW_EVENT = QtCore.QEvent.Show
575
576
_watcher = None
577
578
579
def _install_watcher():
580
    """Install the app-wide Show filter once."""
581
    global _watcher
582
    if _watcher is not None:
583
        return
584
    # QCoreApplication lives in QtCore, so this works regardless of how the
585
    # PySide shim splits QtGui/QtWidgets across Qt versions.
586
    app = QtCore.QCoreApplication.instance()
587
    if app is None:
588
        return
589
    _watcher = _MenuWatcher()
590
    app.installEventFilter(_watcher)
591
    FreeCAD.Console.PrintLog("=== SaveUtils: menu watcher installed ===\n")
592
593
594
_hooked_menu = None
595
596
597
def _on_file_menu_about_to_show():
598
    """Re-add the entries just before the menu is displayed.
599
600
    FreeCAD's MenuManager rebuilds the File menu on workbench switches and drops
601
    any action it does not own, so a one-shot injection does not survive. Doing
602
    it here means the items are present whenever the menu is actually opened.
603
    """
604
    global _hooked_menu
605
    try:
606
        if _hooked_menu is not None:
607
            _add_items(_hooked_menu)
608
    except RuntimeError:
609
        _hooked_menu = None
610
611
612
def _inject_menu():
613
    global _hooked_menu
614
615
    # Works even if the File menu below is never found or is later replaced
616
    _install_watcher()
617
618
    mw = FreeCADGui.getMainWindow()
619
    if mw is None:
620
        FreeCAD.Console.PrintError("=== SaveUtils: main window not found ===\n")
621
        return
622
623
    menubar = mw.menuBar()
624
    file_menu = _find_file_menu(menubar)
625
626
    if file_menu is None:
627
        FreeCAD.Console.PrintError("=== SaveUtils: File menu not found ===\n")
628
        found = [(e[3], e[2]) for e in _menu_entries(menubar)]
629
        FreeCAD.Console.PrintMessage(f"=== SaveUtils: menus found: {found} ===\n")
630
        return
631
632
    try:
633
        if _hooked_menu is not file_menu:
634
            file_menu.aboutToShow.connect(_on_file_menu_about_to_show)
635
            _hooked_menu = file_menu
636
637
        added = _add_items(file_menu)
638
    except RuntimeError as exc:
639
        # File menu was destroyed underneath us mid-rebuild; the next workbench
640
        # activation will inject into the replacement.
641
        FreeCAD.Console.PrintLog(f"=== SaveUtils: menu went away ({exc}) ===\n")
642
        _hooked_menu = None
643
        return
644
645
    FreeCAD.Console.PrintLog(
646
        "=== SaveUtils: menu items injected ===\n"
647
        if added
648
        else "=== SaveUtils: already installed, skipping ===\n"
649
    )