diff --git a/core/app.py b/core/app.py index 0a9301a2..53627c8c 100644 --- a/core/app.py +++ b/core/app.py @@ -26,11 +26,13 @@ from .pe.photo import get_delta_dimensions from .util import cmp_value, fix_surrogate_encoding from . import directories, results, export, fs, prioritize from .ignore import IgnoreList +from .exclude import ExcludeList from .scanner import ScanType from .gui.deletion_options import DeletionOptions from .gui.details_panel import DetailsPanel from .gui.directory_tree import DirectoryTree from .gui.ignore_list_dialog import IgnoreListDialog +from .gui.exclude_list_dialog import ExcludeListDialogCore from .gui.problem_dialog import ProblemDialog from .gui.stats_label import StatsLabel @@ -140,6 +142,7 @@ class DupeGuru(Broadcaster): self.directories = directories.Directories() self.results = results.Results(self) self.ignore_list = IgnoreList() + self.exclude_list = ExcludeList(self) # In addition to "app-level" options, this dictionary also holds options that will be # sent to the scanner. They don't have default values because those defaults values are # defined in the scanner class. @@ -155,6 +158,7 @@ class DupeGuru(Broadcaster): self.directory_tree = DirectoryTree(self) self.problem_dialog = ProblemDialog(self) self.ignore_list_dialog = IgnoreListDialog(self) + self.exclude_list_dialog = ExcludeListDialogCore(self) self.stats_label = StatsLabel(self) self.result_table = None self.deletion_options = DeletionOptions() @@ -587,6 +591,9 @@ class DupeGuru(Broadcaster): p = op.join(self.appdata, "ignore_list.xml") self.ignore_list.load_from_xml(p) self.ignore_list_dialog.refresh() + p = op.join(self.appdata, "exclude_list.xml") + self.exclude_list.load_from_xml(p) + self.exclude_list_dialog.refresh() def load_from(self, filename): """Start an async job to load results from ``filename``. @@ -773,6 +780,8 @@ class DupeGuru(Broadcaster): self.directories.save_to_file(op.join(self.appdata, "last_directories.xml")) p = op.join(self.appdata, "ignore_list.xml") self.ignore_list.save_to_xml(p) + p = op.join(self.appdata, "exclude_list.xml") + self.exclude_list.save_to_xml(p) self.notify("save_session") def save_as(self, filename): diff --git a/core/exclude.py b/core/exclude.py new file mode 100644 index 00000000..03b2dc31 --- /dev/null +++ b/core/exclude.py @@ -0,0 +1,97 @@ +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +from .markable import Markable +from xml.etree import ElementTree as ET +from hscommon.util import FileOrPath + + +class ExcludeList(Markable): + """Exclude list of regular expression strings to filter out directories + and files that we want to avoid scanning.""" + + # ---Override + def __init__(self, app): + Markable.__init__(self) + self.app = app + self._excluded = [] # set of strings + self._count = 0 + + def __iter__(self): + for regex in self._excluded: + yield self.is_marked(regex), regex + + def __len__(self): + return self._count + + def _is_markable(self, row): + return True + + # ---Public + def add(self, regex): + self._excluded.insert(0, regex) + self._count = len(self._excluded) + + def isExcluded(self, regex): + if regex in self._excluded: + return True + return False + + def clear(self): + self._excluded = [] + self._count = 0 + + def remove(self, regex): + return self._excluded.remove(regex) + + def rename(self, regex, newregex): + if regex not in self._excluded: + return + marked = self.is_marked(regex) + index = self._excluded.index(regex) + self._excluded[index] = newregex + if marked: + # Not marked by default when added + self.mark(self._excluded[index]) + + def change_index(self, regex, new_index): + item = self._excluded.pop(regex) + self._excluded.insert(new_index, item) + + def load_from_xml(self, infile): + """Loads the ignore list from a XML created with save_to_xml. + + infile can be a file object or a filename. + """ + try: + root = ET.parse(infile).getroot() + except Exception as e: + print(f"Error while loading {infile}: {e}") + return + marked = set() + exclude_elems = (e for e in root if e.tag == "exclude") + for exclude_item in exclude_elems: + regex_string = exclude_item.get("regex") + if not regex_string: + continue + self.add(regex_string) + if exclude_item.get("marked") == "y": + marked.add(regex_string) + for item in marked: + # this adds item to the Markable "marked" set + self.mark(item) + + def save_to_xml(self, outfile): + """Create a XML file that can be used by load_from_xml. + + outfile can be a file object or a filename. + """ + root = ET.Element("exclude_list") + for regex in self._excluded: + exclude_node = ET.SubElement(root, "exclude") + exclude_node.set("regex", str(regex)) + exclude_node.set("marked", ("y" if self.is_marked(regex) else "n")) + tree = ET.ElementTree(root) + with FileOrPath(outfile, "wb") as fp: + tree.write(fp, encoding="utf-8") diff --git a/core/gui/exclude_list_dialog.py b/core/gui/exclude_list_dialog.py new file mode 100644 index 00000000..1d258033 --- /dev/null +++ b/core/gui/exclude_list_dialog.py @@ -0,0 +1,64 @@ +# Created On: 2012/03/13 +# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +# from hscommon.trans import tr +from .exclude_list_table import ExcludeListTable + +default_regexes = [".*thumbs", "\.DS.Store", "\.Trash", "Trash-Bin"] + + +class ExcludeListDialogCore: + # --- View interface + # show() + # + + def __init__(self, app): + self.app = app + self.exclude_list = self.app.exclude_list # Markable from exclude.py + self.exclude_list_table = ExcludeListTable(self, app) # GUITable, this is the "model" + + def restore_defaults(self): + for _, regex in self.exclude_list: + if regex not in default_regexes: + self.exclude_list.unmark(regex) + for default_regex in default_regexes: + if not self.exclude_list.isExcluded(default_regex): + self.exclude_list.add(default_regex) + self.exclude_list.mark(default_regex) + self.refresh() + + def refresh(self): + self.exclude_list_table.refresh() + + def remove_selected(self): + for row in self.exclude_list_table.selected_rows: + self.exclude_list_table.remove(row) + self.exclude_list.remove(row.regex) + self.refresh() + + def rename_selected(self, newregex): + """Renames the selected regex to ``newregex``. + If there's more than one selected row, the first one is used. + :param str newregex: The regex to rename the row's regex to. + """ + try: + r = self.exclude_list_table.selected_rows[0] + self.exclude_list.rename(r.regex, newregex) + self.refresh() + return True + except Exception as e: + print(f"dupeGuru Warning: {e}") + return False + + def add(self, regex): + self.exclude_list.add(regex) + self.exclude_list.mark(regex) + # TODO make checks here before adding to GUI + self.exclude_list_table.add(regex) + + def show(self): + self.view.show() diff --git a/core/gui/exclude_list_table.py b/core/gui/exclude_list_table.py new file mode 100644 index 00000000..0d495a86 --- /dev/null +++ b/core/gui/exclude_list_table.py @@ -0,0 +1,117 @@ +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +from .base import DupeGuruGUIObject +from hscommon.gui.table import GUITable, Row +from hscommon.gui.column import Column, Columns +from hscommon.trans import trget +tr = trget("ui") + + +class ExcludeListTable(GUITable, DupeGuruGUIObject): + COLUMNS = [ + Column("marked", ""), + Column("regex", tr("Regex")) + ] + + def __init__(self, exclude_list_dialog, app): + GUITable.__init__(self) + DupeGuruGUIObject.__init__(self, app) + # self.columns = Columns(self, prefaccess=app, savename="ExcludeTable") + self.columns = Columns(self) + self.dialog = exclude_list_dialog + + def rename_selected(self, newname): + row = self.selected_row + if row is None: + # There's all kinds of way the current row can be swept off during rename. When it + # happens, selected_row will be None. + return False + row._data = None + return self.dialog.rename_selected(newname) + + # --- Virtual + def _do_add(self, regex): + """(Virtual) Creates a new row, adds it in the table. + Returns ``(row, insert_index)``. + """ + # Return index 0 to insert at the top + return ExcludeListRow(self, self.dialog.exclude_list.is_marked(regex), regex), 0 + + def _do_delete(self): + self.dalog.exclude_list.remove(self.selected_row.regex) + + # --- Override + def add(self, regex): + row, insert_index = self._do_add(regex) + self.insert(insert_index, row) + # self.select([insert_index]) + self.view.refresh() + + def _fill(self): + for enabled, regex in self.dialog.exclude_list: + self.append(ExcludeListRow(self, enabled, regex)) + + # def remove(self): + # super().remove(super().selected_rows) + + # def _update_selection(self): + # # rows = self.selected_rows + # # self.dialog._select_rows(list(map(attrgetter("_dupe"), rows))) + # self.dialog.remove_selected() + + def refresh(self, refresh_view=True): + """Override to avoid keeping previous selection in case of multiple rows + selected previously.""" + self.cancel_edits() + del self[:] + self._fill() + # sd = self._sort_descriptor + # if sd is not None: + # super().sort_by(self, column_name=sd.column, desc=sd.desc) + if refresh_view: + self.view.refresh() + + +class ExcludeListRow(Row): + def __init__(self, table, enabled, regex): + Row.__init__(self, table) + self._app = table.app + self._data = None + self.enabled_original = enabled + self.regex_original = regex + self.enabled = str(enabled) + self.regex = str(regex) + + @property + def data(self): + def get_display_info(row): + return {"marked": row.enabled, "regex": row.regex} + + if self._data is None: + self._data = get_display_info(self) + return self._data + + @property + def markable(self): + return True + + @property + def marked(self): + return self._app.exclude_list.is_marked(self.regex) + + @marked.setter + def marked(self, value): + if value: + self._app.exclude_list.mark(self.regex) + else: + self._app.exclude_list.unmark(self.regex) + + # @property + # def regex(self): + # return self.regex + + # @regex.setter + # def regex(self, value): + # self._app.exclude_list.add(self._regex, value) \ No newline at end of file diff --git a/core/gui/ignore_list_dialog.py b/core/gui/ignore_list_dialog.py index 1590c837..cd3a0996 100644 --- a/core/gui/ignore_list_dialog.py +++ b/core/gui/ignore_list_dialog.py @@ -17,7 +17,7 @@ class IgnoreListDialog: def __init__(self, app): self.app = app self.ignore_list = self.app.ignore_list - self.ignore_list_table = IgnoreListTable(self) + self.ignore_list_table = IgnoreListTable(self) # GUITable def clear(self): if not self.ignore_list: diff --git a/qt/app.py b/qt/app.py index 4ed70b54..2c14b6a2 100644 --- a/qt/app.py +++ b/qt/app.py @@ -27,6 +27,7 @@ from .result_window import ResultWindow from .directories_dialog import DirectoriesDialog from .problem_dialog import ProblemDialog from .ignore_list_dialog import IgnoreListDialog +from .exclude_list_dialog import ExcludeListDialog from .deletion_options import DeletionOptions from .se.details_dialog import DetailsDialog as DetailsDialogStandard from .me.details_dialog import DetailsDialog as DetailsDialogMusic @@ -87,10 +88,16 @@ class DupeGuru(QObject): parent=self.main_window, model=self.model.ignore_list_dialog) self.ignoreListDialog.accepted.connect(self.main_window.onDialogAccepted) + + self.excludeListDialog = self.main_window.createPage( + "ExcludeListDialog", + app=self, + parent=self.main_window, + model=self.model.exclude_list_dialog) else: self.ignoreListDialog = IgnoreListDialog( - parent=parent_window, model=self.model.ignore_list_dialog - ) + parent=parent_window, model=self.model.ignore_list_dialog) + self.excludeDialog = ExcludeListDialog(parent=parent_window) self.deletionOptions = DeletionOptions( parent=parent_window, @@ -130,6 +137,7 @@ class DupeGuru(QObject): tr("Clear Picture Cache"), self.clearPictureCacheTriggered, ), + ("actionExcludeList", "", "", tr("Exclude list"), self.excludeListTriggered), ("actionShowHelp", "F1", "", tr("dupeGuru Help"), self.showHelpTriggered), ("actionAbout", "", "", tr("About dupeGuru"), self.showAboutBoxTriggered), ( @@ -276,10 +284,20 @@ class DupeGuru(QObject): # if not self.main_window.tabWidget.isTabVisible(index): self.main_window.setTabVisible(index, True) self.main_window.setCurrentIndex(index) - return else: self.model.ignore_list_dialog.show() + def excludeListTriggered(self): + if self.main_window: + index = self.main_window.indexOfWidget(self.excludeListDialog) + if index < 0: + index = self.main_window.addTab( + self.excludeListDialog, "Exclude List", switch=True) + self.main_window.setTabVisible(index, True) + self.main_window.setCurrentIndex(index) + else: + self.excludeListDialog.show() + def openDebugLogTriggered(self): debugLogPath = op.join(self.model.appdata, "debug.log") desktop.open_path(debugLogPath) diff --git a/qt/directories_dialog.py b/qt/directories_dialog.py index 45e53be6..82f2896d 100644 --- a/qt/directories_dialog.py +++ b/qt/directories_dialog.py @@ -132,6 +132,7 @@ class DirectoriesDialog(QMainWindow): self.menuView.addAction(self.app.actionDirectoriesWindow) self.menuView.addAction(self.actionShowResultsWindow) self.menuView.addAction(self.app.actionIgnoreList) + self.menuView.addAction(self.app.actionExcludeList) self.menuView.addSeparator() self.menuView.addAction(self.app.actionPreferences) diff --git a/qt/exclude_list_dialog.py b/qt/exclude_list_dialog.py new file mode 100644 index 00000000..3c8a1872 --- /dev/null +++ b/qt/exclude_list_dialog.py @@ -0,0 +1,83 @@ +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +from PyQt5.QtCore import Qt, pyqtSlot +from PyQt5.QtWidgets import ( + QPushButton, QLineEdit, QVBoxLayout, QGridLayout, QDialog, + QTableView, QAbstractItemView, QSpacerItem, QSizePolicy, QHeaderView +) +from .exclude_list_table import ExcludeListTable, ExcludeView + +from hscommon.trans import trget +tr = trget("ui") + + +class ExcludeListDialog(QDialog): + def __init__(self, app, parent, model, **kwargs): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + super().__init__(parent, flags, **kwargs) + self.specific_actions = frozenset() + self._setupUI() + self.model = model # ExcludeListDialogCore + self.model.view = self + self.table = ExcludeListTable(app, view=self.tableView) + + self.buttonAdd.clicked.connect(self.addItem) + self.buttonRemove.clicked.connect(self.removeItem) + self.buttonRestore.clicked.connect(self.restoreDefaults) + self.buttonClose.clicked.connect(self.accept) + + def _setupUI(self): + layout = QVBoxLayout(self) + gridlayout = QGridLayout() + self.buttonAdd = QPushButton(tr("Add")) + self.buttonRemove = QPushButton(tr("Remove Selected")) + self.buttonRestore = QPushButton(tr("Restore defaults")) + self.buttonClose = QPushButton(tr("Close")) + self.linedit = QLineEdit() + self.tableView = ExcludeView() + triggers = ( + QAbstractItemView.DoubleClicked + | QAbstractItemView.EditKeyPressed + | QAbstractItemView.SelectedClicked + ) + self.tableView.setEditTriggers(triggers) + self.tableView.horizontalHeader().setVisible(True) + self.tableView.setSelectionMode(QTableView.ExtendedSelection) + self.tableView.setSelectionBehavior(QTableView.SelectRows) + # vheader = self.tableView.verticalHeader() + # vheader.setSectionsMovable(True) + # vheader.setVisible(True) + # vheader.setDefaultSectionSize(50) + hheader = self.tableView.horizontalHeader() + hheader.setSectionsMovable(False) + hheader.setSectionResizeMode(QHeaderView.Fixed) + hheader.setStretchLastSection(True) + hheader.setHighlightSections(False) + gridlayout.addWidget(self.linedit, 0, 0) + gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignLeft) + gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignLeft) + gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignLeft) + gridlayout.addWidget(self.tableView, 1, 0, 4, 1) + gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 3, 1) + gridlayout.addWidget(self.buttonClose, 4, 1) + layout.addLayout(gridlayout) + + # --- model --> view + def show(self): + super().show() + + @pyqtSlot() + def addItem(self): + text = self.linedit.text() + if not text: + return + self.model.add(text) + self.linedit.clear() + + def removeItem(self): + self.model.remove_selected() + + def restoreDefaults(self): + self.model.restore_defaults() diff --git a/qt/exclude_list_table.py b/qt/exclude_list_table.py new file mode 100644 index 00000000..d6f57003 --- /dev/null +++ b/qt/exclude_list_table.py @@ -0,0 +1,84 @@ +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +from PyQt5.QtCore import Qt, QModelIndex, pyqtSignal +from PyQt5.QtGui import QBrush, QFont, QFontMetrics, QColor +from PyQt5.QtWidgets import QTableView + +from qtlib.column import Column +from qtlib.table import Table + + +class ExcludeListTable(Table): + """Model for exclude list""" + COLUMNS = [ + Column("marked", defaultWidth=15), + Column("regex", defaultWidth=230) + ] + + def __init__(self, app, view, **kwargs): + model = app.model.exclude_list_dialog.exclude_list_table # pointer to GUITable + super().__init__(model, view, **kwargs) + view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) + font = view.font() + font.setPointSize(app.prefs.tableFontSize) + view.setFont(font) + fm = QFontMetrics(font) + view.verticalHeader().setDefaultSectionSize(fm.height() + 2) + app.willSavePrefs.connect(self.appWillSavePrefs) + + def _getData(self, row, column, role): + if column.name == "marked": + if role == Qt.CheckStateRole and row.markable: + return Qt.Checked if row.marked else Qt.Unchecked + return None + if role == Qt.DisplayRole: + return row.data[column.name] + elif role == Qt.FontRole: + return QFont(self.view.font()) + elif role == Qt.EditRole: + if column.name == "regex": + return row.data[column.name] + return None + + def _getFlags(self, row, column): + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if column.name == "marked": + if row.markable: + flags |= Qt.ItemIsUserCheckable + elif column.name == "regex": + flags |= Qt.ItemIsEditable + return flags + + def _setData(self, row, column, value, role): + if role == Qt.CheckStateRole: + if column.name == "marked": + row.marked = bool(value) + return True + elif role == Qt.EditRole: + if column.name == "regex": + return self.model.rename_selected(value) + return False + + def sort(self, column, order): + column = self.model.COLUMNS[column] + self.model.sort(column.name, order == Qt.AscendingOrder) + + # --- Events + def appWillSavePrefs(self): + self.model.columns.save_columns() + + # --- model --> view + def invalidate_markings(self): + # redraw view + # HACK. this is the only way I found to update the widget without reseting everything + self.view.scroll(0, 1) + self.view.scroll(0, -1) + + +class ExcludeView(QTableView): + def mouseDoubleClickEvent(self, event): + # FIXME this doesn't seem to do anything relevant + self.doubleClicked.emit(QModelIndex()) + # We don't call the superclass' method because the default behavior is to rename the cell. diff --git a/qt/ignore_list_table.py b/qt/ignore_list_table.py index 96bc51ec..46a9c175 100644 --- a/qt/ignore_list_table.py +++ b/qt/ignore_list_table.py @@ -10,6 +10,8 @@ from qtlib.table import Table class IgnoreListTable(Table): + """ Ignore list model""" + COLUMNS = [ Column("path1", defaultWidth=230), Column("path2", defaultWidth=230), diff --git a/qt/tabbed_window.py b/qt/tabbed_window.py index f7fc13d7..36a92e1a 100644 --- a/qt/tabbed_window.py +++ b/qt/tabbed_window.py @@ -18,6 +18,7 @@ from qtlib.util import moveToScreenCenter, createActions from .directories_dialog import DirectoriesDialog from .result_window import ResultWindow from .ignore_list_dialog import IgnoreListDialog +from .exclude_list_dialog import ExcludeListDialog tr = trget("ui") @@ -157,6 +158,11 @@ class TabWindow(QMainWindow): parent = kwargs.get("parent", self) model = kwargs.get("model") page = IgnoreListDialog(parent, model) + elif cls == "ExcludeListDialog": + app = kwargs.get("app", app) + parent = kwargs.get("parent", self) + model = kwargs.get("model") + page = ExcludeListDialog(app, parent, model) self.pages[cls] = page return page