From 1d03c4566f555a33259b68f3a942b4d9bc0a7eb6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 30 Mar 2025 11:45:53 -0700 Subject: [PATCH 1/7] feat: add `.ts_ignore` pattern ignoring system --- src/tagstudio/core/constants.py | 1 + src/tagstudio/core/library/alchemy/library.py | 32 +++- src/tagstudio/core/library/ignore.py | 148 ++++++++++++++++++ src/tagstudio/core/utils/missing_files.py | 24 +-- src/tagstudio/core/utils/refresh_dir.py | 46 ++---- src/tagstudio/qt/helpers/file_deleter.py | 37 ++++- src/tagstudio/qt/ts_qt.py | 4 +- tests/macros/test_dupe_entries.py | 2 +- tests/test_search.py | 4 + 9 files changed, 245 insertions(+), 53 deletions(-) create mode 100644 src/tagstudio/core/library/ignore.py diff --git a/src/tagstudio/core/constants.py b/src/tagstudio/core/constants.py index 690bdd4ac..997f301c7 100644 --- a/src/tagstudio/core/constants.py +++ b/src/tagstudio/core/constants.py @@ -9,6 +9,7 @@ TS_FOLDER_NAME: str = ".TagStudio" BACKUP_FOLDER_NAME: str = "backups" COLLAGE_FOLDER_NAME: str = "collages" +IGNORE_NAME: str = ".ts_ignore" THUMB_CACHE_NAME: str = "thumbs" FONT_SAMPLE_TEXT: str = ( diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index c01c20b76..8e380613b 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -83,6 +83,7 @@ ValueType, ) from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder +from tagstudio.core.library.ignore import Ignore from tagstudio.core.library.json.library import Library as JsonLibrary from tagstudio.qt.translations import Translations @@ -92,6 +93,7 @@ logger = structlog.get_logger(__name__) + TAG_CHILDREN_QUERY = text(""" -- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming WITH RECURSIVE ChildTags AS ( @@ -866,6 +868,7 @@ def search_library( """ assert isinstance(search, BrowsingState) assert self.engine + assert self.library_dir with Session(self.engine, expire_on_commit=False) as session: statement = select(Entry) @@ -878,6 +881,7 @@ def search_library( f"SQL Expression Builder finished ({format_timespan(end_time - start_time)})" ) + # TODO: Convert old extension lists to new .ts_ignore format extensions = self.prefs(LibraryPrefs.EXTENSION_LIST) is_exclude_list = self.prefs(LibraryPrefs.IS_EXCLUDE_LIST) @@ -887,11 +891,37 @@ def search_library( statement = statement.where(Entry.suffix.in_(extensions)) statement = statement.distinct(Entry.id) + ignore_patterns: list[str] = Ignore.get_patterns(self.library_dir) + + # Add glob pattern filters with exclusion patterns allowing for overrides. + statement = statement.filter( + and_( + or_( + or_( + *[ + Entry.path.op("GLOB")(p.lstrip("!")) + for p in ignore_patterns + if p.startswith("!") + ] + ), + and_( + *[ + Entry.path.op("NOT GLOB")(p) + for p in ignore_patterns + if not p.startswith("!") + ] + ), + ) + ) + ) + + # TODO: This query will become unnecessary once this method returns unlimited IDs and + # the it becomes the frontend's responsibility (once again) to split and display them. start_time = time.time() query_count = select(func.count()).select_from(statement.alias("entries")) count_all: int = session.execute(query_count).scalar() or 0 end_time = time.time() - logger.info(f"finished counting ({format_timespan(end_time - start_time)})") + logger.info(f"[Library] Finished counting ({format_timespan(end_time - start_time)})") sort_on: ColumnExpressionArgument = Entry.id match search.sorting_mode: diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py new file mode 100644 index 000000000..d2d5040ee --- /dev/null +++ b/src/tagstudio/core/library/ignore.py @@ -0,0 +1,148 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from copy import deepcopy +from pathlib import Path + +import structlog +from wcmatch import glob, pathlib + +from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME +from tagstudio.core.singleton import Singleton + +logger = structlog.get_logger() + +PATH_GLOB_FLAGS = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE | pathlib.MATCHBASE + + +def _ignore_to_glob(ignore_patterns: list[str]) -> list[str]: + """Convert .gitignore-like patterns to explicit glob syntax. + + Args: + ignore_patterns (list[str]): The .gitignore-like patterns to convert. + """ + glob_patterns: list[str] = deepcopy(ignore_patterns) + additional_patterns: list[str] = [] + + # Mimic implicit .gitignore syntax behavior for the SQLite GLOB function. + for pattern in glob_patterns: + # Temporarily remove any exclusion character before processing + exclusion_char = "" + gp = pattern + if pattern.startswith("!"): + gp = pattern[1:] + exclusion_char = "!" + + if not gp.startswith("**/") and not gp.startswith("*/") and not gp.startswith("/"): + # Create a version of a prefix-less pattern that starts with "**/" + gp = "**/" + gp + additional_patterns.append(exclusion_char + gp) + + gp = gp.removesuffix("/**").removesuffix("/*").removesuffix("/") + additional_patterns.append(exclusion_char + gp) + + gp = gp.removeprefix("**/").removeprefix("*/") + additional_patterns.append(exclusion_char + gp) + + glob_patterns = glob_patterns + additional_patterns + + # Add "/**" suffix to suffix-less patterns to match implicit .gitignore behavior. + for pattern in glob_patterns: + if pattern.endswith("/**"): + continue + + glob_patterns.append(pattern.removesuffix("/*").removesuffix("/") + "/**") + + glob_patterns = list(set(glob_patterns)) + + logger.info("[Ignore]", glob_patterns=glob_patterns) + return glob_patterns + + +GLOBAL_IGNORE = _ignore_to_glob( + [ + # TagStudio ------------------- + f"{TS_FOLDER_NAME}", + # System Trashes -------------- + ".Trash", + ".Trash-*", + ".Trashes", + "$RECYCLE.BIN", + # macOS Generated ------------- + ".DS_Store", + ".fseventsd", + ".Spotlight-V100", + "._*", + "System Volume Information", + ] +) + + +class Ignore(metaclass=Singleton): + """Class for processing and managing glob-like file ignore file patterns.""" + + _last_loaded: tuple[Path, float] | None = None + _patterns: list[str] = [] + + @staticmethod + def get_patterns(library_dir: Path, include_global: bool = True) -> list[str]: + """Get the ignore patterns for the given library directory. + + Args: + library_dir (Path): The path of the library to load patterns from. + include_global (bool): Flag for including the global ignore set. + In most scenarios, this should be True. + """ + patterns = GLOBAL_IGNORE if include_global else [] + ts_ignore_path = Path(library_dir / TS_FOLDER_NAME / IGNORE_NAME) + + if not ts_ignore_path.exists(): + logger.info( + "[Ignore] No .ts_ignore file found", + path=ts_ignore_path, + ) + Ignore._last_loaded = None + Ignore._patterns = patterns + + return Ignore._patterns + + # Process the .ts_ignore file if the previous result is non-existent or outdated. + loaded = (ts_ignore_path, ts_ignore_path.stat().st_mtime) + if not Ignore._last_loaded or (Ignore._last_loaded and Ignore._last_loaded != loaded): + logger.info( + "[Ignore] Processing the .ts_ignore file...", + library=library_dir, + last_mtime=Ignore._last_loaded[1] if Ignore._last_loaded else None, + new_mtime=loaded[1], + ) + Ignore._patterns = _ignore_to_glob(patterns + Ignore._load_ignore_file(ts_ignore_path)) + else: + logger.info( + "[Ignore] No updates to the .ts_ignore detected", + library=library_dir, + last_mtime=Ignore._last_loaded[1], + new_mtime=loaded[1], + ) + Ignore._last_loaded = loaded + + return Ignore._patterns + + @staticmethod + def _load_ignore_file(path: Path) -> list[str]: + """Load and process the .ts_ignore file into a list of glob patterns. + + Args: + path (Path): The path of the .ts_ignore file. + """ + patterns: list[str] = [] + if path.exists(): + with open(path, encoding="utf8") as f: + for line_raw in f.readlines(): + line = line_raw.strip() + # Ignore blank lines and comments + if not line or line.startswith("#"): + continue + patterns.append(line) + + return patterns diff --git a/src/tagstudio/core/utils/missing_files.py b/src/tagstudio/core/utils/missing_files.py index a17379e44..3976aa8c1 100644 --- a/src/tagstudio/core/utils/missing_files.py +++ b/src/tagstudio/core/utils/missing_files.py @@ -3,10 +3,11 @@ from pathlib import Path import structlog +from wcmatch import pathlib from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.utils.refresh_dir import GLOBAL_IGNORE_SET +from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore logger = structlog.get_logger() @@ -25,7 +26,9 @@ def missing_file_entries_count(self) -> int: def refresh_missing_files(self) -> Iterator[int]: """Track the number of entries that point to an invalid filepath.""" + assert self.library.library_dir logger.info("[refresh_missing_files] Refreshing missing files...") + self.missing_file_entries = [] for i, entry in enumerate(self.library.get_entries()): full_path = self.library.library_dir / entry.path @@ -38,16 +41,15 @@ def match_missing_file_entry(self, match_entry: Entry) -> list[Path]: Works if files were just moved to different subfolders and don't have duplicate names. """ - matches = [] - for path in self.library.library_dir.glob(f"**/{match_entry.path.name}"): - # Ensure matched file isn't in a globally ignored folder - skip: bool = False - for part in path.parts: - if part in GLOBAL_IGNORE_SET: - skip = True - break - if skip: - continue + assert self.library.library_dir + matches: list[Path] = [] + + ignore_patterns = Ignore.get_patterns(self.library.library_dir) + for path in pathlib.Path(str(self.library.library_dir)).glob( + f"***/{match_entry.path.name}", + flags=PATH_GLOB_FLAGS, + exclude=ignore_patterns, + ): if path.name == match_entry.path.name: new_path = Path(path).relative_to(self.library.library_dir) matches.append(new_path) diff --git a/src/tagstudio/core/utils/refresh_dir.py b/src/tagstudio/core/utils/refresh_dir.py index ddbff94e1..cb1f82b0f 100644 --- a/src/tagstudio/core/utils/refresh_dir.py +++ b/src/tagstudio/core/utils/refresh_dir.py @@ -5,27 +5,14 @@ from time import time import structlog +from wcmatch import pathlib -from tagstudio.core.constants import TS_FOLDER_NAME from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore logger = structlog.get_logger(__name__) -GLOBAL_IGNORE_SET: set[str] = set( - [ - TS_FOLDER_NAME, - "$RECYCLE.BIN", - ".Trashes", - ".Trash", - "tagstudio_thumbs", - ".fseventsd", - ".Spotlight-V100", - "System Volume Information", - ".DS_Store", - ] -) - @dataclass class RefreshDirTracker: @@ -42,7 +29,7 @@ def save_new_files(self): entries = [ Entry( path=entry_path, - folder=self.library.folder, + folder=self.library.folder, # pyright: ignore[reportArgumentType] fields=[], date_added=dt.now(), ) @@ -54,7 +41,7 @@ def save_new_files(self): yield - def refresh_dir(self, lib_path: Path) -> Iterator[int]: + def refresh_dir(self, library_dir: Path) -> Iterator[int]: """Scan a directory for files, and add those relative filenames to internal variables.""" if self.library.library_dir is None: raise ValueError("No library directory set.") @@ -65,13 +52,19 @@ def refresh_dir(self, lib_path: Path) -> Iterator[int]: self.files_not_in_library = [] dir_file_count = 0 - for f in lib_path.glob("**/*"): + ignore_patterns = Ignore.get_patterns(library_dir) + logger.info(ignore_patterns) + for f in pathlib.Path(str(library_dir)).glob( + "***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns + ): end_time_loop = time() # Yield output every 1/30 of a second if (end_time_loop - start_time_loop) > 0.034: yield dir_file_count start_time_loop = time() + logger.info(f) + # Skip if the file/path is already mapped in the Library if f in self.library.included_files: dir_file_count += 1 @@ -81,21 +74,10 @@ def refresh_dir(self, lib_path: Path) -> Iterator[int]: if f.is_dir(): continue - # Ensure new file isn't in a globally ignored folder - skip: bool = False - for part in f.parts: - # NOTE: Files starting with "._" are sometimes generated by macOS Finder. - # More info: https://lists.apple.com/archives/applescript-users/2006/Jun/msg00180.html - if part.startswith("._") or part in GLOBAL_IGNORE_SET: - skip = True - break - if skip: - continue - dir_file_count += 1 self.library.included_files.add(f) - relative_path = f.relative_to(lib_path) + relative_path = f.relative_to(library_dir) # TODO - load these in batch somehow if not self.library.has_path_entry(relative_path): self.files_not_in_library.append(relative_path) @@ -104,8 +86,8 @@ def refresh_dir(self, lib_path: Path) -> Iterator[int]: yield dir_file_count logger.info( "Directory scan time", - path=lib_path, + path=library_dir, duration=(end_time_total - start_time_total), - files_not_in_lib=self.files_not_in_library, files_scanned=dir_file_count, + ignore_patterns=ignore_patterns, ) diff --git a/src/tagstudio/qt/helpers/file_deleter.py b/src/tagstudio/qt/helpers/file_deleter.py index dce7168ff..b384588ca 100644 --- a/src/tagstudio/qt/helpers/file_deleter.py +++ b/src/tagstudio/qt/helpers/file_deleter.py @@ -3,12 +3,14 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging +import os from pathlib import Path +from platform import system +import structlog from send2trash import send2trash -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) def delete_file(path: str | Path) -> bool: @@ -19,13 +21,36 @@ def delete_file(path: str | Path) -> bool: """ _path = Path(path) try: - logging.info(f"[delete_file] Sending to Trash: {_path}") + logger.info(f"[delete_file] Sending to Trash: {_path}") send2trash(_path) return True except PermissionError as e: - logging.error(f"[delete_file][ERROR] PermissionError: {e}") + logger.error(f"[delete_file] PermissionError: {e}") except FileNotFoundError: - logging.error(f"[delete_file][ERROR] File Not Found: {_path}") + logger.error(f"[delete_file] File Not Found: {_path}") + except OSError as e: + if system() == "Darwin" and _path.exists(): + logger.info( + f'[delete_file] Encountered "{e}" on macOS and file exists; ' + "Assuming it's on a network volume and proceeding to delete..." + ) + return _hard_delete_file(_path) + else: + logger.error("[delete_file] OSError", error=e) except Exception as e: - logging.error(e) + logger.error("[delete_file] Unknown Error", error_type=type(e).__name__, error=e) return False + + +def _hard_delete_file(path: Path) -> bool: + """Hard delete a file from the system. Does NOT send to system trash. + + Args: + path (str | Path): The path of the file to delete. + """ + try: + os.remove(path) + return True + except Exception as e: + logger.error("[hard_delete_file] Error", error_type=type(e).__name__, error=e) + return False diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 4ed07a336..055774a6f 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -1181,6 +1181,7 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> def add_new_files_callback(self): """Run when user initiates adding new files to the Library.""" + assert self.lib.library_dir tracker = RefreshDirTracker(self.lib) pw = ProgressWidget( @@ -1190,10 +1191,9 @@ def add_new_files_callback(self): ) pw.setWindowTitle(Translations["library.refresh.title"]) pw.update_label(Translations["library.refresh.scanning_preparing"]) - pw.show() - iterator = FunctionIterator(lambda: tracker.refresh_dir(self.lib.library_dir)) + iterator = FunctionIterator(lambda lib=self.lib.library_dir: tracker.refresh_dir(lib)) iterator.value.connect( lambda x: ( pw.update_progress(x + 1), diff --git a/tests/macros/test_dupe_entries.py b/tests/macros/test_dupe_entries.py index 6e36c8196..f3f7dbf42 100644 --- a/tests/macros/test_dupe_entries.py +++ b/tests/macros/test_dupe_entries.py @@ -7,7 +7,7 @@ def test_refresh_dupe_files(library): - library.library_dir = "/tmp/" + library.library_dir = Path("/tmp/") entry = Entry( folder=library.folder, path=Path("bar/foo.txt"), diff --git a/tests/test_search.py b/tests/test_search.py index bdd948343..9ef19b25e 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,12 +1,16 @@ import pytest +import structlog from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.query_lang.util import ParsingError +logger = structlog.get_logger() + def verify_count(lib: Library, query: str, count: int): results = lib.search_library(BrowsingState.from_search_query(query), page_size=500) + logger.info("results", items=[e.path for e in results.items]) assert results.total_count == count assert len(results.items) == count From 231f40017f7085a83c040b355d9eb5268725551b Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 30 Mar 2025 12:06:08 -0700 Subject: [PATCH 2/7] fix: add wcmatch dependency --- nix/package/default.nix | 1 + pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/nix/package/default.nix b/nix/package/default.nix index a5d620265..0ee7d464a 100644 --- a/nix/package/default.nix +++ b/nix/package/default.nix @@ -6,6 +6,7 @@ qt6, stdenv, wrapGAppsHook, + wcmatch, pillow-jxl-plugin, pyside6, diff --git a/pyproject.toml b/pyproject.toml index 22503b285..038e9ef6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "typing_extensions~=4.13", "ujson~=5.10", "vtf2img~=0.1", + "wcmatch==10.*", ] [project.optional-dependencies] From a02b48fea1ca18a48ddcee7cefe162e2b11bdb08 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 31 Mar 2025 11:53:50 -0700 Subject: [PATCH 3/7] search: add ".TemporaryItems" to GLOBAL_IGNORE --- src/tagstudio/core/library/ignore.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index d2d5040ee..d9a3fb3b1 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -70,10 +70,11 @@ def _ignore_to_glob(ignore_patterns: list[str]) -> list[str]: ".Trashes", "$RECYCLE.BIN", # macOS Generated ------------- + "._*", ".DS_Store", ".fseventsd", ".Spotlight-V100", - "._*", + ".TemporaryItems", "System Volume Information", ] ) From 6043ce4dfda45c8d901f97743b4658f07e482849 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:34:25 -0700 Subject: [PATCH 4/7] add `desktop.ini` and `.localized` to global ignore --- src/tagstudio/core/library/ignore.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index d9a3fb3b1..7affb72ac 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -64,18 +64,20 @@ def _ignore_to_glob(ignore_patterns: list[str]) -> list[str]: [ # TagStudio ------------------- f"{TS_FOLDER_NAME}", - # System Trashes -------------- - ".Trash", + # Trash ----------------------- ".Trash-*", + ".Trash", ".Trashes", "$RECYCLE.BIN", - # macOS Generated ------------- + # System ---------------------- "._*", ".DS_Store", ".fseventsd", ".Spotlight-V100", ".TemporaryItems", + "desktop.ini", "System Volume Information", + ".localized", ] ) From 7a9b8c6d265c39cbfefa9c78c5729d41d250a007 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:34:56 -0700 Subject: [PATCH 5/7] add ".fhdx" and ".ts" filetypes --- src/tagstudio/core/media_types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index f13ce2a0d..6feabe92a 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -225,7 +225,7 @@ class MediaCategories: ".sqlite", ".sqlite3", } - _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".iso"} + _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".fhdx" ".iso"} _DOCUMENT_SET: set[str] = { ".doc", ".docm", @@ -390,6 +390,7 @@ class MediaCategories: ".mp4", ".webm", ".wmv", + ".ts", } ADOBE_PHOTOSHOP_TYPES = MediaCategory( From bcbdbe60fa27fc3a8b7b214ca5d6b562416674d7 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:35:13 -0700 Subject: [PATCH 6/7] chore: remove logging statement --- src/tagstudio/core/utils/refresh_dir.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tagstudio/core/utils/refresh_dir.py b/src/tagstudio/core/utils/refresh_dir.py index cb1f82b0f..5e3a0b2f9 100644 --- a/src/tagstudio/core/utils/refresh_dir.py +++ b/src/tagstudio/core/utils/refresh_dir.py @@ -63,8 +63,6 @@ def refresh_dir(self, library_dir: Path) -> Iterator[int]: yield dir_file_count start_time_loop = time() - logger.info(f) - # Skip if the file/path is already mapped in the Library if f in self.library.included_files: dir_file_count += 1 From 8344dfd4a61c97f62dc2da444d8a1b360963251d Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 5 Jun 2025 18:41:21 -0700 Subject: [PATCH 7/7] chore: format with ruff --- src/tagstudio/core/media_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 6feabe92a..9c71c4a72 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -225,7 +225,7 @@ class MediaCategories: ".sqlite", ".sqlite3", } - _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".fhdx" ".iso"} + _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".fhdx", ".iso"} _DOCUMENT_SET: set[str] = { ".doc", ".docm",