# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """Dialog window for recovering files from autosave""" # Standard library imports from os import path as osp import os import shutil import time # Third party imports from qtpy.compat import getsavefilename from qtpy.QtCore import Qt from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QTableWidget, QVBoxLayout, QWidget) # Local imports from spyder.config.base import _, running_under_pytest class RecoveryDialog(QDialog): """Dialog window to allow users to recover from autosave files.""" def __init__(self, autosave_mapping, parent=None): """ Constructor Parameters ---------- autosave_mapping : List[Tuple[str]] List of tuples, containing the name of the original file and the name of the corresponding autosave file. The first entry of the tuple may be `None` to indicate that the original file is unknown. parent : QWidget, optional Parent of the dialog window. The default is None. """ QDialog.__init__(self, parent) self.layout = QVBoxLayout(self) self.setLayout(self.layout) self.layout.setSpacing(self.layout.spacing() * 3) self.files_to_open = [] self.gather_data(autosave_mapping) self.add_label() self.add_table() self.add_cancel_button() self.setWindowTitle(_('Recover from autosave')) self.setFixedSize(670, 400) self.setWindowFlags( Qt.Dialog | Qt.MSWindowsFixedSizeDialogHint | Qt.WindowStaysOnTopHint) # This is needed because of an error in MacOS. # See https://bugreports.qt.io/browse/QTBUG-49576 if parent and hasattr(parent, 'splash'): self.splash = parent.splash self.splash.hide() else: self.splash = None def accept(self): """Reimplement Qt method.""" if self.splash is not None: self.splash.show() super(RecoveryDialog, self).accept() def reject(self): """Reimplement Qt method.""" if self.splash is not None: self.splash.show() super(RecoveryDialog, self).reject() def gather_file_data(self, name): """ Gather data about a given file. Returns a dict with fields 'name', 'mtime' and 'size', containing the relevant data for the file. If the file does not exists, then the dict contains only the field `name`. """ res = {'name': name} try: res['mtime'] = osp.getmtime(name) res['size'] = osp.getsize(name) except OSError: pass return res def gather_data(self, autosave_mapping): """ Gather data about files which may be recovered. The data is stored in self.data as a list of tuples with the data pertaining to the original file and the autosave file. Each element of the tuple is a dict as returned by gather_file_data(). Autosave files which do not exist, are ignored. """ self.data = [] for orig, autosave in autosave_mapping: if orig: orig_dict = self.gather_file_data(orig) else: orig_dict = None autosave_dict = self.gather_file_data(autosave) if 'mtime' not in autosave_dict: # autosave file does not exist continue self.data.append((orig_dict, autosave_dict)) self.data.sort(key=self.recovery_data_key_function) self.num_enabled = len(self.data) def recovery_data_key_function(self, item): """ Convert item in `RecoveryDialog.data` to tuple so that it can be sorted. Sorting the tuples returned by this function will sort first by name of the original file, then by name of the autosave file. All items without an original file name will be at the end. """ orig_dict, autosave_dict = item if orig_dict: return (0, orig_dict['name'], autosave_dict['name']) else: return (1, 0, autosave_dict['name']) def add_label(self): """Add label with explanation at top of dialog window.""" txt = _('Autosave files found. What would you like to do?\n\n' 'This dialog will be shown again on next startup if any ' 'autosave files are not restored, moved or deleted.') label = QLabel(txt, self) label.setWordWrap(True) self.layout.addWidget(label) def add_label_to_table(self, row, col, txt): """Add a label to specified cell in table.""" label = QLabel(txt) label.setMargin(5) label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.table.setCellWidget(row, col, label) def add_table(self): """Add table with info about files to be recovered.""" table = QTableWidget(len(self.data), 3, self) self.table = table labels = [_('Original file'), _('Autosave file'), _('Actions')] table.setHorizontalHeaderLabels(labels) table.verticalHeader().hide() table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) table.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) table.setSelectionMode(QTableWidget.NoSelection) # Show horizontal grid lines table.setShowGrid(False) table.setStyleSheet('::item { border-bottom: 1px solid gray }') for idx, (original, autosave) in enumerate(self.data): self.add_label_to_table(idx, 0, self.file_data_to_str(original)) self.add_label_to_table(idx, 1, self.file_data_to_str(autosave)) widget = QWidget() layout = QHBoxLayout() tooltip = _('Recover the autosave file to its original location, ' 'replacing the original if it exists.') button = QPushButton(_('Restore')) button.setToolTip(tooltip) button.clicked.connect( lambda checked, my_idx=idx: self.restore(my_idx)) layout.addWidget(button) tooltip = _('Delete the autosave file.') button = QPushButton(_('Discard')) button.setToolTip(tooltip) button.clicked.connect( lambda checked, my_idx=idx: self.discard(my_idx)) layout.addWidget(button) tooltip = _('Display the autosave file (and the original, if it ' 'exists) in Spyder\'s Editor. You will have to move ' 'or delete it manually.') button = QPushButton(_('Open')) button.setToolTip(tooltip) button.clicked.connect( lambda checked, my_idx=idx: self.open_files(my_idx)) layout.addWidget(button) widget.setLayout(layout) self.table.setCellWidget(idx, 2, widget) table.resizeRowsToContents() table.resizeColumnsToContents() self.layout.addWidget(table) def file_data_to_str(self, data): """ Convert file data to a string for display. This function takes the file data produced by gather_file_data(). """ if not data: return _('File name not recorded') res = data['name'] try: mtime_as_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(data['mtime'])) res += '
{}: {}'.format(_('Last modified'), mtime_as_str) res += u'
{}: {} {}'.format( _('Size'), data['size'], _('bytes')) except KeyError: res += '
' + _('File no longer exists') return res def add_cancel_button(self): """Add a cancel button at the bottom of the dialog window.""" button_box = QDialogButtonBox(QDialogButtonBox.Cancel, self) button_box.rejected.connect(self.reject) self.layout.addWidget(button_box) def center(self): """Center the dialog.""" screen = QApplication.desktop().screenGeometry(0) x = int(screen.center().x() - self.width() / 2) y = int(screen.center().y() - self.height() / 2) self.move(x, y) def restore(self, idx): orig, autosave = self.data[idx] if orig: orig_name = orig['name'] else: orig_name, ignored = getsavefilename( self, _('Restore autosave file to ...'), osp.basename(autosave['name'])) if not orig_name: return try: try: os.replace(autosave['name'], orig_name) except (AttributeError, OSError): # os.replace() does not exist on Python 2 and fails if the # files are on different file systems. # See spyder-ide/spyder#8631. shutil.copy2(autosave['name'], orig_name) os.remove(autosave['name']) self.deactivate(idx) except EnvironmentError as error: text = (_('Unable to restore {} using {}') .format(orig_name, autosave['name'])) self.report_error(text, error) def discard(self, idx): ignored, autosave = self.data[idx] try: os.remove(autosave['name']) self.deactivate(idx) except EnvironmentError as error: text = _('Unable to discard {}').format(autosave['name']) self.report_error(text, error) def open_files(self, idx): orig, autosave = self.data[idx] if orig: self.files_to_open.append(orig['name']) self.files_to_open.append(autosave['name']) self.deactivate(idx) def report_error(self, text, error): heading = _('Error message:') msgbox = QMessageBox( QMessageBox.Critical, _('Restore'), _('{}

