Source code for pbpicat.ui.main_window

import platform
import re
import sys
from pathlib import Path

from PySide6.QtCore import QCoreApplication, Qt
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
    QAbstractItemView,
    QCheckBox,
    QComboBox,
    QDialog,
    QFileDialog,
    QHBoxLayout,
    QInputDialog,
    QLabel,
    QLineEdit,
    QListWidget,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QStatusBar,
    QTextBrowser,
    QVBoxLayout,
    QWidget,
)

from pbpicat.config import (
    app_qsettings,
    create_catalog,
    current_catalog,
    delete_catalog,
    duplicate_catalog,
    list_catalogs,
    load_config,
    load_global_config,
    load_history,
    load_last_dest,
    load_last_source_dir,
    qsettings,
    save_history,
    save_last_dest,
    save_last_source_dir,
    set_current_catalog,
)
from pbpicat.renamer import (
    build_rename_plan,
    build_renumber_plan,
    execute_rename,
    execute_renumber,
    undo_rename,
    undo_renumber,
)

from .file_panel import FilePanel
from .history_dialog import HistoryDialog
from .icons import get_icon
from .schema_frame import SchemaFrame
from .settings_dialog import GlobalSettingsDialog, SettingsDialog


class _ShortcutsDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle(_("Shortcuts"))
        self.setMinimumSize(520, 440)
        root = QVBoxLayout(self)
        root.setSpacing(8)
        browser = QTextBrowser()
        browser.setReadOnly(True)
        browser.setOpenExternalLinks(False)
        browser.setHtml(self._build_html())
        root.addWidget(browser)
        close_btn = QPushButton(_("Close"))
        close_btn.clicked.connect(self.close)
        root.addWidget(close_btn, alignment=Qt.AlignRight)
        geom = app_qsettings().value("shortcuts_dialog/geometry")
        if geom:
            self.restoreGeometry(geom)

    def done(self, result) -> None:  # noqa: N802
        app_qsettings().setValue("shortcuts_dialog/geometry", self.saveGeometry())
        super().done(result)

    @staticmethod
    def _build_html() -> str:
        def row(key: str, desc: str) -> str:
            return f"<tr><td>{key}</td><td>{desc}</td></tr>"

        style = (
            "<style>"
            "body{font-family:sans-serif;margin:0;padding:8px;}"
            "h2{color:#2c5f9e;font-size:12pt;margin:14px 0 4px 0;"
            "border-bottom:1px solid #aac;padding-bottom:3px;}"
            "h3{color:#4a7ab5;font-size:10pt;margin:10px 0 2px 0;}"
            "table{border-collapse:collapse;width:100%;margin-bottom:6px;}"
            "td{padding:4px 10px;vertical-align:top;}"
            "td:first-child{font-family:monospace;font-weight:bold;color:#444;"
            "white-space:nowrap;width:210px;}"
            "tr:nth-child(even){background:#f0f4f8;}"
            "</style>"
        )

        title_main = _("Main window")
        title_viewer = _("Image viewer")
        title_keyboard = _("Keyboard")
        title_mouse = _("Mouse")

        def key(seq) -> str:
            return QKeySequence(seq).toString(QKeySequence.SequenceFormat.NativeText)

        key_del = key(Qt.Key.Key_Delete)
        key_esc = key(Qt.Key.Key_Escape)
        file_list = _("file list")
        tree_leaf = _("tree leaf")

        ctrl = key(Qt.Modifier.CTRL | Qt.Key.Key_A)[:-1]

        main_rows = "".join(
            [
                row("F5", _("Refresh")),
                row(key(QKeySequence.StandardKey.Undo), _("Undo rename")),
                row(key(QKeySequence.StandardKey.Open), _("Open selected file(s)")),
                row(key(Qt.Modifier.CTRL | Qt.Modifier.SHIFT | Qt.Key.Key_O), _("Open with…")),
                row(key(Qt.Modifier.CTRL | Qt.Key.Key_N), _("New catalog")),
                row(key(Qt.Modifier.CTRL | Qt.Modifier.SHIFT | Qt.Key.Key_D), _("Duplicate catalog")),
                row(key(Qt.Modifier.CTRL | Qt.Key.Key_Comma), _("Open catalog settings")),
                row(key(Qt.Modifier.CTRL | Qt.Modifier.ALT | Qt.Key.Key_Comma), _("Open program settings")),
                row(key_del, _("Delete selected file(s) and their sidecars")),
                row("F6", _("Rotate 90° CCW")),
                row("F7", _("Rotate 180°")),
                row("F8", _("Rotate 90° CW")),
                row("F9", _("Apply EXIF orientation")),
                row("F10", _("Force EXIF orientation to 0°")),
                row(f"← / → ({file_list})", _("Move focus to the directory tree")),
                row(f"→ ({tree_leaf})", _("Move focus to the file list")),
            ]
        )

        viewer_kb_rows = "".join(
            [
                row("0 / X", _("Fit window")),
                row("1 / Z", _("Actual size (1:1)")),
                row("W", _("Fit width")),
                row("H", _("Fit height")),
                row("+ / −", _("Zoom in / Zoom out")),
                row("↑ / ↓", _("Navigate to previous / next image")),
                row(key_del, _("Delete current image and its sidecars")),
                row(key_esc, _("Close image viewer")),
            ]
        )

        viewer_mouse_rows = "".join(
            [
                row(_("Left-click + drag"), _("Pan image")),
                row(_("Double left-click"), _("Center on clicked point")),
                row(f"{ctrl}{_('left-click')}", _("Zoom in centered on point")),
                row(f"{ctrl}{_('right-click')}", _("Zoom out centered on point")),
            ]
        )

        return (
            f"<html><head>{style}</head><body>"
            f"<h2>{title_main}</h2>"
            f"<table>{main_rows}</table>"
            f"<h2>{title_viewer}</h2>"
            f"<h3>{title_keyboard}</h3>"
            f"<table>{viewer_kb_rows}</table>"
            f"<h3>{title_mouse}</h3>"
            f"<table>{viewer_mouse_rows}</table>"
            "</body></html>"
        )


