diff --git a/.gitignore b/.gitignore index e3ac47d..d27fab4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ *.egg-info dist build -.idea/ \ No newline at end of file +.idea/ +/aas_editor/settings/recovery/ \ No newline at end of file diff --git a/aas_editor/editorApp.py b/aas_editor/editorApp.py index 5f808a6..c4d3cc3 100644 --- a/aas_editor/editorApp.py +++ b/aas_editor/editorApp.py @@ -9,6 +9,7 @@ # A copy of the GNU General Public License is available at http://www.gnu.org/licenses/ import json import webbrowser +from pathlib import Path from PyQt6.QtCore import QModelIndex, pyqtSignal from PyQt6.QtGui import * @@ -18,6 +19,8 @@ import aas_editor.widgets.messsageBoxes import aas_editor.widgets.groupBoxes from aas_editor.settings.app_settings import * +from aas_editor.utils.recovery import find_recovery_files, delete_recovery_file +from aas_editor.utils.exceptionhook import set_crash_callback from aas_editor.settings.icons import EXIT_ICON, SETTINGS_ICON, NEW_PACK_ICON from aas_editor.settings import APPLICATION_NAME, REPORT_ERROR_LINK from aas_editor.widgets.settingWidgets import SettingsDialog @@ -48,6 +51,7 @@ def __init__(self, fileToOpen=None, parent=None): self.initToolbars() self.buildHandlers() self.restoreSettingsFromLastSession() + set_crash_callback(self._save_recovery_files) if fileToOpen: self.openAASFile(fileToOpen) @@ -293,9 +297,49 @@ def restoreSettingsFromLastSession(self): self.applyLastSessionTreeStates() def openLastSessionFiles(self): + recovery_map = find_recovery_files(RECOVERY_DIR) openedAasFiles = AppSettings.AAS_FILES_TO_OPEN_ON_START.value() for file in openedAasFiles: - self.openAASFile(file) + file_path = Path(file) + if file_path in recovery_map: + reply = QMessageBox.question( + self, "Restore unsaved changes", + f"A recovery file was found for:\n{file_path.name}\n\n" + "The app may have crashed with unsaved changes.\n" + "Do you want to restore the last auto-saved version?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if reply == QMessageBox.StandardButton.Yes: + rec = recovery_map[file_path] + self.openAASFile(str(rec)) + # Remap package to original path so Save targets the real file + for pkg in self.packTreeModel.openedPacks(): + if pkg.file == rec: + pkg.file = file_path + break + self.packTreeModel.layoutChanged.emit() + # Recovery files no longer needed — a new crash will create fresh ones + delete_recovery_file(rec) + self.setWindowModified(True) + else: + delete_recovery_file(recovery_map[file_path]) + self.openAASFile(file) + else: + self.openAASFile(file) + + def _save_recovery_files(self): + # Persist open-file list before crash exits — closeEvent won't run + AppSettings.AAS_FILES_TO_OPEN_ON_START.setValue(self.packTreeModel.openedFiles()) + SETTINGS.sync() + if not self.isWindowModified(): + return + for pkg in self.packTreeModel.openedPacks(): + if pkg.file and pkg.file.exists(): + try: + pkg.write_recovery(RECOVERY_DIR) + except Exception: + pass def openAASFile(self, filePath: str): try: diff --git a/aas_editor/package.py b/aas_editor/package.py index 09c9bd5..03c52fd 100644 --- a/aas_editor/package.py +++ b/aas_editor/package.py @@ -8,7 +8,9 @@ # # A copy of the GNU General Public License is available at http://www.gnu.org/licenses/ +import hashlib import io +import json from datetime import datetime from pathlib import Path from typing import Union, Iterable, Optional @@ -103,6 +105,36 @@ def write(self, file: str = None): else: raise TypeError("Wrong file type:", self.file.suffix) + def _recovery_stem(self, for_file: Path = None) -> str: + file = self.file if for_file is None else Path(for_file) + return hashlib.sha256(file.as_posix().encode()).hexdigest()[:12] + + def recovery_path(self, recovery_dir: Path) -> Path: + return recovery_dir / f"{self._recovery_stem()}{self.file.suffix}" + + def write_recovery(self, recovery_dir: Path) -> Path: + recovery_dir.mkdir(parents=True, exist_ok=True) + original_file = self._file + stem = self._recovery_stem() + rec_path = recovery_dir / f"{stem}{original_file.suffix}" + meta_path = recovery_dir / f"{stem}.meta.json" + self.write(str(rec_path)) + self._file = original_file # restore path mutated by write() + with open(meta_path, "w", encoding="utf-8") as f: + json.dump({ + "original_path": original_file.as_posix(), + "recovery_filename": rec_path.name, + }, f) + return rec_path + + def delete_recovery(self, recovery_dir: Path, for_file: Path = None) -> None: + # for_file lets callers target a previous path (e.g. after Save-As mutated self.file) + file = self.file if for_file is None else Path(for_file) + stem = self._recovery_stem(file) + for suffix in (file.suffix, ".meta.json"): + candidate = recovery_dir / f"{stem}{suffix}" + candidate.unlink(missing_ok=True) + def all_submodels_to_aas(self): """Add references of all existing submodels to submodel attribute of existing AAS.""" #TODO: fix if pyi40aas changes diff --git a/aas_editor/settings/app_settings.py b/aas_editor/settings/app_settings.py index 622c2f0..57d9eda 100644 --- a/aas_editor/settings/app_settings.py +++ b/aas_editor/settings/app_settings.py @@ -103,6 +103,7 @@ def _register_search_path(path: Path) -> Path: # Files SETTINGS_FILE = Path(__file__).resolve().parent / "settings.ini" +RECOVERY_DIR = Path(__file__).resolve().parent / "recovery" THEMES_FOLDER = _register_search_path(_PACKAGE_DIR / "themes") ICONS_FOLDER = _register_search_path(_PACKAGE_DIR / "icons") CUSTOM_COLUMN_LISTS_FILE = _PACKAGE_DIR / "custom_column_lists.json" diff --git a/aas_editor/treeviews/treeview_pack.py b/aas_editor/treeviews/treeview_pack.py index 70488f5..5219bad 100644 --- a/aas_editor/treeviews/treeview_pack.py +++ b/aas_editor/treeviews/treeview_pack.py @@ -43,7 +43,8 @@ from aas_editor.settings.app_settings import NAME_ROLE, OBJECT_ROLE, PACKAGE_ROLE, \ MAX_RECENT_FILES, OPENED_PACKS_ROLE, OPENED_FILES_ROLE, ADD_ITEM_ROLE, \ CLEAR_ROW_ROLE, AppSettings, COLUMN_NAME_ROLE, OBJECT_COLUMN_NAME, \ - OBJECT_VALUE_COLUMN_NAME, DEFAULT_COLUMNS_IN_PACKS_TABLE_TO_SHOW, COPY_ROLE, SUBMODEL_TEMPLATES_FOLDER, UPDATE_ROLE + OBJECT_VALUE_COLUMN_NAME, DEFAULT_COLUMNS_IN_PACKS_TABLE_TO_SHOW, COPY_ROLE, SUBMODEL_TEMPLATES_FOLDER, UPDATE_ROLE, \ + RECOVERY_DIR from aas_editor.settings.shortcuts import SC_OPEN, SC_SAVE_ALL from aas_editor.settings.icons import NEW_PACK_ICON, OPEN_ICON, OPEN_DRAG_ICON, SAVE_ICON, SAVE_ALL_ICON, ADD_ICON, \ EDIT_JSON_ICON @@ -651,8 +652,10 @@ def add_pack_to_tree(self, pack: Package): def savePack(self, pack: Package = None, file: str = None) -> bool: pack = self.currentIndex().data(PACKAGE_ROLE) if pack is None else pack + originalFile = pack.file # write(file) may change pack.file on Save-As try: pack.write(file) + pack.delete_recovery(RECOVERY_DIR, for_file=originalFile) self.updateRecentFiles(pack.file.absolute().as_posix()) if self.model().rowCount(QModelIndex()) == 1: self.setWindowModified(False) diff --git a/aas_editor/utils/exceptionhook.py b/aas_editor/utils/exceptionhook.py index 308ecba..2a56134 100644 --- a/aas_editor/utils/exceptionhook.py +++ b/aas_editor/utils/exceptionhook.py @@ -2,23 +2,50 @@ import logging from PyQt6 import QtWidgets +_on_crash_callback = None +_handling_exception = False + + +def set_crash_callback(cb): + global _on_crash_callback + _on_crash_callback = cb + def handle_exception(exc_type, exc_value, exc_traceback): """ Solution from: https://stackoverflow.com/questions/6234405/logging-uncaught-exceptions-in-python - Ignore KeyboardInterrupt so a console python program can exit with Ctrl + C. - I have no Idea what if block does """ + global _handling_exception + if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc_value, exc_traceback) return - box = QtWidgets.QMessageBox(None) - box.setIcon(QtWidgets.QMessageBox.Icon.Critical) - box.setText(f"An Exception was raised. Caught Exception: {exc_value}") - box.setDetailedText(f"Traceback: {exc_traceback}") - logging.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) + if _handling_exception: + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + + _handling_exception = True + try: + logging.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) + + if _on_crash_callback is not None: + try: + _on_crash_callback() + except Exception: + pass + + box = QtWidgets.QMessageBox(None) + box.setIcon(QtWidgets.QMessageBox.Icon.Critical) + box.setText(f"An Exception was raised. Caught Exception: {exc_value}") + box.setDetailedText(f"Traceback: {exc_traceback}") + box.exec() + finally: + _handling_exception = False + + sys.exit(1) sys.excepthook = handle_exception diff --git a/aas_editor/utils/recovery.py b/aas_editor/utils/recovery.py new file mode 100644 index 0000000..0939587 --- /dev/null +++ b/aas_editor/utils/recovery.py @@ -0,0 +1,26 @@ +import json +from pathlib import Path + + +def find_recovery_files(recovery_dir: Path) -> dict: + """Return {original_path: recovery_path} for all valid recovery entries.""" + result = {} + if not recovery_dir.exists(): + return result + for meta_file in recovery_dir.glob("*.meta.json"): + try: + with open(meta_file, encoding="utf-8") as f: + meta = json.load(f) + original = Path(meta["original_path"]) + recovery = recovery_dir / meta["recovery_filename"] + if recovery.exists(): + result[original] = recovery + except Exception: + continue + return result + + +def delete_recovery_file(recovery_path: Path) -> None: + """Delete a recovery data file and its sidecar .meta.json.""" + recovery_path.unlink(missing_ok=True) + (recovery_path.parent / f"{recovery_path.stem}.meta.json").unlink(missing_ok=True) diff --git a/main.py b/main.py index 58cc059..0b29940 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ import logging from PyQt6 import QtWidgets -# from aas_editor.utils import exceptionhook +from aas_editor.utils import exceptionhook # noqa: F401 from PyQt6 import QtWebEngineWidgets logging.basicConfig(level=logging.INFO, filename="log.log", filemode="w",