{}
{}').format(text, heading, error), parent=self) msgbox.exec_() def deactivate(self, idx): for col in range(self.table.columnCount()): self.table.cellWidget(idx, col).setEnabled(False) self.num_enabled -= 1 if self.num_enabled == 0: self.accept() def exec_if_nonempty(self): """Execute dialog window if there is data to show.""" if self.data: self.center() return self.exec_() else: return QDialog.Accepted def exec_(self): """Execute dialog window.""" if running_under_pytest(): return QDialog.Accepted return super(RecoveryDialog, self).exec_() def make_temporary_files(tempdir): """ Make temporary files to simulate a recovery use case. Create a directory under tempdir containing some original files and another directory with autosave files. Return a tuple with the name of the directory with the original files, the name of the directory with the autosave files, and the autosave mapping. """ orig_dir = osp.join(tempdir, 'orig') os.mkdir(orig_dir) autosave_dir = osp.join(tempdir, 'autosave') os.mkdir(autosave_dir) autosave_mapping = {} # ham.py: Both original and autosave files exist, mentioned in mapping orig_file = osp.join(orig_dir, 'ham.py') with open(orig_file, 'w') as f: f.write('ham = "original"\n') autosave_file = osp.join(autosave_dir, 'ham.py') with open(autosave_file, 'w') as f: f.write('ham = "autosave"\n') autosave_mapping = [(orig_file, autosave_file)] # spam.py: Only autosave file exists, mentioned in mapping orig_file = osp.join(orig_dir, 'spam.py') autosave_file = osp.join(autosave_dir, 'spam.py') with open(autosave_file, 'w') as f: f.write('spam = "autosave"\n') autosave_mapping += [(orig_file, autosave_file)] # eggs.py: Only original files exists, mentioned in mapping orig_file = osp.join(orig_dir, 'eggs.py') with open(orig_file, 'w') as f: f.write('eggs = "original"\n') autosave_file = osp.join(autosave_dir, 'eggs.py') autosave_mapping += [(orig_file, autosave_file)] # cheese.py: Only autosave file exists autosave_file = osp.join(autosave_dir, 'cheese.py') with open(autosave_file, 'w') as f: f.write('cheese = "autosave"\n') autosave_mapping += [(None, autosave_file)] return orig_dir, autosave_dir, autosave_mapping def test(): # pragma: no cover """Display recovery dialog for manual testing.""" import shutil import tempfile from spyder.utils.qthelpers import qapplication app = qapplication() tempdir = tempfile.mkdtemp() unused, unused, autosave_mapping = make_temporary_files(tempdir) dialog = RecoveryDialog(autosave_mapping) dialog.exec_() print('files_to_open =', dialog.files_to_open) # spyder: test-skip shutil.rmtree(tempdir) if __name__ == "__main__": # pragma: no cover test()