diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index 344375f5e..9d4f53ab8 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -25,7 +25,7 @@ jobs: run: | python -m pip install --upgrade uv uv pip install --system -r requirements.txt - uv pip install --system mypy==1.11.2 + uv pip install --system mypy==1.13.0 mkdir tagstudio/.mypy_cache - uses: tsuyoshicho/action-mypy@v4 diff --git a/requirements-dev.txt b/requirements-dev.txt index b6b2c6e65..ce97cfae6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ ruff==0.6.4 pre-commit==3.7.0 pytest==8.2.0 Pyinstaller==6.6.0 -mypy==1.11.2 +mypy==1.13.0 syrupy==4.7.1 pytest-qt==4.4.0 pytest-cov==5.0.0 diff --git a/requirements.txt b/requirements.txt index e1dff2fca..91849317b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,8 +13,8 @@ PySide6_Addons==6.7.1 PySide6_Essentials==6.7.1 PySide6==6.7.1 rawpy==0.21.0 -SQLAlchemy==2.0.34 +SQLAlchemy==2.0.36 structlog==24.4.0 typing_extensions>=3.10.0.0,<=4.11.0 ujson>=5.8.0,<=5.9.0 -vtf2img==0.1.0 \ No newline at end of file +vtf2img==0.1.0 diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 0ca053778..d4527a317 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -2,9 +2,9 @@ VERSION_BRANCH: str = "EXPERIMENTAL" # Usually "" or "Pre-Release" # The folder & file names where TagStudio keeps its data relative to a library. -TS_FOLDER_NAME: str = ".TagStudio" BACKUP_FOLDER_NAME: str = "backups" COLLAGE_FOLDER_NAME: str = "collages" +TS_FOLDER_NOINDEX: str = ".ts_noindex" FONT_SAMPLE_TEXT: str = ( """ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]""" diff --git a/tagstudio/src/core/driver.py b/tagstudio/src/core/driver.py index 1561fbc92..73f5f52b7 100644 --- a/tagstudio/src/core/driver.py +++ b/tagstudio/src/core/driver.py @@ -2,7 +2,6 @@ import structlog from PySide6.QtCore import QSettings -from src.core.constants import TS_FOLDER_NAME from src.core.enums import SettingItems from src.core.library.alchemy.library import LibraryStatus @@ -14,27 +13,26 @@ class DriverMixin: def evaluate_path(self, open_path: str | None) -> LibraryStatus: """Check if the path of library is valid.""" - library_path: Path | None = None + storage_path: Path | None = None if open_path: - library_path = Path(open_path) - if not library_path.exists(): + storage_path = Path(open_path) + if not storage_path.exists(): logger.error("Path does not exist.", open_path=open_path) return LibraryStatus(success=False, message="Path does not exist.") elif self.settings.value( SettingItems.START_LOAD_LAST, defaultValue=True, type=bool ) and self.settings.value(SettingItems.LAST_LIBRARY): - library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY))) - if not (library_path / TS_FOLDER_NAME).exists(): + storage_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY))) + if not storage_path.exists(): logger.error( "TagStudio folder does not exist.", - library_path=library_path, - ts_folder=TS_FOLDER_NAME, + storage_path=storage_path, ) self.settings.setValue(SettingItems.LAST_LIBRARY, "") # dont consider this a fatal error, just skip opening the library - library_path = None + storage_path = None return LibraryStatus( success=True, - library_path=library_path, + storage_path=storage_path, ) diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index d4a9aa3d9..1e2e10be4 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -3,13 +3,14 @@ from uuid import uuid4 -class SettingItems(str, enum.Enum): +class SettingItems(enum.StrEnum): """List of setting item names.""" START_LOAD_LAST = "start_load_last" - LAST_LIBRARY = "last_library" + LAST_LIBRARY = "last_storage" LIBS_LIST = "libs_list" WINDOW_SHOW_LIBS = "window_show_libs" + WINDOW_SHOW_DIRS = "window_show_dirs" AUTOPLAY = "autoplay_videos" @@ -25,12 +26,6 @@ class Theme(str, enum.Enum): COLOR_DISABLED_BG = "#65440D12" -class OpenStatus(enum.IntEnum): - NOT_FOUND = 0 - SUCCESS = 1 - CORRUPTED = 2 - - class MacroID(enum.Enum): AUTOFILL = "autofill" SIDECAR = "sidecar" @@ -64,4 +59,4 @@ class LibraryPrefs(DefaultEnum): IS_EXCLUDE_LIST = True EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] PAGE_SIZE: int = 500 - DB_VERSION: int = 2 + LIBRARY_NAME: str = "TS Library" diff --git a/tagstudio/src/core/library/alchemy/enums.py b/tagstudio/src/core/library/alchemy/enums.py index aaf9d32af..7bce772b5 100644 --- a/tagstudio/src/core/library/alchemy/enums.py +++ b/tagstudio/src/core/library/alchemy/enums.py @@ -1,5 +1,5 @@ import enum -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path @@ -50,10 +50,11 @@ class SearchMode(enum.IntEnum): OR = 1 -class ItemType(enum.Enum): - ENTRY = 0 - COLLATION = 1 - TAG_GROUP = 2 +class ItemType(enum.IntEnum): + NONE = 0 + ENTRY = 1 + COLLATION = 2 + TAG_GROUP = 3 @dataclass @@ -61,6 +62,8 @@ class FilterState: """Represent a state of the Library grid view.""" # these should remain + include_folders: set[int] = field(default_factory=set) + exclude_folders: set[int] = field(default_factory=set) page_index: int | None = None page_size: int | None = None search_mode: SearchMode = SearchMode.AND # TODO - actually implement this @@ -81,6 +84,13 @@ class FilterState: # a generic query to be parsed query: str | None = None + def toggle_folder(self, folder_id: int): + # check if any filter is active and adjust that one, as they are disjunctive + if self.include_folders: + self.include_folders ^= {folder_id} + else: + self.exclude_folders ^= {folder_id} + def __post_init__(self): # strip values automatically if query := (self.query and self.query.strip()): @@ -109,6 +119,9 @@ def __post_init__(self): self.name = self.name and self.name.strip() self.id = int(self.id) if str(self.id).isnumeric() else self.id + if self.include_folders and self.exclude_folders: + raise ValueError("Can't combine include_folders and exclude_folders.") + if self.page_index is None: self.page_index = 0 if self.page_size is None: diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 34267eab8..92f32b314 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -15,7 +15,6 @@ and_, create_engine, delete, - exists, func, or_, select, @@ -33,7 +32,6 @@ BACKUP_FOLDER_NAME, TAG_ARCHIVED, TAG_FAVORITE, - TS_FOLDER_NAME, ) from ...enums import LibraryPrefs from .db import make_tables @@ -112,48 +110,55 @@ def __getitem__(self, index: int) -> Entry: """Allow to access items via index directly on the wrapper.""" return self.items[index] + def __iter__(self): + return iter(self.items) + @dataclass class LibraryStatus: """Keep status of library opening operation.""" success: bool - library_path: Path | None = None + storage_path: Path | str | None = None message: str | None = None class Library: """Class for the Library object, and all CRUD operations made upon it.""" - library_dir: Path | None = None - storage_path: Path | str | None + storage_path: Path | str | None = None engine: Engine | None - folder: Folder | None FILENAME: str = "ts_library.sqlite" def close(self): if self.engine: self.engine.dispose() - self.library_dir = None self.storage_path = None - self.folder = None - def open_library(self, library_dir: Path, storage_path: str | None = None) -> LibraryStatus: + def open_library( + self, storage_path: Path | str, library_name: str | None = None + ) -> LibraryStatus: if storage_path == ":memory:": self.storage_path = storage_path is_new = True else: - self.verify_ts_folders(library_dir) - self.storage_path = library_dir / TS_FOLDER_NAME / self.FILENAME - is_new = not self.storage_path.exists() + self.storage_path = Path(storage_path) / self.FILENAME + if is_new := not self.storage_path.exists(): + self.storage_path.touch() connection_string = URL.create( drivername="sqlite", database=str(self.storage_path), ) - logger.info("opening library", library_dir=library_dir, connection_string=connection_string) + logger.info( + "opening library", + storage_path=storage_path, + connection_string=connection_string, + is_new=is_new, + ) + self.engine = create_engine(connection_string) with Session(self.engine) as session: make_tables(self.engine) @@ -166,21 +171,6 @@ def open_library(self, library_dir: Path, storage_path: str | None = None) -> Li # default tags may exist already session.rollback() - # dont check db version when creating new library - if not is_new: - db_version = session.scalar( - select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name) - ) - - if not db_version: - return LibraryStatus( - success=False, - message=( - "Library version mismatch.\n" - f"Found: v0, expected: v{LibraryPrefs.DB_VERSION.default}" - ), - ) - for pref in LibraryPrefs: try: session.add(Preferences(key=pref.name, value=pref.default)) @@ -205,25 +195,7 @@ def open_library(self, library_dir: Path, storage_path: str | None = None) -> Li logger.debug("ValueType already exists", field=field) session.rollback() - db_version = session.scalar( - select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name) - ) - # if the db version is different, we cant proceed - if db_version.value != LibraryPrefs.DB_VERSION.default: - logger.error( - "DB version mismatch", - db_version=db_version.value, - expected=LibraryPrefs.DB_VERSION.default, - ) - # TODO - handle migration - return LibraryStatus( - success=False, - message=( - "Library version mismatch.\n" - f"Found: v{db_version.value}, expected: v{LibraryPrefs.DB_VERSION.default}" - ), - ) - + """ # check if folder matching current path exists already self.folder = session.scalar(select(Folder).where(Folder.path == library_dir)) if not self.folder: @@ -236,10 +208,11 @@ def open_library(self, library_dir: Path, storage_path: str | None = None) -> Li session.commit() self.folder = folder + """ # everything is fine, set the library path - self.library_dir = library_dir - return LibraryStatus(success=True, library_path=library_dir) + self.storage_path = storage_path + return LibraryStatus(success=True, storage_path=storage_path) @property def default_fields(self) -> list[BaseField]: @@ -305,6 +278,34 @@ def get_entry(self, entry_id: int) -> Entry | None: make_transient(entry) return entry + def add_folder(self, path: Path | str) -> Folder | None: + if isinstance(path, str): + path = Path(path) + + logger.info("add_folder", path=path) + + with Session(self.engine) as session: + folder = Folder(path=path, uuid=str(uuid4())) + + try: + session.add(folder) + session.commit() + except IntegrityError: + session.rollback() + logger.exception("add_folder.IntegrityError") + return None + + session.refresh(folder) + session.expunge(folder) + return folder + + def get_folders(self) -> list[Folder]: + with Session(self.engine) as session: + folders = list(session.scalars(select(Folder))) + session.expunge_all() + + return folders + @property def entries_count(self) -> int: with Session(self.engine) as session: @@ -320,6 +321,7 @@ def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: stmt.outerjoin(Entry.text_fields) .outerjoin(Entry.datetime_fields) .outerjoin(Entry.tag_box_fields) + .outerjoin(Entry.folder) ) stmt = stmt.options( contains_eager(Entry.text_fields), @@ -350,19 +352,6 @@ def tags(self) -> list[Tag]: return list(tags_list) - def verify_ts_folders(self, library_dir: Path) -> None: - """Verify/create folders required by TagStudio.""" - if library_dir is None: - raise ValueError("No path set.") - - if not library_dir.exists(): - raise ValueError("Invalid library directory.") - - full_ts_path = library_dir / TS_FOLDER_NAME - if not full_ts_path.exists(): - logger.info("creating library directory", dir=full_ts_path) - full_ts_path.mkdir(parents=True, exist_ok=True) - def add_entries(self, items: list[Entry]) -> list[int]: """Add multiple Entry records to the Library.""" assert items @@ -375,10 +364,11 @@ def add_entries(self, items: list[Entry]) -> list[int]: session.commit() except IntegrityError: session.rollback() - logger.exception("IntegrityError") + logger.exception("add_entries.IntegrityError") return [] new_ids = [item.id for item in items] + session.expunge_all() return new_ids @@ -389,10 +379,10 @@ def remove_entries(self, entry_ids: list[int]) -> None: session.query(Entry).where(Entry.id.in_(entry_ids)).delete() session.commit() - def has_path_entry(self, path: Path) -> bool: + def get_path_entry(self, path: Path) -> Entry | None: """Check if item with given path is in library already.""" with Session(self.engine) as session: - return session.query(exists().where(Entry.path == path)).scalar() + return session.scalar(select(Entry).where(Entry.path == path)) def search_library( self, @@ -448,7 +438,13 @@ def search_library( elif extensions: statement = statement.where(Entry.suffix.in_(extensions)) + if search.exclude_folders: + statement = statement.where(Entry.folder_id.notin_(search.exclude_folders)) + elif search.include_folders: + statement = statement.where(Entry.folder_id.in_(search.include_folders)) + statement = statement.options( + selectinload(Entry.folder), selectinload(Entry.text_fields), selectinload(Entry.datetime_fields), selectinload(Entry.tag_box_fields) @@ -810,15 +806,15 @@ def add_field_tag( return False def save_library_backup_to_disk(self) -> Path: - assert isinstance(self.library_dir, Path) - makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True) + assert isinstance(self.storage_path, Path) + makedirs(str(self.storage_path / BACKUP_FOLDER_NAME), exist_ok=True) filename = f'ts_library_backup_{datetime.now(UTC).strftime("%Y_%m_%d_%H%M%S")}.sqlite' - target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename + target_path = self.storage_path / BACKUP_FOLDER_NAME / filename shutil.copy2( - self.library_dir / TS_FOLDER_NAME / self.FILENAME, + self.storage_path / self.FILENAME, target_path, ) diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 09b54e3e3..935e891fb 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -117,7 +117,7 @@ class Entry(Base): id: Mapped[int] = mapped_column(primary_key=True) folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id")) - folder: Mapped[Folder] = relationship("Folder") + folder: Mapped[Folder] = relationship("Folder", lazy=False) path: Mapped[Path] = mapped_column(PathType, unique=True) suffix: Mapped[str] = mapped_column() @@ -135,6 +135,10 @@ class Entry(Base): cascade="all, delete", ) + @property + def absolute_path(self) -> Path: + return self.folder.path / self.path + @property def fields(self) -> list[BaseField]: fields: list[BaseField] = [] diff --git a/tagstudio/src/core/library/json/enums.py b/tagstudio/src/core/library/json/enums.py new file mode 100644 index 000000000..32664b281 --- /dev/null +++ b/tagstudio/src/core/library/json/enums.py @@ -0,0 +1,9 @@ +import enum + +TS_FOLDER_NAME = ".TagStudio" + + +class OpenStatus(enum.IntEnum): + NOT_FOUND = 0 + SUCCESS = 1 + CORRUPTED = 2 diff --git a/tagstudio/src/core/library/json/library.py b/tagstudio/src/core/library/json/library.py index 0570c2f35..b52bf8c4a 100644 --- a/tagstudio/src/core/library/json/library.py +++ b/tagstudio/src/core/library/json/library.py @@ -20,14 +20,13 @@ from typing import cast, Generator from typing_extensions import Self +from .enums import OpenStatus, TS_FOLDER_NAME from .fields import DEFAULT_FIELDS, TEXT_FIELDS -from src.core.enums import OpenStatus from src.core.utils.str import strip_punctuation from src.core.utils.web import strip_web_protocol from src.core.constants import ( BACKUP_FOLDER_NAME, COLLAGE_FOLDER_NAME, - TS_FOLDER_NAME, VERSION, ) diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index 9611397ec..811634648 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -7,7 +7,6 @@ import json from pathlib import Path -from src.core.constants import TS_FOLDER_NAME from src.core.library import Entry, Library from src.core.library.alchemy.fields import _FieldID from src.core.utils.missing_files import logger @@ -97,7 +96,7 @@ def match_conditions(cls, lib: Library, entry_id: int) -> bool: """Match defined conditions against a file to add Entry data.""" # TODO - what even is this file format? # TODO: Make this stored somewhere better instead of temporarily in this JSON file. - cond_file = lib.library_dir / TS_FOLDER_NAME / "conditions.json" + cond_file = lib.storage_path / "conditions.json" if not cond_file.is_file(): return False diff --git a/tagstudio/src/core/utils/dupe_files.py b/tagstudio/src/core/utils/dupe_files.py index 2d0a074b6..df535c4f7 100644 --- a/tagstudio/src/core/utils/dupe_files.py +++ b/tagstudio/src/core/utils/dupe_files.py @@ -20,50 +20,52 @@ class DupeRegistry: def groups_count(self) -> int: return len(self.groups) - def refresh_dupe_files(self, results_filepath: str | Path): + def refresh_dupe_files(self, dupe_results: str | Path): """Refresh the list of duplicate files. A duplicate file is defined as an identical or near-identical file as determined by a DupeGuru results file. """ - library_dir = self.library.library_dir - if not isinstance(results_filepath, Path): - results_filepath = Path(results_filepath) + if not isinstance(dupe_results, Path): + dupe_results = Path(dupe_results) - if not results_filepath.is_file(): + if not dupe_results.is_file(): raise ValueError("invalid file path") self.groups.clear() - tree = ET.parse(results_filepath) + tree = ET.parse(dupe_results) root = tree.getroot() + folders = self.library.get_folders() + for group in root: - # print(f'-------------------- Match Group {i}---------------------') - files: list[Entry] = [] + files: dict = {} for element in group: - if element.tag == "file": - file_path = Path(element.attrib.get("path")) - - try: - path_relative = file_path.relative_to(library_dir) - except ValueError: - # The file is not in the library directory - continue - - results = self.library.search_library( - FilterState(path=path_relative), - ) - - if not results: - # file not in library - continue - - files.append(results[0]) + if element.tag != "file": + continue - if not len(files) > 1: - # only one file in the group, nothing to do + file_path = Path(element.attrib.get("path")) + for folder in folders: + if file_path.is_relative_to(folder.path): + path_relative = file_path.relative_to(folder.path) + results = self.library.search_library( + FilterState( + include_folders={folder.id}, + path=path_relative, + ), + ) + + if results: + for item in results.items: + files[item.path] = item + break + else: continue - self.groups.append(files) + if not len(files) > 1: + # only one file in the group, nothing to do + continue + + self.groups.append(list(files.values())) def merge_dupe_entries(self): """Merge the duplicate Entry items. diff --git a/tagstudio/src/core/utils/missing_files.py b/tagstudio/src/core/utils/missing_files.py index 7c54c1363..2911d7b9d 100644 --- a/tagstudio/src/core/utils/missing_files.py +++ b/tagstudio/src/core/utils/missing_files.py @@ -29,21 +29,23 @@ def refresh_missing_files(self) -> Iterator[int]: logger.info("refresh_missing_files running") self.missing_files = [] for i, entry in enumerate(self.library.get_entries()): - full_path = self.library.library_dir / entry.path + full_path = entry.absolute_path if not full_path.exists() or not full_path.is_file(): self.missing_files.append(entry) yield i def match_missing_file(self, match_item: Entry) -> list[Path]: - """Try to find missing entry files within the library directory. + """Try to find missing entry files within the library directories. Works if files were just moved to different subfolders and don't have duplicate names. """ matches = [] - for item in self.library.library_dir.glob(f"**/{match_item.path.name}"): - if item.name == match_item.path.name: # TODO - implement IGNORE_ITEMS - new_path = Path(item).relative_to(self.library.library_dir) - matches.append(new_path) + folders = self.library.get_folders() + for folder in folders: + for item in folder.path.glob(f"**/{match_item.path.name}"): + if item.name == match_item.path.name: # TODO - implement IGNORE_ITEMS + new_path = Path(item).relative_to(folder.path) + matches.append(new_path) return matches diff --git a/tagstudio/src/core/utils/refresh_dir.py b/tagstudio/src/core/utils/refresh_dir.py index 87b734ea8..83e319bc9 100644 --- a/tagstudio/src/core/utils/refresh_dir.py +++ b/tagstudio/src/core/utils/refresh_dir.py @@ -4,8 +4,9 @@ from time import time import structlog -from src.core.constants import TS_FOLDER_NAME +from src.core import constants from src.core.library import Entry, Library +from src.core.library.alchemy.models import Folder logger = structlog.get_logger(__name__) @@ -13,7 +14,8 @@ @dataclass class RefreshDirTracker: library: Library - files_not_in_library: list[Path] = field(default_factory=list) + dir_files_count: int = 0 + files_not_in_library: list[tuple[Folder, Path]] = field(default_factory=list) @property def files_count(self) -> int: @@ -24,12 +26,12 @@ def save_new_files(self) -> Iterator[int]: if not self.files_not_in_library: yield 0 - for idx, entry_path in enumerate(self.files_not_in_library): + for idx, (folder, entry_path) in enumerate(self.files_not_in_library): self.library.add_entries( [ Entry( path=entry_path, - folder=self.library.folder, + folder=folder, fields=self.library.default_fields, ) ] @@ -38,40 +40,68 @@ def save_new_files(self) -> Iterator[int]: self.files_not_in_library = [] - def refresh_dir(self, lib_path: 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.") + def refresh_dirs(self, folders: list[Folder]) -> Iterator[int]: + """Scan a directory for changes. + + - Keep track of files which are not in library. + - Remove files from library which are in ignored dirs. + """ + if isinstance(folders, Folder): + folders = [folders] start_time_total = time() - start_time_loop = time() self.files_not_in_library = [] - dir_file_count = 0 - - for path in lib_path.glob("**/*"): - str_path = str(path) - if path.is_dir(): - continue + self.dir_files_count = 0 - if "$RECYCLE.BIN" in str_path or TS_FOLDER_NAME in str_path: - continue - - dir_file_count += 1 - relative_path = path.relative_to(lib_path) - # TODO - load these in batch somehow - if not self.library.has_path_entry(relative_path): - self.files_not_in_library.append(relative_path) - - # Yield output every 1/30 of a second - if (time() - start_time_loop) > 0.034: - yield dir_file_count - start_time_loop = time() + for folder in folders: + # yield values from self._refresh_dir + yield from self._refresh_dir(folder) end_time_total = time() logger.info( "Directory scan time", - path=lib_path, duration=(end_time_total - start_time_total), - new_files_count=dir_file_count, + new_files_count=self.dir_files_count, ) + + def _refresh_dir(self, folder: Folder) -> Iterator[int]: + start_time_loop = time() + folder_path = folder.path + for root, _, files in folder_path.walk(): + if "$RECYCLE.BIN" in str(root).upper(): + continue + + # - if directory contains file `.ts_noindex` then skip the directory + if constants.TS_FOLDER_NOINDEX in files: + logger.info("TS Ignore File found, skipping", directory=root) + # however check if the ignored files aren't in the library; if so, remove them + entries_to_remove = [] + for file in files: + file_path = root / file + entry_path = file_path.relative_to(folder_path) + if entry := self.library.get_path_entry(entry_path): + entries_to_remove.append(entry.id) + + # Yield output every 1/30 of a second + if (time() - start_time_loop) > 0.034: + # yield but dont increase the count + yield self.dir_files_count + start_time_loop = time() + + self.library.remove_entries(entries_to_remove) + continue + + for file in files: + path = root / file + self.dir_files_count += 1 + + relative_path = path.relative_to(folder_path) + # TODO - load these in batch somehow + if not self.library.get_path_entry(relative_path): + self.files_not_in_library.append((folder, relative_path)) + + # Yield output every 1/30 of a second + if (time() - start_time_loop) > 0.034: + yield self.dir_files_count + start_time_loop = time() diff --git a/tagstudio/src/qt/enums.py b/tagstudio/src/qt/enums.py new file mode 100644 index 000000000..a6557ac26 --- /dev/null +++ b/tagstudio/src/qt/enums.py @@ -0,0 +1,7 @@ +import enum + + +class WindowContent(enum.Enum): + LANDING_PAGE = 0 + LIBRARY_EMPTY = 1 + LIBRARY_CONTENT = 2 diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index d3274c7ee..c2e3f2d56 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -1,39 +1,33 @@ -# -*- coding: utf-8 -*- - -################################################################################ -# Form generated from reading UI file 'home.ui' -## -# Created by: Qt User Interface Compiler version 6.5.1 -## -# WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import typing -from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt) + +import structlog +from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect, QSize, Qt) from PySide6.QtGui import QFont from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout, QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow, QPushButton, QScrollArea, QSizePolicy, QStatusBar, QWidget, QSplitter, QCheckBox, - QSpacerItem) + QSpacerItem, QLabel) + +from src.qt.enums import WindowContent from src.qt.pagination import Pagination from src.qt.widgets.landing import LandingWidget +from src.qt.widgets.library_nodirs import LibraryNoFolders # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) class Ui_MainWindow(QMainWindow): - + def __init__(self, driver: "QtDriver", parent=None) -> None: super().__init__(parent) self.driver: "QtDriver" = driver @@ -52,13 +46,12 @@ def __init__(self, driver: "QtDriver", parent=None) -> None: # # self.setStyleSheet( # # 'background:#EE000000;' # # ) - def setupUi(self, MainWindow): if not MainWindow.objectName(): MainWindow.setObjectName(u"MainWindow") MainWindow.resize(1300, 720) - + self.centralwidget = QWidget(MainWindow) self.centralwidget.setObjectName(u"centralwidget") self.gridLayout = QGridLayout(self.centralwidget) @@ -69,7 +62,7 @@ def setupUi(self, MainWindow): # ComboBox group for search type and thumbnail size self.horizontalLayout_3 = QHBoxLayout() self.horizontalLayout_3.setObjectName("horizontalLayout_3") - + # left side spacer spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) self.horizontalLayout_3.addItem(spacerItem) @@ -81,7 +74,7 @@ def setupUi(self, MainWindow): self.comboBox_2.addItem("") self.comboBox_2.addItem("") self.horizontalLayout_3.addWidget(self.comboBox_2) - + # Thumbnail Size placeholder self.thumb_size_combobox = QComboBox(self.centralwidget) self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox") @@ -120,10 +113,15 @@ def setupUi(self, MainWindow): self.gridLayout_2.setContentsMargins(0, 0, 0, 8) self.scrollArea.setWidget(self.scrollAreaWidgetContents) self.frame_layout.addWidget(self.scrollArea) - + self.landing_widget: LandingWidget = LandingWidget(self.driver, self.devicePixelRatio()) self.frame_layout.addWidget(self.landing_widget) + # shown in case library has no folder + # widget with a label and a button to create a folder + self.lib_nofolders = LibraryNoFolders() + self.frame_layout.addWidget(self.lib_nofolders) + self.pagination = Pagination() self.frame_layout.addWidget(self.pagination) @@ -192,6 +190,7 @@ def setupUi(self, MainWindow): self.retranslateUi(MainWindow) QMetaObject.connectSlotsByName(MainWindow) + # setupUi def retranslateUi(self, MainWindow): @@ -202,13 +201,13 @@ def retranslateUi(self, MainWindow): QCoreApplication.translate("MainWindow", u"<", None)) self.forwardButton.setText( QCoreApplication.translate("MainWindow", u">", None)) - + # Search field self.searchField.setPlaceholderText( QCoreApplication.translate("MainWindow", u"Search Entries", None)) self.searchButton.setText( QCoreApplication.translate("MainWindow", u"Search", None)) - + # Search type selector self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)")) self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)")) @@ -217,6 +216,7 @@ def retranslateUi(self, MainWindow): # Thumbnail size selector self.thumb_size_combobox.setPlaceholderText( QCoreApplication.translate("MainWindow", u"Thumbnail Size", None)) + # retranslateUi def moveEvent(self, event) -> None: @@ -227,13 +227,19 @@ def resizeEvent(self, event) -> None: # time.sleep(0.02) # sleep for 20ms pass - def toggle_landing_page(self, enabled: bool): - if enabled: + def set_main_content(self, content: WindowContent): + logger.info("set_main_content", content=content) + if content == WindowContent.LANDING_PAGE: self.scrollArea.setHidden(True) self.landing_widget.setHidden(False) self.landing_widget.animate_logo_in() - else: + self.lib_nofolders.setHidden(True) + elif content == WindowContent.LIBRARY_EMPTY: + self.scrollArea.setHidden(True) + self.landing_widget.setHidden(True) + self.lib_nofolders.setHidden(False) + elif content == WindowContent.LIBRARY_CONTENT: self.landing_widget.setHidden(True) self.landing_widget.set_status_label("") self.scrollArea.setHidden(False) - \ No newline at end of file + self.lib_nofolders.setHidden(True) diff --git a/tagstudio/src/qt/modals/fix_dupes.py b/tagstudio/src/qt/modals/fix_dupes.py index fccdb5bcb..3faac558f 100644 --- a/tagstudio/src/qt/modals/fix_dupes.py +++ b/tagstudio/src/qt/modals/fix_dupes.py @@ -108,7 +108,7 @@ def __init__(self, library: "Library", driver: "QtDriver"): self.set_dupe_count(-1) def select_file(self): - qfd = QFileDialog(self, "Open DupeGuru Results File", str(self.lib.library_dir)) + qfd = QFileDialog(self, "Open DupeGuru Results File", str(self.lib.storage_path)) qfd.setFileMode(QFileDialog.FileMode.ExistingFile) qfd.setNameFilter("DupeGuru Files (*.dupeguru)") if qfd.exec_(): diff --git a/tagstudio/src/qt/modals/library_name.py b/tagstudio/src/qt/modals/library_name.py new file mode 100644 index 000000000..33f2461ae --- /dev/null +++ b/tagstudio/src/qt/modals/library_name.py @@ -0,0 +1,82 @@ +from pathlib import Path + +from PySide6.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) + + +class LABELS: + NO_FOLDER = "No folder selected." + CHOOSE_NAME = "Choose Library Name" + SELECT_FOLDER = "Select Metadata Folder" + + +class LibraryNameDialog(QDialog): + chosen_path: Path | None + + def __init__(self): + super().__init__() + + self.setFixedWidth(400) + + self.chosen_path = None + + self.setWindowTitle(LABELS.CHOOSE_NAME) + + layout = QVBoxLayout() + + label = QLabel(LABELS.CHOOSE_NAME) + layout.addWidget(label) + + self.library_name_input = QLineEdit(self) + self.library_name_input.textChanged.connect(self.update_storage_label) + layout.addWidget(self.library_name_input) + + self.storage_explanation = QLabel("Select a folder where library metadata will be stored.") + layout.addWidget(self.storage_explanation) + + choose_directory_button = QPushButton(LABELS.SELECT_FOLDER, self) + choose_directory_button.clicked.connect(self.choose_directory) + layout.addWidget(choose_directory_button) + + self.directory_label = QLabel(LABELS.NO_FOLDER) + layout.addWidget(self.directory_label) + + cancel_button = QPushButton("Cancel", self) + cancel_button.clicked.connect(self.reject) + + ok_button = QPushButton("OK", self) + ok_button.clicked.connect(self.accept) + + button_layout = QHBoxLayout() + button_layout.addWidget(cancel_button) + button_layout.addWidget(ok_button) + + layout.addLayout(button_layout) + self.setLayout(layout) + + def get_storage_path(self) -> Path: + # return self.chosen_path / f"TS {self.get_library_name()}" + return self.chosen_path / self.get_library_name() + + def get_library_name(self): + return self.library_name_input.text().strip() + + def choose_directory(self): + """Open a dialog to choose a directory and display the selected path.""" + directory = QFileDialog.getExistingDirectory(self, LABELS.SELECT_FOLDER) + if directory: + self.chosen_path = Path(directory) + self.update_storage_label() + + def update_storage_label(self): + if self.chosen_path: + self.directory_label.setText(f"Metadata Storage Folder: {self.get_storage_path()}") + else: + self.directory_label.setText(LABELS.NO_FOLDER) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index e80aa84e6..df597621f 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -59,6 +59,7 @@ from src.core.constants import ( TAG_ARCHIVED, TAG_FAVORITE, + TS_FOLDER_NOINDEX, VERSION, VERSION_BRANCH, ) @@ -72,9 +73,11 @@ ) from src.core.library.alchemy.fields import _FieldID from src.core.library.alchemy.library import LibraryStatus +from src.core.library.alchemy.models import Folder from src.core.ts_core import TagStudioCore from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol +from src.qt.enums import WindowContent from src.qt.flowlayout import FlowLayout from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.helpers.function_iterator import FunctionIterator @@ -84,9 +87,11 @@ from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal from src.qt.modals.folders_to_tags import FoldersToTagsModal +from src.qt.modals.library_name import LibraryNameDialog from src.qt.modals.tag_database import TagDatabasePanel from src.qt.resource_manager import ResourceManager from src.qt.widgets.item_thumb import BadgeType, ItemThumb +from src.qt.widgets.landing import KBShortcut, get_kb_shortcut from src.qt.widgets.panel import PanelModal from src.qt.widgets.preview_panel import PreviewPanel from src.qt.widgets.progress import ProgressWidget @@ -184,14 +189,23 @@ def init_workers(self): self.thumb_threads.append(thread) thread.start() + def create_library_from_dialog(self): + newlib_window = LibraryNameDialog() + if newlib_window.exec(): + storage_dir = newlib_window.get_storage_path() + self.create_library(storage_dir, newlib_window.get_library_name()) + def open_library_from_dialog(self): + user_home = str(Path.home()) + dir = QFileDialog.getExistingDirectory( None, - "Open/Create Library", - "/", + "Open Library", + user_home, QFileDialog.Option.ShowDirsOnly, ) - if dir not in (None, ""): + + if dir: self.open_library(Path(dir)) def signal_handler(self, sig, frame): @@ -274,15 +288,26 @@ def start(self) -> None: # file_menu.addAction(QAction('&New Library', menu_bar)) # file_menu.addAction(QAction('&Open Library', menu_bar)) - open_library_action = QAction("&Open/Create Library", menu_bar) - open_library_action.triggered.connect(lambda: self.open_library_from_dialog()) + create_library_action = QAction("&New Library", menu_bar) + create_library_action.triggered.connect(self.create_library_from_dialog) + create_library_action.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_N, + ) + ) + create_library_action.setToolTip(get_kb_shortcut(KBShortcut.CREATE_LIB)) + file_menu.addAction(create_library_action) + + open_library_action = QAction("&Open Library", menu_bar) + open_library_action.triggered.connect(self.open_library_from_dialog) open_library_action.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), QtCore.Qt.Key.Key_O, ) ) - open_library_action.setToolTip("Ctrl+O") + open_library_action.setToolTip(get_kb_shortcut(KBShortcut.OPEN_LIB)) file_menu.addAction(open_library_action) save_library_backup_action = QAction("&Save Library Backup", menu_bar) @@ -408,10 +433,23 @@ def create_dupe_files_modal(): ) macros_menu.addAction(self.autofill_action) + show_folders = QAction("Show Library Dirs", menu_bar) + show_folders.setCheckable(True) + show_folders.setChecked( + bool(self.settings.value(SettingItems.WINDOW_SHOW_DIRS, defaultValue=True, type=bool)) + ) + show_folders.triggered.connect( + lambda checked: ( + self.settings.setValue(SettingItems.WINDOW_SHOW_DIRS, checked), + self.toggle_lib_dirs(checked), + ) + ) + window_menu.addAction(show_folders) + show_libs_list_action = QAction("Show Recent Libraries", menu_bar) show_libs_list_action.setCheckable(True) show_libs_list_action.setChecked( - bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool)) + bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=False, type=bool)) ) show_libs_list_action.triggered.connect( lambda checked: ( @@ -467,13 +505,13 @@ def create_folders_tags_modal(): path_result = self.evaluate_path(self.args.open) # check status of library path evaluating - if path_result.success and path_result.library_path: + if path_result.success and path_result.storage_path: self.splash.showMessage( - f'Opening Library "{path_result.library_path}"...', + f'Opening Library "{path_result.storage_path}"...', int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter), QColor("#9782ff"), ) - self.open_library(path_result.library_path) + self.open_library(path_result.storage_path) app.exec() self.shutdown() @@ -536,13 +574,20 @@ def init_library_window(self): # or implementing some clever loading tricks. self.main_window.show() self.main_window.activateWindow() - self.main_window.toggle_landing_page(enabled=True) + self.main_window.set_main_content(WindowContent.LANDING_PAGE) self.main_window.pagination.index.connect(lambda i: self.page_move(page_id=i)) self.splash.finish(self.main_window) self.preview_panel.update_widgets() + def toggle_lib_dirs(self, value: bool): + if value: + self.preview_panel.lib_dirs_container.show() + else: + self.preview_panel.lib_dirs_container.hide() + self.preview_panel.update() + def toggle_libs_list(self, value: bool): if value: self.preview_panel.libs_flow_container.show() @@ -552,7 +597,7 @@ def toggle_libs_list(self, value: bool): def callback_library_needed_check(self, func): """Check if loaded library has valid path before executing the button function.""" - if self.lib.library_dir: + if self.lib.storage_path: func() def handle_sigterm(self): @@ -573,7 +618,7 @@ def shutdown(self): QApplication.quit() def close_library(self, is_shutdown: bool = False): - if not self.lib.library_dir: + if not self.lib.storage_path: logger.info("No Library to Close") return @@ -581,7 +626,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.statusbar.showMessage("Closing Library...") start_time = time.time() - self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir)) + self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.storage_path)) self.settings.sync() self.lib.close() @@ -594,10 +639,11 @@ def close_library(self, is_shutdown: bool = False): self.selected = [] self.frame_content = [] - [x.set_mode(None) for x in self.item_thumbs] + for thumb in self.item_thumbs: + thumb.set_mode(ItemType.NONE) self.preview_panel.update_widgets() - self.main_window.toggle_landing_page(enabled=True) + self.main_window.set_main_content(WindowContent.LANDING_PAGE) self.main_window.pagination.setHidden(True) @@ -668,7 +714,7 @@ def show_file_extension_modal(self): self.modal.saved.connect(lambda: (panel.save(), self.filter_items())) self.modal.show() - def add_new_files_callback(self): + def add_new_files_callback(self, folders: list[Folder] | None = None): """Run when user initiates adding new files to the Library.""" tracker = RefreshDirTracker(self.lib) @@ -681,7 +727,10 @@ def add_new_files_callback(self): ) pw.show() - iterator = FunctionIterator(lambda: tracker.refresh_dir(self.lib.library_dir)) + if not folders: + folders = self.lib.get_folders() + + iterator = FunctionIterator(lambda: tracker.refresh_dirs(folders)) iterator.value.connect( lambda x: ( pw.update_progress(x + 1), @@ -780,7 +829,6 @@ def run_macros(self, name: MacroID, grid_idx: list[int]): def run_macro(self, name: MacroID, grid_idx: int): """Run a specific Macro on an Entry given a Macro name.""" entry = self.frame_content[grid_idx] - ful_path = self.lib.library_dir / entry.path source = entry.path.parts[0] logger.info( @@ -798,7 +846,7 @@ def run_macro(self, name: MacroID, grid_idx: int): self.run_macro(macro_id, entry.id) elif name == MacroID.SIDECAR: - parsed_items = TagStudioCore.get_gdl_sidecar(ful_path, source) + parsed_items = TagStudioCore.get_gdl_sidecar(entry.absolute_path, source) for field_id, value in parsed_items.items(): self.lib.add_entry_field_type( entry.id, @@ -890,7 +938,7 @@ def _init_thumb_grid(self): # TODO - init after library is loaded, it can have different page_size for grid_idx in range(self.filter.page_size): item_thumb = ItemThumb( - None, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx + ItemType.NONE, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx ) layout.addWidget(item_thumb) self.item_thumbs.append(item_thumb) @@ -975,7 +1023,6 @@ def update_thumbs(self): item_thumb.hide() continue - filepath = self.lib.library_dir / entry.path item_thumb = self.item_thumbs[idx] item_thumb.set_mode(ItemType.ENTRY) item_thumb.set_item_id(entry) @@ -1015,7 +1062,7 @@ def update_thumbs(self): self.thumb_job_queue.put( ( item_thumb.renderer.render, - (time.time(), filepath, base_size, ratio, False, True), + (time.time(), entry.absolute_path, base_size, ratio, False, True), ) ) @@ -1108,14 +1155,29 @@ def update_libs_list(self, path: Path | str): self.settings.endGroup() self.settings.sync() - def open_library(self, path: Path) -> LibraryStatus: + def create_library(self, storage_dir: Path, library_name: str) -> LibraryStatus: + """Verify/create folders required by TagStudio.""" + if storage_dir is None: + raise ValueError("No path set.") + + if not storage_dir.exists(): + storage_dir.mkdir() + (storage_dir / TS_FOLDER_NOINDEX).touch() + + lib_status = self.open_library(storage_dir) + if lib_status.success: + self.lib.set_prefs(LibraryPrefs.LIBRARY_NAME, library_name) + + return lib_status + + def open_library(self, storage_path: Path | str) -> LibraryStatus: """Open a TagStudio library.""" - open_message: str = f'Opening Library "{str(path)}"...' + open_message: str = f'Opening Library "{str(storage_path)}"...' self.main_window.landing_widget.set_status_label(open_message) self.main_window.statusbar.showMessage(open_message, 3) self.main_window.repaint() - open_status = self.lib.open_library(path) + open_status = self.lib.open_library(storage_path) if not open_status.success: self.show_error_message(open_status.message or "Error opening library.") return open_status @@ -1124,18 +1186,22 @@ def open_library(self, path: Path) -> LibraryStatus: self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE) - # TODO - make this call optional - self.add_new_files_callback() - - self.update_libs_list(path) - title_text = f"{self.base_title} - Library '{self.lib.library_dir}'" + self.update_libs_list(storage_path) + title_text = f"{self.base_title} - Library '{self.lib.prefs(LibraryPrefs.LIBRARY_NAME)}'" self.main_window.setWindowTitle(title_text) self.selected.clear() self.preview_panel.update_widgets() + # TODO - make this call optional + # self.add_new_files_callback() + # page (re)rendering, extract eventually self.filter_items() - self.main_window.toggle_landing_page(enabled=False) + if not self.lib.get_folders(): + self.main_window.set_main_content(WindowContent.LIBRARY_EMPTY) + else: + self.main_window.set_main_content(WindowContent.LIBRARY_CONTENT) + return open_status diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index e15cdd52f..b58e7e9b5 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -38,7 +38,7 @@ def render( keep_aspect, ): entry = self.lib.get_entry(entry_id) - filepath = self.lib.library_dir / entry.path + filepath = entry.absolute_path color: str = "" try: @@ -79,7 +79,7 @@ def render( ext: str = filepath.suffix.lower() if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES): try: - with Image.open(str(self.lib.library_dir / entry.path)) as pic: + with Image.open(str(entry.absolute_path)) as pic: if keep_aspect: pic.thumbnail(size) else: diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index da82a19d5..4ccea112d 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -295,8 +295,8 @@ def is_favorite(self) -> bool: def is_archived(self): return self.badge_active[BadgeType.ARCHIVED] - def set_mode(self, mode: ItemType | None) -> None: - if mode is None: + def set_mode(self, mode: ItemType) -> None: + if mode == ItemType.NONE: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True) self.unsetCursor() self.thumb_button.setHidden(True) @@ -401,14 +401,13 @@ def refresh_badge(self, entry: Entry | None = None): self.assign_badge(BadgeType.FAVORITE, entry.is_favorited) def set_item_id(self, entry: Entry): - filepath = self.lib.library_dir / entry.path - self.opener.set_filepath(filepath) + self.opener.set_filepath(entry.absolute_path) self.item_id = entry.id def assign_badge(self, badge_type: BadgeType, value: bool) -> None: mode = self.mode - # blank mode to avoid recursive badge updates - self.mode = None + # none mode to avoid recursive badge updates + self.mode = ItemType.NONE badge = self.badges[badge_type] self.badge_active[badge_type] = value if badge.isChecked() != value: @@ -433,7 +432,7 @@ def leaveEvent(self, event: QEvent) -> None: # noqa: N802 @badge_update_lock def on_badge_check(self, badge_type: BadgeType): - if self.mode is None: + if self.mode == ItemType.NONE: return toggle_value = self.badges[badge_type].isChecked() diff --git a/tagstudio/src/qt/widgets/landing.py b/tagstudio/src/qt/widgets/landing.py index c1a5a7d8c..a25c34630 100644 --- a/tagstudio/src/qt/widgets/landing.py +++ b/tagstudio/src/qt/widgets/landing.py @@ -3,11 +3,12 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import sys import typing +from enum import IntEnum from pathlib import Path +import structlog from PIL import Image, ImageQt from PySide6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, Qt from PySide6.QtGui import QPixmap @@ -19,7 +20,27 @@ if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) + + +class KBShortcut(IntEnum): + OPEN_LIB = 0 + CREATE_LIB = 1 + + +def get_kb_shortcut(shortcut: KBShortcut) -> str: + match (shortcut, sys.platform): + case (KBShortcut.OPEN_LIB, "darwin"): + return "(⌘+O)" + case (KBShortcut.OPEN_LIB, _): + return "(Ctrl+O)" + case (KBShortcut.CREATE_LIB, "darwin"): + return "(⌘+N)" + case (KBShortcut.CREATE_LIB, _): + return "(Ctrl+N)" + + logger.error("unknown keyboard shortcut", platform=sys.platform, shortcut=shortcut) + return "" class LandingWidget(QWidget): @@ -55,14 +76,14 @@ def __init__(self, driver: "QtDriver", pixel_ratio: float): self.logo_special_anim.setEasingCurve(QEasingCurve.Type.OutCubic) self.logo_special_anim.setDuration(500) - # Create "Open/Create Library" button ---------------------------------- - if sys.platform == "darwin": - open_shortcut_text = "(⌘+O)" - else: - open_shortcut_text = "(Ctrl+O)" + self.create_button: QPushButton = QPushButton() + self.create_button.setMinimumWidth(200) + self.create_button.setText(f"Create Library {get_kb_shortcut(KBShortcut.CREATE_LIB)}") + self.create_button.clicked.connect(self.driver.create_library_from_dialog) + self.open_button: QPushButton = QPushButton() self.open_button.setMinimumWidth(200) - self.open_button.setText(f"Open/Create Library {open_shortcut_text}") + self.open_button.setText(f"Open Library {get_kb_shortcut(KBShortcut.OPEN_LIB)}") self.open_button.clicked.connect(self.driver.open_library_from_dialog) # Create status label -------------------------------------------------- @@ -78,6 +99,7 @@ def __init__(self, driver: "QtDriver", pixel_ratio: float): # Add widgets to layout ------------------------------------------------ self.landing_layout.addWidget(self.logo_label) + self.landing_layout.addWidget(self.create_button, alignment=Qt.AlignmentFlag.AlignCenter) self.landing_layout.addWidget(self.open_button, alignment=Qt.AlignmentFlag.AlignCenter) self.landing_layout.addWidget(self.status_label, alignment=Qt.AlignmentFlag.AlignCenter) diff --git a/tagstudio/src/qt/widgets/library_dirs.py b/tagstudio/src/qt/widgets/library_dirs.py new file mode 100644 index 000000000..ead04372f --- /dev/null +++ b/tagstudio/src/qt/widgets/library_dirs.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, + QFileDialog, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from src.core.library import Library +from src.core.library.alchemy.models import Folder +from src.qt.enums import WindowContent + +if TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + + +class LibraryDirsWidget(QWidget): + library_dirs: list[Folder] + + def __init__(self, library: Library, driver: QtDriver): + super().__init__() + + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(0, 0, 0, 0) + + self.setSizePolicy( + QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Maximum, + ) + + self.driver = driver + self.library = library + + # label and button + self.create_panel() + + # actual library dirs + self.items_layout = QVBoxLayout() + self.root_layout.addLayout(self.items_layout) + + self.library_dirs = [] + # check if library is open + self.refresh() + + def refresh(self): + if not self.library.storage_path: + return + + self.driver.main_window.set_main_content(WindowContent.LIBRARY_CONTENT) + + library_dirs = self.library.get_folders() + if len(library_dirs) == len(self.library_dirs): + # most likely no reason to refresh + return + + self.library_dirs = library_dirs + self.fill_dirs(self.library_dirs) + + def create_panel(self): + label = QLabel("Library Folders") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + row_layout = QHBoxLayout() + row_layout.addWidget(label) + self.root_layout.addLayout(row_layout) + + # add a button which will open a library folder dialog + button = QPushButton("Add Folder") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect(self.add_folder) + self.root_layout.addWidget(button) + + def add_folder(self): + """Open QT dialog to select a folder to add into library.""" + if not self.library.storage_path: + # no library open, dont do anything + return + + directory = QFileDialog.getExistingDirectory(self, "Select Directory") + if directory and (folder := self.library.add_folder(Path(directory))): + self.driver.add_new_files_callback([folder]) + self.driver.filter_items() + self.refresh() + + def fill_dirs(self, folders: list[Folder]) -> None: + def clear_layout(layout_item: QVBoxLayout): + for i in reversed(range(layout_item.count())): + child = layout_item.itemAt(i) + if child.widget() is not None: + child.widget().deleteLater() + elif child.layout() is not None: + clear_layout(child.layout()) # type: ignore + # remove any potential previous items + + clear_layout(self.items_layout) + + for folder in folders: + self.create_item(folder) + + def create_item(self, folder: Folder): + def toggle_folder(): + self.driver.filter.toggle_folder(folder.id) + self.driver.filter_items() + + button_toggle = QCheckBox() + button_toggle.setCursor(Qt.CursorShape.PointingHandCursor) + button_toggle.setFixedWidth(30) + # TODO - figure out which one to check + button_toggle.setChecked(True) # item.id not in self.driver.filter.exclude_folders) + + button_toggle.clicked.connect(toggle_folder) + + folder_label = QLabel(folder.path.name) + + row_layout = QHBoxLayout() + row_layout.addWidget(button_toggle) + row_layout.addWidget(folder_label) + + self.items_layout.addLayout(row_layout) diff --git a/tagstudio/src/qt/widgets/library_nodirs.py b/tagstudio/src/qt/widgets/library_nodirs.py new file mode 100644 index 000000000..6a64687bf --- /dev/null +++ b/tagstudio/src/qt/widgets/library_nodirs.py @@ -0,0 +1,16 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + + +class LibraryNoFolders(QWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.lib_nofolders_layout = QVBoxLayout(self) + self.lib_nofolders_layout.setContentsMargins(0, 0, 0, 0) + self.lib_nofolders_layout.setSpacing(0) + self.lib_nofolders_label = QLabel(self) + self.lib_nofolders_label.setObjectName("lib_nofolders_label") + self.lib_nofolders_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.lib_nofolders_label.setText("No folders found in library.") + self.lib_nofolders_layout.addWidget(self.lib_nofolders_label) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 90a262651..b6490cf1a 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -4,7 +4,6 @@ import io import os -import platform import sys import time import typing @@ -32,9 +31,6 @@ QVBoxLayout, QWidget, ) -from src.core.constants import ( - TS_FOLDER_NAME, -) from src.core.enums import SettingItems, Theme from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import ( @@ -54,6 +50,7 @@ from src.qt.modals.add_field import AddFieldModal from src.qt.platform_strings import PlatformStrings from src.qt.widgets.fields import FieldContainer +from src.qt.widgets.library_dirs import LibraryDirsWidget from src.qt.widgets.panel import PanelModal from src.qt.widgets.tag_box import TagBoxWidget from src.qt.widgets.text import TextWidget @@ -237,6 +234,8 @@ def __init__(self, library: Library, driver: "QtDriver"): info_layout.addWidget(self.dimensions_label) info_layout.addWidget(scroll_area) + self.lib_dirs_container = LibraryDirsWidget(library, driver) + # keep list of rendered libraries to avoid needless re-rendering self.render_libs: set = set() self.libs_layout = QVBoxLayout() @@ -246,13 +245,13 @@ def __init__(self, library: Library, driver: "QtDriver"): self.libs_flow_container.setObjectName("librariesList") self.libs_flow_container.setLayout(self.libs_layout) self.libs_flow_container.setSizePolicy( - QSizePolicy.Preferred, # type: ignore - QSizePolicy.Maximum, # type: ignore + QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Maximum, ) # set initial visibility based on settings if not self.driver.settings.value( - SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool + SettingItems.WINDOW_SHOW_LIBS, defaultValue=False, type=bool ): self.libs_flow_container.hide() @@ -270,6 +269,7 @@ def __init__(self, library: Library, driver: "QtDriver"): splitter.addWidget(self.image_container) splitter.addWidget(info_section) + splitter.addWidget(self.lib_dirs_container) splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) @@ -368,7 +368,7 @@ def set_button_style( button.setObjectName(f"path{item_key}") lib = Path(full_val) - if not lib.exists() or not (lib / TS_FOLDER_NAME).exists(): + if not lib.exists(): button.setDisabled(True) button.setToolTip("Location is missing") @@ -476,12 +476,12 @@ def add_field_to_selected(self, field_list: list): def update_date_label(self, filepath: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" if filepath and filepath.is_file(): - created: dt = None - if platform.system() == "Windows" or platform.system() == "Darwin": - created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined] + file_stats = filepath.stat() + if st_birthtime := getattr(file_stats, "st_birthtime", None): + created = dt.fromtimestamp(st_birthtime) else: - created = dt.fromtimestamp(filepath.stat().st_ctime) - modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) + created = dt.fromtimestamp(file_stats.st_ctime) + modified: dt = dt.fromtimestamp(file_stats.st_mtime) self.date_created_label.setText( f"Date Created: {dt.strftime(created, "%a, %x, %X")}" ) @@ -509,6 +509,8 @@ def update_widgets(self) -> bool: # update list of libraries self.fill_libs_widget(self.libs_layout) + self.lib_dirs_container.refresh() + if not self.driver.selected: if self.selected or not self.initialized: self.file_label.setText("No Items Selected") @@ -572,7 +574,7 @@ def update_widgets(self) -> bool: # If a new selection is made, update the thumbnail and filepath. if not self.selected or self.selected != self.driver.selected: - filepath = self.lib.library_dir / item.path + filepath = item.absolute_path self.file_label.set_file_path(filepath) ratio = self.devicePixelRatio() self.thumb_renderer.render( diff --git a/tagstudio/tag_studio.py b/tagstudio/tag_studio.py index ceceb077d..ef0f0bce5 100755 --- a/tagstudio/tag_studio.py +++ b/tagstudio/tag_studio.py @@ -63,7 +63,7 @@ def main(): # Run the chosen frontend driver. try: driver.start() - except Exception: + except BaseException: traceback.print_exc() logging.info(f"\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...") input() diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index efcc2c66b..fb7d9a131 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -24,15 +24,15 @@ def cwd(): @pytest.fixture def library(request): # when no param is passed, use the default - library_path = "/dev/null/" + folder_path = "/dev/null/" if hasattr(request, "param"): if isinstance(request.param, TemporaryDirectory): - library_path = request.param.name + folder_path = request.param.name else: - library_path = request.param + folder_path = request.param lib = Library() - status = lib.open_library(pathlib.Path(library_path), ":memory:") + status = lib.open_library(":memory:", folder_path) assert status.success tag = Tag( @@ -52,9 +52,11 @@ def library(request): subtags={subtag}, ) + folder = lib.add_folder(folder_path) + # default item with deterministic name entry = Entry( - folder=lib.folder, + folder=folder, path=pathlib.Path("foo.txt"), fields=lib.default_fields, ) @@ -68,7 +70,7 @@ def library(request): ] entry2 = Entry( - folder=lib.folder, + folder=folder, path=pathlib.Path("one/two/bar.md"), fields=lib.default_fields, ) diff --git a/tagstudio/tests/macros/test_dupe_entries.py b/tagstudio/tests/macros/test_dupe_entries.py index 711391988..12f54dcf4 100644 --- a/tagstudio/tests/macros/test_dupe_entries.py +++ b/tagstudio/tests/macros/test_dupe_entries.py @@ -7,15 +7,17 @@ def test_refresh_dupe_files(library): - library.library_dir = "/tmp/" + folder = library.add_folder(pathlib.Path("/tmp/")) + assert folder.path == pathlib.Path("/tmp/") + entry = Entry( - folder=library.folder, + folder=folder, path=pathlib.Path("bar/foo.txt"), fields=library.default_fields, ) entry2 = Entry( - folder=library.folder, + folder=folder, path=pathlib.Path("foo/foo.txt"), fields=library.default_fields, ) @@ -31,6 +33,5 @@ def test_refresh_dupe_files(library): paths = [entry.path for entry in registry.groups[0]] assert paths == [ pathlib.Path("bar/foo.txt"), - pathlib.Path("foo.txt"), pathlib.Path("foo/foo.txt"), - ] + ], f"obtained paths: {paths}" diff --git a/tagstudio/tests/macros/test_missing_files.py b/tagstudio/tests/macros/test_missing_files.py index e90c00777..00f700dda 100644 --- a/tagstudio/tests/macros/test_missing_files.py +++ b/tagstudio/tests/macros/test_missing_files.py @@ -14,7 +14,8 @@ def test_refresh_missing_files(library: Library): registry = MissingRegistry(library=library) # touch the file `one/two/bar.md` but in wrong location to simulate a moved file - (library.library_dir / "bar.md").touch() + folder = library.get_folders()[0] + (folder.path / "bar.md").touch() # no files actually exist, so it should return all entries assert list(registry.refresh_missing_files()) == [0, 1] diff --git a/tagstudio/tests/macros/test_refresh_dir.py b/tagstudio/tests/macros/test_refresh_dir.py index 4655d3995..e0d91faa3 100644 --- a/tagstudio/tests/macros/test_refresh_dir.py +++ b/tagstudio/tests/macros/test_refresh_dir.py @@ -3,6 +3,7 @@ import pytest from src.core.enums import LibraryPrefs +from src.core.library import Entry from src.core.utils.refresh_dir import RefreshDirTracker CWD = pathlib.Path(__file__).parent @@ -13,12 +14,54 @@ def test_refresh_new_files(library, exclude_mode): # Given library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, exclude_mode) - library.set_prefs(LibraryPrefs.EXTENSION_LIST, [".md"]) + library.set_prefs(LibraryPrefs.EXTENSION_LIST, ["md"]) registry = RefreshDirTracker(library=library) - (library.library_dir / "FOO.MD").touch() + + folder = library.get_folders()[0] + (folder.path / "FOO.MD").touch() + + # When + list(registry.refresh_dirs(folder)) + + # Then + assert registry.files_not_in_library == [(folder, pathlib.Path("FOO.MD"))] + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_refresh_removes_noindex_content(library): + # Given + registry = RefreshDirTracker(library=library) + + folder = library.get_folders()[0] + + # create subdirectory with .ts_noindex file in it + (folder.path / "subdir").mkdir() + (folder.path / "subdir" / ".ts_noindex").touch() + + # add entry into library + entry = Entry( + path=pathlib.Path("subdir/FOO.MD"), + folder=library.get_folders()[0], + fields=library.default_fields, + ) + + # create its file in noindex directory + assert entry.folder + assert entry.folder.path + entry.absolute_path.touch() + library.add_entries([entry]) + + # create another file in the same directory + (folder.path / "subdir" / "test.txt").touch() + + # add non-ignored entry into library + (folder.path / "root.txt").touch() # When - assert not list(registry.refresh_dir(library.library_dir)) + list(registry.refresh_dirs(folder)) # Then - assert registry.files_not_in_library == [pathlib.Path("FOO.MD")] + # file in noindex folder should be removed + assert not library.get_path_entry(entry.path) + # file in index folder should be registered + assert registry.files_not_in_library == [(folder, pathlib.Path("root.txt"))] diff --git a/tagstudio/tests/macros/test_sidecar.py b/tagstudio/tests/macros/test_sidecar.py index 700169f45..5ae3da142 100644 --- a/tagstudio/tests/macros/test_sidecar.py +++ b/tagstudio/tests/macros/test_sidecar.py @@ -12,9 +12,9 @@ def test_sidecar_macro(qt_driver, library, cwd, entry_full): entry_full.path = Path("newgrounds/foo.txt") fixture = cwd / "fixtures/sidecar_newgrounds.json" - dst = library.library_dir / "newgrounds" / (entry_full.path.stem + ".json") - dst.parent.mkdir() - shutil.copy(fixture, dst) + entry_dir = entry_full.absolute_path.parent + entry_dir.mkdir() + shutil.copy(fixture, entry_dir / "foo.json") # matches entry name + json qt_driver.frame_content = [entry_full] qt_driver.run_macro(MacroID.SIDECAR, 0) diff --git a/tagstudio/tests/qt/test_preview_panel.py b/tagstudio/tests/qt/test_preview_panel.py index f8550b865..2701dbfd3 100644 --- a/tagstudio/tests/qt/test_preview_panel.py +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -34,7 +34,7 @@ def test_update_widgets_multiple_selected(qt_driver, library): # entry with no tag fields entry = Entry( path=Path("test.txt"), - folder=library.folder, + folder=library.get_folders()[0], fields=[TextField(type_key=_FieldID.TITLE.name, position=0)], ) diff --git a/tagstudio/tests/qt/test_qt_driver.py b/tagstudio/tests/qt/test_qt_driver.py index e032b3fb7..6b6ab29ee 100644 --- a/tagstudio/tests/qt/test_qt_driver.py +++ b/tagstudio/tests/qt/test_qt_driver.py @@ -1,20 +1,12 @@ -from pathlib import Path from unittest.mock import Mock -from src.core.library import Entry from src.core.library.alchemy.enums import FilterState from src.core.library.json.library import ItemType from src.qt.widgets.item_thumb import ItemThumb -def test_update_thumbs(qt_driver): - qt_driver.frame_content = [ - Entry( - folder=qt_driver.lib.folder, - path=Path("/tmp/foo"), - fields=qt_driver.lib.default_fields, - ) - ] +def test_update_thumbs(qt_driver, entry_full): + qt_driver.frame_content = [entry_full] qt_driver.item_thumbs = [] for i in range(3): @@ -106,7 +98,7 @@ def test_close_library(qt_driver): qt_driver.close_library() # Then - assert qt_driver.lib.library_dir is None + assert qt_driver.lib.storage_path is None assert not qt_driver.frame_content assert not qt_driver.selected assert not any(x.mode for x in qt_driver.item_thumbs) diff --git a/tagstudio/tests/test_driver.py b/tagstudio/tests/test_driver.py index 65882d688..4f1227b65 100644 --- a/tagstudio/tests/test_driver.py +++ b/tagstudio/tests/test_driver.py @@ -1,15 +1,13 @@ -from os import makedirs from pathlib import Path from tempfile import TemporaryDirectory from PySide6.QtCore import QSettings -from src.core.constants import TS_FOLDER_NAME from src.core.driver import DriverMixin from src.core.enums import SettingItems from src.core.library.alchemy.library import LibraryStatus -class TestDriver(DriverMixin): +class DriverTest(DriverMixin): def __init__(self, settings): self.settings = settings @@ -17,7 +15,7 @@ def __init__(self, settings): def test_evaluate_path_empty(): # Given settings = QSettings() - driver = TestDriver(settings) + driver = DriverTest(settings) # When result = driver.evaluate_path(None) @@ -29,7 +27,7 @@ def test_evaluate_path_empty(): def test_evaluate_path_missing(): # Given settings = QSettings() - driver = TestDriver(settings) + driver = DriverTest(settings) # When result = driver.evaluate_path("/0/4/5/1/") @@ -42,13 +40,13 @@ def test_evaluate_path_last_lib_not_exists(): # Given settings = QSettings() settings.setValue(SettingItems.LAST_LIBRARY, "/0/4/5/1/") - driver = TestDriver(settings) + driver = DriverTest(settings) # When result = driver.evaluate_path(None) # Then - assert result == LibraryStatus(success=True, library_path=None, message=None) + assert result == LibraryStatus(success=True, storage_path=None, message=None) def test_evaluate_path_last_lib_present(): @@ -56,11 +54,11 @@ def test_evaluate_path_last_lib_present(): settings = QSettings() with TemporaryDirectory() as tmpdir: settings.setValue(SettingItems.LAST_LIBRARY, tmpdir) - makedirs(Path(tmpdir) / TS_FOLDER_NAME) - driver = TestDriver(settings) + storage_path = Path(tmpdir) + driver = DriverTest(settings) # When result = driver.evaluate_path(None) # Then - assert result == LibraryStatus(success=True, library_path=Path(tmpdir)) + assert result == LibraryStatus(success=True, storage_path=storage_path) diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index 81f26690c..edec67788 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -14,15 +14,15 @@ def test_library_add_file(library): entry = Entry( path=Path("bar.txt"), - folder=library.folder, + folder=library.get_folders()[0], fields=library.default_fields, ) - assert not library.has_path_entry(entry.path) + assert not library.get_path_entry(entry.path) assert library.add_entries([entry]) - assert library.has_path_entry(entry.path) + assert library.get_path_entry(entry.path) is not None def test_create_tag(library, generate_tag): @@ -85,7 +85,15 @@ def test_get_entry(library, entry_min): def test_entries_count(library): - entries = [Entry(path=Path(f"{x}.txt"), folder=library.folder, fields=[]) for x in range(10)] + folder = library.get_folders()[0] + entries = [ + Entry( + path=Path(f"{x}.txt"), + folder=folder, + fields=[], + ) + for x in range(10) + ] new_ids = library.add_entries(entries) assert len(new_ids) == 10 @@ -102,7 +110,7 @@ def test_entries_count(library): def test_add_field_to_entry(library): # Given entry = Entry( - folder=library.folder, + folder=library.get_folders()[0], path=Path("xxx"), fields=library.default_fields, ) @@ -205,7 +213,7 @@ def test_save_windows_path(library, generate_tag): entry = Entry( path=PureWindowsPath("foo\\bar.txt"), - folder=library.folder, + folder=library.get_folders()[0], fields=library.default_fields, ) tag = generate_tag("win_path") @@ -283,7 +291,7 @@ def test_update_entry_with_multiple_identical_fields(library, entry_full): def test_mirror_entry_fields(library, entry_full): target_entry = Entry( - folder=library.folder, + folder=library.get_folders()[0], path=Path("xxx"), fields=[ TextField(