├── app ├── __init__.py ├── logging │ ├── __init__.py │ └── logger.py ├── utils │ ├── __init__.py │ ├── files.py │ ├── image.py │ └── gui.py ├── queries │ ├── __init__.py │ ├── grammar.lark │ └── transformer.py ├── assets │ ├── icons │ │ ├── tag.png │ │ ├── folder.png │ │ ├── search.png │ │ ├── warning.png │ │ ├── app-icon.png │ │ ├── edit-move.png │ │ ├── folder-add.png │ │ ├── help-about.png │ │ ├── image-edit.png │ │ ├── list-add.png │ │ ├── system-run.png │ │ ├── tag-delete.png │ │ ├── tag-edit.png │ │ ├── edit-delete.png │ │ ├── folder-open.png │ │ ├── insert-image.png │ │ ├── list-remove.png │ │ ├── document-edit.png │ │ ├── document-open.png │ │ ├── image-compare.png │ │ ├── image-replace.png │ │ ├── application-exit.png │ │ ├── document-save-as.png │ │ ├── edit-clear-history.png │ │ ├── utilities-terminal.png │ │ └── configure-application.png │ └── lang │ │ └── en.json ├── gui │ ├── __init__.py │ ├── dialogs │ │ ├── _progress_dialog.py │ │ ├── __init__.py │ │ ├── _delete_file_dialog.py │ │ ├── _about_dialog.py │ │ ├── _similar_images_dialog.py │ │ ├── _command_line_dialog.py │ │ ├── _dialog_base.py │ │ ├── _move_images_dialog.py │ │ ├── _edit_tags_dialog.py │ │ ├── _settings_dialog.py │ │ └── _operations_dialog.py │ ├── threads.py │ ├── flow_layout.py │ └── image_list.py ├── data_access │ ├── __init__.py │ ├── xml.py │ ├── setup.sql │ ├── _migrations │ │ ├── __init__.py │ │ └── 0000_initial.py │ ├── db_updater.py │ ├── dao.py │ ├── image_dao.py │ └── tags_dao.py ├── constants.py ├── i18n.py ├── model.py └── config.py ├── setup.bat ├── setup.sh ├── .idea ├── .gitignore ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── sqldialects.xml ├── vcs.xml ├── modules.xml ├── misc.xml ├── ImageDatabase.iml ├── dataSources.xml └── inspectionProfiles │ └── Project_Default.xml ├── .gitignore ├── ImageLibrary.py ├── requirements.txt ├── ImageLibrary_cmd.py └── README.md /app/__init__.py: -------------------------------------------------------------------------------- 1 | from .gui import Application 2 | -------------------------------------------------------------------------------- /setup.bat: -------------------------------------------------------------------------------- 1 | pip install -r requirements.txt 2 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | pip install -r requirements.txt 2 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml -------------------------------------------------------------------------------- /app/logging/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import logger 2 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import gui, image, files 2 | -------------------------------------------------------------------------------- /app/queries/__init__.py: -------------------------------------------------------------------------------- 1 | from .transformer import query_to_sympy 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | logs/ 3 | test/ 4 | venv/ 5 | *.ini 6 | *.sqlite3 7 | -------------------------------------------------------------------------------- /app/assets/icons/tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/tag.png -------------------------------------------------------------------------------- /app/assets/icons/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/folder.png -------------------------------------------------------------------------------- /app/assets/icons/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/search.png -------------------------------------------------------------------------------- /app/assets/icons/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/warning.png -------------------------------------------------------------------------------- /app/assets/icons/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/app-icon.png -------------------------------------------------------------------------------- /app/assets/icons/edit-move.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/edit-move.png -------------------------------------------------------------------------------- /app/assets/icons/folder-add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/folder-add.png -------------------------------------------------------------------------------- /app/assets/icons/help-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/help-about.png -------------------------------------------------------------------------------- /app/assets/icons/image-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/image-edit.png -------------------------------------------------------------------------------- /app/assets/icons/list-add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/list-add.png -------------------------------------------------------------------------------- /app/assets/icons/system-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/system-run.png -------------------------------------------------------------------------------- /app/assets/icons/tag-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/tag-delete.png -------------------------------------------------------------------------------- /app/assets/icons/tag-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/tag-edit.png -------------------------------------------------------------------------------- /app/assets/icons/edit-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/edit-delete.png -------------------------------------------------------------------------------- /app/assets/icons/folder-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/folder-open.png -------------------------------------------------------------------------------- /app/assets/icons/insert-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/insert-image.png -------------------------------------------------------------------------------- /app/assets/icons/list-remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/list-remove.png -------------------------------------------------------------------------------- /app/assets/icons/document-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/document-edit.png -------------------------------------------------------------------------------- /app/assets/icons/document-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/document-open.png -------------------------------------------------------------------------------- /app/assets/icons/image-compare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/image-compare.png -------------------------------------------------------------------------------- /app/assets/icons/image-replace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/image-replace.png -------------------------------------------------------------------------------- /app/gui/__init__.py: -------------------------------------------------------------------------------- 1 | from . import threads 2 | from .application import Application 3 | from .dialogs import ProgressDialog 4 | -------------------------------------------------------------------------------- /app/assets/icons/application-exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/application-exit.png -------------------------------------------------------------------------------- /app/assets/icons/document-save-as.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/document-save-as.png -------------------------------------------------------------------------------- /app/assets/icons/edit-clear-history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/edit-clear-history.png -------------------------------------------------------------------------------- /app/assets/icons/utilities-terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/utilities-terminal.png -------------------------------------------------------------------------------- /app/assets/icons/configure-application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamiaV/ImageDatabase/HEAD/app/assets/icons/configure-application.png -------------------------------------------------------------------------------- /app/data_access/__init__.py: -------------------------------------------------------------------------------- 1 | from .db_updater import update_database_if_needed 2 | from .image_dao import ImageDao 3 | from .tags_dao import TagsDao 4 | from .xml import write_playlist 5 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/sqldialects.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /app/logging/logger.py: -------------------------------------------------------------------------------- 1 | """This module declares a global Logger object.""" 2 | 3 | import logging.handlers 4 | 5 | from app import constants 6 | 7 | _log_dir = constants.ERROR_LOG_FILE.parent 8 | if not _log_dir.exists(): 9 | _log_dir.mkdir() 10 | 11 | logging.basicConfig(filename=constants.ERROR_LOG_FILE, format="[%(asctime)s] %(levelname)s: %(message)s") 12 | logger = logging.getLogger(__name__) 13 | logger.setLevel(logging.ERROR) 14 | -------------------------------------------------------------------------------- /app/gui/dialogs/_progress_dialog.py: -------------------------------------------------------------------------------- 1 | import PyQt5.QtWidgets as QtW 2 | 3 | from app.i18n import translate as _t 4 | 5 | 6 | class ProgressDialog(QtW.QProgressDialog): 7 | def __init__(self, parent: QtW.QWidget = None): 8 | super().__init__('', _t('dialog.common.cancel_button.label'), 0, 100, parent=parent) 9 | self.setWindowTitle(_t('popup.progress.title')) 10 | self.setMinimumDuration(500) 11 | self.setModal(parent is not None) 12 | -------------------------------------------------------------------------------- /app/gui/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | from ._about_dialog import AboutDialog 2 | from ._command_line_dialog import CommandLineDialog 3 | from ._delete_file_dialog import DeleteFileConfirmDialog 4 | from ._edit_image_dialog import EditImageDialog 5 | from ._edit_tags_dialog import EditTagsDialog 6 | from ._move_images_dialog import MoveImagesDialog 7 | from ._operations_dialog import OperationsDialog 8 | from ._progress_dialog import ProgressDialog 9 | from ._settings_dialog import SettingsDialog 10 | -------------------------------------------------------------------------------- /ImageLibrary.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import os.path 3 | import pathlib 4 | 5 | import app 6 | 7 | 8 | def main(): 9 | lock_file = pathlib.Path('.lock') 10 | if lock_file.exists(): 11 | print('The application is already running!') 12 | else: 13 | with lock_file.open(mode='w') as f: 14 | f.write(str(os.getpid())) 15 | try: 16 | app.Application.run() 17 | finally: 18 | lock_file.unlink(missing_ok=True) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /.idea/ImageDatabase.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /app/constants.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | APP_NAME = 'Image Library' 4 | VERSION = '4.0' 5 | DB_SETUP_FILE = pathlib.Path('app/data_access/setup.sql').absolute() 6 | CONFIG_FILE = pathlib.Path('config.ini').absolute() 7 | ERROR_LOG_FILE = pathlib.Path('logs/errors.log').absolute() 8 | ICONS_DIR = pathlib.Path('app/assets/icons/').absolute() 9 | LANG_DIR = pathlib.Path('app/assets/lang/').absolute() 10 | GRAMMAR_FILE = pathlib.Path('app/queries/grammar.lark').absolute() 11 | IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp', 'gif'] 12 | MIN_THUMB_SIZE = 50 13 | MAX_THUMB_SIZE = 2000 14 | MIN_THUMB_LOAD_THRESHOLD = 0 15 | MAX_THUMB_LOAD_THRESHOLD = 1000 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | contourpy==1.0.6 2 | cv2imageload==1.0.6 3 | cycler==0.11.0 4 | decorator==5.1.1 5 | fonttools==4.38.0 6 | imageio==2.22.4 7 | kiwisolver==1.4.4 8 | lark-parser==0.12.0 9 | matplotlib==3.6.2 10 | mpmath==1.2.1 11 | networkx==2.8.8 12 | numpy==1.23.5 13 | opencv-python-headless==4.6.0.66 14 | packaging==21.3 15 | Pillow==9.3.0 16 | pyparsing==3.0.9 17 | pyperclip==1.8.2 18 | PyQt5==5.15.7 19 | PyQt5-Qt5==5.15.2 20 | PyQt5-sip==12.11.0 21 | PyQt5-stubs==5.15.6.0 22 | python-dateutil==2.8.2 23 | PyWavelets==1.4.1 24 | scikit-image==0.19.3 25 | scipy==1.9.3 26 | six==1.16.0 27 | sympy==1.11.1 28 | tifffile==2022.10.10 29 | validators==0.20.0 30 | -------------------------------------------------------------------------------- /app/data_access/xml.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import xml.etree.ElementTree as ETree 3 | 4 | from .. import model 5 | 6 | 7 | def write_playlist(file: pathlib.Path, images: list[model.Image]): 8 | """ 9 | Writes the given images as a playlist in the specified file. 10 | 11 | :param file: The output file. 12 | :param images: The image of the playlist. 13 | """ 14 | playlist = ETree.Element('playlist') 15 | for image in images: 16 | item = ETree.SubElement(playlist, 'image') 17 | item.text = str(image.path) 18 | item.set('rotation', '0') 19 | tree = ETree.ElementTree(playlist) 20 | tree.write(file, encoding='UTF-8', xml_declaration=True) 21 | -------------------------------------------------------------------------------- /app/queries/grammar.lark: -------------------------------------------------------------------------------- 1 | %import common.WS_INLINE 2 | 3 | %ignore WS_INLINE 4 | 5 | NAME: /\w+/ 6 | PLAIN_TEXT: /"((\\\\)*|(.*?[^\\](\\\\)*))"/ 7 | REGEX: /\/((\\\\)*|(.*?[^\\](\\\\)*))\// 8 | 9 | ?query: tag | metatag | or | and | p_query 10 | 11 | p_query: "(" query ")" -> group 12 | | "-" p_query -> negation 13 | 14 | or: (tag | metatag | and | p_query) ("+" (tag | metatag | and | p_query))+ -> disjunction 15 | 16 | and: (tag | metatag | p_query) (tag | metatag | p_query)+ -> conjunction 17 | 18 | tag: NAME -> tag 19 | | "-" tag -> negation 20 | 21 | metatag: NAME ":" PLAIN_TEXT -> metatag_plain 22 | | NAME ":" REGEX -> metatag_regex 23 | | "-" metatag -> negation 24 | -------------------------------------------------------------------------------- /app/data_access/setup.sql: -------------------------------------------------------------------------------- 1 | pragma foreign_keys = on; 2 | 3 | create table images ( 4 | id integer primary key autoincrement, 5 | path text unique not null 6 | ); 7 | 8 | create table tag_types ( 9 | id integer primary key autoincrement, 10 | label text not null unique, 11 | symbol text not null unique, 12 | color integer default 0 13 | ); 14 | 15 | create table tags ( 16 | id integer primary key autoincrement, 17 | label text unique not null, 18 | type_id integer 19 | references tag_types (id) on delete set null 20 | ); 21 | 22 | create table image_tag ( 23 | image_id integer not null 24 | references images (id) on delete cascade, 25 | tag_id integer not null 26 | references tags (id) on delete cascade, 27 | primary key (image_id, tag_id) 28 | ); 29 | -------------------------------------------------------------------------------- /.idea/dataSources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sqlite.xerial 6 | true 7 | org.sqlite.JDBC 8 | jdbc:sqlite:$PROJECT_DIR$/test/library.sqlite3 9 | $ProjectFileDir$ 10 | 11 | 12 | file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.25.1/sqlite-jdbc-3.25.1.jar 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/data_access/_migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """This module defines database migrations.""" 2 | import importlib 3 | import os 4 | import re 5 | 6 | migrations = [] 7 | """The list of all migrations, sorted in the correct order. 8 | Each item defines a function attribute named 'migration' 9 | that takes the database connection a the single argument. 10 | """ 11 | 12 | 13 | def _load_migrations(): 14 | global migrations 15 | 16 | migration_pattern = re.compile(r'^(\d{4})_.+\.py$') 17 | module_path = __name__ 18 | module_dir = module_path.replace('.', '/') 19 | files = filter(lambda e: os.path.isfile(os.path.join(module_dir, e)) and re.fullmatch(migration_pattern, e), 20 | os.listdir(module_dir)) 21 | for e in sorted(files, key=lambda e: int(re.search(migration_pattern, e)[1])): 22 | migrations.append(importlib.import_module(module_path + '.' + e[:e.rindex('.py')])) 23 | 24 | 25 | _load_migrations() 26 | 27 | __all__ = [ 28 | 'migrations' 29 | ] 30 | -------------------------------------------------------------------------------- /app/gui/threads.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | import PyQt5.QtCore as QtC 4 | 5 | 6 | class WorkerThread(QtC.QThread): 7 | """Base class for worker threads.""" 8 | progress_signal = QtC.pyqtSignal(float, object, int) 9 | 10 | STATUS_UNKNOWN = 0 11 | STATUS_SUCCESS = 1 12 | STATUS_FAILED = 2 13 | 14 | def __init__(self): 15 | super().__init__() 16 | self._error = None 17 | self._cancelled = False 18 | 19 | def cancel(self): 20 | """Interrupts this thread.""" 21 | self._cancelled = True 22 | 23 | @property 24 | def cancelled(self) -> bool: 25 | """Whether this thread was cancelled.""" 26 | return self._cancelled 27 | 28 | @abc.abstractmethod 29 | def run(self): 30 | pass 31 | 32 | @property 33 | def failed(self) -> bool: 34 | """Returns True if the operation failed.""" 35 | return self._error is not None 36 | 37 | @property 38 | def error(self) -> str | None: 39 | """If the operation failed, returns the reason; otherwise returns None.""" 40 | return self._error 41 | 42 | @error.setter 43 | def error(self, value: str): 44 | """Sets the error message.""" 45 | self._error = value 46 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | 15 | 21 | 22 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/gui/dialogs/_delete_file_dialog.py: -------------------------------------------------------------------------------- 1 | import PyQt5.QtWidgets as QtW 2 | 3 | from app.i18n import translate as _t 4 | 5 | 6 | class DeleteFileConfirmDialog(QtW.QMessageBox): 7 | """Message box thats asks the user if they want to delete some files.""" 8 | 9 | def __init__(self, images_nb: int, parent: QtW.QWidget = None): 10 | """Creates a dialog. 11 | 12 | :param images_nb: Number of images that are pending deletion. 13 | :param parent: Parent widget. 14 | """ 15 | if images_nb > 1: 16 | message = _t('popup.delete_image_warning.text_question_multiple') 17 | else: 18 | message = _t('popup.delete_image_warning.text_question_single') 19 | super().__init__(QtW.QMessageBox.Question, _t('popup.delete_image_warning.title'), message, 20 | QtW.QMessageBox.Yes | QtW.QMessageBox.No, parent=parent) 21 | self.button(QtW.QMessageBox.Yes).setText(_t('dialog.common.yes_button.label')) 22 | self.button(QtW.QMessageBox.No).setText(_t('dialog.common.no_button.label')) 23 | # noinspection PyTypeChecker 24 | layout: QtW.QGridLayout = self.layout() 25 | self._delete_disk_chck = QtW.QCheckBox(_t('popup.delete_image_warning.checkbox_label'), parent=self) 26 | self._delete_disk_chck.setChecked(True) 27 | layout.addItem(QtW.QWidgetItem(self._delete_disk_chck), 1, 2) 28 | 29 | def delete_from_disk(self) -> bool: 30 | """Returns True if the file(s) have to be deleted from the disk; False otherwise.""" 31 | return self._delete_disk_chck.isChecked() 32 | 33 | def exec_(self) -> int: 34 | button = super().exec_() 35 | return int(button == QtW.QMessageBox.Yes) 36 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 31 | -------------------------------------------------------------------------------- /app/utils/files.py: -------------------------------------------------------------------------------- 1 | """Utility functions to handle files.""" 2 | import os 3 | import pathlib 4 | 5 | from app import constants 6 | 7 | 8 | def get_files_from_directory(directory: pathlib.Path, recursive: bool = True) -> list[pathlib.Path]: 9 | """Returns all image files contained in the given directory. 10 | 11 | :param directory: The directory to look into. 12 | :param recursive: Whether to return images from all sub-directories. 13 | :return: The list of valid image files. 14 | :raise RecursionError: If the function reaches a depth of more than 20 sub-directories. 15 | """ 16 | max_depth = 20 17 | 18 | def aux(root: pathlib.Path, depth: int = 0) -> list[pathlib.Path]: 19 | if depth > max_depth: 20 | raise RecursionError(max_depth) 21 | files = [] 22 | for f in root.glob('*'): 23 | if f.is_dir() and recursive: 24 | files.extend(aux(f, depth + 1)) 25 | elif f.is_file() and accept_image_file(f): 26 | files.append(f.absolute()) 27 | return files 28 | 29 | return aux(directory) 30 | 31 | 32 | def accept_image_file(filename: str | pathlib.Path) -> bool: 33 | """Indicates whether the given file has a valid image extension.""" 34 | return get_extension(filename) in constants.IMAGE_FILE_EXTENSIONS 35 | 36 | 37 | def get_extension(filename: str | pathlib.Path, keep_dot: bool = False) -> str: 38 | """Returns the extension of the given file. 39 | 40 | :param filename: File’s name. 41 | :param keep_dot: Whether to return keep the dot in the result. 42 | :return: File’s extension. If there is none, an empty string is returned. 43 | """ 44 | ext = os.path.splitext(str(filename))[1] 45 | if not keep_dot and ext: 46 | return ext[1:].lower() 47 | return ext.lower() 48 | -------------------------------------------------------------------------------- /app/data_access/_migrations/0000_initial.py: -------------------------------------------------------------------------------- 1 | """Migrates from database from app version 3.1 to 3.2.""" 2 | import sqlite3 3 | 4 | from app import utils, constants, data_access, gui 5 | from app.i18n import translate as _t 6 | 7 | 8 | def migrate(connection: sqlite3.Connection, thread: gui.threads.WorkerThread): 9 | connection.executescript(f""" 10 | BEGIN; 11 | CREATE TABLE version ( 12 | db_version INTEGER PRIMARY KEY, 13 | app_version TEXT 14 | ); 15 | ALTER TABLE images ADD COLUMN hash BLOB; -- Cannot use INTEGER as hashes are 64-bit *unsigned* integers 16 | CREATE INDEX idx_images_hash ON images (hash); -- Speed up hash querying 17 | ALTER TABLE tags ADD COLUMN definition TEXT; 18 | INSERT INTO version (db_version, app_version) VALUES (1, "{constants.VERSION}"); 19 | """) 20 | 21 | cursor = connection.execute('SELECT id, path FROM images') 22 | rows = cursor.fetchall() 23 | total_rows = len(rows) 24 | for i, (ident, path) in enumerate(rows): 25 | if thread.cancelled: 26 | cursor.close() 27 | connection.rollback() 28 | break 29 | 30 | thread.progress_signal.emit( 31 | i / total_rows, 32 | _t(f'popup.database_update.migration_0000.hashing_image_text', image=path, index=i + 1, 33 | total=total_rows), 34 | thread.STATUS_UNKNOWN 35 | ) 36 | image_hash = utils.image.get_hash(path) 37 | try: 38 | connection.execute( 39 | 'UPDATE images SET hash = ? WHERE id = ?', 40 | (data_access.ImageDao.encode_hash(image_hash) if image_hash is not None else None, ident) 41 | ) 42 | except sqlite3.Error as e: 43 | cursor.close() 44 | thread.error = str(e) 45 | thread.cancel() 46 | else: 47 | thread.progress_signal.emit( 48 | (i + 1) / total_rows, 49 | _t(f'popup.database_update.migration_0000.hashing_image_text', image=path, index=i + 1, 50 | total=total_rows), 51 | thread.STATUS_SUCCESS 52 | ) 53 | else: 54 | cursor.close() 55 | connection.commit() 56 | -------------------------------------------------------------------------------- /app/gui/dialogs/_about_dialog.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import PyQt5.QtCore as QtC 4 | import PyQt5.QtWidgets as QtW 5 | import pyperclip 6 | 7 | from app import constants 8 | from app.i18n import translate as _t 9 | from . import _dialog_base 10 | 11 | 12 | class AboutDialog(_dialog_base.Dialog): 13 | """This dialog shows information about the application.""" 14 | 15 | def __init__(self, parent: QtW.QWidget = None): 16 | """Creates the 'About' dialog. 17 | 18 | :param parent: The widget this dialog is attached to. 19 | """ 20 | super().__init__(parent=parent, 21 | title=_t('dialog.about.title', app_name=constants.APP_NAME), 22 | modal=True, 23 | mode=_dialog_base.Dialog.CLOSE) 24 | 25 | def _init_body(self) -> QtW.QLayout: 26 | self.setMinimumSize(200, 140) 27 | 28 | body = QtW.QHBoxLayout() 29 | 30 | self._label = QtW.QLabel(parent=self) 31 | year = datetime.now().year 32 | copyright_year = '' if year == 2018 else f' - {year}' 33 | self._label.setText(f""" 34 | 35 |

Image Library v{constants.VERSION}

36 |

© 2018{copyright_year} Damien Vergnet

37 |

Icons © FatCow

38 |

Find more on GitHub.