class _OrphanSidecarsDialog(QDialog):
    def __init__(self, orphans: list, parent=None):
        super().__init__(parent)
        self.setWindowTitle(_("Orphan sidecars"))
        self.setMinimumWidth(420)
        self._orphans: list = list(orphans)
        self._setup_ui()
        geom = app_qsettings().value("orphan_sidecars_dialog/geometry")
        if geom:
            self.restoreGeometry(geom)

    def done(self, result) -> None:  # noqa: N802
        app_qsettings().setValue("orphan_sidecars_dialog/geometry", self.saveGeometry())
        super().done(result)

    def _setup_ui(self) -> None:
        root = QVBoxLayout(self)
        root.setSpacing(8)

        self._label = QLabel()
        root.addWidget(self._label)

        self._list = QListWidget()
        self._list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        root.addWidget(self._list)

        btn_layout = QHBoxLayout()

        self._del_sel_btn = QPushButton(_("Delete selected"))
        self._del_sel_btn.clicked.connect(self._delete_selected)
        btn_layout.addWidget(self._del_sel_btn)

        del_all_btn = QPushButton(_("Delete all"))
        del_all_btn.clicked.connect(self._delete_all)
        btn_layout.addWidget(del_all_btn)

        btn_layout.addStretch()

        close_btn = QPushButton(_("Close"))
        close_btn.clicked.connect(self.reject)
        btn_layout.addWidget(close_btn)

        root.addLayout(btn_layout)
        self._refresh_list()

    def refresh(self, orphans: list) -> None:
        self._orphans = list(orphans)
        self._refresh_list()

    def _refresh_list(self) -> None:
        self._label.setText(_("{n} orphan sidecar file(s):").format(n=len(self._orphans)))
        self._list.clear()
        for path in self._orphans:
            self._list.addItem(path.name)
        self._del_sel_btn.setEnabled(bool(self._orphans))

    def _delete_selected(self) -> None:
        rows = sorted({idx.row() for idx in self._list.selectedIndexes()})
        if not rows:
            return
        self._confirm_and_delete([self._orphans[r] for r in rows])

    def _delete_all(self) -> None:
        if not self._orphans:
            return
        self._confirm_and_delete(list(self._orphans))

    def _confirm_and_delete(self, files: list) -> None:
        names = "\n".join(f.name for f in files)
        reply = QMessageBox.question(
            self,
            _("Confirm deletion"),
            _("Permanently delete:\n{names}").format(names=names),
            QMessageBox.Yes | QMessageBox.No,
        )
        if reply != QMessageBox.Yes:
            return
        errors = []
        deleted = set()
        for f in files:
            try:
                f.unlink()
                deleted.add(f)
            except OSError as exc:
                errors.append(f"{f.name}: {exc}")
        self._orphans = [p for p in self._orphans if p not in deleted]
        self._refresh_list()
        if errors:
            QMessageBox.warning(self, _("Deletion error"), "\n".join(errors))
        if not self._orphans:
            self.accept()


