Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/tagstudio/core/library/alchemy/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
60 changes: 51 additions & 9 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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)

Comment on lines +1092 to +1094
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks when the query has a LIMIT due to the page size.

res = SearchResult(
total_count=total_count,
ids=ids,
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -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Η παρακάτω δομή εμφανίζει όλες τις ετικέτες που πρόκειται να δημιουργηθούν, καθώς και σε ποιες εγγραφές θα εφαρμοστούν.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/fil.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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以下の構造は、作成されるすべてのタグと、それらが適用されるエントリを示しています。",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/nb_NO.json
Original file line number Diff line number Diff line change
Expand Up @@ -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å.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/pt_BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/qpv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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Внизу указаны все теги, которые будут созданы, а также записи к которым они будут применены.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/ta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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கீழே காணப்படும் கட்டமைப்பானது உருவாக்கப்படும் அனைத்து குறிச்சொற்களையும், அவை எந்த நுழைவுகளில் பயன்படுத்தப்படும் என்பதையும் காட்டுகிறது.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/tok.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/zh_Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 下方的结构图将会显示所创建的所有标签及其对应的应用项目。",
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/resources/translations/zh_Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -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以下結構顯示了所有將被建立的標籤和這些標籤會被套用到哪些項目上。",
Expand Down
123 changes: 122 additions & 1 deletion tests/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()