diff --git a/REUSE.toml b/REUSE.toml index 93f80ffe4..73f8a137a 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -55,3 +55,14 @@ path = [ ] SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors" SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = ["src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf"] +SPDX-FileCopyrightText = "(c) 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)" +SPDX-License-Identifier = "OFL-1.1" + + +[[annotations]] +path = ["src/tagstudio/resources/fonts/JetBrainsMono/**"] +SPDX-FileCopyrightText = "(c) 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)" +SPDX-License-Identifier = "OFL-1.1" diff --git a/pyproject.toml b/pyproject.toml index a0ec9bb53..08b885154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,15 +21,18 @@ dependencies = [ "mutagen~=1.47", "numpy~=2.2", "opencv_python~=4.11", - "Pillow>=10.2,<12", "pillow-heif~=1.5.0", "pillow-jxl-plugin~=1.3", + "Pillow>=10.2,<12", "py7zr~=1.1.3", "pydantic~=2.10", "pydub~=0.25", + "Pygments~=2.21", "PySide6==6.11.2", "rarfile==4.2", "rawpy~=0.27", + "requests~=2.31.0", + "semver~=3.0.4", "Send2Trash>=1.8,<3", "SQLAlchemy~=2.0", "srctools~=2.6", @@ -38,8 +41,6 @@ dependencies = [ "typing_extensions~=4.13", "ujson~=5.10", "wcmatch==10.*", - "requests~=2.31.0", - "semver~=3.0.4", ] [project.gui-scripts] diff --git a/src/tagstudio/core/driver.py b/src/tagstudio/core/driver.py index e553afe18..3995b5b84 100644 --- a/src/tagstudio/core/driver.py +++ b/src/tagstudio/core/driver.py @@ -10,6 +10,7 @@ from tagstudio.core.constants import TS_FOLDER_NAME from tagstudio.core.enums import AppCacheItems from tagstudio.core.library.alchemy.library import LibraryStatus +from tagstudio.core.query_lang.file_groups import register_types from tagstudio.qt.app_settings import AppSettings logger = structlog.get_logger(__name__) @@ -21,6 +22,8 @@ class DriverMixin: # TODO: AppSettings is Qt-specific and should not be in a base driver class. settings: AppSettings + register_types() # Register all filetypes for the SEARCH context. + def evaluate_path(self, open_path: str | None) -> LibraryStatus: """Check if the path of library is valid.""" library_path: Path | None = None diff --git a/src/tagstudio/core/enums.py b/src/tagstudio/core/enums.py index ae8bf8bc9..7c2ded544 100644 --- a/src/tagstudio/core/enums.py +++ b/src/tagstudio/core/enums.py @@ -31,7 +31,7 @@ class TagClickActionOption(enum.IntEnum): DEFAULT = OPEN_EDIT -class Theme(enum.StrEnum): +class ThemePalette(enum.StrEnum): COLOR_BG_DARK = "#65000000" COLOR_BG_LIGHT = "#22000000" COLOR_DARK_LABEL = "#DD000000" @@ -44,6 +44,13 @@ class Theme(enum.StrEnum): COLOR_FORBIDDEN_BG = "#65440D12" +class Theme(enum.IntEnum): + DARK = 0 + LIGHT = 1 + SYSTEM = 2 + DEFAULT = SYSTEM + + class OpenStatus(enum.IntEnum): NOT_FOUND = 0 SUCCESS = 1 diff --git a/src/tagstudio/core/library/alchemy/visitors.py b/src/tagstudio/core/library/alchemy/visitors.py index 16f5102e4..ca61bd4ab 100644 --- a/src/tagstudio/core/library/alchemy/visitors.py +++ b/src/tagstudio/core/library/alchemy/visitors.py @@ -13,7 +13,7 @@ from tagstudio.core.library.alchemy.constants import TAG_CHILDREN_ID_QUERY from tagstudio.core.library.alchemy.joins import TagEntry from tagstudio.core.library.alchemy.models import Entry, Tag, TagAlias -from tagstudio.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories +from tagstudio.core.media_types import MediaTypeGroup, MediaTypes from tagstudio.core.query_lang.ast import ( AST, ANDList, @@ -24,6 +24,7 @@ ORList, Property, ) +from tagstudio.core.query_lang.file_groups import SEARCH # Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: @@ -34,13 +35,6 @@ logger = structlog.get_logger(__name__) -def get_filetype_equivalency_list(item: str) -> list[str] | set[str]: - for s in FILETYPE_EQUIVALENTS: - if item in s: - return s - return [item] - - class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]): def __init__(self, lib: Library) -> None: super().__init__() @@ -95,15 +89,27 @@ def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: ) return Entry.path.regexp_match(re.escape(node.value)) elif node.type == ConstraintType.MediaType: - extensions: set[str] = set[str]() - for media_cat in MediaCategories.ALL_CATEGORIES: - if node.value == media_cat.name: - extensions = extensions | media_cat.extensions - break + key = ( + MediaTypes.get_group_key_from_name( + node.value, case_sensitive=False, ignore_whitespace=True + ) + or node.value + ) + + media_type: MediaTypeGroup | None = getattr(MediaTypes, key, None) + extensions: set[str] = ( + media_type.context_sets.get(SEARCH, set()) if media_type else set() + ) return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions)) + elif node.type == ConstraintType.FileType: + # NOTE: Entries store their suffix without a leading dot, the MediaTypes system includes + # the leading dot (if any), and this search system should take in either variant. return or_( - *[Entry.suffix.ilike(ft) for ft in get_filetype_equivalency_list(node.value)] + *[ + Entry.suffix.ilike(ft.removeprefix(".")) + for ft in MediaTypes.get_equivalent_exts(f".{node.value.removeprefix('.')}") + ] ) elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint if node.value.lower() == "untagged": diff --git a/src/tagstudio/core/library/refresh.py b/src/tagstudio/core/library/refresh.py index e0267b295..18848366e 100644 --- a/src/tagstudio/core/library/refresh.py +++ b/src/tagstudio/core/library/refresh.py @@ -105,7 +105,14 @@ def __get_dir_list(self, library_dir: Path, ignore_patterns: list[str]) -> list[ shell=True, encoding="UTF-8", ) - compiled_ignore_path.unlink() + try: + compiled_ignore_path.unlink() + except Exception as e: + logger.error( + "[Refresh] Could not remove compiled ignore path", + path=compiled_ignore_path, + error=e, + ) if result.stderr: logger.error(result.stderr) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 5f2864193..1aa9446c5 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -1,714 +1,305 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT -import enum -import mimetypes -from dataclasses import dataclass -from pathlib import Path +import re +from typing import Any import structlog +from tagstudio.core.utils.sanitized_attr import SanitizedAttr + logger = structlog.get_logger(__name__) -FILETYPE_EQUIVALENTS = [ - {"aif", "aiff", "aifc"}, - {"html", "htm", "xhtml", "shtml", "dhtml"}, - {"jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"}, - {"json", "jsonc", "json5"}, - {"md", "markdown", "mkd", "rmd"}, - {"tar.gz", "tgz"}, - {"xml", "xul"}, - {"yaml", "yml"}, -] - - -class MediaType(enum.StrEnum): - """Names of media types.""" - - ADOBE_PHOTOSHOP = "adobe_photoshop" - AFFINITY_PHOTO = "affinity_photo" - ARCHIVE = "archive" - AUDIO_MIDI = "audio_midi" - AUDIO = "audio" - BLENDER = "blender" - CLIP_STUDIO_PAINT = "clip_studio_paint" - CODE = "code" - DATABASE = "database" - DISK_IMAGE = "disk_image" - DOCUMENT = "document" - EBOOK = "ebook" - FONT = "font" - IMAGE_ANIMATED = "image_animated" - IMAGE_RAW = "image_raw" - IMAGE_VECTOR = "image_vector" - IMAGE = "image" - INSTALLER = "installer" - IWORK = "iwork" - MATERIAL = "material" - MDIPACK = "mdipack" - MODEL = "model" - OPEN_DOCUMENT = "open_document" - PACKAGE = "package" - PAINT_DOT_NET = "paint_dot_net" - PDF = "pdf" - PLAINTEXT = "plaintext" - PRESENTATION = "presentation" - PROGRAM = "program" - SHADER = "shader" - SHORTCUT = "shortcut" - SOURCE_ENGINE = "source_engine" - SPREADSHEET = "spreadsheet" - TEXT = "text" - VIDEO = "video" - - -@dataclass(frozen=True) -class MediaCategory: - """An object representing a category of media. - - Includes a MediaType identifier, extensions set, and IANA status flag. + +def slugify(text: str) -> str: + """Return a sanitized string with no whitespace or hyphens.""" + # Replace non-word characters with underscores, strip whitespace and make lowercase + text = re.sub(r"\W", "_", text.strip().lower()) + # Replace remaining spaces and hyphens with underscores + text = re.sub(r"[\s-]+", "_", text) + return text + + +class FileType: + """A description of a single file type, along with an associated context. Args: - media_type (MediaType): The MediaType Enum representing this category. + exts (str | list[str]): The file extention(s), including a leading dot if there is one. + Passing a list of extensions will treat them as equivalent/interchangeable. + E.g. [".jpg", ".jpeg", ".jfif"] would be treated as the same extention. + """ + + def __init__(self, exts: str | list[str], contexts: str | list[str]) -> None: + self.exts: set[str] + self.contexts: set[str] + + if isinstance(exts, str): + self.exts = set([exts]) + else: + self.exts = set(exts) + + if isinstance(contexts, str): + self.contexts = set([contexts]) + else: + self.contexts = set(contexts) - extensions (set[str]): The set of file extensions associated with this category. - Includes leading ".", all lowercase, and does not need to be unique to this category. - is_iana (bool): Represents whether this is an IANA registered category. +class MediaTypeGroup: + """A named group of FileTypes and context associations that represents a media group. + + For example, "Image" files may be represented by a MediaTypeGroup, consisting of FileType + objects that are associated with individual extensions such as ".jpg" and ".png". """ - media_type: MediaType - extensions: set[str] - name: str - is_iana: bool = False + def __init__(self, key: str, types: list[FileType]) -> None: + """Initialize the MediaTypeGroup. + + Args: + key (str): A key for the name of this group. + Dots are used to separate group levels (e.g. "microsoft.office.word"). + This key is slugified before being used as an attribute. + types (list[FileType]): A list of FileType objects to include in the group. + """ + self.context_sets: dict[str, set[str]] = {} + self.name_aliases: list[str] = [] + self.key = key + self.types: list[FileType] = [] + self.add_types(types) + + def add_types(self, types: list[FileType]) -> None: + """Add one or more types to the group. + + Args: + types (list[FileType]): A list of FileType objects to add to the group. + """ + for type_ in types: + updated_types: set[FileType] = set() + for existing_type in self.types: + # If there's any overlap between the extensions, it's the same type + if not existing_type.exts.isdisjoint(type_.exts): + existing_type.contexts |= type_.contexts + existing_type.exts |= type_.exts + updated_types.add(type_) + if type_ not in updated_types: + self.types.append(type_) + + contexts_ = [type_.contexts] if isinstance(type_.contexts, str) else type_.contexts + for context in contexts_: + self.context_sets.setdefault(context, set()) + for ext in type_.exts: + self.context_sets[context].add(ext) + + def contains(self, ext: str, context: str) -> bool: + """Return true if the group contains an extention under a given context, otherwise False. + + Args: + ext (str): The file extention to check for in the group. + context (str): The context to check for group membership under. + """ + equivalent_exts = MediaTypes.equivalent_exts.get(ext) or [ext] + return any(e in self.context_sets.get(context, []) for e in equivalent_exts) + + +class MediaTypes(metaclass=SanitizedAttr): + """A singleton class that manages registered media types and their relationships.""" + + _chained_groups: dict[str, set[str]] = {} + _name_to_key_map: dict[str, str] = {} + all_groups: list[MediaTypeGroup] = [] + equivalent_exts: dict[str, set[str]] = {} + + @classmethod + def _snapshot(cls) -> dict[str, Any]: + """Return a snapshot of the class's attributes. Used in tests.""" + return {attr: getattr(cls, attr) for attr in dir(cls) if not attr.startswith("__")} + + @classmethod + def _restore(cls, attrs: dict[str, Any]) -> None: + """Restore the state of the class from a snapshot. Used in tests.""" + attrs_to_delete: list[str] = [] + for name in cls.__dict__: + if not name.startswith("__"): + try: + setattr(cls, name, attrs[name]) + except KeyError: + attrs_to_delete.append(name) + + for attr in attrs_to_delete: + delattr(cls, attr) + + @classmethod + def add_name_aliases(cls, group_key: str, names: str | list[str]) -> None: + """Adds one or more user-facing names for a MediaTypeGroup. + + For example, "Adobe" and "Adobe Photoshop" would be proper group names. + + Args: + group_key (str): The name key associated with a MediaTypeGroup. + If a group with this group_key does not exist, it will be created. + names (str | list[str]): One or more user-facing names for the group. + """ + group = getattr(MediaTypes, group_key, None) + if group is None: + cls.register(group_key, [], []) + group = getattr(MediaTypes, group_key, None) + + if not isinstance(group, MediaTypeGroup): + return + + if isinstance(names, str): + names = [names] + for name in names: + group.name_aliases.append(name) + # Map the name and common variants of the name to the group key. + name_no_whitespace = name.replace(" ", "").replace("-", "").replace("_", "") + name_no_space_lower = name_no_whitespace.lower() + + cls._name_to_key_map[name] = group_key + cls._name_to_key_map[name.lower()] = group_key + cls._name_to_key_map[name_no_whitespace] = group_key + cls._name_to_key_map[name_no_space_lower] = group_key + + @classmethod + def chain_group(cls, parent_group: str, child_groups: str | list[str]) -> None: + """Chain groups so when a type is added to a child group it's also added to a parent group. + + Args: + parent_group (str): The group that will also update whenever a child group is updated. + If the group doesn't exist, it will be created. + child_groups (str | list[str]): Groups that tell a parent group to update as well. + If the group doesn't exist, it will be created. + """ + if isinstance(child_groups, str): + child_groups = [child_groups] + + # If the groups don't exist, register it. + if getattr(MediaTypes, slugify(parent_group), None) is None: + cls.register(parent_group, [], []) + for child_group in child_groups: + if getattr(MediaTypes, slugify(child_group), None) is None: + cls.register(child_group, [], []) + cls._chained_groups.setdefault(parent_group, set()) + + for c_group in child_groups: + cls._chained_groups[parent_group].add(c_group) + + @classmethod + def find(cls, ext: str, context: str) -> list[MediaTypeGroup]: + """Return a list of MediaTypeGroups this extention is found in with the given context. + + Args: + ext (str): The file extention to check for in the group. + context (str): The context to check for group membership under. + """ + groups: list[MediaTypeGroup] = [] + for group in cls.all_groups: + for type_ in group.types: + equivalent_exts = cls.equivalent_exts.get(ext) or [ext] + for e in equivalent_exts: + if e in type_.exts and context in type_.contexts: + groups.append(group) + break - def contains(self, ext: str, mime_fallback: bool = False) -> bool: - """Check if an extension is a member of this MediaCategory. + return groups + + @classmethod + def contains(cls, group_key: str, ext: str, context: str) -> bool: + """A passthrough method for using `MediaTypeGroup.contains()` given a group name. + + If the group does not exist, this raises an `AttributeError`. + + Args: + group_key (str): The name key or attribute name for the MediaTypeGroup. + ext (str): The file extention to check for in the group. + context (str): The context to check for group membership under. + """ + group: MediaTypeGroup | None = getattr(MediaTypes, slugify(group_key), None) + if not group: + raise AttributeError(f"'{slugify(group_key)} is not registered with MediaTypes.") + return group.contains(ext, context) + + @classmethod + def get_group_key_from_name( + cls, name: str, case_sensitive: bool = True, ignore_whitespace: bool = False + ) -> str | None: + """Attempt to return a group key given a proper name for the group. Args: - ext (str): File extension with a leading "." and in all lowercase. - mime_fallback (bool): Flag to guess MIME type if no set matches are made. + name (str): The user-facing name of a group, or name key. + case_sensitive (bool): Should the name be treated with case sensitivity? + ignore_whitespace (bool): Should whitespace in the name be ignored? """ - if ext in self.extensions: - return True - elif mime_fallback and self.is_iana: - mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0] - if mime_type is not None and mime_type.startswith(self.media_type.value): - return True - return False - - -class MediaCategories: - """Contain pre-made MediaCategory objects as well as methods to interact with them.""" - - # These sets are used either individually or together to form the final sets - # for the MediaCategory(s). - # These sets may be combined and are NOT 1:1 with the final categories. - _ADOBE_ILLUSTRATOR_SET: set[str] = {".ai"} - _ADOBE_PHOTOSHOP_SET: set[str] = { - ".pdd", - ".psb", - ".psd", - } - _AFFINITY_PHOTO_SET: set[str] = {".afphoto"} - _KRITA_SET: set[str] = {".kra", ".krz"} - _ARCHIVE_SET: set[str] = { - ".7z", - ".gz", - ".rar", - ".s7z", - ".tar", - ".tgz", - ".zip", - } - _AUDIO_MIDI_SET: set[str] = { - ".mid", - ".midi", - } - _AUDIO_SET: set[str] = { - ".aac", - ".aif", - ".aifc", - ".aiff", - ".alac", - ".caf", - ".flac", - ".m4a", - ".m4p", - ".mp3", - ".mpeg4", - ".ogg", - ".wav", - ".wma", - } - _BLENDER_SET: set[str] = { - ".blen_tc", - ".blend", - ".blend1", - ".blend2", - ".blend3", - ".blend4", - ".blend5", - ".blend6", - ".blend7", - ".blend8", - ".blend9", - ".blend10", - ".blend11", - ".blend12", - ".blend13", - ".blend14", - ".blend15", - ".blend16", - ".blend17", - ".blend18", - ".blend19", - ".blend20", - ".blend21", - ".blend22", - ".blend23", - ".blend24", - ".blend25", - ".blend26", - ".blend27", - ".blend28", - ".blend29", - ".blend30", - ".blend31", - ".blend32", - } - _CLIP_STUDIO_PAINT_SET: set[str] = {".clip"} - _CODE_SET: set[str] = { - ".bat", - ".cfg", - ".conf", - ".cpp", - ".cs", - ".csh", - ".css", - ".d", - ".dhtml", - ".fgd", - ".fish", - ".gitignore", - ".h", - ".hpp", - ".htm", - ".html", - ".inf", - ".ini", - ".js", - ".json", - ".json5", - ".jsonc", - ".jsx", - ".kv3", - ".lua", - ".meta", - ".nix", - ".nu", - ".nut", - ".php", - ".plist", - ".prefs", - ".ps1", - ".py", - ".pyi", - ".qml", - ".qrc", - ".qss", - ".rs", - ".sh", - ".shtml", - ".sip", - ".spec", - ".tcl", - ".timestamp", - ".toml", - ".ts", - ".tsx", - ".vcfg", - ".vdf", - ".vmt", - ".vqlayout", - ".vsc", - ".vsnd_template", - ".xhtml", - ".xml", - ".xul", - ".yaml", - ".yml", - } - _DATABASE_SET: set[str] = { - ".accdb", - ".mdb", - ".pdb", - ".sqlite", - ".sqlite3", - } - _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".fhdx", ".iso"} - _DOCUMENT_SET: set[str] = { - ".doc", - ".docm", - ".docx", - ".dot", - ".dotm", - ".dotx", - ".odt", - ".pages", - ".pdf", - ".pxd", - ".rtf", - ".tex", - ".wpd", - ".wps", - } - _EBOOK_SET: set[str] = { - ".azw", - ".azw3", - ".cb7", - ".cba", - ".cbr", - ".cbt", - ".cbz", - ".djvu", - ".epub", - ".fb2", - ".ibook", - ".inf", - ".kfx", - ".lit", - ".mobi", - ".pdb", - ".prc", - } - _FONT_SET: set[str] = { - ".fon", - ".otf", - ".ttc", - ".ttf", - ".woff", - ".woff2", - } - _IMAGE_ANIMATED_SET: set[str] = { - ".apng", - ".gif", - ".webp", - } - _IMAGE_RAW_SET: set[str] = { - ".arw", - ".cr2", - ".cr3", - ".crw", - ".dng", - ".nef", - ".nrw", - ".orf", - ".r3d", - ".raf", - ".raw", - ".rw2", - ".srf", - ".srf2", - } - _IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"} - _IMAGE_RASTER_SET: set[str] = { - ".apng", - ".avif", - ".bmp", - ".exr", - ".gif", - ".heic", - ".heif", - ".icns", - ".j2k", - ".jfif", - ".jp2", - ".jpeg_large", - ".jpeg", - ".jpg_large", - ".jpg", - ".jpg2", - ".jxl", - ".png", - ".psb", - ".psd", - ".tif", - ".tiff", - ".webp", - } - _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} - _IWORK_SET: set[str] = {".key", ".numbers", ".pages"} - _MATERIAL_SET: set[str] = {".mtl"} - _MDIPACK_SET: set[str] = {".mdp"} - _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} - _OPEN_DOCUMENT_SET: set[str] = { - ".fodg", - ".fodp", - ".fods", - ".fodt", - ".mscz", - ".odf", - ".odg", - ".odp", - ".ods", - ".odt", - ".ora", - } - _PACKAGE_SET: set[str] = { - ".aab", - ".akp", - ".apk", - ".apkm", - ".apks", - ".pkg", - ".xapk", - } - _PAINT_DOT_NET_SET: set[str] = {".pdn"} - _PDF_SET: set[str] = {".pdf"} - _PLAINTEXT_SET: set[str] = { - ".csv", - ".i3u", - ".lang", - ".lock", - ".log", - ".markdown", - ".md", - ".mkd", - ".rmd", - ".text", - ".txt", - "contributing", - "license", - "readme", - } - _PRESENTATION_SET: set[str] = { - ".key", - ".odp", - ".ppt", - ".pptx", - } - _PROGRAM_SET: set[str] = {".app", ".bin", ".exe"} - _SOURCE_ENGINE_SET: set[str] = {".vtf"} - _SHADER_SET: set[str] = { - ".effect", - ".frag", - ".fsh", - ".glsl", - ".shader", - ".vert", - ".vsh", - } - _SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"} - _SPREADSHEET_SET: set[str] = { - ".csv", - ".numbers", - ".ods", - ".xls", - ".xlsx", - } - _VIDEO_SET: set[str] = { - ".3gp", - ".avi", - ".flv", - ".gifv", - ".hevc", - ".m4p", - ".m4v", - ".mkv", - ".mov", - ".mp4", - ".webm", - ".wmv", - ".ts", - } - - ADOBE_PHOTOSHOP_TYPES = MediaCategory( - media_type=MediaType.ADOBE_PHOTOSHOP, - extensions=_ADOBE_PHOTOSHOP_SET, - is_iana=False, - name="photoshop", - ) - AFFINITY_PHOTO_TYPES = MediaCategory( - media_type=MediaType.AFFINITY_PHOTO, - extensions=_AFFINITY_PHOTO_SET, - is_iana=False, - name="affinity photo", - ) - ARCHIVE_TYPES = MediaCategory( - media_type=MediaType.ARCHIVE, - extensions=_ARCHIVE_SET, - is_iana=False, - name="archive", - ) - AUDIO_MIDI_TYPES = MediaCategory( - media_type=MediaType.AUDIO_MIDI, - extensions=_AUDIO_MIDI_SET, - is_iana=False, - name="audio midi", - ) - AUDIO_TYPES = MediaCategory( - media_type=MediaType.AUDIO, - extensions=_AUDIO_SET | _AUDIO_MIDI_SET, - is_iana=True, - name="audio", - ) - BLENDER_TYPES = MediaCategory( - media_type=MediaType.BLENDER, - extensions=_BLENDER_SET, - is_iana=False, - name="blender", - ) - CLIP_STUDIO_PAINT_TYPES = MediaCategory( - media_type=MediaType.CLIP_STUDIO_PAINT, - extensions=_CLIP_STUDIO_PAINT_SET, - is_iana=False, - name="clip studio paint", - ) - CODE_TYPES = MediaCategory( - media_type=MediaType.CODE, - extensions=_CODE_SET, - is_iana=False, - name="code", - ) - DATABASE_TYPES = MediaCategory( - media_type=MediaType.DATABASE, - extensions=_DATABASE_SET, - is_iana=False, - name="database", - ) - DISK_IMAGE_TYPES = MediaCategory( - media_type=MediaType.DISK_IMAGE, - extensions=_DISK_IMAGE_SET, - is_iana=False, - name="disk image", - ) - DOCUMENT_TYPES = MediaCategory( - media_type=MediaType.DOCUMENT, - extensions=_DOCUMENT_SET, - is_iana=False, - name="document", - ) - EBOOK_TYPES = MediaCategory( - media_type=MediaType.EBOOK, - extensions=_EBOOK_SET, - is_iana=False, - name="ebook", - ) - FONT_TYPES = MediaCategory( - media_type=MediaType.FONT, - extensions=_FONT_SET, - is_iana=True, - name="font", - ) - IMAGE_ANIMATED_TYPES = MediaCategory( - media_type=MediaType.IMAGE_ANIMATED, - extensions=_IMAGE_ANIMATED_SET, - is_iana=False, - name="animated image", - ) - IMAGE_RAW_TYPES = MediaCategory( - media_type=MediaType.IMAGE_RAW, - extensions=_IMAGE_RAW_SET, - is_iana=False, - name="raw image", - ) - IMAGE_VECTOR_TYPES = MediaCategory( - media_type=MediaType.IMAGE_VECTOR, - extensions=_IMAGE_VECTOR_SET, - is_iana=False, - name="vector image", - ) - IMAGE_RASTER_TYPES = MediaCategory( - media_type=MediaType.IMAGE, - extensions=_IMAGE_RASTER_SET, - is_iana=False, - name="raster image", - ) - IMAGE_TYPES = MediaCategory( - media_type=MediaType.IMAGE, - extensions=_IMAGE_RASTER_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET, - is_iana=True, - name="image", - ) - INSTALLER_TYPES = MediaCategory( - media_type=MediaType.INSTALLER, - extensions=_INSTALLER_SET, - is_iana=False, - name="installer", - ) - IWORK_TYPES = MediaCategory( - media_type=MediaType.IWORK, - extensions=_IWORK_SET, - is_iana=False, - name="iwork", - ) - MATERIAL_TYPES = MediaCategory( - media_type=MediaType.MATERIAL, - extensions=_MATERIAL_SET, - is_iana=False, - name="material", - ) - MDIPACK_TYPES = MediaCategory( - media_type=MediaType.MDIPACK, - extensions=_MDIPACK_SET, - is_iana=False, - name="mdipack", - ) - MODEL_TYPES = MediaCategory( - media_type=MediaType.MODEL, - extensions=_MODEL_SET, - is_iana=True, - name="model", - ) - OPEN_DOCUMENT_TYPES = MediaCategory( - media_type=MediaType.OPEN_DOCUMENT, - extensions=_OPEN_DOCUMENT_SET, - is_iana=False, - name="open document", - ) - PACKAGE_TYPES = MediaCategory( - media_type=MediaType.PACKAGE, - extensions=_PACKAGE_SET, - is_iana=False, - name="package", - ) - PAINT_DOT_NET_TYPES = MediaCategory( - media_type=MediaType.PAINT_DOT_NET, - extensions=_PAINT_DOT_NET_SET, - is_iana=False, - name="paint.net", - ) - PDF_TYPES = MediaCategory( - media_type=MediaType.PDF, - extensions=_PDF_SET | _ADOBE_ILLUSTRATOR_SET, - is_iana=False, - name="pdf", - ) - PLAINTEXT_TYPES = MediaCategory( - media_type=MediaType.PLAINTEXT, - extensions=_PLAINTEXT_SET | _CODE_SET, - is_iana=False, - name="plaintext", - ) - PRESENTATION_TYPES = MediaCategory( - media_type=MediaType.PRESENTATION, - extensions=_PRESENTATION_SET, - is_iana=False, - name="presentation", - ) - PROGRAM_TYPES = MediaCategory( - media_type=MediaType.PROGRAM, - extensions=_PROGRAM_SET, - is_iana=False, - name="program", - ) - SHADER_TYPES = MediaCategory( - media_type=MediaType.SHADER, - extensions=_SHADER_SET, - is_iana=False, - name="shader", - ) - SHORTCUT_TYPES = MediaCategory( - media_type=MediaType.SHORTCUT, - extensions=_SHORTCUT_SET, - is_iana=False, - name="shortcut", - ) - SOURCE_ENGINE_TYPES = MediaCategory( - media_type=MediaType.SOURCE_ENGINE, - extensions=_SOURCE_ENGINE_SET, - is_iana=False, - name="source engine", - ) - SPREADSHEET_TYPES = MediaCategory( - media_type=MediaType.SPREADSHEET, - extensions=_SPREADSHEET_SET, - is_iana=False, - name="spreadsheet", - ) - TEXT_TYPES = MediaCategory( - media_type=MediaType.TEXT, - extensions=_DOCUMENT_SET | _PLAINTEXT_SET, - is_iana=True, - name="text", - ) - VIDEO_TYPES = MediaCategory( - media_type=MediaType.VIDEO, - extensions=_VIDEO_SET, - is_iana=True, - name="video", - ) - KRITA_TYPES = MediaCategory( - media_type=MediaType.IMAGE, - extensions=_KRITA_SET, - is_iana=False, - name="krita", - ) - - ALL_CATEGORIES = [ - ADOBE_PHOTOSHOP_TYPES, - AFFINITY_PHOTO_TYPES, - ARCHIVE_TYPES, - AUDIO_MIDI_TYPES, - AUDIO_TYPES, - BLENDER_TYPES, - CLIP_STUDIO_PAINT_TYPES, - DATABASE_TYPES, - DISK_IMAGE_TYPES, - DOCUMENT_TYPES, - EBOOK_TYPES, - FONT_TYPES, - IMAGE_ANIMATED_TYPES, - IMAGE_RAW_TYPES, - IMAGE_TYPES, - IMAGE_VECTOR_TYPES, - INSTALLER_TYPES, - IWORK_TYPES, - MATERIAL_TYPES, - MDIPACK_TYPES, - MODEL_TYPES, - OPEN_DOCUMENT_TYPES, - PACKAGE_TYPES, - PAINT_DOT_NET_TYPES, - PDF_TYPES, - PLAINTEXT_TYPES, - PRESENTATION_TYPES, - PROGRAM_TYPES, - CODE_TYPES, - SHADER_TYPES, - SHORTCUT_TYPES, - SOURCE_ENGINE_TYPES, - SPREADSHEET_TYPES, - TEXT_TYPES, - VIDEO_TYPES, - KRITA_TYPES, - ] - - @staticmethod - def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]: - """Return a set of MediaTypes given a file extension. + if not case_sensitive: + name = name.lower() + if ignore_whitespace: + name = name.replace(" ", "").replace("-", "").replace("_", "") + return cls._name_to_key_map.get(name) + + @classmethod + def register(cls, group_key: str, ext: str | list[str], contexts: str | list[str]) -> None: + """Create and register or update a existing MediaTypeGroup inside MediaTypes. Args: - ext (str): File extension with a leading "." and in all lowercase. - mime_fallback (bool): Flag to guess MIME type if no set matches are made. + group_key (str): group_key (str): The name key of the MediaTypeGroup. + ext (str | list[str]): One or more file extensions, including leading dot. + Passing a list of extensions will treat them as equivalent/interchangeable. + E.g. [".jpg", ".jpeg", ".jfif"] would be treated as the same extention. + contexts (list[str] | str): One or more contexts to register the extension(s) under. """ - media_types: set[MediaType] = set() + # Sanitize and homogenize arguments + attr_name = slugify(group_key) + + if attr_name in _FORBIDDEN_NAMES: + raise AttributeError(f"{attr_name}' collides with an internal attribute.") - for cat in MediaCategories.ALL_CATEGORIES: - if cat.contains(ext, mime_fallback): - media_types.add(cat.media_type) + if isinstance(ext, str): + ext = [ext] + if isinstance(contexts, str): + contexts = [contexts] - return media_types + # Check for existing group or create new one + group = getattr(MediaTypes, attr_name, None) + assert isinstance(group, MediaTypeGroup) or group is None - @staticmethod - def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = False) -> bool: - """Check if an extension is a member of a MediaCategory. + if group is None: + group = MediaTypeGroup(group_key, []) + group.add_types([FileType(ext, contexts)]) + setattr(MediaTypes, attr_name, group) + cls.all_groups.append(group) + else: + group.add_types([FileType(ext, contexts)]) + + # Store any file extention equivalents + if len(ext) > 1: + for e in ext: + cls.equivalent_exts.setdefault(e, set(ext)) + + # Create any chained groups from dot notations (e.g. "adobe.photoshop") + name_parts = group_key.split(".") + for i in range(1, len(name_parts)): + parent = ".".join(name_parts[:i]) + child = ".".join(name_parts[: i + 1]) + cls.chain_group(parent, [child]) + + # Update any chained groups + chained_groups: set[str] = set() + for k, v in cls._chained_groups.items(): + if group_key in v: + chained_groups.add(k) + + if chained_groups: + for c_name in chained_groups: + cls.register(c_name, ext, contexts) + + @classmethod + def get_equivalent_exts(cls, ext: str) -> set[str]: + """Return a set of equivalent file extensions given an extention, including itself. Args: - ext (str): File extension with a leading "." and in all lowercase. - media_cat (MediaCategory): The MediaCategory to check for extension membership. - mime_fallback (bool): Flag to guess MIME type if no set matches are made. + ext (str): The file extension, including a leading dot (if there is one). """ - return media_cat.contains(ext, mime_fallback) + return cls.equivalent_exts.get(ext, {ext}) + + +_FORBIDDEN_NAMES = set(dir(MediaTypes)) diff --git a/src/tagstudio/core/query_lang/file_groups.py b/src/tagstudio/core/query_lang/file_groups.py new file mode 100644 index 000000000..deb13f246 --- /dev/null +++ b/src/tagstudio/core/query_lang/file_groups.py @@ -0,0 +1,778 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +import structlog + +from tagstudio.core.media_types import MediaTypes + +logger = structlog.get_logger(__name__) + + +SEARCH = "SEARCH" # MediaType Context + + +def register_types() -> None: + """Register all internally configured filetype groups with the MediaTypes system.""" + # Vendor.Suite.Product ========================================================================= + # These groups are designed so that searching for either the vendor, suite, or product + # will return file types only under that group level. + + # Initial Miscellaneous Chaining ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + MediaTypes.chain_group( + "office", + [ + "adobe.acrobat", + "apple.iwork", + "microsoft.office", + "open_document", + ], + ) + MediaTypes.add_name_aliases("office", ["Office", "Office Suite"]) + + MediaTypes.chain_group( + "document", + [ + "adobe.acrobat", + "apple.iwork.pages", + "microsoft.office.word", + "open_document.document", + "typesetting", + ], + ) + + MediaTypes.chain_group( + "presentation", + [ + "apple.iwork.keynote", + "microsoft.office.powerpoint", + "open_document.presentation", + ], + ) + + MediaTypes.chain_group( + "spreadsheet", + [ + "apple.iwork.numbers", + "microsoft.office.excel", + "open_document.spreadsheet", + ], + ) + + # Adobe -------------------------------------------------------------------- + MediaTypes.add_name_aliases("adobe", "Adobe") + + # Adobe Acrobat/Reader + MediaTypes.add_name_aliases( + "adobe.acrobat", + [ + "Acrobat", + "Adobe Acrobat", + "Adobe Reader", + "PDF", + "Reader", + ], + ) + MediaTypes.register("adobe.acrobat", ".fdf", SEARCH) + MediaTypes.register("adobe.acrobat", ".pdf", SEARCH) + MediaTypes.register("adobe.acrobat", ".pdx", SEARCH) + MediaTypes.register("adobe.acrobat", ".ps", SEARCH) + MediaTypes.register("adobe.acrobat", ".xfdf", SEARCH) + MediaTypes.register("adobe.acrobat", ".xps", SEARCH) + + # Adobe Illustrator + MediaTypes.add_name_aliases("adobe.illustrator", ["Illustrator", "Adobe Illustrator"]) + MediaTypes.register("adobe.illustrator", ".ai", SEARCH) + + # Adobe Photoshop + MediaTypes.add_name_aliases("adobe.photoshop", ["Photoshop", "Adobe Photoshop"]) + MediaTypes.register("adobe.photoshop", ".pdd", SEARCH) + MediaTypes.register("adobe.photoshop", ".psb", SEARCH) + MediaTypes.register("adobe.photoshop", ".psd", SEARCH) + + # Affinity ----------------------------------------------------------------- + # NOTE: Affinity suite products with generic names (e.g. "Photo") should not have those + # names be standalone aliases as they will conflict with other, more common names. + MediaTypes.add_name_aliases("affinity", "Affinity") + MediaTypes.register("affinity", ".af", SEARCH) + + # Affinity Designer + MediaTypes.add_name_aliases("affinity.designer", ["Designer", "Affinity Designer"]) + MediaTypes.register("affinity.designer", ".afdesign", SEARCH) + + # Affinity Photo + MediaTypes.add_name_aliases("affinity.photo", "Affinity Photo") + MediaTypes.register("affinity.photo", ".afphoto", SEARCH) + + # Affinity Publisher + MediaTypes.add_name_aliases("affinity.publisher", "Affinity Publisher") + MediaTypes.register("affinity.publisher", [".afpublisher", ".afpub"], SEARCH) + + # Apple & iWork ------------------------------------------------------------ + MediaTypes.add_name_aliases("apple", "Apple") + MediaTypes.add_name_aliases("apple.iwork", "iWork") + MediaTypes.add_name_aliases("apple.creator_studio", ["Apple Creator Studio", "Creator Studio"]) + # NOTE: iWork is a subset of Creator Studio + MediaTypes.chain_group("apple.creator_studio", "apple.iwork") + + # Apple Books + MediaTypes.add_name_aliases("apple.books", ["Apple Books", "Apple iBooks", "iBooks"]) + MediaTypes.register("apple.books", ".ibook", SEARCH) + + # Keynote (iWork + Apple Creator Studio) + MediaTypes.add_name_aliases( + "apple.iwork.keynote", + [ + "Keynote", + "Apple Keynote", + "Apple iWork Keynote", + "iWork Keynote", + ], + ) + MediaTypes.register("apple.iwork.keynote", ".key", SEARCH) + + # Numbers (iWork + Apple Creator Studio) + MediaTypes.add_name_aliases( + "apple.iwork.numbers", + [ + "Numbers", + "Apple Numbers", + "Apple iWork Numbers", + "iWork Numbers", + ], + ) + MediaTypes.register("apple.iwork.numbers", ".numbers", SEARCH) + + # Pages (iWork + Apple Creator Studio) + MediaTypes.add_name_aliases( + "apple.iwork.pages", + [ + "Pages", + "Apple Pages", + "Apple iWork Pages", + "iWork Pages", + ], + ) + MediaTypes.register("apple.iwork.pages", ".pages", SEARCH) + + # Pixelmator Pro (Apple Creator Studio) + MediaTypes.add_name_aliases( + "apple.creator_studio.pixelmator", + [ + "Apple Pixelmator Pro", + "Apple Pixelmator", + "Pixelmator Pro", + "Pixelmator", + ], + ) + MediaTypes.register("apple.creator_studio.pixelmator", ".pxd", SEARCH) + + # Autodesk ----------------------------------------------------------------- + MediaTypes.add_name_aliases("autodesk", "Autodesk") + MediaTypes.register("autodesk", ".3ds", SEARCH) + MediaTypes.register("autodesk", ".fbx", SEARCH) + + # Blender ------------------------------------------------------------------ + MediaTypes.add_name_aliases("blender", "Blender") + MediaTypes.register("blender", ".blen_tc", SEARCH) + MediaTypes.register("blender", ".blend", SEARCH) + # Numbered Blender auto-backup files (.blend1 - .blend32) + MediaTypes.register("blender", [f".blend{i}" for i in range(1, 33)], SEARCH) + + # Clip Studio Paint -------------------------------------------------------- + MediaTypes.add_name_aliases("clip_studio_paint", ["Clip Studio", "Clip Studio Paint"]) + MediaTypes.register("clip_studio_paint", ".clip", SEARCH) + MediaTypes.register("clip_studio_paint", ".cmc", SEARCH) + MediaTypes.register("clip_studio_paint", ".lip", SEARCH) + + # Corel -------------------------------------------------------------------- + MediaTypes.add_name_aliases("corel.wordperfect", ["WordPerfect", "Corel WordPerfect"]) + MediaTypes.register("corel.wordperfect", ".wpd", SEARCH) + MediaTypes.add_name_aliases("corel", "Corel") + + # GIMP --------------------------------------------------------------------- + MediaTypes.add_name_aliases("gimp", "GIMP") + MediaTypes.register("gimp", ".ora", SEARCH) # OpenRaster, used by Krita, GIMP, etc. + MediaTypes.register("gimp", ".xcf", SEARCH) + + # Krita -------------------------------------------------------------------- + # NOTE: As more KDE apps potentially get added, this might need to go under a KDE group. + MediaTypes.add_name_aliases("krita", ["Krita", "KDE Krita"]) + MediaTypes.register("krita", ".kra", SEARCH) + MediaTypes.register("krita", ".krz", SEARCH) + MediaTypes.register("krita", ".ora", SEARCH) # OpenRaster, used by Krita, GIMP, etc. + + # MediBang Paint / FireAlpaca ---------------------------------------------- + MediaTypes.add_name_aliases("medibang_paint", ["FireAlpaca", "MediBang Paint", "MediBang"]) + MediaTypes.register("medibang_paint", ".mdp", SEARCH) + + # Microsoft Office --------------------------------------------------------- + MediaTypes.add_name_aliases( + "microsoft.office", + [ + "Microsoft 365", + "Microsoft Office 365", + "Microsoft Office", + "MS Office", + "Office 365", + ], + ) + MediaTypes.register("microsoft.office", ".wdb", SEARCH) # Microsoft Works Database + + MediaTypes.add_name_aliases( + "microsoft.office.access", + [ + "Access", + "Microsoft Access", + "Microsoft Office Access", + "Office Access", + ], + ) + MediaTypes.register("microsoft.office.access", ".accdb", SEARCH) + MediaTypes.register("microsoft.office.access", ".mdb", SEARCH) + + MediaTypes.add_name_aliases( + "microsoft.office.excel", + [ + "Excel", + "Microsoft Excel", + "Microsoft Office Excel", + "Office Excel", + ], + ) + MediaTypes.register("microsoft.office.excel", ".xlr", SEARCH) + MediaTypes.register("microsoft.office.excel", ".xls", SEARCH) + MediaTypes.register("microsoft.office.excel", ".xlsx", SEARCH) + + MediaTypes.add_name_aliases( + "microsoft.office.powerpoint", + [ + "PowerPoint", + "Microsoft PowerPoint", + "Microsoft Office PowerPoint", + "Office PowerPoint", + ], + ) + MediaTypes.register("microsoft.office.powerpoint", ".pot", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".potm", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".potx", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".ppam", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".pps", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".ppsm", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".ppsx", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".ppt", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".pptm", SEARCH) + MediaTypes.register("microsoft.office.powerpoint", ".pptx", SEARCH) + + MediaTypes.add_name_aliases( + "microsoft.office.word", + [ + "Word", + "Microsoft Word", + "Microsoft Office Word", + "Office Word", + ], + ) + MediaTypes.register("microsoft.office.word", ".doc", SEARCH) + MediaTypes.register("microsoft.office.word", ".docm", SEARCH) + MediaTypes.register("microsoft.office.word", ".docx", SEARCH) + MediaTypes.register("microsoft.office.word", ".dot", SEARCH) + MediaTypes.register("microsoft.office.word", ".dotm", SEARCH) + MediaTypes.register("microsoft.office.word", ".dotx", SEARCH) + MediaTypes.register("microsoft.office.word", ".wps", SEARCH) + + # MuseScore ---------------------------------------------------------------- + MediaTypes.add_name_aliases("musescore", ["MuseScore", "MuseScore Studio"]) + MediaTypes.register("musescore", ".mscz", SEARCH) + + # OpenDocument ------------------------------------------------------------- + MediaTypes.add_name_aliases("open_document", ["LibreOffice", "OpenDocument", "OpenOffice"]) + MediaTypes.register("open_document", ".fodg", SEARCH) + MediaTypes.register("open_document", ".odf", SEARCH) + MediaTypes.register("open_document", ".odg", SEARCH) + + MediaTypes.register("open_document.document", ".fodt", SEARCH) + MediaTypes.register("open_document.document", ".odt", SEARCH) + + MediaTypes.register("open_document.presentation", ".fodp", SEARCH) + MediaTypes.register("open_document.presentation", ".odp", SEARCH) + + MediaTypes.register("open_document.spreadsheet", ".fods", SEARCH) + MediaTypes.register("open_document.spreadsheet", ".ods", SEARCH) + + # Paint.NET ---------------------------------------------------------------- + MediaTypes.add_name_aliases("paint_dot_net", ["Paint.NET", "PaintDotNet"]) + MediaTypes.register("paint_dot_net", ".pdn", SEARCH) + + # Unity Game Engine -------------------------------------------------------- + MediaTypes.add_name_aliases("unity", ["Unity Engine", "Unity"]) + MediaTypes.register("unity", ".meta", SEARCH) + + # Valve Source Engine ------------------------------------------------------ + MediaTypes.add_name_aliases( + "source_engine", + [ + "Source 2 Engine", + "Source Engine", + "Valve Source 2 Engine", + "Valve Source Engine", + ], + ) + MediaTypes.register("source_engine", ".fgd", SEARCH) + MediaTypes.register("source_engine", ".gi", SEARCH) + MediaTypes.register("source_engine", ".kv3", SEARCH) + MediaTypes.register("source_engine", ".nut", SEARCH) + MediaTypes.register("source_engine", ".vcfg", SEARCH) + MediaTypes.register("source_engine", ".vdf", SEARCH) + MediaTypes.register("source_engine", ".vmt", SEARCH) + MediaTypes.register("source_engine", ".vqlayout", SEARCH) + MediaTypes.register("source_engine", ".vsc", SEARCH) + MediaTypes.register("source_engine", ".vsnd_template", SEARCH) + MediaTypes.register("source_engine", ".vtf", SEARCH) + + # General Media Types ========================================================================== + # These are general groups for media types based on the file formats and uses themselves, rather + # than the vendors. Extensions may be duplicated here if they belong in both sections. + + # 3D Models & Materials ---------------------------------------------------- + MediaTypes.add_name_aliases("material", "Material") + MediaTypes.register("material", ".mtl", SEARCH) + + MediaTypes.add_name_aliases("model", ["3D Model", "3D Object", "Model", "Object"]) + MediaTypes.register("model", ".3ds", SEARCH) + MediaTypes.register("model", ".3mf", SEARCH) + MediaTypes.register("model", ".fbx", SEARCH) + MediaTypes.register("model", ".obj", SEARCH) + MediaTypes.register("model", ".stl", SEARCH) + + # Archives ----------------------------------------------------------------- + MediaTypes.add_name_aliases("archive", ["Archive", "Compressed"]) + MediaTypes.register("archive", ".cba", SEARCH) # Also under "ebook.comic" + + # RAR + MediaTypes.add_name_aliases( + "archive.rar", + [ + "RAR Archive", + "RAR", + "WinRAR", + "WinRAR Archive", + ], + ) + MediaTypes.register("archive.rar", ".cbr", SEARCH) # Also under "ebook.comic" + MediaTypes.register("archive.rar", ".rar", SEARCH) + MediaTypes.register("archive.rar", ".rev", SEARCH) + + # tar + MediaTypes.add_name_aliases( + "archive.tar", + [ + "Tape Archive", + "tar Archive", + "tarball", + "tar", + ], + ) + MediaTypes.register("archive.tar", ".tar", SEARCH) + MediaTypes.register("archive.tar", [".tar.bz", ".tb2", ".tbz", ".tbz2", ".tz2"], SEARCH) + MediaTypes.register("archive.tar", [".tar.gz", ".taz", ".tgz"], SEARCH) + MediaTypes.register("archive.tar", [".tar.lzma", ".tlz"], SEARCH) + MediaTypes.register("archive.tar", [".tar.xz", ".txz"], SEARCH) + MediaTypes.register("archive.tar", [".tar.zst", ".tzst"], SEARCH) + MediaTypes.register("archive.tar", ".cbt", SEARCH) # Also under "ebook.comic" + + # ZIP + MediaTypes.add_name_aliases( + "archive.zip", + [ + "7-Zip Archive", + "7-Zip", + "SevenZip Archive", + "SevenZip", + "WinZIP Archive", + "WinZIP", + "Zip Archive", + "ZIP", + "ZIP File", + ], + ) + MediaTypes.register("archive.zip", ".7z", SEARCH) + MediaTypes.register("archive.zip", ".cb7", SEARCH) # Also under "ebook.comic" + MediaTypes.register("archive.zip", ".cbz", SEARCH) # Also under "ebook.comic" + MediaTypes.register("archive.zip", ".gz", SEARCH) + MediaTypes.register("archive.zip", ".s7z", SEARCH) + MediaTypes.register("archive.zip", ".zip", SEARCH) + MediaTypes.register("archive.zip", ".zipx", SEARCH) + + # Audio -------------------------------------------------------------------- + MediaTypes.add_name_aliases("audio", "Audio") + MediaTypes.register("audio", ".aac", SEARCH) + MediaTypes.register("audio", ".aifc", SEARCH) + MediaTypes.register("audio", ".alac", SEARCH) + MediaTypes.register("audio", ".caf", SEARCH) + MediaTypes.register("audio", ".flac", SEARCH) + MediaTypes.register("audio", ".m4a", SEARCH) + MediaTypes.register("audio", ".m4p", SEARCH) + MediaTypes.register("audio", ".m4r", SEARCH) + MediaTypes.register("audio", ".mp3", SEARCH) + MediaTypes.register("audio", ".ogg", SEARCH) + MediaTypes.register("audio", ".wma", SEARCH) + MediaTypes.register("audio", [".aif", ".aiff"], SEARCH) + MediaTypes.register("audio", [".wav", ".wave"], SEARCH) + + # MIDI + MediaTypes.add_name_aliases("audio.midi", ["MIDI", "General MIDI"]) + MediaTypes.register("audio.midi", [".mid", ".midi"], SEARCH) + + # Binary ------------------------------------------------------------------- + MediaTypes.add_name_aliases("binary", "Binary") + MediaTypes.register("binary", ".aab", SEARCH) + MediaTypes.register("binary", ".dll", SEARCH) + MediaTypes.register("binary", ".dylib", SEARCH) + MediaTypes.register("binary", ".exe", SEARCH) + MediaTypes.register("binary", ".o", SEARCH) + MediaTypes.register("binary", ".pyc", SEARCH) + MediaTypes.register("binary", ".pyd", SEARCH) + MediaTypes.register("binary", ".pyo", SEARCH) + + # Databases ---------------------------------------------------------------- + MediaTypes.add_name_aliases("database", ["Database", "DB"]) + MediaTypes.register("database", ".db", SEARCH) + MediaTypes.register("database", ".pdb", SEARCH) + MediaTypes.register("database", ".sqlite", SEARCH) + MediaTypes.register("database", ".sqlite3", SEARCH) + MediaTypes.register("database", ".wdb", SEARCH) + + # Documents ---------------------------------------------------------------- + MediaTypes.add_name_aliases("document", ["Document", "Text Document", "Word Processor"]) + + # Disk Images -------------------------------------------------------------- + MediaTypes.add_name_aliases("disk_image", ["Disk Image", "Disc Image"]) + MediaTypes.register("disk_image", ".bios", SEARCH) + MediaTypes.register("disk_image", ".dmg", SEARCH) + MediaTypes.register("disk_image", ".fhdx", SEARCH) + MediaTypes.register("disk_image", ".iso", SEARCH) + MediaTypes.register("disk_image", ".udf", SEARCH) + + # eBooks & Comics ---------------------------------------------------------- + MediaTypes.add_name_aliases("ebook", "eBook") + MediaTypes.register("ebook", ".azw", SEARCH) + MediaTypes.register("ebook", ".azw3", SEARCH) + MediaTypes.register("ebook", ".djvu", SEARCH) + MediaTypes.register("ebook", ".epub", SEARCH) + MediaTypes.register("ebook", ".fb2", SEARCH) + MediaTypes.register("ebook", ".ibook", SEARCH) # Also under "apple.books" + MediaTypes.register("ebook", ".kfx", SEARCH) + MediaTypes.register("ebook", ".lit", SEARCH) + MediaTypes.register("ebook", ".mobi", SEARCH) + MediaTypes.register("ebook", ".prc", SEARCH) + + # Comic Book Archives + MediaTypes.add_name_aliases("ebook.comic", ["Comic Archive", "Comic Book Archive", "Comic"]) + MediaTypes.register("ebook.comic", ".cb7", SEARCH) + MediaTypes.register("ebook.comic", ".cba", SEARCH) + MediaTypes.register("ebook.comic", ".cbr", SEARCH) + MediaTypes.register("ebook.comic", ".cbt", SEARCH) + MediaTypes.register("ebook.comic", ".cbz", SEARCH) + + # Fonts -------------------------------------------------------------------- + MediaTypes.add_name_aliases("font", "Font") + MediaTypes.register("font", ".fon", SEARCH) + MediaTypes.register("font", ".otf", SEARCH) + MediaTypes.register("font", ".ttc", SEARCH) + MediaTypes.register("font", ".ttf", SEARCH) + MediaTypes.register("font", ".woff", SEARCH) + MediaTypes.register("font", ".woff2", SEARCH) + + # Images ------------------------------------------------------------------- + MediaTypes.add_name_aliases("image", ["Image", "Photo", "Picture"]) + + # Raster Images + MediaTypes.add_name_aliases("image.raster", "Raster Image") + MediaTypes.register("image.raster", ".apng", SEARCH) + MediaTypes.register("image.raster", ".avif", SEARCH) + MediaTypes.register("image.raster", ".bmp", SEARCH) + MediaTypes.register("image.raster", ".exr", SEARCH) + MediaTypes.register("image.raster", ".gif", SEARCH) + MediaTypes.register("image.raster", ".jxl", SEARCH) + MediaTypes.register("image.raster", ".png", SEARCH) + MediaTypes.register("image.raster", ".webp", SEARCH) + MediaTypes.register("image.raster", [".heic", ".heif"], SEARCH) + MediaTypes.register("image.raster", [".j2k", ".jp2", ".jpg2"], SEARCH) + MediaTypes.register( + "image.raster", + [ + ".jfif", + ".jpeg_large", + ".jpeg", + ".jpg_large", + ".jpg", + ], + SEARCH, + ) + MediaTypes.register("image.raster", [".tif", ".tiff"], SEARCH) + + # Icons + MediaTypes.add_name_aliases("image.raster.icon", "Icon") + MediaTypes.register("image.raster.icon", ".icns", SEARCH) + MediaTypes.register("image.raster.icon", ".ico", SEARCH) + MediaTypes.register("image.raster.icon", ".icon", SEARCH) + + # Raw Images + MediaTypes.add_name_aliases("image.raster.raw", ["Digital Negative", "Raw Image", "Raw"]) + MediaTypes.register("image.raster.raw", ".arw", SEARCH) + MediaTypes.register("image.raster.raw", ".cr2", SEARCH) + MediaTypes.register("image.raster.raw", ".cr3", SEARCH) + MediaTypes.register("image.raster.raw", ".crw", SEARCH) + MediaTypes.register("image.raster.raw", ".dng", SEARCH) + MediaTypes.register("image.raster.raw", ".nef", SEARCH) + MediaTypes.register("image.raster.raw", ".nrw", SEARCH) + MediaTypes.register("image.raster.raw", ".orf", SEARCH) + MediaTypes.register("image.raster.raw", ".r3d", SEARCH) + MediaTypes.register("image.raster.raw", ".raf", SEARCH) + MediaTypes.register("image.raster.raw", ".raw", SEARCH) + MediaTypes.register("image.raster.raw", ".rw2", SEARCH) + MediaTypes.register("image.raster.raw", ".srf", SEARCH) + MediaTypes.register("image.raster.raw", ".srf2", SEARCH) + + # Vector Images + MediaTypes.add_name_aliases( + "image.vector", + [ + "Scalable Vector Graphic", + "Scalable Vector", + "Vector Graphic", + "Vector Image", + "Vector", + ], + ) + MediaTypes.register("image.vector", ".eps", SEARCH) + MediaTypes.register("image.vector", ".epsf", SEARCH) + MediaTypes.register("image.vector", ".epsi", SEARCH) + MediaTypes.register("image.vector", ".svg", SEARCH) + MediaTypes.register("image.vector", ".svgz", SEARCH) + + # Animated Images + MediaTypes.add_name_aliases("image.animated", ["Animated Image", "Animated"]) + MediaTypes.register("image.animated", ".gif", SEARCH) + MediaTypes.register("image.animated", ".apng", SEARCH) + MediaTypes.register("image.animated", ".webp", SEARCH) + MediaTypes.register("image.animated", ".jxl", SEARCH) + + # Presentations ------------------------------------------------------------ + MediaTypes.add_name_aliases("presentation", ["Presentation", "Slide Show", "Slides"]) + MediaTypes.register("presentation", ".fodp", SEARCH) + MediaTypes.register("presentation", ".key", SEARCH) + MediaTypes.register("presentation", ".odp", SEARCH) + MediaTypes.register("presentation", ".pot", SEARCH) + MediaTypes.register("presentation", ".potm", SEARCH) + MediaTypes.register("presentation", ".potx", SEARCH) + MediaTypes.register("presentation", ".ppam", SEARCH) + MediaTypes.register("presentation", ".pps", SEARCH) + MediaTypes.register("presentation", ".ppsm", SEARCH) + MediaTypes.register("presentation", ".ppsx", SEARCH) + MediaTypes.register("presentation", ".ppt", SEARCH) + MediaTypes.register("presentation", ".pptm", SEARCH) + MediaTypes.register("presentation", ".pptx", SEARCH) + + # Programs, Installers, & Packages ----------------------------------------- + MediaTypes.add_name_aliases("program", ["App", "Application", "Executable", "Program"]) + MediaTypes.register("program", ".apk", SEARCH) + MediaTypes.register("program", ".apkm", SEARCH) + MediaTypes.register("program", ".apks", SEARCH) + MediaTypes.register("program", ".app", SEARCH) + MediaTypes.register("program", ".appx", SEARCH) + MediaTypes.register("program", ".bin", SEARCH) + MediaTypes.register("program", ".exe", SEARCH) + MediaTypes.register("program", ".msi", SEARCH) + MediaTypes.register("program", ".msix", SEARCH) + MediaTypes.register("program", ".pkg", SEARCH) + MediaTypes.register("program", ".xapk", SEARCH) + + # Rich Text ---------------------------------------------------------------- + MediaTypes.add_name_aliases("rich_text", ["Rich Text", "Rich Text Document"]) + MediaTypes.register("rich_text", ".rtf", SEARCH) + + # Shaders ------------------------------------------------------------------ + MediaTypes.add_name_aliases("shader", "Shader") + MediaTypes.register("shader", ".effect", SEARCH) + MediaTypes.register("shader", ".frag", SEARCH) + MediaTypes.register("shader", ".fsh", SEARCH) + MediaTypes.register("shader", ".glsl", SEARCH) + MediaTypes.register("shader", ".shader", SEARCH) + MediaTypes.register("shader", ".vert", SEARCH) + MediaTypes.register("shader", ".vsh", SEARCH) + + # Shell Script ------------------------------------------------------------- + MediaTypes.add_name_aliases("shell", ["Shell Script", "Shell"]) + MediaTypes.register("shell", ".bat", SEARCH) + MediaTypes.register("shell", ".csh", SEARCH) + MediaTypes.register("shell", ".fish", SEARCH) + MediaTypes.register("shell", ".nu", SEARCH) + MediaTypes.register("shell", ".ps1", SEARCH) + MediaTypes.register("shell", ".sh", SEARCH) + MediaTypes.register("shell", "activate", SEARCH) + + # Shortcuts ---------------------------------------------------------------- + MediaTypes.add_name_aliases("shortcut", "Shortcut") + MediaTypes.register("shortcut", ".desktop", SEARCH) + MediaTypes.register("shortcut", ".lnk", SEARCH) + MediaTypes.register("shortcut", ".url", SEARCH) + + # Spreadsheets ------------------------------------------------------------- + MediaTypes.add_name_aliases("spreadsheet", ["Spreadsheet", "Sheet"]) + MediaTypes.register("spreadsheet", ".csv", SEARCH) + + # Plaintext ---------------------------------------------------------------- + # NOTE: If extensions here can be grouped or moved to more specific categories, do that. + # Something like a "Code" group may be considered, but that may be too subjective. + + MediaTypes.add_name_aliases("plaintext", "Plaintext") + MediaTypes.register("plaintext", ".cfg", SEARCH) + MediaTypes.register("plaintext", ".conf", SEARCH) + MediaTypes.register("plaintext", ".config", SEARCH) + MediaTypes.register("plaintext", ".gitignore", SEARCH) + MediaTypes.register("plaintext", ".i3u", SEARCH) + MediaTypes.register("plaintext", ".lang", SEARCH) + MediaTypes.register("plaintext", ".lock", SEARCH) + MediaTypes.register("plaintext", ".log", SEARCH) + MediaTypes.register("plaintext", ".plist", SEARCH) + MediaTypes.register("plaintext", ".prefs", SEARCH) + MediaTypes.register("plaintext", ".spec", SEARCH) + MediaTypes.register("plaintext", ".theme", SEARCH) + MediaTypes.register("plaintext", ".timestamp", SEARCH) + MediaTypes.register("plaintext", "contributing", SEARCH) + MediaTypes.register("plaintext", "license", SEARCH) + MediaTypes.register("plaintext", "readme", SEARCH) + MediaTypes.register("plaintext", [".editorconfig", ".inf", ".ini"], SEARCH) + MediaTypes.register("plaintext", [".patch", ".diff"], SEARCH) + MediaTypes.register("plaintext", [".txt", ".text"], SEARCH) + MediaTypes.register("plaintext", ["pkginfo", ".pkginfo"], SEARCH) + + # C + MediaTypes.add_name_aliases("plaintext.c", "C") + MediaTypes.register("plaintext.c", ".c", SEARCH) + MediaTypes.register("plaintext.c", ".h", SEARCH) + + # C++ + MediaTypes.add_name_aliases("plaintext.cpp", ["C++", "CPP"]) + MediaTypes.register("plaintext.cpp", ".cpp", SEARCH) + MediaTypes.register("plaintext.cpp", ".h", SEARCH) + MediaTypes.register("plaintext.cpp", ".hpp", SEARCH) + + # C# + MediaTypes.add_name_aliases("plaintext.csharp", ["C#", "C Sharp"]) + MediaTypes.register("plaintext.csharp", ".cs", SEARCH) + + # CSS + MediaTypes.add_name_aliases("plaintext.css", "CSS") + MediaTypes.register("plaintext.css", ".css", SEARCH) + MediaTypes.register("plaintext.css", ".less", SEARCH) + MediaTypes.register("plaintext.css", ".qss", SEARCH) + MediaTypes.register("plaintext.css", ".sass", SEARCH) + MediaTypes.register("plaintext.css", ".scss", SEARCH) + MediaTypes.register("plaintext.css", ".styl", SEARCH) + + # D + MediaTypes.add_name_aliases("plaintext.d", "D") + MediaTypes.register("plaintext.d", ".d", SEARCH) + MediaTypes.register("plaintext.d", ".h", SEARCH) + + # HTML + MediaTypes.add_name_aliases("plaintext.html", "HTML") + MediaTypes.register("plaintext.html", [".dhtml", ".htm", ".html", ".shtml", ".xhtml"], SEARCH) + + # JavaScript + MediaTypes.chain_group("plaintext.javascript", "plaintext.typescript") + MediaTypes.add_name_aliases("plaintext.javascript", ["JavaScript", "JS"]) + MediaTypes.register("plaintext.javascript", ".cjs", SEARCH) + MediaTypes.register("plaintext.javascript", ".js", SEARCH) + MediaTypes.register("plaintext.javascript", ".jsx", SEARCH) + MediaTypes.register("plaintext.javascript", ".mjs", SEARCH) + + # JSON + MediaTypes.add_name_aliases("plaintext.json", "JSON") + MediaTypes.register("plaintext.json", [".json", ".json5", ".jsonc", ".jsonl"], SEARCH) + + # Lua + MediaTypes.add_name_aliases("plaintext.lua", "Lua") + MediaTypes.register("plaintext.lua", ".lua", SEARCH) + + # Markdown + MediaTypes.add_name_aliases("plaintext.markdown", ["Markdown", "MD"]) + MediaTypes.register("plaintext.markdown", [".markdown", ".md", ".mkd", ".rmd"], SEARCH) + + # Nix + MediaTypes.add_name_aliases("plaintext.nix", "Nix") + MediaTypes.register("plaintext.nix", ".nix", SEARCH) + + # PHP + MediaTypes.add_name_aliases("plaintext.php", "PHP") + MediaTypes.register("plaintext.php", ".php", SEARCH) + + # Qt + MediaTypes.add_name_aliases("plaintext.qt", "Qt") + MediaTypes.register("plaintext.qt", ".qml", SEARCH) + MediaTypes.register("plaintext.qt", ".qrc", SEARCH) + + # Rust + MediaTypes.add_name_aliases("plaintext.rust", "Rust") + MediaTypes.register("plaintext.rust", ".rs", SEARCH) + + # Tcl + MediaTypes.add_name_aliases("plaintext.tcl", "Tcl") + MediaTypes.register("plaintext.tcl", ".tcl", SEARCH) + + # TOML + MediaTypes.add_name_aliases("plaintext.toml", "TOML") + MediaTypes.register("plaintext.toml", ".toml", SEARCH) + + # TypeScript + MediaTypes.add_name_aliases("plaintext.typescript", "TypeScript") + MediaTypes.register("plaintext.typescript", ".cts", SEARCH) + MediaTypes.register("plaintext.typescript", ".ts", SEARCH) + MediaTypes.register("plaintext.typescript", ".mts", SEARCH) + MediaTypes.register("plaintext.typescript", ".tsx", SEARCH) + + # XML + MediaTypes.add_name_aliases("plaintext.xml", "XML") + MediaTypes.register("plaintext.xml", [".xml", ".xul"], SEARCH) + + # YAML + MediaTypes.add_name_aliases("plaintext.yaml", "YAML") + MediaTypes.register("plaintext.yaml", [".yaml", ".yml"], SEARCH) + + # Python ------------------------------------------------------------------- + MediaTypes.add_name_aliases("python", "Python") + MediaTypes.register("python", ".ipynb", SEARCH) + MediaTypes.register("python", ".py", SEARCH) + MediaTypes.register("python", ".pyc", SEARCH) + MediaTypes.register("python", ".pyd", SEARCH) + MediaTypes.register("python", ".pyi", SEARCH) + MediaTypes.register("python", ".pyo", SEARCH) + MediaTypes.register("python", ".sip", SEARCH) + + # Typesetting -------------------------------------------------------------- + MediaTypes.add_name_aliases("typesetting", ["Typesetting", "Typesetter"]) + + # TeX/LaTeX + MediaTypes.add_name_aliases("typesetting.latex", ["LaTeX", "TeX"]) + MediaTypes.register("typesetting.latex", ".tex", SEARCH) + + # Typst + MediaTypes.add_name_aliases("typesetting.typst", "Typst") + MediaTypes.register("typesetting.typst", ".typ", SEARCH) + + # Video -------------------------------------------------------------------- + MediaTypes.add_name_aliases("video", "Video") + MediaTypes.register("video", ".3gp", SEARCH) + MediaTypes.register("video", ".avi", SEARCH) + MediaTypes.register("video", ".flv", SEARCH) + MediaTypes.register("video", ".gifv", SEARCH) + MediaTypes.register("video", ".hevc", SEARCH) + MediaTypes.register("video", ".m4v", SEARCH) + MediaTypes.register("video", ".mkv", SEARCH) + MediaTypes.register("video", ".mov", SEARCH) + MediaTypes.register("video", ".mp4", SEARCH) + MediaTypes.register("video", ".webm", SEARCH) + MediaTypes.register("video", ".wmv", SEARCH) diff --git a/src/tagstudio/core/utils/sanitized_attr.py b/src/tagstudio/core/utils/sanitized_attr.py new file mode 100644 index 000000000..67bc6ae3d --- /dev/null +++ b/src/tagstudio/core/utils/sanitized_attr.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +from typing import Any + + +class SanitizedAttr(type): + def __getattr__(cls, name: str) -> Any: # pyright: ignore[reportExplicitAny] + sanitized = name.replace(".", "_") + + if sanitized == name: + raise AttributeError(f"'{type(cls).__name__}' object has no attribute '{name}'") + + return getattr(cls, sanitized) diff --git a/src/tagstudio/core/utils/singleton.py b/src/tagstudio/core/utils/singleton.py index 82b4518da..383632d77 100644 --- a/src/tagstudio/core/utils/singleton.py +++ b/src/tagstudio/core/utils/singleton.py @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT from threading import Lock diff --git a/src/tagstudio/previews/base_preview.py b/src/tagstudio/previews/base_preview.py new file mode 100644 index 000000000..773716e32 --- /dev/null +++ b/src/tagstudio/previews/base_preview.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +# pyright: standard + + +from pathlib import Path + +from PIL.Image import Image + +from tagstudio.core.enums import Theme + +RENDER = "RENDER" # MediaType Context + + +class BasePreview: + """A base preview renderer class. + + Attributes: + _fallback_icon (str): The name of the fallback icon resource to use, if needed. + media_type_name (str): Used for identifying the MediaType. + priority (int): Render priority over other Preview classes. + """ + + _fallback_icon: str = "" + media_type_name: str + priority: int = 50 + + def __init__(self) -> None: + pass + + @classmethod + def register_types(cls) -> None: + pass + + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + raise NotImplementedError + + @classmethod + def icon_name(cls) -> str: + """Get the name of the fallback icon resource associated with this renderer.""" + return cls._fallback_icon or cls.media_type_name diff --git a/src/tagstudio/previews/effects.py b/src/tagstudio/previews/effects.py new file mode 100644 index 000000000..93b094f02 --- /dev/null +++ b/src/tagstudio/previews/effects.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +from PIL.Image import Image +from PIL.Image import new as new_image + +from tagstudio.core.enums import Theme +from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color + + +# TODO: Split out Qt color palette stuff from anything needed by the core. +def apply_overlay_color(image: Image, color: UiColor, theme: Theme) -> Image: + """Apply a color overlay effect to an image based on its color channel data. + + Red channel for foreground, green channel for outline, none for background. + + Args: + image (Image.Image): The image to apply an overlay to. + color (UiColor): The name of the ColorType color to use. + theme (Theme): A theme enum to determine the light/dark theme. + """ + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if theme == Theme.DARK + else get_ui_color(ColorType.PRIMARY, color) + ) + fg_color: str = ( + get_ui_color(ColorType.PRIMARY, color) + if theme == Theme.DARK + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + ol_color: str = ( + get_ui_color(ColorType.BORDER, color) + if theme == Theme.DARK + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + + bg: Image = new_image(image.mode, image.size, color=bg_color) + fg: Image = new_image(image.mode, image.size, color=fg_color) + ol: Image = new_image(image.mode, image.size, color=ol_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(ol, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py index 07df276b6..73086bb9a 100644 --- a/src/tagstudio/previews/file_renderer.py +++ b/src/tagstudio/previews/file_renderer.py @@ -1,57 +1,37 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT import contextlib import hashlib +import importlib +import inspect import math +import pkgutil from copy import deepcopy from pathlib import Path import structlog from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, UnidentifiedImageError -from PIL.Image import DecompressionBombError -from tagstudio.core.exceptions import NoRendererError +from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.ignore import Ignore -from tagstudio.core.media_types import MediaCategories, MediaType +from tagstudio.core.media_types import MediaTypeGroup, MediaTypes, slugify +from tagstudio.core.query_lang.file_groups import SEARCH from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.effects import apply_overlay_color from tagstudio.previews.gradients import four_corner_gradient -from tagstudio.previews.renderers.archive import ( - apple_embedded_thumb, - archive_thumb, - krita_thumb, - open_doc_thumb, - powerpoint_thumb, -) -from tagstudio.previews.renderers.audio import audio_album_thumb, audio_waveform_thumb -from tagstudio.previews.renderers.blender import blender_thumb -from tagstudio.previews.renderers.clip_studio import clip_studio_thumb -from tagstudio.previews.renderers.ebook import epub_thumb -from tagstudio.previews.renderers.font import font_full_preview, font_small_thumb -from tagstudio.previews.renderers.medibang_paint import medibang_paint_thumb -from tagstudio.previews.renderers.paint_dot_net import paint_dot_net_thumb -from tagstudio.previews.renderers.pdf import pdf_thumb -from tagstudio.previews.renderers.raster_image import ( - exr_image_thumb, - raster_image_thumb, - raw_image_thumb, -) -from tagstudio.previews.renderers.source_engine import vtf_thumb -from tagstudio.previews.renderers.text import text_thumb -from tagstudio.previews.renderers.vector_image import vector_image_thumb -from tagstudio.previews.renderers.video import video_thumb from tagstudio.qt.app_settings import ( DEFAULT_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES, MIN_CACHED_THUMB_RES, AppSettings, - Theme, ) from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.resource_manager import ResourceManager -from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor, get_ui_color +from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor ImageFile.LOAD_TRUNCATED_IMAGES = True Image.MAX_IMAGE_PIXELS = None @@ -60,11 +40,55 @@ logger = structlog.get_logger(__name__) +# TODO: Allow user-created preview renderers from an external directory. +def _get_preview_renderers() -> list[type[BasePreview]]: + """Discover all BasePreview subclasses in src/tagstudio/previews/renderers. + + Classes are sorted by their priority (descending), falling back to alphabetical order. + """ + found: list[type[BasePreview]] = [] + from tagstudio.previews import renderers # pyright: ignore + + for module_info in sorted(pkgutil.iter_modules(renderers.__path__), key=lambda m: m.name): + module = importlib.import_module(f"{renderers.__name__}.{module_info.name}") + for _, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, BasePreview) + and obj is not BasePreview + and obj.__module__ == module.__name__ + ): + obj.register_types() + found.append(obj) + break + + found.sort(key=lambda cls: cls.priority, reverse=True) + return found + + class FileRenderer: """A class for rendering image previews and thumbnails from files.""" - rm: ResourceManager = ResourceManager() - cached_img_ext: str = ".webp" + _rm: ResourceManager = ResourceManager() + _cached_img_ext: str = ".webp" + _preview_renderers: list[type[BasePreview]] = _get_preview_renderers() + + # Map of media group keys to preview renderer priorities. + _media_group_priorities: dict[str, int] = {} + for pr in _preview_renderers: + _media_group_priorities[pr.media_type_name] = pr.priority + + # Map of media group name keys to preferred icons declared in preview renderers. + _media_group_icons: dict[str, str] = {} + for pr in _preview_renderers: + _media_group_icons[pr.media_type_name] = pr.icon_name() + + for pr in _preview_renderers: + logger.info( + "[FileRenderer] Loaded Preview Renderer", + name=pr.__name__, + media_type=pr.media_type_name, + priority=pr.priority, + ) def __init__(self, library: Library, settings: AppSettings) -> None: super().__init__() @@ -89,26 +113,18 @@ def _get_resource_id(self, url: Path) -> str: url (Path): The file url to assess. "$LOADING" will return the loading graphic. """ ext = url.suffix.lower() - types: set[MediaType] = MediaCategories.get_types(ext, mime_fallback=True) - - # Manual icon overrides. - if ext in {".gif", ".vtf"}: - return MediaType.IMAGE - elif ext in {".dll", ".pyc", ".o", ".dylib"}: - return MediaType.PROGRAM - elif ext in {".mscz"}: # noqa: SIM114 - return MediaType.TEXT - - # Loop though the specific (non-IANA) categories and return the string - # name of the first matching category found. - for cat in MediaCategories.ALL_CATEGORIES: - if not cat.is_iana and cat.media_type in types: - return cat.media_type.value - - # If the type is broader (IANA registered) then search those types. - for cat in MediaCategories.ALL_CATEGORIES: - if cat.is_iana and cat.media_type in types: - return cat.media_type.value + groups = MediaTypes.find(ext, SEARCH) # Fallback icons use the SEARCH context + groups.sort( # Sort by priority and most specific dot-separated subgroup. + key=lambda g: ( + g.key.count("."), + self._media_group_priorities.get(g.key, BasePreview.priority), + ), + reverse=True, + ) + for group in groups: + slug = slugify(self._media_group_icons.get(group.key, group.key)) + if self._rm.get(slug, silent_fail=True): + return slug return "file_generic" @@ -354,10 +370,10 @@ def _render_center_icon( fg: Image.Image = Image.new("RGB", size=size, color="#00FF00") # Get icon by name - icon = self.rm.get(name) + icon = self._rm.get(name) assert isinstance(icon, Image.Image) or icon is None if not icon: - icon = self.rm.file_generic + icon = self._rm.file_generic # Resize icon to fit icon_ratio icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) @@ -373,7 +389,7 @@ def _render_center_icon( ) # Apply color overlay - im = self._apply_overlay_color(im, color, theme) + im = apply_overlay_color(im, color, theme) return im @@ -420,7 +436,7 @@ def _render_corner_icon( color="#000000", ) # Apply color overlay - bg = self._apply_overlay_color(im, color, theme) + bg = apply_overlay_color(im, color, theme) # Paste background color with rounded rectangle mask onto blank image im.paste( @@ -440,10 +456,10 @@ def _render_corner_icon( fg: Image.Image = Image.new("RGB", size=size, color=primary_color) # Get icon by name - icon = self.rm.get(name) + icon = self._rm.get(name) assert isinstance(icon, Image.Image) if not icon: - icon = self.rm.file_generic + icon = self._rm.file_generic # Resize icon to fit icon_ratio icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) @@ -457,48 +473,6 @@ def _render_corner_icon( return im - def _apply_overlay_color(self, image: Image.Image, color: UiColor, theme: Theme) -> Image.Image: - """Apply a color overlay effect to an image based on its color channel data. - - Red channel for foreground, green channel for outline, none for background. - - Args: - image (Image.Image): The image to apply an overlay to. - color (UiColor): The name of the ColorType color to use. - theme (Theme): A theme enum to determine the light/dark theme. - """ - bg_color: str = ( - get_ui_color(ColorType.DARK_ACCENT, color) - if theme == Theme.DARK - else get_ui_color(ColorType.PRIMARY, color) - ) - fg_color: str = ( - get_ui_color(ColorType.PRIMARY, color) - if theme == Theme.DARK - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - ol_color: str = ( - get_ui_color(ColorType.BORDER, color) - if theme == Theme.DARK - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - - bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) - fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) - ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) - - bg.paste(fg, (0, 0), mask=image.getchannel(0)) - bg.paste(ol, (0, 0), mask=image.getchannel(1)) - - if image.mode == "RGBA": - alpha_bg: Image.Image = bg.copy() - alpha_bg.convert("RGBA") - alpha_bg.putalpha(0) - alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) - bg = alpha_bg - - return bg - # NOTE: This method will be replaced with frontend specific decorations (Qt painting) def _apply_edge( self, @@ -596,7 +570,7 @@ def render_ignored(size: tuple[int, int], im: Image.Image) -> Image.Image: padding_factor = 18 im_ = im - icon: Image.Image = self.rm.ignored + icon: Image.Image = self._rm.ignored icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) im_.paste( im=icon.resize( @@ -634,7 +608,7 @@ def fetch_cached_image(file_name: Path): mod_time = str(filepath.stat().st_mtime_ns) hashable_str: str = f"{str(filepath)}{mod_time}" hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8) - file_name = Path(f"{hash_value}{FileRenderer.cached_img_ext}") + file_name = Path(f"{hash_value}{FileRenderer._cached_img_ext}") image = fetch_cached_image(file_name) if not image and self.settings.generate_thumbs: @@ -654,7 +628,7 @@ def fetch_cached_image(file_name: Path): size=(thumb_res, thumb_res), dpi_scale=1, theme=theme, - is_thumb=is_thumb, + is_small=is_thumb, cache_filename=file_name, ) @@ -735,159 +709,56 @@ def _render( size: tuple[int, int], dpi_scale: float, theme: Theme = Theme.DARK, - is_thumb: bool = False, + is_small: bool = False, cache_filename: Path | None = None, ) -> Image.Image | None: """Render a thumbnail or preview image. Args: cache (CacheManager | None): A cache manager instance. - timestamp (float): The timestamp for which this job was dispatched. filepath (str | Path): The path of the file to render a thumbnail for. size (tuple[int, int]): The unmodified base size of the thumbnail. dpi_scale (float): The screen pixel ratio. theme (Theme): A theme enum to determine the light/dark theme. - is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants. + is_small (bool): Is this specifically a thumbnail? Use for specifying small variants. cache_filename (Path | None): An optional filename to use to save to the cache. """ + filepath = Path(filepath) if isinstance(filepath, str) else filepath scaled_size = math.ceil(max(size[0], size[1]) * dpi_scale) image: Image.Image | None = None - filepath_: Path = Path(filepath) is_savable_type: bool = True - if filepath_ and filepath_.is_file(): + if filepath and filepath.is_file(): try: - ext: str = filepath_.suffix.lower() if filepath_.suffix else filepath_.stem.lower() - # eBooks =========================================================================== - if MediaCategories.is_ext_in_category( - ext, MediaCategories.EBOOK_TYPES, mime_fallback=True - ): - image = epub_thumb(filepath_, ext) - # Krita ============================================================================ - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.KRITA_TYPES, mime_fallback=True - ): - image = krita_thumb(filepath_) - # Clip Studio Paint ================================================================ - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES - ): - image = clip_studio_thumb(filepath_) - # VTF ============================================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True - ): - image = vtf_thumb(filepath_) - # Images =========================================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_TYPES, mime_fallback=True - ): - # Raw Images ------------------------------------------------------------------- - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ): - image = raw_image_thumb(filepath_) - # Vector Images ---------------------------------------------------------------- - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True - ): - image = vector_image_thumb(filepath_, scaled_size) - # EXR Images ------------------------------------------------------------------- - elif ext in [".exr"]: - image = exr_image_thumb(filepath_) - # Normal Images ---------------------------------------------------------------- - else: - image = raster_image_thumb(filepath_) - # Videos =========================================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ): - image = video_thumb(filepath_) - # PowerPoint ======================================================================= - elif ext in {".pptx"}: - image = powerpoint_thumb(filepath_) - # OpenDocument/OpenOffice ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True - ): - image = open_doc_thumb(filepath_) - # Apple iWork + Creator Studio ===================================================== - elif ( - MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES) - or ext == ".pxd" - ): - image = apple_embedded_thumb(filepath_) - # Plain Text ======================================================================= - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True - ): - image = text_thumb(filepath_) - # Fonts ============================================================================ - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): - if is_thumb: - # Short (Aa) Preview - image = font_small_thumb(filepath_, scaled_size) - if image is not None: - image = self._apply_overlay_color(image, UiColor.BLUE, theme) - else: - # Large (Full Alphabet) Preview - image = font_full_preview(filepath_, scaled_size) - # Audio ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - ): - image = audio_album_thumb(filepath_, ext) - if image is None: - image = audio_waveform_thumb(filepath_, ext, scaled_size, dpi_scale) - is_savable_type = False - if image is not None: - image = self._apply_overlay_color(image, UiColor.GREEN, theme) - # Blender ====================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.BLENDER_TYPES, mime_fallback=True - ): - image = blender_thumb(filepath_) - # PDF ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PDF_TYPES, mime_fallback=True - ): - image = pdf_thumb(filepath_, scaled_size, ext) - # Archives ===================================================== - elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES): - image = archive_thumb(filepath_, ext=ext) - # MDIPACK ====================================================== - elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES): - image = medibang_paint_thumb(filepath_) - # Paint.NET ==================================================== - elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES): - image = paint_dot_net_thumb(filepath_) - # No Rendered Thumbnail ======================================== - if not image: - raise NoRendererError + ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower() + for preview in FileRenderer._preview_renderers: + media_type: MediaTypeGroup | None = getattr( + MediaTypes, preview.media_type_name, None + ) + if media_type is None: + logger.error( + f"[FileRenderer] " + f"Attribute '{preview.media_type_name}' not registered with MediaTypes", + ) + break + + if media_type.contains(ext, RENDER): + image = preview.render( + filepath=filepath, + is_small=is_small, + theme=theme, + size=(scaled_size, scaled_size), + dpi_scale=dpi_scale, + ) + break if image: image = self._resize_image(image, (scaled_size, scaled_size)) - if cache_filename and is_savable_type and image and cache: cache.save_image(image, cache_filename, mode="RGBA") - - except ( - AssertionError, - ChildProcessError, - DecompressionBombError, - UnidentifiedImageError, - ValueError, - ) as e: - logger.error( - "[FileRenderer] Couldn't render thumbnail", - filepath=filepath, - error=type(e).__name__, - ) - image = None - except NoRendererError: + except Exception as e: + logger.error("[FileRenderer] Couldn't render thumbnail", filepath=filepath, error=e) image = None return image diff --git a/src/tagstudio/previews/renderers/apple_embedded.py b/src/tagstudio/previews/renderers/apple_embedded.py new file mode 100644 index 000000000..616afd07e --- /dev/null +++ b/src/tagstudio/previews/renderers/apple_embedded.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class AppleEmbeddedPreview(BasePreview): + media_type_name = "apple.embedded" + + image_names: list[str] = [ + "preview.jpg", + "QuickLook/Preview.heic", + "QuickLook/Thumbnail.jpg", + "QuickLook/Thumbnail.heic", + "QuickLook/Thumbnail.webp", + "QuickLook/Icon.webp", + ] + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("apple.embedded", ".pxd", RENDER) + MediaTypes.register("apple.embedded", ".pages", RENDER) + MediaTypes.register("apple.embedded", ".numbers", RENDER) + MediaTypes.register("apple.embedded", ".key", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return cls.apple_embedded_thumb(filepath) + + @classmethod + def apple_embedded_thumb(cls, filepath: Path) -> Image | None: + """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio).""" + return archive_thumb(filepath, cls.image_names) diff --git a/src/tagstudio/previews/renderers/archive.py b/src/tagstudio/previews/renderers/archive.py index 418d1e38b..3d1cbf0f2 100644 --- a/src/tagstudio/previews/renderers/archive.py +++ b/src/tagstudio/previews/renderers/archive.py @@ -6,16 +6,18 @@ import zipfile from io import BytesIO from pathlib import Path -from typing import Literal +from typing import Literal, override import py7zr import py7zr.io import rarfile import structlog -from PIL import Image +from PIL.Image import Image -from tagstudio.core.media_types import MediaCategories +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import RENDER, BasePreview from tagstudio.previews.renderers.raster_image import image_from_bytes logger = structlog.get_logger(__name__) @@ -23,6 +25,34 @@ type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile +class ArchivePreview(BasePreview): + media_type_name = "archive" + + @override + @classmethod + def register_types(cls) -> None: + # NOTE: Filetype equivalents (i.e. ".tar.gz" == ".tgz") are already declared internally. + MediaTypes.register("archive", ".7z", RENDER) + MediaTypes.register("archive", ".gz", RENDER) + MediaTypes.register("archive", ".rar", RENDER) + MediaTypes.register("archive", ".s7z", RENDER) + MediaTypes.register("archive", ".tar", RENDER) + MediaTypes.register("archive", ".zip", RENDER) + MediaTypes.register("archive", ".tar.gz", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return archive_thumb(filepath) + + class SevenZipFile(py7zr.SevenZipFile): """Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API.""" @@ -60,7 +90,7 @@ def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType self.tar.__exit__(*args) -def open_archive(filepath: Path, ext: str = "") -> Archive: +def open_archive(filepath: Path) -> Archive: """Open an archive with its corresponding archiver. Args: @@ -70,6 +100,7 @@ def open_archive(filepath: Path, ext: str = "") -> Archive: Returns: Archive: The opened archive. """ + ext = filepath.suffix.lower() archiver: type[Archive] = zipfile.ZipFile if ext in {".7z", ".cb7", ".s7z"}: archiver = SevenZipFile @@ -80,7 +111,7 @@ def open_archive(filepath: Path, ext: str = "") -> Archive: return archiver(filepath, "r") -def first_image_in_archive(archive: Archive) -> Image.Image | None: +def first_image_in_archive(archive: Archive) -> Image | None: """Find and extract the first renderable image in the archive. Args: @@ -91,7 +122,7 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None: """ for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType] ext = Path(file_name).suffix - if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): + if MediaTypes.image_raster.contains(ext, RENDER): image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType] return image_from_bytes(BytesIO(image_data)) @@ -101,8 +132,7 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None: def archive_thumb( filepath: Path, image_names: list[Path] | list[str] | None = None, - ext: str = "", -) -> Image.Image | None: +) -> Image | None: """Extract an embedded preview image from an archive. Args: @@ -114,7 +144,7 @@ def archive_thumb( Image: The first image found in the archive. """ try: - with open_archive(filepath, ext) as archive: + with open_archive(filepath) as archive: # If no list of image names to search for was provided, default to the first image. if not image_names: return first_image_in_archive(archive) @@ -131,34 +161,3 @@ def archive_thumb( except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return None - - -def apple_embedded_thumb(filepath: Path) -> Image.Image | None: - """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio).""" - image_names: list[str] = [ - "preview.jpg", - "QuickLook/Preview.heic", - "QuickLook/Thumbnail.jpg", - "QuickLook/Thumbnail.heic", - "QuickLook/Thumbnail.webp", - "QuickLook/Icon.webp", - ] - return archive_thumb(filepath, image_names) - - -def krita_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Krita file.""" - image_names = ["preview.png"] - return archive_thumb(filepath, image_names) - - -def open_doc_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an OpenDocument file.""" - image_names = ["Thumbnails/thumbnail.png"] - return archive_thumb(filepath, image_names) - - -def powerpoint_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Microsoft PowerPoint file.""" - image_names = ["docProps/thumbnail.jpeg"] - return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py index 6b981a7ee..fefb768e9 100644 --- a/src/tagstudio/previews/renderers/audio.py +++ b/src/tagstudio/previews/renderers/audio.py @@ -1,149 +1,196 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT import math from io import BytesIO from pathlib import Path +from typing import override from warnings import catch_warnings import numpy as np import structlog from mutagen import flac, id3, mp4 from mutagen._util import MutagenError -from PIL import Image, ImageDraw - +from PIL import ImageDraw +from PIL.Image import Image, Resampling +from PIL.Image import new as new_image +from PIL.Image import open as open_image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.effects import apply_overlay_color from tagstudio.previews.vendored.pydub.audio_segment import ( _AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage] ) +from tagstudio.qt.views.styles.palette import UiColor logger = structlog.get_logger(__name__) -def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: - """Return an album cover thumb from an audio file if a cover is present. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - """ - image: Image.Image | None = None - try: - if not filepath.is_file(): - raise FileNotFoundError - - artwork = None - if ext in [".mp3"]: - id3_tags: id3.ID3 = id3.ID3(filepath) - id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType] - if id3_covers: - artwork = Image.open(BytesIO(id3_covers[0].data)) - elif ext in [".flac"]: - flac_tags: flac.FLAC = flac.FLAC(filepath) - flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType] - if flac_covers: - artwork = Image.open(BytesIO(flac_covers[0].data)) - elif ext in [".mp4", ".m4a", ".aac"]: - mp4_tags: mp4.MP4 = mp4.MP4(filepath) - mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType] - if mp4_covers: - artwork = Image.open(BytesIO(mp4_covers[0])) - if artwork: - image = artwork - except ( - FileNotFoundError, - id3.ID3NoHeaderError, - mp4.MP4MetadataError, - mp4.MP4StreamInfoError, - MutagenError, - ) as e: - logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) - return image - - -def audio_waveform_thumb( - filepath: Path, ext: str, size: int, pixel_ratio: float -) -> Image.Image | None: - """Render a waveform image from an audio file. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - size (tuple[int,int]): The size of the thumbnail. - pixel_ratio (float): The screen pixel ratio. - """ - # BASE_SCALE used for drawing on a larger image and resampling down - # to provide an antialiased effect. - base_scale: int = 2 - samples_per_bar: int = 3 - size_scaled: int = size * base_scale - allow_small_min: bool = False - im: Image.Image | None = None - - try: - bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) - audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType] - data = np.frombuffer(buffer=audio._data, dtype=np.int16) - data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) - bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 - line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale - bar_height: float = (size_scaled) - (size_scaled // bar_margin) - - count: int = 0 - maximum_item: int = 0 - max_array: list[int] = [] - highest_line: int = 0 - - for i in range(-1, len(data_indices)): - d = data[math.ceil(data_indices[i]) - 1] - if count < samples_per_bar: - count = count + 1 - with catch_warnings(record=True): - if abs(d) > maximum_item: - maximum_item = int(abs(d)) - else: - max_array.append(maximum_item) - - if maximum_item > highest_line: - highest_line = maximum_item - - maximum_item = 0 - count = 1 - - line_ratio = max(highest_line / bar_height, 1) - - im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") - draw = ImageDraw.Draw(im) - - current_x = bar_margin - for item in max_array: - item_height = item / line_ratio - - # If small minimums are not allowed, raise all values - # smaller than the line width to the same value. - if not allow_small_min: - item_height = max(item_height, line_width) - - current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 - - draw.rounded_rectangle( - ( - current_x, - current_y, - (current_x + line_width), - (current_y + item_height), - ), - radius=100 * base_scale, - fill=("#FF0000"), - outline=("#FFFF00"), - width=max(math.ceil(line_width / 6), base_scale), - ) - - current_x = current_x + line_width + bar_margin - - im.resize((size, size), Image.Resampling.BILINEAR) - - except Exception as e: - logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) - - return im +class AudioPreview(BasePreview): + media_type_name = "audio" + priority = 70 + + @override + @classmethod + def register_types(cls) -> None: + # NOTE: Filetype equivalents (i.e. ".aif" == ".aif") are already declared internally. + MediaTypes.register("audio", ".aac", RENDER) + MediaTypes.register("audio", ".aif", RENDER) + MediaTypes.register("audio", ".aifc", RENDER) + MediaTypes.register("audio", ".caf", RENDER) + MediaTypes.register("audio", ".flac", RENDER) + MediaTypes.register("audio", ".m4a", RENDER) + MediaTypes.register("audio", ".m4p", RENDER) + MediaTypes.register("audio", ".m4r", RENDER) + MediaTypes.register("audio", ".mp3", RENDER) + MediaTypes.register("audio", ".ogg", RENDER) + MediaTypes.register("audio", ".wav", RENDER) + MediaTypes.register("audio", ".wma", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + + return cls.audio_album_thumb(filepath) or cls.audio_waveform_thumb( + filepath, theme, size, dpi_scale + ) + + @staticmethod + def audio_album_thumb(filepath: Path) -> Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + filepath (Path): The path of the file. + """ + image: Image | None = None + ext = filepath.suffix.lower() + try: + if not filepath.is_file(): + raise FileNotFoundError + + artwork = None + if ext in {".mp3", ".aif", ".aiff"}: + id3_tags: id3.ID3 = id3.ID3(filepath) + id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType] + if id3_covers: + artwork = open_image(BytesIO(id3_covers[0].data)) + elif ext in {".flac"}: + flac_tags: flac.FLAC = flac.FLAC(filepath) + flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType] + if flac_covers: + artwork = open_image(BytesIO(flac_covers[0].data)) + elif ext in {".mp4", ".m4a", ".aac", ".alac"}: + mp4_tags: mp4.MP4 = mp4.MP4(filepath) + mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType] + if mp4_covers: + artwork = open_image(BytesIO(mp4_covers[0])) + if artwork: + image = artwork + except ( + FileNotFoundError, + id3.ID3NoHeaderError, + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + MutagenError, + ) as e: + logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) + return image + + @staticmethod + def audio_waveform_thumb( + filepath: Path, theme: Theme, size: tuple[int, int], dpi_scale: float + ) -> Image | None: + """Render a waveform image from an audio file. + + Args: + filepath (Path): The path of the file. + theme (Theme): The system color theme. + size (int): The size of the thumbnail. + dpi_scale (float): The screen pixel ratio. + """ + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + base_scale: int = 2 + samples_per_bar: int = 3 + size_scaled: int = size[0] * base_scale # TODO: Allow for non-square sizes + allow_small_min: bool = False + im: Image | None = None + + try: + bar_count: int = min(math.floor((size[0] // dpi_scale) / 5), 64) + audio = AudioSegment.from_file(filepath, filepath.suffix.lower()[1:]) # pyright: ignore[reportUnknownVariableType] + data = np.frombuffer(buffer=audio._data, dtype=np.int16) + data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) + bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 + line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale + bar_height: float = (size_scaled) - (size_scaled // bar_margin) + + count: int = 0 + maximum_item: int = 0 + max_array: list[int] = [] + highest_line: int = 0 + + for i in range(-1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < samples_per_bar: + count = count + 1 + with catch_warnings(record=True): + if abs(d) > maximum_item: + maximum_item = int(abs(d)) + else: + max_array.append(maximum_item) + + if maximum_item > highest_line: + highest_line = maximum_item + + maximum_item = 0 + count = 1 + + line_ratio = max(highest_line / bar_height, 1) + + im = new_image("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(im) + + current_x = bar_margin + for item in max_array: + item_height = item / line_ratio + + # If small minimums are not allowed, raise all values + # smaller than the line width to the same value. + if not allow_small_min: + item_height = max(item_height, line_width) + + current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 + + draw.rounded_rectangle( + ( + current_x, + current_y, + (current_x + line_width), + (current_y + item_height), + ), + radius=100 * base_scale, + fill=("#FF0000"), + outline=("#FFFF00"), + width=max(math.ceil(line_width / 6), base_scale), + ) + + current_x = current_x + line_width + bar_margin + + im.resize(size, Resampling.BILINEAR) + im = apply_overlay_color(im, UiColor.GREEN, theme) + + except Exception as e: + logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) + + return im diff --git a/src/tagstudio/previews/renderers/blender.py b/src/tagstudio/previews/renderers/blender.py index 8b0e4074a..b85d497d9 100644 --- a/src/tagstudio/previews/renderers/blender.py +++ b/src/tagstudio/previews/renderers/blender.py @@ -1,34 +1,58 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT from pathlib import Path +from typing import override import structlog -from PIL import Image -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication +from PIL.Image import Image +from PIL.Image import new as new_image +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview from tagstudio.previews.vendored.blender_thumbnailer import blend_thumb logger = structlog.get_logger(__name__) -def blender_thumb(filepath: Path) -> Image.Image | None: +class BlenderPreview(BasePreview): + media_type_name = "blender" + priority = 40 + + @override + @classmethod + def register_types(cls) -> None: + # NOTE: Filetype equivalents (i.e. ".blend1" == ".blend32") are already declared internally. + MediaTypes.register("blender", ".blend", RENDER) + MediaTypes.register("blender", ".blend1", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return _blender_thumb(filepath, theme) + + +def _blender_thumb(filepath: Path, theme: Theme) -> Image | None: """Get an emended thumbnail from a Blender file, if a thumbnail is present. Args: filepath (Path): The path of the file. + theme (Theme): The system color theme. """ - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - im: Image.Image | None = None + bg_color: str = "#1e1e1e" if theme == Theme.DARK else "#FFFFFF" + im: Image | None = None try: if (blend_image := blend_thumb(str(filepath))) is not None: - bg = Image.new("RGB", blend_image.size, color=bg_color) + bg = new_image("RGB", blend_image.size, color=bg_color) bg.paste(blend_image, mask=blend_image.getchannel(3)) im = bg else: diff --git a/src/tagstudio/previews/renderers/clip_studio.py b/src/tagstudio/previews/renderers/clip_studio.py index ec031f5ca..e918aa132 100644 --- a/src/tagstudio/previews/renderers/clip_studio.py +++ b/src/tagstudio/previews/renderers/clip_studio.py @@ -5,14 +5,41 @@ import sqlite3 from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image +from PIL.Image import open as open_image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview logger = structlog.get_logger(__name__) -def clip_studio_thumb(filepath: Path) -> Image.Image | None: +class ClipStudioPaintPreview(BasePreview): + media_type_name = "clip_studio_paint" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("clip_studio_paint", ".clip", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return clip_studio_thumb(filepath) + + +def clip_studio_thumb(filepath: Path) -> Image | None: """Extract the thumbnail from the SQLite database embedded in a .clip file. Args: @@ -21,7 +48,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None: Returns: Image: The embedded thumbnail, if extractable. """ - im: Image.Image | None = None + im: Image | None = None try: with open(filepath, "rb") as f: blob = f.read() @@ -33,7 +60,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None: conn.deserialize(blob[sqlite_index:]) thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone() if thumbnail: - im = Image.open(BytesIO(thumbnail[0])) + im = open_image(BytesIO(thumbnail[0])) conn.close() except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) diff --git a/src/tagstudio/previews/renderers/code.py b/src/tagstudio/previews/renderers/code.py new file mode 100644 index 000000000..1afd308fe --- /dev/null +++ b/src/tagstudio/previews/renderers/code.py @@ -0,0 +1,240 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image +from pygments.style import Style +from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Literal, + Name, + Number, + Operator, + Other, + Punctuation, + String, + Text, + Whitespace, +) + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.renderers.text import text_thumb + +logger = structlog.get_logger(__name__) + + +class CodePreview(BasePreview): + media_type_name = "code" + priority = 60 + + @override + @classmethod + def register_types(cls) -> None: + # NOTE: Filetype equivalents (i.e. ".ini" == ".inf") are already declared internally. + # CSS + MediaTypes.register("code", ".css", RENDER) + MediaTypes.register("code", ".less", RENDER) + MediaTypes.register("code", ".qss", RENDER) + MediaTypes.register("code", ".sass", RENDER) + MediaTypes.register("code", ".scss", RENDER) + MediaTypes.register("code", ".styl", RENDER) + + # C + MediaTypes.register("code", ".c", RENDER) + MediaTypes.register("code", ".h", RENDER) + + # C++ + MediaTypes.register("code", ".cpp", RENDER) + MediaTypes.register("code", ".hpp", RENDER) + + # C# + MediaTypes.register("code", ".cs", RENDER) + + # D + MediaTypes.register("code", ".d", RENDER) + + # HTML + MediaTypes.register("code", ".html", RENDER) + + # JavaScript + MediaTypes.register("code", ".cjs", RENDER) + MediaTypes.register("code", ".js", RENDER) + MediaTypes.register("code", ".jsx", RENDER) + MediaTypes.register("code", ".mjs", RENDER) + + # JSON + MediaTypes.register("code", ".json", RENDER) + + # Lua + MediaTypes.register("code", ".lua", RENDER) + + # Markdown + MediaTypes.register("code", ".md", RENDER) + + # Nix + MediaTypes.register("code", ".nix", RENDER) + + # PHP + MediaTypes.register("code", ".php", RENDER) + + # Qt + MediaTypes.register("code", ".qml", RENDER) + MediaTypes.register("code", ".qrc", RENDER) + + # Rust + MediaTypes.register("code", ".rs", RENDER) + + # TCL + MediaTypes.register("code", ".tcl", RENDER) + + # Python + MediaTypes.register("code", ".ipynb", RENDER) + MediaTypes.register("code", ".py", RENDER) + MediaTypes.register("code", ".pyi", RENDER) + MediaTypes.register("code", ".sip", RENDER) + + # Shaders + MediaTypes.register("code", ".effect", RENDER) + MediaTypes.register("code", ".frag", RENDER) + MediaTypes.register("code", ".fsh", RENDER) + MediaTypes.register("code", ".glsl", RENDER) + MediaTypes.register("code", ".shader", RENDER) + MediaTypes.register("code", ".vert", RENDER) + MediaTypes.register("code", ".vsh", RENDER) + + # Shell Script + MediaTypes.register("code", ".bat", RENDER) + MediaTypes.register("code", ".csh", RENDER) + MediaTypes.register("code", ".fish", RENDER) + MediaTypes.register("code", ".nu", RENDER) + MediaTypes.register("code", ".ps1", RENDER) + MediaTypes.register("code", ".sh", RENDER) + MediaTypes.register("code", "activate", RENDER) + + # Shortcuts + MediaTypes.register("code", ".desktop", RENDER) + MediaTypes.register("code", ".lnk", RENDER) + MediaTypes.register("code", ".url", RENDER) + + # TOML + MediaTypes.register("code", ".ini", RENDER) + MediaTypes.register("code", ".toml", RENDER) + + # TypeScript + MediaTypes.register("code", ".cts", RENDER) + MediaTypes.register("code", ".ts", RENDER) + MediaTypes.register("code", ".mts", RENDER) + MediaTypes.register("code", ".tsx", RENDER) + + # Valve Source Engine + MediaTypes.register("code", ".fgd", RENDER) + MediaTypes.register("code", ".gi", RENDER) + MediaTypes.register("code", ".kv3", RENDER) + MediaTypes.register("code", ".nut", RENDER) + MediaTypes.register("code", ".vcfg", RENDER) + MediaTypes.register("code", ".vdf", RENDER) + MediaTypes.register("code", ".vqlayout", RENDER) + MediaTypes.register("code", ".vsc", RENDER) + MediaTypes.register("code", ".vsnd_template", RENDER) + + # XML + MediaTypes.register("code", ".xml", RENDER) + + # YAML + MediaTypes.register("code", ".yaml", RENDER) + + # Misc + MediaTypes.register("code", ".cfg", RENDER) + MediaTypes.register("code", ".conf", RENDER) + MediaTypes.register("code", ".config", RENDER) + MediaTypes.register("code", ".csv", RENDER) + MediaTypes.register("code", ".gitignore", RENDER) + MediaTypes.register("code", ".lock", RENDER) + MediaTypes.register("code", ".log", RENDER) + MediaTypes.register("code", ".meta", RENDER) + MediaTypes.register("code", ".patch", RENDER) + MediaTypes.register("code", ".pkginfo", RENDER) + MediaTypes.register("code", ".plist", RENDER) + MediaTypes.register("code", ".prefs", RENDER) + MediaTypes.register("code", ".spec", RENDER) + MediaTypes.register("code", ".tex", RENDER) + MediaTypes.register("code", ".theme", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return text_thumb(filepath, size, CodeStyle) + + +class CodeStyle(Style): + # TODO: Use different syntax highlighting for different filetypes. + background = "#111111" + foreground = "#f8f8f2" + selection = "#44475a" + comment = "#6272a4" + cyan = "#8be9fd" + green = "#50fa7b" + orange = "#ffb86c" + pink = "#ff79c6" + purple = "#bd93f9" + red = "#ff5555" + yellow = "#f1fa8c" + deletion = "#8b080b" + + background_color = background + highlight_color = selection + line_number_color = yellow + line_number_background_color = selection + line_number_special_color = green + line_number_special_background_color = comment + + styles = { + Whitespace: foreground, + Comment: comment, + Comment.Preproc: pink, + Generic: foreground, + Generic.Deleted: deletion, + Generic.Emph: "underline", + Generic.Heading: "bold", + Generic.Inserted: "bold", + Generic.Output: selection, + Generic.EmphStrong: "underline", + Generic.Subheading: "bold", + Error: foreground, + Keyword: pink, + Keyword.Constant: pink, + Keyword.Declaration: cyan + " italic", + Keyword.Type: cyan, + Literal: foreground, + Name: foreground, + Name.Attribute: green, + Name.Builtin: cyan + " italic", + Name.Builtin.Pseudo: foreground, + Name.Class: green, + Name.Function: green, + Name.Label: cyan + " italic", + Name.Tag: pink, + Name.Variable: cyan + " italic", + Number: orange, + Operator: pink, + Other: foreground, + Punctuation: foreground, + String: purple, + Text: foreground, + } diff --git a/src/tagstudio/previews/renderers/ebook.py b/src/tagstudio/previews/renderers/ebook.py index b8fcf0be4..879e5d8e5 100644 --- a/src/tagstudio/previews/renderers/ebook.py +++ b/src/tagstudio/previews/renderers/ebook.py @@ -5,20 +5,59 @@ import xml.etree.ElementTree as ET from io import BytesIO from pathlib import Path +from typing import override from xml.etree.ElementTree import Element import structlog -from PIL import Image +from PIL.Image import Image -from tagstudio.core.media_types import MediaCategories +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import RENDER, BasePreview from tagstudio.previews.renderers.archive import Archive, first_image_in_archive, open_archive from tagstudio.previews.renderers.raster_image import image_from_bytes logger = structlog.get_logger(__name__) -def epub_thumb(filepath: Path, ext: str) -> Image.Image | None: +class EbookPreview(BasePreview): + media_type_name = "ebook" + priority = 40 + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("ebook", ".azw", RENDER) + MediaTypes.register("ebook", ".azw3", RENDER) + MediaTypes.register("ebook", ".cb7", RENDER) + MediaTypes.register("ebook", ".cba", RENDER) + MediaTypes.register("ebook", ".cbr", RENDER) + MediaTypes.register("ebook", ".cbt", RENDER) + MediaTypes.register("ebook", ".cbz", RENDER) + MediaTypes.register("ebook", ".djvu", RENDER) + MediaTypes.register("ebook", ".epub", RENDER) + MediaTypes.register("ebook", ".fb2", RENDER) + MediaTypes.register("ebook", ".ibook", RENDER) + MediaTypes.register("ebook", ".kfx", RENDER) + MediaTypes.register("ebook", ".lit", RENDER) + MediaTypes.register("ebook", ".mobi", RENDER) + MediaTypes.register("ebook", ".prc", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return epub_thumb(filepath) + + +def epub_thumb(filepath: Path) -> Image | None: """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. Args: @@ -29,14 +68,14 @@ def epub_thumb(filepath: Path, ext: str) -> Image.Image | None: Image: The cover specified in ComicInfo.xml, the first image found in the ePub file, or None by default. """ - im: Image.Image | None = None + im: Image | None = None try: - with open_archive(filepath, ext) as archive: + with open_archive(filepath) as archive: if "ComicInfo.xml" in archive.namelist(): comic_info = ET.fromstring(archive.read("ComicInfo.xml")) - im = _cover_from_comic_info(archive, comic_info, "FrontCover") + im = cover_from_comic_info(archive, comic_info, "FrontCover") if not im: - im = _cover_from_comic_info(archive, comic_info, "InnerCover") + im = cover_from_comic_info(archive, comic_info, "InnerCover") if not im: im = first_image_in_archive(archive) @@ -46,9 +85,7 @@ def epub_thumb(filepath: Path, ext: str) -> Image.Image | None: return im -def _cover_from_comic_info( - archive: Archive, comic_info: Element, cover_type: str -) -> Image.Image | None: +def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str) -> Image | None: """Extract the cover specified in ComicInfo.xml. Args: @@ -59,14 +96,14 @@ def _cover_from_comic_info( Returns: Image: The cover specified in ComicInfo.xml. """ - im: Image.Image | None = None + im: Image | None = None cover = comic_info.find(f"./*Page[@Type='{cover_type}']") if cover is not None: pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType] page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType] ext = Path(page_name).suffix - if MediaCategories.IMAGE_RASTER_TYPES.contains(ext): + if MediaTypes.image_raster.contains(ext, RENDER): image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType] im = image_from_bytes(BytesIO(image_data)) diff --git a/src/tagstudio/previews/renderers/font.py b/src/tagstudio/previews/renderers/font.py index 4b39351cf..4d8c41415 100644 --- a/src/tagstudio/previews/renderers/font.py +++ b/src/tagstudio/previews/renderers/font.py @@ -4,37 +4,73 @@ import math from pathlib import Path -from typing import cast +from typing import cast, override import numpy as np import structlog -from PIL import Image, ImageDraw, ImageFont +from PIL import ImageDraw, ImageFont +from PIL.Image import Image, Resampling, fromarray +from PIL.Image import new as new_image from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.effects import apply_overlay_color from tagstudio.qt.helpers.text_wrapper import wrap_full_text from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay +from tagstudio.qt.views.styles.palette import UiColor logger = structlog.get_logger(__name__) -def font_small_thumb(filepath: Path, size: int) -> Image.Image | None: +class FontPreview(BasePreview): + media_type_name = "font" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("font", ".otf", RENDER) + MediaTypes.register("font", ".ttc", RENDER) + MediaTypes.register("font", ".ttf", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return ( + font_small_thumb(filepath, theme, size) + if is_small + else font_full_preview(filepath, size) + ) + + +def font_small_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image | None: """Render a small font preview ("Aa") thumbnail from a font file. Args: filepath (Path): The path of the file. + theme (Theme): The system color theme. size (tuple[int,int]): The size of the thumbnail. """ - im: Image.Image | None = None + # TODO: Support for non-square images + im: Image | None = None try: - bg = Image.new("RGB", (size, size), color="#000000") - raw = Image.new("RGB", (size * 3, size * 3), color="#000000") + bg = new_image("RGB", size, color="#000000") + raw = new_image("RGB", (size[0] * 3, size[1] * 3), color="#000000") draw = ImageDraw.Draw(raw) - font = ImageFont.truetype(filepath, size=size) + font = ImageFont.truetype(filepath, size=size[0]) # NOTE: While a stroke effect is desired, the text # method only allows for outer strokes, which looks # a bit weird when rendering fonts. draw.text( - (size // 8, size // 8), + (size[0] // 8, size[1] // 8), "Aa", font=font, fill="#FF0000", @@ -51,34 +87,37 @@ def font_small_thumb(filepath: Path, size: int) -> Image.Image | None: row.argmax() : m - row[::-1].argmax(), col.argmax() : n - col[::-1].argmax(), ] - cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") + cropped_im: Image = fromarray(cropped_data, "RGB") - margin: int = math.ceil(size // 16) + margin: int = math.ceil(size[0] // 16) orig_x, orig_y = cropped_im.size - new_x, new_y = (size, size) + new_x, new_y = size if orig_x > orig_y: - new_x = size - new_y = math.ceil(size * (orig_y / orig_x)) + new_x = size[0] + new_y = math.ceil(size[1] * (orig_y / orig_x)) elif orig_y > orig_x: - new_y = size - new_x = math.ceil(size * (orig_x / orig_y)) + new_y = size[1] + new_x = math.ceil(size[0] * (orig_x / orig_y)) cropped_im = cropped_im.resize( size=(new_x - (margin * 2), new_y - (margin * 2)), - resample=Image.Resampling.BILINEAR, + resample=Resampling.BILINEAR, ) bg.paste( cropped_im, - box=(margin, margin + ((size - new_y) // 2)), + box=(margin, margin + ((size[1] - new_y) // 2)), ) im = bg + im = apply_overlay_color(im, UiColor.BLUE, theme) + except OSError as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im -def font_full_preview(filepath: Path, size: int) -> Image.Image | None: +def font_full_preview(filepath: Path, size: tuple[int, int]) -> Image | None: """Render a large font preview ("Alphabet") thumbnail from a font file. Args: @@ -87,21 +126,25 @@ def font_full_preview(filepath: Path, size: int) -> Image.Image | None: """ # Scale the sample font sizes to the preview image # resolution,assuming the sizes are tuned for 256px. - im: Image.Image | None = None + im: Image | None = None + # TODO: Support for non-square images try: - scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES] - bg = Image.new("RGBA", (size, size), color="#00000000") + scaled_sizes: list[int] = [math.floor(x * (size[0] / 256)) for x in FONT_SAMPLE_SIZES] + bg = new_image("RGBA", size, color="#00000000") draw = ImageDraw.Draw(bg) lines_of_padding = 2 y_offset = 0.0 for font_size in scaled_sizes: font = ImageFont.truetype(filepath, size=font_size) - text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, font=font, width=size[0], draw=draw + ) draw.multiline_text((0, y_offset), text_wrapped, font=font) y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox( (0, 0), "A", font=font )[-1] + # TODO: Separate from any Qt stuff im = auto_theme_overlay(bg, use_alpha=False) except OSError as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) diff --git a/src/tagstudio/previews/renderers/krita.py b/src/tagstudio/previews/renderers/krita.py new file mode 100644 index 000000000..ee5c68d3d --- /dev/null +++ b/src/tagstudio/previews/renderers/krita.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class KritaPreview(BasePreview): + media_type_name = "krita" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("krita", ".kra", RENDER) + MediaTypes.register("krita", ".krz", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return krita_thumb(filepath) + + +def krita_thumb(filepath: Path) -> Image | None: + """Extract and render a thumbnail for a Krita file.""" + image_names = ["preview.png"] + return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/medibang_paint.py b/src/tagstudio/previews/renderers/medibang_paint.py index d0beb1aad..13a551f09 100644 --- a/src/tagstudio/previews/renderers/medibang_paint.py +++ b/src/tagstudio/previews/renderers/medibang_paint.py @@ -7,16 +7,41 @@ import xml.etree.ElementTree as ET import zlib from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image, frombytes +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import RENDER, BasePreview logger = structlog.get_logger(__name__) -def medibang_paint_thumb(filepath: Path) -> Image.Image | None: +class MediBangPaintPreview(BasePreview): + media_type_name = "medibang_paint" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("medibang_paint", ".mdp", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return medibang_paint_thumb(filepath) + + +def medibang_paint_thumb(filepath: Path) -> Image | None: """Extract the thumbnail from a .mdp file. Args: @@ -25,7 +50,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None: Returns: Image: The embedded thumbnail. """ - im: Image.Image | None = None + im: Image | None = None try: with open(filepath, "rb") as f: magic = struct.unpack("<7sx", f.read(8))[0] @@ -50,7 +75,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None: if pac_header[2] == 1: thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4]) - im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA") + im = frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA") break except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) diff --git a/src/tagstudio/previews/renderers/open_document.py b/src/tagstudio/previews/renderers/open_document.py new file mode 100644 index 000000000..668a8a0a0 --- /dev/null +++ b/src/tagstudio/previews/renderers/open_document.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class OpenDocumentPreview(BasePreview): + media_type_name = "open_document" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("open_document", ".fodg", RENDER) + MediaTypes.register("open_document", ".fodp", RENDER) + MediaTypes.register("open_document", ".fods", RENDER) + MediaTypes.register("open_document", ".fodt", RENDER) + MediaTypes.register("open_document", ".mscz", RENDER) + MediaTypes.register("open_document", ".odf", RENDER) + MediaTypes.register("open_document", ".odg", RENDER) + MediaTypes.register("open_document", ".odp", RENDER) + MediaTypes.register("open_document", ".ods", RENDER) + MediaTypes.register("open_document", ".odt", RENDER) + MediaTypes.register("open_document", ".ora", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return open_doc_thumb(filepath) + + +def open_doc_thumb(filepath: Path) -> Image | None: + """Extract and render a thumbnail for an OpenDocument file.""" + image_names = ["Thumbnails/thumbnail.png"] + return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/paint_dot_net.py b/src/tagstudio/previews/renderers/paint_dot_net.py index c362c7d1c..ff1b4778e 100644 --- a/src/tagstudio/previews/renderers/paint_dot_net.py +++ b/src/tagstudio/previews/renderers/paint_dot_net.py @@ -7,14 +7,42 @@ import xml.etree.ElementTree as ET from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image +from PIL.Image import new as new_image +from PIL.Image import open as open_image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview logger = structlog.get_logger(__name__) -def paint_dot_net_thumb(filepath: Path) -> Image.Image | None: +class PaintDotNetPreview(BasePreview): + media_type_name = "paint_dot_net" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("paint_dot_net", ".pdn", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return paint_dot_net_thumb(filepath) + + +def paint_dot_net_thumb(filepath: Path) -> Image | None: """Extract the base64-encoded thumbnail from a .pdn file header. Args: @@ -23,7 +51,7 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None: Returns: Image: the decoded PNG thumbnail or None by default. """ - im: Image.Image | None = None + im: Image | None = None with open(filepath, "rb") as f: try: # First 4 bytes are the magic number @@ -39,9 +67,9 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None: encoded_png = thumb_element.get("png") if encoded_png: decoded_png = base64.b64decode(encoded_png) - im = Image.open(BytesIO(decoded_png)) + im = open_image(BytesIO(decoded_png)) if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg = new_image("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg except Exception as e: diff --git a/src/tagstudio/previews/renderers/pdf.py b/src/tagstudio/previews/renderers/pdf.py index 65b676465..bdbe4aa4c 100644 --- a/src/tagstudio/previews/renderers/pdf.py +++ b/src/tagstudio/previews/renderers/pdf.py @@ -7,26 +7,53 @@ from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image +from PIL.Image import open as open_image from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF from PySide6.QtGui import QImage from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview from tagstudio.qt.views.styles.image_effects import replace_transparent_pixels logger = structlog.get_logger(__name__) -def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None: +class PdfPreview(BasePreview): + media_type_name = "pdf" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("pdf", ".pdf", RENDER) + MediaTypes.register("pdf", ".ai", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return pdf_thumb(filepath, size) + + +def pdf_thumb(filepath: Path, size: tuple[int, int]) -> Image | None: """Render a thumbnail for a PDF or Adobe Illustrator file. filepath (Path): The path of the file. size (int): The size of the icon. ext (str): The file extension. """ - im: Image.Image | None = None + im: Image | None = None file: QFile = QFile(filepath) success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser) @@ -39,12 +66,13 @@ def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None: # Transform page_size in points to pixels with proper aspect ratio page_size: QSizeF = document.pagePointSize(0) ratio_hw: float = page_size.height() / page_size.width() + # TODO: Make compatible with non-square images if ratio_hw >= 1: - page_size *= size / page_size.height() + page_size *= size[0] / page_size.height() else: - page_size *= size / page_size.width() + page_size *= size[0] / page_size.width() # Enlarge image for anti-aliasing - scale_factor = 2.5 if ext in {".pdf"} else 1 + scale_factor = 2.5 page_size *= scale_factor # Render image with no anti-aliasing for speed render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() @@ -59,7 +87,7 @@ def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None: buffer.open(QBuffer.OpenModeFlag.ReadWrite) try: q_image.save(buffer, "PNG") # pyright: ignore - im = Image.open(BytesIO(buffer.buffer().data())) + im = open_image(BytesIO(buffer.buffer().data())) finally: buffer.close() # Replace transparent pixels with white (otherwise Background defaults to transparent) diff --git a/src/tagstudio/previews/renderers/powerpoint.py b/src/tagstudio/previews/renderers/powerpoint.py new file mode 100644 index 000000000..5c8774368 --- /dev/null +++ b/src/tagstudio/previews/renderers/powerpoint.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class PowerPointPreview(BasePreview): + _fallback_icon = "presentation" + media_type_name = "microsoft.office.powerpoint" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("microsoft.office.powerpoint", ".pptx", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return powerpoint_thumb(filepath) + + +def powerpoint_thumb(filepath: Path) -> Image | None: + """Extract and render a thumbnail for a Microsoft PowerPoint file.""" + image_names = ["docProps/thumbnail.jpeg"] + return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/raster_image.py b/src/tagstudio/previews/renderers/raster_image.py index 3af649f18..4e7eea97e 100644 --- a/src/tagstudio/previews/renderers/raster_image.py +++ b/src/tagstudio/previews/renderers/raster_image.py @@ -1,24 +1,25 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT import os from io import BytesIO from pathlib import Path +from typing import override import cv2 import numpy as np -import rawpy import structlog -from PIL import Image, ImageOps, UnidentifiedImageError -from PIL.Image import DecompressionBombError +from PIL import ImageOps, UnidentifiedImageError +from PIL.Image import DecompressionBombError, Image, fromarray +from PIL.Image import new as new_image +from PIL.Image import open as open_image from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType] -from rawpy import ( - LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] - LibRawIOError, # pyright: ignore[reportPrivateImportUsage] -) +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import RENDER, BasePreview logger = structlog.get_logger(__name__) @@ -31,33 +32,73 @@ os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" -def raster_image_thumb(filepath: Path) -> Image.Image | None: +class RasterImagePreview(BasePreview): + media_type_name = "image.raster" + + @override + @classmethod + def register_types(cls) -> None: + # NOTE: Filetype equivalents (i.e. ".jpg" == ".jpeg") are already declared internally. + MediaTypes.register("image.raster", ".apng", RENDER) + MediaTypes.register("image.raster", ".avif", RENDER) + MediaTypes.register("image.raster", ".bmp", RENDER) + MediaTypes.register("image.raster", ".png", RENDER) + MediaTypes.register("image.raster", ".exr", RENDER) + MediaTypes.register("image.raster", ".gif", RENDER) + MediaTypes.register("image.raster", ".jxl", RENDER) + MediaTypes.register("image.raster", ".psd", RENDER) + MediaTypes.register("image.raster", ".webp", RENDER) + MediaTypes.register("image.raster", ".heif", RENDER) + MediaTypes.register("image.raster", ".jpg2", RENDER) + MediaTypes.register("image.raster", ".jpeg", RENDER) + MediaTypes.register("image.raster", ".tiff", RENDER) + MediaTypes.register("image.raster", ".icns", RENDER) + MediaTypes.register("image.raster", ".ico", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return raster_image_thumb(filepath) + + +def raster_image_thumb(filepath: Path) -> Image | None: """Render a thumbnail for a standard image type. Args: filepath (Path): The path of the file. """ - im: Image.Image | None = None + im: Image | None = None try: + if filepath.suffix.lower() == ".exr": + return exr_image_thumb(filepath) + with filepath.open("rb") as file: im = image_from_bytes(BytesIO(file.read())) except ( - FileNotFoundError, - UnidentifiedImageError, DecompressionBombError, + FileNotFoundError, NotImplementedError, + OSError, + UnidentifiedImageError, ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im -def exr_image_thumb(filepath: Path) -> Image.Image | None: +def exr_image_thumb(filepath: Path) -> Image | None: """Render a thumbnail for a EXR image type. Args: filepath (Path): The path of the file. """ - im: Image.Image | None = None + im: Image | None = None try: # Load the EXR data to an array and rotate the color space from BGRA -> RGBA raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED) @@ -69,11 +110,11 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None: array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma) array = (array_gamma * 255).astype(np.uint8) - im = Image.fromarray(array, mode="RGBA") + im = fromarray(array, mode="RGBA") # Paste solid background if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg = new_image("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg @@ -82,32 +123,7 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None: return im -def raw_image_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a RAW image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess(use_camera_wb=True) - im = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except ( - DecompressionBombError, - LibRawIOError, - LibRawFileUnsupportedError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - -def image_from_bytes(image_data: BytesIO) -> Image.Image: +def image_from_bytes(image_data: BytesIO) -> Image: """Load a raster image and add a background if it's transparent. Args: @@ -116,11 +132,11 @@ def image_from_bytes(image_data: BytesIO) -> Image.Image: Returns: Image.Image: The loaded raster image, with a background if needed. """ - im: Image.Image = Image.open(image_data) + im: Image = open_image(image_data) if im.mode != "RGB" and im.mode != "RGBA": im = im.convert(mode="RGBA") if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg = new_image("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg return unwrap(ImageOps.exif_transpose(im)) diff --git a/src/tagstudio/previews/renderers/raw_image.py b/src/tagstudio/previews/renderers/raw_image.py new file mode 100644 index 000000000..b4f660e8a --- /dev/null +++ b/src/tagstudio/previews/renderers/raw_image.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import rawpy +import structlog +from PIL.Image import DecompressionBombError, Image, frombytes +from rawpy import ( + LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] + LibRawIOError, # pyright: ignore[reportPrivateImportUsage] +) + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview + +logger = structlog.get_logger(__name__) + + +class RawImagePreview(BasePreview): + media_type_name = "image.raster.raw" + priority = 60 + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("image.raster.raw", ".arw", RENDER) + MediaTypes.register("image.raster.raw", ".cr2", RENDER) + MediaTypes.register("image.raster.raw", ".cr3", RENDER) + MediaTypes.register("image.raster.raw", ".crw", RENDER) + MediaTypes.register("image.raster.raw", ".dng", RENDER) + MediaTypes.register("image.raster.raw", ".nef", RENDER) + MediaTypes.register("image.raster.raw", ".nrw", RENDER) + MediaTypes.register("image.raster.raw", ".orf", RENDER) + MediaTypes.register("image.raster.raw", ".r3d", RENDER) + MediaTypes.register("image.raster.raw", ".raf", RENDER) + MediaTypes.register("image.raster.raw", ".raw", RENDER) + MediaTypes.register("image.raster.raw", ".rw2", RENDER) + MediaTypes.register("image.raster.raw", ".srf", RENDER) + MediaTypes.register("image.raster.raw", ".srf2", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return raw_image_thumb(filepath) + + +def raw_image_thumb(filepath: Path) -> Image | None: + """Render a thumbnail for a RAW image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image | None = None + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess(use_camera_wb=True) + im = frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + except ( + DecompressionBombError, + LibRawFileUnsupportedError, + LibRawIOError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im diff --git a/src/tagstudio/previews/renderers/source_engine.py b/src/tagstudio/previews/renderers/source_engine.py index 72ee3cea1..a8107c2fc 100644 --- a/src/tagstudio/previews/renderers/source_engine.py +++ b/src/tagstudio/previews/renderers/source_engine.py @@ -1,17 +1,45 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT +# TODO: Remove this file from the project, turning it into an external plugin. from pathlib import Path +from typing import override import srctools import structlog -from PIL import Image +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview logger = structlog.get_logger(__name__) -def vtf_thumb(filepath: Path) -> Image.Image | None: +class SourceEnginePreview(BasePreview): + media_type_name = "source_engine" + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("source_engine", ".vtf", RENDER) + MediaTypes.register("code", ".vmt", RENDER) # Fallback + + @classmethod + @override + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return vtf_thumb(filepath) + + +def vtf_thumb(filepath: Path) -> Image | None: """Extract and render a thumbnail for VTF (Valve Texture Format) images. Uses the srctools library for reading VTF files. @@ -19,7 +47,7 @@ def vtf_thumb(filepath: Path) -> Image.Image | None: Args: filepath (Path): The path of the file. """ - im: Image.Image | None = None + im: Image | None = None try: with open(filepath, "rb") as f: vtf = srctools.VTF.read(f) diff --git a/src/tagstudio/previews/renderers/text.py b/src/tagstudio/previews/renderers/text.py index 94028e05f..715f2b19b 100644 --- a/src/tagstudio/previews/renderers/text.py +++ b/src/tagstudio/previews/renderers/text.py @@ -1,55 +1,170 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT +import io +import textwrap +from math import ceil from pathlib import Path +from typing import override -import cv2 import structlog -from PIL import Image, ImageDraw, UnidentifiedImageError -from PIL.Image import DecompressionBombError -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication +from PIL import ImageFont, UnidentifiedImageError +from PIL.Image import DecompressionBombError, Image, Resampling +from PIL.Image import new as new_image +from PIL.Image import open as open_image +from pygments import highlight +from pygments.formatters import ImageFormatter +from pygments.lexers import PythonLexer # pyright: ignore[reportUnknownVariableType] +from pygments.style import Style +from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Literal, + Name, + Number, + Operator, + Other, + Punctuation, + String, + Text, +) +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes from tagstudio.core.utils.encoding import detect_char_encoding +from tagstudio.previews.base_preview import RENDER, BasePreview logger = structlog.get_logger(__name__) -def text_thumb(filepath: Path) -> Image.Image | None: +class TextPreview(BasePreview): + media_type_name = "plaintext" + font = ImageFont.load_default(20) + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("plaintext", ".i3u", RENDER) + MediaTypes.register("plaintext", "contributing", RENDER) + MediaTypes.register("plaintext", "license", RENDER) + MediaTypes.register("plaintext", "readme", RENDER) + MediaTypes.register("plaintext", [".txt", ".text"], RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return text_thumb( + filepath=filepath, + size=size, + style=TextDarkStyle if theme == Theme.DARK else TextLightStyle, + ) + + +class TextLightStyle(Style): + background = "#FFFFFF" + foreground = "#000000" + + background_color = background + styles = { + Generic: foreground + " bold", + Text: foreground + " bold", + Literal: foreground + " bold", + String: foreground + " bold", + } + + +class TextDarkStyle(Style): + background = "#111111" + foreground = "#FFFFFF" + + background_color = background + styles = { + Generic: foreground, + Text: foreground, + Literal: foreground, + String: foreground, + Comment: foreground, + Error: foreground, + Keyword: foreground, + Name: foreground, + Number: foreground, + Operator: foreground, + Other: foreground, + Punctuation: foreground, + } + + +def text_thumb( + filepath: Path, + size: tuple[int, int], + style: type[Style], +) -> Image | None: """Render a thumbnail for a plaintext file. Args: filepath (Path): The path of the file. + size (str): The final size for the image. + style (str): The pygments style class to use. """ - im: Image.Image | None = None - - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - fg_color: str = ( - "#FFFFFF" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#111111" - ) + im: Image | None = None try: - encoding = detect_char_encoding(filepath) + encoding: str = detect_char_encoding(filepath) or "utf-8" with open(filepath, encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color=bg_color) - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=fg_color) - im = bg + text = text_file.read(1024) + + wrapped_text = "\n".join( + "\n".join(textwrap.wrap(line, width=40)) for line in text.splitlines() + ) + + # TODO: Get this path from the ResourceManager, when that can handle fonts. + font_path = str( + Path(__file__).parents[2] / "resources/fonts/JetBrainsMono/JetBrainsMono.ttf" + ) + # logger.info(font_path) + + image_bytes = highlight( + wrapped_text, + PythonLexer(), + ImageFormatter( + encoding=encoding, + font_name=font_path, + font_size=32, + line_numbers=False, + style=style, + image_pad=48, + ), + ) + im_text = open_image(io.BytesIO(image_bytes)) + + ratio_w = size[0] / im_text.width + im_text = Image.resize( + im_text, + (ceil(im_text.width * ratio_w), ceil(im_text.height * ratio_w)), + Resampling.BILINEAR, + ) + + bg = new_image("RGB", size, color=style.background_color) + Image.paste(bg, im_text, (0, 0)) + + return bg + except ( UnidentifiedImageError, - cv2.error, DecompressionBombError, UnicodeDecodeError, OSError, FileNotFoundError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) return im diff --git a/src/tagstudio/previews/renderers/vector_image.py b/src/tagstudio/previews/renderers/vector_image.py index a0c2d2b38..bbbdb13bf 100644 --- a/src/tagstudio/previews/renderers/vector_image.py +++ b/src/tagstudio/previews/renderers/vector_image.py @@ -6,29 +6,56 @@ from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import ( - Image, - UnidentifiedImageError, -) +from PIL import UnidentifiedImageError +from PIL.Image import Image +from PIL.Image import new as new_image +from PIL.Image import open as open_image from PySide6.QtCore import QBuffer, Qt from PySide6.QtGui import QImage, QPainter from PySide6.QtSvg import QSvgRenderer +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview + logger = structlog.get_logger(__name__) -def vector_image_thumb(filepath: Path, size: int) -> Image.Image: +class VectorImagePreview(BasePreview): + media_type_name = "image.vector" + priority = 70 + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("image.vector", ".svg", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return vector_image_thumb(filepath, size) + + +def vector_image_thumb(filepath: Path, size: tuple[int, int]) -> Image: """Render a thumbnail for a vector image, such as SVG. Args: filepath (Path): The path of the file. size (tuple[int,int]): The size of the thumbnail. """ - im: Image.Image | None = None + im: Image | None = None # Create an image to draw the svg to and a painter to do the drawing - q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32) + q_image: QImage = QImage(size[0], size[1], QImage.Format.Format_ARGB32) q_image.fill("#1e1e1e") # Create an svg renderer, then render to the painter @@ -48,8 +75,8 @@ def vector_image_thumb(filepath: Path, size: int) -> Image.Image: q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType] # Load the image from the buffer - im = Image.new("RGB", (size, size), color="#1e1e1e") - im.paste(Image.open(BytesIO(buffer.data().data()))) + im = new_image("RGB", size, color="#1e1e1e") + im.paste(open_image(BytesIO(buffer.data().data()))) im = im.convert(mode="RGB") buffer.close() diff --git a/src/tagstudio/previews/renderers/video.py b/src/tagstudio/previews/renderers/video.py index 8ea8b4db6..69fc5d2ee 100644 --- a/src/tagstudio/previews/renderers/video.py +++ b/src/tagstudio/previews/renderers/video.py @@ -1,28 +1,65 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT import math from pathlib import Path +from typing import override import cv2 import structlog from cv2.typing import MatLike -from PIL import Image, UnidentifiedImageError -from PIL.Image import DecompressionBombError +from PIL import UnidentifiedImageError +from PIL.Image import DecompressionBombError, Image, fromarray +from tagstudio.core.enums import Theme +from tagstudio.core.media_types import MediaTypes +from tagstudio.previews.base_preview import RENDER, BasePreview from tagstudio.previews.video_tester import is_readable_video logger = structlog.get_logger(__name__) -def video_thumb(filepath: Path) -> Image.Image | None: +class VideoPreview(BasePreview): + media_type_name = "video" + priority = 70 + + @override + @classmethod + def register_types(cls) -> None: + MediaTypes.register("video", ".3gp", RENDER) + MediaTypes.register("video", ".avi", RENDER) + MediaTypes.register("video", ".flv", RENDER) + MediaTypes.register("video", ".gifv", RENDER) + MediaTypes.register("video", ".hevc", RENDER) + MediaTypes.register("video", ".m4p", RENDER) + MediaTypes.register("video", ".m4v", RENDER) + MediaTypes.register("video", ".mkv", RENDER) + MediaTypes.register("video", ".mov", RENDER) + MediaTypes.register("video", ".mp4", RENDER) + MediaTypes.register("video", ".webm", RENDER) + MediaTypes.register("video", ".wmv", RENDER) + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return video_thumb(filepath) + + +def video_thumb(filepath: Path) -> Image | None: """Render a thumbnail for a video file. Args: filepath (Path): The path of the file. """ - im: Image.Image | None = None + im: Image | None = None frame: MatLike | None = None try: if is_readable_video(filepath): @@ -49,7 +86,12 @@ def video_thumb(filepath: Path) -> Image.Image | None: break if frame is not None: frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - im = Image.fromarray(frame) - except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e: + im = fromarray(frame) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im diff --git a/src/tagstudio/previews/vendored/pydub/audio_segment.py b/src/tagstudio/previews/vendored/pydub/audio_segment.py index bdb08b63f..90aa005a8 100644 --- a/src/tagstudio/previews/vendored/pydub/audio_segment.py +++ b/src/tagstudio/previews/vendored/pydub/audio_segment.py @@ -82,7 +82,10 @@ def classproperty(func): AUDIO_FILE_EXT_ALIASES = { "m4a": "mp4", + "m4r": "mp4", "wave": "wav", + "aif": "aiff", + "aifc": "aiff", } WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"]) diff --git a/src/tagstudio/qt/app_settings.py b/src/tagstudio/qt/app_settings.py index 450fa788e..2ef96ea77 100644 --- a/src/tagstudio/qt/app_settings.py +++ b/src/tagstudio/qt/app_settings.py @@ -4,7 +4,7 @@ import platform from datetime import datetime -from enum import Enum, IntEnum, StrEnum +from enum import Enum, StrEnum from pathlib import Path from typing import override @@ -12,7 +12,7 @@ import toml from pydantic import BaseModel, Field -from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption +from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption, Theme logger = structlog.get_logger(__name__) @@ -32,13 +32,6 @@ DEFAULT_CACHED_THUMB_RES = 256 # Pixels -class Theme(IntEnum): - DARK = 0 - LIGHT = 1 - SYSTEM = 2 - DEFAULT = SYSTEM - - class Splash(StrEnum): DEFAULT = "default" RANDOM = "random" diff --git a/src/tagstudio/qt/controllers/preview_thumb.py b/src/tagstudio/qt/controllers/preview_thumb.py index 8e99d2df0..25b5b29a8 100644 --- a/src/tagstudio/qt/controllers/preview_thumb.py +++ b/src/tagstudio/qt/controllers/preview_thumb.py @@ -12,13 +12,11 @@ from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import QSize -from rawpy import ( - LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] - LibRawIOError, # pyright: ignore[reportPrivateImportUsage] -) +from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories +from tagstudio.core.media_types import MediaTypes +from tagstudio.core.query_lang.file_groups import SEARCH from tagstudio.previews.video_tester import is_readable_video from tagstudio.qt.mixed.file_attributes import FileAttributeData from tagstudio.qt.utils.file_opener import open_file @@ -45,7 +43,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: if filepath.is_dir(): pass - elif MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): + elif MediaTypes.contains("image.raster.raw", ext, SEARCH): try: with rawpy.imread(str(filepath)) as raw: rgb = raw.postprocess() @@ -58,7 +56,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: FileNotFoundError, ): pass - elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): + elif MediaTypes.contains("image.raster", ext, SEARCH): try: image = Image.open(str(filepath)) stats.width = image.width @@ -70,7 +68,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: UnidentifiedImageError, ) as e: logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) - elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): + elif MediaTypes.contains("image.vector", ext, SEARCH): pass # TODO return stats @@ -118,9 +116,7 @@ def display_file(self, filepath: Path) -> FileAttributeData: ext = filepath.suffix.lower() # Video - if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( - filepath - ): + if MediaTypes.contains("video", ext, SEARCH) and is_readable_video(filepath): size: QSize | None = None try: success, size = self.__get_video_res(str(filepath)) @@ -131,10 +127,10 @@ def display_file(self, filepath: Path) -> FileAttributeData: return self._display_video(filepath, size) # Audio - elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): + elif MediaTypes.contains("audio", ext, SEARCH): return self._display_audio(filepath) # Animated Images - elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): + elif MediaTypes.contains("image.animated", ext, SEARCH): if (ret := self.__get_gif_data(filepath)) and ( stats := self._display_gif(ret[0], ret[1]) ) is not None: diff --git a/src/tagstudio/qt/controllers/progress_bar.py b/src/tagstudio/qt/controllers/progress_bar.py index 59026b8b8..3ace3ebe6 100644 --- a/src/tagstudio/qt/controllers/progress_bar.py +++ b/src/tagstudio/qt/controllers/progress_bar.py @@ -64,8 +64,8 @@ def from_iterable_function( self.show() - r = CustomRunnable(lambda: iterator.run()) - r.done.connect( + runnable = CustomRunnable(lambda: iterator.run()) + runnable.done.connect( lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks]) ) - QThreadPool.globalInstance().start(r) + QThreadPool.globalInstance().start(runnable) diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index 43071a398..cb595bd58 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -18,7 +18,8 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.ignore import Ignore -from tagstudio.core.media_types import MediaCategories +from tagstudio.core.media_types import MediaTypes +from tagstudio.core.query_lang.file_groups import SEARCH from tagstudio.core.utils.str_formatting import format_duration from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations @@ -178,9 +179,7 @@ def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | try: file_size = format_size(filepath.stat().st_size) - if MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): + if MediaTypes.contains("font", ext, SEARCH): font = ImageFont.truetype(filepath) font_family = f"{font.getname()[0]} ({font.getname()[1]}) " except (FileNotFoundError, OSError) as e: diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py index fd0031e0d..8ed74010c 100644 --- a/src/tagstudio/qt/mixed/item_thumb.py +++ b/src/tagstudio/qt/mixed/item_thumb.py @@ -15,7 +15,8 @@ from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE from tagstudio.core.library.alchemy.enums import ItemType from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories, MediaType +from tagstudio.core.media_types import MediaTypes +from tagstudio.core.query_lang.file_groups import SEARCH from tagstudio.core.utils.types import unwrap from tagstudio.i18n.platform_strings import open_file_str, trash_term from tagstudio.i18n.translations import Translations @@ -361,12 +362,11 @@ def set_extension(self, filename: Path) -> None: ext = filename.suffix.lower() if ext and ext.startswith(".") is False: ext = "." + ext - media_types: set[MediaType] = MediaCategories.get_types(ext) if ( - not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) - or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES) - or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES) - or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES) + not MediaTypes.contains("image.raster", ext, SEARCH) + or MediaTypes.contains("image.raster.raw", ext, SEARCH) + or MediaTypes.contains("image.vector", ext, SEARCH) + or MediaTypes.contains("adobe.photoshop", ext, SEARCH) or ext in [ ".apng", @@ -380,7 +380,9 @@ def set_extension(self, filename: Path) -> None: if ext or filename.stem: self.ext_badge.setText(ext.upper()[1:] or filename.stem.upper()) show_ext_badge = True - if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types: + if MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains( + "audio", ext, SEARCH + ): show_count_badge = True self.ext_badge.setHidden(not show_ext_badge) diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index 5723616ac..639859baf 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -22,7 +22,7 @@ ) from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX -from tagstudio.core.enums import Theme +from tagstudio.core.enums import ThemePalette from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.modal import Modal @@ -56,9 +56,9 @@ def __init__( self.root_layout.setContentsMargins(6, 6, 6, 6) panel_bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) self.title_label = QLabel() diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index 7d708f2c2..57ba1877e 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -48,7 +48,8 @@ from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.ignore import Ignore from tagstudio.core.library.refresh import RefreshTracker -from tagstudio.core.media_types import MediaCategories +from tagstudio.core.media_types import MediaTypes +from tagstudio.core.query_lang.file_groups import SEARCH from tagstudio.core.query_lang.util import ParsingError from tagstudio.core.ts_core import TagStudioCore from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus @@ -1371,29 +1372,18 @@ def update_completions_list(self, text: str) -> None: map(lambda x: prefix + "path:" + x, self.lib.get_paths(limit=100)) ) elif query_type == "mediatype": - single_word_completions = map( - lambda x: prefix + "mediatype:" + x.name, - filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES), - ) - single_word_completions_quoted = map( - lambda x: prefix + 'mediatype:"' + x.name + '"', - filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES), - ) - multi_word_completions = map( - lambda x: prefix + 'mediatype:"' + x.name + '"', - filter(lambda y: " " in y.name, MediaCategories.ALL_CATEGORIES), - ) - - all_completions = [ - single_word_completions, - single_word_completions_quoted, - multi_word_completions, - ] - completion_list = [j for i in all_completions for j in i] + completion_list = [] + for group in MediaTypes.all_groups: + for alias in group.name_aliases: + if " " in alias: + completion_list.append(f'{prefix}mediatype:"{alias}"') + else: + completion_list.append(f"{prefix}mediatype:{alias}") + completion_list.append(f'{prefix}mediatype:"{alias}"') elif query_type == "filetype": extensions_list: set[str] = set() - for media_cat in MediaCategories.ALL_CATEGORIES: - extensions_list = extensions_list | media_cat.extensions + for group in MediaTypes.all_groups: + extensions_list |= group.context_sets.get(SEARCH, set()) completion_list = list( map(lambda x: prefix + "filetype:" + x.replace(".", ""), extensions_list) ) diff --git a/src/tagstudio/qt/resource_manager.py b/src/tagstudio/qt/resource_manager.py index 7eb28ddfa..9db51744d 100644 --- a/src/tagstudio/qt/resource_manager.py +++ b/src/tagstudio/qt/resource_manager.py @@ -58,11 +58,12 @@ def get_path(id: str): return RESOURCE_FOLDER / "resources" / resource_path - def get(self, id: str): + def get(self, id: str, silent_fail: bool = False): """Get a resource from the ResourceManager. Args: id (str): The name of the resource. + silent_fail (bool): Don't log if the resource can not be found. Returns: bytes: When the data is in byte format. @@ -85,7 +86,8 @@ def get(self, id: str): if resource_path is None: raise FileNotFoundError except (FileNotFoundError, AttributeError) as e: - logger.error("[ResourceManager]: Could not find resource", id=id, error=e) + if not silent_fail: + logger.error("[ResourceManager]: Could not find resource", id=id, error=e) return None file_path = RESOURCE_FOLDER / "resources" / resource_path diff --git a/src/tagstudio/qt/resources.json b/src/tagstudio/qt/resources.json index 7568ca53b..d778002ba 100644 --- a/src/tagstudio/qt/resources.json +++ b/src/tagstudio/qt/resources.json @@ -163,7 +163,7 @@ "mode": "pil", "path": "qt/images/file_icons/spreadsheet.png" }, - "text": { + "plaintext": { "mode": "pil", "path": "qt/images/file_icons/text.png" }, diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index 5fcb922ff..13f6c9c32 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -4,6 +4,7 @@ import math import time +from enum import Enum, auto from pathlib import Path from typing import TYPE_CHECKING, override @@ -13,7 +14,6 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaType from tagstudio.i18n.platform_strings import open_file_str, trash_term from tagstudio.i18n.translations import Translations from tagstudio.qt.mixed.file_attributes import FileAttributeData @@ -30,6 +30,16 @@ THUMB_SIZE_FACTOR = 2 +class PreviewType(Enum): + """Enum for which of the Inspector's stacked pages should be shown for a file.""" + + ANIMATED = auto() + AUDIO = auto() + IMAGE = auto() + TEXT = auto() + VIDEO = auto() + + # TODO: Use newer MVC style guidelines class PreviewThumbView(QWidget): """The Preview Panel Widget.""" @@ -200,8 +210,8 @@ def __update_image_size(self, size: tuple[int, int]) -> None: if m: m.setScaledSize(adj_size) - def __switch_preview(self, preview: MediaType | None) -> None: - if preview in [MediaType.AUDIO, MediaType.VIDEO]: + def __switch_preview(self, preview: PreviewType | None) -> None: + if preview in [PreviewType.AUDIO, PreviewType.VIDEO]: self.__media_player.show() self.__image_layout.setCurrentWidget(self.__media_player_page) self.check_ffmpeg.emit(True) # noqa: FBT003 @@ -210,15 +220,17 @@ def __switch_preview(self, preview: MediaType | None) -> None: self.__media_player.hide() self.check_ffmpeg.emit(False) # noqa: FBT003 - if preview in [MediaType.IMAGE, MediaType.AUDIO]: + if preview in [PreviewType.IMAGE, PreviewType.AUDIO]: self.__button_wrapper.show() self.__image_layout.setCurrentWidget( - self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page + self.__preview_img_page + if preview == PreviewType.IMAGE + else self.__media_player_page ) else: self.__button_wrapper.hide() - if preview == MediaType.IMAGE_ANIMATED: + if preview == PreviewType.ANIMATED: self.__preview_gif.show() self.__image_layout.setCurrentWidget(self.__preview_gif_page) else: @@ -251,7 +263,7 @@ def __update_media_player(self, filepath: Path) -> None: def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: self.__should_render_on_resize = False - self.__switch_preview(MediaType.VIDEO) + self.__switch_preview(PreviewType.VIDEO) self.__update_media_player(filepath) stats = FileAttributeData() @@ -270,7 +282,7 @@ def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeDat return stats def _display_audio(self, filepath: Path) -> FileAttributeData: - self.__switch_preview(MediaType.AUDIO) + self.__switch_preview(PreviewType.AUDIO) self.__render_thumb(filepath) self.__update_media_player(filepath) return FileAttributeData() @@ -300,7 +312,7 @@ def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeD return None # The animation has more than 1 frame, continue displaying it as an animation - self.__switch_preview(MediaType.IMAGE_ANIMATED) + self.__switch_preview(PreviewType.ANIMATED) self.resizeEvent( QResizeEvent( QSize(stats.width, stats.height), @@ -315,7 +327,7 @@ def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeD def _display_image(self, filepath: Path): """Renders the given file as an image, no matter its media type.""" - self.__switch_preview(MediaType.IMAGE) + self.__switch_preview(PreviewType.IMAGE) self.__render_thumb(filepath) def hide_preview(self) -> None: diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py index c8ff15f21..69d1b0856 100644 --- a/src/tagstudio/qt/views/styles/stylesheets.py +++ b/src/tagstudio/qt/views/styles/stylesheets.py @@ -5,7 +5,7 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QGuiApplication -from tagstudio.core.enums import Theme +from tagstudio.core.enums import ThemePalette from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.views.styles.palette import ( @@ -55,14 +55,14 @@ def button_style() -> str: """Style used for common QPushButtons.""" return f""" QPushButton{{ - background-color: {Theme.COLOR_BG.value}; + background-color: {ThemePalette.COLOR_BG.value}; border-radius: 6px; font-weight: 500; text-align: center; padding: 0px 12px; }} QPushButton::hover{{ - background-color: {Theme.COLOR_HOVER.value}; + background-color: {ThemePalette.COLOR_HOVER.value}; border-style: solid; border-width: 2px; border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; @@ -84,7 +84,7 @@ def button_style() -> str: padding: 0px 8px; }} QPushButton::disabled{{ - background-color: {Theme.COLOR_DISABLED_BG.value}; + background-color: {ThemePalette.COLOR_DISABLED_BG.value}; }} """ @@ -92,9 +92,9 @@ def button_style() -> str: def line_edit_style_main() -> str: """Style used for common QLineEdits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -118,7 +118,7 @@ def line_edit_style_main() -> str: padding: 0px 2px; }} QLineEdit::disabled{{ - background-color: {Theme.COLOR_DISABLED_BG.value}; + background-color: {ThemePalette.COLOR_DISABLED_BG.value}; }} """ @@ -259,10 +259,10 @@ def container_style() -> str: border-radius: 4px; }} QWidget#fieldContainer::hover{{ - background-color: {Theme.COLOR_HOVER.value}; + background-color: {ThemePalette.COLOR_HOVER.value}; }} QWidget#fieldContainer::pressed{{ - background-color: {Theme.COLOR_PRESSED.value}; + background-color: {ThemePalette.COLOR_PRESSED.value}; }} """ @@ -271,9 +271,9 @@ def form_content_style() -> str: return f""" QLabel{{ background-color: { - Theme.COLOR_BG.value + ThemePalette.COLOR_BG.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value }; border-radius: 3px; font-weight: 500; @@ -340,9 +340,9 @@ def list_button_style( def properties_style() -> str: """Style used for small labels such as file properties.""" label_bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_DARK_LABEL.value + else ThemePalette.COLOR_DARK_LABEL.value ) return f""" @@ -447,9 +447,9 @@ def title_line_edit_style() -> str: def inset_container_style(object_name: str = "") -> str: """Used for darkened inset areas.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -464,9 +464,9 @@ def inset_container_style(object_name: str = "") -> str: def autofill_scroll_top_style(object_name: str = "") -> str: """Used autofill lists positioned on top of line edits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -482,9 +482,9 @@ def autofill_scroll_top_style(object_name: str = "") -> str: def autofill_scroll_top_focus_style(object_name: str = "") -> str: """Used autofill lists positioned on top of line edits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -502,9 +502,9 @@ def autofill_scroll_top_focus_style(object_name: str = "") -> str: def autofill_line_edit_style() -> str: """Used for QLineEdits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -525,9 +525,9 @@ def autofill_line_edit_style() -> str: def autofill_line_edit_top_style() -> str: """Used for QLineEdits when there's a top autofill section present.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" diff --git a/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf new file mode 100644 index 000000000..541483553 Binary files /dev/null and b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf differ diff --git a/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono.ttf b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono.ttf new file mode 100644 index 000000000..b60e77f5d Binary files /dev/null and b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono.ttf differ diff --git a/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license b/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license deleted file mode 100644 index d56fe894e..000000000 --- a/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license +++ /dev/null @@ -1,2 +0,0 @@ -SPDX-FileCopyrightText: 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium) -SPDX-License-Identifier: OFL-1.1 diff --git a/tests/conftest.py b/tests/conftest.py index d1a17f169..868365c04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +# pyright: reportPrivateUsage=false +# pyright: reportUnusedFunction=false + import sys from collections.abc import Callable, Generator @@ -13,6 +16,7 @@ from pytestqt.qtbot import QtBot from tagstudio.core.library.alchemy.fields import TextField +from tagstudio.core.media_types import MediaTypes CWD = Path(__file__).parent # this needs to be above `src` imports @@ -148,11 +152,25 @@ def entry_full(library: Library): @pytest.fixture(autouse=True) -def _init_qtbot(qtbot: QtBot): # pyright: ignore[reportUnusedFunction] +def _init_qtbot(qtbot: QtBot): """Ensures that a QtBot is initialized for all subsequent tests, regardless of order.""" return qtbot +@pytest.fixture(autouse=True) +def _reset_media_types(): + """Snapshot the MediaTypes state before each test, then restore it after.""" + pre_snapshop = MediaTypes._snapshot() + + yield + + post_snapshop = MediaTypes._snapshot() + + if pre_snapshop != post_snapshop: + MediaTypes._restore(pre_snapshop) + assert pre_snapshop == MediaTypes._snapshot(), "The MediaTypes state was not restored!" + + @pytest.fixture def qt_driver(library: Library, library_dir: Path): class Args: diff --git a/tests/core/test_media_types.py b/tests/core/test_media_types.py new file mode 100644 index 000000000..307cf1f7f --- /dev/null +++ b/tests/core/test_media_types.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pytestqt.exceptions import pytest + +from tagstudio.core.media_types import MediaTypes + + +def test_register_and_contains(): + MediaTypes.register("zzztest.basic", ".zzzfoo", "SEARCH") + + assert MediaTypes.contains("zzztest.basic", ".zzzfoo", "SEARCH") + assert not MediaTypes.contains("zzztest.basic", ".zzzfoo", "RENDER") + + +def test_additive_register(): + MediaTypes.register("zzztest.additive", ".zzzfoo", "SEARCH") + MediaTypes.register("zzztest.additive", ".zzzbar", "SEARCH") + + assert MediaTypes.contains("zzztest.additive", ".zzzfoo", "SEARCH") + assert MediaTypes.contains("zzztest.additive", ".zzzbar", "SEARCH") + + +def test_contains_missing_group_raises_error(): + with pytest.raises(AttributeError, match=r"is not registered"): + MediaTypes.contains("zzztest.does_not_exist", ".zzzfoo", "SEARCH") + + +def test_dot_notation_chains_to_parents(): + MediaTypes.register("zzztest.chain.parent.child", ".zzzchild", "SEARCH") + + assert MediaTypes.contains("zzztest.chain.parent.child", ".zzzchild", "SEARCH") + assert MediaTypes.contains("zzztest.chain.parent", ".zzzchild", "SEARCH") + assert MediaTypes.contains("zzztest.chain", ".zzzchild", "SEARCH") + + +def test_explicit_chain_group(): + MediaTypes.chain_group("zzztest.composite", ["zzztest.composite_child"]) + MediaTypes.register("zzztest.composite_child", ".zzzcomposite", "SEARCH") + + assert MediaTypes.contains("zzztest.composite", ".zzzcomposite", "SEARCH") + + +def test_equivalent_extensions(): + MediaTypes.register("zzztest.equiv", [".zzzone", ".zzztwo"], "SEARCH") + + assert MediaTypes.get_equivalent_exts(".zzzone") == {".zzzone", ".zzztwo"} + assert MediaTypes.get_equivalent_exts(".zzztwo") == {".zzzone", ".zzztwo"} + assert MediaTypes.contains("zzztest.equiv", ".zzzone", "SEARCH") + assert MediaTypes.contains("zzztest.equiv", ".zzztwo", "SEARCH") + + +def test_get_equivalent_exts_defaults_to_itself(): + assert MediaTypes.get_equivalent_exts(".zzzunregistered") == {".zzzunregistered"} + + +def test_find(): + MediaTypes.register("zzztest.find_a", ".zzzfind", "SEARCH") + MediaTypes.register("zzztest.find_b", ".zzzfind", "RENDER") + + search_keys = {group.key for group in MediaTypes.find(".zzzfind", "SEARCH")} + render_keys = {group.key for group in MediaTypes.find(".zzzfind", "RENDER")} + + assert "zzztest.find_a" in search_keys + assert "zzztest.find_a" not in render_keys + assert "zzztest.find_b" in render_keys + assert "zzztest.find_b" not in search_keys + + +def test_add_name_aliases_and_lookup(): + MediaTypes.register("zzztest.alias_target", ".zzzalias", "SEARCH") + MediaTypes.add_name_aliases("zzztest.alias_target", ["ZZZ Test Group", "zzztest"]) + + assert MediaTypes.get_group_key_from_name("ZZZ Test Group") == "zzztest.alias_target" + assert MediaTypes.get_group_key_from_name("zzz test group", case_sensitive=False) == ( + "zzztest.alias_target" + ) + assert ( + MediaTypes.get_group_key_from_name( + "zzztestGroup", case_sensitive=False, ignore_whitespace=True + ) + == "zzztest.alias_target" + ) + assert MediaTypes.get_group_key_from_name("Not A Real Alias") is None diff --git a/tests/qt/test_theme_system.py b/tests/qt/test_theme_system.py index 9e12b8ebe..f4cc55fc9 100644 --- a/tests/qt/test_theme_system.py +++ b/tests/qt/test_theme_system.py @@ -9,7 +9,7 @@ import pytest from PySide6.QtCore import Qt -from tagstudio.qt.app_settings import Theme +from tagstudio.core.enums import Theme @pytest.mark.parametrize(