[docs] class MainWindow(QMainWindow): def __init__(self): super().__init__() self._config = load_config() self._undo_stack: list[tuple[str, list]] = [] self._orphan_dlg: _OrphanSidecarsDialog | None = None self._shortcuts_dlg: _ShortcutsDialog | None = None self._setup_rotation_actions() self._setup_ui() self._setup_menu() self._update_title() geom = app_qsettings().value("main_window/geometry") if geom: self.restoreGeometry(geom) else: self.resize(1280, 820) def _setup_rotation_actions(self) -> None: self._act_rotate_ccw = QAction(_("Rotate 90° CCW"), self) self._act_rotate_ccw.setIcon(get_icon("object-rotate-left", text_fallback="↺")) self._act_rotate_ccw.setStatusTip(_("Rotate selected image(s) 90° counter-clockwise")) self._act_rotate_ccw.setShortcut(QKeySequence(Qt.Key.Key_F6)) self._act_rotate_ccw.setEnabled(False) self._act_rotate_ccw.triggered.connect(lambda: self._rotate_selected(-90)) self._act_rotate_cw = QAction(_("Rotate 90° CW"), self) self._act_rotate_cw.setIcon(get_icon("object-rotate-right", text_fallback="↻")) self._act_rotate_cw.setStatusTip(_("Rotate selected image(s) 90° clockwise")) self._act_rotate_cw.setShortcut(QKeySequence(Qt.Key.Key_F8)) self._act_rotate_cw.setEnabled(False) self._act_rotate_cw.triggered.connect(lambda: self._rotate_selected(90)) self._act_rotate_180 = QAction(_("Rotate 180°"), self) self._act_rotate_180.setIcon(get_icon("object-flip-vertical", text_fallback="↕")) self._act_rotate_180.setStatusTip(_("Rotate selected image(s) 180°")) self._act_rotate_180.setShortcut(QKeySequence(Qt.Key.Key_F7)) self._act_rotate_180.setEnabled(False) self._act_rotate_180.triggered.connect(lambda: self._rotate_selected(180)) self._act_rotate_auto = QAction(_("Apply EXIF orientation"), self) self._act_rotate_auto.setIcon(get_icon("auto-rotate", text_fallback="EXIF")) self._act_rotate_auto.setStatusTip(_("Apply and remove the EXIF orientation tag")) self._act_rotate_auto.setShortcut(QKeySequence(Qt.Key.Key_F9)) self._act_rotate_auto.setEnabled(False) self._act_rotate_auto.triggered.connect(lambda: self._rotate_selected("auto")) self._act_reset_exif = QAction(_("Force EXIF orientation to 0°"), self) self._act_reset_exif.setIcon(get_icon("reset-exif", "edit-clear", text_fallback="0°")) self._act_reset_exif.setStatusTip(_("Set the EXIF orientation tag to 1 (0°, normal) without rotating pixels")) self._act_reset_exif.setShortcut(QKeySequence(Qt.Key.Key_F10)) self._act_reset_exif.setEnabled(False) self._act_reset_exif.triggered.connect(self._reset_exif_selected) def _setup_menu(self) -> None: mb = self.menuBar() file_menu = mb.addMenu(_("&File")) act = file_menu.addAction(_("&Quit"), self.close) act.setShortcut(QKeySequence.StandardKey.Quit) act.setStatusTip(_("Quit the application")) act.setIcon(get_icon("quit", "application-exit")) self._catalog_menu = mb.addMenu(_("&Catalog")) act = self._catalog_menu.addAction(_("&New catalog…"), self._new_catalog) act.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_N)) act.setStatusTip(_("Create a new catalog")) act.setIcon(get_icon("folder-new", "folder-new")) act = self._catalog_menu.addAction(_("&Delete catalog…"), self._delete_catalog_action) act.setStatusTip(_("Delete a catalog")) act.setIcon(get_icon("delete", "edit-delete")) act = self._catalog_menu.addAction(_("D&uplicate catalog…"), self._duplicate_catalog_action) act.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Modifier.SHIFT | Qt.Key.Key_D)) act.setStatusTip(_("Duplicate the current catalog")) act.setIcon(get_icon("duplicate", "edit-copy")) self._catalog_menu.addSeparator() self._catalog_menu.aboutToShow.connect(self._populate_catalog_list) images_menu = mb.addMenu(_("&Images")) self._act_img_open = images_menu.addAction(_("&Open"), self._img_open) self._act_img_open.setShortcut(QKeySequence.StandardKey.Open) self._act_img_open.setStatusTip(_("Open selected file(s) with the default application")) self._act_img_open.setIcon(get_icon("open", "document-open")) self._act_img_open_with = images_menu.addAction(_("Open &with…"), self._img_open_with) self._act_img_open_with.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Modifier.SHIFT | Qt.Key.Key_O)) self._act_img_open_with.setStatusTip(_("Open selected file with a chosen application")) self._act_img_open_with.setIcon(get_icon("open-with", "document-open")) images_menu.addSeparator() self._act_img_template = images_menu.addAction(_("&Template"), self._img_template) self._act_img_template.setStatusTip(_("Infer rename template from the selected file name")) self._act_img_template.setIcon(get_icon("rename-template", "view-list-details")) images_menu.addSeparator() self._act_img_delete = images_menu.addAction(_("&Delete"), self._img_delete) self._act_img_delete.setShortcut(QKeySequence(Qt.Key.Key_Delete)) self._act_img_delete.setStatusTip(_("Permanently delete the selected file(s)")) self._act_img_delete.setIcon(get_icon("delete", "edit-delete")) for act in (self._act_img_open, self._act_img_open_with, self._act_img_template, self._act_img_delete): act.setEnabled(False) images_menu.addSeparator() images_menu.addAction(self._act_rotate_ccw) images_menu.addAction(self._act_rotate_cw) images_menu.addAction(self._act_rotate_180) images_menu.addAction(self._act_rotate_auto) images_menu.addAction(self._act_reset_exif) images_menu.addSeparator() act = images_menu.addAction(_("&Refresh"), self._refresh) act.setShortcut(QKeySequence(Qt.Key.Key_F5)) act.setStatusTip(_("Refresh")) act.setIcon(get_icon("view-refresh", "view-refresh")) settings_menu = mb.addMenu(_("&Settings")) act = settings_menu.addAction(_("&Catalog configuration…"), self._open_settings) act.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_Comma)) act.setStatusTip(_("Open catalog settings")) act.setIcon(get_icon("configure", "configure")) act = settings_menu.addAction(_("&History…"), self._open_history) act.setStatusTip(_("Edit field and filter history")) act.setIcon(get_icon("history", "document-open-recent")) settings_menu.addSeparator() act = settings_menu.addAction(_("&Program settings…"), self._open_global_settings) act.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Modifier.ALT | Qt.Key.Key_Comma)) act.setStatusTip(_("Open program settings")) act.setIcon(get_icon("preferences-system", "preferences-system")) help_menu = mb.addMenu(_("&Help")) act = help_menu.addAction(_("&Shortcuts…"), self._show_keyboard_shortcuts) act.setShortcut(QKeySequence(Qt.Key.Key_F1)) act.setStatusTip(_("Show shortcuts")) act.setIcon(get_icon("help-keyboard-shortcuts", "help-keyboard-shortcuts")) help_menu.addSeparator() act = help_menu.addAction(_("&About"), self._about) act.setStatusTip(_("About PBPicat")) act.setIcon(get_icon("help-about", "help-about")) self._file_panel.file_list.set_context_actions( self._act_img_open, self._act_img_open_with, self._act_img_template, self._act_img_delete, ) self._file_panel.file_list.set_rotation_actions( self._act_rotate_ccw, self._act_rotate_cw, self._act_rotate_180, self._act_rotate_auto, self._act_reset_exif, ) def _setup_ui(self) -> None: central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setSpacing(6) root.setContentsMargins(8, 8, 8, 8) root.addLayout(self._build_dest_zone()) self._schema_frame = SchemaFrame(self._config) root.addWidget(self._schema_frame) self._file_panel = FilePanel(self._config) root.addWidget(self._file_panel, stretch=1) root.addLayout(self._build_button_zone()) self._status = QStatusBar() self.setStatusBar(self._status) self._status.showMessage(_("Ready.")) self._file_panel.file_list.rename_requested.connect(self._rename_files) self._file_panel.file_list.schema_proposed.connect(self._apply_proposed_schema) self._file_panel.file_list.set_schema_getter(self._schema_frame.get_fields) self._file_panel.file_list.set_video_marker_pos_getter(self._schema_frame.get_video_marker_pos) self._file_panel.file_list.set_rotate_callback(self._rotate_images) self._file_panel.file_list.file_count_changed.connect(self._on_file_count_changed) self._file_panel.file_list.itemSelectionChanged.connect(self._update_rename_btn) self._file_panel.file_list.itemSelectionChanged.connect(self._update_image_actions) self._file_panel.file_list.orphan_sidecar_count_changed.connect(self._on_orphan_count_changed) self._file_panel.dir_tree.directory_selected.connect(self._on_directory_changed) self._file_panel.dir_tree.select_path(load_last_source_dir()) def _build_dest_zone(self) -> QHBoxLayout: layout = QHBoxLayout() layout.addWidget(QLabel(_("Destination:"))) self._dest_edit = QLineEdit() self._dest_edit.setPlaceholderText(_("Destination root directory (DEST_ROOTDIR)…")) self._dest_edit.setText(load_last_dest()) layout.addWidget(self._dest_edit, stretch=1) browse_btn = QPushButton(_("Browse…")) browse_btn.setFixedWidth(100) browse_btn.clicked.connect(self._browse_dest) layout.addWidget(browse_btn) self._rename_all_btn = QPushButton(_("Rename all")) self._rename_all_btn.setFixedWidth(130) self._rename_all_btn.clicked.connect(self._rename_all) self._rename_all_btn.setToolTip(_("Rename all files in the current directory according to the schema")) self._rename_all_btn.setEnabled(False) layout.addWidget(self._rename_all_btn) self._renumber_btn = QPushButton(_("Renumber from 1")) self._renumber_btn.setFixedWidth(140) self._renumber_btn.clicked.connect(self._renumber_all) self._renumber_btn.setToolTip(_("Renumber all files in the current directory starting from 1")) self._renumber_btn.setEnabled(False) layout.addWidget(self._renumber_btn) return layout def _build_button_zone(self) -> QHBoxLayout: layout = QHBoxLayout() self._undo_btn = QPushButton(_("Undo rename")) self._undo_btn.setToolTip(_("Undo last operation")) self._undo_btn.setShortcut(QKeySequence.StandardKey.Undo) self._undo_btn.clicked.connect(self._undo_last_rename) self._undo_btn.setEnabled(False) layout.addWidget(self._undo_btn) layout.addStretch() self._sort_by_date_chk = QCheckBox(_("Sort by date")) self._sort_by_date_chk.setToolTip(_("Sort files by modification date (unchecked: sort by name)")) self._sort_by_date_chk.setChecked(False) self._sort_by_date_chk.toggled.connect(self._on_sort_by_date_changed) layout.addWidget(self._sort_by_date_chk) self._sort_reverse_chk = QCheckBox(_("Reverse sort")) self._sort_reverse_chk.setToolTip(_("Reverse the sort order")) self._sort_reverse_chk.setChecked(False) self._sort_reverse_chk.toggled.connect(self._on_sort_reverse_changed) layout.addWidget(self._sort_reverse_chk) self._filter_edit = QComboBox() self._filter_edit.setEditable(True) self._filter_edit.setInsertPolicy(QComboBox.NoInsert) self._filter_edit.setFixedWidth(260) self._filter_edit.lineEdit().setPlaceholderText(_("Sidecar filter…")) self._filter_edit.setToolTip( _( "<b>Sidecar content filter</b><br>" "Python regular expression applied to the <i>content</i> of sidecar files.<br>" "Only files with at least one sidecar whose content matches are shown.<br>" "Files with no sidecar are always hidden when a filter is active.<br>" "<tt>.*</tt> matches across line breaks (re.DOTALL).<br>" "Case-insensitive (re.IGNORECASE), including accented characters.<br>" "Leave empty to show all files." ) ) for item in load_history("sidecar_filter"): self._filter_edit.addItem(item) self._filter_edit.setCurrentIndex(-1) self._filter_edit.currentTextChanged.connect(self._on_filter_changed) self._filter_edit.lineEdit().returnPressed.connect(self._save_filter_to_history) layout.addWidget(self._filter_edit) self._orphan_btn = QPushButton(_("Orphan sidecars")) self._orphan_btn.setToolTip(_("Show orphan sidecar files (no matching image or video)")) self._orphan_btn.clicked.connect(self._show_orphan_dialog) self._orphan_btn.setEnabled(False) layout.addWidget(self._orphan_btn) layout.addStretch() self._rename_btn = QPushButton(_("Rename selection")) self._rename_btn.setToolTip(_("Rename selected files according to the schema")) self._rename_btn.clicked.connect(self._rename_selected) self._rename_btn.setEnabled(False) layout.addWidget(self._rename_btn) return layout def _on_file_count_changed(self, n: int) -> None: self._rename_all_btn.setEnabled(n > 0) self._renumber_btn.setEnabled(n > 0) def _browse_dest(self) -> None: current = self._dest_edit.text().strip() or str(Path.home()) path = QFileDialog.getExistingDirectory(self, _("Choose destination directory"), current) if path: self._dest_edit.setText(path) save_last_dest(path) def _refresh(self) -> None: self._file_panel.refresh() def _update_rename_btn(self) -> None: self._rename_btn.setEnabled(bool(self._file_panel.file_list.get_selected_files())) def _update_image_actions(self) -> None: selected = self._file_panel.file_list.get_selected_files() n = len(selected) any_sel = n > 0 self._act_img_open.setEnabled(any_sel) self._act_img_open_with.setEnabled(any_sel) self._act_img_template.setEnabled(n == 1) self._act_img_delete.setEnabled(any_sel) image_exts = set(self._config.get("image_extensions", [])) img_sel = [p for p in selected if p.suffix.lower() in image_exts] n_img = len(img_sel) for act in (self._act_rotate_ccw, self._act_rotate_cw, self._act_rotate_180): act.setEnabled(n_img > 0) has_exif = any(self._selected_image_has_exif_orientation(p) for p in img_sel) self._act_rotate_auto.setEnabled(has_exif) self._act_reset_exif.setEnabled(has_exif) def _selected_image_has_exif_orientation(self, path) -> bool: from pbpicat.image_ops import get_exif_orientation return get_exif_orientation(path) is not None def _img_open(self) -> None: self._file_panel.file_list.open_selected() def _img_open_with(self) -> None: self._file_panel.file_list.open_with_selected() def _img_template(self) -> None: self._file_panel.file_list.template_selected() def _img_delete(self) -> None: self._file_panel.file_list.delete_selected() def _on_sort_by_date_changed(self, checked: bool) -> None: self._file_panel.file_list.set_sort_by_date(checked) def _on_sort_reverse_changed(self, checked: bool) -> None: self._file_panel.file_list.set_sort_reverse(checked) def _on_filter_changed(self, text: str) -> None: if text: try: re.compile(text, re.DOTALL | re.IGNORECASE) self._filter_edit.lineEdit().setStyleSheet("") except re.error: self._filter_edit.lineEdit().setStyleSheet("border: 1px solid red;") return else: self._filter_edit.lineEdit().setStyleSheet("") self._file_panel.file_list.set_sidecar_filter(text) def _on_orphan_count_changed(self, n: int) -> None: self._orphan_btn.setEnabled(n > 0) if self._orphan_dlg is not None and self._orphan_dlg.isVisible(): self._orphan_dlg.refresh(self._file_panel.file_list.get_orphan_sidecars()) def _show_orphan_dialog(self) -> None: orphans = self._file_panel.file_list.get_orphan_sidecars() self._orphan_dlg = _OrphanSidecarsDialog(orphans, self) self._orphan_dlg.exec() self._orphan_dlg = None def _save_filter_to_history(self) -> None: text = self._filter_edit.currentText().strip() if not text: return try: re.compile(text, re.DOTALL | re.IGNORECASE) except re.error: return items = [self._filter_edit.itemText(i) for i in range(self._filter_edit.count())] if text in items: items.remove(text) items.insert(0, text) items = items[: self._config["history_max"]] self._filter_edit.blockSignals(True) self._filter_edit.clear() for item in items: self._filter_edit.addItem(item) self._filter_edit.setCurrentText(text) self._filter_edit.blockSignals(False) save_history("sidecar_filter", items) def _reload_filter_history(self) -> None: current = self._filter_edit.currentText() self._filter_edit.blockSignals(True) self._filter_edit.clear() for item in load_history("sidecar_filter"): self._filter_edit.addItem(item) self._filter_edit.setCurrentText(current) self._filter_edit.blockSignals(False) def _on_directory_changed(self, _path: str) -> None: if self._filter_edit.currentText(): self._filter_edit.clearEditText() def _rotate_selected(self, op) -> None: selected = self._file_panel.file_list.get_selected_files() image_exts = set(self._config.get("image_extensions", [])) paths = [p for p in selected if p.suffix.lower() in image_exts] if op == "auto": from pbpicat.image_ops import get_exif_orientation paths = [p for p in paths if get_exif_orientation(p) is not None] self._rotate_images(paths, op) def _reset_exif_selected(self) -> None: selected = self._file_panel.file_list.get_selected_files() image_exts = set(self._config.get("image_extensions", [])) from pbpicat.image_ops import get_exif_orientation paths = [p for p in selected if p.suffix.lower() in image_exts and get_exif_orientation(p) is not None] self._reset_exif_images(paths) def _reset_exif_images(self, paths: list) -> None: from pbpicat.image_ops import get_exif_orientation, set_exif_orientation if not paths: return pairs = [] for path in paths: orig_orient = get_exif_orientation(path) if orig_orient is None: continue set_exif_orientation(path, 1) pairs.append((path, orig_orient)) if pairs: self._undo_stack.append(("reset_exif", pairs)) self._update_undo_btn() self._status.showMessage(_("{n} EXIF orientation tag(s) reset.").format(n=len(pairs))) self._file_panel.file_list.refresh_thumbnails_for_paths({p for p, _ in pairs}) def _rotate_images(self, paths: list, op) -> None: if op == "reset_exif": self._reset_exif_images(paths) return from pbpicat.image_ops import get_exif_orientation, rotate_lossless if not paths: return pairs = [] errors = [] for path in paths: try: orig_orient = get_exif_orientation(path) undo_op = rotate_lossless(path, op) pairs.append((path, undo_op, orig_orient)) except RuntimeError as exc: errors.append(f"{path.name}\n{exc}") except ValueError: continue if errors: box = QMessageBox( QMessageBox.Icon.Warning, _("Rotation unavailable"), _("{n} file(s) could not be rotated.").format(n=len(errors)), parent=self, ) box.setDetailedText("\n\n".join(errors)) box.exec() if pairs: self._undo_stack.append(("rotation", pairs)) self._update_undo_btn() self._status.showMessage(_("{n} file(s) rotated.").format(n=len(pairs))) self._file_panel.file_list.refresh_thumbnails_for_paths({p for p, *_ in pairs}) def _rename_selected(self) -> None: files = self._file_panel.file_list.get_selected_files() if not files: self._status.showMessage(_("No file selected.")) return self._rename_files(files) def _rename_all(self) -> None: files = self._file_panel.file_list.get_all_files() if not files: self._status.showMessage(_("No files to rename in the current directory.")) return reply = QMessageBox.question( self, _("Confirm rename"), _("Rename {n} file(s) according to the current schema?").format(n=len(files)), QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: self._rename_files(files) def _renumber_all(self) -> None: files = self._file_panel.file_list.get_all_files() if not files: return schema_fields = self._schema_frame.get_fields() try: plan = build_renumber_plan( schema_fields, [Path(p) for p in files], self._config.get("sidecar_extensions", []), self._config.get("image_extensions", []), self._config.get("video_extensions", []), self._config.get("video_marker", ""), self._schema_frame.get_video_marker_pos(), ) except ValueError as exc: QMessageBox.critical(self, _("Rename error"), str(exc)) return if not plan: self._status.showMessage(_("Files are already correctly numbered.")) return reply = QMessageBox.question( self, _("Confirm renumber"), _("Renumber {n} file(s) starting from 1?").format(n=len(files)), QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return try: execute_renumber(plan) except RuntimeError as exc: QMessageBox.critical(self, _("Rename error"), str(exc)) self._status.showMessage(_("Error: rename cancelled.")) return self._undo_stack.append(("renumber", plan)) self._update_undo_btn() self._status.showMessage(_("{n} file(s) renumbered successfully.").format(n=len(files))) self._file_panel.file_list.refresh_and_select(0) def _rename_files(self, file_paths: list) -> None: dest_root = self._dest_edit.text().strip() if not dest_root: QMessageBox.warning(self, _("Missing destination"), _("Please specify a destination directory.")) return schema_fields = self._schema_frame.get_fields() try: plan = build_rename_plan( dest_root, schema_fields, [Path(p) for p in file_paths], self._config.get("sidecar_extensions", []), self._config.get("image_extensions", []), self._config.get("video_extensions", []), self._config.get("video_marker", ""), self._schema_frame.get_video_marker_pos(), ) execute_rename(plan) except (ValueError, FileExistsError, RuntimeError) as exc: QMessageBox.critical(self, _("Rename error"), str(exc)) self._status.showMessage(_("Error: rename cancelled.")) return self._schema_frame.push_history(schema_fields) save_last_dest(dest_root) self._undo_stack.append(("rename", plan)) self._update_undo_btn() media_exts = set(self._config.get("image_extensions", [])) | set(self._config.get("video_extensions", [])) n = len([p for p, _ in plan if p.suffix.lower() in media_exts]) self._status.showMessage(_("{n} file(s) renamed successfully.").format(n=n)) next_row = self._file_panel.file_list.next_row_after_files(file_paths) self._file_panel.file_list.refresh_and_select(next_row) def _update_undo_btn(self) -> None: n = len(self._undo_stack) if n: kind = self._undo_stack[-1][0] if kind == "rotation": label = _("Undo rotation ({n})").format(n=n) tip = _("Undo last rotation (restores original pixel data)") else: label = _("Undo rename ({n})").format(n=n) tip = _("Undo last rename (restores files to their original location)") self._undo_btn.setText(label) self._undo_btn.setToolTip(tip) self._undo_btn.setEnabled(True) else: self._undo_btn.setText(_("Undo rename")) self._undo_btn.setToolTip(_("Undo last rename (restores files to their original location)")) self._undo_btn.setEnabled(False) def _undo_last_rename(self) -> None: if not self._undo_stack: return kind, plan = self._undo_stack[-1] path = None try: if kind == "renumber": undo_renumber(plan) elif kind == "rotation": from pbpicat.image_ops import rotate_lossless, set_exif_orientation for path, undo_op, orig_orient in plan: rotate_lossless(path, undo_op) if orig_orient is not None: set_exif_orientation(path, orig_orient) elif kind == "reset_exif": from pbpicat.image_ops import set_exif_orientation for path, orig_orient in plan: set_exif_orientation(path, orig_orient) else: undo_rename(plan) except (FileNotFoundError, FileExistsError, RuntimeError, ValueError) as exc: message = f"{path.name}\n\n{exc}" if path is not None else str(exc) QMessageBox.critical(self, _("Undo error"), message) self._status.showMessage(_("Error: undo failed.")) return self._undo_stack.pop() self._update_undo_btn() if kind == "rotation": self._status.showMessage(_("Rotation undone.")) self._file_panel.file_list.refresh_thumbnails_for_paths({p for p, *_ in plan}) elif kind == "reset_exif": self._status.showMessage(_("EXIF orientation reset undone.")) self._file_panel.file_list.refresh_thumbnails_for_paths({p for p, _ in plan}) else: self._status.showMessage(_("Last rename undone.")) restored = {src for src, _dst in plan} self._file_panel.file_list.refresh_and_select_paths(restored) def _open_settings(self) -> None: dlg = SettingsDialog(self._config, self) if dlg.exec() == SettingsDialog.Accepted: self._config = dlg.updated_config() self._schema_frame.rebuild(self._config) self._file_panel.reconfigure(self._config) self._status.showMessage(_("Settings saved.")) def _open_global_settings(self) -> None: dlg = GlobalSettingsDialog(self) if dlg.exec() == GlobalSettingsDialog.Accepted: self._status.showMessage(_("Program settings saved.")) def _open_history(self) -> None: dlg = HistoryDialog(self._config, self) if dlg.exec() == HistoryDialog.Accepted: self._schema_frame.rebuild(self._config) self._reload_filter_history() self._status.showMessage(_("History saved.")) def _apply_proposed_schema(self, fields: list[str]) -> None: self._schema_frame.set_fields(fields) self._status.showMessage(_("Template applied from history.")) # ----------------------------------------------------------------------- # Catalog management # ----------------------------------------------------------------------- def _update_title(self) -> None: cat = current_catalog() if cat == "default": self.setWindowTitle(_("PBPicat — Image & Video File Renamer")) else: self.setWindowTitle(_("PBPicat — Image & Video File Renamer [{catalog}]").format(catalog=cat)) def _populate_catalog_list(self) -> None: actions = self._catalog_menu.actions() sep_idx = next(i for i, a in enumerate(actions) if a.isSeparator()) for a in actions[sep_idx + 1 :]: self._catalog_menu.removeAction(a) active = current_catalog() for name in list_catalogs(): action = self._catalog_menu.addAction(name, lambda n=name: self._switch_to_catalog(n)) action.setCheckable(True) action.setChecked(name == active) action.setIcon(get_icon("folder-open", "folder-open")) def _new_catalog(self) -> None: name, ok = QInputDialog.getText( self, _("New catalog"), _("Catalog name (letters, digits, hyphens, underscores):") ) if not ok: return name = name.strip() if not name: return if not re.fullmatch(r"[A-Za-z0-9_\-]+", name): QMessageBox.warning( self, _("Invalid name"), _("Catalog name may only contain letters, digits, hyphens, or underscores."), ) return if name in list_catalogs(): QMessageBox.warning( self, _("Already exists"), _('A catalog named "{name}" already exists.').format(name=name), ) return global_cfg = load_global_config() initial = {"sidecar_extensions": global_cfg["default_sidecar_extensions"]} create_catalog(name, initial) self._switch_to_catalog(name) def _delete_catalog_action(self) -> None: all_catalogs = list_catalogs() if len(all_catalogs) <= 1: QMessageBox.information( self, _("No catalogs"), _("The last catalog cannot be deleted."), ) return name, ok = QInputDialog.getItem( self, _("Delete catalog"), _("Select catalog to delete:"), all_catalogs, 0, False ) if not ok: return reply = QMessageBox.question( self, _("Confirm deletion"), _('Delete catalog "{name}" and all its settings? This cannot be undone.').format(name=name), QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return if current_catalog() == name: fallback = next(c for c in all_catalogs if c != name) self._switch_to_catalog(fallback) delete_catalog(name) self._status.showMessage(_('Catalog "{name}" deleted.').format(name=name)) def _duplicate_catalog_action(self) -> None: source = current_catalog() name, ok = QInputDialog.getText( self, _("Duplicate catalog"), _('New catalog name (copy of "{source}"):').format(source=source), ) if not ok: return name = name.strip() if not name: return if not re.fullmatch(r"[A-Za-z0-9_\-]+", name): QMessageBox.warning( self, _("Invalid name"), _("Catalog name may only contain letters, digits, hyphens, or underscores."), ) return if name in list_catalogs(): QMessageBox.warning( self, _("Already exists"), _('A catalog named "{name}" already exists.').format(name=name), ) return duplicate_catalog(source, name) self._status.showMessage(_('Catalog "{source}" duplicated as "{name}".').format(source=source, name=name)) def _save_current_catalog_state(self) -> None: save_last_source_dir(self._file_panel.dir_tree.current_path()) save_last_dest(self._dest_edit.text().strip()) app_qsettings().setValue("main_window/geometry", self.saveGeometry()) qsettings().sync() def _switch_to_catalog(self, name: str) -> None: if name == current_catalog(): return self._save_current_catalog_state() set_current_catalog(name) self._config = load_config() self._undo_stack.clear() self._update_undo_btn() self._schema_frame.rebuild(self._config) self._file_panel.reconfigure(self._config) self._reload_filter_history() self._dest_edit.setText(load_last_dest()) self._file_panel.dir_tree.select_path(load_last_source_dir()) self._update_title() self._status.showMessage(_('Switched to catalog "{name}".').format(name=name))
[docs] def closeEvent(self, event) -> None: # noqa: N802 self._save_current_catalog_state() super().closeEvent(event)
def _show_keyboard_shortcuts(self) -> None: if self._shortcuts_dlg is None: self._shortcuts_dlg = _ShortcutsDialog(self) self._shortcuts_dlg.show() self._shortcuts_dlg.raise_() self._shortcuts_dlg.activateWindow() def _about(self) -> None: from email.utils import getaddresses from importlib.metadata import metadata import PySide6 version = QCoreApplication.applicationVersion() py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" pyside_version = PySide6.__version__ os_info = platform.platform() meta = metadata("pbpicat") authors_html = ", ".join( f'<a href="mailto:{email}">{name}</a>' if email else name for name, email in getaddresses([meta["Author-email"] or ""]) ) QMessageBox.about( self, _("About PBPicat"), _( "<b>PBPicat</b> {version}<br>Image and video file renaming tool using a structured schema." "<br><br>Supported formats: images (JPEG, PNG, HEIC, RAW…) and videos (MP4, MOV…)," "<br>with associated sidecar files (XMP, DOP, PP3…)." "<br><br><b>{authors_label}:</b> {authors}" "<br><b>Python:</b> {py_version}" "<br><b>PySide6:</b> {pyside_version}" "<br><b>{platform_label}:</b> {os_info}" ).format( version=version, authors_label=_("Authors"), authors=authors_html, py_version=py_version, pyside_version=pyside_version, platform_label=_("Platform"), os_info=os_info, ), )