Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
*.egg-info
dist
build
.idea/
.idea/
/aas_editor/settings/recovery/
46 changes: 45 additions & 1 deletion aas_editor/editorApp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions aas_editor/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions aas_editor/settings/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion aas_editor/treeviews/treeview_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 33 additions & 6 deletions aas_editor/utils/exceptionhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions aas_editor/utils/recovery.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading