From 31833245a4d7a655dc4e66e6005a9dd78c36c04d Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Thu, 13 Mar 2025 01:28:42 +0100 Subject: [PATCH] feat: add filename and path sorting (#842) * feat: add filename sorting to dropdown * feat: add file path sorting to dropdown * feat: implement path sorting * feat: add filename column and bump db version * feat: implement filename sorting * doc: tick off roadmap item for filename sorting * fix: use existing filename translation instead * fix: populate Entry.filename in constructor * fix: add missing assertion in search_library fixture * fix: update search test library * feat: add db migration test * fix: add missing library for test --- docs/updates/roadmap.md | 2 +- src/tagstudio/core/enums.py | 2 +- src/tagstudio/core/library/alchemy/enums.py | 2 + src/tagstudio/core/library/alchemy/library.py | 44 +++++++++++++++++- src/tagstudio/core/library/alchemy/models.py | 2 + src/tagstudio/resources/translations/de.json | 1 + src/tagstudio/resources/translations/en.json | 1 + tests/conftest.py | 3 +- .../DB_VERSION_9/.TagStudio/ts_library.sqlite | Bin 0 -> 98304 bytes .../.TagStudio/ts_library.sqlite | Bin 98304 -> 98304 bytes tests/test_db_migrations.py | 1 + 11 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_9/.TagStudio/ts_library.sqlite diff --git a/docs/updates/roadmap.md b/docs/updates/roadmap.md index 17037374..5d7a0ca6 100644 --- a/docs/updates/roadmap.md +++ b/docs/updates/roadmap.md @@ -94,7 +94,7 @@ These version milestones are rough estimations for when the previous core featur - [ ] Field content search [HIGH] - [ ] Sort by date created [HIGH] - [ ] Sort by date modified [HIGH] -- [ ] Sort by filename [HIGH] +- [x] Sort by filename [HIGH] - [ ] HAS operator for composition tags [HIGH] - [ ] Search bar rework - [ ] Improved tag autocomplete [HIGH] diff --git a/src/tagstudio/core/enums.py b/src/tagstudio/core/enums.py index 31e383b6..bebe348d 100644 --- a/src/tagstudio/core/enums.py +++ b/src/tagstudio/core/enums.py @@ -82,4 +82,4 @@ class LibraryPrefs(DefaultEnum): IS_EXCLUDE_LIST = True EXTENSION_LIST = [".json", ".xmp", ".aae"] PAGE_SIZE = 500 - DB_VERSION = 8 + DB_VERSION = 9 diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index dc3b8b56..e2da7382 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -67,6 +67,8 @@ class ItemType(enum.Enum): class SortingModeEnum(enum.Enum): DATE_ADDED = "file.date_added" + FILE_NAME = "generic.filename" + PATH = "file.path" @dataclass diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 9794a79d..bb932685 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -472,12 +472,25 @@ class Library: # Apply any post-SQL migration patches. if not is_new: + # save backup if patches will be applied + if LibraryPrefs.DB_VERSION.default != db_version: + self.library_dir = library_dir + self.save_library_backup_to_disk() + self.library_dir = None + + # schema changes first if db_version < 8: self.apply_db8_schema_changes(session) + if db_version < 9: + self.apply_db9_schema_changes(session) + + # now the data changes if db_version == 6: self.apply_repairs_for_db6(session) if db_version >= 6 and db_version < 8: self.apply_db8_default_data(session) + if db_version < 9: + self.apply_db9_filename_population(session) # Update DB_VERSION if LibraryPrefs.DB_VERSION.default > db_version: @@ -580,6 +593,29 @@ class Library: ) session.rollback() + def apply_db9_schema_changes(self, session: Session): + """Apply database schema changes introduced in DB_VERSION 9.""" + add_filename_column = text( + "ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''" + ) + try: + session.execute(add_filename_column) + session.commit() + logger.info("[Library][Migration] Added filename column to entries table") + except Exception as e: + logger.error( + "[Library][Migration] Could not create filename column in entries table!", + error=e, + ) + session.rollback() + + def apply_db9_filename_population(self, session: Session): + """Populate the filename column introduced in DB_VERSION 9.""" + for entry in self.get_entries(): + session.merge(entry).filename = entry.path.name + session.commit() + logger.info("[Library][Migration] Populated filename column in entries table") + @property def default_fields(self) -> list[BaseField]: with Session(self.engine) as session: @@ -852,7 +888,7 @@ class Library: statement = statement.distinct(Entry.id) start_time = time.time() query_count = select(func.count()).select_from(statement.alias("entries")) - count_all: int = session.execute(query_count).scalar() + 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)})") @@ -860,6 +896,10 @@ class Library: match search.sorting_mode: case SortingModeEnum.DATE_ADDED: sort_on = Entry.id + case SortingModeEnum.FILE_NAME: + sort_on = func.lower(Entry.filename) + case SortingModeEnum.PATH: + sort_on = func.lower(Entry.path) statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on)) statement = statement.limit(search.limit).offset(search.offset) @@ -1371,6 +1411,8 @@ class Library: target_path, ) + logger.info("Library backup saved to disk.", path=target_path) + return target_path def get_tag(self, tag_id: int) -> Tag | None: diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 5df75b73..f85a02a4 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -187,6 +187,7 @@ class Entry(Base): folder: Mapped[Folder] = relationship("Folder") path: Mapped[Path] = mapped_column(PathType, unique=True) + filename: Mapped[str] = mapped_column() suffix: Mapped[str] = mapped_column() date_created: Mapped[dt | None] date_modified: Mapped[dt | None] @@ -232,6 +233,7 @@ class Entry(Base): self.path = path self.folder = folder self.id = id + self.filename = path.name self.suffix = path.suffix.lstrip(".").lower() # The date the file associated with this entry was created. diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index 44b96a5c..ef32101b 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -66,6 +66,7 @@ "file.date_added": "Hinzufügungsdatum", "file.date_created": "Erstellungsdatum", "file.date_modified": "Datum geändert", + "file.path": "Dateipfad", "file.dimensions": "Abmessungen", "file.duplicates.description": "TagStudio unterstützt das Importieren von DupeGuru-Ergebnissen um Dateiduplikate zu verwalten.", "file.duplicates.dupeguru.advice": "Nach dem Kopiervorgang kann DupeGuru benutzt werden und ungewollte Dateien zu löschen. Anschließend kann TagStudios \"Unverknüpfte Einträge reparieren\" Funktion im \"Werkzeuge\" Menü benutzt werden um die nicht verknüpften Einträge zu löschen.", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 6be2a131..ed683b61 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -68,6 +68,7 @@ "file.date_added": "Date Added", "file.date_created": "Date Created", "file.date_modified": "Date Modified", + "file.path": "File Path", "file.dimensions": "Dimensions", "file.duplicates.description": "TagStudio supports importing DupeGuru results to manage duplicate files.", "file.duplicates.dupeguru.advice": "After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's \"Fix Unlinked Entries\" feature in the Tools menu in order to delete the unlinked Entries.", diff --git a/tests/conftest.py b/tests/conftest.py index 989ecad6..1a119515 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,7 +114,8 @@ def library(request): @pytest.fixture def search_library() -> Library: lib = Library() - lib.open_library(Path(CWD / "fixtures" / "search_library")) + status = lib.open_library(Path(CWD / "fixtures" / "search_library")) + assert status.success return lib diff --git a/tests/fixtures/empty_libraries/DB_VERSION_9/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_9/.TagStudio/ts_library.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..73c0a685e385dc045906f435cec3852793f8f034 GIT binary patch literal 98304 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 Y)bjw$HZx`NNVjGOL7Aqk%BcYV2PJBcI{*Lx literal 0 HcmV?d00001 diff --git a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 6d6d85c60ad56066150ba80f410fb4a3e2b57a09..b47e91da9eabad64da9fb0ebed3d6a9c01bc7ce0 100644 GIT binary patch delta 5881 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 delta 4145 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