diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 15e6efa93..bdb5c2c92 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -70,6 +70,7 @@ class SortingModeEnum(enum.Enum): DATE_ADDED = "file.date_added" FILE_NAME = "generic.filename" PATH = "file.path" + SIZE = "file.size" RANDOM = "sorting.mode.random" diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index ddb1a7bbe..a624e42ff 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -1053,16 +1053,19 @@ def search_library( statement = statement.distinct(Entry.id) + is_size_sort = search.sorting_mode == SortingModeEnum.SIZE + sort_on: ColumnExpressionArgument = Entry.id - 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) - case SortingModeEnum.RANDOM: - sort_on = func.sin(Entry.id * search.random_seed) + if not is_size_sort: + 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) + case SortingModeEnum.RANDOM: + sort_on = func.sin(Entry.id * search.random_seed) statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on)) @@ -1086,6 +1089,9 @@ def search_library( end_time = time.time() logger.info(f"SQL Execution finished ({format_timespan(end_time - start_time)})") + if is_size_sort: + ids = self._sort_ids_by_file_size(ids, search.ascending) + res = SearchResult( total_count=total_count, ids=ids, @@ -1095,6 +1101,42 @@ def search_library( return res + def _sort_ids_by_file_size(self, ids: list[int], ascending: bool) -> list[int]: + """Sort entry IDs by their file size on disk. + + Entries whose files cannot be stat-ed (unlinked or missing) are + assigned a sentinel size of -1 and sort to the low end. + + Args: + ids: Entry IDs to sort. + ascending: If True, sort smallest first. + + Returns: + The same IDs re-ordered by file size. + """ + if not ids: + return ids + + library_dir = unwrap(self.library_dir) + + with Session(unwrap(self.engine)) as session: + rows = session.execute( + select(Entry.id, Entry.path).where(Entry.id.in_(ids)) + ).fetchall() + + id_to_path: dict[int, Path] = {row[0]: row[1] for row in rows} + + def get_size(entry_id: int) -> int: + path = id_to_path.get(entry_id) + if path is None: + return -1 + try: + return (library_dir / path).stat().st_size + except OSError: + return -1 + + return sorted(ids, key=get_size, reverse=not ascending) + def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]: """Return a list of Tag records matching the query.""" with Session(self.engine) as session: diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index 18749c9b5..496afb840 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -97,6 +97,7 @@ "file.open_location.mac": "In Finder anzeigen", "file.open_location.windows": "Im Explorer anzeigen", "file.path": "Dateipfad", + "file.size": "Dateigröße", "folders_to_tags.close_all": "Alle schließen", "folders_to_tags.converting": "Wandele Ordner zu Tags um", "folders_to_tags.description": "Erstellt Tags basierend auf der Verzeichnisstruktur und wendet sie auf die Einträge an.\nDer folgende Verzeichnisbaum zeigt welche Tags erstellt werden würden und auf welche Einträge sie angewendet werden würden.", diff --git a/src/tagstudio/resources/translations/el.json b/src/tagstudio/resources/translations/el.json index a7cdd9112..5768c1146 100644 --- a/src/tagstudio/resources/translations/el.json +++ b/src/tagstudio/resources/translations/el.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Εμφάνιση στο Finder", "file.open_location.windows": "Εμφάνιση στην Εξερεύνηση αρχείων", "file.path": "Διαδρομή αρχείου", + "file.size": "Μέγεθος αρχείου", "folders_to_tags.close_all": "Κλείσιμο όλων", "folders_to_tags.converting": "Μετατροπή φακέλων σε Tags", "folders_to_tags.description": "Δημιουργεί ετικέτες με βάση τη δομή των φακέλων σας και τις εφαρμόζει στις εγγραφές σας. \nΗ παρακάτω δομή εμφανίζει όλες τις ετικέτες που πρόκειται να δημιουργηθούν, καθώς και σε ποιες εγγραφές θα εφαρμοστούν.", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index cdd46dc11..dd2807b3c 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Reveal in Finder", "file.open_location.windows": "Show in File Explorer", "file.path": "File Path", + "file.size": "File Size", "folders_to_tags.close_all": "Close All", "folders_to_tags.converting": "Converting folders to Tags", "folders_to_tags.description": "Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to.", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index b53838a44..f51e3efe1 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Abrir en el Finder", "file.open_location.windows": "Abrir en el Explorador", "file.path": "Ruta de archivo", + "file.size": "Tamaño de archivo", "folders_to_tags.close_all": "Cerrar todo", "folders_to_tags.converting": "Convertir carpetas en etiquetas", "folders_to_tags.description": "Crea etiquetas basadas en su estructura de carpetas y las aplica a sus entradas.\nLa siguiente estructura muestra todas las etiquetas que se crearán y a qué entradas se aplicarán.", diff --git a/src/tagstudio/resources/translations/fil.json b/src/tagstudio/resources/translations/fil.json index 68c2df241..e7b9625ae 100644 --- a/src/tagstudio/resources/translations/fil.json +++ b/src/tagstudio/resources/translations/fil.json @@ -86,6 +86,7 @@ "file.open_location.mac": "Ipakita sa Finder", "file.open_location.windows": "Ipakita sa File Explorer", "file.path": "Path ng File", + "file.size": "Laki ng File", "folders_to_tags.close_all": "Isara Lahat", "folders_to_tags.converting": "Kino-convert ang mga folder sa Tag", "folders_to_tags.description": "Gumawa ng mga tag base sa iyong estruktura ng folder at ia-apply sa iyong mga entry.\nAng istraktura sa ibaba ay pinapakita ang lahat ng mga tag na gagawin at anong mga entry ang ia-apply sa.", diff --git a/src/tagstudio/resources/translations/fr.json b/src/tagstudio/resources/translations/fr.json index 5f7b91fce..461b33373 100644 --- a/src/tagstudio/resources/translations/fr.json +++ b/src/tagstudio/resources/translations/fr.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Montrer dans le Finder", "file.open_location.windows": "Montrer dans l'explorateur de Fichiers", "file.path": "Chemin du Fichier", + "file.size": "Taille du Fichier", "folders_to_tags.close_all": "Tout Fermer", "folders_to_tags.converting": "Conversion des dossiers en Tags", "folders_to_tags.description": "Créé des Tags basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les labels qui seront créés et à quelles entrées ils seront appliqués.", diff --git a/src/tagstudio/resources/translations/hu.json b/src/tagstudio/resources/translations/hu.json index a4d01385a..4f9cdaa71 100644 --- a/src/tagstudio/resources/translations/hu.json +++ b/src/tagstudio/resources/translations/hu.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Megnyitás Finderben", "file.open_location.windows": "Megnyitás Intézőben", "file.path": "Elérési út", + "file.size": "Fájlméret", "folders_to_tags.close_all": "Az összes ö&sszecsukása", "folders_to_tags.converting": "Mappák címkékké alakítása", "folders_to_tags.description": "Címkék automatikus létrehozása a létező mappastruktúra alapján.\nAz alábbi mappafán megtekintheti a létrehozandó címkéket, és hogy mely elemekre lesznek alkalmazva.", diff --git a/src/tagstudio/resources/translations/it.json b/src/tagstudio/resources/translations/it.json index b748e4fb5..cdccbe209 100644 --- a/src/tagstudio/resources/translations/it.json +++ b/src/tagstudio/resources/translations/it.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Mostra nel Finder", "file.open_location.windows": "Mostra in File Explorer", "file.path": "Percorso del File", + "file.size": "Dimensione del File", "folders_to_tags.close_all": "Chiudi Tutto", "folders_to_tags.converting": "Conversione delle cartelle in Etichette", "folders_to_tags.description": "Crea Etichette basate sulla struttura delle tue cartelle e le applica alle tue voci.\nLa struttura riportata di seguito mostra tutte le etichette che verranno create ed a che voci verranno applicate.", diff --git a/src/tagstudio/resources/translations/ja.json b/src/tagstudio/resources/translations/ja.json index 232b26cf5..4aa3748df 100644 --- a/src/tagstudio/resources/translations/ja.json +++ b/src/tagstudio/resources/translations/ja.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Finder で見る", "file.open_location.windows": "エクスプローラーで表示する", "file.path": "ファイル パス", + "file.size": "ファイルサイズ", "folders_to_tags.close_all": "すべて閉じる", "folders_to_tags.converting": "フォルダーをタグに変換", "folders_to_tags.description": "フォルダー構造に基づいてタグを作成し、エントリに適用します。\n以下の構造は、作成されるすべてのタグと、それらが適用されるエントリを示しています。", diff --git a/src/tagstudio/resources/translations/nb_NO.json b/src/tagstudio/resources/translations/nb_NO.json index b4fdab36b..c3ea3ab3c 100644 --- a/src/tagstudio/resources/translations/nb_NO.json +++ b/src/tagstudio/resources/translations/nb_NO.json @@ -94,6 +94,7 @@ "file.open_location.mac": "Åpne i Finder", "file.open_location.windows": "Vis i Filutforsker", "file.path": "Filbane", + "file.size": "Filstørrelse", "folders_to_tags.close_all": "Lukk Alle", "folders_to_tags.converting": "Konverterer mapper til etiketter", "folders_to_tags.description": "Lager etiketter basert på din filsturktur og legger dem til dine oppføringer.\n Strukturen viser alle de etikettene som vil bli lagd og hvilke oppføringer de vil bli lagt til på.", diff --git a/src/tagstudio/resources/translations/pl.json b/src/tagstudio/resources/translations/pl.json index 854bacf45..d9eb64e5e 100644 --- a/src/tagstudio/resources/translations/pl.json +++ b/src/tagstudio/resources/translations/pl.json @@ -86,6 +86,7 @@ "file.open_location.mac": "Pokaż plik w Finderze", "file.open_location.windows": "Pokaż plik w Eksploratorze plików", "file.path": "Ścieżka Pliku", + "file.size": "Rozmiar Pliku", "folders_to_tags.close_all": "Zamknij wszystko", "folders_to_tags.converting": "Konwertowanie folderów na tagi", "folders_to_tags.description": "Tworzy tagi na podstawie struktury folderów i stosuje je do wpisów.\n Poniższa struktura przedstawia wszystkie utworzone tagi i wpisy, do których zostaną one zastosowane.", diff --git a/src/tagstudio/resources/translations/pt.json b/src/tagstudio/resources/translations/pt.json index c5b3960f9..b6bbd1f29 100644 --- a/src/tagstudio/resources/translations/pt.json +++ b/src/tagstudio/resources/translations/pt.json @@ -84,6 +84,7 @@ "file.open_location.mac": "Mostrar no Finder", "file.open_location.windows": "Mostrar no Explorador de Ficheiros", "file.path": "Caminho do ficheiro", + "file.size": "Tamanho do ficheiro", "folders_to_tags.close_all": "Fechar Tudo", "folders_to_tags.converting": "A converter pastas para Tags", "folders_to_tags.description": "Cria tags com base na sua estrutura de pastas e aplica-as aos seus registos\nA estrutura abaixo mostra todas as tags que serão criadas e em quais elementos serão aplicadas.", diff --git a/src/tagstudio/resources/translations/pt_BR.json b/src/tagstudio/resources/translations/pt_BR.json index 0e663bc60..bd2366aed 100644 --- a/src/tagstudio/resources/translations/pt_BR.json +++ b/src/tagstudio/resources/translations/pt_BR.json @@ -97,6 +97,7 @@ "file.open_location.mac": "Mostrar no Finder", "file.open_location.windows": "Mostrar no Explorador de Arquivos", "file.path": "Caminho do Arquivo", + "file.size": "Tamanho do Arquivo", "folders_to_tags.close_all": "Fechar Tudo", "folders_to_tags.converting": "Convertendo pastas para Tags", "folders_to_tags.description": "Cria tags com base na sua estrutura de arquivos e aplica elas nos seus registros\nA estrutura abaixo mostra todas as tags que serão criadas e em quais itens elas serão aplicadas.", diff --git a/src/tagstudio/resources/translations/qpv.json b/src/tagstudio/resources/translations/qpv.json index 5b68f1480..2d0653524 100644 --- a/src/tagstudio/resources/translations/qpv.json +++ b/src/tagstudio/resources/translations/qpv.json @@ -86,6 +86,7 @@ "file.open_location.mac": "Mahase na Finder", "file.open_location.windows": "Mahase na File Explorer", "file.path": "Mlafuplas", + "file.size": "File Size", "folders_to_tags.close_all": "Kini al", "folders_to_tags.converting": "Kjannos mlafukaban na festaretol", "folders_to_tags.description": "Afto maha festaretol parjatazma fu mlafukaban kara au nasii na shiruzmakaban.\n Parjatazma una mahase al festaretol ke zol mahajena au ka shiruzmakaban hej zol nasiijena na.", diff --git a/src/tagstudio/resources/translations/ru.json b/src/tagstudio/resources/translations/ru.json index 55158add7..d632c85fd 100644 --- a/src/tagstudio/resources/translations/ru.json +++ b/src/tagstudio/resources/translations/ru.json @@ -87,6 +87,7 @@ "file.open_location.mac": "Показать в Finder", "file.open_location.windows": "Открыть в проводнике", "file.path": "Путь до файла", + "file.size": "Размер файла", "folders_to_tags.close_all": "Закрыть всё", "folders_to_tags.converting": "Конвертировать папки в теги", "folders_to_tags.description": "Создаёт теги для записей согласно имеющейся иерархии папок.\nВнизу указаны все теги, которые будут созданы, а также записи к которым они будут применены.", diff --git a/src/tagstudio/resources/translations/ta.json b/src/tagstudio/resources/translations/ta.json index 5f7e3d688..4cebac992 100644 --- a/src/tagstudio/resources/translations/ta.json +++ b/src/tagstudio/resources/translations/ta.json @@ -97,6 +97,7 @@ "file.open_location.mac": "கண்டுபிடிப்பாளரில் வெளிப்படுத்துங்கள்", "file.open_location.windows": "கோப்பு எக்ச்ப்ளோரரில் காண்பி", "file.path": "கோப்பு பாதை", + "file.size": "கோப்பு அளவு", "folders_to_tags.close_all": "அனைத்தையும் மூடு", "folders_to_tags.converting": "கோப்புறைகளை குறிச்சொற்களாக மாற்றப்படுகிறது", "folders_to_tags.description": "உங்கள் அடைவு கட்டமைப்பின் அடிப்படையில் குறிச்சொற்களை உருவாக்கி, அவற்றை உங்கள் நுழைவுகளில் பயன்படுத்துகிறது.\nகீழே காணப்படும் கட்டமைப்பானது உருவாக்கப்படும் அனைத்து குறிச்சொற்களையும், அவை எந்த நுழைவுகளில் பயன்படுத்தப்படும் என்பதையும் காட்டுகிறது.", diff --git a/src/tagstudio/resources/translations/tok.json b/src/tagstudio/resources/translations/tok.json index 5169cea12..ef27e8557 100644 --- a/src/tagstudio/resources/translations/tok.json +++ b/src/tagstudio/resources/translations/tok.json @@ -96,6 +96,7 @@ "file.open_location.mac": "o alasa lon ilo Finder", "file.open_location.windows": "o alasa lon ilo File Explorer", "file.path": "nasin lipu", + "file.size": "suli lipu", "folders_to_tags.close_all": "o pini e lukin pi ijo ale", "folders_to_tags.converting": "mi pali e poki tan poki tomo", "folders_to_tags.description": "ni li pali e poki tan poki lipu sina li pana e poki sin tawa ijo lon tomo sina.\n ilo ni li ken lukin e poki ale ni e ijo ni.", diff --git a/src/tagstudio/resources/translations/zh_Hans.json b/src/tagstudio/resources/translations/zh_Hans.json index 66cea1092..dbd187b41 100644 --- a/src/tagstudio/resources/translations/zh_Hans.json +++ b/src/tagstudio/resources/translations/zh_Hans.json @@ -94,6 +94,7 @@ "file.open_location.mac": "在 Finder 中显示", "file.open_location.windows": "在资源管理器中展示", "file.path": "文件路径", + "file.size": "文件大小", "folders_to_tags.close_all": "关闭全部", "folders_to_tags.converting": "正在将文件夹转换为标签", "folders_to_tags.description": "根据你的文件夹结构创建标签并应用在项目上。\n 下方的结构图将会显示所创建的所有标签及其对应的应用项目。", diff --git a/src/tagstudio/resources/translations/zh_Hant.json b/src/tagstudio/resources/translations/zh_Hant.json index 8dd73377c..9b75b6602 100644 --- a/src/tagstudio/resources/translations/zh_Hant.json +++ b/src/tagstudio/resources/translations/zh_Hant.json @@ -97,6 +97,7 @@ "file.open_location.mac": "在 Finder 中顯示", "file.open_location.windows": "在檔案總管中顯示", "file.path": "檔案路徑", + "file.size": "檔案大小", "folders_to_tags.close_all": "關閉全部", "folders_to_tags.converting": "正在轉換資料夾為標籤", "folders_to_tags.description": "根據資料夾結構建立標籤並套用到您的項目上。\n以下結構顯示了所有將被建立的標籤和這些標籤會被套用到哪些項目上。", diff --git a/tests/test_search.py b/tests/test_search.py index 79812dfa1..430e3ec2e 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -3,12 +3,17 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from pathlib import Path +from tempfile import TemporaryDirectory + import pytest import structlog -from tagstudio.core.library.alchemy.enums import BrowsingState +from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.query_lang.util import ParsingError +from tagstudio.core.utils.types import unwrap logger = structlog.get_logger() @@ -146,3 +151,119 @@ def test_parent_tags(search_library: Library, query: str, count: int): def test_syntax(search_library: Library, invalid_query: str): with pytest.raises(ParsingError) as e_info: # noqa: F841 # pyright: ignore[reportUnusedVariable] search_library.search_library(BrowsingState.from_search_query(invalid_query), page_size=500) + + +def _make_size_library(files: list[tuple[str, bytes]]) -> tuple[Library, TemporaryDirectory]: + """Create a temporary library with files of known sizes. + + Args: + files: List of (relative path, content) pairs. + + Returns: + A tuple of (open Library, TemporaryDirectory) — caller must close the tempdir. + """ + tmp = TemporaryDirectory() + lib_path = Path(tmp.name) + + lib = Library() + status = lib.open_library(lib_path) + assert status.success + + folder = unwrap(lib.folder) + entries = [] + for rel_path, content in files: + full = lib_path / rel_path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_bytes(content) + entries.append(Entry(folder=folder, path=Path(rel_path), fields=lib.default_fields)) + + lib.add_entries(entries) + return lib, tmp + + +def test_sort_by_size_ascending(): + """Entries are returned smallest-first when sorting by size ascending.""" + files = [ + ("large.bin", b"x" * 300), + ("small.bin", b"x" * 100), + ("medium.bin", b"x" * 200), + ] + lib, tmp = _make_size_library(files) + try: + state = BrowsingState(sorting_mode=SortingModeEnum.SIZE, ascending=True) + results = lib.search_library(state, page_size=None) + + assert results.total_count == 3 + sizes = [] + for entry_id in results.ids: + entry = lib.get_entry(entry_id) + assert entry is not None + sizes.append((unwrap(lib.library_dir) / entry.path).stat().st_size) + + assert sizes == sorted(sizes), f"Expected ascending order, got sizes: {sizes}" + finally: + tmp.cleanup() + + +def test_sort_by_size_descending(): + """Entries are returned largest-first when sorting by size descending.""" + files = [ + ("large.bin", b"x" * 300), + ("small.bin", b"x" * 100), + ("medium.bin", b"x" * 200), + ] + lib, tmp = _make_size_library(files) + try: + state = BrowsingState(sorting_mode=SortingModeEnum.SIZE, ascending=False) + results = lib.search_library(state, page_size=None) + + assert results.total_count == 3 + sizes = [] + for entry_id in results.ids: + entry = lib.get_entry(entry_id) + assert entry is not None + sizes.append((unwrap(lib.library_dir) / entry.path).stat().st_size) + + assert sizes == sorted(sizes, reverse=True), f"Expected descending order, got sizes: {sizes}" + finally: + tmp.cleanup() + + +def test_sort_by_size_empty_result(): + """Sorting an empty result set returns an empty list without error.""" + lib, tmp = _make_size_library([("placeholder.bin", b"x")]) + try: + state = BrowsingState( + sorting_mode=SortingModeEnum.SIZE, + ascending=True, + query="tag:nonexistent_tag_xyz", + ) + results = lib.search_library(state, page_size=None) + assert results.total_count == 0 + assert results.ids == [] + finally: + tmp.cleanup() + + +def test_sort_by_size_missing_file_sorts_to_start_ascending(): + """Entries with missing files (size=-1) sort to the start when ascending.""" + files = [ + ("exists.bin", b"x" * 200), + ] + lib, tmp = _make_size_library(files) + try: + folder = unwrap(lib.folder) + # Add an entry for a file that doesn't exist on disk + ghost = Entry(folder=folder, path=Path("ghost.bin"), fields=lib.default_fields) + lib.add_entries([ghost]) + + state = BrowsingState(sorting_mode=SortingModeEnum.SIZE, ascending=True) + results = lib.search_library(state, page_size=None) + + assert results.total_count == 2 + # The ghost entry (size=-1) should come first in ascending order + first_entry = lib.get_entry(results.ids[0]) + assert first_entry is not None + assert first_entry.path == Path("ghost.bin") + finally: + tmp.cleanup()