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(