39 | 40 | """.strip()) 41 | self._label.setOpenExternalLinks(True) 42 | self._label.setContextMenuPolicy(QtC.Qt.CustomContextMenu) 43 | self._label.customContextMenuRequested.connect(self._link_context_menu) 44 | self._label.linkHovered.connect(self._update_current_link) 45 | body.addWidget(self._label) 46 | self._current_link = None 47 | 48 | self._label_menu = QtW.QMenu(parent=self._label) 49 | self._label_menu.addAction(_t('dialog.about.menu.copy_link_item')) 50 | self._label_menu.triggered.connect(lambda: pyperclip.copy(self._current_link)) 51 | 52 | return body 53 | 54 | def _update_current_link(self, url: str): 55 | self._current_link = url 56 | 57 | def _link_context_menu(self, pos: QtC.QPoint): 58 | if self._current_link: 59 | self._label_menu.exec_(self._label.mapToGlobal(pos)) 60 | -------------------------------------------------------------------------------- /app/utils/image.py: -------------------------------------------------------------------------------- 1 | """Functions related to image hashing.""" 2 | import pathlib 3 | 4 | import cv2 5 | 6 | 7 | def get_hash(image_path: pathlib.Path, diff_size: int = 8) -> int | None: 8 | """Computes the difference hash of the image at the given path. 9 | 10 | :param image_path: Image’s path. 11 | :param diff_size: Size of the difference matrix. 12 | :return: The hash or None if the image could not be opened. 13 | """ 14 | image = cv2.imread(str(image_path)) 15 | if image is None: 16 | return None 17 | # Convert the image to grayscale and compute the hash 18 | image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) 19 | # Resize the input image, adding a single column (width) so we can compute the horizontal gradient 20 | resized = cv2.resize(image, (diff_size + 1, diff_size)) 21 | # Compute the (relative) horizontal gradient between adjacent column pixels 22 | diff = resized[:, 1:] > resized[:, :-1] 23 | # Convert the difference image to a hash 24 | # noinspection PyUnresolvedReferences 25 | return sum([2 ** i for i, v in enumerate(diff.flatten()) if v]) 26 | 27 | 28 | def compare_hashes(hash1: int, hash2: int, diff_size: int = 8) -> tuple[int, float | None, bool]: 29 | """Compares two image hashes. Two hashes are considered similar if their Hamming distance 30 | is ≤ 10 (cf. http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html). 31 | 32 | Similarity confidence coefficient is computed using the following formula: (11 − d) / 11 33 | 34 | :param hash1: A hash. 35 | :param hash2: Another hash. 36 | :param diff_size: Size of the difference matrix. 37 | :return: Three values: the Hamming distance, the similarity confidence coefficient (None if third value is False) 38 | and a boolean indicating whether the images behind the hashes are similar or not. 39 | """ 40 | threslhold = 10 41 | h1 = bin(hash1)[2:].rjust(diff_size ** 2, '0') 42 | h2 = bin(hash2)[2:].rjust(diff_size ** 2, '0') 43 | dist_counter = 0 44 | for n in range(len(h1)): 45 | if h1[n] != h2[n]: 46 | dist_counter += 1 47 | similar = dist_counter <= threslhold 48 | confidence = ((threslhold + 1) - dist_counter) / (threslhold + 1) if similar else None 49 | return dist_counter, confidence, similar 50 | 51 | 52 | def image_size(image_path: pathlib.Path) -> tuple[int, int] | None: 53 | """Returns the size of the given image file. 54 | 55 | :param image_path: Path to the image. 56 | :return: A tuple (width, height) or None if file could not be opened. 57 | """ 58 | image = cv2.imread(str(image_path)) 59 | if image is None: 60 | return None 61 | return image.shape[1], image.shape[0] 62 | -------------------------------------------------------------------------------- /app/data_access/db_updater.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import shutil 4 | import sqlite3 5 | 6 | from ._migrations import migrations 7 | from .. import config, constants, gui, utils 8 | from ..i18n import translate as _t 9 | 10 | 11 | def update_database_if_needed() -> tuple[bool | None, str | None]: 12 | """Updates the database if it needs to be.""" 13 | db_file = config.CONFIG.database_path 14 | setup = not db_file.exists() 15 | connection = sqlite3.connect(str(db_file)) 16 | connection.isolation_level = None 17 | 18 | if setup: 19 | with constants.DB_SETUP_FILE.open(encoding='UTF-8') as f: 20 | connection.executescript(f.read()) 21 | 22 | try: 23 | cursor = connection.execute('SELECT db_version, app_version FROM version') 24 | except sqlite3.OperationalError: 25 | db_version = 0 26 | app_version = '3.1' 27 | else: 28 | db_version, app_version = cursor.fetchone() 29 | cursor.close() 30 | connection.close() 31 | 32 | if db_version == len(migrations): # DB up to date, return now 33 | return True, None 34 | 35 | if not setup and not utils.gui.show_question(_t('popup.update_needed.text')): # Update cancelled 36 | return None, None 37 | 38 | progress_dialog = gui.ProgressDialog() 39 | progress_dialog.setWindowTitle(_t('popup.database_update.title')) 40 | 41 | def update_progress(progress: float, data: str, _): 42 | progress_dialog.setValue(int(progress * 100)) 43 | progress_dialog.setLabelText(data) 44 | 45 | thread = _UpdateThread(setup, db_file, db_version, app_version) 46 | thread.progress_signal.connect(update_progress) 47 | progress_dialog.canceled.connect(thread.cancel) 48 | thread.finished.connect(progress_dialog.cancel) 49 | thread.start() 50 | progress_dialog.exec_() 51 | 52 | if thread.error: 53 | message = thread.error 54 | status = False 55 | elif thread.cancelled: 56 | message = _t('popup.update_cancelled.text') 57 | status = None 58 | else: 59 | message = _t('popup.database_updated.text') if not setup else None 60 | status = True 61 | 62 | return status, message 63 | 64 | 65 | class _UpdateThread(gui.threads.WorkerThread): 66 | def __init__(self, setup: bool, db_file: pathlib.Path, previous_db_version: str, previous_app_version: str): 67 | super().__init__() 68 | self._setup = setup 69 | self._db_file = db_file 70 | self._db_version = previous_db_version 71 | self._app_version = previous_app_version 72 | 73 | def run(self): 74 | connection = sqlite3.connect(str(self._db_file)) 75 | connection.isolation_level = None 76 | # Apply all migrations starting from the DB’s version all the way up to the current version 77 | for i, migration in enumerate(migrations[self._db_version:]): 78 | if self.cancelled: 79 | break 80 | self.progress_signal.emit(0, '', self.STATUS_UNKNOWN) 81 | if i == 0 and not self._setup: 82 | name, ext = os.path.splitext(self._db_file.name) 83 | shutil.copy(self._db_file, self._db_file.parent / f'{name}-old_{self._app_version}{ext}') 84 | migration.migrate(connection, self) 85 | -------------------------------------------------------------------------------- /app/data_access/dao.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import pathlib 3 | import re 4 | import sqlite3 5 | 6 | from .. import utils 7 | 8 | 9 | class DAO(abc.ABC): 10 | """Base class for DAO objects. It defines 'REGEX', 'RINSTR' and 'SIMILAR' functions to use in SQL queries.""" 11 | 12 | def __init__(self, database: pathlib.Path): 13 | """Initializes this DAO using the given database. 14 | 15 | :param database: The database file to connect to. 16 | """ 17 | self._database_path = database 18 | self._connection = sqlite3.connect(str(self._database_path)) 19 | # Disable autocommit when BEGIN has been called. 20 | self._connection.isolation_level = None 21 | self._connection.create_function('REGEXP', 2, self._regexp, deterministic=True) 22 | self._connection.create_function('RINSTR', 2, self._rinstr, deterministic=True) 23 | self._connection.create_function('SIMILAR', 2, self._similarity) 24 | self._connection.execute('PRAGMA foreign_keys = ON') 25 | 26 | @property 27 | def database_path(self) -> pathlib.Path: 28 | return self._database_path 29 | 30 | def close(self): 31 | """Closes database connection.""" 32 | self._connection.close() 33 | 34 | @staticmethod 35 | def _regexp(pattern: str, string: str) -> bool: 36 | """Implementation of REGEXP function for SQL. 37 | Scans through string looking for a match to the pattern. 38 | 39 | @note Uses re.search() 40 | 41 | :param pattern: The regex pattern. 42 | :param string: The string to search into. 43 | :return: True if the second argument matches the pattern. 44 | """ 45 | return re.search(pattern, string) is not None 46 | 47 | @staticmethod 48 | def _rinstr(s: str, sub: str) -> int: 49 | """Implementation of RINSTR function for SQL. 50 | Returns the highest index in s where substring sub is found. 51 | 52 | @note Uses str.rindex() 53 | 54 | :param s: The string to search into. 55 | :param sub: The string to search for. 56 | :return: The index, starting at 1; 0 if the substring could not be found. 57 | """ 58 | try: 59 | return s.rindex(sub) + 1 # SQLite string indices start from 1 60 | except ValueError: 61 | return 0 62 | 63 | @staticmethod 64 | def _similarity(hash1: bytes | None, hash2: bytes | None) -> bool: 65 | """Indicates whether the two provided hashes are similar, based on 66 | Hamming distance. 67 | 68 | @note Uses utils.image.compare_hashes() 69 | 70 | :param hash1: A hash. 71 | :param hash2: Another hash. 72 | :return: True if the hashes are similar, False if not or at least one of them is None. 73 | """ 74 | if hash1 is not None and hash2 is not None: 75 | return utils.image.compare_hashes(DAO.decode_hash(hash1), DAO.decode_hash(hash2))[2] 76 | return False 77 | 78 | @staticmethod 79 | def encode_hash(hash_int: int) -> bytes: 80 | """Encodes the given image hash into a bytes. 81 | 82 | :param hash_int: The images hash to encode. 83 | :return: The resulting bytes. 84 | """ 85 | return hash_int.to_bytes(8, byteorder='big', signed=False) 86 | 87 | @staticmethod 88 | def decode_hash(hash_bytes: bytes) -> int: 89 | """Decode the given bytes into an image hash. 90 | 91 | :param hash_bytes: The bytes to decode. 92 | :return: The resulting int. 93 | """ 94 | return int.from_bytes(hash_bytes, byteorder='big', signed=False) 95 | -------------------------------------------------------------------------------- /app/i18n.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import json 5 | import pathlib 6 | import sys 7 | 8 | from . import constants 9 | from .logging import logger 10 | 11 | 12 | @dataclasses.dataclass(frozen=True) 13 | class Language: 14 | name: str 15 | code: str 16 | _mappings: dict[str, str] 17 | 18 | def translate(self, key: str, default: str = None, **kwargs): 19 | """Translates the given key in this language. 20 | 21 | :param key: The key to translate 22 | :param default: The default value to return if the specified key does not exist. 23 | :param kwargs: Keyword arguments to use for formatting. 24 | :return: The translated string. 25 | """ 26 | return self._mappings.get(key, default or key).format(**kwargs) 27 | 28 | def __eq__(self, other: Language): 29 | return isinstance(other, Language) and self.code == other.code 30 | 31 | 32 | def translate(key: str, default: str = None, **kwargs): 33 | """Translates the given key in the current language. 34 | 35 | :param key: The key to translate 36 | :param default: The default value to return if the specified key does not exist. 37 | :param kwargs: Keyword arguments to use for formatting. 38 | :return: The translated string. 39 | """ 40 | from . import config 41 | return config.CONFIG.language.translate(key, default=default, **kwargs) 42 | 43 | 44 | def get_language(code: str) -> Language | None: 45 | return _LANGUAGES.get(code) 46 | 47 | 48 | def get_languages() -> list[Language]: 49 | return sorted(_LANGUAGES.values(), key=lambda lang: lang.name) 50 | 51 | 52 | def load_languages() -> bool: 53 | for path in constants.LANG_DIR.glob('*'): 54 | if path.is_file() and path.name.lower().endswith('.json'): 55 | if res := _get_language_for_file(path): 56 | _LANGUAGES[res[1]] = res[0] 57 | 58 | return len(_LANGUAGES) != 0 59 | 60 | 61 | def _get_language_for_file(path: pathlib.Path) -> tuple[Language, str] | None: 62 | """Loads the language from the given file. 63 | 64 | :param path: File path. 65 | :return: A Language object, None if the file could not be found or is improperly formatted. 66 | """ 67 | mappings = {} 68 | code = path.stem 69 | try: 70 | with path.open(encoding='UTF-8') as f: 71 | json_object = json.load(f) 72 | for k, v in _build_mapping(json_object['mappings']).items(): 73 | mappings[k] = v 74 | return Language(name=json_object['name'], code=code, _mappings=mappings), code 75 | except (KeyError, FileNotFoundError, json.JSONDecodeError) as e: 76 | logger.exception(f'could not load language file {path}') 77 | print(f'could not load language file {path}', file=sys.stderr) 78 | print(e, file=sys.stderr) 79 | return None 80 | 81 | 82 | def _build_mapping(json_object: dict[str, str | dict], root: str = None) -> dict[str, str]: 83 | """ 84 | Converts a JSON object to a flat key-value mapping. 85 | This function is recursive. 86 | 87 | :param json_object: The JSON object to flatten. 88 | :param root: The root to prepend to the keys. 89 | :return: The flattened mapping. 90 | :raises ValueError: If one of the values in the JSON object is neither a string or a mapping. 91 | """ 92 | mapping = {} 93 | 94 | for k, v in json_object.items(): 95 | if root is not None: 96 | key = f'{root}.{k}' 97 | else: 98 | key = k 99 | if isinstance(v, str): 100 | mapping[key] = str(v) 101 | elif isinstance(v, dict): 102 | mapping = dict(mapping, **_build_mapping(v, key)) 103 | else: 104 | raise ValueError(f'illegal value type "{type(v)}" for translation value') 105 | 106 | return mapping 107 | 108 | 109 | _MAPPINGS = {} 110 | _LANGUAGES = {} 111 | -------------------------------------------------------------------------------- /app/queries/transformer.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import lark 4 | import sympy as sp 5 | 6 | from .. import data_access as da, constants 7 | from ..i18n import translate as _t 8 | 9 | 10 | class _TreeToBoolean(lark.InlineTransformer): 11 | """This class is the lexer for the tag query language. 12 | It converts a string query into a SymPy expression. 13 | """ 14 | 15 | def __init__(self): 16 | with constants.GRAMMAR_FILE.open(encoding='UTF-8') as f: 17 | grammar = '\n'.join(f.readlines()) 18 | self._parser = lark.Lark(grammar, parser='lalr', lexer='contextual', start='query') 19 | 20 | def conjunction(self, *args): 21 | # Filter out whitespace 22 | return sp.And(*self._filter_whitespace(args)) 23 | 24 | def disjunction(self, *args): 25 | # Filter out whitespace 26 | return sp.Or(*self._filter_whitespace(args)) 27 | 28 | def group(self, *args): 29 | # Filter out whitespace 30 | return self._filter_whitespace(args)[0] 31 | 32 | @staticmethod 33 | def _filter_whitespace(args: tuple) -> list: 34 | return [arg for arg in args if not isinstance(arg, lark.lexer.Token)] 35 | 36 | def tag(self, tag): 37 | return self._symbol(str(tag)) 38 | 39 | def metatag_plain(self, metatag, value): 40 | return self._metatag(metatag, value, 'plain') 41 | 42 | def metatag_regex(self, metatag, value): 43 | return self._metatag(metatag, value, 'regex') 44 | 45 | def _metatag(self, metatag, value, mode: str): 46 | metatag = str(metatag) 47 | # Remove enclosing / or " 48 | value = str(value)[1:-1] 49 | if mode == 'plain': 50 | value = value.replace(r'\"', '"') 51 | if not da.ImageDao.check_metatag_value(metatag, value, mode): 52 | raise ValueError(_t('query_parser.error.invalid_metatag_value', value=value, metatag=metatag)) 53 | return self._symbol(f'{metatag}:{mode}:{value}') 54 | 55 | negation = sp.Not 56 | 57 | @staticmethod 58 | def _symbol(name: str): 59 | """Creates a new Sympy symbol. Escapes special characters from name: colon, space, comma and parentheses.""" 60 | return sp.symbols(re.sub('([: ,()])', r'\\\1', name)) 61 | 62 | def get_sympy(self, query: str, simplify: bool = True) -> sp.Basic: 63 | """Converts the given string query into a SymPy expression. 64 | 65 | :param query: The query to convert. 66 | :param simplify: If true (default) the result will be simplified using boolean logic. 67 | :return: A SymPy expression. 68 | """ 69 | parsed_query = self._parser.parse(query) 70 | # noinspection PyUnresolvedReferences 71 | bool_expr = self.transform(parsed_query) 72 | 73 | if simplify: 74 | bool_expr = sp.simplify_logic(bool_expr) 75 | 76 | return bool_expr 77 | 78 | 79 | _transformer = None 80 | 81 | 82 | def query_to_sympy(query: str, simplify: bool = True) -> sp.Basic: 83 | """Converts a query into a simplified SymPy boolean expression. 84 | 85 | :param query: The query to convert. 86 | :param simplify: If true the query will be simplified once it is converted into a SymPy expression. 87 | :return: The simplified SymPy expression. 88 | """ 89 | global _transformer 90 | 91 | if _transformer is None: 92 | _transformer = _TreeToBoolean() 93 | 94 | try: 95 | return _transformer.get_sympy(query, simplify=simplify) 96 | except lark.ParseError as e: 97 | message = str(e) 98 | # Lark parse errors are not very readable, just send a simpler error message if possible. 99 | if match := re.match(r"^Unexpected token Token\('[\w$]+', '(.+?)'\)", message): 100 | raise ValueError(_t('query_parser.error.syntax_error', token=match[1])) 101 | elif '$END' in message: 102 | raise ValueError(_t('query_parser.error.syntax_error_eol')) 103 | raise ValueError(e) 104 | except lark.UnexpectedInput as e: 105 | c = query[e.pos_in_stream] 106 | raise ValueError(_t('query_parser.error.illegal_character', char=c, code=hex(ord(c))[2:].upper().rjust(4, '0'))) 107 | -------------------------------------------------------------------------------- /ImageLibrary_cmd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """Command-line application to interact with the database.""" 3 | 4 | # Required to enable arrow keys navigation with input() 5 | # noinspection PyUnresolvedReferences 6 | import readline 7 | import sqlite3 8 | import sys 9 | import typing as typ 10 | 11 | from app import config, constants, data_access as da 12 | from app.i18n import translate as _t 13 | 14 | 15 | def print_rows(rows: list[tuple[str, ...]], column_names: typ.Sequence[str]): 16 | """Prints rows in a table. 17 | 18 | :param rows: List of rows. 19 | :param column_names: Names of each column. 20 | """ 21 | columns = list(zip(*([column_names] + rows))) 22 | column_sizes = [max([len(str(v)) for v in col]) for col in columns] 23 | print(*[str(v).ljust(column_sizes[i]) for i, v in enumerate(column_names)], sep=' | ') 24 | print(*['-' * size for size in column_sizes], sep='-+-') 25 | for i, row in enumerate(rows): 26 | print(*[str(v).ljust(column_sizes[i]) for i, v in enumerate(row)], sep=' | ') 27 | 28 | 29 | def main(): 30 | try: 31 | config.load_config() 32 | except config.ConfigError as e: 33 | print(e, file=sys.stderr) 34 | sys.exit(-1) 35 | 36 | print(constants.APP_NAME + ' v' + constants.VERSION) 37 | print(f'SQLite v{sqlite3.sqlite_version} - PySQLite v{sqlite3.version}') 38 | print(_t('SQL_console.exit_notice')) 39 | 40 | dao = da.ImageDao(config.CONFIG.database_path) 41 | # noinspection PyProtectedMember 42 | connection = dao._connection 43 | 44 | print(_t('SQL_console.connection', path=dao.database_path)) 45 | 46 | while 'user hasn’t typed "exit"': 47 | cmd = input('SQL> ').strip() 48 | 49 | if cmd.lower() == 'exit': 50 | break 51 | 52 | cursor = connection.cursor() 53 | try: 54 | cursor.execute(cmd) 55 | except sqlite3.Error as e: 56 | print('\033[31m' + _t('SQL_console.error')) 57 | print(f'{e}\033[0m') 58 | cursor.close() 59 | else: 60 | if cmd.lower().startswith('select'): 61 | results = cursor.fetchall() 62 | if cursor.description is not None: 63 | column_names = tuple(desc[0] for desc in cursor.description) 64 | else: 65 | column_names = () 66 | 67 | if len(results) == 0: 68 | print(_t('SQL_console.no_results')) 69 | else: 70 | results_nb = len(results) 71 | limit = 20 72 | i = 0 73 | rows = [] 74 | for result in results: 75 | if i % limit == 0: 76 | if i > 0: 77 | print_rows(rows, column_names) 78 | rows.clear() 79 | while 'user enters neither Y or N': 80 | print(_t('SQL_console.display_more')) 81 | choice = input('?> ').upper() 82 | if choice.upper() == 'Y': 83 | proceed = True 84 | break 85 | elif choice.upper() == 'N': 86 | proceed = False 87 | break 88 | if not proceed: 89 | break 90 | upper_bound = i + limit if i + limit <= results_nb else results_nb 91 | print(_t('SQL_console.results', start=i + 1, end=upper_bound, total=results_nb)) 92 | rows.append(tuple(map(repr, result))) 93 | i += 1 94 | else: 95 | print_rows(rows, column_names) 96 | else: 97 | print(_t('SQL_console.affected_rows', row_count=cursor.rowcount)) 98 | 99 | cursor.close() 100 | 101 | print(_t('SQL_console.goodbye')) 102 | 103 | dao.close() 104 | 105 | 106 | if __name__ == '__main__': 107 | main() 108 | -------------------------------------------------------------------------------- /app/gui/dialogs/_similar_images_dialog.py: -------------------------------------------------------------------------------- 1 | import PyQt5.QtWidgets as QtW 2 | from PyQt5.QtCore import Qt 3 | 4 | from app import data_access, model, utils 5 | from app.i18n import translate as _t 6 | from . import _dialog_base 7 | from .. import components 8 | 9 | 10 | class SimilarImagesDialog(_dialog_base.Dialog): 11 | def __init__(self, images: list[tuple[model.Image, float]], image_dao: data_access.ImageDao, 12 | tags_dao: data_access.TagsDao, parent: QtW.QWidget = None): 13 | self._images = images 14 | self._index = -1 15 | super().__init__(parent=parent, title=_t('dialog.similar_images.title'), modal=True, mode=self.OK_CANCEL) 16 | self._ok_btn.setText(_t('dialog.similar_images.button.copy_tags.label')) 17 | self._ok_btn.setDisabled(True) 18 | self._cancel_btn.setText(_t('dialog.similar_images.button.close.label')) 19 | self._image_dao = image_dao 20 | self._tags_dao = tags_dao 21 | 22 | def _init_body(self): 23 | layout = QtW.QVBoxLayout() 24 | 25 | label = QtW.QLabel(_t('dialog.similar_images.text'), parent=self) 26 | label.setWordWrap(True) 27 | layout.addWidget(label) 28 | 29 | layout.addSpacing(10) 30 | 31 | grid_layout = QtW.QGridLayout() 32 | grid_layout.setContentsMargins(0, 0, 0, 0) 33 | grid_layout.setColumnStretch(1, 1) 34 | label = QtW.QLabel(_t('dialog.similar_images.grid.header.image_path'), parent=self) 35 | label.setAlignment(Qt.AlignCenter) 36 | grid_layout.addWidget(label, 0, 1) 37 | label = QtW.QLabel(_t('dialog.similar_images.grid.header.image_size'), parent=self) 38 | label.setAlignment(Qt.AlignCenter) 39 | grid_layout.addWidget(label, 0, 2) 40 | label = QtW.QLabel(_t('dialog.similar_images.grid.header.confidence_score'), parent=self) 41 | label.setAlignment(Qt.AlignCenter) 42 | grid_layout.addWidget(label, 0, 3) 43 | 44 | button_group = QtW.QButtonGroup(parent=self) 45 | for i, (image, score) in enumerate(self._images): 46 | radio_button = QtW.QRadioButton(parent=self) 47 | radio_button.clicked.connect(self._on_radio_button_clicked) 48 | radio_button.setWhatsThis(str(i)) 49 | button_group.addButton(radio_button, id=i) 50 | grid_layout.addWidget(radio_button, i + 1, 0) 51 | 52 | label = components.EllipsisLabel(str(image.path), parent=self) 53 | # Click on associated radio button when label is clicked 54 | label.set_on_click(lambda this: button_group.button(int(this.whatsThis())).click()) 55 | label.setToolTip(str(image.path)) 56 | label.setMinimumWidth(80) 57 | label.setAlignment(Qt.AlignLeft) 58 | label.setWhatsThis(str(i)) 59 | grid_layout.addWidget(label, i + 1, 1) 60 | 61 | image_size = utils.image.image_size(image.path) 62 | label = QtW.QLabel(f'{image_size[0]}×{image_size[1]}', parent=self) 63 | label.setFixedWidth(80) 64 | label.setAlignment(Qt.AlignCenter) 65 | grid_layout.addWidget(label, i + 1, 2) 66 | 67 | label = QtW.QLabel(f'{score * 100:.2f} %', parent=self) 68 | label.setFixedWidth(80) 69 | label.setAlignment(Qt.AlignCenter) 70 | grid_layout.addWidget(label, i + 1, 3) 71 | 72 | button = QtW.QPushButton( 73 | utils.gui.icon('folder-open'), 74 | _t('dialog.similar_images.grid.open_file_button.label'), 75 | parent=self 76 | ) 77 | button.setWhatsThis(str(i)) 78 | button.clicked.connect(self._on_open_file_button_clicked) 79 | button.setFixedWidth(80) 80 | grid_layout.addWidget(button, i + 1, 4) 81 | 82 | wrapper_layout = QtW.QVBoxLayout() 83 | wrapper_layout.addLayout(grid_layout) 84 | wrapper_layout.addStretch() 85 | 86 | scroll = QtW.QScrollArea(parent=self) 87 | scroll.setWidgetResizable(True) 88 | w = QtW.QWidget(parent=self) 89 | w.setLayout(wrapper_layout) 90 | scroll.setWidget(w) 91 | layout.addWidget(scroll) 92 | 93 | self.setMinimumSize(500, 200) 94 | self.setGeometry(0, 0, 500, 200) 95 | 96 | return layout 97 | 98 | def _on_radio_button_clicked(self): 99 | self._index = int(self.sender().whatsThis()) 100 | self._ok_btn.setDisabled(False) 101 | 102 | def _on_open_file_button_clicked(self): 103 | index = int(self.sender().whatsThis()) 104 | utils.gui.show_file(self._images[index][0].path) 105 | 106 | def get_tags(self) -> list[model.Tag] | None: 107 | if self._applied and 0 <= self._index < len(self._images): 108 | return self._image_dao.get_image_tags(self._images[self._index][0].id, self._tags_dao) 109 | return None 110 | -------------------------------------------------------------------------------- /app/model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import re 5 | from dataclasses import dataclass 6 | 7 | import PyQt5.QtGui as QtG 8 | 9 | 10 | @dataclass(frozen=True) 11 | class Image: 12 | """This class represents an image.""" 13 | id: int 14 | path: pathlib.Path 15 | hash: int | None 16 | 17 | def __lt__(self, other: Image): 18 | if not isinstance(other, Image): 19 | raise ValueError(f'expected Image, got {type(other)}') 20 | return self.path < other.path 21 | 22 | def __gt__(self, other: Image): 23 | if not isinstance(other, Image): 24 | raise ValueError(f'expected Image, got {type(other)}') 25 | return self.path > other.path 26 | 27 | def __le__(self, other: Image): 28 | return self == other or self < other 29 | 30 | def __ge__(self, other: Image): 31 | return self == other or self > other 32 | 33 | 34 | class TagType: 35 | """This class represents a tag type.""" 36 | LABEL_PATTERN = re.compile(r'^\S.*$') 37 | SYMBOL_PATTERN = re.compile(r'^[^\w+()\\:-]$') 38 | 39 | def __init__(self, ident: int, label: str, symbol: str, color: QtG.QColor = QtG.QColor(0, 0, 0)): 40 | """Creates a tag type. 41 | 42 | :param ident: Type’s SQLite ID. 43 | :param label: Type’s label. 44 | :param symbol: Type’s symbol. 45 | :param color: Type’s color. 46 | """ 47 | if not self.LABEL_PATTERN.match(label): 48 | raise ValueError(f'illegal type label "{label}"') 49 | if not self.SYMBOL_PATTERN.match(symbol): 50 | raise ValueError(f'illegal type symbol "{symbol}"') 51 | self._id = ident 52 | self._label = label 53 | self._symbol = symbol 54 | self._color = color 55 | 56 | @property 57 | def id(self) -> int: 58 | """Returns this type’s ID.""" 59 | return self._id 60 | 61 | @property 62 | def label(self) -> str: 63 | """Returns this type’s label.""" 64 | return self._label 65 | 66 | @property 67 | def symbol(self) -> str: 68 | """Returns this type’s symbol.""" 69 | return self._symbol 70 | 71 | @property 72 | def color(self) -> QtG.QColor: 73 | """Returns this type’s color.""" 74 | return self._color 75 | 76 | def __eq__(self, other: TagType): 77 | if not isinstance(other, TagType): 78 | return False 79 | return (self.id == other.id and self.label == other.label and self.symbol == other.symbol and 80 | self._color == other.color) 81 | 82 | def __repr__(self): 83 | return f'TagType{{id={self.id}, label={self.label}, symbol={self.symbol}, color={self.color.name()}}}' 84 | 85 | 86 | class Tag: 87 | """This class represents an image tag. Tags can be associated to a type.""" 88 | LABEL_PATTERN = re.compile(r'^\w+$') 89 | 90 | def __init__(self, ident: int, label: str, tag_type: TagType = None): 91 | """Creates a tag with an optional type. 92 | 93 | :param ident: Tag’s SQLite ID. 94 | :param label: Tag’s label. 95 | :param tag_type: Tag’s type? 96 | """ 97 | if not self.LABEL_PATTERN.match(label): 98 | raise ValueError(f'illegal tag label "{label}"') 99 | 100 | self._id = ident 101 | self._label = label 102 | self._type = tag_type 103 | 104 | def raw_label(self) -> str: 105 | """Returns the raw label, i.e. the name prefixed with its type symbol.""" 106 | symbol = self._type.symbol if self._type is not None else '' 107 | return symbol + self._label 108 | 109 | @property 110 | def id(self) -> int: 111 | """Returns this tag’s SQLite ID.""" 112 | return self._id 113 | 114 | @property 115 | def label(self) -> str: 116 | """Return this tag’s label.""" 117 | return self._label 118 | 119 | @property 120 | def type(self) -> TagType: 121 | """Returns this tag’s type.""" 122 | return self._type 123 | 124 | def __repr__(self): 125 | return self._label 126 | 127 | def __eq__(self, other: Tag): 128 | if not isinstance(other, Tag): 129 | return False 130 | return self.id == other.id and self.label == other.label and self.type == other.type 131 | 132 | 133 | class CompoundTag(Tag): 134 | """A compound tag is a tag defined by a tag query. This type of tags is only used in queries, they cannot be used to 135 | tag images directly. 136 | """ 137 | 138 | def __init__(self, ident: int, label: str, definition: str, tag_type: TagType = None): 139 | """Creates a compound tag. 140 | 141 | :param ident: Tag’s SQLite ID. 142 | :param label: Tag’s label. 143 | :param definition: Tag’s definition (tag expression). 144 | :param tag_type: Tag’s optional type. 145 | """ 146 | super().__init__(ident, label, tag_type=tag_type) 147 | self._definition = definition 148 | 149 | @property 150 | def definition(self) -> str: 151 | """Returns the tag expression defining this tag.""" 152 | return self._definition 153 | 154 | def __eq__(self, other: CompoundTag): 155 | if not super().__eq__(other) or not isinstance(other, CompoundTag): 156 | return False 157 | return self.definition == other.definition 158 | -------------------------------------------------------------------------------- /app/gui/dialogs/_command_line_dialog.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import typing as typ 3 | 4 | import PyQt5.QtCore as QtC 5 | import PyQt5.QtGui as QtG 6 | import PyQt5.QtWidgets as QtW 7 | 8 | from app import config, data_access 9 | from app.i18n import translate as _t 10 | from . import _dialog_base 11 | from .. import components 12 | 13 | 14 | class CommandLineDialog(_dialog_base.Dialog): 15 | """A simple command line interface to interact with the database.""" 16 | 17 | def __init__(self, parent: QtW.QWidget = None): 18 | super().__init__( 19 | parent=parent, 20 | title=_t('dialog.command_line.title'), 21 | modal=True, 22 | mode=_dialog_base.Dialog.CLOSE 23 | ) 24 | # noinspection PyProtectedMember 25 | self._connection = data_access.ImageDao(config.CONFIG.database_path)._connection 26 | self._command_line.setFocus() 27 | self._disable_closing = False 28 | 29 | self._column_names = None 30 | self._results = None 31 | self._results_offset = None 32 | self._results_total = None 33 | 34 | def _init_body(self) -> QtW.QLayout: 35 | self.setMinimumSize(500, 300) 36 | 37 | layout = QtW.QVBoxLayout() 38 | 39 | self._command_line = components.CommandLineWidget(parent=self) 40 | self._command_line.set_input_callback(self._on_input) 41 | self._command_line.set_input_placeholder(_t('dialog.command_line.query_input.placeholder')) 42 | layout.addWidget(self._command_line) 43 | 44 | return layout 45 | 46 | def _on_input(self, input_: str): 47 | if self._results: 48 | if input_.upper() == 'Y': 49 | self._print_results() 50 | elif input_.upper() == 'N': 51 | self._column_names = None 52 | self._results = None 53 | else: 54 | self._command_line.print(_t('SQL_console.display_more')) 55 | else: 56 | cursor = self._connection.cursor() 57 | try: 58 | cursor.execute(input_) 59 | except sqlite3.Error as e: 60 | self._command_line.print_error(_t('SQL_console.error')) 61 | self._command_line.print_error(e) 62 | cursor.close() 63 | else: 64 | if input_.lower().startswith('select'): 65 | results = cursor.fetchall() 66 | if cursor.description is not None: 67 | column_names = tuple(desc[0] for desc in cursor.description) 68 | else: 69 | column_names = () 70 | self._column_names = column_names 71 | self._results = results 72 | self._results_offset = 0 73 | self._results_total = len(results) 74 | self._print_results() 75 | else: 76 | self._command_line.print(_t('SQL_console.affected_rows', row_count=cursor.rowcount)) 77 | cursor.close() 78 | 79 | def _print_results(self): 80 | results = self._results[self._results_offset:] 81 | if len(results) == 0: 82 | self._command_line.print(_t('SQL_console.no_results')) 83 | else: 84 | limit = 20 85 | i = 0 86 | rows = [] 87 | for result in results: 88 | if i % limit == 0: 89 | if i > 0: 90 | self._print_rows(rows, self._column_names) 91 | rows.clear() 92 | self._command_line.print(_t('SQL_console.display_more')) 93 | self._results_offset += i 94 | break 95 | upper_bound = min(self._results_offset + i + limit, self._results_total) 96 | self._command_line.print(_t('SQL_console.results', start=self._results_offset + i + 1, 97 | end=upper_bound, total=self._results_total)) 98 | rows.append(tuple(map(repr, result))) 99 | i += 1 100 | else: 101 | self._print_rows(rows, self._column_names) 102 | self._results = None 103 | 104 | def _print_rows(self, rows: list[tuple[str, ...]], column_names: typ.Sequence[str]): 105 | """Prints rows in a table. 106 | 107 | :param rows: List of rows. 108 | :param column_names: Names of each column. 109 | """ 110 | columns = list(zip(*([column_names] + rows))) 111 | column_sizes = [max([len(str(v)) for v in col]) for col in columns] 112 | self._command_line.print(*[str(v).ljust(column_sizes[i]) for i, v in enumerate(column_names)], sep=' | ') 113 | self._command_line.print(*['-' * size for size in column_sizes], sep='-+-') 114 | for i, row in enumerate(rows): 115 | self._command_line.print(*[str(v).ljust(column_sizes[i]) for i, v in enumerate(row)], sep=' | ') 116 | 117 | def keyPressEvent(self, event: QtG.QKeyEvent): 118 | if event.key() in [QtC.Qt.Key_Return, QtC.Qt.Key_Enter] and self.focusWidget() != self._ok_btn: 119 | self._disable_closing = True 120 | super().keyPressEvent(event) 121 | 122 | def _on_ok_clicked(self): 123 | if self._disable_closing: 124 | self._disable_closing = False 125 | else: 126 | super()._on_ok_clicked() 127 | -------------------------------------------------------------------------------- /app/gui/flow_layout.py: -------------------------------------------------------------------------------- 1 | """PyQt5 port of the layouts/flowlayout example from Qt v4.x""" 2 | import PyQt5.QtCore as QtC 3 | import PyQt5.QtWidgets as QtW 4 | from PyQt5.QtCore import Qt 5 | 6 | 7 | class FlowLayout(QtW.QLayout): 8 | """Standard PyQt examples FlowLayout modified to work with a scrollable parent.""" 9 | 10 | def __init__(self, margin: int = 0, spacing: int = -1, parent: QtW.QWidget = None): 11 | """Creates a flow layout. 12 | 13 | :param margin: Margins value. 14 | :param spacing: Inner spacing value. 15 | :param parent: An optional parent for this layout. 16 | """ 17 | super().__init__(parent) 18 | 19 | if parent is not None: 20 | self.setContentsMargins(margin, margin, margin, margin) 21 | self.setSpacing(spacing) 22 | 23 | self._item_list: list[QtW.QLayoutItem] = [] 24 | 25 | @property 26 | def items(self) -> list[QtW.QWidget]: 27 | """Returns a list of all inner widgets in the order they have been added.""" 28 | return [item.widget() for item in self._item_list] 29 | 30 | def addItem(self, item): 31 | self._item_list.append(item) 32 | 33 | def count(self): 34 | return len(self._item_list) 35 | 36 | def itemAt(self, index): 37 | if 0 <= index < len(self._item_list): 38 | return self._item_list[index] 39 | return None 40 | 41 | def takeAt(self, index): 42 | if 0 <= index < len(self._item_list): 43 | return self._item_list.pop(index) 44 | return None 45 | 46 | def expandingDirections(self): 47 | return Qt.Orientations(Qt.Orientation(0)) 48 | 49 | def hasHeightForWidth(self): 50 | return True 51 | 52 | def heightForWidth(self, width): 53 | return self._do_layout(QtC.QRect(0, 0, width, 0), test_only=True) 54 | 55 | def setGeometry(self, rect): 56 | super().setGeometry(rect) 57 | self._do_layout(rect, test_only=False) 58 | 59 | def sizeHint(self): 60 | return self.minimumSize() 61 | 62 | def minimumSize(self): 63 | size = QtC.QSize() 64 | 65 | for item in self._item_list: 66 | size = size.expandedTo(item.minimumSize()) 67 | 68 | margin, _, _, _ = self.getContentsMargins() 69 | size += QtC.QSize(2 * margin, 2 * margin) 70 | 71 | return size 72 | 73 | def clear(self): 74 | """Removes all inner components.""" 75 | for i in reversed(range(self.count())): 76 | if self.itemAt(i).widget() is not None: 77 | # noinspection PyTypeChecker 78 | self.itemAt(i).widget().setParent(None) 79 | 80 | def _do_layout(self, rect: QtC.QRect, test_only: bool = False) -> int: 81 | """Sets the geometry of this layout and its inner widgets. 82 | 83 | :param rect: The rectangle of this layout. 84 | :param test_only: Used only in heightForWidth method. 85 | :return: The height of this layout. 86 | """ 87 | x = rect.x() 88 | y = rect.y() 89 | line_height = 0 90 | 91 | for item in self._item_list: 92 | width = item.widget() 93 | space_x = self.spacing() + width.style().layoutSpacing( 94 | QtW.QSizePolicy.PushButton, 95 | QtW.QSizePolicy.PushButton, 96 | Qt.Horizontal 97 | ) 98 | space_y = self.spacing() + width.style().layoutSpacing( 99 | QtW.QSizePolicy.PushButton, 100 | QtW.QSizePolicy.PushButton, 101 | Qt.Vertical 102 | ) 103 | next_x = x + item.sizeHint().width() + space_x 104 | if next_x - space_x > rect.right() and line_height > 0: 105 | x = rect.x() 106 | y = y + line_height + space_y 107 | next_x = x + item.sizeHint().width() + space_x 108 | line_height = 0 109 | 110 | if not test_only: 111 | item.setGeometry(QtC.QRect(QtC.QPoint(x, y), item.sizeHint())) 112 | 113 | x = next_x 114 | line_height = max(line_height, item.sizeHint().height()) 115 | 116 | return y + line_height - rect.y() 117 | 118 | def __del__(self): 119 | self.clear() 120 | 121 | 122 | class ScrollingFlowWidget(QtW.QWidget): 123 | """A resizable and scrollable widget that uses a flow layout. 124 | Use its add_widget() method to flow children into it. 125 | """ 126 | 127 | def __init__(self, parent: QtW.QWidget = None): 128 | super().__init__(parent) 129 | grid = QtW.QGridLayout(self) 130 | scroll = _ResizeScrollArea() 131 | self._wrapper = QtW.QWidget(scroll) 132 | self._flow_layout = FlowLayout(parent=self._wrapper) 133 | self._wrapper.setLayout(self._flow_layout) 134 | scroll.setWidget(self._wrapper) 135 | scroll.setWidgetResizable(True) 136 | grid.addWidget(scroll) 137 | 138 | def add_widget(self, widget: QtW.QWidget): 139 | """Adds a widget to the underlying flow flayout. 140 | 141 | :param widget: The widget to add. 142 | """ 143 | self._flow_layout.addWidget(widget) 144 | 145 | def clear_widgets(self): 146 | """Removes all widgets in the underlying flow layout.""" 147 | self._flow_layout.clear() 148 | 149 | 150 | class _ResizeScrollArea(QtW.QScrollArea): 151 | """A QScrollArea that propagates the resizing to any FlowLayout children.""" 152 | 153 | def resizeEvent(self, event): 154 | wrapper = self.findChild(QtW.QWidget) 155 | flow = wrapper.findChild(FlowLayout) 156 | 157 | if wrapper and flow: 158 | width = self.viewport().width() 159 | height = flow.heightForWidth(width) 160 | size = QtC.QSize(width, height) 161 | point = self.viewport().rect().topLeft() 162 | flow.setGeometry(QtC.QRect(point, size)) 163 | self.viewport().update() 164 | 165 | super().resizeEvent(event) 166 | -------------------------------------------------------------------------------- /app/gui/dialogs/_dialog_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as typ 4 | 5 | import PyQt5.QtCore as QtC 6 | import PyQt5.QtGui as QtG 7 | import PyQt5.QtWidgets as QtW 8 | 9 | from app import utils 10 | from app.i18n import translate as _t 11 | 12 | _T = typ.TypeVar('_T', bound='Dialog') 13 | 14 | 15 | class Dialog(QtW.QDialog): 16 | """Base class for all dialog windows.""" 17 | OK_CANCEL = 0 18 | CLOSE = 1 19 | 20 | def __init__(self, parent: QtW.QWidget = None, title: str = None, modal: bool = True, mode: int = OK_CANCEL): 21 | """Creates a dialog window. 22 | 23 | :param parent: The widget this dialog is attached to. 24 | :param title: Dialog’s title. 25 | :param modal: If true all events to the parent widget will be blocked while this dialog is visible. 26 | :param mode: Buttons mode. OK_CANCEL will add 'OK' and 'Cancel' buttons; CLOSE will add a single 'Close' button. 27 | """ 28 | super().__init__(parent) 29 | # Remove "?" button but keep "close" button. 30 | # noinspection PyTypeChecker 31 | self.setWindowFlags(self.windowFlags() & ~QtC.Qt.WindowContextHelpButtonHint) 32 | self.setModal(modal) 33 | if modal: 34 | self.setAttribute(QtC.Qt.WA_DeleteOnClose) 35 | 36 | self._buttons_mode = mode 37 | 38 | if self._buttons_mode != Dialog.OK_CANCEL and self._buttons_mode != Dialog.CLOSE: 39 | raise ValueError(f'unknown mode "{self._buttons_mode}"') 40 | 41 | self._close_action = None 42 | self._applied = False 43 | # noinspection PyUnresolvedReferences 44 | self.rejected.connect(self.close) 45 | 46 | if title: 47 | self.setWindowTitle(title) 48 | 49 | body = QtW.QVBoxLayout() 50 | center = self._init_body() 51 | if center is not None: 52 | # noinspection PyTypeChecker 53 | body.addLayout(center) 54 | body.addLayout(self.__init_button_box()) 55 | 56 | self.setLayout(body) 57 | utils.gui.center(self) 58 | 59 | def _init_body(self) -> QtW.QLayout | None: 60 | """Initializes this dialog’s body. 61 | 62 | :return: The components to disply in this dialog. 63 | """ 64 | return None 65 | 66 | def __init_button_box(self) -> QtW.QBoxLayout: 67 | """Initializes the buttons. Additional buttons can be set by overriding the _init_buttons method. These buttons 68 | will be added in the order they are returned and to the left of default buttons. 69 | 70 | :return: The list of buttons. 71 | """ 72 | box = QtW.QHBoxLayout() 73 | box.addStretch(1) 74 | if self._buttons_mode == Dialog.OK_CANCEL: 75 | icon = QtW.QStyle.SP_DialogOkButton 76 | else: 77 | icon = QtW.QStyle.SP_DialogCloseButton 78 | self._ok_btn = QtW.QPushButton( 79 | self.style().standardIcon(icon), 80 | _t('dialog.common.ok_button.label') if self._buttons_mode == Dialog.OK_CANCEL 81 | else _t('dialog.common.close_button.label'), 82 | parent=self 83 | ) 84 | self._ok_btn.clicked.connect(self._on_ok_clicked) 85 | if self._buttons_mode == Dialog.OK_CANCEL: 86 | self._cancel_btn = QtW.QPushButton( 87 | self.style().standardIcon(QtW.QStyle.SP_DialogCancelButton), 88 | _t('dialog.common.cancel_button.label'), 89 | parent=self 90 | ) 91 | self._cancel_btn.clicked.connect(self.reject) 92 | 93 | buttons = self._init_buttons() 94 | for b in buttons: 95 | box.addWidget(b) 96 | 97 | box.addWidget(self._ok_btn) 98 | if self._buttons_mode == Dialog.OK_CANCEL: 99 | box.addWidget(self._cancel_btn) 100 | 101 | return box 102 | 103 | def _init_buttons(self) -> list[QtW.QAbstractButton]: 104 | """Use this method to return additional buttons. 105 | 106 | :return: The list of additional buttons. 107 | """ 108 | return [] 109 | 110 | def set_on_close_action(self, action: typ.Callable[[_T], None]): 111 | """Sets the action that will be called when this dialog closes after the user clicked OK/Apply or Close. 112 | 113 | :param action: The action to call when this dialog closes. 114 | The dialog instance will be passed as the single argument. 115 | """ 116 | self._close_action = action 117 | 118 | def _on_ok_clicked(self): 119 | """Called when the OK button is clicked. Checks the validity of this dialog and applies changes if possible.""" 120 | if self._buttons_mode == Dialog.CLOSE: 121 | self.close() 122 | else: 123 | if not self._is_valid(): 124 | reason = self._get_error() 125 | utils.gui.show_error( 126 | reason if reason is not None else _t('dialog.common.invalid_data.text'), 127 | parent=self 128 | ) 129 | elif self._apply(): 130 | self.close() 131 | 132 | def _is_valid(self) -> bool: 133 | """Checks if this dialog’s state is valid. Called when the dialog closes. An invalid state will prevent the 134 | dialog from closing. 135 | 136 | :return: True if everything is fine; false otherwise. 137 | """ 138 | return True 139 | 140 | def _get_error(self) -> str | None: 141 | """Returns the reason data is invalid. If data is valid, None is returned.""" 142 | return None 143 | 144 | def _apply(self) -> bool: 145 | """Applies changes. Called when the dialog closes and is in a valid state. 146 | 147 | :return: Whether to close this dialog. 148 | """ 149 | self._applied = True 150 | return True 151 | 152 | def closeEvent(self, event: QtG.QCloseEvent): 153 | if self._close_action is not None and (self._applied or self._buttons_mode == self.CLOSE): 154 | self._close_action(self) 155 | -------------------------------------------------------------------------------- /app/gui/dialogs/_move_images_dialog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import shutil 5 | 6 | import PyQt5.QtWidgets as QtW 7 | 8 | from app import config, data_access, model, utils 9 | from app.i18n import translate as _t 10 | from . import _dialog_base, _progress_dialog 11 | from .. import components, threads 12 | 13 | 14 | class MoveImagesDialog(_dialog_base.Dialog): 15 | """This dialog provides tools to apply transformations to images.""" 16 | 17 | def __init__(self, images: list[model.Image], parent: QtW.QWidget = None): 18 | self._images = images 19 | super().__init__(parent=parent, title=_t('dialog.move_images.title'), modal=True, 20 | mode=_dialog_base.Dialog.OK_CANCEL) 21 | self._update_ui() 22 | 23 | def _init_body(self): 24 | layout = QtW.QVBoxLayout() 25 | 26 | # Destination 27 | self._dest_path_warning_label = components.LabelWithIcon( 28 | utils.gui.icon('warning', use_theme=False), 29 | '', 30 | parent=self 31 | ) 32 | retain_size = self._dest_path_warning_label.sizePolicy() 33 | retain_size.setRetainSizeWhenHidden(True) 34 | self._dest_path_warning_label.setSizePolicy(retain_size) 35 | layout.addWidget(self._dest_path_warning_label) 36 | 37 | dest_layout = QtW.QHBoxLayout() 38 | 39 | dest_layout.addWidget(QtW.QLabel(_t('dialog.move_images.destination'), parent=self)) 40 | 41 | self._destination_input = components.TranslatedLineEdit(parent=self) 42 | self._destination_input.textChanged.connect(self._update_ui) 43 | dest_layout.addWidget(self._destination_input, stretch=1) 44 | 45 | self._choose_destination_button = QtW.QPushButton(utils.gui.icon('folder'), '', parent=self) 46 | self._choose_destination_button.setToolTip(_t('dialog.move_images.choose_directory_button.tooltip')) 47 | self._choose_destination_button.clicked.connect(self._set_destination) 48 | dest_layout.addWidget(self._choose_destination_button) 49 | 50 | layout.addLayout(dest_layout) 51 | 52 | # Delete empty directories 53 | self._delete_empty_dirs_check = QtW.QCheckBox( 54 | _t('dialog.move_images.delete_empty_dirs_button.label'), 55 | parent=self 56 | ) 57 | layout.addWidget(self._delete_empty_dirs_check) 58 | 59 | layout.addStretch() 60 | 61 | self.setFixedSize(350, 150) 62 | 63 | return layout 64 | 65 | def _init_buttons(self) -> list[QtW.QAbstractButton]: 66 | self._ok_btn.setText(_t('dialog.move_images.move_button.label')) 67 | 68 | return [] 69 | 70 | def _set_destination(self): 71 | selection = utils.gui.open_directory_chooser(directory=config.CONFIG.last_directory, parent=self) 72 | if selection: 73 | config.CONFIG.last_directory = selection 74 | self._destination_input.setText(str(selection)) 75 | 76 | def _update_ui(self): 77 | dest = self._destination_input.text() 78 | dest_exists = dest and pathlib.Path(dest).absolute().is_dir() 79 | if not dest_exists: 80 | self._dest_path_warning_label.setText( 81 | _t('dialog.move_images.destination_empty') if not dest 82 | else _t('dialog.move_images.destination_non_existant') 83 | ) 84 | self._dest_path_warning_label.setVisible(not dest_exists) 85 | self._ok_btn.setDisabled(not dest_exists or not self._images) 86 | 87 | def _on_progress_update(self, progress: float, data: pathlib.Path, status: int): 88 | self._progress_dialog.setValue(int(progress * 100)) 89 | status_label = _t('popup.progress.status_label') 90 | if status == 1: 91 | status_ = _t('popup.progress.status.success') 92 | elif status == 2: 93 | status_ = _t('popup.progress.status.failed') 94 | else: 95 | status_ = _t('popup.progress.status.unknown') 96 | self._progress_dialog.setLabelText( 97 | f'{progress * 100:.2f} %\n{data}\n{status_label} {status_}' 98 | ) 99 | 100 | def _is_valid(self) -> bool: 101 | dest = self._destination_input.text() 102 | return dest and pathlib.Path(dest).absolute().is_dir() and self._images 103 | 104 | def _on_work_done(self): 105 | self._progress_dialog.cancel() 106 | if self._thread.failed: 107 | utils.gui.show_error(self._thread.error, parent=self) 108 | if self._thread.failed_images: 109 | errors = '\n'.join([str(image.path) for image in self._thread.failed_images]) 110 | utils.gui.show_warning(_t('popup.files_move_result_errors.text', errors=errors), parent=self) 111 | else: 112 | message = _t('popup.files_move_result_success.text') 113 | utils.gui.show_info(message, parent=self) 114 | if self._thread.failed_deletions: 115 | errors = '\n'.join([str(path) for path in self._thread.failed_deletions]) 116 | utils.gui.show_warning(_t('popup.directories_deletion_errors.text', errors=errors), parent=self) 117 | 118 | self.close() 119 | 120 | def _apply(self) -> bool: 121 | self._progress_dialog = _progress_dialog.ProgressDialog(parent=self) 122 | 123 | destination = pathlib.Path(self._destination_input.text()).absolute() 124 | self._thread = _WorkerThread(self._images, destination, self._delete_empty_dirs_check.isChecked()) 125 | self._thread.progress_signal.connect(self._on_progress_update) 126 | self._thread.finished.connect(self._on_work_done) 127 | self._progress_dialog.canceled.connect(self._thread.cancel) 128 | self._thread.start() 129 | 130 | super()._apply() 131 | return False 132 | 133 | 134 | class _WorkerThread(threads.WorkerThread): 135 | """Moves the selected files and directories to the selected destination.""" 136 | 137 | def __init__(self, images: list[model.Image], destination: pathlib.Path, delete_directories_if_empty: bool): 138 | super().__init__() 139 | self._images = images 140 | self._destination = destination 141 | self._delete_empty_dirs = delete_directories_if_empty 142 | self._failed_images = [] 143 | self._failed_deletions = [] 144 | 145 | def run(self): 146 | image_dao = data_access.ImageDao(config.CONFIG.database_path) 147 | 148 | dirs = set() 149 | total = len(self._images) 150 | progress = 0 151 | for i, image in enumerate(self._images): 152 | if self._cancelled: 153 | break 154 | self.progress_signal.emit(progress, image.path, self.STATUS_UNKNOWN) 155 | try: 156 | shutil.move(str(image.path), self._destination) 157 | except OSError: 158 | self._failed_images.append(image) 159 | ok = False 160 | else: 161 | new_path = self._destination / image.path.name 162 | ok = image_dao.update_image(image.id, new_path, image.hash) 163 | if not ok: 164 | self._failed_images.append(image) 165 | else: 166 | for d in image.path.parents: 167 | dirs.add(d) 168 | 169 | progress = i / total 170 | self.progress_signal.emit(progress, image.path, self.STATUS_SUCCESS if ok else self.STATUS_FAILED) 171 | 172 | if self._delete_empty_dirs: 173 | stop = False 174 | while not stop: # Iterate while there are still empty directories to delete 175 | nb = len(dirs) 176 | to_remove = [] 177 | for d in dirs: 178 | if d.is_dir() and not next(d.iterdir(), False): 179 | try: 180 | d.rmdir() 181 | except OSError: 182 | self._failed_deletions.append(d) 183 | to_remove.append(d) 184 | for d in to_remove: 185 | dirs.remove(d) 186 | stop = nb == len(dirs) 187 | 188 | @property 189 | def failed_images(self) -> list[model.Image]: 190 | return self._failed_images 191 | 192 | @property 193 | def failed_deletions(self) -> list[pathlib.Path]: 194 | return self._failed_deletions 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Library 2 | 3 | ## Important notice! 4 | 5 | This project is archived and thus no *longer maintained* as of 17/09/2024. 6 | 7 | A rebooted version with more features is available at https://github.com/DamiaV/ImagesLibrary. 8 | 9 | ## Description 10 | 11 | Image Library lets you manage images by associating tags to them. 12 | 13 | Main features: 14 | 15 | - Tag images 16 | - Search tagged images 17 | - Replace/move image files while keeping all associated tags 18 | - Manage tags (create, remove, associate type) 19 | - Tag completion in queries and image tags editor 20 | - Export query results as “playlists” (XML files) 21 | - Apply pattern-based transformations to images paths 22 | - Replace a tag by another on all concerned images at once 23 | - List similar images (hash-based) 24 | - Fully translated interface, available in English, French, and Esperanto 25 | - Integrated SQL console 26 | - Database auto-update 27 | 28 | ## Installation 29 | 30 | Image Library is *not* available on PyPI. Might be some day… I dunno… When I take the time to look into that. 31 | 32 | ### Version 4.0+ 33 | 34 | Download the attached zip file in the release then unpack it where you want to install the app. Once unpacked, run the 35 | `setup.sh` (Linux users) or `setup.bat` (Windows users) file to install all required dependencies. 36 | 37 | ### Version 3.1 and prior 38 | 39 | You need to build the application in order to use it. To do so, run `build.py` and wait for it to complete. Once it is 40 | done, go into `build/` and copy the application directory (`Image-Library/`) where you want to. 41 | 42 | ## Updating 43 | 44 | Delete all files and directories except database (`library.sqlite3`, may differ if changed in config file) and 45 | config (`config.ini`) files. Once done, follow [installation instructions](#Installation). Discard the new `config.ini` 46 | file if you want to keep the previous configuration. 47 | 48 | For versions 4.0+, database files are automatically updated. A backup of the old version will be created under the 49 | name `-old_.sqlite3`. For instance, updating the file `library.sqlite3` from version 3.1 will 50 | create the backup file `library-old_3.1.sqlite3`. 51 | 52 | ## Usage 53 | 54 | ### Main application 55 | 56 | #### Launch on Linux 57 | 58 | Simply run `./ImageLibrary.py`. If it does not work, you might need to change file’s user 59 | rights: `chmod u+x ./ImageLibrary.py`. 60 | 61 | #### Launch on Windows 62 | 63 | Just run `python3 ImageLibrary.py`. 64 | 65 | #### Registering images 66 | 67 | Go through the _File_ menu and click on _Add Files_ to add images or _Add Directory_ to import all images from a 68 | directory; or you can simply drag-and-drop files or directories into the main window. 69 | 70 | You should see a dialog window with a preview of an image and a text field. This text field is where you have to type 71 | the tags for the displayed image. Once you’re satisfied, click on _Apply & Continue_ or _Finish_ to go to the next 72 | image. You can click on _Skip_ to skip the current image and go directly to the next one. 73 | 74 | While editing tags, you can choose where to move the current image by clicking on _Move to…_; the path is then displayed 75 | next to the button. 76 | 77 | If the application found similar images already registered, a button labelled _Similar Images…_ will appear above the 78 | text area. It will show a list of similar images, ordered by decreasing estimated similarity. You can select one of 79 | these images and copy its tags by clicking on _Copy Tags_ (**Warning**: it will replace all tags in the text box). 80 | 81 | #### Searching for registered images 82 | 83 | You can search for images by typing queries in the search field. Syntax is as follow: 84 | 85 | - `a` will match images with tag `a` 86 | - `a b` will match images with both tags `a` *and* `b` 87 | - `a + b` will match images with tags `a` *or* `b` or *both* 88 | - `-a` will match images *without* tag `a` 89 | - `ext:"png"` will match images that are *PNG* files 90 | - `name:"*awesome pic*"` will match images whose *name* contains the string `awesome pic`; the `*` character matches any 91 | character, 0 or more times 92 | - `path:"/home/user/images/summer?.png"` will match images with *paths* like `/home/user/images/summer.png`, 93 | `/home/user/images/summer1.png`, `/home/user/images/summers.png`, etc.; the `?` character matcher any character 0 or 1 94 | times 95 | - `similar_to:"/home/user/images/house.png"` will match all images that are similar to `/home/user/images/house.png` 96 | (if it is registered in the database) 97 | 98 | Special tags accept two types of values: plain text, in between `"` and regular expressions (regex) in between `/`. 99 | 100 | As seen in the examples above, plain text values accept two special characters, `*` and `?` that match respectively 0 or 101 | more, and 0 or 1 characters. You can disable them by putting a `\` before (e.g.: `\*` will match the character `*` 102 | literally). You also have to escape all single `\` by doubling them: `\\`. For instance, to match all images whose path 103 | begin with `C:\Users\me\images\`, you will have to type `path:"C:\\Users\\me\\images\\*"`. If a path or file name 104 | contains a double quote, you have to escape it in the same way: `\"`. 105 | 106 | Regular expressions follow Python’s format. See [this page](https://www.w3schools.com/python/python_regex.asp) for 107 | explanations of the syntax. Note that you have to escape all `/` too, as this is the delimiter. 108 | 109 | More complex queries can be written by grouping with parentheses. 110 | 111 | Example: 112 | 113 | ``` 114 | a (b + c) + -(d e) ext:"jp?g" 115 | ``` 116 | 117 | Here’s how to interpret it: 118 | 119 | - `a (b + c)` returns the set of images with both tags `a` and `b` and/or both tags `a` and `c` 120 | - `-(d + e) ext:"jp?g"` = `-d -e ext:"jp?g"` returns the set of JPG images without tags `d` nor `e`; note the `?` to 121 | match both `jpg` and `jpeg` extensions 122 | 123 | The result is the union of both image sets. 124 | 125 | The application also supports compound tags, i.e. tags defined from tag queries (e.g.: tag `animal` could be defined as 126 | `cat + dog + bird`). You cannot tag images directly with compound tags, they exist only for querying purposes. 127 | 128 | ### External command line tool 129 | 130 | An external SQLite command line interface is available to interact directly with the database. Use with extreme caution 131 | as you may break the database’s structure and render it unusable by the app. 132 | 133 | Linux: Run `./ImageLibrary_cmd.py`. If you get errors, refer to [Launch on Linux](#run-linux) section. 134 | 135 | Windows: Run `python3 ImageLibrary_cmd.py`. 136 | 137 | ## Configuration file 138 | 139 | The following configurations can be modified in the `config.ini` file. If the file does not exist, launch the 140 | application at least once to generate it. 141 | 142 | - Section `[Database]`: 143 | - `File`: path to database file; can be absolute or relative to the app’s root directory 144 | - Section `[Images]`: 145 | - `LoadThumbnails`: `true` or `false` to load or not thumbnails (can be changed from app) 146 | - `ThumbnailSize`: thumbnail size in pixels (can be changed from app) 147 | - `ThumbnailLoadThreshold`: maximum number of thumbnails that can be displayed without warning when querying images 148 | - Section `[UI]`: 149 | - `Language`: language code of app’s interface; can be either `en` for English, `fr` for French, or `eo` for 150 | Esperanto 151 | 152 | ## Found a bug? 153 | 154 | If you encounter a bug or the app crashed, check the error log located in `logs/error.log` and see if there’s an error. 155 | You can send me a message or open an issue with the error and a description of how you got this error, that would be 156 | really appreciated! 157 | 158 | ## Documentation 159 | 160 | Soon… 161 | 162 | ## Requirements 163 | 164 | - [Python 3.8](https://www.python.org/downloads/release/python-380/) or above (Will *not* work with older versions) 165 | - [PyQt5](https://pypi.org/project/PyQt5/) (GUI) 166 | - [Lark](https://pypi.org/project/lark-parser/) (Query parsing) 167 | - [SymPy](https://pypi.org/project/sympy/) (Query simplification) 168 | - [scikit-image](https://pypi.org/project/scikit-image/) (Image comparison) 169 | - [OpenCV2](https://pypi.org/project/cv2imageload/) (Image comparison) 170 | - [Pyperclip](https://pypi.org/project/pyperclip/) (Copy text to clipboard) 171 | 172 | See [requirements.txt](https://github.com/Darmo117/ImageDatabase/blob/master/requirements.txt) for up-to-date list. 173 | 174 | ## Author 175 | 176 | - Damia Vergnet [@DamiaV](https://github.com/DamiaV) 177 | -------------------------------------------------------------------------------- /app/gui/dialogs/_edit_tags_dialog.py: -------------------------------------------------------------------------------- 1 | import PyQt5.QtCore as QtC 2 | import PyQt5.QtGui as QtG 3 | import PyQt5.QtWidgets as QtW 4 | 5 | from app import data_access, model, utils 6 | from app.i18n import translate as _t 7 | from . import _dialog_base, _tabs 8 | from .. import components 9 | 10 | 11 | class EditTagsDialog(_dialog_base.Dialog): 12 | """This dialog is used to edit tags and tag types.""" 13 | _TAG_TYPES_TAB = 0 14 | _COMPOUND_TAGS_TAB = 1 15 | _TAGS_TAB = 2 16 | 17 | def __init__(self, tags_dao: data_access.TagsDao, editable: bool = True, parent: QtW.QWidget = None): 18 | """Creates a dialog. 19 | 20 | :param tags_dao: Tags DAO instance. 21 | :param parent: The widget this dialog is attached to. 22 | :param editable: If true tags and types will be editable. 23 | """ 24 | self._init = False 25 | self._editable = editable 26 | 27 | def type_cell_changed(row: int, col: int, _): 28 | if col == 1: 29 | for tab in self._tabs[1:]: 30 | tag_type = self._tabs[self._TAG_TYPES_TAB].get_value(row) 31 | if tag_type is not None: 32 | tab.update_type_label(tag_type) 33 | self._check_integrity() 34 | 35 | def types_deleted(deleted_types: list[model.TagType]): 36 | for tab in self._tabs[1:]: 37 | tab.delete_types(deleted_types) 38 | 39 | self._tabs = ( 40 | _tabs.TagTypesTab(self, tags_dao, self._editable, selection_changed=self._selection_changed, 41 | cell_changed=type_cell_changed, rows_deleted=types_deleted), 42 | _tabs.CompoundTagsTab(self, tags_dao, self._editable, selection_changed=self._selection_changed, 43 | cell_changed=self._check_integrity, rows_deleted=self._check_integrity), 44 | _tabs.TagsTab(self, tags_dao, self._editable, selection_changed=self._selection_changed, 45 | cell_changed=self._check_integrity, rows_deleted=self._check_integrity) 46 | ) 47 | 48 | title = _t('dialog.edit_tags.title_edit') if self._editable else _t('dialog.edit_tags.title_readonly') 49 | mode = self.CLOSE if not self._editable else self.OK_CANCEL 50 | super().__init__(parent=parent, title=title, modal=self._editable, mode=mode) 51 | self._valid = True 52 | 53 | def _init_body(self) -> QtW.QLayout: 54 | self.setGeometry(0, 0, 480, 400) 55 | 56 | layout = QtW.QVBoxLayout() 57 | 58 | buttons = QtW.QHBoxLayout() 59 | buttons.addStretch(1) 60 | 61 | self._add_row_btn = QtW.QPushButton(parent=self) 62 | self._add_row_btn.setIcon(utils.gui.icon('list-add')) 63 | self._add_row_btn.setToolTip(_t('dialog.edit_tags.add_item_button.tooltip')) 64 | self._add_row_btn.setFixedSize(24, 24) 65 | self._add_row_btn.setFocusPolicy(QtC.Qt.NoFocus) 66 | self._add_row_btn.clicked.connect(self._add_row) 67 | buttons.addWidget(self._add_row_btn) 68 | 69 | self._delete_row_btn = QtW.QPushButton(parent=self) 70 | self._delete_row_btn.setIcon(utils.gui.icon('list-remove')) 71 | self._delete_row_btn.setToolTip(_t('dialog.edit_tags.delete_items_button.tooltip')) 72 | self._delete_row_btn.setFixedSize(24, 24) 73 | self._delete_row_btn.setFocusPolicy(QtC.Qt.NoFocus) 74 | self._delete_row_btn.clicked.connect(self._delete_selected_row) 75 | buttons.addWidget(self._delete_row_btn) 76 | 77 | if self._editable: 78 | layout.addLayout(buttons) 79 | else: 80 | self._add_row_btn.hide() 81 | self._delete_row_btn.hide() 82 | 83 | self._tabbed_pane = QtW.QTabWidget(parent=self) 84 | self._tabbed_pane.currentChanged.connect(self._tab_changed) 85 | self._init_tabs() 86 | layout.addWidget(self._tabbed_pane) 87 | 88 | search_layout = QtW.QHBoxLayout() 89 | self._search_field = _InputField(parent=self) 90 | self._search_field.setPlaceholderText(_t('dialog.edit_tags.search_field.placeholder')) 91 | self._search_field.returnPressed.connect(self._search) 92 | self._search_field.textChanged.connect(self._reset_status_label) 93 | search_layout.addWidget(self._search_field) 94 | 95 | search_btn = QtW.QPushButton( 96 | utils.gui.icon('search'), 97 | _t('dialog.edit_tags.search_button.label'), 98 | parent=self 99 | ) 100 | search_btn.clicked.connect(self._search) 101 | search_layout.addWidget(search_btn) 102 | 103 | layout.addLayout(search_layout) 104 | 105 | self._status_label = components.LabelWithIcon(parent=self) 106 | layout.addWidget(self._status_label) 107 | 108 | return layout 109 | 110 | def _init_buttons(self) -> list[QtW.QAbstractButton]: 111 | if self._editable: 112 | def apply(): 113 | self._apply() 114 | self._init_tabs() 115 | 116 | self._ok_btn.setEnabled(False) 117 | self._apply_btn = QtW.QPushButton( 118 | self.style().standardIcon(QtW.QStyle.SP_DialogApplyButton), 119 | _t('dialog.common.apply_button.label'), 120 | parent=self 121 | ) 122 | self._apply_btn.clicked.connect(apply) 123 | self._apply_btn.setEnabled(False) 124 | return [self._apply_btn] 125 | else: 126 | return [] 127 | 128 | def _init_tabs(self): 129 | self._tabbed_pane.clear() 130 | for tab in self._tabs: 131 | tab.init() 132 | self._tabbed_pane.addTab(tab.table, tab.title) 133 | 134 | def _add_row(self): 135 | self._tabs[self._tabbed_pane.currentIndex()].add_row() 136 | self._check_integrity() 137 | 138 | def _delete_selected_row(self): 139 | self._tabs[self._tabbed_pane.currentIndex()].delete_selected_rows() 140 | self._check_integrity() 141 | 142 | def _tab_changed(self, index: int): 143 | self._add_row_btn.setEnabled(self._tabs[index].addable) 144 | self._update_delete_row_btn(index) 145 | 146 | def _selection_changed(self): 147 | self._update_delete_row_btn(self._tabbed_pane.currentIndex()) 148 | 149 | def _update_delete_row_btn(self, index: int): 150 | tab = self._tabs[index] 151 | self._delete_row_btn.setEnabled(tab.deletable and tab.selected_rows_number != 0) 152 | 153 | def _reset_status_label(self): 154 | self._status_label.setText('') 155 | self._status_label.setIcon(None) 156 | 157 | def _search(self): 158 | text = self._search_field.text().strip() 159 | if len(text) > 0: 160 | found = self._tabs[self._tabbed_pane.currentIndex()].search(text) 161 | if found is None: 162 | self._status_label.setText(_t('dialog.edit_tags.syntax_error')) 163 | self._status_label.setIcon(utils.gui.icon('warning')) 164 | elif not found: 165 | self._status_label.setText(_t('dialog.edit_tags.no_match')) 166 | self._status_label.setIcon(utils.gui.icon('help-about')) 167 | else: 168 | self._reset_status_label() 169 | self._search_field.setFocus() 170 | 171 | def _is_valid(self) -> bool: 172 | return self._valid 173 | 174 | def _apply(self) -> bool: 175 | ok = all(map(lambda t: t.apply(), self._tabs)) 176 | if not ok: 177 | utils.gui.show_error(_t('dialog.edit_tags.error.saving'), parent=self) 178 | else: 179 | self._apply_btn.setEnabled(False) 180 | super()._apply() 181 | 182 | return True 183 | 184 | def _check_integrity(self, *_): 185 | """Checks the integrity of all tables. Parameters are ignored, they are here only to conform to the Tab class 186 | constructor. 187 | """ 188 | self._valid = all(map(lambda t: t.check_integrity(), self._tabs)) 189 | edited_rows_nb = sum(map(lambda t: t.modified_rows_number, self._tabs)) 190 | self._apply_btn.setEnabled(edited_rows_nb > 0 and self._valid) 191 | self._ok_btn.setEnabled(self._valid) 192 | 193 | 194 | class _InputField(components.TranslatedLineEdit): 195 | def keyPressEvent(self, event: QtG.QKeyEvent): 196 | # Prevent event from propagating to the search button 197 | if event.key() in [QtC.Qt.Key_Return, QtC.Qt.Key_Enter]: 198 | event.ignore() 199 | else: 200 | super().keyPressEvent(event) 201 | -------------------------------------------------------------------------------- /app/gui/dialogs/_settings_dialog.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import PyQt5.QtWidgets as QtW 4 | 5 | from app import config, constants, i18n, utils 6 | from app.i18n import translate as _t 7 | from . import _dialog_base 8 | from .. import components 9 | 10 | 11 | class SettingsDialog(_dialog_base.Dialog): 12 | """This dialog lets users edit app settings.""" 13 | 14 | def __init__(self, parent: QtW.QWidget = None): 15 | self._initial_config = config.CONFIG.copy(replace_by_pending=True) 16 | super().__init__(parent, _t('dialog.settings.title'), modal=True) 17 | self._update_ui() 18 | 19 | def _init_body(self) -> QtW.QLayout | None: 20 | layout = QtW.QVBoxLayout() 21 | 22 | # Database 23 | db_box = QtW.QGroupBox(_t('dialog.settings.box.database.title'), parent=self) 24 | db_box_layout = QtW.QGridLayout() 25 | 26 | self._db_path_warning_label = components.LabelWithIcon( 27 | utils.gui.icon('warning', use_theme=False), 28 | _t('dialog.settings.box.database.db_path_warning'), 29 | parent=self 30 | ) 31 | retain_size = self._db_path_warning_label.sizePolicy() 32 | retain_size.setRetainSizeWhenHidden(True) 33 | self._db_path_warning_label.setSizePolicy(retain_size) 34 | font = self._db_path_warning_label.font() 35 | font.setPointSizeF(font.pointSizeF() * 0.8) 36 | self._db_path_warning_label.setFont(font) 37 | db_box_layout.addWidget(self._db_path_warning_label, 0, 0, 1, 3) 38 | 39 | db_box_layout.addWidget(QtW.QLabel(_t('dialog.settings.box.database.db_path.label')), 1, 0) 40 | self._db_path_input = components.TranslatedLineEdit(str(self._initial_config.database_path), parent=self) 41 | self._db_path_input.textChanged.connect(self._update_ui) 42 | db_box_layout.addWidget(self._db_path_input, 1, 1) 43 | choose_file_button = QtW.QPushButton(utils.gui.icon('document-open'), '', parent=self) 44 | choose_file_button.setToolTip(_t('dialog.settings.box.database.choose_file_button.tooltip')) 45 | choose_file_button.clicked.connect(self._open_db_file_chooser) 46 | db_box_layout.addWidget(choose_file_button, 1, 2) 47 | 48 | db_box.setLayout(db_box_layout) 49 | layout.addWidget(db_box) 50 | 51 | # Thumbnails 52 | thumbs_box = QtW.QGroupBox(_t('dialog.settings.box.thumbnails.title'), parent=self) 53 | thumbs_box_layout = QtW.QGridLayout() 54 | 55 | self._load_thumbs_check = QtW.QCheckBox( 56 | _t('dialog.settings.box.thumbnails.load_thumbs_button.label'), 57 | parent=self 58 | ) 59 | self._load_thumbs_check.setChecked(self._initial_config.load_thumbnails) 60 | thumbs_box_layout.addWidget(self._load_thumbs_check, 0, 0, 1, 2) 61 | 62 | thumbs_box_layout.addWidget( 63 | QtW.QLabel(_t('dialog.settings.box.thumbnails.thumbs_size'), parent=self), 64 | 1, 0 65 | ) 66 | self._thumbs_size_input = components.IntLineEdit( 67 | constants.MIN_THUMB_SIZE, 68 | constants.MAX_THUMB_SIZE, 69 | parent=self 70 | ) 71 | self._thumbs_size_input.set_value(self._initial_config.thumbnail_size) 72 | self._thumbs_size_input.textChanged.connect(self._update_ui) 73 | self._thumbs_size_input.setMaximumWidth(100) 74 | thumbs_box_layout.addWidget(self._thumbs_size_input, 1, 2) 75 | 76 | thumbs_box_layout.addWidget( 77 | QtW.QLabel(_t('dialog.settings.box.thumbnails.thumbs_threshold'), parent=self), 78 | 2, 0 79 | ) 80 | self._thumbs_load_threshold_input = components.IntLineEdit( 81 | constants.MIN_THUMB_LOAD_THRESHOLD, 82 | constants.MAX_THUMB_LOAD_THRESHOLD, 83 | parent=self 84 | ) 85 | self._thumbs_load_threshold_input.set_value(self._initial_config.thumbnail_load_threshold) 86 | self._thumbs_load_threshold_input.textChanged.connect(self._update_ui) 87 | self._thumbs_load_threshold_input.setMaximumWidth(100) 88 | thumbs_box_layout.addWidget(self._thumbs_load_threshold_input, 2, 2) 89 | 90 | thumbs_box.setLayout(thumbs_box_layout) 91 | 92 | layout.addWidget(thumbs_box) 93 | 94 | # Language 95 | language_box = QtW.QGroupBox(_t('dialog.settings.box.language.title'), parent=self) 96 | language_box_layout = QtW.QVBoxLayout() 97 | 98 | language_chooser_layout = QtW.QHBoxLayout() 99 | language_box_layout.addWidget(QtW.QLabel(_t('dialog.settings.box.language.language'), parent=self)) 100 | self._lang_combo = QtW.QComboBox(parent=self) 101 | for i, lang in enumerate(i18n.get_languages()): 102 | self._lang_combo.addItem(lang.name, userData=lang) 103 | if lang == self._initial_config.language: 104 | self._lang_combo.setCurrentIndex(i) 105 | self._lang_combo.currentIndexChanged.connect(self._update_ui) 106 | language_chooser_layout.addWidget(self._lang_combo) 107 | language_box_layout.addLayout(language_chooser_layout) 108 | 109 | language_box.setLayout(language_box_layout) 110 | layout.addWidget(language_box) 111 | 112 | layout.addStretch() 113 | 114 | self.setMinimumSize(300, 300) 115 | self.setGeometry(0, 0, 450, 420) 116 | 117 | body_layout = QtW.QVBoxLayout() 118 | scroll = QtW.QScrollArea(parent=self) 119 | scroll.setWidgetResizable(True) 120 | w = QtW.QWidget(parent=self) 121 | w.setLayout(layout) 122 | scroll.setWidget(w) 123 | body_layout.addWidget(scroll) 124 | 125 | return body_layout 126 | 127 | def _init_buttons(self) -> list[QtW.QAbstractButton]: 128 | self._apply_button = QtW.QPushButton( 129 | self.style().standardIcon(QtW.QStyle.SP_DialogApplyButton), 130 | _t('dialog.common.apply_button.label'), 131 | parent=self 132 | ) 133 | self._apply_button.clicked.connect(self._apply) 134 | return [self._apply_button] 135 | 136 | def _open_db_file_chooser(self): 137 | file = utils.gui.open_file_chooser(single_selection=True, mode=utils.gui.FILTER_DB, 138 | directory=config.CONFIG.last_directory, parent=self) 139 | if file: 140 | config.CONFIG.last_directory = file.parent 141 | self._db_path_input.setText(str(file)) 142 | 143 | def _update_ui(self): 144 | self._db_path_warning_label.setVisible(not pathlib.Path(self._db_path_input.text()).exists()) 145 | self._apply_button.setDisabled(not self._settings_changed()) 146 | 147 | def _settings_changed(self) -> bool: 148 | db_path = pathlib.Path(self._db_path_input.text()) 149 | load_thumbs = self._load_thumbs_check.isChecked() 150 | thumbs_size = self._thumbs_size_input.value() 151 | thumbs_load_threshold = self._thumbs_load_threshold_input.value() 152 | language = self._lang_combo.currentData() 153 | return ((db_path != self._initial_config.database_path) 154 | or load_thumbs != self._initial_config.load_thumbnails 155 | or thumbs_size != self._initial_config.thumbnail_size 156 | or thumbs_load_threshold != self._initial_config.thumbnail_load_threshold 157 | or language != self._initial_config.language) 158 | 159 | def _apply(self) -> bool: 160 | changed = self._settings_changed() 161 | db_path = pathlib.Path(self._db_path_input.text()) 162 | load_thumbs = self._load_thumbs_check.isChecked() 163 | thumbs_size = self._thumbs_size_input.value() 164 | thumbs_load_threshold = self._thumbs_load_threshold_input.value() 165 | language = self._lang_combo.currentData() 166 | 167 | needs_restart = False 168 | if db_path != self._initial_config.database_path: 169 | config.CONFIG.database_path = db_path 170 | needs_restart = True 171 | config.CONFIG.load_thumbnails = load_thumbs 172 | config.CONFIG.thumbnail_size = thumbs_size 173 | config.CONFIG.thumbnail_load_threshold = thumbs_load_threshold 174 | if language != self._initial_config.language: 175 | config.CONFIG.language = language 176 | needs_restart = True 177 | 178 | config.CONFIG.save() 179 | 180 | if needs_restart and changed: 181 | utils.gui.show_info(_t('popup.app_needs_restart.text'), parent=self) 182 | 183 | self._initial_config = config.CONFIG.copy(replace_by_pending=True) 184 | self._update_ui() 185 | 186 | return super()._apply() 187 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import configparser 4 | import pathlib 5 | 6 | from . import constants, i18n, logging 7 | 8 | 9 | class ConfigError(ValueError): 10 | pass 11 | 12 | 13 | _DEFAULT_LANG_CODE = 'en' 14 | _DEFAULT_DB_PATH = pathlib.Path('library.sqlite3').absolute() 15 | _DEFAULT_LOAD_THUMBS = True 16 | _DEFAULT_THUMBS_SIZE = 200 17 | _DEFAULT_THUMBS_LOAD_THRESHOLD = 50 18 | _DEFAULT_DEBUG = False 19 | 20 | 21 | class Config: 22 | def __init__( 23 | self, 24 | language: i18n.Language, 25 | database_path: pathlib.Path, 26 | load_thumbnails: bool, 27 | thumbnail_size: int, 28 | thumbnail_load_threshold: int, 29 | debug: bool, ): 30 | """Creates a new configuration object. 31 | 32 | :param language: App’s UI language. 33 | :param database_path: Path to database file. 34 | :param load_thumbnails: Whether to load thumbnails when querying images. 35 | :param thumbnail_size: Thumbnails maximum width and height. 36 | :param thumbnail_load_threshold: Limit above which thumbnails will 37 | automatically be disabled to avoid memory issues. 38 | :param debug: Whether to load the app in debug mode. Set to True if you have issues with file dialogs. 39 | """ 40 | self._language = language 41 | self._language_pending = None 42 | self._database_path = database_path 43 | self._database_path_pending = None 44 | self.load_thumbnails = load_thumbnails 45 | self.thumbnail_size = thumbnail_size 46 | self.thumbnail_load_threshold = thumbnail_load_threshold 47 | self._debug = debug 48 | self._last_directory = None 49 | 50 | @property 51 | def language(self) -> i18n.Language: 52 | return self._language 53 | 54 | @language.setter 55 | def language(self, value: i18n.Language): 56 | self._language_pending = value 57 | 58 | @property 59 | def language_pending(self) -> i18n.Language | None: 60 | return self._language_pending 61 | 62 | @property 63 | def database_path(self) -> pathlib.Path: 64 | return self._database_path 65 | 66 | @database_path.setter 67 | def database_path(self, value: pathlib.Path): 68 | self._database_path_pending = value 69 | 70 | @property 71 | def database_path_pending(self) -> pathlib.Path | None: 72 | return self._database_path_pending 73 | 74 | @property 75 | def debug(self) -> bool: 76 | return self._debug 77 | 78 | @property 79 | def last_directory(self) -> pathlib.Path | None: 80 | return self._last_directory 81 | 82 | @last_directory.setter 83 | def last_directory(self, value: pathlib.Path): 84 | self._last_directory = value 85 | 86 | @property 87 | def app_needs_restart(self) -> bool: 88 | """Whether the application needs to be restarted to apply some changes.""" 89 | return self._language_pending or self._database_path_pending 90 | 91 | def copy(self, replace_by_pending: bool = False) -> Config: 92 | """Returns a copy of this Config object.""" 93 | return Config( 94 | language=self.language if not replace_by_pending or not self.language_pending else self.language_pending, 95 | database_path=(self.database_path if not replace_by_pending or not self.database_path_pending 96 | else self.database_path_pending), 97 | load_thumbnails=self.load_thumbnails, 98 | thumbnail_size=self.thumbnail_size, 99 | thumbnail_load_threshold=self.thumbnail_load_threshold, 100 | debug=self.debug, 101 | ) 102 | 103 | def save(self): 104 | """Saves the config to the file specified in app.constants.CONFIG_FILE.""" 105 | parser = configparser.ConfigParser(strict=True) 106 | parser.optionxform = str 107 | 108 | parser[_UI_SECTION] = { 109 | _LANG_KEY: self.language_pending.code if self.language_pending else self.language.code, 110 | } 111 | if self.debug: # Write only if True 112 | parser[_UI_SECTION][_DEBUG_KEY] = 'true' 113 | parser[_IMAGES_SECTION] = { 114 | _LOAD_THUMBS_KEY: str(self.load_thumbnails).lower(), 115 | _THUMB_SIZE_KEY: self.thumbnail_size, 116 | _THUMB_LOAD_THRESHOLD_KEY: self.thumbnail_load_threshold, 117 | } 118 | parser[_DB_SECTION] = { 119 | _FILE_KEY: str(self.database_path_pending or self.database_path), 120 | } 121 | 122 | try: 123 | with constants.CONFIG_FILE.open(mode='w', encoding='UTF-8') as configfile: 124 | parser.write(configfile) 125 | except IOError as e: 126 | logging.logger.exception(e) 127 | return False 128 | else: 129 | return True 130 | 131 | 132 | # noinspection PyTypeChecker 133 | CONFIG: Config = None 134 | 135 | _UI_SECTION = 'UI' 136 | _DEBUG_KEY = 'Debug' 137 | _LANG_KEY = 'Language' 138 | 139 | _DB_SECTION = 'Database' 140 | _FILE_KEY = 'File' 141 | 142 | _IMAGES_SECTION = 'Images' 143 | _LOAD_THUMBS_KEY = 'LoadThumbnails' 144 | _THUMB_SIZE_KEY = 'ThumbnailSize' 145 | _THUMB_LOAD_THRESHOLD_KEY = 'ThumbnailLoadThreshold' 146 | 147 | 148 | def load_config(): 149 | """Loads the configuration file specified in app.constants.CONFIG_FILE. 150 | If the file does not exist, a default config will be returned. 151 | 152 | :raise ConfigError: If an option is missing or has an illegal value. 153 | """ 154 | global CONFIG 155 | 156 | if not i18n.load_languages(): 157 | raise ConfigError(f'could not load languages') 158 | 159 | lang_code = _DEFAULT_LANG_CODE 160 | database_path = _DEFAULT_DB_PATH 161 | load_thumbs = _DEFAULT_LOAD_THUMBS 162 | thumbs_size = _DEFAULT_THUMBS_SIZE 163 | thumbs_load_threshold = _DEFAULT_THUMBS_LOAD_THRESHOLD 164 | debug = _DEFAULT_DEBUG 165 | 166 | config_file_exists = constants.CONFIG_FILE.is_file() 167 | 168 | if config_file_exists: 169 | config_parser = configparser.ConfigParser() 170 | config_parser.read(constants.CONFIG_FILE) 171 | try: 172 | # UI section 173 | lang_code = config_parser.get(_UI_SECTION, _LANG_KEY, fallback=_DEFAULT_LANG_CODE) 174 | debug = _to_bool(config_parser.get(_UI_SECTION, _DEBUG_KEY, fallback=_DEFAULT_DEBUG)) 175 | 176 | # Images section 177 | load_thumbs = _to_bool(config_parser.get(_IMAGES_SECTION, _LOAD_THUMBS_KEY, 178 | fallback=str(_DEFAULT_LOAD_THUMBS))) 179 | 180 | try: 181 | thumbs_size = int( 182 | config_parser.get(_IMAGES_SECTION, _THUMB_SIZE_KEY, fallback=str(_DEFAULT_THUMBS_SIZE))) 183 | except ValueError as e: 184 | raise ConfigError(f'key {_THUMB_SIZE_KEY!r}: {e}') 185 | if thumbs_size < constants.MIN_THUMB_SIZE or thumbs_size > constants.MAX_THUMB_SIZE: 186 | raise ConfigError( 187 | f'illegal thumbnail size {thumbs_size}px, must be between {constants.MIN_THUMB_SIZE}px ' 188 | f'and {constants.MAX_THUMB_SIZE}px') 189 | 190 | try: 191 | thumbs_load_threshold = int(config_parser.get(_IMAGES_SECTION, _THUMB_LOAD_THRESHOLD_KEY, 192 | fallback=_DEFAULT_THUMBS_LOAD_THRESHOLD)) 193 | except ValueError as e: 194 | raise ConfigError(f'key {_THUMB_LOAD_THRESHOLD_KEY!r}: {e}') 195 | if thumbs_load_threshold < 0: 196 | raise ConfigError(f'illegal thumbnail load threshold {thumbs_load_threshold}, must be between ' 197 | f'{constants.MIN_THUMB_LOAD_THRESHOLD}px and {constants.MAX_THUMB_LOAD_THRESHOLD}px') 198 | 199 | # Database section 200 | database_path = pathlib.Path( 201 | config_parser.get(_DB_SECTION, _FILE_KEY, fallback=_DEFAULT_DB_PATH)).absolute() 202 | except ValueError as e: 203 | raise ConfigError(e) 204 | except KeyError as e: 205 | raise ConfigError(f'missing key {e}') 206 | 207 | language = i18n.get_language(lang_code) or i18n.get_language(_DEFAULT_LANG_CODE) 208 | if not language: 209 | raise ConfigError('could not load language') 210 | 211 | CONFIG = Config(language, database_path, load_thumbs, thumbs_size, thumbs_load_threshold, debug) 212 | 213 | if not config_file_exists: 214 | CONFIG.save() 215 | 216 | 217 | def _to_bool(value: str | bool) -> bool: 218 | if isinstance(value, bool): 219 | return value 220 | elif value.lower() in ['true', '1', 'yes']: 221 | return True 222 | elif value.lower() in ['false', '0', 'no']: 223 | return False 224 | else: 225 | raise ConfigError(f'illegal value {repr(value)} for key {repr(_LOAD_THUMBS_KEY)}') 226 | -------------------------------------------------------------------------------- /app/utils/gui.py: -------------------------------------------------------------------------------- 1 | """Utility functions to display popup messages and file dialogs, and various functions related to Qt.""" 2 | import pathlib 3 | import platform 4 | import subprocess 5 | 6 | import PyQt5.QtGui as QtG 7 | import PyQt5.QtWidgets as QtW 8 | 9 | from . import files 10 | from .. import config, constants 11 | from ..i18n import translate as _t 12 | 13 | 14 | def show_info(message: str, title='popup.info.title', parent: QtW.QWidget = None): 15 | """Shows an information popup. 16 | 17 | :param message: Popup’s message. 18 | :param title: Popup’s unlocalized title. 19 | :param parent: Popup’s parent. 20 | """ 21 | mb = QtW.QMessageBox(QtW.QMessageBox.Information, _t(title), message, buttons=QtW.QMessageBox.Ok, parent=parent) 22 | mb.button(QtW.QMessageBox.Ok).setText(_t('dialog.common.ok_button.label')) 23 | mb.exec_() 24 | 25 | 26 | def show_warning(message: str, title: str = 'popup.warning.title', parent: QtW.QWidget = None): 27 | """Shows a warning popup. 28 | 29 | :param message: Popup’s message. 30 | :param title: Popup’s unlocalized title. 31 | :param parent: Popup’s parent. 32 | """ 33 | mb = QtW.QMessageBox(QtW.QMessageBox.Warning, _t(title), message, buttons=QtW.QMessageBox.Ok, parent=parent) 34 | mb.button(QtW.QMessageBox.Ok).setText(_t('dialog.common.ok_button.label')) 35 | mb.exec_() 36 | 37 | 38 | def show_error(message: str, title: str = 'popup.error.title', parent: QtW.QWidget = None): 39 | """Shows an error popup. 40 | 41 | :param message: Popup’s message. 42 | :param title: Popup’s unlocalized title. 43 | :param parent: Popup’s parent. 44 | """ 45 | mb = QtW.QMessageBox(QtW.QMessageBox.Critical, _t(title), message, buttons=QtW.QMessageBox.Ok, parent=parent) 46 | mb.button(QtW.QMessageBox.Ok).setText(_t('dialog.common.ok_button.label')) 47 | mb.exec_() 48 | 49 | 50 | def show_question(message: str, title: str = 'popup.question.title', cancel: bool = False, 51 | parent: QtW.QWidget = None) -> bool | None: 52 | """Shows a question popup. 53 | 54 | :param message: Popup’s message. 55 | :param title: Popup’s unlocalized title. 56 | :param cancel: If true a "Cancel" button will be added. 57 | :param parent: Popup’s parent. 58 | :return: True for yes, False for no or None for cancel. 59 | """ 60 | answers = { 61 | QtW.QMessageBox.Yes: True, 62 | QtW.QMessageBox.No: False, 63 | QtW.QMessageBox.Cancel: None, 64 | } 65 | buttons = QtW.QMessageBox.Yes | QtW.QMessageBox.No 66 | if cancel: 67 | buttons |= QtW.QMessageBox.Cancel 68 | 69 | mb = QtW.QMessageBox(QtW.QMessageBox.Question, _t(title), message, buttons=buttons, parent=parent) 70 | mb.button(QtW.QMessageBox.Yes).setText(_t('dialog.common.yes_button.label')) 71 | mb.button(QtW.QMessageBox.No).setText(_t('dialog.common.no_button.label')) 72 | if cancel: 73 | mb.button(QtW.QMessageBox.Cancel).setText(_t('dialog.common.cancel_button.label')) 74 | # noinspection PyTypeChecker 75 | return answers[mb.exec_()] 76 | 77 | 78 | def show_text_input(message: str, title: str, text: str = '', parent: QtW.QWidget = None) -> str | None: 79 | """Shows an input popup. 80 | 81 | :param message: Popup’s message. 82 | :param title: Popup’s title. 83 | :param text: Text to show in the input field. 84 | :param parent: Popup’s parent. 85 | :return: The typed text or None if the popup was cancelled. 86 | """ 87 | input_d = QtW.QInputDialog(parent=parent) 88 | input_d.setWindowTitle(title) 89 | input_d.setLabelText(message) 90 | input_d.setTextValue(text) 91 | input_d.setOkButtonText(_t('dialog.common.ok_button.label')) 92 | input_d.setCancelButtonText(_t('dialog.common.cancel_button.label')) 93 | ok = input_d.exec_() 94 | return input_d.textValue() if ok else None 95 | 96 | 97 | FILTER_IMAGES = 0 98 | FILTER_DB = 1 99 | 100 | 101 | def open_file_chooser(single_selection: bool, mode: int, directory: pathlib.Path = None, parent: QtW.QWidget = None) \ 102 | -> list[pathlib.Path] | pathlib.Path | None: 103 | """Opens a file chooser for images. 104 | 105 | :param single_selection: Whether the user can select only a single file. 106 | :param mode: What file filter to apply. 107 | :param directory: The directory to open the chooser in. 108 | :param parent: Chooser’s parent. 109 | :return: The selected files or None if the chooser was cancelled. 110 | """ 111 | kwargs = {} 112 | if config.CONFIG.debug: 113 | kwargs['options'] = QtW.QFileDialog.DontUseNativeDialog 114 | if mode == FILTER_IMAGES: 115 | caption_k = 'image' if single_selection else 'images' 116 | filter_k = 'images' 117 | exts = ' '.join(map(lambda e: '*.' + e, constants.IMAGE_FILE_EXTENSIONS)) 118 | else: 119 | caption_k = filter_k = 'database' 120 | exts = '*.sqlite3' 121 | function = QtW.QFileDialog.getOpenFileName if single_selection else QtW.QFileDialog.getOpenFileNames 122 | selection, _ = function( 123 | caption=_t('popup.file_chooser.caption.' + caption_k), 124 | directory=str(directory) if directory else None, 125 | filter=_t('popup.file_chooser.filter.' + filter_k) + f' ({exts})', 126 | parent=parent, 127 | **kwargs 128 | ) 129 | 130 | if selection: 131 | def check_ext(p: str) -> bool: 132 | return ((mode == FILTER_IMAGES and files.accept_image_file(p)) 133 | or (mode == FILTER_DB and files.get_extension(p) == 'sqlite3')) 134 | 135 | # Check extensions if user removed filter 136 | if single_selection: 137 | return pathlib.Path(selection).absolute() if check_ext(selection) else None 138 | else: 139 | return [pathlib.Path(s).absolute() for s in selection if check_ext(s)] 140 | return None 141 | 142 | 143 | def open_directory_chooser(directory: pathlib.Path = None, parent: QtW.QWidget = None) -> pathlib.Path | None: 144 | """Opens a directory chooser. 145 | 146 | :param directory: The directory to open the chooser in. 147 | :param parent: Chooser’s parent. 148 | :return: The selected directory or None if the chooser was cancelled. 149 | """ 150 | options = QtW.QFileDialog.ShowDirsOnly 151 | if config.CONFIG.debug: 152 | options |= QtW.QFileDialog.DontUseNativeDialog 153 | if directory: 154 | if directory.is_file(): 155 | d = str(directory.parent) 156 | else: 157 | d = str(directory) 158 | else: 159 | d = None 160 | dir_ = QtW.QFileDialog.getExistingDirectory( 161 | caption=_t('popup.directory_chooser.caption'), 162 | directory=d, 163 | parent=parent, 164 | options=options 165 | ) 166 | return pathlib.Path(dir_).absolute() if dir_ else None 167 | 168 | 169 | def open_playlist_saver(directory: pathlib.Path = None, parent: QtW.QWidget = None) -> pathlib.Path | None: 170 | """Opens a file saver for playlists. 171 | 172 | :param directory: The directory to open the chooser in. 173 | :param parent: Saver’s parent. 174 | :return: The selected file’s path or None if the saver was cancelled. 175 | """ 176 | kwargs = {} 177 | if config.CONFIG.debug: 178 | kwargs['options'] = QtW.QFileDialog.DontUseNativeDialog 179 | ext = '.play' 180 | file, _ = QtW.QFileDialog.getSaveFileName( 181 | caption=_t('popup.playlist_saver.caption'), 182 | directory=str(directory) if directory else None, 183 | filter=_t('popup.playlist_saver.filter') + f' (*{ext})', 184 | parent=parent, 185 | **kwargs 186 | ) 187 | if file and not file.endswith(ext): 188 | file += ext 189 | return pathlib.Path(file).absolute() if file else None 190 | 191 | 192 | def center(window: QtW.QWidget): 193 | """Centers the given window on the screen. 194 | 195 | :param window: The window to center. 196 | """ 197 | rect = window.frameGeometry() 198 | rect.moveCenter(QtW.QDesktopWidget().availableGeometry().center()) 199 | window.move(rect.topLeft()) 200 | 201 | 202 | def show_file(file_path: pathlib.Path): 203 | """Shows the given file in the system’s file explorer.""" 204 | try: 205 | path = str(file_path.absolute()) 206 | except RuntimeError: # Raised if loop is encountered in path 207 | return 208 | os_name = platform.system().lower() 209 | if os_name == 'windows': 210 | subprocess.Popen(f'explorer /select,"{path}"') 211 | elif os_name == 'linux': 212 | command = ['dbus-send', '--dest=org.freedesktop.FileManager1', '--type=method_call', 213 | '/org/freedesktop/FileManager1', 'org.freedesktop.FileManager1.ShowItems', 214 | f'array:string:file:{path}', 'string:""'] 215 | subprocess.Popen(command) 216 | elif os_name == 'darwin': # OS-X 217 | subprocess.Popen(['open', '-R', path]) 218 | 219 | 220 | def negate(color: QtG.QColor) -> QtG.QColor: 221 | """Negates the given color. 222 | 223 | :param color: The base color. 224 | :return: The negated color. 225 | """ 226 | return QtG.QColor(255 - color.red(), 255 - color.green(), 255 - color.blue()) 227 | 228 | 229 | _BLACK = QtG.QColor(0, 0, 0) 230 | _WHITE = QtG.QColor(255, 255, 255) 231 | 232 | 233 | def font_color(bg_color: QtG.QColor) -> QtG.QColor: 234 | """Computes the font color that will yeild the best contrast with the given background color. 235 | 236 | @see 237 | https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color 238 | 239 | :param bg_color: Background color. 240 | :return: Font color with highest contrast. 241 | """ 242 | luminance = 0.2126 * bg_color.redF() + 0.7152 * bg_color.greenF() + 0.0722 * bg_color.blueF() 243 | return _BLACK if luminance > 0.179 else _WHITE 244 | 245 | 246 | def icon(icon_name: str, use_theme: bool = True) -> QtG.QIcon: 247 | """Returns a QIcon for the given icon name. 248 | 249 | :param icon_name: Icon name, without file extension. 250 | :param use_theme: Whether to icons theme. 251 | :return: The QIcon object. 252 | """ 253 | 254 | if use_theme: 255 | icon_ = QtG.QIcon.fromTheme(icon_name) 256 | else: 257 | icon_ = None 258 | 259 | if not icon_ or icon_.isNull(): 260 | return QtG.QIcon(str(constants.ICONS_DIR / (icon_name + '.png'))) 261 | else: 262 | return icon_ 263 | 264 | 265 | def get_key_sequence(event: QtG.QKeyEvent) -> QtG.QKeySequence: 266 | """Returns a QKeySequence object for the keystroke of the given event.""" 267 | # noinspection PyTypeChecker 268 | return QtG.QKeySequence(event.modifiers() | event.key()) 269 | 270 | 271 | def event_matches_action(event: QtG.QKeyEvent, action: QtW.QAction) -> bool: 272 | """Checks whether the keystroke of the given event exactly matches any of the shortcuts of the given action.""" 273 | ks = get_key_sequence(event) 274 | return any([s.matches(ks) == QtG.QKeySequence.ExactMatch for s in action.shortcuts()]) 275 | 276 | 277 | def translate_text_widget_menu(menu: QtW.QMenu): 278 | """Translates the text of each action from the given text widget’s context menu.""" 279 | keys = [ 280 | 'menu_common.undo_item', 281 | 'menu_common.redo_item', 282 | 'menu_common.cut_item', 283 | 'menu_common.copy_item', 284 | 'menu_common.paste_item', 285 | 'menu_common.delete_item', 286 | 'menu_common.select_all_item', 287 | ] 288 | i = 0 289 | for action in menu.actions(): 290 | if not action.isSeparator(): 291 | shortcut = action.text().split('\t')[1] if '\t' in action.text() else '' 292 | action.setText(_t(keys[i]) + '\t' + shortcut) 293 | i += 1 294 | -------------------------------------------------------------------------------- /app/gui/image_list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import typing as typ 5 | 6 | import PyQt5.QtCore as QtC 7 | import PyQt5.QtGui as QtG 8 | import PyQt5.QtWidgets as QtW 9 | import pyperclip 10 | 11 | from . import components, flow_layout 12 | from .. import config, model, utils 13 | from ..i18n import translate as _t 14 | 15 | SelectionChangeListener = typ.Callable[[typ.Iterable[model.Image]], None] 16 | ItemDoubleClickListener = typ.Callable[[model.Image], None] 17 | 18 | 19 | ################ 20 | # Base classes # 21 | ################ 22 | 23 | 24 | class ImageItem: 25 | """An ImageItem is an item that holds an image.""" 26 | 27 | def __init__(self): 28 | self._image = None 29 | 30 | @property 31 | def image(self) -> model.Image: 32 | """Returns the image.""" 33 | return self._image 34 | 35 | 36 | class ImageListView: 37 | def _init_contextual_menu(self): 38 | # noinspection PyTypeChecker 39 | self._menu = QtW.QMenu(parent=self) 40 | 41 | self._copy_paths_action = self._menu.addAction( 42 | utils.gui.icon('edit-copy'), 43 | _t('main_window.tab.context_menu.copy_path_item'), 44 | self.copy_image_paths, 45 | 'Ctrl+C' 46 | ) 47 | self._select_all_action = self._menu.addAction( 48 | utils.gui.icon('edit-select-all'), 49 | _t('menu_common.select_all_item'), 50 | self.select_all, 51 | 'Ctrl+A' 52 | ) 53 | 54 | # noinspection PyUnresolvedReferences 55 | self.setContextMenuPolicy(QtC.Qt.CustomContextMenu) 56 | # noinspection PyUnresolvedReferences 57 | self.customContextMenuRequested.connect(self._show_context_menu) 58 | self._update_actions() 59 | 60 | def _show_context_menu(self): 61 | self._menu.exec_(QtG.QCursor.pos()) 62 | 63 | def _update_actions(self): 64 | selected_items = len(self.selected_items()) 65 | self._copy_paths_action.setDisabled(not selected_items) 66 | if selected_items > 1: 67 | self._copy_paths_action.setText(_t('main_window.tab.context_menu.copy_paths_item')) 68 | else: 69 | self._copy_paths_action.setText(_t('main_window.tab.context_menu.copy_path_item')) 70 | self._select_all_action.setDisabled(not self.count()) 71 | 72 | def copy_image_paths(self): 73 | if self._copy_paths_action.isEnabled(): 74 | text = '\n'.join([str(image.path) for image in self.selected_images()]) 75 | pyperclip.copy(text) 76 | 77 | @abc.abstractmethod 78 | def select_all(self): 79 | pass 80 | 81 | @abc.abstractmethod 82 | def selected_items(self) -> list[ImageItem]: 83 | """Returns selected items.""" 84 | pass 85 | 86 | @abc.abstractmethod 87 | def selected_images(self) -> list[model.Image]: 88 | """Returns selected images.""" 89 | pass 90 | 91 | @abc.abstractmethod 92 | def selected_indexes(self) -> list[int]: 93 | """Returns selected indexes sorted in ascending order.""" 94 | pass 95 | 96 | @abc.abstractmethod 97 | def get_images(self) -> list[model.Image]: 98 | """Returns all images from this list.""" 99 | pass 100 | 101 | @abc.abstractmethod 102 | def item(self, row: int) -> ImageItem: 103 | pass 104 | 105 | @abc.abstractmethod 106 | def add_image(self, image: model.Image): 107 | """Adds an image to this list. 108 | 109 | :param image: The image to add. 110 | """ 111 | pass 112 | 113 | @abc.abstractmethod 114 | def clear(self): 115 | """Empties this list.""" 116 | pass 117 | 118 | @abc.abstractmethod 119 | def count(self) -> int: 120 | """Returns the number of items in this list.""" 121 | pass 122 | 123 | 124 | class ImageList(QtW.QListWidget, ImageListView): 125 | """This list displays image paths.""" 126 | 127 | def __init__(self, on_selection_changed: SelectionChangeListener, 128 | on_item_double_clicked: ItemDoubleClickListener, 129 | parent: QtW.QWidget = None): 130 | """Creates an image list. 131 | 132 | :param on_selection_changed: Function called when the selection changes. 133 | :param on_item_double_clicked: Function called when an item is double-clicked. 134 | :param parent: The widget this list belongs to. 135 | """ 136 | super().__init__(parent) 137 | self.setSelectionMode(QtW.QAbstractItemView.ExtendedSelection) 138 | self.selectionModel().selectionChanged.connect(lambda _: on_selection_changed(self.selected_images())) 139 | self.itemDoubleClicked.connect(lambda i: on_item_double_clicked(i.image)) 140 | self._init_contextual_menu() 141 | self.model().rowsInserted.connect(self._update_actions) 142 | self.model().rowsRemoved.connect(self._update_actions) 143 | 144 | def clear(self): 145 | super().clear() 146 | self._update_actions() 147 | 148 | def keyPressEvent(self, event: QtG.QKeyEvent): 149 | """Overrides “Ctrl+C“ action.""" 150 | if utils.gui.event_matches_action(event, self._select_all_action): 151 | self.select_all() 152 | event.ignore() 153 | if utils.gui.event_matches_action(event, self._copy_paths_action): 154 | self.copy_image_paths() 155 | event.ignore() 156 | super().keyPressEvent(event) 157 | 158 | def selectionChanged(self, selected: QtC.QItemSelection, deselected: QtC.QItemSelection): 159 | super().selectionChanged(selected, deselected) 160 | self._update_actions() 161 | 162 | def select_all(self): 163 | self.selectAll() 164 | 165 | def selected_items(self) -> list[ImageItem]: 166 | return [self.item(i) for i in self.selected_indexes()] 167 | 168 | def selected_images(self) -> list[model.Image]: 169 | return [self.item(i).image for i in self.selected_indexes()] 170 | 171 | def selected_indexes(self) -> list[int]: 172 | return sorted(map(QtC.QModelIndex.row, self.selectedIndexes())) 173 | 174 | def get_images(self) -> list[model.Image]: 175 | return [self.item(i).image for i in range(self.count())] 176 | 177 | def item(self, row: int) -> ImageItem: 178 | return super().item(row) 179 | 180 | def add_image(self, image: model.Image): 181 | self.addItem(_ImageListItem(self, image)) 182 | 183 | 184 | class _ImageListItem(QtW.QListWidgetItem, ImageItem): 185 | """This class is used as an item in the ImageList widget.""" 186 | 187 | def __init__(self, parent: QtW.QListWidget, image: model.Image): 188 | """Creates an item with the given image. 189 | 190 | :param parent: The list this item belongs to. 191 | :param image: The image to associate to this item. 192 | """ 193 | super().__init__(parent=parent) 194 | self.setText(str(image.path)) 195 | self._image = image 196 | 197 | 198 | class ThumbnailList(flow_layout.ScrollingFlowWidget, ImageListView): 199 | """This widget lists results returned by the user query as image thumbnails.""" 200 | 201 | def __init__(self, on_selection_changed: SelectionChangeListener, 202 | on_item_double_clicked: ItemDoubleClickListener, 203 | parent: QtW.QWidget = None): 204 | """Creates an image list. 205 | 206 | :param on_selection_changed: Function called when the selection changes. 207 | :param on_item_double_clicked: Function called when an item is double-clicked. 208 | :param parent: The widget this list belongs to. 209 | """ 210 | super().__init__(parent) 211 | self._selection_changed = on_selection_changed 212 | self._on_item_double_clicked = on_item_double_clicked 213 | self._last_index = -1 214 | bg_color = QtW.QApplication.palette().color(QtG.QPalette.Normal, QtG.QPalette.Base) 215 | self.setStyleSheet(f'background-color: {bg_color.name()}') 216 | self._init_contextual_menu() 217 | 218 | def _on_selection_changed(self): 219 | self._update_actions() 220 | self._selection_changed(self.selected_images()) 221 | 222 | def select_all(self): 223 | for item in self._flow_layout.items: 224 | item.selected = True 225 | self._on_selection_changed() 226 | 227 | def selected_items(self) -> list[ImageItem]: 228 | return [item for item in self._flow_layout.items if item.selected] 229 | 230 | def selected_images(self) -> list[model.Image]: 231 | return [item.image for item in self._flow_layout.items if item.selected] 232 | 233 | def selected_indexes(self) -> list[int]: 234 | return [i for i, item in enumerate(self._flow_layout.items) if item.selected] 235 | 236 | def get_images(self) -> list[model.Image]: 237 | return [item.image for item in self._flow_layout.items] 238 | 239 | def item(self, index: int) -> ImageItem: 240 | return self._flow_layout.items[index] 241 | 242 | def add_image(self, image: model.Image): 243 | self.add_widget(_FlowImageItem(image, len(self._flow_layout.items), self._item_clicked, 244 | self._item_double_clicked)) 245 | self._update_actions() 246 | 247 | def clear(self): 248 | self._flow_layout.clear() 249 | self._last_index = -1 250 | self._update_actions() 251 | 252 | def count(self) -> int: 253 | return self._flow_layout.count() 254 | 255 | def mousePressEvent(self, event: QtG.QMouseEvent): 256 | if event.button() != QtC.Qt.RightButton: 257 | self._last_index = -1 258 | self._deselect_except(None) 259 | super().mousePressEvent(event) 260 | 261 | def keyPressEvent(self, event: QtG.QKeyEvent): 262 | """Handles “Ctrl+A“ and “Ctrl+C“ actions.""" 263 | if utils.gui.event_matches_action(event, self._select_all_action): 264 | self.select_all() 265 | event.ignore() 266 | elif utils.gui.event_matches_action(event, self._copy_paths_action): 267 | self.copy_image_paths() 268 | event.ignore() 269 | super().keyPressEvent(event) 270 | 271 | def _item_clicked(self, item: _FlowImageItem): 272 | """Called when an item is clicked once. It handles Ctrl+Click and Shift+Click actions. 273 | 274 | :param item: The clicked item. 275 | """ 276 | modifiers = QtW.QApplication.keyboardModifiers() 277 | if modifiers == QtC.Qt.ControlModifier: 278 | if item.selected: 279 | item.selected = False 280 | self._last_index = -1 281 | else: 282 | item.selected = True 283 | self._last_index = item.index 284 | elif modifiers == QtC.Qt.ShiftModifier: 285 | self._deselect_except(item) 286 | if self._last_index != -1: 287 | if self._last_index <= item.index: 288 | items = self._flow_layout.items[self._last_index:item.index + 1] 289 | else: 290 | items = self._flow_layout.items[item.index:self._last_index + 1] 291 | for i in items: 292 | i.selected = True 293 | else: 294 | item.selected = True 295 | self._last_index = item.index 296 | else: 297 | self._last_index = item.index 298 | item.selected = True 299 | self._deselect_except(item) 300 | self._on_selection_changed() 301 | 302 | def _item_double_clicked(self, item: _FlowImageItem): 303 | self._deselect_except(item) 304 | item.selected = True 305 | self._last_index = item.index 306 | self._on_item_double_clicked(item.image) 307 | 308 | def _deselect_except(self, item: _FlowImageItem | None): 309 | """Deselects all items apart from the given one. 310 | 311 | :param item: The item to keep selected. 312 | """ 313 | for i in self.selected_items(): 314 | if i is not item: 315 | i.selected = False 316 | self._on_selection_changed() 317 | 318 | 319 | class _FlowImageItem(QtW.QFrame, ImageItem): 320 | """An widget that displays an image and can be selected. 321 | Used by the ThumbnailList class to display images returned by the user query. 322 | """ 323 | 324 | def __init__(self, image: model.Image, index: int, on_click: typ.Callable[[_FlowImageItem], None], 325 | on_double_click: typ.Callable[[_FlowImageItem], None]): 326 | """Creates an image item. 327 | 328 | :param image: The image to display. 329 | :param index: Item's index. 330 | :param on_click: Function to call when this item is clicked. 331 | :param on_double_click: Function to call when this item is double-clicked. 332 | """ 333 | super().__init__() 334 | 335 | self._image = image 336 | self._index = index 337 | 338 | self._on_click = on_click 339 | self._on_double_click = on_double_click 340 | 341 | layout = QtW.QVBoxLayout() 342 | layout.setContentsMargins(2, 2, 2, 2) 343 | 344 | self._image_view = components.Canvas(keep_border=False, show_errors=False, parent=self) 345 | # Allows file drag-and-drop 346 | self._image_view.dragEnterEvent = self.dragEnterEvent 347 | self._image_view.dragMoveEvent = self.dragMoveEvent 348 | self._image_view.dropEvent = self.dropEvent 349 | self._image_view.set_image(self._image.path) 350 | size = config.CONFIG.thumbnail_size 351 | self._image_view.setFixedSize(QtC.QSize(size, size)) 352 | self._image_view.mousePressEvent = self.mousePressEvent 353 | self._image_view.mouseReleaseEvent = self.mouseReleaseEvent 354 | self._image_view.mouseDoubleClickEvent = self.mouseDoubleClickEvent 355 | layout.addWidget(self._image_view) 356 | 357 | text = self._image.path.name 358 | label = components.EllipsisLabel(text, parent=self) 359 | label.setAlignment(QtC.Qt.AlignCenter) 360 | label.setFixedWidth(size) 361 | label.setToolTip(text) 362 | layout.addWidget(label) 363 | 364 | self.setLayout(layout) 365 | 366 | self.selected = False 367 | 368 | @property 369 | def index(self): 370 | return self._index 371 | 372 | @property 373 | def selected(self): 374 | return self._selected 375 | 376 | @selected.setter 377 | def selected(self, value: bool): 378 | """Toggles selection. Border and background will turn blue whenever this item is selected.""" 379 | self._selected = value 380 | if self._selected: 381 | bg_color = QtW.QApplication.palette().color(QtG.QPalette.Active, QtG.QPalette.Highlight) 382 | fg_color = QtW.QApplication.palette().color(QtG.QPalette.Active, QtG.QPalette.HighlightedText) 383 | else: 384 | bg_color = QtW.QApplication.palette().color(QtG.QPalette.Normal, QtG.QPalette.Base) 385 | fg_color = QtW.QApplication.palette().color(QtG.QPalette.Normal, QtG.QPalette.Text) 386 | self.setStyleSheet(f'background-color: {bg_color.name()}; color: {fg_color.name()}') 387 | 388 | def mousePressEvent(self, event: QtG.QMouseEvent): 389 | # Do not deselect other items if right click on already selected item 390 | if event.button() != QtC.Qt.RightButton or not self._selected: 391 | self._on_click(self) 392 | 393 | def mouseDoubleClickEvent(self, event: QtG.QMouseEvent): 394 | self._on_double_click(self) 395 | -------------------------------------------------------------------------------- /app/data_access/image_dao.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import re 3 | import sqlite3 4 | 5 | import sympy as sp 6 | 7 | from .dao import DAO 8 | from .tags_dao import TagsDao 9 | from .. import model, utils 10 | from ..i18n import translate as _t 11 | from ..logging import logger 12 | 13 | 14 | class ImageDao(DAO): 15 | """This class manages images.""" 16 | 17 | def get_images(self, tags: sp.Basic) -> list[model.Image] | None: 18 | """Returns all images matching the given tags. 19 | 20 | :param tags: Tags query. 21 | :return: All images matching the tags or None if an exception occured. 22 | """ 23 | query = self._get_query(tags) 24 | if query is None: 25 | return [] 26 | cursor = self._connection.cursor() 27 | try: 28 | cursor.execute(query) 29 | except sqlite3.Error as e: 30 | logger.exception(e) 31 | cursor.close() 32 | return None 33 | else: 34 | results = cursor.fetchall() 35 | cursor.close() 36 | return [self._get_image(r) for r in results] 37 | 38 | def get_tagless_images(self) -> list[model.Image] | None: 39 | """Returns the list of images that do not have any tag. 40 | 41 | :return: The list of images or None if an error occured. 42 | """ 43 | cursor = self._connection.cursor() 44 | try: 45 | cursor.execute(""" 46 | SELECT I.id, I.path, I.hash 47 | FROM images AS I 48 | WHERE ( 49 | SELECT COUNT(*) 50 | FROM image_tag 51 | WHERE image_id = I.id 52 | ) = 0 53 | """) 54 | except sqlite3.Error as e: 55 | logger.exception(e) 56 | cursor.close() 57 | return None 58 | else: 59 | results = cursor.fetchall() 60 | cursor.close() 61 | return [self._get_image(r) for r in results] 62 | 63 | def get_image_tags(self, image_id: int, tags_dao: TagsDao) -> list[model.Tag] | None: 64 | """Returns all tags for the given image. 65 | 66 | :param image_id: Image’s ID. 67 | :param tags_dao: Tags DAO instance. 68 | :return: The tags for the image or None if an exception occured. 69 | """ 70 | cursor = self._connection.cursor() 71 | try: 72 | cursor.execute(""" 73 | SELECT T.id, T.label, T.type_id 74 | FROM tags AS T, image_tag AS IT 75 | WHERE IT.image_id = ? 76 | AND IT.tag_id = T.id 77 | """, (image_id,)) 78 | except sqlite3.Error as e: 79 | logger.exception(e) 80 | cursor.close() 81 | return None 82 | else: 83 | def row_to_tag(row: tuple) -> model.Tag: 84 | tag_type = tags_dao.get_tag_type_from_id(row[2]) if row[2] is not None else None 85 | return model.Tag(row[0], row[1], tag_type) 86 | 87 | tags = list(map(row_to_tag, cursor.fetchall())) 88 | cursor.close() 89 | return tags 90 | 91 | IMG_REGISTERED = 0 92 | """Indicates that the given image is already registered.""" 93 | IMG_SIMILAR = 1 94 | """Indicates that the given image may already be registered.""" 95 | IMG_NOT_REGISTERED = 2 96 | """Indicates that the given image is not registered.""" 97 | 98 | def image_registered(self, image_path: pathlib.Path) -> bool: 99 | """Tells whether the given image has already been registered, i.e. if an entry for the path already exists. 100 | 101 | :param image_path: Path to the image. 102 | :return: A boolean value indicating whether it is already registered or not. 103 | """ 104 | cursor = self._connection.cursor() 105 | cursor.execute("SELECT * FROM images WHERE path = ?", (str(image_path),)) 106 | result = cursor.fetchone() 107 | cursor.close() 108 | return result is not None 109 | 110 | def get_similar_images(self, image_path: pathlib.Path) \ 111 | -> list[tuple[model.Image, int, float, bool]] | None: 112 | """Returns a list of all images that may be similar to the given one. 113 | 114 | Two images are considered similar if the Hamming distance between their respective hashes is ≤ 10 115 | (cf. http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html) or if their 116 | paths are the exact same. Hashes are computed using the “difference hashing” method 117 | (cf. https://www.pyimagesearch.com/2017/11/27/image-hashing-opencv-python/). 118 | 119 | @see IMG_REGISTERED, IMG_SIMILAR, IMG_NOT_REGISTERED 120 | 121 | :param image_path: Path to the image. 122 | :return: A list of candidate images with their Hamming distance, confidence score and a boolean indicating 123 | whether the paths are the same (True) or not (False). 124 | """ 125 | image_hash = utils.image.get_hash(image_path) 126 | if image_hash is None: 127 | return None 128 | images = [] 129 | for registered_image in self.get_images(sp.true): 130 | if image_path == registered_image.path: 131 | images.append((registered_image, 0, 1.0, True)) 132 | elif registered_image.hash is not None \ 133 | and utils.image.compare_hashes(image_hash, registered_image.hash)[2]: 134 | images.append( 135 | (registered_image, *utils.image.compare_hashes(image_hash, registered_image.hash)[:2], False)) 136 | # Sort by: sameness (desc), distance (asc), confidence (desc), path (normal) 137 | return sorted(images, key=lambda e: (not e[3], e[1], -e[2], e[0])) 138 | 139 | def add_image(self, image_path: pathlib.Path, tags: list[model.Tag]) -> bool: 140 | """Adds an image. 141 | 142 | :param image_path: Path to the image. 143 | :param tags: Image’s tags. 144 | :return: True if the image was added. 145 | """ 146 | try: 147 | self._connection.execute('BEGIN') 148 | image_cursor = self._connection.cursor() 149 | image_hash = utils.image.get_hash(image_path) or 0 150 | image_cursor.execute( 151 | 'INSERT INTO images(path, hash) VALUES(?, ?)', 152 | (str(image_path), self.encode_hash(image_hash) if image_hash is not None else None) 153 | ) 154 | for tag in tags: 155 | tag_id = self._insert_tag_if_not_exists(tag) 156 | self._connection.execute('INSERT INTO image_tag(image_id, tag_id) VALUES(?, ?)', 157 | (image_cursor.lastrowid, tag_id)) 158 | except sqlite3.Error as e: 159 | logger.exception(e) 160 | self._connection.rollback() 161 | return False 162 | else: 163 | self._connection.commit() 164 | return True 165 | 166 | def update_image(self, image_id: int, new_path: pathlib.Path, new_hash: int | None) -> bool: 167 | """Sets the path of the given image. 168 | 169 | :param image_id: Image’s ID. 170 | :param new_path: The new path. 171 | :param new_hash: The new hash. 172 | :return: True if the image was updated. 173 | """ 174 | cursor = self._connection.cursor() 175 | try: 176 | cursor.execute( 177 | 'UPDATE images SET path = ?, hash = ? WHERE id = ?', 178 | (str(new_path), self.encode_hash(new_hash) if new_hash is not None else None, image_id) 179 | ) 180 | except sqlite3.Error as e: 181 | logger.exception(e) 182 | cursor.close() 183 | return False 184 | else: 185 | cursor.close() 186 | return True 187 | 188 | def update_image_tags(self, image_id: int, tags: list[model.Tag]) -> bool: 189 | """Sets the tags for the given image. 190 | 191 | :param image_id: Image’s ID. 192 | :param tags: The tags to set. 193 | :return: True if the image was added. 194 | """ 195 | try: 196 | self._connection.execute('BEGIN') 197 | self._connection.execute('DELETE FROM image_tag WHERE image_id = ?', (image_id,)) 198 | for tag in tags: 199 | tag_id = self._insert_tag_if_not_exists(tag) 200 | self._connection.execute('INSERT INTO image_tag(image_id, tag_id) VALUES(?, ?)', (image_id, tag_id)) 201 | except sqlite3.Error as e: 202 | logger.exception(e) 203 | self._connection.rollback() 204 | return False 205 | else: 206 | self._connection.commit() 207 | return True 208 | 209 | def delete_image(self, image_id: int) -> bool: 210 | """Deletes the given image. 211 | 212 | :param image_id: Image’s ID. 213 | :return: True if the image was deleted. 214 | """ 215 | cursor = self._connection.cursor() 216 | try: 217 | cursor.execute('DELETE FROM images WHERE id = ?', (image_id,)) 218 | except sqlite3.Error as e: 219 | logger.exception(e) 220 | cursor.close() 221 | return False 222 | else: 223 | cursor.close() 224 | return True 225 | 226 | def _get_image(self, result: tuple[int, str, bytes]) -> model.Image: 227 | """Creates an Image object from a result tuple.""" 228 | return model.Image( 229 | id=result[0], 230 | path=pathlib.Path(result[1]).absolute(), 231 | hash=self.decode_hash(result[2]) if result[2] is not None else None 232 | ) 233 | 234 | def _insert_tag_if_not_exists(self, tag: model.Tag) -> int: 235 | """Inserts the given tag if it does not already exist. 236 | 237 | :return: The tag’s ID. 238 | """ 239 | cursor = self._connection.cursor() 240 | cursor.execute('SELECT id FROM tags WHERE label = ?', (tag.label,)) 241 | result = cursor.fetchone() 242 | cursor.close() 243 | if result is None: 244 | cursor = self._connection.cursor() 245 | tag_type = tag.type.id if tag.type is not None else None 246 | cursor.execute('INSERT INTO tags(label, type_id) VALUES(?, ?)', (tag.label, tag_type)) 247 | return cursor.lastrowid 248 | return result[0] 249 | 250 | @staticmethod 251 | def _get_query(sympy_expr: sp.Basic) -> str | None: 252 | """Transforms a SymPy expression into an SQL query. 253 | 254 | :param sympy_expr: The SymPy query. 255 | :return: The SQL query or None if the argument is a contradiction. 256 | """ 257 | if isinstance(sympy_expr, sp.Symbol): 258 | tag_name = sympy_expr.name 259 | if ':' in tag_name: 260 | metatag, mode, value = tag_name.split(':', maxsplit=2) 261 | value = value.replace(r'\(', '(').replace(r'\)', ')') 262 | if not ImageDao.check_metatag_value(metatag, value, mode): 263 | raise ValueError(_t('query_parser.error.invalid_metatag_value', value=value, metatag=metatag)) 264 | return ImageDao._metatag_query(metatag, value, mode) 265 | else: 266 | return f""" 267 | SELECT I.id, I.path, I.hash 268 | FROM images AS I, tags AS T, image_tag AS IT 269 | WHERE T.label = "{tag_name}" 270 | AND T.id = IT.tag_id 271 | AND IT.image_id = I.id 272 | """ 273 | elif isinstance(sympy_expr, sp.Or): 274 | subs = [ImageDao._get_query(arg) for arg in sympy_expr.args if arg] 275 | return 'SELECT id, path, hash FROM (' + '\nUNION\n'.join(subs) + ')' 276 | elif isinstance(sympy_expr, sp.And): 277 | subs = [ImageDao._get_query(arg) for arg in sympy_expr.args if arg] 278 | return 'SELECT id, path, hash FROM (' + '\nINTERSECT\n'.join(subs) + ')' 279 | elif isinstance(sympy_expr, sp.Not): 280 | sub = ImageDao._get_query(sympy_expr.args[0]) 281 | return f'SELECT id, path, hash FROM images' + (f' EXCEPT {sub}' if sub else '') 282 | elif sympy_expr == sp.true: 283 | return 'SELECT id, path, hash FROM images' 284 | elif sympy_expr == sp.false: 285 | return None 286 | 287 | raise Exception(f'invalid symbol type “{type(sympy_expr)}”') 288 | 289 | @staticmethod 290 | def metatag_exists(metatag: str) -> bool: 291 | """Checks if the given metatag exists. 292 | 293 | :param metatag: The metatag to check. 294 | :return: True if the metatag exists. 295 | """ 296 | return metatag in ImageDao._METATAG_QUERIES 297 | 298 | @staticmethod 299 | def check_metatag_value(metatag: str, value: str, mode: str) -> bool: 300 | """Checks the validity of a value for the given metatag. 301 | 302 | :param metatag: The metatag. 303 | :param value: Metatag’s value. 304 | :param mode: 'plain' for plain text or 'regex' for regex. 305 | :return: True if the value is valid. 306 | :exception: ValueError if the given metatag doesn’t exist. 307 | """ 308 | if not ImageDao.metatag_exists(metatag): 309 | raise ValueError(_t('query_parser.error.unknown_metatag', metatag=metatag)) 310 | if mode == 'plain': 311 | if metatag == 'similar_to': 312 | return True 313 | return not re.search(r'((? str: 325 | """Escapes all special characters of plain text mode.""" 326 | return re.sub(r'([\\"*?])', r'\\\1', s) 327 | 328 | @staticmethod 329 | def _metatag_query(metatag: str, value: str, mode: str) -> str: 330 | """Returns the SQL query for the given metatag. 331 | 332 | :param metatag: The metatag. 333 | :param value: Metatag’s value. 334 | :return: The SQL query for the metatag. 335 | """ 336 | if mode == 'plain': 337 | if metatag == 'similar_to': 338 | value = value.replace('\\', r'\\') 339 | else: 340 | # Escape regex meta-characters except * and ? 341 | value = re.sub(r'([\[\]()+{.^$])', r'\\\1', value) 342 | # Replace '*' and '?' by a regex 343 | value = re.sub(r'((? OperationsDialog.State: 29 | return OperationsDialog.State( 30 | regex=self.regex, 31 | replacement=self.replacement, 32 | tag_to_replace=self.tag_to_replace, 33 | replacement_tag=self.replacement_tag, 34 | delete_tag_after_replacement=self.delete_tag_after_replacement 35 | ) 36 | 37 | def __init__(self, tags: typ.Iterable[str], state: OperationsDialog.State = None, 38 | parent: QtW.QWidget = None): 39 | self._state = state.copy() if state else self.State() 40 | self._tags = tags 41 | super().__init__(parent=parent, title=_t('dialog.perform_operations.title'), modal=True, 42 | mode=_dialog_base.Dialog.CLOSE) 43 | self._update_ui() 44 | 45 | def _init_body(self): 46 | layout = QtW.QVBoxLayout() 47 | 48 | # Path pattern replacer 49 | image_paths_box = QtW.QGroupBox(_t('dialog.perform_operations.box.image_paths.title'), parent=self) 50 | image_paths_layout = QtW.QGridLayout() 51 | image_paths_box.setLayout(image_paths_layout) 52 | 53 | warning_label = QtW.QLabel(_t('dialog.perform_operations.box.image_paths.description'), parent=self) 54 | warning_label.setWordWrap(True) 55 | image_paths_layout.addWidget(warning_label, 0, 0, 1, 2) 56 | 57 | image_paths_layout.addWidget( 58 | QtW.QLabel(_t('dialog.perform_operations.box.image_paths.regex'), parent=self), 1, 0) 59 | self._regex_input = components.TranslatedLineEdit(self._state.regex, parent=self) 60 | self._regex_input.textChanged.connect(self._update_ui) 61 | image_paths_layout.addWidget(self._regex_input, 1, 1) 62 | 63 | image_paths_layout.addWidget( 64 | QtW.QLabel(_t('dialog.perform_operations.box.image_paths.replacement'), parent=self), 2, 0) 65 | self._replacement_input = components.TranslatedLineEdit(self._state.replacement, parent=self) 66 | self._replacement_input.textChanged.connect(self._update_ui) 67 | image_paths_layout.addWidget(self._replacement_input, 2, 1) 68 | 69 | apply_layout = QtW.QHBoxLayout() 70 | apply_layout.setContentsMargins(0, 0, 0, 0) 71 | apply_layout.addStretch() 72 | w = QtW.QWidget(parent=self) 73 | self._paths_apply_button = QtW.QPushButton( 74 | self.style().standardIcon(QtW.QStyle.SP_DialogApplyButton), 75 | _t('dialog.common.apply_button.label'), 76 | parent=self 77 | ) 78 | self._paths_apply_button.setFixedWidth(80) 79 | self._paths_apply_button.clicked.connect(lambda: self._replace(_WorkerThread.PATHS)) 80 | apply_layout.addWidget(self._paths_apply_button) 81 | apply_layout.addStretch() 82 | w.setLayout(apply_layout) 83 | image_paths_layout.addWidget(w, 3, 0, 1, 2) 84 | 85 | layout.addWidget(image_paths_box) 86 | 87 | # Tags replacer 88 | tags_repl_box = QtW.QGroupBox(_t('dialog.perform_operations.box.tags_replacer.title'), parent=self) 89 | tags_repl_layout = QtW.QGridLayout() 90 | tags_repl_box.setLayout(tags_repl_layout) 91 | 92 | tags_repl_layout.addWidget(QtW.QLabel(_t('dialog.perform_operations.box.tags_replacer.tag_to_replace')), 0, 0) 93 | self._tag_to_replace_input = components.AutoCompleteLineEdit(parent=self) 94 | self._tag_to_replace_input.setText(self._state.tag_to_replace) 95 | self._tag_to_replace_input.set_completer_model(self._tags) 96 | self._tag_to_replace_input.textChanged.connect(self._update_ui) 97 | tags_repl_layout.addWidget(self._tag_to_replace_input, 0, 1) 98 | 99 | tags_repl_layout.addWidget(QtW.QLabel(_t('dialog.perform_operations.box.tags_replacer.replacement_tag')), 1, 0) 100 | self._replacement_tag_input = components.AutoCompleteLineEdit(parent=self) 101 | self._replacement_tag_input.setText(self._state.replacement_tag) 102 | self._replacement_tag_input.set_completer_model(self._tags) 103 | self._replacement_tag_input.textChanged.connect(self._update_ui) 104 | tags_repl_layout.addWidget(self._replacement_tag_input, 1, 1) 105 | 106 | self._delete_tag_after = QtW.QCheckBox( 107 | _t('dialog.perform_operations.box.tags_replacer.delete_tag_after'), 108 | parent=self 109 | ) 110 | self._delete_tag_after.setChecked(self._state.delete_tag_after_replacement) 111 | self._delete_tag_after.clicked.connect(self._update_ui) 112 | tags_repl_layout.addWidget(self._delete_tag_after, 2, 0, 1, 2) 113 | 114 | apply_layout = QtW.QHBoxLayout() 115 | apply_layout.addStretch() 116 | apply_layout.setContentsMargins(0, 0, 0, 0) 117 | w = QtW.QWidget(parent=self) 118 | self._tags_apply_button = QtW.QPushButton( 119 | self.style().standardIcon(QtW.QStyle.SP_DialogApplyButton), 120 | _t('dialog.perform_operations.box.tags_replacer.replace_tag_button.label'), 121 | parent=self 122 | ) 123 | self._tags_apply_button.clicked.connect(lambda: self._replace(_WorkerThread.TAGS)) 124 | apply_layout.addWidget(self._tags_apply_button) 125 | apply_layout.addStretch() 126 | w.setLayout(apply_layout) 127 | tags_repl_layout.addWidget(w, 3, 0, 1, 2) 128 | 129 | layout.addWidget(tags_repl_box) 130 | 131 | layout.addStretch() 132 | 133 | self.setMinimumSize(350, 250) 134 | self.setGeometry(0, 0, 400, 500) 135 | 136 | body_layout = QtW.QVBoxLayout() 137 | scroll = QtW.QScrollArea(parent=self) 138 | scroll.setWidgetResizable(True) 139 | w = QtW.QWidget(parent=self) 140 | w.setLayout(layout) 141 | scroll.setWidget(w) 142 | body_layout.addWidget(scroll) 143 | 144 | return body_layout 145 | 146 | def _update_ui(self): 147 | regex = self._regex_input.text() 148 | tag_to_repl = self._tag_to_replace_input.text().strip() 149 | repl_tag = self._replacement_tag_input.text().strip() 150 | self._paths_apply_button.setDisabled(not regex) 151 | self._tags_apply_button.setDisabled(tag_to_repl not in self._tags or tag_to_repl == repl_tag 152 | or (repl_tag != '' and repl_tag not in self._tags)) 153 | self._state.regex = regex 154 | self._state.replacement = self._replacement_input.text() 155 | self._state.tag_to_replace = tag_to_repl 156 | self._state.replacement_tag = repl_tag 157 | self._state.delete_tag_after_replacement = self._delete_tag_after.isChecked() 158 | 159 | def _replace(self, mode: int): 160 | self._progress_dialog = _progress_dialog.ProgressDialog(parent=self) 161 | 162 | if mode == _WorkerThread.PATHS: 163 | to_replace = self._regex_input.text() 164 | replacement = self._replacement_input.text() 165 | elif mode == _WorkerThread.TAGS: 166 | to_replace = self._tag_to_replace_input.text().strip() 167 | replacement = self._replacement_tag_input.text().strip() 168 | else: 169 | to_replace = None 170 | replacement = None 171 | 172 | if to_replace: 173 | delete_tag_after = mode == _WorkerThread.TAGS and self._delete_tag_after.isChecked() 174 | self._thread = _WorkerThread(to_replace, replacement, mode, delete_tag_after=delete_tag_after) 175 | self._thread.progress_signal.connect(self._on_progress_update) 176 | self._thread.finished.connect(self._on_work_done) 177 | self._progress_dialog.canceled.connect(self._thread.cancel) 178 | self._thread.start() 179 | 180 | def _on_progress_update(self, progress: float, data: tuple, status: int): 181 | progress *= 100 182 | self._progress_dialog.setValue(int(progress)) 183 | status_label = _t('popup.progress.status_label') 184 | if status == 1: 185 | status_ = _t('popup.progress.status.success') 186 | elif status == 2: 187 | status_ = _t('popup.progress.status.failed') 188 | else: 189 | status_ = _t('popup.progress.status.unknown') 190 | mode = data[0] 191 | if mode == _WorkerThread.PATHS: 192 | old_path, new_path = data[1:] 193 | self._progress_dialog.setLabelText( 194 | f'{progress:.2f} %\n{old_path}\n→ {new_path}\n{status_label} {status_}' 195 | ) 196 | elif mode == _WorkerThread.TAGS: 197 | image_path = data[1] 198 | self._progress_dialog.setLabelText( 199 | f'{progress:.2f} %\n{image_path}\n{status_label} {status_}' 200 | ) 201 | 202 | def _on_work_done(self): 203 | self._progress_dialog.cancel() 204 | if self._thread.failed: 205 | utils.gui.show_error(self._thread.error, parent=self) 206 | elif self._thread.failed_images: 207 | errors = '\n'.join(map(lambda i: i.path, self._thread.failed_images)) 208 | message = _t('popup.operation_result_errors.text', affected=self._thread.affected, errors=errors) 209 | utils.gui.show_warning(message, _t('popup.operation_result_errors.title'), parent=self) 210 | else: 211 | message = _t('popup.operation_result_success.text', affected=self._thread.affected) 212 | utils.gui.show_info(message, _t('popup.operation_result_success.title'), parent=self) 213 | 214 | self._update_ui() 215 | 216 | @property 217 | def state(self) -> OperationsDialog.State: 218 | return self._state.copy() 219 | 220 | 221 | class _WorkerThread(threads.WorkerThread): 222 | """Applies the given replacement of every images whose path match the given regex.""" 223 | 224 | PATHS = 0 225 | TAGS = 1 226 | 227 | def __init__(self, to_replace: str, replacement: str, mode: int, delete_tag_after: bool = False): 228 | """Creates a worker thread for a query. 229 | 230 | :param to_replace: The text to replace. 231 | :param replacement: The replacement string. 232 | :param mode: Replacement mode. 233 | """ 234 | super().__init__() 235 | self._mode = mode 236 | self._to_replace = to_replace 237 | self._replacement = replacement 238 | self._delete_tag_after = delete_tag_after 239 | self._affected = 0 240 | self._failed_images: list[model.Image] = [] 241 | 242 | def run(self): 243 | if self._mode == self.PATHS: 244 | self._replace_paths() 245 | elif self._mode == self.TAGS: 246 | self._replace_tags() 247 | 248 | def _replace_paths(self): 249 | image_dao = data_access.ImageDao(config.CONFIG.database_path) 250 | try: 251 | regex = self._to_replace.replace('/', r'\/') 252 | query = queries.query_to_sympy(f'path:/{regex}/', simplify=False) 253 | except ValueError as e: 254 | self._error = str(e) 255 | return 256 | 257 | images = image_dao.get_images(query) 258 | if images is None: 259 | self._error = _t('thread.search.error.image_loading_error') 260 | else: 261 | total = len(images) 262 | progress = 0 263 | for i, image in enumerate(images): 264 | if self._cancelled: 265 | break 266 | # Replace but keep absolute path 267 | new_path = pathlib.Path(re.sub(self._to_replace, self._replacement, str(image.path))).absolute() 268 | self.progress_signal.emit(progress, (self._mode, image.path, new_path), self.STATUS_UNKNOWN) 269 | new_hash = utils.image.get_hash(new_path) 270 | ok = image_dao.update_image(image.id, new_path, new_hash) 271 | if ok: 272 | self._affected += 1 273 | else: 274 | self._failed_images.append(image) 275 | progress = i / total 276 | self.progress_signal.emit(progress, (self._mode, image.path, new_path), 277 | self.STATUS_SUCCESS if ok else self.STATUS_FAILED) 278 | 279 | def _replace_tags(self): 280 | image_dao = data_access.ImageDao(config.CONFIG.database_path) 281 | tags_dao = data_access.TagsDao(config.CONFIG.database_path) 282 | try: 283 | query = queries.query_to_sympy(self._to_replace, simplify=False) 284 | except ValueError as e: 285 | self._error = str(e) 286 | return 287 | 288 | images = image_dao.get_images(query) 289 | if images is None: 290 | self._error = _t('thread.search.error.image_loading_error') 291 | else: 292 | tag_to_replace = tags_dao.get_tag_from_label(self._to_replace) 293 | replacement_tag = tags_dao.get_tag_from_label(self._replacement) if self._replacement else None 294 | if not tag_to_replace: 295 | self._error = _t('thread.perform_operations.error.non_existent_tag', label=self._to_replace) 296 | elif self._replacement and not replacement_tag: 297 | self._error = _t('thread.perform_operations.error.non_existent_tag', label=self._replacement) 298 | elif isinstance(tag_to_replace, model.CompoundTag): 299 | self._error = _t('thread.perform_operations.error.compound_tag', label=self._to_replace) 300 | elif isinstance(replacement_tag, model.CompoundTag): 301 | self._error = _t('thread.perform_operations.error.compound_tag', label=self._replacement) 302 | else: 303 | total = len(images) 304 | progress = 0 305 | for i, image in enumerate(images): 306 | if self._cancelled: 307 | break 308 | self.progress_signal.emit(progress, (self._mode, image.path), self.STATUS_UNKNOWN) 309 | tags = [tag for tag in image_dao.get_image_tags(image.id, tags_dao) 310 | if tag.label not in (self._to_replace, self._replacement)] 311 | if replacement_tag: 312 | tags.append(replacement_tag) 313 | ok = image_dao.update_image_tags(image.id, tags) 314 | if ok: 315 | self._affected += 1 316 | else: 317 | self._failed_images.append(image) 318 | progress = i / total 319 | self.progress_signal.emit(progress, (self._mode, image.path), 320 | self.STATUS_SUCCESS if ok else self.STATUS_FAILED) 321 | 322 | if self._delete_tag_after and not self._failed_images: 323 | tags_dao.delete_tag(tag_to_replace.id) 324 | 325 | @property 326 | def failed_images(self) -> list[model.Image]: 327 | return self._failed_images 328 | 329 | @property 330 | def affected(self) -> int: 331 | return self._affected 332 | -------------------------------------------------------------------------------- /app/data_access/tags_dao.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import typing as typ 3 | 4 | import PyQt5.QtGui as QtG 5 | 6 | from .dao import DAO 7 | from .. import model 8 | from ..logging import logger 9 | 10 | _T = typ.TypeVar('_T', model.Tag, model.CompoundTag) 11 | 12 | 13 | class TagsDao(DAO): 14 | """This class manages tags and tag types.""" 15 | 16 | def get_all_types(self) -> list[model.TagType] | None: 17 | """Returns all tag types. 18 | 19 | :return: All tag types or None if an exception occured. 20 | """ 21 | cursor = self._connection.cursor() 22 | try: 23 | cursor.execute('SELECT id, label, symbol, color FROM tag_types') 24 | except sqlite3.Error as e: 25 | logger.exception(e) 26 | cursor.close() 27 | return None 28 | else: 29 | types = [self._get_tag_type(t) for t in cursor.fetchall()] 30 | cursor.close() 31 | return types 32 | 33 | def is_special_char(self, c: str) -> bool: 34 | """Tells if a character is a type symbol. 35 | 36 | :param c: The character to check. 37 | :return: True if the argument is a type symbol. 38 | """ 39 | cursor = self._connection.cursor() 40 | try: 41 | cursor.execute('SELECT * FROM tag_types WHERE symbol = ?', (c,)) 42 | except sqlite3.Error as e: 43 | logger.exception(e) 44 | cursor.close() 45 | return False 46 | else: 47 | special = len(cursor.fetchall()) != 0 48 | cursor.close() 49 | return special 50 | 51 | def create_tag_from_string(self, s: str) -> model.Tag: 52 | """Creates a new Tag instance from a given string. 53 | 54 | :param s: The string to parse. 55 | :return: The corresponding tag. 56 | """ 57 | has_type = self.is_special_char(s[0]) 58 | label = s[1:] if has_type else s 59 | tag_type = self.get_tag_type_from_symbol(s[0]) if has_type else None 60 | return model.Tag(0, label, tag_type) 61 | 62 | def get_tag_from_label(self, label: str) -> model.Tag | None: 63 | """Returns the tag that has the given label. 64 | 65 | :param label: 66 | :return: 67 | """ 68 | cursor = self._connection.cursor() 69 | try: 70 | cursor.execute('SELECT id, label, definition, type_id FROM tags WHERE label = ?', (label,)) 71 | except sqlite3.Error as e: 72 | logger.exception(e) 73 | cursor.close() 74 | return None 75 | else: 76 | results = cursor.fetchall() 77 | cursor.close() 78 | if results: 79 | return self._get_tag(results[0]) 80 | return None 81 | 82 | def get_tag_type_from_symbol(self, symbol: str) -> model.TagType | None: 83 | """Returns the type with from the given symbol. 84 | 85 | :param symbol: The type symbol. 86 | :return: The corresponding type. 87 | """ 88 | cursor = self._connection.cursor() 89 | try: 90 | cursor.execute('SELECT id, label, symbol, color FROM tag_types WHERE symbol = ?', (symbol,)) 91 | except sqlite3.Error as e: 92 | logger.exception(e) 93 | cursor.close() 94 | return None 95 | else: 96 | results = cursor.fetchall() 97 | cursor.close() 98 | if results: 99 | return self._get_tag_type(results[0]) 100 | return None 101 | 102 | def get_tag_type_from_id(self, ident: int) -> model.TagType | None: 103 | """Returns the type with the given ID. 104 | 105 | :param ident: The SQLite ID. 106 | :return: The corresponding type. 107 | """ 108 | cursor = self._connection.cursor() 109 | try: 110 | cursor.execute('SELECT id, label, symbol, color FROM tag_types WHERE id = ?', (ident,)) 111 | except sqlite3.Error as e: 112 | logger.exception(e) 113 | cursor.close() 114 | return None 115 | else: 116 | results = cursor.fetchall() 117 | cursor.close() 118 | if results: 119 | return self._get_tag_type(results[0]) 120 | return None 121 | 122 | def add_type(self, tag_type: model.TagType) -> bool: 123 | """Adds a tag type. 124 | 125 | :param tag_type: The type to add. 126 | :return: True if the type was added or None if an exception occured. 127 | """ 128 | cursor = self._connection.cursor() 129 | try: 130 | cursor.execute('INSERT INTO tag_types (label, symbol, color) VALUES (?, ?, ?)', 131 | (tag_type.label, tag_type.symbol, tag_type.color.rgb())) 132 | except sqlite3.Error as e: 133 | logger.exception(e) 134 | cursor.close() 135 | return False 136 | else: 137 | cursor.close() 138 | return True 139 | 140 | def update_type(self, tag_type: model.TagType) -> bool: 141 | """Updates a tag type. 142 | 143 | :param tag_type: The tag type to update. 144 | :return: True if the type was updated. 145 | """ 146 | cursor = self._connection.cursor() 147 | try: 148 | cursor.execute('UPDATE tag_types SET label = ?, symbol = ?, color = ? WHERE id = ?', 149 | (tag_type.label, tag_type.symbol, tag_type.color.rgb(), tag_type.id)) 150 | except sqlite3.Error as e: 151 | logger.exception(e) 152 | cursor.close() 153 | return False 154 | else: 155 | cursor.close() 156 | return True 157 | 158 | def delete_type(self, type_id: int) -> bool: 159 | """Deletes the given tag type. 160 | 161 | :param type_id: ID of the tag type to delete. 162 | :return: True if the type was deleted. 163 | """ 164 | cursor = self._connection.cursor() 165 | try: 166 | cursor.execute('DELETE FROM tag_types WHERE id = ?', (type_id,)) 167 | except sqlite3.Error as e: 168 | logger.exception(e) 169 | cursor.close() 170 | return False 171 | else: 172 | cursor.close() 173 | return True 174 | 175 | def get_all_tags(self, tag_class: typ.Type[_T] = None, sort_by_label: bool = False, get_count: bool = False) \ 176 | -> list[tuple[_T, int]] | list[_T] | None: 177 | """Returns all tags. Result can be sorted by label. You can also query use count for each tag. 178 | 179 | :param tag_class: Sets type of tags to return. If None all tags wil be returned. 180 | :param sort_by_label: Result will be sorted by label using lexicographical ordering. 181 | :param get_count: If true, result will be a list of tuples containing the tag and its use count. 182 | :return: The list of tags or tag/count pairs or None if an exception occured. 183 | """ 184 | counts = {} 185 | if get_count: 186 | cursor_ = self._connection.cursor() 187 | try: 188 | cursor_.execute(""" 189 | SELECT tag_id, COUNT(*) 190 | FROM image_tag 191 | GROUP BY tag_id 192 | """) 193 | except sqlite3.Error as e: 194 | logger.exception(e) 195 | cursor_.close() 196 | else: 197 | counts = {tag_id: count for tag_id, count in cursor_.fetchall()} 198 | cursor_.close() 199 | 200 | query = 'SELECT id, label, type_id, definition FROM tags' 201 | if sort_by_label: 202 | query += ' ORDER BY label' 203 | cursor = self._connection.cursor() 204 | try: 205 | cursor.execute(query) 206 | except sqlite3.Error as e: 207 | logger.exception(e) 208 | cursor.close() 209 | return None 210 | else: 211 | tags = [] 212 | tag_types = {} # Cache tag types for efficiency 213 | for row in cursor.fetchall(): 214 | tag_type_id = row[2] 215 | if tag_type_id is not None: 216 | if tag_type_id not in tag_types: 217 | tag_types[tag_type_id] = self.get_tag_type_from_id(tag_type_id) 218 | tag_type = tag_types[tag_type_id] 219 | else: 220 | tag_type = None 221 | 222 | tag = None 223 | if row[3] is None and (tag_class == model.Tag or tag_class is None): 224 | tag = model.Tag(ident=row[0], label=row[1], tag_type=tag_type) 225 | elif row[3] is not None and (tag_class == model.CompoundTag or tag_class is None): 226 | tag = model.CompoundTag(ident=row[0], label=row[1], definition=row[3], tag_type=tag_type) 227 | 228 | if tag: 229 | if get_count: 230 | tags.append((tag, counts.get(tag.id, 0))) 231 | else: 232 | tags.append(tag) 233 | 234 | cursor.close() 235 | return tags 236 | 237 | def get_all_tag_types(self, sort_by_symbol: bool = False, get_count: bool = False) \ 238 | -> list[model.TagType] | list[tuple[model.TagType, int]] | None: 239 | """Returns all tag types. 240 | 241 | :param sort_by_symbol: Whether to sort types by symbol along with labels. 242 | :param get_count: If true, result will be a list of tuples containing the tag type and its use count. 243 | :return: All currently defined tag types. 244 | """ 245 | counts = {} 246 | if get_count: 247 | cursor_ = self._connection.cursor() 248 | try: 249 | cursor_.execute(""" 250 | SELECT type_id, COUNT(*) 251 | FROM tags 252 | WHERE type_id IS NOT NULL 253 | GROUP BY type_id 254 | """) 255 | except sqlite3.Error as e: 256 | logger.exception(e) 257 | cursor_.close() 258 | else: 259 | counts = {type_id: count for type_id, count in cursor_.fetchall()} 260 | cursor_.close() 261 | 262 | query = 'SELECT id, label, symbol, color FROM tag_types ORDER BY label' 263 | if sort_by_symbol: 264 | query += ', symbol' 265 | cursor = self._connection.cursor() 266 | try: 267 | cursor.execute(query) 268 | except sqlite3.Error as e: 269 | logger.exception(e) 270 | cursor.close() 271 | return None 272 | else: 273 | results = cursor.fetchall() 274 | cursor.close() 275 | types = [] 276 | for ident, label, symbol, color in results: 277 | tag_type = self._get_tag_type((ident, label, symbol, color)) 278 | types.append(tag_type if not get_count else (tag_type, counts.get(tag_type.id, 0))) 279 | return types 280 | 281 | def tag_exists(self, tag_id: int, tag_name: str) -> bool | None: 282 | """Checks wether a tag with the same name exists. 283 | 284 | :param tag_id: Tag’s ID. 285 | :param tag_name: Tag’s name. 286 | :return: True if a tag with the same name already exists. 287 | """ 288 | cursor = self._connection.cursor() 289 | try: 290 | cursor.execute('SELECT COUNT(*) FROM tags WHERE label = ? AND id != ?', (tag_name, tag_id)) 291 | except sqlite3.Error as e: 292 | logger.exception(e) 293 | cursor.close() 294 | return None 295 | else: 296 | exists = cursor.fetchall()[0][0] != 0 297 | cursor.close() 298 | return exists 299 | 300 | def get_tag_class(self, tag_name: str) -> typ.Type[model.Tag] | typ.Type[model.CompoundTag] | None: 301 | """Returns the type of the given tag if any. 302 | 303 | :param tag_name: Tag’s name. 304 | :return: Tag’s class or None if tag doesn't exist. 305 | """ 306 | cursor = self._connection.cursor() 307 | try: 308 | cursor.execute('SELECT definition FROM tags WHERE label = ?', (tag_name,)) 309 | except sqlite3.Error as e: 310 | logger.exception(e) 311 | cursor.close() 312 | return None 313 | else: 314 | results = cursor.fetchall() 315 | cursor.close() 316 | if len(results) == 0: 317 | return None 318 | return model.Tag if results[0][0] is None else model.CompoundTag 319 | 320 | def add_compound_tag(self, tag: model.CompoundTag) -> bool: 321 | """Adds a compound tag. 322 | 323 | :param tag: The compound tag to add. 324 | :return: True if the type was added or None if an exception occured. 325 | """ 326 | cursor = self._connection.cursor() 327 | try: 328 | cursor.execute('INSERT INTO tags (label, type_id, definition) VALUES (?, ?, ?)', 329 | (tag.label, tag.type.id if tag.type is not None else None, tag.definition)) 330 | except sqlite3.Error as e: 331 | logger.exception(e) 332 | cursor.close() 333 | return False 334 | else: 335 | cursor.close() 336 | return True 337 | 338 | def update_tag(self, tag: model.Tag) -> bool: 339 | """Updates the given tag. 340 | 341 | :param tag: The tag to update. 342 | :return: True if the tag was updated. 343 | """ 344 | tag_type = tag.type.id if tag.type is not None else None 345 | cursor = self._connection.cursor() 346 | try: 347 | if isinstance(tag, model.CompoundTag): 348 | cursor.execute('UPDATE tags SET label = ?, type_id = ?, definition = ? WHERE id = ?', 349 | (tag.label, tag_type, tag.definition, tag.id)) 350 | else: 351 | cursor.execute('UPDATE tags SET label = ?, type_id = ? WHERE id = ?', 352 | (tag.label, tag_type, tag.id)) 353 | except sqlite3.Error as e: 354 | logger.exception(e) 355 | cursor.close() 356 | return False 357 | else: 358 | cursor.close() 359 | return True 360 | 361 | def delete_tag(self, tag_id: int) -> bool: 362 | """Deletes the given tag. 363 | 364 | :param tag_id: ID of the tag to delete. 365 | :return: True if the tag was deleted. 366 | """ 367 | cursor = self._connection.cursor() 368 | try: 369 | cursor.execute('DELETE FROM tags WHERE id = ?', (tag_id,)) 370 | except sqlite3.Error as e: 371 | logger.exception(e) 372 | cursor.close() 373 | return False 374 | else: 375 | cursor.close() 376 | return True 377 | 378 | def _get_tag(self, result: tuple[int, str, str | None, int | None]) -> model.Tag: 379 | """Creates a Tag object based on the given result tuple.""" 380 | if result[2]: 381 | return model.CompoundTag( 382 | ident=result[0], 383 | label=result[1], 384 | definition=result[2], 385 | tag_type=self.get_tag_type_from_id(result[3]) if result[3] is not None else None 386 | ) 387 | else: 388 | return model.Tag( 389 | ident=result[0], 390 | label=result[1], 391 | tag_type=self.get_tag_type_from_id(result[3]) if result[3] is not None else None 392 | ) 393 | 394 | @staticmethod 395 | def _get_tag_type(result: tuple[int, str, str, int]) -> model.TagType: 396 | """Creates a TagType object based on the given result tuple.""" 397 | return model.TagType( 398 | ident=result[0], 399 | label=result[1], 400 | symbol=result[2], 401 | color=QtG.QColor.fromRgb(result[3]) 402 | ) 403 | -------------------------------------------------------------------------------- /app/assets/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "English", 3 | "mappings": { 4 | "main_window": { 5 | "menu": { 6 | "file": { 7 | "label": "&File", 8 | "item": { 9 | "add_files": "Add &Files…", 10 | "add_directory": "Add &Directory…", 11 | "export_playlist": "Export Selection As &Playlist…", 12 | "exit": "&Exit" 13 | } 14 | }, 15 | "edit": { 16 | "label": "&Edit", 17 | "item": { 18 | "edit_tags": "Edit &Tags…", 19 | "rename_image": "&Rename Image…", 20 | "replace_image": "Re&place Image…", 21 | "move_images": "&Move Images…", 22 | "edit_images": "&Edit Images…", 23 | "delete_images": "&Delete Images" 24 | } 25 | }, 26 | "tools": { 27 | "label": "&Tools", 28 | "item": { 29 | "tagless_images": "&Show images without tags", 30 | "perform_operations": "&Operations…", 31 | "SQL_terminal": "SQL &Terminal…" 32 | } 33 | }, 34 | "help": { 35 | "label": "&Help", 36 | "item": { 37 | "settings": "&Settings…", 38 | "about": "&About…" 39 | } 40 | } 41 | }, 42 | "tags_tree": { 43 | "title": "Tags ({amount})", 44 | "type_item_unclassified": "Unclassified", 45 | "context_menu": { 46 | "copy_all_item": "Copy &All", 47 | "copy_tags_item": "Copy &Tags", 48 | "delete_tag_item": "&Delete Tag", 49 | "delete_tag_type_item": "&Delete Tag Type", 50 | "insert_tag_item": "&Insert" 51 | } 52 | }, 53 | "tab": { 54 | "context_menu": { 55 | "copy_path_item": "&Copy Image Path", 56 | "copy_paths_item": "&Copy Image Paths" 57 | }, 58 | "paths_list": { 59 | "title": "Paths ({images_number})" 60 | }, 61 | "thumbnails_list": { 62 | "title": "Thumbnails ({images_number})" 63 | } 64 | }, 65 | "query_form": { 66 | "search_button": { 67 | "label": "Search" 68 | }, 69 | "text_field": { 70 | "placeholder": "Search images with tags…" 71 | } 72 | }, 73 | "error": { 74 | "remote_URL": "URL is not local!" 75 | } 76 | }, 77 | "menu_common": { 78 | "undo_item": "&Undo", 79 | "redo_item": "&Redo", 80 | "cut_item": "C&ut", 81 | "copy_item": "&Copy", 82 | "paste_item": "&Paste", 83 | "delete_item": "&Delete", 84 | "select_all_item": "Select &All" 85 | }, 86 | "canvas": { 87 | "image_load_error": "Could not load image!", 88 | "no_image": "No image" 89 | }, 90 | "command_line": { 91 | "menu": { 92 | "clear_item": "C&lear Console" 93 | } 94 | }, 95 | "dialog": { 96 | "common": { 97 | "ok_button": { 98 | "label": "OK" 99 | }, 100 | "close_button": { 101 | "label": "Close" 102 | }, 103 | "apply_button": { 104 | "label": "Apply" 105 | }, 106 | "yes_button": { 107 | "label": "Yes" 108 | }, 109 | "no_button": { 110 | "label": "No" 111 | }, 112 | "cancel_button": { 113 | "label": "Cancel" 114 | }, 115 | "invalid_data": { 116 | "text": "Invalid data!" 117 | } 118 | }, 119 | "settings": { 120 | "title": "Settings", 121 | "box": { 122 | "database": { 123 | "title": "Database", 124 | "db_path_warning": "This database file does not exist.\nIt will be created the next time you launch the application.", 125 | "db_path": { 126 | "label": "Database file" 127 | }, 128 | "choose_file_button": { 129 | "tooltip": "Select a file" 130 | } 131 | }, 132 | "thumbnails": { 133 | "title": "Thumbnails", 134 | "load_thumbs_button": { 135 | "label": "Load thumbnails" 136 | }, 137 | "thumbs_size": "Size (in pixels)", 138 | "thumbs_threshold": "Load threshold" 139 | }, 140 | "language": { 141 | "title": "Interface Language", 142 | "language": "Language" 143 | } 144 | } 145 | }, 146 | "perform_operations": { 147 | "title": "Perform Operations", 148 | "box": { 149 | "image_paths": { 150 | "title": "Image Paths", 151 | "description": "You can perform a substitution operation on the paths of all images that match the given pattern. Supports regular expressions.", 152 | "regex": "Pattern", 153 | "replacement": "Replacement" 154 | }, 155 | "tags_replacer": { 156 | "title": "Replace tag", 157 | "tag_to_replace": "Replace:", 158 | "replacement_tag": "With:", 159 | "delete_tag_after": "Delete tag after replacement", 160 | "replace_tag_button": { 161 | "label": "Replace" 162 | } 163 | } 164 | } 165 | }, 166 | "move_images": { 167 | "title": "Move Images", 168 | "destination_non_existant": "The destination directory does not exist.", 169 | "destination_empty": "Field is empty.", 170 | "destination": "Destination", 171 | "choose_directory_button": { 172 | "tooltip": "Select directory" 173 | }, 174 | "delete_empty_dirs_button": { 175 | "label": "Delete empty directories after moving files?" 176 | }, 177 | "move_button": { 178 | "label": "Move" 179 | } 180 | }, 181 | "edit_image": { 182 | "title_add": "Add Image ({index}/{total})", 183 | "title_edit": "Edit Image ({index}/{total})", 184 | "title_replace": "Replace Image ({index}/{total})", 185 | "similarities": { 186 | "label": "Similar Images" 187 | }, 188 | "replace_button": { 189 | "label": "Replace with…" 190 | }, 191 | "move_to_button": { 192 | "label": "Move to…" 193 | }, 194 | "tags_button": { 195 | "label": "Tags" 196 | }, 197 | "show_directory_button": { 198 | "label": "Show in directory" 199 | }, 200 | "skip_button": { 201 | "label": "Skip" 202 | }, 203 | "apply_next_button": { 204 | "label": "Apply && Continue" 205 | }, 206 | "finish_button": { 207 | "label": "Finish" 208 | }, 209 | "target_path": "Destination folder: {path}", 210 | "target_image": "Image: {path}", 211 | "error": { 212 | "replace_self": "Cannot replace image with itself!", 213 | "no_tags": "No tags specified!", 214 | "duplicate_tags": "Duplicate tag(s) detected:\n{tags}", 215 | "compound_tags_disallowed": "Compound tag(s) detected:\n{tags}", 216 | "invalid_tag_format": "Invalid tag format “{error}”!", 217 | "changes_not_applied": "Could not apply changes!", 218 | "image_not_added": "Could not add image!", 219 | "file_already_exists": "File already exists in destination!", 220 | "file_does_not_exists": "This image does not exist!", 221 | "failed_to_move_file": "The image could not be moved!" 222 | } 223 | }, 224 | "edit_tags": { 225 | "title_edit": "Edit Tags", 226 | "title_readonly": "Tags", 227 | "add_item_button": { 228 | "tooltip": "Add Item" 229 | }, 230 | "delete_items_button": { 231 | "tooltip": "Delete Selected Items" 232 | }, 233 | "search_field": { 234 | "placeholder": "Search tag or tag type…" 235 | }, 236 | "search_button": { 237 | "label": "Search" 238 | }, 239 | "delete_warning": { 240 | "text": "Delete selected entries?" 241 | }, 242 | "no_match": "No match found.", 243 | "syntax_error": "Syntax error!", 244 | "tab": { 245 | "tag_types": { 246 | "title": "Tag Types", 247 | "table": { 248 | "header": { 249 | "type_id": "ID", 250 | "label": "Label", 251 | "symbol": "Symbol", 252 | "color": "Color" 253 | }, 254 | "default_label": "New Type" 255 | } 256 | }, 257 | "tags_common": { 258 | "table": { 259 | "header": { 260 | "tag_id": "ID", 261 | "label": "Label", 262 | "type": "Type", 263 | "usage": "Times Used" 264 | }, 265 | "combo_no_type": "None" 266 | } 267 | }, 268 | "tags": { 269 | "title": "Tags" 270 | }, 271 | "compound_tags": { 272 | "title": "Compound Tags", 273 | "table": { 274 | "header": { 275 | "definition": "Definition" 276 | } 277 | } 278 | } 279 | }, 280 | "error": { 281 | "saving": "An error occured! Some changes may not have been saved.", 282 | "empty_cell": "Cell is empty!", 283 | "duplicate_value": "Value is already used on row {row}! Please choose another.", 284 | "invalid_tag_type_symbol": "Symbol should only be one character long and any character except letters, digits, “_”, “+”, “-”, “:”, “(” and “)”!", 285 | "invalid_tag_name": "Tag label should only be letters, digits or “_”!", 286 | "duplicate_tag_name": "A tag with this name already exists!" 287 | } 288 | }, 289 | "similar_images": { 290 | "title": "Similar Images", 291 | "text": "The following images may be similar to the one you are currently adding.\nThe confidence score indicates how sure the application is that the image may be similar to the one being added.", 292 | "grid": { 293 | "header": { 294 | "image_path": "Image Path", 295 | "image_size": "Image Size", 296 | "confidence_score": "Confidence" 297 | }, 298 | "open_file_button": { 299 | "label": "Show" 300 | } 301 | }, 302 | "button": { 303 | "copy_tags": { 304 | "label": "Copy tags", 305 | "tooltip": "Copy tags of the selected image" 306 | }, 307 | "close": { 308 | "label": "Close" 309 | } 310 | } 311 | }, 312 | "command_line": { 313 | "title": "SQL Console", 314 | "query_input": { 315 | "placeholder": "SQL Query…" 316 | } 317 | }, 318 | "about": { 319 | "title": "About {app_name}", 320 | "menu": { 321 | "copy_link_item": "&Copy Link Location" 322 | } 323 | } 324 | }, 325 | "popup": { 326 | "info": { 327 | "title": "Information" 328 | }, 329 | "warning": { 330 | "title": "Warning" 331 | }, 332 | "error": { 333 | "title": "Error" 334 | }, 335 | "question": { 336 | "title": "Question" 337 | }, 338 | "no_files_selected": { 339 | "text": "No files selected!" 340 | }, 341 | "empty_directory": { 342 | "text": "No images in this directory!" 343 | }, 344 | "no_files_found": { 345 | "text": "No images found!" 346 | }, 347 | "maximum_recursion": { 348 | "text": "Maximum search depth of {depth} sub-directories has been reached!" 349 | }, 350 | "image_registered": { 351 | "text": "This image has already been registered." 352 | }, 353 | "images_registered": { 354 | "text": "All these images have already been registered." 355 | }, 356 | "some_images_registered": { 357 | "text": "Some of these images have already been registered; they will be ignored." 358 | }, 359 | "file_chooser": { 360 | "caption": { 361 | "image": "Open Image", 362 | "images": "Open Images", 363 | "database": "Choose Database File" 364 | }, 365 | "filter": { 366 | "images": "Image file", 367 | "database": "Database file" 368 | } 369 | }, 370 | "directory_chooser": { 371 | "caption": "Choose Directory" 372 | }, 373 | "playlist_saver": { 374 | "caption": "Save Playlist", 375 | "filter": "Playlist" 376 | }, 377 | "rename_image": { 378 | "title": "Rename image", 379 | "text": "Enter the new name" 380 | }, 381 | "rename_error": { 382 | "text": "Could not rename image!" 383 | }, 384 | "rename_overwrite": { 385 | "title": "Name conflict", 386 | "text": "A file with this name already exists, do you want to overwrite it?" 387 | }, 388 | "tags_load_error": { 389 | "text": "Failed to load tags!" 390 | }, 391 | "tag_types_load_error": { 392 | "text": "Failed to load tag types!" 393 | }, 394 | "playlist_exported": { 395 | "text": "Playlist exported!" 396 | }, 397 | "delete_image_warning": { 398 | "title": "Delete Image", 399 | "text_question_single": "Are you sure you want to delete this image?", 400 | "text_question_multiple": "Are you sure you want to delete these images?", 401 | "checkbox_label": "Delete from the disk" 402 | }, 403 | "delete_image_error": { 404 | "text": "Could not delete file(s):\n{files}" 405 | }, 406 | "delete_tag_type_confirm": { 407 | "text": "Are you sure you want to delete this tag type?\nTags that currently have this type will not be deleted." 408 | }, 409 | "delete_tag_confirm": { 410 | "text": "Are you sure you want to delete this tag?" 411 | }, 412 | "load_thumbs_warning": { 413 | "title": "Load image thumbnails?", 414 | "text": "Query returned more than {threshold} image(s), thumbnails will be disabled.\nDo you want to load them anyway? The app may crash as a result." 415 | }, 416 | "app_needs_restart": { 417 | "text": "Some changes need the application to restart to apply." 418 | }, 419 | "operation_result_success": { 420 | "title": "Operation Result", 421 | "text": "Operation successful!\n{affected} row(s) affected." 422 | }, 423 | "operation_result_errors": { 424 | "title": "Operation Result", 425 | "text": "Operation ended with errors!\n{affected} row(s) were changed but the following images raised errors:\n{errors}" 426 | }, 427 | "files_move_result_success": { 428 | "text": "Files moved successfully!" 429 | }, 430 | "files_move_result_errors": { 431 | "text": "Operation ended with errors.\nThe following images could not be moved or updated:\n{errors}" 432 | }, 433 | "directories_deletion_errors": { 434 | "text": "The following directories could not be deleted:\n{errors}" 435 | }, 436 | "progress": { 437 | "title": "Progress", 438 | "status_label": "Status:", 439 | "status": { 440 | "success": "Success", 441 | "failed": "Failed", 442 | "unknown": "N/A" 443 | } 444 | }, 445 | "update_needed": { 446 | "text": "The database needs to update. Do you want to proceed?\nA backup will be created. You can stop the update at any moment." 447 | }, 448 | "database_update": { 449 | "title": "Updating database", 450 | "migration_0000": { 451 | "hashing_image_text": "Hashing image {index}/{total}:\n{image}" 452 | } 453 | }, 454 | "update_cancelled": { 455 | "text": "Database update was cancelled." 456 | }, 457 | "database_updated": { 458 | "text": "Database was updated successfully!" 459 | } 460 | }, 461 | "thread": { 462 | "search": { 463 | "error": { 464 | "image_loading_error": "Failed to load images!", 465 | "max_recursion": "Maximum recursion depth of {max_depth} reached!" 466 | } 467 | }, 468 | "perform_operations": { 469 | "non_existent_tag": "Tag “{label}” does not exist!", 470 | "compound_tag": "Compound tags are not allowed!" 471 | } 472 | }, 473 | "query_parser": { 474 | "error": { 475 | "unknown_metatag": "Unknown metatag “{metatag}”!", 476 | "invalid_metatag_value": "Invalid value “{value}” for metatag “{metatag}”!", 477 | "syntax_error": "Syntax error! Unexpected character “{token}”.", 478 | "syntax_error_eol": "Syntax error! Unexpected end of query.", 479 | "illegal_character": "Illegal character ”{char}“ (U+{code})!" 480 | } 481 | }, 482 | "SQL_console": { 483 | "exit_notice": "Type \"exit\" to terminate the command-line.\n", 484 | "connection": "Connection: {path}", 485 | "no_results": "Query returned no results", 486 | "display_more": "Display more? (Y / N)", 487 | "results": "Showing result(s) {start} to {end} of {total}", 488 | "affected_rows": "{row_count} row(s) affected", 489 | "error": "Error:", 490 | "goodbye": "Goodbye!" 491 | } 492 | } 493 | } 494 | --------------------------------------------------------------------------------