From bf1018cd7e5e445d691e9d63868f2ea64af9c213 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:12:29 -0700 Subject: [PATCH 1/8] feat: store and update file created and modified dates --- src/tagstudio/core/library/alchemy/library.py | 57 +++++++++++++++++-- src/tagstudio/core/library/alchemy/models.py | 8 +-- src/tagstudio/core/library/refresh.py | 26 ++++++++- src/tagstudio/core/utils/stat.py | 16 ++++++ tests/core/library/test_library.py | 8 +-- 5 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 src/tagstudio/core/utils/stat.py diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 473774b46..9797d6b8b 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -94,6 +94,7 @@ from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder from tagstudio.core.library.ignore import migrate_ext_list from tagstudio.core.library.json.library import Library as JsonLibrary +from tagstudio.core.utils.stat import get_date_created, get_date_modified from tagstudio.core.utils.types import unwrap if TYPE_CHECKING: @@ -650,6 +651,54 @@ def get_entry_full_by_path(self, path: Path) -> Entry | None: make_transient(entry) return entry + def refresh_file_entry_stats(self, entry_id: int, path: Path | None): + """Updates a file entry's associated stat() data.""" + needs_update = False + + entry = self.get_entry_full(entry_id, with_fields=False, with_tags=False) + if not entry: + return + + if not path: + full_path = unwrap(self.library_dir) / entry.path + else: + full_path = unwrap(self.library_dir) / path + + file_date_created = get_date_created(full_path) + file_date_modified = get_date_modified(full_path) + + # Log info + if entry.date_created != file_date_created: + logger.info(full_path) + logger.warning(f"Difference in date_created!: {entry.date_created}/{file_date_created}") + needs_update = True + # else: + # logger.info("No difference in date_created.") + + if entry.date_modified != file_date_modified: + logger.info(full_path) + logger.warning( + f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}" + ) + needs_update = True + # else: + # logger.info("No difference in date_modified") + + if not needs_update: + return + else: + logger.info(f"Updating entry file_metadata for {full_path}") + + with Session(self.engine) as session: + stmt = update(Entry).where(Entry.id == entry_id) + if file_date_created: + stmt = stmt.values(date_created=file_date_created) + if file_date_modified: + stmt = stmt.values(date_modified=file_date_modified) + + session.execute(stmt) + session.commit() + def get_tag_entries( self, tag_ids: Iterable[int], entry_ids: Iterable[int] ) -> dict[int, set[int]]: @@ -758,10 +807,10 @@ def remove_entries(self, entry_ids: list[int]) -> None: session.query(Entry).where(Entry.id.in_(sub_list)).delete() session.commit() - def has_entry_with_path(self, path: Path) -> bool: - """Check if an entry with this path is in the library.""" + def get_entry_id_from_path(self, path: Path) -> int: + """Attempt to return an Entry ID given a filepath, else return -1.""" with Session(self.engine) as session: - return session.query(exists().where(Entry.path == path)).scalar() + return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1 def get_paths(self, limit: int = -1) -> list[str]: path_strings: list[str] = [] @@ -1079,7 +1128,7 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool: Returns True if the action succeeded and False if the path already exists. """ - if self.has_entry_with_path(path): + if self.get_entry_id_from_path(path): return False if isinstance(entry_id, Entry): entry_id = entry_id.id diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 4daf42d58..b357cd459 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -202,8 +202,8 @@ class Entry(Base): path: Mapped[Path] = mapped_column(PathType, unique=True) filename: Mapped[str] = mapped_column() suffix: Mapped[str] = mapped_column() - date_created: Mapped[dt | None] - date_modified: Mapped[dt | None] + date_created: Mapped[float | None] + date_modified: Mapped[float | None] date_added: Mapped[dt | None] tags: Mapped[set[Tag]] = relationship(secondary="tag_entries") @@ -237,8 +237,8 @@ def __init__( path: Path, fields: list[BaseField], id: int | None = None, - date_created: dt | None = None, - date_modified: dt | None = None, + date_created: float | None = None, + date_modified: float | None = None, date_added: dt | None = None, ) -> None: super().__init__() diff --git a/src/tagstudio/core/library/refresh.py b/src/tagstudio/core/library/refresh.py index 18848366e..a14469c43 100644 --- a/src/tagstudio/core/library/refresh.py +++ b/src/tagstudio/core/library/refresh.py @@ -16,6 +16,8 @@ from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore +from tagstudio.core.utils.stat import get_date_created, get_date_modified +from tagstudio.core.utils.types import unwrap logger = structlog.get_logger(__name__) @@ -41,6 +43,8 @@ def save_new_files(self) -> Iterator[int]: Entry( path=entry_path, fields=[], + date_created=get_date_created(unwrap(self.library.library_dir) / entry_path), + date_modified=get_date_modified(unwrap(self.library.library_dir) / entry_path), date_added=dt.now(), ) for entry_path in self.files_not_in_library[index:end] @@ -140,6 +144,10 @@ def __rg_add(self, library_dir: Path, dir_list: list[str]) -> Iterator[int]: # Skip if the file/path is already mapped in the Library if f in self.library.included_files: dir_file_count += 1 + + entry_id = self.library.get_entry_id_from_path(f) + self.library.refresh_file_entry_stats(entry_id, path=f) + continue # Ignore if the file is a directory @@ -149,8 +157,13 @@ def __rg_add(self, library_dir: Path, dir_list: list[str]) -> Iterator[int]: dir_file_count += 1 self.library.included_files.add(f) - if not self.library.has_entry_with_path(f): + # if not self.library.has_entry_with_path(f): + # self.files_not_in_library.append(f) + entry_id = self.library.get_entry_id_from_path(f) + if entry_id < 0: self.files_not_in_library.append(f) + else: + self.library.refresh_file_entry_stats(entry_id, path=f) end_time_total = time() yield dir_file_count @@ -183,6 +196,9 @@ def __wc_add(self, library_dir: Path, ignore_patterns: list[str]) -> Iterator[in # Skip if the file/path is already mapped in the Library if f in self.library.included_files: dir_file_count += 1 + relative_path = f.relative_to(library_dir) + entry_id = self.library.get_entry_id_from_path(relative_path) + self.library.refresh_file_entry_stats(entry_id, path=relative_path) continue # Ignore if the file is a directory @@ -194,8 +210,14 @@ def __wc_add(self, library_dir: Path, ignore_patterns: list[str]) -> Iterator[in relative_path = f.relative_to(library_dir) - if not self.library.has_entry_with_path(relative_path): + # if not self.library.has_entry_with_path(relative_path): + # self.files_not_in_library.append(relative_path) + entry_id = self.library.get_entry_id_from_path(relative_path) + if entry_id < 0: self.files_not_in_library.append(relative_path) + else: + self.library.refresh_file_entry_stats(entry_id, path=relative_path) + except ValueError: logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!") diff --git a/src/tagstudio/core/utils/stat.py b/src/tagstudio/core/utils/stat.py new file mode 100644 index 000000000..7ee463000 --- /dev/null +++ b/src/tagstudio/core/utils/stat.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +import platform +from pathlib import Path + + +def get_date_modified(path: Path) -> float: + return path.stat().st_mtime + + +def get_date_created(path: Path) -> float: + if platform.system() in {"Windows", "Darwin"}: + return path.stat().st_birthtime + else: + return path.stat().st_ctime diff --git a/tests/core/library/test_library.py b/tests/core/library/test_library.py index e260225a8..2ee25f646 100644 --- a/tests/core/library/test_library.py +++ b/tests/core/library/test_library.py @@ -80,9 +80,9 @@ def test_library_add_file(library: Library): fields=[TextField(name="Title", value="I'm a Test Title")], ) - assert not library.has_entry_with_path(entry.path) + assert not library.get_entry_id_from_path(entry.path) assert library.add_entries([entry]) - assert library.has_entry_with_path(entry.path) + assert library.get_entry_id_from_path(entry.path) def test_create_tag(library: Library, generate_tag: Callable[..., Tag]): @@ -338,8 +338,8 @@ def test_merge_entries(library: Library): entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id)) assert library.merge_entries(entry_a_, entry_b_) - assert not library.has_entry_with_path(Path("a")) - assert library.has_entry_with_path(Path("b")) + assert not library.get_entry_id_from_path(Path("a")) + assert library.get_entry_id_from_path(Path("b")) entry_b_merged = unwrap(library.get_entry_full(entry_b_id)) From 416b19e3543d405d07dd3a840afb8d793846dbfa Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:05:02 -0700 Subject: [PATCH 2/8] refactor!: rework and expand refresh (sync) system --- .../core/library/alchemy/constants.py | 2 +- src/tagstudio/core/library/alchemy/db.py | 4 +- src/tagstudio/core/library/alchemy/enums.py | 3 + src/tagstudio/core/library/alchemy/library.py | 212 +++++++-- .../core/library/alchemy/migrations.py | 51 +++ src/tagstudio/core/library/alchemy/models.py | 11 +- .../alchemy/registries/unlinked_registry.py | 98 ----- src/tagstudio/core/library/ignore.py | 4 +- src/tagstudio/core/library/refresh.py | 232 ---------- src/tagstudio/core/library/scanners.py | 101 +++++ src/tagstudio/core/library/sync.py | 391 +++++++++++++++++ src/tagstudio/core/utils/filesystem.py | 15 + src/tagstudio/core/utils/normalization.py | 16 + src/tagstudio/core/utils/stat.py | 22 +- src/tagstudio/qt/controllers/banner.py | 245 +++++++++++ src/tagstudio/qt/controllers/inspector.py | 7 +- src/tagstudio/qt/controllers/main_window.py | 20 +- .../qt/controllers/relink_entries_progress.py | 10 +- .../qt/controllers/rounded_progress_bar.py | 236 ++++++++++ src/tagstudio/qt/controllers/stable_label.py | 38 ++ src/tagstudio/qt/mixed/drop_import_modal.py | 2 +- src/tagstudio/qt/mixed/file_attributes.py | 75 ++-- src/tagstudio/qt/mixed/fix_unlinked.py | 57 +-- .../qt/mixed/remove_unlinked_modal.py | 6 +- src/tagstudio/qt/qt_driver.py | 389 +++++++++++++---- src/tagstudio/qt/views/banner_view.py | 47 ++ .../qt/views/layouts/thumb_grid_layout.py | 9 +- src/tagstudio/qt/views/styles/stylesheets.py | 139 ++++++ src/tagstudio/resources/translations/de.json | 6 - src/tagstudio/resources/translations/el.json | 1 - src/tagstudio/resources/translations/en.json | 29 +- src/tagstudio/resources/translations/es.json | 6 - src/tagstudio/resources/translations/fi.json | 2 - src/tagstudio/resources/translations/fil.json | 6 - src/tagstudio/resources/translations/fr.json | 6 - src/tagstudio/resources/translations/hu.json | 6 - src/tagstudio/resources/translations/it.json | 6 - src/tagstudio/resources/translations/ja.json | 6 - .../resources/translations/nb_NO.json | 6 - src/tagstudio/resources/translations/nl.json | 1 - src/tagstudio/resources/translations/pl.json | 5 - src/tagstudio/resources/translations/pt.json | 6 - .../resources/translations/pt_BR.json | 6 - src/tagstudio/resources/translations/qpv.json | 6 - src/tagstudio/resources/translations/ru.json | 6 - src/tagstudio/resources/translations/sv.json | 3 - src/tagstudio/resources/translations/ta.json | 6 - src/tagstudio/resources/translations/tok.json | 6 - src/tagstudio/resources/translations/tr.json | 6 - .../resources/translations/zh_Hans.json | 6 - .../resources/translations/zh_Hant.json | 6 - tests/core/library/test_library.py | 76 +++- tests/core/library/test_refresh.py | 50 --- tests/core/library/test_sync.py | 406 ++++++++++++++++++ tests/core/library/test_unlinked_entries.py | 38 -- .../.TagStudio/ts_library.sqlite | Bin 122880 -> 122880 bytes tests/qt/test_file_path_options.py | 2 +- 57 files changed, 2378 insertions(+), 779 deletions(-) delete mode 100644 src/tagstudio/core/library/alchemy/registries/unlinked_registry.py delete mode 100644 src/tagstudio/core/library/refresh.py create mode 100644 src/tagstudio/core/library/scanners.py create mode 100644 src/tagstudio/core/library/sync.py create mode 100644 src/tagstudio/core/utils/filesystem.py create mode 100644 src/tagstudio/core/utils/normalization.py create mode 100644 src/tagstudio/qt/controllers/banner.py create mode 100644 src/tagstudio/qt/controllers/rounded_progress_bar.py create mode 100644 src/tagstudio/qt/controllers/stable_label.py create mode 100644 src/tagstudio/qt/views/banner_view.py delete mode 100644 tests/core/library/test_refresh.py create mode 100644 tests/core/library/test_sync.py delete mode 100644 tests/core/library/test_unlinked_entries.py diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index aba48a278..71f1bdd45 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -14,7 +14,7 @@ DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 400 +DB_VERSION: int = 500 TAG_CHILDREN_QUERY = text(""" WITH RECURSIVE ChildTags AS ( diff --git a/src/tagstudio/core/library/alchemy/db.py b/src/tagstudio/core/library/alchemy/db.py index e28880c3d..332f35517 100644 --- a/src/tagstudio/core/library/alchemy/db.py +++ b/src/tagstudio/core/library/alchemy/db.py @@ -9,6 +9,8 @@ from sqlalchemy import Dialect, String, TypeDecorator from sqlalchemy.orm import DeclarativeBase +from tagstudio.core.utils.normalization import norm_path + logger = structlog.getLogger(__name__) @@ -19,7 +21,7 @@ class PathType(TypeDecorator): @override def process_bind_param(self, value: Path | None, dialect: Dialect): if value is not None: - return Path(value).as_posix() + return norm_path(Path(value), case_sensitive=True).as_posix() return None @override diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 0c3c55c43..6cf1264be 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -72,7 +72,10 @@ class ItemType(enum.Enum): class SortingModeEnum(enum.Enum): DATE_ADDED = "file.date_added" + DATE_CREATED = "file.date_created" + DATE_MODIFIED = "file.date_modified" FILE_NAME = "generic.filename" + FILE_SIZE = "file.size" PATH = "file.path" RANDOM = "sorting.mode.random" diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 9797d6b8b..b888a1576 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -12,7 +12,7 @@ from datetime import UTC, datetime from os import makedirs from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import structlog from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType] @@ -28,7 +28,6 @@ create_engine, delete, desc, - exists, func, inspect, or_, @@ -94,7 +93,8 @@ from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder from tagstudio.core.library.ignore import migrate_ext_list from tagstudio.core.library.json.library import Library as JsonLibrary -from tagstudio.core.utils.stat import get_date_created, get_date_modified +from tagstudio.core.utils.normalization import norm_path +from tagstudio.core.utils.stat import get_date_created, get_date_modified, get_file_size from tagstudio.core.utils.types import unwrap if TYPE_CHECKING: @@ -204,12 +204,22 @@ class LibraryStatus: json_migration_req: bool = False +class FileStat(NamedTuple): + """Cached stat() fields. Matches the ones stored in file entries.""" + + date_created: float | None + date_modified: float | None + file_size: int | None + + class Library: """Class for the Library object, and all CRUD operations made upon it.""" library_dir: Path | None = None engine: Engine | None = None - included_files: set[Path] = set() + path_cache: dict[Path, int] | None = None + duplicate_path_entry_ids: list[int] | None = None + is_case_sensitive_fs: bool | None = None def __init__(self) -> None: self.dupe_entries_count: int = -1 # NOTE: For internal management. @@ -222,7 +232,9 @@ def close(self): self.engine.dispose() self.library_dir = None self.folder = None - self.included_files = set() + self.path_cache = None + self.duplicate_path_entry_ids = None + self.is_case_sensitive_fs = None self.dupe_entries_count = -1 self.dupe_files_count = -1 @@ -651,54 +663,84 @@ def get_entry_full_by_path(self, path: Path) -> Entry | None: make_transient(entry) return entry - def refresh_file_entry_stats(self, entry_id: int, path: Path | None): - """Updates a file entry's associated stat() data.""" - needs_update = False + def refresh_entries_stats(self, entries: list[tuple[int, Path]]) -> int: + """Check and update os.stat() metadata for multiple file entries in bulk. - entry = self.get_entry_full(entry_id, with_fields=False, with_tags=False) - if not entry: - return + Only file entries that have differing stat data will be updated. - if not path: - full_path = unwrap(self.library_dir) / entry.path - else: - full_path = unwrap(self.library_dir) / path + Args: + entries (list[tuple[int, Path]]): A list of (ID, Path) tuples to check. - file_date_created = get_date_created(full_path) - file_date_modified = get_date_modified(full_path) + Returns: + int: The number of entries that were updated. + """ + if not entries: + return 0 - # Log info - if entry.date_created != file_date_created: - logger.info(full_path) - logger.warning(f"Difference in date_created!: {entry.date_created}/{file_date_created}") - needs_update = True - # else: - # logger.info("No difference in date_created.") + library_dir = unwrap(self.library_dir) + entry_ids = [entry_id for entry_id, _ in entries] - if entry.date_modified != file_date_modified: - logger.info(full_path) - logger.warning( - f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}" + stored: dict[int, FileStat] = {} + with Session(self.engine) as session: + for sub_list in [ + entry_ids[i : i + MAX_SQL_VARIABLES] + for i in range(0, len(entry_ids), MAX_SQL_VARIABLES) + ]: + stmt = select( + Entry.id, Entry.date_created, Entry.date_modified, Entry.file_size + ).where(Entry.id.in_(sub_list)) + for row in session.execute(stmt): + stored[row.id] = FileStat(row.date_created, row.date_modified, row.file_size) + + updates: dict[int, FileStat] = {} + for entry_id, path in entries: + stored_stat = stored.get(entry_id, FileStat(None, None, None)) + full_path = library_dir / path + + try: + file_stat = full_path.stat() + except OSError as e: + logger.error( + "[Library] Could not stat file while refreshing entry metadata", + path=full_path, + error=e, + ) + continue + + current_stat = FileStat( + get_date_created(file_stat), + get_date_modified(file_stat), + get_file_size(file_stat), ) - needs_update = True - # else: - # logger.info("No difference in date_modified") - if not needs_update: - return - else: - logger.info(f"Updating entry file_metadata for {full_path}") + if stored_stat == current_stat: + continue - with Session(self.engine) as session: - stmt = update(Entry).where(Entry.id == entry_id) - if file_date_created: - stmt = stmt.values(date_created=file_date_created) - if file_date_modified: - stmt = stmt.values(date_modified=file_date_modified) + logger.info( + "[Library] Entry stat data changed", + path=full_path, + date_created=(stored_stat.date_created, current_stat.date_created), + date_modified=(stored_stat.date_modified, current_stat.date_modified), + file_size=(stored_stat.file_size, current_stat.file_size), + ) + updates[entry_id] = current_stat + + if not updates: + return 0 - session.execute(stmt) + with Session(self.engine) as session: + session.execute( + update(Entry), + [ + {"id": entry_id, **entry_stat._asdict()} + for entry_id, entry_stat in updates.items() + ], + ) session.commit() + logger.info(f"[Library] Refreshed stat data for {len(updates)} of {len(entries)} entries") + return len(updates) + def get_tag_entries( self, tag_ids: Iterable[int], entry_ids: Iterable[int] ) -> dict[int, set[int]]: @@ -777,8 +819,47 @@ def verify_ts_folder(self, library_dir: Path | None) -> bool: full_ts_path.mkdir(parents=True, exist_ok=True) return False + def _path_cache_key(self, path: Path) -> Path: + case_sensitive = ( + self.is_case_sensitive_fs if self.is_case_sensitive_fs is not None else True + ) + return norm_path(path, case_sensitive=case_sensitive) + + def _cache_add_path(self, entry_id: int, path: Path) -> None: + """Keep the path cache consistent with a newly-added or relinked entry.""" + if self.path_cache is None: + return + key = self._path_cache_key(path) + if key in self.path_cache: + displaced_id = self.path_cache[key] + logger.warning( + "[Library] Duplicate path discovered in path cache while normalizing path, " + "marking displaced entry as unlinked.", + path=path, + displaced_entry_id=displaced_id, + entry_id=entry_id, + ) + if self.duplicate_path_entry_ids is None: + self.duplicate_path_entry_ids = [] + self.duplicate_path_entry_ids.append(displaced_id) + self.path_cache[key] = entry_id + + def _cache_remove_entries(self, entry_ids: Iterable[int]) -> None: + """Keep the path cache consistent with removed entry ids.""" + removed = set(entry_ids) + if not removed: + return + if self.path_cache is not None: + stale_keys = [key for key, eid in self.path_cache.items() if eid in removed] + for key in stale_keys: + del self.path_cache[key] + if self.duplicate_path_entry_ids: + self.duplicate_path_entry_ids = [ + eid for eid in self.duplicate_path_entry_ids if eid not in removed + ] + def add_entries(self, items: list[Entry]) -> list[int]: - """Add multiple Entry records to the Library.""" + """Add multiple entries to the Library.""" assert items with Session(self.engine) as session: @@ -795,6 +876,9 @@ def add_entries(self, items: list[Entry]) -> list[int]: new_ids = [item.id for item in items] session.expunge_all() + for entry_id, item in zip(new_ids, items, strict=True): + self._cache_add_path(entry_id, item.path) + return new_ids def remove_entries(self, entry_ids: list[int]) -> None: @@ -806,12 +890,40 @@ def remove_entries(self, entry_ids: list[int]) -> None: ]: session.query(Entry).where(Entry.id.in_(sub_list)).delete() session.commit() + self._cache_remove_entries(entry_ids) def get_entry_id_from_path(self, path: Path) -> int: """Attempt to return an Entry ID given a filepath, else return -1.""" with Session(self.engine) as session: return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1 + def all_paths_with_ids(self) -> dict[int, Path]: + """Bulk fetch every Entry's (id, path). Only used to init the path cache.""" + with Session(self.engine) as session: + rows = session.execute(select(Entry.id, Entry.path)).all() + return {row.id: row.path for row in rows} + + def get_or_build_path_cache(self) -> dict[Path, int]: + """Return the dict cache of normalized paths -> entry IDs.""" + if self.path_cache is None: + cache: dict[Path, int] = {} + duplicates: list[int] = [] + for entry_id, path in self.all_paths_with_ids().items(): + key = self._path_cache_key(path) + if key in cache: + logger.warning( + "[Library] Duplicate normalized path created while building cache, " + "marking the displaced entry as unlinked.", + path=path, + displaced_entry_id=cache[key], + entry_id=entry_id, + ) + duplicates.append(cache[key]) + cache[key] = entry_id + self.path_cache = cache + self.duplicate_path_entry_ids = duplicates + return self.path_cache + def get_paths(self, limit: int = -1) -> list[str]: path_strings: list[str] = [] with Session(self.engine) as session: @@ -829,7 +941,8 @@ def search_library( ) -> SearchResult: """Filter library by search query. - :return: number of entries matching the query and one page of results. + Returns: + SearchResult: number of entries matching the query and one page of results. """ assert isinstance(search, BrowsingState) assert self.library_dir @@ -867,8 +980,14 @@ def search_library( match search.sorting_mode: case SortingModeEnum.DATE_ADDED: sort_on = Entry.id + case SortingModeEnum.DATE_CREATED: + sort_on = Entry.date_created + case SortingModeEnum.DATE_MODIFIED: + sort_on = Entry.date_modified case SortingModeEnum.FILE_NAME: sort_on = func.lower(Entry.filename) + case SortingModeEnum.FILE_SIZE: + sort_on = Entry.file_size case SortingModeEnum.PATH: sort_on = func.lower(Entry.path) case SortingModeEnum.RANDOM: @@ -1128,7 +1247,7 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool: Returns True if the action succeeded and False if the path already exists. """ - if self.get_entry_id_from_path(path): + if self.get_entry_id_from_path(path) >= 0: return False if isinstance(entry_id, Entry): entry_id = entry_id.id @@ -1146,6 +1265,9 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool: session.execute(update_stmt) session.commit() + + self._cache_remove_entries([entry_id]) + self._cache_add_path(entry_id, path) return True def remove_tag(self, tag_id: int) -> bool: diff --git a/src/tagstudio/core/library/alchemy/migrations.py b/src/tagstudio/core/library/alchemy/migrations.py index 5acc31e8a..5dde198c4 100644 --- a/src/tagstudio/core/library/alchemy/migrations.py +++ b/src/tagstudio/core/library/alchemy/migrations.py @@ -24,6 +24,7 @@ from tagstudio.core.library.alchemy.joins import TagParent from tagstudio.core.library.alchemy.models import Entry, Tag, TagColorGroup, Version from tagstudio.core.library.ignore import migrate_ext_list +from tagstudio.core.utils.normalization import norm_path from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations @@ -101,6 +102,7 @@ def run(self): MigrationTo202, # changes: tag_parents MigrationTo300, # changes: deletes folders MigrationTo400, # changes: add category_exclusions + MigrationTo500, # changes: entries ] with Session(self.engine) as session: for migration in migrations: @@ -616,3 +618,52 @@ def run(cls, session: Session, library_dir: Path, fmt_log): """) ) session.flush() + + +class MigrationTo500(DBMigration): + version = 500 + + @override + @classmethod + def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod): + """Migrate DB to DB_VERSION 500.""" + # Drop date columns that were string based to add new float ones, plus int file_size + logger.info(fmt_log("Dropping old entry columns...")) + session.execute(text("ALTER TABLE entries DROP COLUMN date_created")) + session.execute(text("ALTER TABLE entries DROP COLUMN date_modified")) + session.flush() + logger.info(fmt_log("Adding new entry columns...")) + session.execute(text("ALTER TABLE entries ADD COLUMN date_created REAL")) + session.execute(text("ALTER TABLE entries ADD COLUMN date_modified REAL")) + session.execute(text("ALTER TABLE entries ADD COLUMN file_size INTEGER")) + session.flush() + + # Normalize entry paths to NFD + logger.info(fmt_log("Normalizing file entry paths...")) + rows = session.execute(text("SELECT id, path FROM entries")).all() + entries_by_key: dict[Path, list[tuple[int, str]]] = {} + for entry_id, path in rows: + nfd_path = norm_path(Path(path), case_sensitive=True) + entries_by_key.setdefault(nfd_path, []).append((entry_id, path)) + + for nfd_path, group in entries_by_key.items(): + if len(group) > 1: + continue + + entry_id, path = group[0] + if path == nfd_path.as_posix(): + continue # Already normalized + + session.execute( + text( + "UPDATE entries SET path = :path, filename = :filename, " + "suffix = :suffix WHERE id = :id" + ), + { + "path": nfd_path.as_posix(), + "filename": nfd_path.name, + "suffix": nfd_path.suffix.lstrip(".").lower(), + "id": entry_id, + }, + ) + session.flush() diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index b357cd459..fb838a294 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -17,6 +17,7 @@ TextField, ) from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent +from tagstudio.core.utils.normalization import norm_path class Namespace(Base): @@ -204,6 +205,7 @@ class Entry(Base): suffix: Mapped[str] = mapped_column() date_created: Mapped[float | None] date_modified: Mapped[float | None] + file_size: Mapped[int | None] date_added: Mapped[dt | None] tags: Mapped[set[Tag]] = relationship(secondary="tag_entries") @@ -239,19 +241,22 @@ def __init__( id: int | None = None, date_created: float | None = None, date_modified: float | None = None, + file_size: int | None = None, date_added: dt | None = None, ) -> None: super().__init__() - self.path = path self.id = id # pyright: ignore[reportAttributeAccessIssue] - self.filename = path.name - self.suffix = path.suffix.lstrip(".").lower() + self.path = norm_path(path, case_sensitive=True) # NFD is enforced + self.filename = self.path.name + self.suffix = self.path.suffix.lstrip(".").lower() # The date the file associated with this entry was created. # st_birthtime on Windows and Mac, st_ctime on Linux. self.date_created = date_created # The date the file associated with this entry was last modified: st_mtime. self.date_modified = date_modified + # The size of the file associated with this entry, in bytes: st_size. + self.file_size = file_size # The date this entry was added to the library. self.date_added = date_added diff --git a/src/tagstudio/core/library/alchemy/registries/unlinked_registry.py b/src/tagstudio/core/library/alchemy/registries/unlinked_registry.py deleted file mode 100644 index 6b6fa65c3..000000000 --- a/src/tagstudio/core/library/alchemy/registries/unlinked_registry.py +++ /dev/null @@ -1,98 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from collections.abc import Iterator -from dataclasses import dataclass, field -from pathlib import Path - -import structlog -from wcmatch import glob, pathlib - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob -from tagstudio.core.utils.types import unwrap - -logger = structlog.get_logger() - - -@dataclass -class UnlinkedRegistry: - """State tracker for unlinked entries.""" - - lib: Library - files_fixed_count: int = 0 - unlinked_entries: list[Entry] = field(default_factory=list) - - @property - def unlinked_entries_count(self) -> int: - return len(self.unlinked_entries) - - def reset(self): - self.unlinked_entries.clear() - - def refresh_unlinked_files(self) -> Iterator[int]: - """Track the number of entries that point to an invalid filepath.""" - logger.info("[UnlinkedRegistry] Refreshing unlinked files...") - - self.unlinked_entries = [] - for i, entry in enumerate(self.lib.all_entries()): - yield i - full_path = unwrap(self.lib.library_dir) / entry.path - if not full_path.exists() or not full_path.is_file(): - self.unlinked_entries.append(entry) - - def match_unlinked_file_entry(self, match_entry: Entry) -> list[Path]: - """Try and match unlinked file entries with matching results in the library directory. - - Works if files were just moved to different subfolders and don't have duplicate names. - """ - library_dir = unwrap(self.lib.library_dir) - matches: list[Path] = [] - - # NOTE: ignore_to_glob() is needed for wcmatch, not ripgrep. - ignore_patterns = ignore_to_glob(Ignore.get_patterns(library_dir)) - for path in pathlib.Path(str(library_dir)).glob( - patterns=f"***/{glob.escape(match_entry.path.name)}", - flags=PATH_GLOB_FLAGS, - exclude=ignore_patterns, - ): - if path.is_dir(): - continue - if path.name == match_entry.path.name: - new_path = Path(path).relative_to(library_dir) - matches.append(new_path) - - logger.info("[UnlinkedRegistry] Matches", matches=matches) - return matches - - def fix_unlinked_entries(self) -> Iterator[int]: - """Attempt to fix unlinked file entries by finding a match in the library directory.""" - self.files_fixed_count = 0 - matched_entries: list[Entry] = [] - for i, entry in enumerate(self.unlinked_entries): - yield i - item_matches = self.match_unlinked_file_entry(entry) - if len(item_matches) == 1: - logger.info( - "[UnlinkedRegistry]", - entry=entry.path.as_posix(), - item_matches=item_matches[0].as_posix(), - ) - if not self.lib.update_entry_path(entry.id, item_matches[0]): - try: - match = unwrap(self.lib.get_entry_full_by_path(item_matches[0])) - entry_full = unwrap(self.lib.get_entry_full(entry.id)) - self.lib.merge_entries(entry_full, match) - except AttributeError: - continue - self.files_fixed_count += 1 - matched_entries.append(entry) - - for entry in matched_entries: - self.unlinked_entries.remove(entry) - - def remove_unlinked_entries(self) -> None: - self.lib.remove_entries(list(map(lambda unlinked: unlinked.id, self.unlinked_entries))) - self.unlinked_entries = [] diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index 63b7ab18e..20191c5dd 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT from copy import deepcopy @@ -14,7 +14,7 @@ logger = structlog.get_logger() -PATH_GLOB_FLAGS = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE | pathlib.MATCHBASE +PATH_GLOB_FLAGS: int = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE | pathlib.MATCHBASE GLOBAL_IGNORE = [ diff --git a/src/tagstudio/core/library/refresh.py b/src/tagstudio/core/library/refresh.py deleted file mode 100644 index a14469c43..000000000 --- a/src/tagstudio/core/library/refresh.py +++ /dev/null @@ -1,232 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import shutil -from collections.abc import Iterator -from dataclasses import dataclass, field -from datetime import datetime as dt -from pathlib import Path -from time import time - -import structlog -from wcmatch import pathlib - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob -from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore -from tagstudio.core.utils.stat import get_date_created, get_date_modified -from tagstudio.core.utils.types import unwrap - -logger = structlog.get_logger(__name__) - - -@dataclass -class RefreshTracker: - library: Library - files_not_in_library: list[Path] = field(default_factory=list) - - @property - def files_count(self) -> int: - return len(self.files_not_in_library) - - def save_new_files(self) -> Iterator[int]: - """Save the list of files that are not in the library.""" - batch_size = 200 - - index = 0 - while index < len(self.files_not_in_library): - yield index - end = min(len(self.files_not_in_library), index + batch_size) - entries = [ - Entry( - path=entry_path, - fields=[], - date_created=get_date_created(unwrap(self.library.library_dir) / entry_path), - date_modified=get_date_modified(unwrap(self.library.library_dir) / entry_path), - date_added=dt.now(), - ) - for entry_path in self.files_not_in_library[index:end] - ] - self.library.add_entries(entries) - index = end - self.files_not_in_library = [] - - def refresh_dir(self, library_dir: Path, force_internal_tools: bool = False) -> Iterator[int]: - """Scan a directory for files, and add those relative filenames to internal variables. - - Args: - library_dir (Path): The library directory. - force_internal_tools (bool): Option to force the use of internal tools for scanning - (i.e. wcmatch) instead of using tools found on the system (i.e. ripgrep). - """ - if self.library.library_dir is None: - raise ValueError("No library directory set.") - - ignore_patterns = Ignore.get_patterns(library_dir) - - if force_internal_tools: - return self.__wc_add(library_dir, ignore_to_glob(ignore_patterns)) - - dir_list: list[str] | None = self.__get_dir_list(library_dir, ignore_patterns) - - # Use ripgrep if it was found and working, else fallback to wcmatch. - if dir_list is not None: - return self.__rg_add(library_dir, dir_list) - else: - return self.__wc_add(library_dir, ignore_to_glob(ignore_patterns)) - - def __get_dir_list(self, library_dir: Path, ignore_patterns: list[str]) -> list[str] | None: - """Use ripgrep to return a list of matched directories and files. - - Return `None` if ripgrep not found on system. - """ - rg_path = shutil.which("rg") - # Use ripgrep if found on system - if rg_path is not None: - logger.info("[Refresh: Using ripgrep for scanning]") - - compiled_ignore_path = library_dir / ".TagStudio" / ".compiled_ignore" - - # Write compiled ignore patterns (built-in + user) to a temp file to pass to ripgrep - with open(compiled_ignore_path, "w") as pattern_file: - pattern_file.write("\n".join(ignore_patterns)) - - result = silent_run( - " ".join( - [ - "rg", - "--files", - "--follow", - "--hidden", - "--ignore-file", - f'"{str(compiled_ignore_path)}"', - ] - ), - cwd=library_dir, - capture_output=True, - shell=True, - encoding="UTF-8", - ) - try: - compiled_ignore_path.unlink() - except Exception as e: - logger.error( - "[Refresh] Could not remove compiled ignore path", - path=compiled_ignore_path, - error=e, - ) - - if result.stderr: - logger.error(result.stderr) - - return result.stdout.splitlines() # pyright: ignore [reportReturnType] - - logger.warning("[Refresh: ripgrep not found on system]") - return None - - def __rg_add(self, library_dir: Path, dir_list: list[str]) -> Iterator[int]: - start_time_total = time() - start_time_loop = time() - dir_file_count = 0 - self.files_not_in_library = [] - - for r in dir_list: - f = pathlib.Path(r) - - end_time_loop = time() - # Yield output every 1/30 of a second - if (end_time_loop - start_time_loop) > 0.034: - yield dir_file_count - start_time_loop = time() - - # Skip if the file/path is already mapped in the Library - if f in self.library.included_files: - dir_file_count += 1 - - entry_id = self.library.get_entry_id_from_path(f) - self.library.refresh_file_entry_stats(entry_id, path=f) - - continue - - # Ignore if the file is a directory - if f.is_dir(): - continue - - dir_file_count += 1 - self.library.included_files.add(f) - - # if not self.library.has_entry_with_path(f): - # self.files_not_in_library.append(f) - entry_id = self.library.get_entry_id_from_path(f) - if entry_id < 0: - self.files_not_in_library.append(f) - else: - self.library.refresh_file_entry_stats(entry_id, path=f) - - end_time_total = time() - yield dir_file_count - logger.info( - "[Refresh]: Directory scan time", - path=library_dir, - duration=(end_time_total - start_time_total), - files_scanned=dir_file_count, - tool_used="ripgrep (system)", - ) - - def __wc_add(self, library_dir: Path, ignore_patterns: list[str]) -> Iterator[int]: - start_time_total = time() - start_time_loop = time() - dir_file_count = 0 - self.files_not_in_library = [] - - logger.info("[Refresh]: Falling back to wcmatch for scanning") - - try: - for f in pathlib.Path(str(library_dir)).glob( - "***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns - ): - end_time_loop = time() - # Yield output every 1/30 of a second - if (end_time_loop - start_time_loop) > 0.034: - yield dir_file_count - start_time_loop = time() - - # Skip if the file/path is already mapped in the Library - if f in self.library.included_files: - dir_file_count += 1 - relative_path = f.relative_to(library_dir) - entry_id = self.library.get_entry_id_from_path(relative_path) - self.library.refresh_file_entry_stats(entry_id, path=relative_path) - continue - - # Ignore if the file is a directory - if f.is_dir(): - continue - - dir_file_count += 1 - self.library.included_files.add(f) - - relative_path = f.relative_to(library_dir) - - # if not self.library.has_entry_with_path(relative_path): - # self.files_not_in_library.append(relative_path) - entry_id = self.library.get_entry_id_from_path(relative_path) - if entry_id < 0: - self.files_not_in_library.append(relative_path) - else: - self.library.refresh_file_entry_stats(entry_id, path=relative_path) - - except ValueError: - logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!") - - end_time_total = time() - yield dir_file_count - logger.info( - "[Refresh]: Directory scan time", - path=library_dir, - duration=(end_time_total - start_time_total), - files_scanned=dir_file_count, - tool_used="wcmatch (internal)", - ) diff --git a/src/tagstudio/core/library/scanners.py b/src/tagstudio/core/library/scanners.py new file mode 100644 index 000000000..17f567a62 --- /dev/null +++ b/src/tagstudio/core/library/scanners.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +import subprocess +from collections.abc import Iterator +from pathlib import Path + +import structlog +from wcmatch import pathlib + +from tagstudio.core.constants import TS_FOLDER_NAME +from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, ignore_to_glob +from tagstudio.core.utils.ripgrep_status import RipgrepStatus +from tagstudio.core.utils.silent_subprocess import silent_popen # pyright: ignore + +logger = structlog.get_logger(__name__) + + +def scan_paths( + scan_dir: Path, ignore_patterns: list[str], force_internal_scanner: bool = False +) -> Iterator[Path]: + """Scan `scan_dir` for files, yielding each match's path relative to `scan_dir`. + + Uses ripgrep if present on the system, falling back to the internal (wcmatch) scanner + otherwise or if `force_internal_scanner` is set. + """ + if not force_internal_scanner and RipgrepStatus.which() is not None: + yield from _scan_with_ripgrep(scan_dir, ignore_patterns) + return + yield from _scan_with_internal_scanner(scan_dir, ignore_patterns) + + +def _scan_with_ripgrep(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[Path]: + """Scan for files with ripgrep.""" + logger.info("[Scanners] Using ripgrep for scanning", path=scan_dir) + + compiled_ignore_path = scan_dir / TS_FOLDER_NAME / ".compiled_ignore" + compiled_ignore_path.parent.mkdir(parents=True, exist_ok=True) + compiled_ignore_path.write_text("\n".join(ignore_patterns), encoding="utf-8") + + proc: subprocess.Popen[str] | None = None + try: + proc = silent_popen( + ["rg", "--files", "--follow", "--hidden", "--ignore-file", str(compiled_ignore_path)], + cwd=scan_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="UTF-8", + ) + assert proc.stdout is not None + for line in proc.stdout: + line = line.rstrip("\n") + if not line: + continue + path = Path(line) + if (scan_dir / path).is_dir(): + continue + yield path + + proc.wait() + if proc.returncode not in (0, 1): # 1 == "no matches", still successful + logger.error( + "[Scanners] ripgrep exited with an error", + returncode=proc.returncode, + stderr=proc.stderr.read() if proc.stderr else "", + ) + finally: + if proc is not None: + # Loop finished + if proc.stdout is not None: + proc.stdout.close() + # Still running, but cancelled mid-loop + if proc.poll() is None: + proc.terminate() + proc.wait() + try: + compiled_ignore_path.unlink(missing_ok=True) + except OSError as e: + logger.error( + "[Scanners] Could not remove compiled ignore path", + path=compiled_ignore_path, + error=e, + ) + + +def _scan_with_internal_scanner(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[Path]: + """Scan for files with the internal glob-based scanner (wcmatch).""" + logger.info("[Scanners] Using internal scanner for scanning", path=scan_dir) + + glob_patterns = ignore_to_glob(ignore_patterns) + try: + for f in pathlib.Path(str(scan_dir)).glob( + "***/*", flags=PATH_GLOB_FLAGS, exclude=glob_patterns + ): + if f.is_dir(): + continue + yield Path(f).relative_to(scan_dir) + except ValueError: + logger.error("[Scanners] ValueError while scanning directory with the internal scanner") diff --git a/src/tagstudio/core/library/sync.py b/src/tagstudio/core/library/sync.py new file mode 100644 index 000000000..6172741ee --- /dev/null +++ b/src/tagstudio/core/library/sync.py @@ -0,0 +1,391 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from collections.abc import Iterator +from dataclasses import dataclass, field +from datetime import datetime as dt +from pathlib import Path +from time import time + +import structlog +from wcmatch import glob, pathlib + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob +from tagstudio.core.library.scanners import scan_paths +from tagstudio.core.utils.filesystem import is_fs_case_sensitive +from tagstudio.core.utils.normalization import norm_path +from tagstudio.core.utils.stat import get_date_created, get_date_modified, get_file_size +from tagstudio.core.utils.types import unwrap + +logger = structlog.get_logger(__name__) + +# Yield progress this often during a loop to avoid overwhelming the UI. +# TODO: Look into whether or not this can be handled on the UI side. +YIELD_INTERVAL_SECONDS = 0.034 + + +@dataclass +class LibrarySyncEngine: + """Keeps a Library's entries in sync with its content directories on disk.""" + + library: Library + new_paths: list[Path] = field(default_factory=list) + paths_to_restat: list[tuple[int, Path]] = field(default_factory=list) + unlinked_entries: list[Entry] = field(default_factory=list) + relinked_entries: list[Entry] = field(default_factory=list) + manual_relink_count: int = 0 + cancelled: bool = False + + _scanned_paths: list[Path] = field(default_factory=list, init=False, repr=False) + _filename_to_path_map: dict[Path, list[Path]] | None = field( + default=None, init=False, repr=False + ) + + @property + def new_file_count(self) -> int: + return len(self.new_paths) + + @property + def restat_count(self) -> int: + return len(self.paths_to_restat) + + @property + def unlinked_entries_count(self) -> int: + return len(self.unlinked_entries) + + @property + def relinked_entries_count(self) -> int: + return len(self.relinked_entries) + + def reset(self) -> None: + """Clear this engine's scan results.""" + self.new_paths = [] + self.paths_to_restat = [] + self.unlinked_entries = [] + self.relinked_entries = [] + self._scanned_paths = [] + self._filename_to_path_map = None + + def _get_case_sensitivity(self) -> bool: + if self.library.is_case_sensitive_fs is None: + self.library.is_case_sensitive_fs = is_fs_case_sensitive() + return self.library.is_case_sensitive_fs + + def sync_dir( + self, library_dir: Path, force_internal_scanner: bool = False + ) -> Iterator[tuple[int, int]]: + """Scan library directory for files, then reconcile them against the Library's entries. + + - Entries with no matching file on disk are marked as "unlinked" + - Automatically relink to appropriate new files on disk where possible + - Remaining new files on disk are added as new entires + - Remaining unlinked entries are tracked for manual review. + + Yields (searched_count, found_count) + + Args: + library_dir (Path): The library directory. + force_internal_scanner (bool): Option to force the use of the internal scanner + (i.e. wcmatch) instead of third-party tools found on the system (i.e. ripgrep). + """ + self.reset() + self.cancelled = False + + case_sensitive = self._get_case_sensitivity() + cache = self.library.get_or_build_path_cache() + unvisited = set(cache.keys()) + ignore_patterns = Ignore.get_patterns(library_dir) + + start_time = time() + start_time_loop = time() + count = 0 + for raw_path in scan_paths(library_dir, ignore_patterns, force_internal_scanner): + if self.cancelled: + break + count += 1 + self._scanned_paths.append(raw_path) + key = norm_path(raw_path, case_sensitive=case_sensitive) + entry_id = cache.get(key) + if entry_id is not None: + unvisited.discard(key) + self.paths_to_restat.append((entry_id, raw_path)) + else: + self.new_paths.append(raw_path) + + if (time() - start_time_loop) > YIELD_INTERVAL_SECONDS: + yield count, len(self.new_paths) + start_time_loop = time() + + if self.cancelled: + yield count, len(self.new_paths) + logger.info("[Sync] Directory scan cancelled", path=library_dir, files_scanned=count) + return + + unlinked_ids = {cache[key] for key in unvisited} + if self.library.duplicate_path_entry_ids: + unlinked_ids.update(self.library.duplicate_path_entry_ids) + if unlinked_ids: + self.unlinked_entries = self.library.get_entries(list(unlinked_ids)) + + self._auto_relink_matched_entries(case_sensitive, cache) + + yield count, len(self.new_paths) + logger.info( + "[Sync] Directory scan complete", + path=library_dir, + duration=(time() - start_time), + files_scanned=count, + new_files=len(self.new_paths), + unlinked_entries=len(self.unlinked_entries), + relinked_entries=len(self.relinked_entries), + ) + + def save_new_entries(self) -> Iterator[int]: + """Save the paths found on disk that don't have a Library entry yet.""" + batch_size = 200 + library_dir = unwrap(self.library.library_dir) + + index = 0 + while index < len(self.new_paths): + if self.cancelled: + break + yield index + end = min(len(self.new_paths), index + batch_size) + batch = self.new_paths[index:end] + entries = [] + for entry_path in batch: + file_stat = (library_dir / entry_path).stat() + entries.append( + Entry( + path=entry_path, + fields=[], + date_created=get_date_created(file_stat), + date_modified=get_date_modified(file_stat), + file_size=get_file_size(file_stat), + date_added=dt.now(), + ) + ) + self.library.add_entries(entries) # Path cache is updated in the library + index = end + self.new_paths = self.new_paths[index:] # Saved entries are removed from new_paths + + def sync_entry_stats(self) -> Iterator[int]: + """Refresh cached os.stat() metadata for entries already known to the Library.""" + batch_size = 500 + + index = 0 + while index < len(self.paths_to_restat): + if self.cancelled: + break + yield index + end = min(len(self.paths_to_restat), index + batch_size) + self.library.refresh_entries_stats(self.paths_to_restat[index:end]) + index = end + self.paths_to_restat = self.paths_to_restat[index:] + + def _build_filename_to_path_map(self, case_sensitive: bool) -> dict[Path, list[Path]]: + index: dict[Path, list[Path]] = {} + for path in self._scanned_paths: + key = norm_path(Path(path.name), case_sensitive=case_sensitive) + index.setdefault(key, []).append(path) + return index + + def _glob_for_filename(self, filename: str, case_sensitive: bool) -> list[Path]: + """Search the library directory for files matching `filename`. + + Used only as a fallback when find_relink_candidates() is called without a prior + sync_dir() scan in this engine instance to reuse results from. + """ + library_dir = unwrap(self.library.library_dir) + ignore_patterns = ignore_to_glob(Ignore.get_patterns(library_dir)) + target_path = norm_path(Path(filename), case_sensitive=case_sensitive) + flags = PATH_GLOB_FLAGS | (0 if case_sensitive else glob.IGNORECASE) + + matches: list[Path] = [] + for path in pathlib.Path(str(library_dir)).glob( + patterns=f"***/{glob.escape(filename)}", + flags=flags, + exclude=ignore_patterns, + ): + if path.is_dir(): + continue + candidate = Path(path).relative_to(library_dir) + if norm_path(Path(candidate.name), case_sensitive=case_sensitive) == target_path: + matches.append(candidate) + return matches + + def find_relink_candidates(self, entry: Entry) -> list[Path]: + """Try to find files in the library directory matching an unlinked entry's filename. + + Comparisons are made using NFD normalization and the assumed filesystem's case sensitivity. + """ + case_sensitive = self._get_case_sensitivity() + target_key = norm_path(Path(entry.path.name), case_sensitive=case_sensitive) + + if self._scanned_paths: + if self._filename_to_path_map is None: + self._filename_to_path_map = self._build_filename_to_path_map(case_sensitive) + matches = list(self._filename_to_path_map.get(target_key, [])) + else: + matches = self._glob_for_filename(entry.path.name, case_sensitive) + + logger.info("[Sync] Relink candidates", entry=entry.path.as_posix(), matches=matches) + return matches + + def _apply_relink( + self, entry: Entry, new_path: Path, cache: dict[Path, int], case_sensitive: bool + ) -> bool: + """Assign `new_path` to the `entry`, merging into a single entry if entries exist for both. + + Returns: + bool: True if the relink was successful. + """ + new_key = norm_path(new_path, case_sensitive=case_sensitive) + + existing_id = cache.get(new_key) + if existing_id is not None and existing_id != entry.id: + # Merge both entries into one with the single path + target = unwrap(self.library.get_entry_full(existing_id)) + source = unwrap(self.library.get_entry_full(entry.id)) + return self.library.merge_entries(source, target) + return self.library.update_entry_path(entry.id, new_path) + + def relink_unlinked_entries(self) -> Iterator[int]: + """Attempt to fix unlinked entries by finding a single matching file in the library.""" + self.manual_relink_count = 0 + case_sensitive = self._get_case_sensitivity() + cache = self.library.get_or_build_path_cache() + matched: list[Entry] = [] + + for i, entry in enumerate(self.unlinked_entries): + yield i + candidates = self.find_relink_candidates(entry) + if len(candidates) != 1: + continue + new_path = candidates[0] + if not self._apply_relink(entry, new_path, cache, case_sensitive): + continue + + self.manual_relink_count += 1 + matched.append(entry) + logger.info( + "[Sync] Relinked entry", + entry=entry.path.as_posix(), + new_path=new_path.as_posix(), + ) + + for entry in matched: + self.unlinked_entries.remove(entry) + + def _auto_relink_matched_entries(self, case_sensitive: bool, cache: dict[Path, int]) -> None: + """Attempt to automatically relink unlinked entries under available conditions. + + Auto-relink applies to: + - Files with the same filename but different paths + - Handles moves, moves + changes + - Files with different names and/or paths but the same date_modified and file_size + - Handles moves, renames + moves + + Auto-relink DOES NOT apply to: + - Renames + Moves + Changes + - Deletions + - Ambiguous (more than one) matches + """ + if not self.new_paths or not self.unlinked_entries: + return + + library_dir = unwrap(self.library.library_dir) + + # Pass 1: Filename + metadata + by_name_and_stat: dict[tuple[Path, float | None, int | None], list[Entry]] = {} + for entry in self.unlinked_entries: + name_key = norm_path(Path(entry.path.name), case_sensitive=case_sensitive) + by_name_and_stat.setdefault( + (name_key, entry.date_modified, entry.file_size), [] + ).append(entry) + + relinked: list[Entry] = [] + remaining_new: list[Path] = [] + stats_by_path: dict[Path, tuple[float | None, int | None]] = {} + + for new_path in self.new_paths: + try: + file_stat = (library_dir / new_path).stat() + except OSError as e: + logger.error( + "[Sync] Could not stat file during auto-relink check", + path=new_path, + error=e, + ) + remaining_new.append(new_path) + continue + + mtime = get_date_modified(file_stat) + size = get_file_size(file_stat) + stats_by_path[new_path] = (mtime, size) + + name_key = norm_path(Path(new_path.name), case_sensitive=case_sensitive) + key = (name_key, mtime, size) + candidates = by_name_and_stat.get(key, []) + if len(candidates) != 1: + remaining_new.append(new_path) + continue + + entry = candidates[0] + if not self._apply_relink(entry, new_path, cache, case_sensitive): + remaining_new.append(new_path) + continue + by_name_and_stat[key] = [] # Don't match a second new_path here + + logger.info( + "[Sync] Automatically relinked moved file", + old_path=entry.path.as_posix(), + new_path=new_path.as_posix(), + ) + relinked.append(entry) + + for entry in relinked: + self.unlinked_entries.remove(entry) + + # Pass 2: Different filename checking for same metadata + by_stat_only: dict[tuple[float | None, int | None], list[Entry]] = {} + for entry in self.unlinked_entries: + by_stat_only.setdefault((entry.date_modified, entry.file_size), []).append(entry) + + still_remaining: list[Path] = [] + for new_path in remaining_new: + stat_key = stats_by_path.get(new_path) + if stat_key is None: + still_remaining.append(new_path) + continue + + candidates = by_stat_only.get(stat_key, []) + if len(candidates) != 1: + still_remaining.append(new_path) + continue + + entry = candidates[0] + if not self._apply_relink(entry, new_path, cache, case_sensitive): + still_remaining.append(new_path) + continue + by_stat_only[stat_key] = [] + + logger.info( + "[Sync] Automatically relinked renamed file (matched by size/date only)", + old_path=entry.path.as_posix(), + new_path=new_path.as_posix(), + ) + relinked.append(entry) + self.unlinked_entries.remove(entry) + + self.new_paths = still_remaining + self.relinked_entries = relinked + + def remove_unlinked_entries(self) -> None: + """Remove unlinked entries from the Library.""" + # Path cache is updated in the library. + self.library.remove_entries([entry.id for entry in self.unlinked_entries]) + self.unlinked_entries = [] diff --git a/src/tagstudio/core/utils/filesystem.py b/src/tagstudio/core/utils/filesystem.py new file mode 100644 index 000000000..425266084 --- /dev/null +++ b/src/tagstudio/core/utils/filesystem.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +import platform + + +def is_fs_case_sensitive() -> bool: + """Whether the filesystem is case sensitive. + + NOTE: Not authoritative for OSes other than Windows. + """ + # TODO: Make this more robust instead of assuming Windows == NTFS/exFAT + # and other OS filesystems are automatically case sensitive. + return platform.system() != "Windows" diff --git a/src/tagstudio/core/utils/normalization.py b/src/tagstudio/core/utils/normalization.py new file mode 100644 index 000000000..3ba172302 --- /dev/null +++ b/src/tagstudio/core/utils/normalization.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +import unicodedata +from pathlib import Path + + +def norm_path(path: Path | str, case_sensitive: bool) -> Path: + """Return `path` normalized to Unicode Normalization Form D (NFD).""" + if isinstance(path, str): + path = Path(path) + normalized = unicodedata.normalize("NFD", path.as_posix()) + if not case_sensitive: + normalized = normalized.casefold() + return Path(normalized) diff --git a/src/tagstudio/core/utils/stat.py b/src/tagstudio/core/utils/stat.py index 7ee463000..cafd71d4e 100644 --- a/src/tagstudio/core/utils/stat.py +++ b/src/tagstudio/core/utils/stat.py @@ -1,16 +1,28 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: MIT +import os import platform from pathlib import Path -def get_date_modified(path: Path) -> float: - return path.stat().st_mtime +def _resolve(path_or_stat: Path | os.stat_result) -> os.stat_result: + if isinstance(path_or_stat, os.stat_result): + return path_or_stat + return path_or_stat.stat() -def get_date_created(path: Path) -> float: +def get_date_modified(path_or_stat: Path | os.stat_result) -> float: + return _resolve(path_or_stat).st_mtime + + +def get_date_created(path_or_stat: Path | os.stat_result) -> float: + stat = _resolve(path_or_stat) if platform.system() in {"Windows", "Darwin"}: - return path.stat().st_birthtime + return stat.st_birthtime else: - return path.stat().st_ctime + return stat.st_ctime + + +def get_file_size(path_or_stat: Path | os.stat_result) -> int: + return _resolve(path_or_stat).st_size diff --git a/src/tagstudio/qt/controllers/banner.py b/src/tagstudio/qt/controllers/banner.py new file mode 100644 index 000000000..cf6ba5efa --- /dev/null +++ b/src/tagstudio/qt/controllers/banner.py @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import time +from collections.abc import Callable +from typing import Literal, override + +from PySide6.QtCore import ( + QEasingCurve, + QPropertyAnimation, + QRectF, + Qt, + QTimer, + QVariantAnimation, + Signal, +) +from PySide6.QtGui import QColor, QPainter, QPainterPath, QPaintEvent +from PySide6.QtWidgets import QVBoxLayout, QWidget + +from tagstudio.qt.views.banner_view import BannerView +from tagstudio.qt.views.styles.stylesheets import ( + BANNER_CORNER_RADIUS, + banner_notice_bg_color, + banner_notice_style, + banner_progress_bg_color, + banner_progress_chunk_color, + banner_progress_style, +) + +BannerMode = Literal["progress", "notice", "fleeting_notice"] + + +class _BannerBackground(QWidget): + """The banner's background widget. Used for custom animations, like fading the color.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._bg_color = QColor(Qt.GlobalColor.transparent) + + @property + def bg_color(self) -> QColor: + return self._bg_color + + def set_bg_color(self, color: QColor) -> None: + self._bg_color = color + self.update() + + @override + def paintEvent(self, event: QPaintEvent) -> None: + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + path = QPainterPath() + path.addRoundedRect(QRectF(self.rect()), BANNER_CORNER_RADIUS, BANNER_CORNER_RADIUS) + painter.fillPath(path, self._bg_color) + painter.end() + + +class Banner(QWidget): + """A notification banner with an optional progress bar, action button, and close button.""" + + CONTENT_HEIGHT = 36 + GAP = 6 + HEIGHT = CONTENT_HEIGHT + GAP + ANIMATION_MS = 250 + COLOR_ANIMATION_MS = 250 + MIN_VISIBLE_MS = 3000 + STARTUP_EXTRA_HOLD_MS = 500 # Starting up may eat into time shown, so add extra time. + + notice_action_clicked = Signal() + cancel_requested = Signal() + dismissed = Signal() + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setMinimumHeight(0) + self.setMaximumHeight(0) + + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 0, 0, self.GAP) + outer_layout.setSpacing(0) + + self._background = _BannerBackground(self) + self._background.setObjectName("banner") + self.view = BannerView() + self._background.setLayout(self.view) + outer_layout.addWidget(self._background) + + self._mode: BannerMode = "progress" + self._progress_phase: object = None + self._notice_style = banner_notice_style() + self._progress_style = banner_progress_style() + self._notice_bg_color = banner_notice_bg_color() + self._progress_bg_color = banner_progress_bg_color() + self._background.setStyleSheet(self._notice_style) + self._background.set_bg_color(self._notice_bg_color) + self.view.progress_bar.set_corner_radius(BANNER_CORNER_RADIUS) + self.view.progress_bar.set_chunk_color(banner_progress_chunk_color()) + + self._card_color_anim = QVariantAnimation(self) + self._card_color_anim.setDuration(self.COLOR_ANIMATION_MS) + self._card_color_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._card_color_anim.valueChanged.connect(self._background.set_bg_color) + + self._connect_callbacks() + self._set_mode("notice") + + self._height_anim = QPropertyAnimation(self, b"maximumHeight", self) + self._height_anim.setDuration(self.ANIMATION_MS) + self._height_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._height_anim.valueChanged.connect(self.setMinimumHeight) + + self._shown_at: float | None = None + self._extra_hold_ms = 0 + self._hide_timer = QTimer(self) + self._hide_timer.setSingleShot(True) + self._hide_timer.timeout.connect(lambda: self._start_height_animation(0)) + + def request_extra_duration(self) -> None: + """Add STARTUP_EXTRA_HOLD_MS to the next automatic hide's minimum-visible window.""" + self._extra_hold_ms = self.STARTUP_EXTRA_HOLD_MS + + def call_when_open(self, callback: Callable[[], None]) -> None: + """Call `callback` if/when the banner is fully open.""" + if self.maximumHeight() == self.HEIGHT and self._height_anim.state() != ( + QPropertyAnimation.State.Running + ): + callback() + return + + def _on_finished() -> None: + self._height_anim.finished.disconnect(_on_finished) + callback() + + self._height_anim.finished.connect(_on_finished) + + def _connect_callbacks(self) -> None: + self.view.close_button.clicked.connect(self._on_dismiss) + self.view.action_button.clicked.connect(self._on_action_clicked) + + def _on_action_clicked(self) -> None: + self.notice_action_clicked.emit() + + def _start_height_animation(self, target_height: int) -> None: + if self.maximumHeight() == target_height and self._height_anim.state() != ( + QPropertyAnimation.State.Running + ): + return + self._height_anim.stop() + self._height_anim.setStartValue(self.maximumHeight()) + self._height_anim.setEndValue(target_height) + self._height_anim.start() + + def _animate_to(self, target_height: int, force: bool = False) -> None: + self._hide_timer.stop() + if target_height > 0: + self._shown_at = time.monotonic() + self._start_height_animation(target_height) + return + + extra_hold_ms = self._extra_hold_ms + self._extra_hold_ms = 0 + if not force and self._shown_at is not None: + elapsed_ms = (time.monotonic() - self._shown_at) * 1000 + remaining_ms = (self.MIN_VISIBLE_MS + extra_hold_ms) - elapsed_ms + if remaining_ms > 0: + self._hide_timer.start(int(remaining_ms)) + return + self._shown_at = None + self._start_height_animation(0) + + def _set_mode(self, mode: BannerMode): + if mode == self._mode: + return + + self._mode = mode + self._progress_phase = None + self.view.label.reset_width() + self.view.close_button.setVisible(mode != "fleeting_notice") + self.view.action_button.setVisible(mode == "notice") + self.view.progress_bar.setVisible(mode == "progress") + + # The progress bar state gets a darkened background, while notices get the accent color. + is_progress = mode == "progress" + self._background.setStyleSheet(self._progress_style if is_progress else self._notice_style) + + target_color = self._progress_bg_color if is_progress else self._notice_bg_color + self._card_color_anim.stop() + self._card_color_anim.setStartValue(self._background.bg_color) + self._card_color_anim.setEndValue(target_color) + self._card_color_anim.start() + + def _present(self, mode: BannerMode, button_text: str, message: str) -> None: + """Applies the banner mode and any label + button text, then animates the banner open.""" + self._set_mode(mode) + self.view.action_button.setText(button_text) + self.view.label.setText(message) + self._animate_to(self.HEIGHT) + + def _on_dismiss(self): + if self._mode == "progress": + self.cancel_requested.emit() + # Explicit dismiss, apply immediately + self._animate_to(0, force=True) + self.dismissed.emit() + + def show_notice(self, message: str, action_text: str) -> None: + """Show a dismissible notice with an action button, until dismissed or replaced.""" + self._present("notice", action_text, message) + + def show_fleeting_notice(self, message: str) -> None: + """Show a brief notice with no action button that dismisses itself automatically.""" + self._set_mode("fleeting_notice") + self.view.label.setText(message) + self._animate_to(self.HEIGHT) + # Deferred by the existing MIN_VISIBLE_MS guard, same as an unforced hide_banner(). + self._animate_to(0) + + def show_progress(self, text: str, value: int = 0, maximum: int = 0, phase: str | None = None): + """Show the progress banner. + + Args: + text (str): The status text shown in the banner body. + value (int): The current progress value. + maximum (int): The maximum progress value. If 0, shown as indeterminate. + phase (str | None): An identifier for the current sub-phase of progress. + Helps inform widgets that need to update between phases, like the StableLabel. + """ + self._set_mode("progress") + if phase != self._progress_phase: + self._progress_phase = phase + self.view.label.reset_width() + self.view.label.setText(text) + self.view.progress_bar.set_range(0, maximum) + self.view.progress_bar.set_value(value) + self._animate_to(self.HEIGHT) + + def hide_banner(self, force: bool = False): + """Hide the banner (if shown). + + Args: + force (bool): Bypass the minimum visible duration and hide immediately. + """ + self._animate_to(0, force=force) diff --git a/src/tagstudio/qt/controllers/inspector.py b/src/tagstudio/qt/controllers/inspector.py index 4e98c5464..ebeacc388 100644 --- a/src/tagstudio/qt/controllers/inspector.py +++ b/src/tagstudio/qt/controllers/inspector.py @@ -158,7 +158,8 @@ def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData if stats.duration is not None: self._current_stats.duration = stats.duration - self.layout().file_attrs.update_stats(filepath, self._current_stats) + entry = unwrap(self._lib.get_entry(self._selected[0])) + self.layout().file_attrs.update_stats(filepath, self._current_stats, entry) def _set_selection_callback(self) -> None: with catch_warnings(record=True): @@ -257,8 +258,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non if update_preview: stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath) self._current_stats = stats - self.layout().file_attrs.update_stats(filepath, stats) - self.layout().file_attrs.update_date_label(filepath) + self.layout().file_attrs.update_stats(filepath, stats, entry) + self.layout().file_attrs.update_date_label(entry) self.layout().containers.update_from_entry(entry_id) self._set_selection_callback() diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py index 98eeb4bad..5632fd370 100644 --- a/src/tagstudio/qt/controllers/main_window.py +++ b/src/tagstudio/qt/controllers/main_window.py @@ -40,6 +40,7 @@ from tagstudio.core.library.alchemy.enums import SortingModeEnum from tagstudio.i18n.platform_strings import trash_term from tagstudio.i18n.translations import Translations +from tagstudio.qt.controllers.banner import Banner from tagstudio.qt.controllers.inspector import Inspector from tagstudio.qt.helpers.mnemonics import assign_mnemonics from tagstudio.qt.mixed.landing import LandingWidget @@ -64,7 +65,7 @@ class MainMenuBar(QMenuBar): save_library_backup_action: QAction settings_action: QAction open_on_start_action: QAction - refresh_dir_action: QAction + sync_library_action: QAction close_library_action: QAction edit_menu: QMenu @@ -152,17 +153,17 @@ def setup_file_menu(self): self.file_menu.addSeparator() - # Refresh Directories - self.refresh_dir_action = QAction(Translations["menu.file.refresh_directories"], self) - self.refresh_dir_action.setShortcut( + # Sync Library + self.sync_library_action = QAction(Translations["menu.file.sync_library"], self) + self.sync_library_action.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), QtCore.Qt.Key.Key_R, ) ) - self.refresh_dir_action.setStatusTip("Ctrl+R") - self.refresh_dir_action.setEnabled(False) - self.file_menu.addAction(self.refresh_dir_action) + self.sync_library_action.setStatusTip("Ctrl+R") + self.sync_library_action.setEnabled(False) + self.file_menu.addAction(self.sync_library_action) self.file_menu.addSeparator() @@ -485,6 +486,7 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None: # initialized in setup_entry_list self.entry_list_container: QWidget self.entry_list_layout: QVBoxLayout + self.banner: Banner self.entry_scroll_area: QScrollArea self.thumb_grid: QWidget self.thumb_layout: ThumbGridLayout @@ -691,6 +693,9 @@ def setup_entry_list(self, driver: QtDriver): self.thumb_grid.setLayout(self.thumb_layout) self.entry_scroll_area.setWidget(self.thumb_grid) + self.banner = Banner() + self.entry_list_layout.addWidget(self.banner) + self.entry_list_layout.addWidget(self.entry_scroll_area) self.landing_widget = LandingWidget(driver, self.devicePixelRatio()) @@ -698,6 +703,7 @@ def setup_entry_list(self, driver: QtDriver): self.pagination = Pagination() self.entry_list_layout.addWidget(self.pagination) + self.content_splitter.addWidget(self.entry_list_container) def setup_preview_panel(self, driver: QtDriver): diff --git a/src/tagstudio/qt/controllers/relink_entries_progress.py b/src/tagstudio/qt/controllers/relink_entries_progress.py index 9aae50563..0a09f7ad7 100644 --- a/src/tagstudio/qt/controllers/relink_entries_progress.py +++ b/src/tagstudio/qt/controllers/relink_entries_progress.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QObject, Signal -from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry +from tagstudio.core.library.sync import LibrarySyncEngine from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.progress_bar import ProgressWidget @@ -12,7 +12,7 @@ class RelinkUnlinkedEntriesProgress(QObject): done = Signal() - def __init__(self, tracker: UnlinkedRegistry): + def __init__(self, tracker: LibrarySyncEngine): super().__init__() self.tracker = tracker @@ -22,7 +22,7 @@ def displayed_text(x): "entries.unlinked.relink.attempting", index=x, unlinked_count=self.tracker.unlinked_entries_count, - fixed_count=self.tracker.files_fixed_count, + fixed_count=self.tracker.manual_relink_count, ) pw = ProgressWidget( @@ -32,4 +32,6 @@ def displayed_text(x): maximum=self.tracker.unlinked_entries_count, ) pw.setWindowTitle(Translations["entries.unlinked.relink.title"]) - pw.from_iterable_function(self.tracker.fix_unlinked_entries, displayed_text, self.done.emit) + pw.from_iterable_function( + self.tracker.relink_unlinked_entries, displayed_text, self.done.emit + ) diff --git a/src/tagstudio/qt/controllers/rounded_progress_bar.py b/src/tagstudio/qt/controllers/rounded_progress_bar.py new file mode 100644 index 000000000..2dc0ae5ce --- /dev/null +++ b/src/tagstudio/qt/controllers/rounded_progress_bar.py @@ -0,0 +1,236 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import time +from typing import override + +from PySide6.QtCore import QEasingCurve, QRectF, Qt, QTimer, QVariantAnimation +from PySide6.QtGui import ( + QColor, + QHideEvent, + QLinearGradient, + QPainter, + QPainterPath, + QPaintEvent, + QResizeEvent, + QShowEvent, +) +from PySide6.QtWidgets import QWidget + + +class RoundedProgressBar(QWidget): + """A custom stylized progress bar that supports smooth animations and rounded corners.""" + + MARQUEE_FRACTION = 0.5 + MARQUEE_INTERVAL_MS = 8 + MARQUEE_CYCLE_MS = 2000 + VALUE_ANIMATION_MS = 150 + FADE_MS = 2000 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._minimum = 0 + self._maximum = 0 + self._value = 0 + + self._displayed_value = 0.0 + self._corner_radius = 0.0 + self._chunk_color = QColor(Qt.GlobalColor.transparent) + self._marquee_start: float | None = None + self._cached_path: QPainterPath | None = None + + self._opacity = 1.0 + self._pending_range_change = False + + self._marquee_timer = QTimer(self) + self._marquee_timer.setTimerType(Qt.TimerType.PreciseTimer) + self._marquee_timer.setInterval(self.MARQUEE_INTERVAL_MS) + self._marquee_timer.timeout.connect(self.update) + + self._value_anim = QVariantAnimation(self) + self._value_anim.setDuration(self.VALUE_ANIMATION_MS) + self._value_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._value_anim.valueChanged.connect(self._on_value_anim_changed) + self._value_anim.finished.connect(self._on_value_anim_finished) + + self._fade_anim = QVariantAnimation(self) + self._fade_anim.setDuration(self.FADE_MS) + self._fade_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._fade_anim.valueChanged.connect(self._on_fade_anim_changed) + + def _on_value_anim_changed(self, value: float) -> None: + self._displayed_value = value + self.update() + + def _on_value_anim_finished(self) -> None: + """Start the fade-out once the progress bar has reached its maximum.""" + if not self._is_indeterminate() and self._value >= self._maximum: + self._fade_anim.stop() + self._fade_anim.setStartValue(self._opacity) + self._fade_anim.setEndValue(0.0) + self._fade_anim.start() + + def _on_fade_anim_changed(self, value: float) -> None: + """Update the fill opacity for the fade animation.""" + self._opacity = value + self.update() + + def _reset_fade(self) -> None: + """Stop any fading and reset opacity back to full.""" + self._fade_anim.stop() + if self._opacity != 1.0: + self._opacity = 1.0 + self.update() + + def set_range(self, minimum: int, maximum: int) -> None: + """Set the value range, switching to indeterminate mode if maximum <= minimum.""" + if (minimum, maximum) == (self._minimum, self._maximum): + return + + self._pending_range_change = True + self._minimum = minimum + self._maximum = maximum + if self._is_indeterminate(): + self._reset_fade() + self._sync_marquee_timer() + self.update() + + def set_value(self, value: int) -> None: + """Animate the fill towards `value`.""" + self._value = value + if self._is_indeterminate(): + self._pending_range_change = False + return + if self._pending_range_change: + self._pending_range_change = False + if value >= self._maximum: + self._value_anim.stop() + self._displayed_value = float(self._maximum) + self.update() + self._on_value_anim_finished() + return + + self._displayed_value = float(self._minimum) + self._reset_fade() + elif value < self._maximum: + self._reset_fade() + self._value_anim.stop() + self._value_anim.setStartValue(self._displayed_value) + self._value_anim.setEndValue(float(value)) + self._value_anim.start() + + def set_corner_radius(self, radius: float) -> None: + """Set the bottom corner radius and invalidate the cached QPainterPath.""" + self._corner_radius = radius + self._cached_path = None + self.update() + + def set_chunk_color(self, color: QColor) -> None: + """Set the fill color.""" + self._chunk_color = color + self.update() + + def _is_indeterminate(self) -> bool: + """Whether the bar is in indeterminate (marquee) mode.""" + return self._maximum <= self._minimum + + def _sync_marquee_timer(self) -> None: + """Start or stop the marquee timer to match the current mode/visibility.""" + if self._is_indeterminate() and self.isVisible(): + if self._marquee_start is None: + self._marquee_start = time.monotonic() + if not self._marquee_timer.isActive(): + self._marquee_timer.start() + else: + self._marquee_timer.stop() + self._marquee_start = None + + def _marquee_fraction(self) -> float: + """Return the marquee's current position as a fraction of its cycle.""" + if self._marquee_start is None: + return 0.0 + elapsed_ms = (time.monotonic() - self._marquee_start) * 1000 + return (elapsed_ms % self.MARQUEE_CYCLE_MS) / self.MARQUEE_CYCLE_MS + + @override + def showEvent(self, event: QShowEvent) -> None: + """Resume the marquee timer when the bar becomes visible.""" + super().showEvent(event) + self._sync_marquee_timer() + + @override + def hideEvent(self, event: QHideEvent) -> None: + """Stop all animations and reset state when the bar is hidden.""" + super().hideEvent(event) + self._marquee_timer.stop() + self._marquee_start = None + self._value_anim.stop() + self._reset_fade() + self._pending_range_change = False + + @override + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._cached_path = None # Invalidate the cached QPainterPath on resize + + def _bottom_rounded_path(self, rect: QRectF) -> QPainterPath: + """Return the bottom-rounded clip path for `rect`.""" + if self._cached_path is not None: + return self._cached_path + + # TODO: Currently these values are hardcoded for use with the Banner widget, but this + # could be made customizable to specify the exact rounding configuration. + # If you're reading this and want to make use of this progress bar, there you go. + radius = max(0.0, min(self._corner_radius, rect.width() / 2)) + diam = radius * 2 + path = QPainterPath() + path.moveTo(rect.left(), rect.top()) + path.lineTo(rect.right(), rect.top()) + path.lineTo(rect.right(), rect.bottom() - radius) + path.arcTo(QRectF(rect.right() - diam, rect.bottom() - diam, diam, diam), 0, -90) + path.lineTo(rect.left() + radius, rect.bottom()) + path.arcTo(QRectF(rect.left(), rect.bottom() - diam, diam, diam), -90, -90) + path.closeSubpath() + self._cached_path = path + return path + + def _marquee_gradient(self, full_chunk_rect: QRectF) -> QLinearGradient: + """Gradient for the indeterminate marquee mode (Transparent -> Color -> Transparent).""" + gradient = QLinearGradient(full_chunk_rect.left(), 0, full_chunk_rect.right(), 0) + transparent = QColor(self._chunk_color) + transparent.setAlpha(0) + gradient.setColorAt(0.0, transparent) + gradient.setColorAt(0.5, self._chunk_color) + gradient.setColorAt(1.0, transparent) + return gradient + + @override + def paintEvent(self, event: QPaintEvent) -> None: + del event + # Paint the marquee or normal chunk fill, clipped to the rounded bottom corners. + rect = QRectF(self.rect()) + + if self._is_indeterminate(): + chunk_width = rect.width() * self.MARQUEE_FRACTION + travel = rect.width() * (1 + self.MARQUEE_FRACTION) + chunk_left = (self._marquee_fraction() * travel) - chunk_width + full_chunk_rect = QRectF(chunk_left, 0, chunk_width, rect.height()) + chunk_rect = full_chunk_rect.intersected(rect) + if chunk_rect.isEmpty(): + return + fill = self._marquee_gradient(full_chunk_rect) + else: + fraction = (self._displayed_value - self._minimum) / (self._maximum - self._minimum) + fraction = max(0.0, min(1.0, fraction)) + chunk_rect = QRectF(0, 0, rect.width() * fraction, rect.height()).intersected(rect) + if chunk_rect.isEmpty(): + return + fill = self._chunk_color + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setClipPath(self._bottom_rounded_path(rect)) + painter.setOpacity(self._opacity) + painter.fillRect(chunk_rect, fill) + painter.end() diff --git a/src/tagstudio/qt/controllers/stable_label.py b/src/tagstudio/qt/controllers/stable_label.py new file mode 100644 index 000000000..067d2d4a9 --- /dev/null +++ b/src/tagstudio/qt/controllers/stable_label.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import override + +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QLabel, QWidget + + +class StableLabel(QLabel): + """A QLabel that resists "jiggling" from rapidly changing text. + + Holds its `sizeHint()` width at the widest shown since the last reset_width() call, + and is always kept left aligned of that, so a centering layout's box stops + growing/shrinking on every update (aka the "jiggle" effect). + """ + + def __init__(self, text: str = "", parent: QWidget | None = None) -> None: + super().__init__(text, parent) + self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self._min_width = 0 + + def reset_width(self) -> None: + """Let the tracked width shrink again, for new unrelated text.""" + self._min_width = 0 + self.updateGeometry() + + @override + def setText(self, text: str) -> None: + super().setText(text) + self._min_width = max(self._min_width, super().sizeHint().width()) + self.updateGeometry() + + @override + def sizeHint(self) -> QSize: + hint = super().sizeHint() + return QSize(max(hint.width(), self._min_width), hint.height()) diff --git a/src/tagstudio/qt/mixed/drop_import_modal.py b/src/tagstudio/qt/mixed/drop_import_modal.py index 859097584..6f54c5925 100644 --- a/src/tagstudio/qt/mixed/drop_import_modal.py +++ b/src/tagstudio/qt/mixed/drop_import_modal.py @@ -178,7 +178,7 @@ def displayed_text(x): pw.from_iterable_function( self.copy_files, displayed_text, - self.driver.add_new_files_callback, + self.driver.sync_library_callback, self.deleteLater, ) diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index cb595bd58..b4136fd0e 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -3,7 +3,6 @@ import os -import platform import typing from dataclasses import dataclass from datetime import datetime as dt @@ -17,6 +16,7 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaTypes from tagstudio.core.query_lang.file_groups import SEARCH @@ -92,40 +92,35 @@ def __init__(self, library: Library, driver: QtDriver): self.library = library self.driver = driver - def update_date_label(self, filepath: Path | None = None) -> None: + def _format_date_or_na(self, timestamp: float | None) -> str: + if timestamp is None: + return "N/A" + return self.driver.settings.format_datetime(dt.fromtimestamp(timestamp)) + + def update_date_label(self, entry: Entry | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" - if filepath and filepath.is_file(): - created: dt - if platform.system() == "Windows" or platform.system() == "Darwin": - # NOTE: Accessing stat().st_birthtime causes linter checks to fail on some systems. - created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore] - else: - created = dt.fromtimestamp(filepath.stat().st_ctime) - modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) - self.date_created_label.setText( - f"{Translations['file.date_created']}:" - + f" {self.driver.settings.format_datetime(created)}" - ) - self.date_modified_label.setText( - f"{Translations['file.date_modified']}: " - f"{self.driver.settings.format_datetime(modified)}" - ) - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - elif filepath: - self.date_created_label.setText( - f"{Translations['file.date_created']}: N/A" - ) - self.date_modified_label.setText( - f"{Translations['file.date_modified']}: N/A" - ) - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - else: + if entry is None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - - def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): + return + + created_text = self._format_date_or_na(entry.date_created) + modified_text = self._format_date_or_na(entry.date_modified) + self.date_created_label.setText( + f"{Translations['file.date_created']}: {created_text}" + ) + self.date_modified_label.setText( + f"{Translations['file.date_modified']}: {modified_text}" + ) + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + + def update_stats( + self, + filepath: Path | None = None, + stats: FileAttributeData | None = None, + entry: Entry | None = None, + ): """Render the panel widgets with the newest data from the Library.""" if not stats: stats = FileAttributeData() @@ -170,18 +165,15 @@ def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | # Initialize the possible stat variables stats_label_text = "" ext_display: str = "" - file_size: str = "" + file_size: str = format_size(entry.file_size) if entry and entry.file_size else "" font_family: str = "" # Attempt to populate the stat variables ext_display = ext.upper()[1:] or filepath.stem.upper() - if filepath and filepath.is_file(): + if filepath and filepath.is_file() and MediaTypes.contains("font", ext, SEARCH): try: - file_size = format_size(filepath.stat().st_size) - - if MediaTypes.contains("font", ext, SEARCH): - font = ImageFont.truetype(filepath) - font_family = f"{font.getname()[0]} ({font.getname()[1]}) " + font = ImageFont.truetype(filepath) + font_family = f"{font.getname()[0]} ({font.getname()[1]}) " except (FileNotFoundError, OSError) as e: logger.error( "[FileAttributes] Could not process file stats", filepath=filepath, error=e @@ -206,14 +198,15 @@ def add_newline(stats_label_text: str) -> str: f" • " f"{Translations['preview.ignored'].upper()}" ) + if file_size: + stats_label_text += f" • {file_size}" if not filepath.exists(): stats_label_text = ( f"{stats_label_text}" f" • " f"{Translations['preview.unlinked'].upper()}" ) - if file_size: - stats_label_text += f" • {file_size}" + elif file_size: stats_label_text += file_size diff --git a/src/tagstudio/qt/mixed/fix_unlinked.py b/src/tagstudio/qt/mixed/fix_unlinked.py index 39cad7c79..4a9a958e0 100644 --- a/src/tagstudio/qt/mixed/fix_unlinked.py +++ b/src/tagstudio/qt/mixed/fix_unlinked.py @@ -4,12 +4,13 @@ from typing import TYPE_CHECKING, override +import structlog from PySide6 import QtCore, QtGui from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry +from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.merge_dupe_entries_progress import MergeDuplicateEntriesProgress from tagstudio.qt.controllers.progress_bar import ProgressWidget @@ -17,19 +18,19 @@ from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal from tagstudio.qt.views.styles.stylesheets import header -# Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: from tagstudio.qt.qt_driver import QtDriver +logger = structlog.get_logger(__name__) -# TODO: Split to use MVC guidelines. + +# TODO: Split to use MVC guidelines, or completely redo. class FixUnlinkedEntriesModal(QWidget): def __init__(self, library: Library, driver: QtDriver): super().__init__() self.lib = library self.driver = driver - - self.tracker = UnlinkedRegistry(lib=self.lib) + self.sync_engine = driver.sync_engine self.unlinked_count = -1 self.dupe_count = -1 @@ -56,14 +57,13 @@ def __init__(self, library: Library, driver: QtDriver): self.refresh_unlinked_button.clicked.connect(self.refresh_unlinked) self.merge_class = MergeDuplicateEntriesProgress(self.lib, self.driver) - self.relink_class = RelinkUnlinkedEntriesProgress(self.tracker) + self.relink_class = RelinkUnlinkedEntriesProgress(self.sync_engine) self.search_button = QPushButton(Translations["entries.unlinked.search_and_relink"]) self.relink_class.done.connect( - # refresh the grid lambda: ( self.driver.update_browsing_state(), - self.refresh_unlinked(), + self._sync_ui_from_tracker(), ) ) self.search_button.clicked.connect(self.relink_class.repair_entries) @@ -72,16 +72,16 @@ def __init__(self, library: Library, driver: QtDriver): self.manual_button.setHidden(True) self.remove_button = QPushButton(Translations["entries.unlinked.remove_alt"]) - self.remove_modal = RemoveUnlinkedEntriesModal(self.driver, self.tracker) + self.remove_modal = RemoveUnlinkedEntriesModal(self.driver, self.sync_engine) self.remove_modal.done.connect( lambda: ( - self.set_unlinked_count(), - # refresh the grid self.driver.update_browsing_state(), - self.refresh_unlinked(), + self._sync_ui_from_tracker(), ) ) - self.remove_button.clicked.connect(self.remove_modal.show) + self.remove_button.clicked.connect( + lambda: (self.remove_modal.refresh_list(), self.remove_modal.show()) + ) self.button_container = QWidget() self.button_layout = QHBoxLayout(self.button_container) @@ -106,6 +106,11 @@ def __init__(self, library: Library, driver: QtDriver): self.update_unlinked_count() def refresh_unlinked(self): + if self.driver.file_scan_lock: + logger.info("[FixUnlinkedEntries] Sync already in progress, ignoring refresh request") + return + self.driver.file_scan_lock = True + pw = ProgressWidget( cancel_button_text=None, minimum=0, @@ -114,37 +119,41 @@ def refresh_unlinked(self): pw.setWindowTitle(Translations["library.scan_library.title"]) pw.update_label(Translations["entries.unlinked.scanning"]) - def update_driver_widgets(): + def finish(): + self.driver.file_scan_lock = False if ( hasattr(self.driver, "library_info_window") and self.driver.library_info_window.isVisible() ): self.driver.library_info_window.update_cleanup() + # Uses the Library's shared path cache pw.from_iterable_function( - self.tracker.refresh_unlinked_files, + lambda: self.sync_engine.sync_dir(unwrap(self.lib.library_dir)), None, self.set_unlinked_count, + finish, self.update_unlinked_count, self.remove_modal.refresh_list, - update_driver_widgets, ) + def _sync_ui_from_tracker(self) -> None: + """Refresh the UI from the tracker's current state, without rescanning the library.""" + self.set_unlinked_count() + self.update_unlinked_count() + self.remove_modal.refresh_list() + def set_unlinked_count(self): """Sets the unlinked_entries_count in the Library to the tracker's value.""" - self.lib.unlinked_entries_count = self.tracker.unlinked_entries_count + self.lib.unlinked_entries_count = self.sync_engine.unlinked_entries_count def update_unlinked_count(self): """Updates the UI to reflect the Library's current unlinked_entries_count.""" - # Indicates that the library is new compared to the last update. - # NOTE: Make sure set_unlinked_count() is called before this! - if self.tracker.unlinked_entries_count > 0 and self.lib.unlinked_entries_count < 0: - self.tracker.reset() - count: int = self.lib.unlinked_entries_count + syncing = self.driver.file_scan_lock # Disabled while a sync is running - self.search_button.setDisabled(count < 1) - self.remove_button.setDisabled(count < 1) + self.search_button.setDisabled(count < 1 or syncing) + self.remove_button.setDisabled(count < 1 or syncing) count_text: str = Translations.format( "entries.unlinked.unlinked_count", count=count if count >= 0 else "—" diff --git a/src/tagstudio/qt/mixed/remove_unlinked_modal.py b/src/tagstudio/qt/mixed/remove_unlinked_modal.py index d34fa02b9..38faa86e7 100644 --- a/src/tagstudio/qt/mixed/remove_unlinked_modal.py +++ b/src/tagstudio/qt/mixed/remove_unlinked_modal.py @@ -9,7 +9,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import QHBoxLayout, QLabel, QListView, QPushButton, QVBoxLayout, QWidget -from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry +from tagstudio.core.library.sync import LibrarySyncEngine from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.progress_bar import ProgressWidget from tagstudio.qt.utils.custom_runnable import CustomRunnable @@ -18,11 +18,11 @@ from tagstudio.qt.qt_driver import QtDriver -# TODO: Split to use MVC guidelines. +# TODO: Split to use MVC guidelines or completely redo. class RemoveUnlinkedEntriesModal(QWidget): done = Signal() - def __init__(self, driver: QtDriver, tracker: UnlinkedRegistry): + def __init__(self, driver: QtDriver, tracker: LibrarySyncEngine): super().__init__() self.driver = driver self.tracker = tracker diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index 57ba1877e..d38dfb18b 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -17,10 +17,11 @@ import time from argparse import Namespace from collections import OrderedDict +from collections.abc import Callable, Iterator from functools import partial from pathlib import Path from queue import Queue -from typing import TypeVar +from typing import Literal, TypeVar from warnings import catch_warnings import structlog @@ -47,7 +48,7 @@ from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.ignore import Ignore -from tagstudio.core.library.refresh import RefreshTracker +from tagstudio.core.library.sync import LibrarySyncEngine from tagstudio.core.media_types import MediaTypes from tagstudio.core.query_lang.file_groups import SEARCH from tagstudio.core.query_lang.util import ParsingError @@ -67,7 +68,6 @@ from tagstudio.qt.controllers.library_info_window import LibraryInfoWindow from tagstudio.qt.controllers.main_window import MainWindow from tagstudio.qt.controllers.modal import Modal -from tagstudio.qt.controllers.progress_bar import ProgressWidget from tagstudio.qt.controllers.splash import SplashScreen from tagstudio.qt.controllers.tag_search_panel import TagSearchPanel from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox @@ -104,6 +104,9 @@ from signal import SIGINT, SIGQUIT, SIGTERM, signal # pyright: ignore logger = structlog.get_logger(__name__) +T = TypeVar("T") +# Used to track the context state of the banner widget. +_BannerContext = Literal["new_files", "unlinked", "relinked", "sync_disabled", "sync_finished"] def clamp(value, lower_bound, upper_bound): @@ -128,9 +131,6 @@ def run(self): pass -T = TypeVar("T") - - # Ex. User visits | A ->[B] | # | A B ->[C]| # | A [B]<- C | @@ -192,13 +192,17 @@ class QtDriver(DriverMixin, QObject): def __init__(self, args: Namespace): super().__init__() - # prevent recursive badges update when multiple items selected - self.badge_update_lock = False self.lib = Library() + self.sync_engine = LibrarySyncEngine(self.lib) self.rm: ResourceManager = ResourceManager() self.args = args self.frame_content: list[int] = [] # List of Entry IDs for the current query + self.badge_update_lock = False + self.file_scan_lock: bool = False # Prevent multiple file scanning operations at once self._selected: OrderedDict[int, None] = OrderedDict() + self._sync_session_id: int = 0 # Prevent current sync from affecting subsequent libraries. + self._sync_disabled_notice_shown: bool = False + self._banner_context: _BannerContext | None = None self.pages_count = 0 self.scrollbar_pos = 0 @@ -454,9 +458,9 @@ def set_open_last_loaded_on_startup(checked: bool): set_open_last_loaded_on_startup ) - # Refresh Directories - self.main_window.menu_bar.refresh_dir_action.triggered.connect( - lambda: self.call_if_library_open(self.add_new_files_callback) + # Sync Library + self.main_window.menu_bar.sync_library_action.triggered.connect( + lambda: self.call_if_library_open(self.sync_library_callback) ) # Close Library @@ -552,13 +556,8 @@ def on_increase_thumbnail_size_action(): # region Tools Menu =========================================================== - def create_fix_unlinked_entries_modal(): - if not hasattr(self, "unlinked_modal"): - self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self) - self.unlinked_modal.show() - self.main_window.menu_bar.fix_unlinked_entries_action.triggered.connect( - create_fix_unlinked_entries_modal + self.open_fix_unlinked_entries_modal ) def create_ignored_entries_modal(): @@ -577,7 +576,7 @@ def create_dupe_files_modal(): self.main_window.menu_bar.fix_dupe_files_action.triggered.connect(create_dupe_files_modal) - # TODO: Move this to a settings screen. + # TODO: Make this accessible somewhere more sensible too, like "Library Information" self.main_window.menu_bar.clear_thumb_cache_action.triggered.connect( lambda: unwrap(self.cache_manager).clear_cache() ) @@ -638,6 +637,7 @@ def on_visible_changed(entry_id: int | None): self.init_library_window() self.migration_modal: JsonMigrationModal | None = None + self.main_window.banner.request_extra_duration() path_result = self.evaluate_path(str(self.args.open).lstrip().rstrip()) if path_result.success and path_result.library_path: self.open_library(path_result.library_path) @@ -678,6 +678,9 @@ def init_library_window(self): # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) def _update_browsing_state(): + # Clear any banner asking for a manual refresh of the view + if self._banner_context == "new_files": + self._clear_notice() try: self.update_browsing_state( BrowsingState.from_search_query(self.main_window.search_field.text()) @@ -725,9 +728,11 @@ def _update_browsing_state(): self.main_window.back_button.clicked.connect(lambda: self.navigation_callback(-1)) self.main_window.forward_button.clicked.connect(lambda: self.navigation_callback(1)) - # NOTE: Putting this early will result in a white non-responsive - # window until everything is loaded. Consider adding a splash screen - # or implementing some clever loading tricks. + # Banner + self.main_window.banner.notice_action_clicked.connect(self._on_notice_action_clicked) + self.main_window.banner.cancel_requested.connect(self._on_sync_cancel_requested) + + # NOTE: Putting this too early will result in a non-responsive white window on start. self.main_window.show() self.main_window.activateWindow() self.main_window.toggle_landing_page(enabled=True) @@ -784,8 +789,14 @@ def close_library(self, is_shutdown: bool = False): if not self.lib.library_dir: logger.info("No Library to Close") return - logger.info("Closing Library...") + + self.sync_engine.cancelled = True + self.file_scan_lock = False + self._new_sync_session() # Invalidate any sync still active for the old library + self._banner_context = None + self.main_window.banner.hide_banner(force=True) + self.main_window.status_bar.showMessage(Translations["status.library_closing"]) start_time = time.time() @@ -800,6 +811,7 @@ def close_library(self, is_shutdown: bool = False): self.__reset_navigation() self.lib.close() + self.sync_engine.reset() self.cache_manager = None self.thumb_job_queue.queue.clear() @@ -827,7 +839,7 @@ def close_library(self, is_shutdown: bool = False): try: self.main_window.menu_bar.save_library_backup_action.setEnabled(False) self.main_window.menu_bar.close_library_action.setEnabled(False) - self.main_window.menu_bar.refresh_dir_action.setEnabled(False) + self.main_window.menu_bar.sync_library_action.setEnabled(False) self.main_window.menu_bar.tag_manager_action.setEnabled(False) self.main_window.menu_bar.color_manager_action.setEnabled(False) self.main_window.menu_bar.field_template_manager_action.setEnabled(False) @@ -1067,82 +1079,269 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> return msg.exec() - def add_new_files_callback(self): - """Run when user initiates adding new files to the Library.""" - tracker = RefreshTracker(self.lib) - - pw = ProgressWidget( - cancel_button_text=None, - minimum=0, - maximum=0, - ) - pw.setWindowTitle(Translations["library.refresh.title"]) - pw.update_label(Translations["library.refresh.scanning_preparing"]) - pw.show() - - iterator = FunctionIterator(lambda lib=self.lib.library_dir: tracker.refresh_dir(lib)) - iterator.value.connect( - lambda x: ( - pw.update_progress(x + 1), - pw.update_label( - Translations.format( - "library.refresh.scanning.plural" - if x + 1 != 1 - else "library.refresh.scanning.singular", - searched_count=f"{x + 1:n}", - found_count=f"{tracker.files_count:n}", - ) + def _run_sync_step( + self, + generator: Callable[[], Iterator[T]], + on_progress: Callable[[T], None], + on_done: Callable[[], None], + ) -> None: + """Run a generator function on a background thread with signals for progress and completion. + + Args: + generator (Callable[[], Iterator[T]]): Zero-argument callable returning the + generator to iterate. + on_progress (Callable[[T], None]): Called on the main thread with each yielded value. + on_done (Callable[[], None]): Called on the main thread once `generator` is finished. + """ + iterator = FunctionIterator(generator) + iterator.value.connect(on_progress) + runnable = CustomRunnable(iterator.run) + runnable.done.connect(on_done) + QThreadPool.globalInstance().start(runnable) + + def sync_library_callback(self): + """Run when syncing a Library is initiated.""" + if self.file_scan_lock: + logger.info("[QtDriver] Sync already in progress, ignoring request") + return + self.file_scan_lock = True + session_id = self._new_sync_session() + # Disable the "Fix Unlinked Entries" modal's relink/remove actions during the sync + if hasattr(self, "unlinked_modal") and self.unlinked_modal.isVisible(): + self.unlinked_modal.update_unlinked_count() + + engine = self.sync_engine + library_dir = unwrap(self.lib.library_dir) + self.main_window.banner.show_progress( + Translations["library.sync.preparing"], phase="preparing" + ) + + def on_progress(progress: tuple[int, int]) -> None: + if engine.cancelled: + return + searched_count, found_count = progress + self.main_window.banner.show_progress( + Translations.format( + "library.sync.scanning", + searched_count=f"{searched_count + 1:n}", + found_count=f"{found_count:n}", ), + phase="scanning", ) - ) - r = CustomRunnable(iterator.run) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.add_new_files_runnable(tracker), + + def _start_scan() -> None: + self._run_sync_step( + lambda lib=library_dir: engine.sync_dir(lib), + on_progress, + lambda: self.save_new_entries_runnable(engine, session_id=session_id), ) - ) - QThreadPool.globalInstance().start(r) - def add_new_files_runnable(self, tracker: RefreshTracker): - """Adds any known new files to the library and run default macros on them. + self.main_window.banner.call_when_open(_start_scan) - Threaded method. + def _finish_sync( + self, + new_count: int = 0, + unlinked_count: int = 0, + relinked_count: int = 0, + session_id: int = 0, + ): + """Reset the banner once the sync is completed. + + Args: + new_count (int): New files count. + unlinked_count (int): Unlinked entries count. + relinked_count (int): Automatically relinked files count. + session_id (int): The sync_session_id this sync started with. """ - files_count = tracker.files_count + if self._is_sync_stale(session_id): + return - iterator = FunctionIterator(tracker.save_new_files) - pw = ProgressWidget( - cancel_button_text=None, - minimum=0, - maximum=0, - ) - pw.setWindowTitle(Translations["entries.running.dialog.title"]) - pw.update_label( - Translations.format("entries.running.dialog.new_entries", total=f"{files_count:n}") - ) - pw.show() + self.file_scan_lock = False + self.lib.unlinked_entries_count = unlinked_count + if hasattr(self, "unlinked_modal") and self.unlinked_modal.isVisible(): + self.unlinked_modal.update_unlinked_count() - iterator.value.connect( - lambda _count: ( - pw.update_label( - Translations.format( - "entries.running.dialog.new_entries", total=f"{files_count:n}" - ) + if self.sync_engine.cancelled: + return + + # Show fleeting count of any new files added with button to refresh view + if new_count: + text = Translations.format( + "library.sync.new_files_banner.plural" + if new_count != 1 + else "library.sync.new_files_banner.singular", + count=f"{new_count:n}", + ) + text += self._count_suffix(relinked_count, "library.sync.relinked_suffix") + if relinked_count: + text += self._count_suffix(unlinked_count, "library.sync.remaining_unlinked_suffix") + self._show_notice("new_files", text, Translations["entries.generic.refresh_alt"]) + # Show persistent count of any remaining unlinked files and button to manually review + elif unlinked_count: + text = Translations.format( + "library.sync.unlinked_banner.plural" + if unlinked_count != 1 + else "library.sync.unlinked_banner.singular", + count=f"{unlinked_count:n}", + ) + text += self._count_suffix(relinked_count, "library.sync.relinked_suffix") + self._show_notice("unlinked", text, Translations["entries.unlinked.review"]) + # Show fleeting notice number of entries automatically relinked + elif relinked_count: + text = Translations.format( + "library.sync.relinked_banner.plural" + if relinked_count != 1 + else "library.sync.relinked_banner.singular", + count=f"{relinked_count:n}", + ) + text += self._count_suffix(unlinked_count, "library.sync.remaining_unlinked_suffix") + self._show_notice("relinked", text, Translations["entries.generic.refresh_alt"]) + # Show a fleeting "Library Synced" message + else: + self._show_notice("sync_finished", Translations["library.sync.complete"]) + + def _count_suffix(self, count: int, key: str) -> str: + """Build a count suffix suffix, or "" if count is 0.""" + if not count: + return "" + return " " + Translations.format(key, count=f"{count:n}") + + def _new_sync_session(self) -> int: + """Increment the sync session, invalidating any active sync's callbacks.""" + self._sync_session_id += 1 + return self._sync_session_id + + def _is_sync_stale(self, session_id: int) -> bool: + """Whether `session_id` belongs to an older sync session and should be invalidated.""" + return session_id != self._sync_session_id + + def _show_notice( + self, context: _BannerContext, message: str, button_text: str | None = None + ) -> None: + """Show a "notice" banner and keep track of its context type. + + Args: + context (_BannerContext): The subtype of banner notice. + Used to keep track of the context state currently used for the banner. + This could be for a startup message, sync progress, an entry relink prompt, etc. + message (str): The notice message text. + button_text (str): The action button text. + """ + self._banner_context = context + if button_text is not None: + self.main_window.banner.show_notice(message, button_text) + else: + self.main_window.banner.show_fleeting_notice(message) + + def _clear_notice(self, force: bool = False) -> None: + self._banner_context = None + self.main_window.banner.hide_banner(force=force) + + def _on_notice_action_clicked(self) -> None: + if self._banner_context == "unlinked": + self._on_unlinked_banner_review() + elif self._banner_context == "sync_disabled": + self._on_sync_disabled_open_settings() + else: # "new_files" or "relinked" + self._on_new_files_banner_refresh() + + def _on_new_files_banner_refresh(self): + self.update_browsing_state() + # If there are still unlinked entries after the automatic relinking step, show a notice. + if self.lib.unlinked_entries_count > 0: + count = self.lib.unlinked_entries_count + text = Translations.format( + "library.sync.unlinked_banner.plural" + if count != 1 + else "library.sync.unlinked_banner.singular", + count=f"{count:n}", + ) + self._show_notice("unlinked", text, Translations["entries.unlinked.review"]) + else: + self._clear_notice(force=True) + + def _on_unlinked_banner_review(self): + self._clear_notice(force=True) + self.open_fix_unlinked_entries_modal() + + def _on_sync_disabled_open_settings(self): + self._clear_notice(force=True) + self.open_settings_modal() + + def _on_sync_cancel_requested(self): + """Stop the in-progress sync at its next opportunity.""" + self.sync_engine.cancelled = True + logger.info("[QtDriver] Sync cancelled") + + def open_fix_unlinked_entries_modal(self): + if not hasattr(self, "unlinked_modal"): + self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self) + self.unlinked_modal.show() + + def sync_entry_stats_runnable( + self, + engine: LibrarySyncEngine, + new_count: int = 0, + unlinked_count: int = 0, + relinked_count: int = 0, + session_id: int = 0, + ): + """Refresh cached stat() data for files already known to the library. + + Threaded method. + """ + if self._is_sync_stale(session_id): + return + restat_count = engine.restat_count + + def on_progress(idx: int) -> None: + if engine.cancelled: + return + self.main_window.banner.show_progress( + Translations.format( + "library.sync.updating.label", idx=f"{idx:n}", total=f"{restat_count:n}" ), + idx, + restat_count, + phase="updating", ) + + on_progress(0) + self._run_sync_step( + engine.sync_entry_stats, + on_progress, + lambda: self._finish_sync(new_count, unlinked_count, relinked_count, session_id), ) - r = CustomRunnable(iterator.run) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - # refresh the library only when new items are added - files_count and self.update_browsing_state(), + + def save_new_entries_runnable(self, engine: LibrarySyncEngine, session_id: int = 0): + """Adds any known new files to the library and run default macros on them. + + Threaded method. + """ + if self._is_sync_stale(session_id): + return + new_count = engine.new_file_count + unlinked_count = engine.unlinked_entries_count + relinked_count = engine.relinked_entries_count + + def on_progress(idx: int) -> None: + if engine.cancelled: + return + self.main_window.banner.show_progress( + Translations.format("entries.running.dialog.new_entries", total=f"{new_count:n}"), + idx, + new_count, + phase="new_entries", ) + + on_progress(0) + self._run_sync_step( + engine.save_new_entries, + on_progress, + lambda: self.sync_entry_stats_runnable( + engine, new_count, unlinked_count, relinked_count, session_id + ), ) - QThreadPool.globalInstance().start(r) def new_file_macros_runnable(self, new_ids): """Threaded method that runs macros on a set of Entry IDs.""" @@ -1640,7 +1839,7 @@ def open_library(self, path: Path) -> None: f"[Config] Thumbnail Cache Size: {format_size(cache_size)}", ) - # Migration is required + # JSON Migration is required if open_status.json_migration_req: self.migration_modal = JsonMigrationModal(path) self.migration_modal.migration_finished.connect( @@ -1666,7 +1865,19 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.__reset_navigation() if self.settings.scan_files_on_open: - self.add_new_files_callback() + self.sync_library_callback() + elif not self._sync_disabled_notice_shown: + self._sync_disabled_notice_shown = True + # Show that the setting for opening a library on start is turned off, + # with a prompt to open the settings to change that (encouraged but not required). + self._show_notice( + "sync_disabled", + Translations.format( + "library.sync.disabled_notice", + sync_setting=Translations["settings.scan_files_on_open"], + ), + Translations["library.sync.open_settings"], + ) if self.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS: library_dir_display = self.lib.library_dir @@ -1688,7 +1899,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.set_select_actions_visibility() self.main_window.menu_bar.save_library_backup_action.setEnabled(True) self.main_window.menu_bar.close_library_action.setEnabled(True) - self.main_window.menu_bar.refresh_dir_action.setEnabled(True) + self.main_window.menu_bar.sync_library_action.setEnabled(True) self.main_window.menu_bar.tag_manager_action.setEnabled(True) self.main_window.menu_bar.color_manager_action.setEnabled(True) self.main_window.menu_bar.field_template_manager_action.setEnabled(True) diff --git a/src/tagstudio/qt/views/banner_view.py b/src/tagstudio/qt/views/banner_view.py new file mode 100644 index 000000000..aff8003b0 --- /dev/null +++ b/src/tagstudio/qt/views/banner_view.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout + +from tagstudio.i18n.translations import Translations +from tagstudio.qt.controllers.rounded_progress_bar import RoundedProgressBar +from tagstudio.qt.controllers.stable_label import StableLabel + + +class BannerView(QVBoxLayout): + PROGRESS_BAR_HEIGHT = 4 + + def __init__(self) -> None: + super().__init__() + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(0) + + content_row = QHBoxLayout() + content_row.setContentsMargins(6, 6, 6, 2) + content_row.setSpacing(8) + + self.close_button = QPushButton("×") + self.close_button.setObjectName("bannerCloseButton") + self.close_button.setFixedSize(24, 24) + content_row.addWidget(self.close_button) + + content_row.addStretch(1) + + self.label = StableLabel() + content_row.addWidget(self.label) + + self.action_button = QPushButton(Translations["entries.generic.refresh_alt"]) + self.action_button.setObjectName("bannerActionButton") + content_row.addWidget(self.action_button) + + content_row.addStretch(1) + self.addLayout(content_row, 1) + + self.progress_bar = RoundedProgressBar() + self.progress_bar.setFixedHeight(self.PROGRESS_BAR_HEIGHT) + + policy = self.progress_bar.sizePolicy() + policy.setRetainSizeWhenHidden(True) + self.progress_bar.setSizePolicy(policy) + self.addWidget(self.progress_bar) diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py index a7c3e8dce..dc1f69191 100644 --- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py +++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py @@ -57,8 +57,6 @@ def scroll_to(self, entry_id: int): self._scroll_to = entry_id def set_entries(self, entry_ids: list[int]): - self.scroll_area.verticalScrollBar().setValue(0) - self._entry_ids = entry_ids self._entries.clear() self._tag_entries.clear() @@ -211,9 +209,10 @@ def setGeometry(self, arg__1: QRect) -> None: pass self._scroll_to = None - visible_rows = math.ceil((view_height + (offset % height_offset)) / height_offset) - offset = int(offset / height_offset) - start = offset * per_row + row_offset = offset + visible_rows = math.ceil((view_height + (row_offset % height_offset)) / height_offset) + row_offset = int(row_offset / height_offset) + start = row_offset * per_row end = start + (visible_rows * per_row) first_visible = self._entry_ids[start] if 0 <= start < len(self._entry_ids) else None diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py index 69d1b0856..417b795b9 100644 --- a/src/tagstudio/qt/views/styles/stylesheets.py +++ b/src/tagstudio/qt/views/styles/stylesheets.py @@ -19,6 +19,9 @@ # TODO: There's plenty of good opportunities here to consolidate similar styles. # Work should be done to more closely use Qt's theming systems rather than override them. +# Shared with RoundedProgressBar.set_corner_radius() so both use the exact same corner arc. +BANNER_CORNER_RADIUS = 6 + def add_button_style() -> str: """Style used for tag-like "Add" buttons [+].""" @@ -557,6 +560,142 @@ def preview_warning_style() -> str: """ +def _is_dark_theme() -> bool: + return QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + + +def get_contrast_text_color(background_color: QColor) -> QColor: + """Return plain black or white, whichever reads better against `background_color`.""" + return QColor(0, 0, 0) if background_color.lightness() > 120 else QColor(255, 255, 255) + + +def _banner_button_hover_style(object_name: str) -> str: + """Shared hover/pressed/focus feedback for the banner's accent-tinted buttons.""" + hover = Palette.accent().darker(140) + pressed = Palette.accent().lighter(120) + return f""" + QPushButton#{object_name}::hover {{ + background-color: rgba{hover.toTuple()}; + }} + QPushButton#{object_name}::pressed {{ + background-color: rgba{pressed.toTuple()}; + }} + QPushButton#{object_name}::focus {{ + outline: none; + }} + """ + + +def banner_close_button_style() -> str: + """Style for the banner's close ("X") button in accent-colored notice modes.""" + accent = Palette.accent().darker(180) + text_color = get_contrast_text_color(accent) + + return f""" + QPushButton#bannerCloseButton {{ + font-size: 24pt; + padding-bottom: 4px; + background: transparent; + color: rgba{text_color.toTuple()}; + border: none; + border-radius: 3px; + }} + {_banner_button_hover_style("bannerCloseButton")} + """ + + +def banner_action_button_style() -> str: + """Style for the banner's action button (Refresh / Review / Open Settings).""" + accent = Palette.accent().darker(160) + text_color = get_contrast_text_color(accent) + + return f""" + QPushButton#bannerActionButton {{ + background-color: rgba{accent.toTuple()}; + color: rgba{text_color.toTuple()}; + border: none; + border-radius: 3px; + padding: 4px 8px; + outline: none; + }} + {_banner_button_hover_style("bannerActionButton")} + """ + + +def banner_notice_bg_color() -> QColor: + """Fill color for the banner card in "notice" mode.""" + color = QColor(Palette.accent()) + color.setAlpha(235) + return color + + +def banner_notice_style() -> str: + """Label/button rules for the banner's accent-colored "notice" mode.""" + accent = Palette.accent() + text_color = get_contrast_text_color(accent) + + return f""" + #banner QLabel {{ color: rgba{text_color.toTuple()}; background: transparent; }} + {banner_close_button_style()} + {banner_action_button_style()} + """ + + +def banner_close_button_progress_style() -> str: + """Close button style for the progress banner mode.""" + is_dark = _is_dark_theme() + text_color = QColor(255, 255, 255) if is_dark else QColor(0, 0, 0) + hover = "rgba(255, 255, 255, 40)" if is_dark else "rgba(0, 0, 0, 40)" + pressed = "rgba(255, 255, 255, 70)" if is_dark else "rgba(0, 0, 0, 70)" + + return f""" + QPushButton#bannerCloseButton {{ + font-size: 24pt; + padding-bottom: 4px; + background: transparent; + color: rgba{text_color.toTuple()}; + border: none; + border-radius: 3px; + }} + QPushButton#bannerCloseButton::hover {{ + background-color: {hover}; + }} + QPushButton#bannerCloseButton::pressed {{ + background-color: {pressed}; + }} + QPushButton#bannerCloseButton::focus {{ + outline: none; + }} + """ + + +def banner_progress_bg_color() -> QColor: + """Fill color for the banner card in "progress"/"fleeting_notice" mode.""" + is_dark = _is_dark_theme() + return QColor( + ThemePalette.COLOR_BG_DARK.value if is_dark else ThemePalette.COLOR_BG_LIGHT.value + ) + + +def banner_progress_style() -> str: + """Label/button rules for the banner's neutral "progress" mode.""" + is_dark = _is_dark_theme() + text_str = "white" if is_dark else "black" + + return f""" + #banner QLabel {{ color: {text_str}; background: transparent; }} + {banner_close_button_progress_style()} + """ + + +def banner_progress_chunk_color() -> QColor: + """Fill color for the banner's custom-painted progress bar chunk.""" + is_dark = _is_dark_theme() + chunk = QColor(Palette.accent().lighter(130) if is_dark else Palette.accent().darker(115)) + chunk.setAlpha(235) + return chunk + + def header(string: str, level: int, color: str | None = None) -> str: """Wrap a string in HTML header tags. diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index 23967904e..e0038419a 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -57,7 +57,6 @@ "entries.remove.plural.confirm": "Sollen die folgenden {count} Einträge gelöscht werden? Es werden keine Dateien auf der Festplatte gelöscht.", "entries.remove.singular.confirm": "Soll dieser Eintrag von der Bibliothek entfernt werden? Es werden keine Dateien auf der Festplatte gelöscht.", "entries.running.dialog.new_entries": "Füge {total} neue Dateieinträge hinzu...", - "entries.running.dialog.title": "Füge neue Dateieinträge hinzu", "entries.tags": "Tags", "entries.unlinked.description": "Jeder Bibliothekseintrag ist mit einer Datei in einem Ihrer Verzeichnisse verknüpft. Wenn eine Datei, die mit einem Eintrag verknüpft ist, außerhalb von TagStudio verschoben oder gelöscht wird, gilt sie als nicht verknüpft.

Nicht verknüpfte Einträge können durch das Durchsuchen Ihrer Verzeichnisse automatisch neu verknüpft, vom Benutzer manuell neu verknüpft oder auf Wunsch gelöscht werden.", "entries.unlinked.relink.attempting": "Versuche {index}/{unlinked_count} Einträge neu zu verknüpfen, {fixed_count} bereits erfolgreich neu verknüpft", @@ -180,10 +179,6 @@ "landing.open_create_library": "Bibliothek öffnen/erstellen {shortcut}", "library.missing": "Dateiort fehlt", "library.name": "Bibliothek", - "library.refresh.scanning.plural": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Dateien durchsucht, {found_count} neue Dateien gefunden", - "library.refresh.scanning.singular": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Datei durchsucht, {found_count} neue Datei gefunden", - "library.refresh.scanning_preparing": "Überprüfe Verzeichnisse auf neue Dateien...\nBereite vor...", - "library.refresh.title": "Verzeichnisse werden aktualisiert", "library.scan_library.title": "Bibliothek wird scannen", "library_info.cleanup": "Aufräumen", "library_info.cleanup.backups": "Bibliotheks-Backups:", @@ -225,7 +220,6 @@ "menu.file.open_create_library": "Bibli&othek öffnen/erstellen", "menu.file.open_library": "Bibliothek öffnen", "menu.file.open_recent_library": "Zuletzt verwendete öffnen", - "menu.file.refresh_directories": "Ve&rzeichnisse aktualisieren", "menu.file.save_backup": "Bibliotheksbackup speichern", "menu.file.save_library": "Bibliothek speichern", "menu.help": "&Hilfe", diff --git a/src/tagstudio/resources/translations/el.json b/src/tagstudio/resources/translations/el.json index 14224d689..fa9033f03 100644 --- a/src/tagstudio/resources/translations/el.json +++ b/src/tagstudio/resources/translations/el.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτές τις {count} εγγραφές από τη βιβλιοθήκη σας; Δεν θα διαγραφεί κανένα αρχείο από τον δίσκο.", "entries.remove.singular.confirm": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτή την εγγραφή από τη βιβλιοθήκη σας; Δεν θα διαγραφεί κανένα αρχείο από τον δίσκο.", "entries.running.dialog.new_entries": "Προσθήκη {total} νέων εγγραφών αρχείων...", - "entries.running.dialog.title": "Προσθήκη νέων εγγραφών αρχείων", "entries.tags": "Tags", "entries.unlinked.description": "Κάθε εγγραφή της βιβλιοθήκης είναι συνδεδεμένη με ένα αρχείο σε έναν από τους καταλόγους σας. Εάν ένα αρχείο που είναι συνδεδεμένο με μια εγγραφή μετακινηθεί ή διαγραφεί εκτός του TagStudio, τότε θεωρείται αποσυνδεδεμένο.

Οι αποσυνδεδεμένες εγγραφές μπορούν να επανασυνδεθούν αυτόματα μέσω αναζήτησης στους καταλόγους σας ή να διαγραφούν, εάν το επιθυμείτε.", "entries.unlinked.relink.attempting": "Προσπάθεια επανασύνδεσης {index}/{unlinked_count} εγγραφών, {fixed_count} επανασυνδέθηκαν επιτυχώς", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 5f35ae38f..cd8915dc6 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -3,13 +3,13 @@ "about.config_path": "Config Path", "about.description": "TagStudio is a photo and file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.", "about.documentation": "Documentation", + "about.library_version": "Library Format", "about.module.found": "Found", "about.modules.title": "Optional Modules", "about.title": "About TagStudio", "about.version": "Version", "about.version.latest": "{built_version} (Latest Release: {latest_version})", "about.website": "Website", - "about.library_version": "Library Format", "app.git": "Git Commit", "app.nightly": "Nightly", "app.pre_release": "Pre-Release", @@ -62,7 +62,6 @@ "entries.remove.plural.confirm": "Are you sure you want to remove these {count} entries from your library? No files on disk will be deleted.", "entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.", "entries.running.dialog.new_entries": "Adding {total} New File Entries…", - "entries.running.dialog.title": "Adding New File Entries", "entries.tags": "Tags", "entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.

Unlinked entries may be automatically relinked via searching your directories or deleted if desired.", "entries.unlinked.relink.attempting": "Attempting to Relink {index}/{unlinked_count} Entries, {fixed_count} Successfully Relinked", @@ -70,6 +69,7 @@ "entries.unlinked.relink.title": "Relinking Entries", "entries.unlinked.remove": "Remove Unlinked Entries", "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", + "entries.unlinked.review": "Manual &Review", "entries.unlinked.scanning": "Scanning Library for Unlinked Entries…", "entries.unlinked.search_and_relink": "&Search && Relink", "entries.unlinked.title": "Fix Unlinked Entries", @@ -121,6 +121,7 @@ "file.open_location.mac": "Reveal in Finder", "file.open_location.windows": "Show in File Explorer", "file.path": "File Path", + "file.size": "File Size", "folders_to_tags.close_all": "Close All", "folders_to_tags.converting": "Converting folders to Tags", "folders_to_tags.description": "Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to.", @@ -253,11 +254,21 @@ "library_object.slug_required": "ID Slug (Required)", "library.missing": "Library Location is Missing", "library.name": "Library", - "library.refresh.scanning_preparing": "Scanning Directories for New Files…\nPreparing…", - "library.refresh.scanning.plural": "Scanning Directories for New Files…\n{searched_count} Files Searched, {found_count} New Files Found", - "library.refresh.scanning.singular": "Scanning Directories for New Files…\n{searched_count} File Searched, {found_count} New Files Found", - "library.refresh.title": "Refreshing Directories", "library.scan_library.title": "Scanning Library", + "library.sync.complete": "Library Synced", + "library.sync.disabled_notice": "\"{sync_setting}\" Is Currently Turned Off", + "library.sync.new_files_banner.plural": "{count} New Files Found", + "library.sync.new_files_banner.singular": "{count} New File Found", + "library.sync.open_settings": "Open &Settings", + "library.sync.preparing": "Preparing to Sync…", + "library.sync.relinked_banner.plural": "{count} Files Automatically Relinked", + "library.sync.relinked_banner.singular": "{count} File Automatically Relinked", + "library.sync.relinked_suffix": "({count} Automatically Relinked)", + "library.sync.remaining_unlinked_suffix": "({count} Still Unlinked)", + "library.sync.scanning": "Discovering Files… ({found_count} New Out of {searched_count})", + "library.sync.unlinked_banner.plural": "{count} Unlinked Entries Found", + "library.sync.unlinked_banner.singular": "{count} Unlinked Entry Found", + "library.sync.updating.label": "Syncing {idx}/{total} Entries…", "macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries…", "macros.running.dialog.title": "Running Macros on New Entries", "media_player.autoplay": "Autoplay", @@ -280,9 +291,9 @@ "menu.file.open_create_library": "&Open/Create Library", "menu.file.open_library": "Open Library", "menu.file.open_recent_library": "Open Recent", - "menu.file.refresh_directories": "&Refresh Directories", "menu.file.save_backup": "&Save Library Backup", "menu.file.save_library": "Save Library", + "menu.file.sync_library": "&Sync Library", "menu.help": "&Help", "menu.help.about": "About", "menu.macros": "&Macros", @@ -335,10 +346,10 @@ "settings.library": "Library Settings", "settings.localization": "Localization", "settings.media": "Media", - "settings.open_library_on_start": "Open Library on Start", + "settings.open_library_on_start": "Open Last Library on Start", "settings.page_size": "Page Size", "settings.restart_required": "Please restart TagStudio for changes to take effect.", - "settings.scan_files_on_open": "Automatically Load New Files", + "settings.scan_files_on_open": "Sync Library on Open", "settings.show_filenames_in_grid": "Show Filenames in Grid", "settings.show_recent_libraries": "Show Recent Libraries", "settings.splash.label": "Splash Screen", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index d9bf5dcb7..694d69f11 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -60,7 +60,6 @@ "entries.remove.plural.confirm": "¿Está seguro de que desea eliminar estas {count} entradas de su librería? No se eliminará ningún archivo del disco.", "entries.remove.singular.confirm": "¿Está seguro que quiere eliminar ésta entrada de su librería? Ningún archivo en el disco será eliminado.", "entries.running.dialog.new_entries": "Añadiendo {total} nuevas entradas de archivos...", - "entries.running.dialog.title": "Añadiendo las nuevas entradas de archivos", "entries.tags": "Etiquetas", "entries.unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de tus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado.

Las entradas no vinculadas se pueden volver a vincular automáticamente mediante una búsqueda en tus directorios, el usuario puede eliminarlas si así lo desea.", "entries.unlinked.relink.attempting": "Intentando volver a vincular {index}/{unlinked_count} Entradas, {fixed_count} Reenlazado correctamente", @@ -229,10 +228,6 @@ "language.zh_Hant": "Chino (tradicional)", "library.missing": "Falta la Ubicación de la Biblioteca", "library.name": "Biblioteca", - "library.refresh.scanning.plural": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} archivos buscados, {found_count} nuevos archivos encontrados", - "library.refresh.scanning.singular": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} Archivos buscados, {found_count} Nuevos archivos encontrados", - "library.refresh.scanning_preparing": "Buscando archivos nuevos en los directorios...\nPreparando...", - "library.refresh.title": "Refrescando directorios", "library.scan_library.title": "Escaneando la biblioteca", "library_info.cleanup": "Limpieza", "library_info.cleanup.backups": "Reespaldos de la Librería:", @@ -275,7 +270,6 @@ "menu.file.open_create_library": "&Abrir/Crear biblioteca", "menu.file.open_library": "Abrir biblioteca", "menu.file.open_recent_library": "Abrir reciente", - "menu.file.refresh_directories": "Actualizar directorios", "menu.file.save_backup": "&Guardar copia de seguridad de la biblioteca", "menu.file.save_library": "Guardar biblioteca", "menu.help": "&Ayuda", diff --git a/src/tagstudio/resources/translations/fi.json b/src/tagstudio/resources/translations/fi.json index 3acaf7a4a..2c4a2d834 100644 --- a/src/tagstudio/resources/translations/fi.json +++ b/src/tagstudio/resources/translations/fi.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "Haluatko varmasti poistaa nämä {count} merkintää kirjastostasi? Levyllä olevia tiedostoja ei poisteta.", "entries.remove.singular.confirm": "Haluatko varmasti poistaa tämän merkinnän kirjastostasi? Levyllä olevia tiedostoja ei poisteta.", "entries.running.dialog.new_entries": "Lisätään {total} uutta tiedosto merkintää...", - "entries.running.dialog.title": "Lisätään uudet tiedosto merkinnät", "entries.tags": "Tunnisteet", "entries.unlinked.description": "Jokainen kirjastomerkintä on linkitetty tiedostoon jossakin hakemistoistasi. Jos merkintään linkitetty tiedosto siirretään tai poistetaan TagStudion ulkopuolelle, sitä pidetään linkittämättömänä.

Linkittämättömät merkinnät voidaan linkittää automaattisesti uudelleen hakemistojen haun avulla tai poistaa haluttaessa.", "entries.unlinked.relink.attempting": "Yritetään linkittää uudelleen {index}/{unlinked_count} merkintää, {fixed_count} uudelleenlinkitys onnistui", @@ -176,7 +175,6 @@ "landing.open_create_library": "Avaa/Luo kirjasto {shortcut}", "library.missing": "Kirjaston sijainti puuttuu", "library.name": "Kirjasto", - "library.refresh.title": "Virkistetty hakemistot", "library.scan_library.title": "Skannataan kirjastoa", "library_info.cleanup": "Puhdistus", "library_info.cleanup.backups": "Kirjasto varmuuskopiot:", diff --git a/src/tagstudio/resources/translations/fil.json b/src/tagstudio/resources/translations/fil.json index c5dda6102..99e329b84 100644 --- a/src/tagstudio/resources/translations/fil.json +++ b/src/tagstudio/resources/translations/fil.json @@ -47,7 +47,6 @@ "entries.mirror.window_title": "I-mirror ang Mga Entry", "entries.remove.plural.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na {count} entry?", "entries.running.dialog.new_entries": "Dinadagdag ang {total} Mga Bagong Entry ng File…", - "entries.running.dialog.title": "Dinadagdag ang Mga Bagong Entry ng File", "entries.tags": "Mga Tag", "entries.unlinked.description": "Ang bawat entry sa library ay naka-link sa isang file sa isa sa iyong mga direktoryo. Kung ang isang file na naka-link sa isang entry ay inilipat o binura sa labas ng TagStudio, ito ay isinasaalang-alang na naka-unlink.

Ang mga naka-unlink na entry ay maaring i-link muli sa pamamagitan ng paghahanap sa iyong mga direktoryo o buburahin kung ninanais.", "entries.unlinked.relink.attempting": "Sinusubukang i-link muli ang {index}/{unlinked_count} Mga Entry, {fixed_count} Matagumpay na na-link muli", @@ -159,10 +158,6 @@ "landing.open_create_library": "Buksan/Gumawa ng Library {shortcut}", "library.missing": "Nawawala ang Lokasyon ng Library", "library.name": "Library", - "library.refresh.scanning.plural": "Sina-scan ang Direktoryo para sa Mga Bagong File…\n{searched_count} Nahanap na File, {found_count} Nahanap na Bagong FIle", - "library.refresh.scanning.singular": "Sina-scan ang Direktoryo para sa Mga Bagong File…\n{searched_count} Mga Nahanap na File, {found_count} Nahanap na Bagong FIle", - "library.refresh.scanning_preparing": "Sina-scan ang Mga Direktoryo para sa Mga Bagong File...\nNaghahanda...", - "library.refresh.title": "Nire-refresh ang Mga Direktoryo", "library.scan_library.title": "Sina-scan ang Library", "library_info.stats.entries": "Mga entry:", "library_info.stats.fields": "Mga Field:", @@ -191,7 +186,6 @@ "menu.file.open_create_library": "Magbukas/Gumawa ng Library (&O)", "menu.file.open_library": "Magbukas ng Library", "menu.file.open_recent_library": "Magbukas ng Kamakailan", - "menu.file.refresh_directories": "I-refresh ang mga Direktoryo (&R)", "menu.file.save_backup": "I-save ang Backup ng Library (&S)", "menu.file.save_library": "I-save ang Library", "menu.help": "Tulong", diff --git a/src/tagstudio/resources/translations/fr.json b/src/tagstudio/resources/translations/fr.json index e2c7e5270..6a1be3ed4 100644 --- a/src/tagstudio/resources/translations/fr.json +++ b/src/tagstudio/resources/translations/fr.json @@ -61,7 +61,6 @@ "entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les {count} entrées suivantes ? Aucun fichier sur votre disque ne sera supprimée.", "entries.remove.singular.confirm": "Êtes-vous sûr de vouloir supprimer cette entrée de votre bibliothèque ? Aucun fichier sur le disque ne sera supprimé.", "entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...", - "entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier", "entries.tags": "Tags", "entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié.

Les entrées non liées peuvent être automatiquement reliées via la recherche dans vos dossiers, reliées manuellement par l'utilisateur, ou supprimées si désiré.", "entries.unlinked.relink.attempting": "Tentative de Reliage de {index}/{unlinked_count} Entrées, {fixed_count} ont été Reliées avec Succès", @@ -230,10 +229,6 @@ "language.zh_Hant": "Chinois (Traditionnelle)", "library.missing": "Emplacement Manquant", "library.name": "Bibliothèque", - "library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers", - "library.refresh.scanning.singular": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichier Trouvé, {found_count} Nouveaux Fichiers", - "library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...", - "library.refresh.title": "Rafraîchissement des Dossiers", "library.scan_library.title": "Balayage de la Bibliothèque", "library_info.cleanup": "Nettoyage", "library_info.cleanup.backups": "Sauvegardes de bibliothèque :", @@ -276,7 +271,6 @@ "menu.file.open_create_library": "&Ouvrir/Créer une Bibliothèque", "menu.file.open_library": "Ouvrir la Bibliothèque", "menu.file.open_recent_library": "Ouvrir la Bibliothèque récente", - "menu.file.refresh_directories": "&Rafraichir les Répertoires", "menu.file.save_backup": "&Sauvegarde de la Bibliothèque", "menu.file.save_library": "Enregistrer la Bibliothèque", "menu.help": "&Aide", diff --git a/src/tagstudio/resources/translations/hu.json b/src/tagstudio/resources/translations/hu.json index 3d445c1b7..c7ef5f628 100644 --- a/src/tagstudio/resources/translations/hu.json +++ b/src/tagstudio/resources/translations/hu.json @@ -61,7 +61,6 @@ "entries.remove.plural.confirm": "Biztosan el akarja távolítani ezt a(z) {count} elemet a könyvtárból? A lemezen található fájl nem lesz törölve.", "entries.remove.singular.confirm": "Biztosan el akarja távolítani ezt az elemet a könyvtárból? A lemezen található fájl nem lesz törölve.", "entries.running.dialog.new_entries": "{total} új elem felvétele folyamatban…", - "entries.running.dialog.title": "Új elemek felvétele", "entries.tags": "Címkék", "entries.unlinked.description": "A könyvtár minden eleme egy fájllal van összekapcsolva a számítógépen. Ha egy kapcsolt fájl a TagSudión kívül áthelyezésre vagy törésre kerül, akkor ez a kapcsolat megszakad.

Ezeket a kapcsolat nélküli elemeket a program megpróbálhatja automatikusan megkeresni, de Ön is kézileg újra összekapcsolhatja vagy törölheti őket.", "entries.unlinked.relink.attempting": "{unlinked_count}/{index} elem újra összekapcsolásának megkísérlése; {fixed_count} elem sikeresen újra összekapcsolva", @@ -233,10 +232,6 @@ "language.zh_Hant": "kínai (hagyományos)", "library.missing": "Hiányzó hely", "library.name": "Könyvtár", - "library.refresh.scanning.plural": "Új fájlok keresése a mappákban…\n{searched_count} fájl megvizsgálva; ebből {found_count} új fájl", - "library.refresh.scanning.singular": "Új fájlok keresése a mappákban…\n{searched_count} fájl megvizsgálva; ebből {found_count} új fájl", - "library.refresh.scanning_preparing": "Új fájlok keresése a mappákban…\nElőkészítés…", - "library.refresh.title": "Könyvtárak frissítése", "library.scan_library.title": "Könyvtár vizsgálata", "library_info.cleanup": "Megtisztítás", "library_info.cleanup.backups": "Könyvtár biztonsági mentései:", @@ -279,7 +274,6 @@ "menu.file.open_create_library": "Könyvtár meg&nyitása/létrehozása", "menu.file.open_library": "Könyvtár megnyitása", "menu.file.open_recent_library": "&Legutóbbi könyvtárak", - "menu.file.refresh_directories": "Könyvtárak &frissítése", "menu.file.save_backup": "Biztonsági &mentés létrehozása", "menu.file.save_library": "Könyvtár &mentése", "menu.help": "&Súgó", diff --git a/src/tagstudio/resources/translations/it.json b/src/tagstudio/resources/translations/it.json index 8e4b632e5..f2f846d61 100644 --- a/src/tagstudio/resources/translations/it.json +++ b/src/tagstudio/resources/translations/it.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "Sei sicuro di voler rimuovere queste {count} voci dalla tua biblioteca? Nessun file su disco verrà eliminato.", "entries.remove.singular.confirm": "Sei sicuro di voler rimuovere questa voce dalla tua biblioteca? Nessun file su disco verrà eliminato.", "entries.running.dialog.new_entries": "Aggiundendo {total} Nuove Voci di File...", - "entries.running.dialog.title": "Aggiungendo Nuove Voci di File", "entries.tags": "Etichette", "entries.unlinked.description": "Ogni voce della biblioteca è collegata ad un file in una delle tue cartelle. Se un file collegato ad una voce viene spostato o eliminito al di fuori di TagStudio, la voce corrispondente viene considerata scollegata.

Le voci scollegate possono essere ricollegate automaticamente attraverso la ricerca nelle tue cartelle oppure cancellate se lo si desidera.", "entries.unlinked.relink.attempting": "Tentativo di Ricollegare {index}/{unlinked_count} Voci, {fixed_count} Ricollegate con Successo", @@ -180,10 +179,6 @@ "landing.open_create_library": "Apri/Crea Biblioteca {shortcut}", "library.missing": "Manca la Posizione della Biblioteca", "library.name": "Biblioteca", - "library.refresh.scanning.plural": "Ricerca di Nuovi File nelle Cartelle...\n{searched_count} Files Cercati, {found_count} Nuovi File Trovati", - "library.refresh.scanning.singular": "Ricerca di Nuovi File nelle Cartelle...\n{searched_count} File Cercati, {found_count} Nuovi File Trovati", - "library.refresh.scanning_preparing": "Ricerca di Nuovi File nelle Cartelle...\nPreparazione in corso...", - "library.refresh.title": "Aggiornamento delle Cartelle", "library.scan_library.title": "Scansione della Biblioteca", "library_info.cleanup": "Pulizia", "library_info.cleanup.backups": "Backup della Biblioteca:", @@ -225,7 +220,6 @@ "menu.file.open_create_library": "&Apri/Crea Biblioteca", "menu.file.open_library": "Apri Biblioteca", "menu.file.open_recent_library": "Apri Recenti", - "menu.file.refresh_directories": "Aggiorna Cartelle", "menu.file.save_backup": "&Salva Backup della Biblioteca", "menu.file.save_library": "Salva Biblioteca", "menu.help": "&Aiuto", diff --git a/src/tagstudio/resources/translations/ja.json b/src/tagstudio/resources/translations/ja.json index 3cbc6b1a9..6fdfac1c7 100644 --- a/src/tagstudio/resources/translations/ja.json +++ b/src/tagstudio/resources/translations/ja.json @@ -61,7 +61,6 @@ "entries.remove.plural.confirm": "これら {count} 件のエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。", "entries.remove.singular.confirm": "このエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。", "entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています…", - "entries.running.dialog.title": "新しいファイルエントリを追加", "entries.tags": "タグ", "entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。

リンク切れのエントリは、ディレクトリを検索して自動的に再リンクすることも、必要に応じて削除することもできます。", "entries.unlinked.relink.attempting": "{unlinked_count} 件中 {index} 件のエントリを再リンク中、{fixed_count} 件を正常に再リンクしました", @@ -233,10 +232,6 @@ "language.zh_Hant": "中国語 (繁体字)", "library.missing": "ライブラリの場所が見つかりません", "library.name": "ライブラリ", - "library.refresh.scanning.plural": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出", - "library.refresh.scanning.singular": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出", - "library.refresh.scanning_preparing": "新しいファイルを検索中...\n準備中…", - "library.refresh.title": "ディレクトリを更新しています", "library.scan_library.title": "ライブラリをスキャンしています", "library_info.cleanup": "クリーンアップ", "library_info.cleanup.backups": "ライブラリのバックアップ:", @@ -279,7 +274,6 @@ "menu.file.open_create_library": "ライブラリを開く/作成する(&O)", "menu.file.open_library": "ライブラリを開く", "menu.file.open_recent_library": "最近使用したライブラリを開く", - "menu.file.refresh_directories": "ディレクトリの更新(&R)", "menu.file.save_backup": "ライブラリ バックアップを保存(&S)", "menu.file.save_library": "ライブラリを保存", "menu.help": "ヘルプ(&H)", diff --git a/src/tagstudio/resources/translations/nb_NO.json b/src/tagstudio/resources/translations/nb_NO.json index f2d40da72..63c92ef4e 100644 --- a/src/tagstudio/resources/translations/nb_NO.json +++ b/src/tagstudio/resources/translations/nb_NO.json @@ -55,7 +55,6 @@ "entries.remove.plural.confirm": "Er du sikker på at du vil slette følgende {count} oppføringer fra biblioteket ditt? Ingen filer på disken vil slettes.", "entries.remove.singular.confirm": "Er du sikker på at du vil fjerne denne oppføringen fra bibliotek ditt? Ingen filer på disken vil slettes.", "entries.running.dialog.new_entries": "Legger til {total} Nye Filoppføringer...", - "entries.running.dialog.title": "Legger til Nye Filoppføringer", "entries.tags": "Etiketter", "entries.unlinked.description": "Hver biblioteksoppføring er koblet til en fil i en av dine mapper. Hvis en fil koblet til en oppføring er flyttet eller slettet utenfor TagStudio, så er den sett på som frakoblet.

Frakoblede oppføringer kan bli automatisk gjenkoblet ved å søke i mappene dine eller slettet om det er ønsket.", "entries.unlinked.relink.attempting": "Forsøker å Gjenkoble {index}/{unlinked_count} Oppføringer, {fixed_count} Klart Gjenkoblet", @@ -167,10 +166,6 @@ "landing.open_create_library": "Åpne/Lag nytt Bibliotek {shortcut}", "library.missing": "Posisjon mangler", "library.name": "Bibliotek", - "library.refresh.scanning.plural": "Skanner Mapper for Nye Filer...\n{searched_count} Filer Sjekket, {found_count} Nye Filer Funnet", - "library.refresh.scanning.singular": "Skanner Mapper for Nye Filer...\n{searched_count} Fil Sjekket, {found_count} Nye Filer Funnet", - "library.refresh.scanning_preparing": "Skanner Mapper for Nye Filer...\nForbereder...", - "library.refresh.title": "Oppdaterer Mapper", "library.scan_library.title": "Skanning av bibliotek", "library_info.stats.entries": "Oppføringer:", "library_info.stats.fields": "Felter:", @@ -199,7 +194,6 @@ "menu.file.open_create_library": "&Åpne/Lag nytt Bibliotek", "menu.file.open_library": "Åpne Bibliotek", "menu.file.open_recent_library": "Åpne Nylig", - "menu.file.refresh_directories": "&Oppdater Mapper", "menu.file.save_backup": "&Lagre Sikkerhetskopi av Bibliotek", "menu.file.save_library": "Lagre Bibliotek", "menu.help": "Hjelp", diff --git a/src/tagstudio/resources/translations/nl.json b/src/tagstudio/resources/translations/nl.json index 76f5e5e24..3649bcc04 100644 --- a/src/tagstudio/resources/translations/nl.json +++ b/src/tagstudio/resources/translations/nl.json @@ -96,7 +96,6 @@ "json_migration.heading.shorthands": "Afkortingen:", "json_migration.migration_complete": "Migratie Afgerond!", "json_migration.title": "Migratie Formaat Opslaan: \"{path}\"", - "library.refresh.scanning_preparing": "Mappen scannen voor nieuwe bestanden...\nVoorbereiden...", "library_info.stats.fields": "Velden:", "library_info.stats.tags": "Labels:", "menu.delete_selected_files_ambiguous": "Bestand(en) verplaatsen naar {trash_term}", diff --git a/src/tagstudio/resources/translations/pl.json b/src/tagstudio/resources/translations/pl.json index 87c794670..ed83ac6b7 100644 --- a/src/tagstudio/resources/translations/pl.json +++ b/src/tagstudio/resources/translations/pl.json @@ -47,7 +47,6 @@ "entries.mirror.window_title": "Odzwierciedl wpisy", "entries.remove.plural.confirm": "Jesteś pewien że chcesz usunąć następujące {count} wpisy?", "entries.running.dialog.new_entries": "Dodawanie {total} nowych wpisów plików...", - "entries.running.dialog.title": "Dodawanie nowych wpisów plików", "entries.tags": "Tagi", "entries.unlinked.description": "Każdy wpis w bibliotece jest połączony z plikiem w jednym z twoich katalogów. Jeśli połączony plik jest przeniesiony poza TagStudio albo usunięty to jest uważany za odłączony.

Odłączone wpisy mogą być automatycznie połączone ponownie przez szukanie twoich katalogów, ręczne ponowne łączenie przez użytkownika lub usunięte jeśli zajdzie taka potrzeba.", "entries.unlinked.relink.attempting": "Próbowanie ponownego łączenia {index}/{unlinked_count} wpisów, {fixed_count} poprawnie połączono ponownie", @@ -157,9 +156,6 @@ "landing.open_create_library": "Otwórz/Stwórz bibliotekę {shortcut}", "library.missing": "Brak lokalizacji", "library.name": "Biblioteka", - "library.refresh.scanning.plural": "Skanowanie folderów w poszukiwaniu nowych plików...\nPrzeszukano {searched_count} plików, Znaleziono {found_count} nowych plików", - "library.refresh.scanning_preparing": "Skanowanie katalogów w poszukiwaniu nowych plików\nPrzygotowywanie...", - "library.refresh.title": "Odświeżanie katalogów", "library.scan_library.title": "Skanowanie biblioteki", "library_info.stats.entries": "Wpisy:", "library_info.stats.fields": "Pola:", @@ -185,7 +181,6 @@ "menu.file.open_create_library": "&Otwórz/Stwórz bibliotekę", "menu.file.open_library": "Otwórz bibliotekę", "menu.file.open_recent_library": "Otwórz ostatnie", - "menu.file.refresh_directories": "Odśwież katalogi", "menu.file.save_backup": "&Zapisz kopię zapasową biblioteki", "menu.file.save_library": "Zapisz bibliotekę", "menu.help": "&Pomoc", diff --git a/src/tagstudio/resources/translations/pt.json b/src/tagstudio/resources/translations/pt.json index f8595413e..3de2d849c 100644 --- a/src/tagstudio/resources/translations/pt.json +++ b/src/tagstudio/resources/translations/pt.json @@ -52,7 +52,6 @@ "entries.mirror.window_title": "Espelhar Registos", "entries.remove.plural.confirm": "Tem certeza que deseja apagar os seguintes {count} registos ?", "entries.running.dialog.new_entries": "A Adicionar {total} Novos Registos de Ficheiros...", - "entries.running.dialog.title": "A Adicionar Novos Registos de Ficheiros", "entries.tags": "Tags", "entries.unlinked.description": "Cada registo na biblioteca faz referência à um ficheiro numa das suas pastas. Se um ficheiro referenciado à uma entrada for movido ou apagado fora do TagStudio, ele é depois considerado não-referenciado.

Registos não-referenciados podem ser automaticamente referenciados por pesquisas nos seus diretórios, manualmente pelo utilizador, ou apagado se for desejado.", "entries.unlinked.relink.attempting": "A tentar referenciar {index}/{unlinked_count} Registos, {fixed_count} Referenciados com Sucesso", @@ -163,10 +162,6 @@ "landing.open_create_library": "Abrir/Criar Biblioteca {shortcut}", "library.missing": "Localização Ausente", "library.name": "Biblioteca", - "library.refresh.scanning.plural": "A escanear pastas por Novos Ficheiros ...\n{searched_count} Ficheiros pesquisados, {found_count} Novos Ficheiros", - "library.refresh.scanning.singular": "A Escanear pastas por novos ficheiros ...\n{searched_count} Ficheiros encontrados, {found_count} Novos Ficheiros", - "library.refresh.scanning_preparing": "A Escanear Diretórios por Novos Ficheiros...\nPreparando...", - "library.refresh.title": "A atualizar Pastas", "library.scan_library.title": "A Escanear Biblioteca", "library_info.cleanup": "Limpeza", "library_info.cleanup.dupe_files": "Ficheiros Duplicados:", @@ -197,7 +192,6 @@ "menu.file.open_create_library": "&Abrir/Criar Biblioteca", "menu.file.open_library": "Abrir Biblioteca", "menu.file.open_recent_library": "Abrir Recente", - "menu.file.refresh_directories": "Atualizar Pastas", "menu.file.save_backup": "&Gravar Backup da Biblioteca", "menu.file.save_library": "Gravar Biblioteca", "menu.help": "&Ajuda", diff --git a/src/tagstudio/resources/translations/pt_BR.json b/src/tagstudio/resources/translations/pt_BR.json index 81ff26a1c..d074a9998 100644 --- a/src/tagstudio/resources/translations/pt_BR.json +++ b/src/tagstudio/resources/translations/pt_BR.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "Tem certeza que deseja deletar os seguintes {count} Registros ?", "entries.remove.singular.confirm": "Você tem certeza que deseja remover esse registro da sua bilbioteca ? Nenhum arquivo no disco será excluído.", "entries.running.dialog.new_entries": "Adicionando {total} Novos Registros de Arquivos...", - "entries.running.dialog.title": "Adicionando Novos Registros de Arquivos", "entries.tags": "Tags", "entries.unlinked.description": "Cada registro na biblioteca faz referência à um arquivo em uma de suas pastas. Se um arquivo referenciado à uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não-referenciado.

Registros não-referenciados podem ser automaticamente referenciados por buscas nos seus diretórios, manualmente pelo usuário, ou deletado se for desejado.", "entries.unlinked.relink.attempting": "Tentando referenciar {index}/{unlinked_count} Registros, {fixed_count} Referenciados com Sucesso", @@ -176,10 +175,6 @@ "landing.open_create_library": "Abrir/Criar Biblioteca {shortcut}", "library.missing": "Localização Ausente", "library.name": "Biblioteca", - "library.refresh.scanning.plural": "Escaneando pastas em busca de novos arquivos ...\n{searched_count} Arquivos encontrados, {found_count} Novos Arquivos", - "library.refresh.scanning.singular": "Escaneando pastas em busca de novos arquivos ...\n{searched_count} Arquivos encontrados, {found_count} Novos Arquivos", - "library.refresh.scanning_preparing": "Escaneando Diretórios por Novos Arquivos...\nPreparando...", - "library.refresh.title": "Atualizando Pastas", "library.scan_library.title": "Escaneando Biblioteca", "library_info.cleanup": "Limpeza", "library_info.cleanup.backups": "Backup de Bibliotecas:", @@ -221,7 +216,6 @@ "menu.file.open_create_library": "&Abrir/Criar Biblioteca", "menu.file.open_library": "Abrir Biblioteca", "menu.file.open_recent_library": "Abrir Recente", - "menu.file.refresh_directories": "Atualizar Pastas", "menu.file.save_backup": "&Salvar Backup da Biblioteca", "menu.file.save_library": "Salvar Biblioteca", "menu.help": "&Ajuda", diff --git a/src/tagstudio/resources/translations/qpv.json b/src/tagstudio/resources/translations/qpv.json index c2bc8a23d..cf26d51ac 100644 --- a/src/tagstudio/resources/translations/qpv.json +++ b/src/tagstudio/resources/translations/qpv.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "Du kestetsa afto {count} shiruzmakaban we? Nil mlafu na shiruzmabaksu bli kestejena.", "entries.remove.singular.confirm": "Du kestetsa afto shiruzmakaban long mlafuhuomi we? Nil mlafu na shiruzmabaksu bli kestejena.", "entries.running.dialog.new_entries": "Nasii {total} neo shiruzmakaban fu mlafu ima...", - "entries.running.dialog.title": "Nasii neo shiruzmakaban fu mlafu ima", "entries.tags": "Festaretol", "entries.unlinked.description": "Tont shiruzmakaban fu mlafuhuomi tsunagajena na mlafu ine joku mlafukaban fu du. Li mlafu tsunagajena na shiruzmakaban ugokijena os kestejena ekso TagStudio, sit sore kntsunagajena.

Tsunaganaijena shiruzmakaban deki tsunaga gen na suha per mlafukaban fu du os keste li du vil.", "entries.unlinked.relink.attempting": "Iskat ima na tsunaga gen {index}/{unlinked_count} shiruzmakaban, {fixed_count} tsunagajena gen", @@ -179,10 +178,6 @@ "landing.open_create_library": "Auki/Maha mlafuhuomi {shortcut}", "library.missing": "Mlafuplas fu mlafuhuomi nai finnajenadan", "library.name": "Mlafuhuomi", - "library.refresh.scanning.plural": "Taskame mlafukaban fu neo mlafu ima...\n{searched_count} mlafu suhajenadan, {found_count} neo mlafu finnajenadan", - "library.refresh.scanning.singular": "Taskame mlafukaban fu neo mlafu ima...\n{searched_count} mlafu suhajenadan, {found_count} neo mlafu finnajenadan", - "library.refresh.scanning_preparing": "Taskame mlafukaban fu neo mlafu ima...\nGotova ima...", - "library.refresh.title": "Gengotova al mlafukaban", "library.scan_library.title": "Taskame mlafuhuomi ima", "library_info.cleanup": "Parjat", "library_info.cleanup.backups": "Mverm long mlafuhuomi:", @@ -217,7 +212,6 @@ "menu.file.open_create_library": "&Auki/maha mlafuhuomi", "menu.file.open_library": "Auki mlafuhuomi", "menu.file.open_recent_library": "Auki moloda", - "menu.file.refresh_directories": "&Gengotova al mlafukaban", "menu.file.save_backup": "&Ufne mverm fu mlafuhuomi", "menu.file.save_library": "Ufne mlafuhuomi", "menu.help": "&Aputsa", diff --git a/src/tagstudio/resources/translations/ru.json b/src/tagstudio/resources/translations/ru.json index 573b4e5f7..f7b630917 100644 --- a/src/tagstudio/resources/translations/ru.json +++ b/src/tagstudio/resources/translations/ru.json @@ -58,7 +58,6 @@ "entries.remove.plural.confirm": "Вы уверены, что хотите удалить {count} записей? Файлы на диске не будут удалены.", "entries.remove.singular.confirm": "Вы уверены, что хотите удалить эту запись? Файл на диске не будет удалён.", "entries.running.dialog.new_entries": "Добавление {total} новых записей...", - "entries.running.dialog.title": "Добавление новых записей", "entries.tags": "Теги", "entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".

Откреплённые записи могут быть прикреплены обратно автоматически, либо же удалены если в них нет надобности.", "entries.unlinked.relink.attempting": "Попытка перепривязать {index}/{unlinked_count} записей, {fixed_count} привязано успешно", @@ -211,10 +210,6 @@ "language.zh_Hant": "Китайский (традиционный)", "library.missing": "Отсутствует путь к библиотеке", "library.name": "Библиотека", - "library.refresh.scanning.plural": "Сканирование папок на наличие новых файлов...\nПросканировано {searched_count} файлов, найдено {found_count} новых", - "library.refresh.scanning.singular": "Сканирование папок на наличие новых файлов...\nПросканирован {searched_count} файл, найдено {found_count} новых", - "library.refresh.scanning_preparing": "Сканирование папок на наличие новых файлов...\nПодготовка...", - "library.refresh.title": "Обновление папок", "library.scan_library.title": "Сканирование библиотеки", "library_info.cleanup.backups": "Резервные копии библиотек:", "library_info.cleanup.dupe_files": "Файлы-дубликаты:", @@ -255,7 +250,6 @@ "menu.file.open_create_library": "&Открыть/создать библиотеку", "menu.file.open_library": "Открыть библиотеку", "menu.file.open_recent_library": "Открыть последнюю", - "menu.file.refresh_directories": "Обновить папки", "menu.file.save_backup": "&Сохранить резервную копию библиотеки", "menu.file.save_library": "Сохранить библиотеку", "menu.help": "&Помощь", diff --git a/src/tagstudio/resources/translations/sv.json b/src/tagstudio/resources/translations/sv.json index a34c19400..ed6872813 100644 --- a/src/tagstudio/resources/translations/sv.json +++ b/src/tagstudio/resources/translations/sv.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "Är du säker att du vill radera följande {count} poster?", "entries.remove.singular.confirm": "Är du säker på att du vill ta bort denna post från ditt bibliotek? Inga filer på disken kommer att raderas.", "entries.running.dialog.new_entries": "Lägger Till {total} Nya Filposter...", - "entries.running.dialog.title": "Lägger Till Nya Filposter", "entries.tags": "Etiketter", "entries.unlinked.description": "Varje post i biblioteket är länkad till en fil i en av dina kataloger. Om en fil länkad till en post är flyttad eller borttagen utanför TagStudio blir den olänkad. Olänkade poster kan automatiskt bli omlänkade genom att söka genom dina kataloger, manuellt omlänkade av användaren eller tas bort om så önskas.", "entries.unlinked.relink.attempting": "Försöker att länka om {index}/{unlinked_count} Poster, {fixed_count} Lyckades Länkas Om", @@ -100,8 +99,6 @@ "home.thumbnail_size": "Miniatyrbildsstorlek", "library.missing": "Platsen saknas", "library.name": "Bibliotek", - "library.refresh.scanning_preparing": "Skannar kataloger efter nya filer...\nFörbereder...", - "library.refresh.title": "Uppdaterar kataloger", "library.scan_library.title": "Skannar bibliotek", "macros.running.dialog.title": "Kör makros på nya poster", "menu.edit": "Redigera", diff --git a/src/tagstudio/resources/translations/ta.json b/src/tagstudio/resources/translations/ta.json index 750887723..a058ec494 100644 --- a/src/tagstudio/resources/translations/ta.json +++ b/src/tagstudio/resources/translations/ta.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "இந்த {count} உள்ளீடுகளை உங்கள் நூலகத்திலிருந்து நீக்க விரும்புகிறீர்களா? வட்டில் உள்ள எந்தக் கோப்புகளும் நீக்கப்படாது.", "entries.remove.singular.confirm": "உங்கள் நூலகத்திலிருந்து இந்தப் பதிவை நிச்சயமாக அகற்ற விரும்புகிறீர்களா? வட்டில் உள்ள கோப்புகள் எதுவும் நீக்கப்படாது.", "entries.running.dialog.new_entries": "{total} புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது ...", - "entries.running.dialog.title": "புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது", "entries.tags": "குறிச்சொற்கள்", "entries.unlinked.description": "ஒவ்வொரு நூலக நுழைவும் உங்கள் கோப்பகங்களில் ஒன்றில் ஒரு கோப்போடு இணைக்கப்பட்டுள்ளது. ஒரு நுழைவுடன் இணைக்கப்பட்ட ஒரு கோப்பு முகவரிச்சீட்டுஅறைக்கு வெளியே நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அது பின்னர் இணைக்கப்படாததாகக் கருதப்படுகிறது.", "entries.unlinked.relink.attempting": "{index}/{unlinked_count} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, {fixed_count} மீண்டும் இணைக்கப்பட்டது", @@ -176,10 +175,6 @@ "landing.open_create_library": "நூலகத்தைத் திறக்கவும்/உருவாக்கவும் {shortcut}", "library.missing": "இடம் காணவில்லை", "library.name": "நூலகம்", - "library.refresh.scanning.plural": "புதிய கோப்புகளுக்கான கோப்பகங்களை ச்கேன் செய்தல் ...\n {searched_count} கோப்புகள் தேடப்பட்டன, {found_count} புதிய கோப்புகள் காணப்படுகின்றன", - "library.refresh.scanning.singular": "புதிய கோப்புகளுக்கான கோப்பகங்களை ச்கேன் செய்தல் ...\n {searched_count} கோப்பு தேடப்பட்டது, {found_count} புதிய கோப்புகள் காணப்படுகின்றன", - "library.refresh.scanning_preparing": "புதிய கோப்புகளுக்கான அடைவுகள் சோதனை செய்யப்படுகின்றது...\nதயாராகிறது...", - "library.refresh.title": "கோப்பகங்கள் புதுப்பிக்கப்படுகின்றன", "library.scan_library.title": "புத்தககல்லரி சோதனை செய்யப்படுகிறது", "library_info.cleanup": "தூய்மை", "library_info.cleanup.backups": "நூலக காப்புப்பிரதிகள்:", @@ -221,7 +216,6 @@ "menu.file.open_create_library": "& நூலகத்தைத் திறக்க/உருவாக்கவும்", "menu.file.open_library": "திறந்த நூலகம்", "menu.file.open_recent_library": "அண்மைக் கால திறப்பு", - "menu.file.refresh_directories": "கோப்பகத்தை புதுப்பிக்கவும்", "menu.file.save_backup": " நூலக காப்புப்பிரதியை சேமிக்கவும்", "menu.file.save_library": "நூலகத்தை சேமிக்கவும்", "menu.help": "உதவி (&h)", diff --git a/src/tagstudio/resources/translations/tok.json b/src/tagstudio/resources/translations/tok.json index eaeb15d25..c49bdb7c9 100644 --- a/src/tagstudio/resources/translations/tok.json +++ b/src/tagstudio/resources/translations/tok.json @@ -55,7 +55,6 @@ "entries.remove.plural.confirm": "mi weka e ijo {count}. ni li pona anu seme? poki lipu pi ilo sina la lipu ala li weka.", "entries.remove.singular.confirm": "mi weka e ijo ni. ni li pona anu seme? poki lipu pi ilo sina la lipu ala li weka.", "entries.running.dialog.new_entries": "mi pana e lipu sin {total}...", - "entries.running.dialog.title": "mi pana e lipu sin", "entries.tags": "poki", "entries.unlinked.description": "ijo ale li jo e ijo lon tomo sina. ona li tawa anu weka lon ilo TagStudio ala la, ona li jo ala e ijo lon.

ijo pi ijo lon li ken alasa lon tomo li ken kama jo e ijo lon. ante la sina ken weka e ona.", "entries.unlinked.relink.attempting": "mi o pana e ijo lon tawa ijo {index}/{unlinked_count}. mi pana e ijo lon tawa ijo {fixed_count}", @@ -176,10 +175,6 @@ "landing.open_create_library": "o open anu pali sin e tomo {shortcut}", "library.missing": "tomo li lon ala", "library.name": "tomo", - "library.refresh.scanning.plural": "mi alasa e lipu sin lon tomo...\nmi alasa e lipu {searched_count}, mi lukin e lipu sin {found_count}", - "library.refresh.scanning.singular": "mi alasa e lipu sin lon tomo...\nmi alasa e lipu {searched_count}, mi lukin e lipu sin {found_count}", - "library.refresh.scanning_preparing": "mi alasa e ijo sin lon tomo...\nmi kama pona...", - "library.refresh.title": "mi kama jo e sin lon tomo", "library.scan_library.title": "mi o lukin e tomo", "library_info.cleanup": "jaki", "library_info.cleanup.backups": "sama awen tomo:", @@ -219,7 +214,6 @@ "menu.file.open_create_library": "o &open/pali e tomo", "menu.file.open_library": "o open e tomo", "menu.file.open_recent_library": "o open e poka", - "menu.file.refresh_directories": "o lukin sin lon tomo (&R)", "menu.file.save_backup": "o awen e &sama awen tomo", "menu.file.save_library": "o awen e sona tomo", "menu.help": "mi jo e toki seme (&H)", diff --git a/src/tagstudio/resources/translations/tr.json b/src/tagstudio/resources/translations/tr.json index fe0c682d5..f5c4b25d5 100644 --- a/src/tagstudio/resources/translations/tr.json +++ b/src/tagstudio/resources/translations/tr.json @@ -46,7 +46,6 @@ "entries.mirror.window_title": "Kayıtları Yansıt", "entries.remove.plural.confirm": "{count} tane kayıtları silmek istediğinden emin misin?", "entries.running.dialog.new_entries": "{total} Yeni Dosya Kaydı Ekleniyor...", - "entries.running.dialog.title": "Yeni Dosya Kayıtları Ekleniyor", "entries.tags": "Etiketler", "entries.unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopmuş olarak sayılır.

Kopmuş kayıtlar dizinlerinizde arama yapılırken otomatik olarak tekrar eşleştirilebilir, manuel olarak sizin tarafınızdan eşleştirilebilir veya isteğiniz üzere silinebilir.", "entries.unlinked.relink.attempting": "{index}/{unlinked_count} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, {fixed_count} Başarıyla Yeniden Eşleştirildi", @@ -156,10 +155,6 @@ "landing.open_create_library": "Kütüphane Aç/Oluştur {shortcut}", "library.missing": "Lokasyon bulunamadı", "library.name": "Kütüphane", - "library.refresh.scanning.plural": "Yeni Dosyalar İçin Dizinler Taranıyor...\n{searched_count} Dosya Tarandı, {found_count} Yeni Dosya Bulundu", - "library.refresh.scanning.singular": "Yeni Dosyalar için Dizinler Taranıyor...\n{searched_count} Dosya Tarandı, {found_count} Yeni Dosya Bulundu", - "library.refresh.scanning_preparing": "Yeni Dosyalar için Dizinler Taranıyor...\nHazırlanıyor...", - "library.refresh.title": "Dizinler Yenileniyor", "library.scan_library.title": "Kütüphane Taranıyor", "library_info.stats.entries": "Kayıtlar:", "library_info.stats.fields": "Ek Bilgiler:", @@ -185,7 +180,6 @@ "menu.file.open_create_library": "Kütüphane &Aç/Oluştur", "menu.file.open_library": "Kütüphane Aç", "menu.file.open_recent_library": "Son Kullanılanları Aç", - "menu.file.refresh_directories": "Klasörleri &Yenile", "menu.file.save_backup": "Kütüphane Yedeğini &Kaydet", "menu.file.save_library": "Kütüphaneyi Kaydet", "menu.help": "&Yardım", diff --git a/src/tagstudio/resources/translations/zh_Hans.json b/src/tagstudio/resources/translations/zh_Hans.json index acf9f408b..689837bd5 100644 --- a/src/tagstudio/resources/translations/zh_Hans.json +++ b/src/tagstudio/resources/translations/zh_Hans.json @@ -55,7 +55,6 @@ "entries.mirror.window_title": "项目镜像", "entries.remove.plural.confirm": "您确定要删除以下 {count} 个项目?", "entries.running.dialog.new_entries": "正在加入 {total} 个新文件项目...", - "entries.running.dialog.title": "正在加入新文件项目", "entries.tags": "标签", "entries.unlinked.description": "每个仓库条目都链接到一个目录中的文件。如果链接到某个条目的文件在TagStudio之外被移动或删除,则会被视为未链接。

未链接的条目可能会通过搜索目录自动重新链接,或者根据需要删除。", "entries.unlinked.relink.attempting": "正在尝试重新链接 {index}/{unlinked_count} 个项目, {fixed_count} 个项目成功重链", @@ -173,10 +172,6 @@ "landing.open_create_library": "打开/创建仓库 {shortcut}", "library.missing": "仓库路径缺失", "library.name": "仓库", - "library.refresh.scanning.plural": "正在扫描文件夹中的新文件...\n已找到 {searched_count} 个文件,找到 {found_count} 个新文件", - "library.refresh.scanning.singular": "正在扫描文件夹中的新文件...\n已找到 {searched_count} 个文件,找到 {found_count} 个新文件", - "library.refresh.scanning_preparing": "正在扫描文件夹中的新文件...\n准备中...", - "library.refresh.title": "正在刷新目录", "library.scan_library.title": "正在扫描仓库", "library_info.cleanup": "清理", "library_info.cleanup.dupe_files": "重复文件:", @@ -213,7 +208,6 @@ "menu.file.open_create_library": "打开/创建仓库(&o)", "menu.file.open_library": "打开仓库", "menu.file.open_recent_library": "打开最近仓库", - "menu.file.refresh_directories": "刷新文件夹(&r)", "menu.file.save_backup": "保存仓库备份(&s)", "menu.file.save_library": "保存仓库", "menu.help": "帮助(&h)", diff --git a/src/tagstudio/resources/translations/zh_Hant.json b/src/tagstudio/resources/translations/zh_Hant.json index 4e31a5a0a..140c3907f 100644 --- a/src/tagstudio/resources/translations/zh_Hant.json +++ b/src/tagstudio/resources/translations/zh_Hant.json @@ -56,7 +56,6 @@ "entries.remove.plural.confirm": "您確定要刪除 {count} 個項目嗎?硬碟上不會有檔案被刪除。", "entries.remove.singular.confirm": "您確定要從您的文件庫刪除這個項目嗎? 硬碟上不會有檔案被刪除。", "entries.running.dialog.new_entries": "正在加入 {total} 個新檔案項目...", - "entries.running.dialog.title": "正在加入新檔案項目", "entries.tags": "標籤", "entries.unlinked.description": "每個文件庫的項目都連接到您的其中一個檔案,如果一個已連接的檔案被刪除或移出 TagStudio,那麼這個項目會被歸類為「未連接」。

您可以透過搜尋您的檔案來讓未連接的項目自動重新連接,或者自動刪除這些未連接項目。", "entries.unlinked.relink.attempting": "正在嘗試重新連接 {index}/{unlinked_count} 個項目,已成功重新連接 {fixed_count} 個", @@ -175,10 +174,6 @@ "landing.open_create_library": "開啟/建立文件庫 {shortcut}", "library.missing": "文件庫路徑遺失", "library.name": "文件庫", - "library.refresh.scanning.plural": "正在掃描目錄尋找新檔案...\n已搜尋 {searched_count} 個檔案,找到 {found_count} 個新檔案", - "library.refresh.scanning.singular": "正在掃描目錄尋找新檔案...\n已搜尋 {searched_count} 個檔案,找到 {found_count} 個新檔案", - "library.refresh.scanning_preparing": "正在掃描目錄尋找新檔案...\n準備中...", - "library.refresh.title": "重新整理目錄", "library.scan_library.title": "掃描文件庫", "library_info.cleanup": "清理", "library_info.cleanup.backups": "文件庫備份:", @@ -220,7 +215,6 @@ "menu.file.open_create_library": "開啟/建立文件庫 (&O)", "menu.file.open_library": "開啟文件庫", "menu.file.open_recent_library": "開啟最近使用的文件庫", - "menu.file.refresh_directories": "重新整理目錄 (&R)", "menu.file.save_backup": "儲存文件庫備份 (&S)", "menu.file.save_library": "儲存文件庫", "menu.help": "幫助 (&H)", diff --git a/tests/core/library/test_library.py b/tests/core/library/test_library.py index 2ee25f646..a89cb9fb5 100644 --- a/tests/core/library/test_library.py +++ b/tests/core/library/test_library.py @@ -9,7 +9,7 @@ import pytest import structlog -from tagstudio.core.library.alchemy.enums import BrowsingState +from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum from tagstudio.core.library.alchemy.fields import ( DatetimeField, TextField, @@ -80,11 +80,52 @@ def test_library_add_file(library: Library): fields=[TextField(name="Title", value="I'm a Test Title")], ) - assert not library.get_entry_id_from_path(entry.path) + assert library.get_entry_id_from_path(entry.path) == -1 assert library.add_entries([entry]) assert library.get_entry_id_from_path(entry.path) +def test_path_cache_untouched_when_not_yet_built(library: Library): + """Only `get_or_build_path_cache()` may build the path cache.""" + assert library.path_cache is None + entry = Entry(path=Path("before_any_cache.txt"), fields=[]) + library.add_entries([entry]) + assert library.path_cache is None + + +def test_path_cache_self_maintained_by_add_entries(library: Library): + """`add_entries()` must keep an already-built path cache up to date on its own.""" + library.is_case_sensitive_fs = True + cache = library.get_or_build_path_cache() + assert Path("added_directly.txt") not in cache + + entry = Entry(path=Path("added_directly.txt"), fields=[]) + new_ids = library.add_entries([entry]) + assert cache.get(Path("added_directly.txt")) == new_ids[0] + + +def test_path_cache_self_maintained_by_remove_entries(library: Library): + library.is_case_sensitive_fs = True + cache = library.get_or_build_path_cache() + entry = Entry(path=Path("to_remove.txt"), fields=[]) + entry_id = library.add_entries([entry])[0] + assert cache.get(Path("to_remove.txt")) == entry_id + + library.remove_entries([entry_id]) + assert Path("to_remove.txt") not in cache + + +def test_path_cache_self_maintained_by_update_entry_path(library: Library): + library.is_case_sensitive_fs = True + cache = library.get_or_build_path_cache() + entry = Entry(path=Path("old_location.txt"), fields=[]) + entry_id = library.add_entries([entry])[0] + + assert library.update_entry_path(entry_id, Path("new_location.txt")) + assert Path("old_location.txt") not in cache + assert cache.get(Path("new_location.txt")) == entry_id + + def test_create_tag(library: Library, generate_tag: Callable[..., Tag]): # tag already exists assert library.add_tag(generate_tag("foo", id=1000)) is None @@ -148,6 +189,35 @@ def test_entries_count(library: Library): assert len(results) == 5 +@pytest.mark.parametrize( + "sorting_mode", + [SortingModeEnum.DATE_CREATED, SortingModeEnum.DATE_MODIFIED, SortingModeEnum.FILE_SIZE], +) +def test_search_library_sorting(library: Library, sorting_mode: SortingModeEnum): + entries = [ + Entry( + path=Path(f"sort_{i}.txt"), + fields=[], + date_created=float(i), + date_modified=float(i), + file_size=i, + ) + for i in range(3) + ] + new_ids = library.add_entries(entries) + assert len(new_ids) == 3 + + results = library.search_library( + BrowsingState.show_all() + .with_sorting_mode(sorting_mode) + .with_sorting_direction(ascending=True), + page_size=None, + ) + + sorted_new_ids = [entry_id for entry_id in results if entry_id in new_ids] + assert sorted_new_ids == new_ids + + def test_parents_add(library: Library, generate_tag: Callable[..., Tag]): # Given tag: Tag = library.tags[0] @@ -338,7 +408,7 @@ def test_merge_entries(library: Library): entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id)) assert library.merge_entries(entry_a_, entry_b_) - assert not library.get_entry_id_from_path(Path("a")) + assert library.get_entry_id_from_path(Path("a")) == -1 assert library.get_entry_id_from_path(Path("b")) entry_b_merged = unwrap(library.get_entry_full(entry_b_id)) diff --git a/tests/core/library/test_refresh.py b/tests/core/library/test_refresh.py deleted file mode 100644 index 01578aad8..000000000 --- a/tests/core/library/test_refresh.py +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from pathlib import Path -from tempfile import TemporaryDirectory - -import pytest - -from tagstudio.core.constants import IGNORE_NAME -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.refresh import RefreshTracker -from tagstudio.core.utils.types import unwrap - -CWD = Path(__file__).parent - - -@pytest.mark.parametrize("exclude_mode", [True, False]) -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_refresh_new_files(library: Library, exclude_mode: bool): - library_dir = unwrap(library.library_dir) - # Given - registry = RefreshTracker(library=library) - library.included_files.clear() - (library_dir / "FOO.MD").touch() - (library_dir / IGNORE_NAME).write_text("*.md" if exclude_mode else "*\n!*.md") - - # Test if the single file was added - list(registry.refresh_dir(library_dir, force_internal_tools=True)) - assert set(registry.files_not_in_library) == set([Path(IGNORE_NAME), Path("FOO.MD")]) - - -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_refresh_multi_byte_filenames(library: Library): - library_dir = unwrap(library.library_dir) - # Given - registry = RefreshTracker(library=library) - library.included_files.clear() - (library_dir / ".TagStudio").mkdir() - (library_dir / "こんにちは.txt").touch() - (library_dir / "em–dash.txt").touch() - (library_dir / "apostrophe’.txt").touch() - (library_dir / "umlaute äöü.txt").touch() - - # Test if all files were added with their correct names and without exceptions - list(registry.refresh_dir(library_dir)) - assert Path("こんにちは.txt") in registry.files_not_in_library - assert Path("em–dash.txt") in registry.files_not_in_library - assert Path("apostrophe’.txt") in registry.files_not_in_library - assert Path("umlaute äöü.txt") in registry.files_not_in_library diff --git a/tests/core/library/test_sync.py b/tests/core/library/test_sync.py new file mode 100644 index 000000000..b9b071fe4 --- /dev/null +++ b/tests/core/library/test_sync.py @@ -0,0 +1,406 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +# pyright: reportPrivateUsage=false + +import os +import unicodedata +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME +from tagstudio.core.library.alchemy.enums import BrowsingState +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.library.sync import LibrarySyncEngine +from tagstudio.core.utils.types import unwrap + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_new_files(library: Library): + """New files that aren't excluded by an ignore pattern must be picked up as new.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + (library_dir / "foo.md").touch() + (library_dir / "bar.txt").touch() + ts_ignore_path = library_dir / TS_FOLDER_NAME / IGNORE_NAME + ts_ignore_path.parent.mkdir(parents=True, exist_ok=True) + ts_ignore_path.write_text("*.md") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert set(engine.new_paths) == {Path("bar.txt")} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_multi_byte_filenames(library: Library): + """Multi-byte and accented Unicode filenames must be scanned and added without errors.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + (library_dir / ".TagStudio").mkdir() + (library_dir / "こんにちは.txt").touch() + (library_dir / "em–dash.txt").touch() + (library_dir / "apostrophe’.txt").touch() + (library_dir / "umlaute äöü.txt").touch() + + list(engine.sync_dir(library_dir)) + assert Path("こんにちは.txt") in engine.new_paths + assert Path("em–dash.txt") in engine.new_paths + assert Path("apostrophe’.txt") in engine.new_paths + assert Path("umlaute äöü.txt") in engine.new_paths + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_unlinked_entries(library: Library): + """An unlinked entry with one matching file must be found by `relink_unlinked_entries()`.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + # Touch the file "bar.md" but in the wrong location, to simulate a moved file + (library_dir / "bar.md").touch() + + # Neither library entry ("foo.txt", "one/two/bar.md") exists on disk, so both are unlinked + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.unlinked_entries_count == 2 + + # Relinking bar.md should match and relink to the entry that was at "one/two/bar.md" + list(engine.relink_unlinked_entries()) + assert engine.manual_relink_count == 1 + assert engine.unlinked_entries_count == 1 + + results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) + entries = library.get_entries(results.ids) + assert entries[0].path == Path("bar.md") + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_nfd_nfc_false_positive(library: Library): + """A file reappearing in a different Unicode form must not look like a new/duplicate entry.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + nfc_name = unicodedata.normalize("NFC", "SKÅL.txt") + nfd_name = unicodedata.normalize("NFD", "SKÅL.txt") + assert nfc_name != nfd_name + + (library_dir / nfc_name).touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + # Simulate the file later showing up in NFD form, as if the filesystem was changed + (library_dir / nfc_name).unlink() + (library_dir / nfd_name).touch() + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [] + assert len(engine.paths_to_restat) == 1 + assert engine.unlinked_entries_count == 2 + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_case_sensitivity_aware_relink(library: Library): + """`find_relink_candidates()` must respect the library's case-sensitivity setting.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "Other").mkdir() + (library_dir / "Other" / "name.txt").touch() + library.add_entries([Entry(path=Path("Folder/Name.txt"), fields=[])]) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + unlinked_entry = next(e for e in engine.unlinked_entries if e.path == Path("Folder/Name.txt")) + + library.is_case_sensitive_fs = True + assert engine.find_relink_candidates(unlinked_entry) == [] + + library.is_case_sensitive_fs = False + engine._filename_to_path_map = None # Rebuild the index with the new case sensitivity + assert engine.find_relink_candidates(unlinked_entry) == [Path("Other/name.txt")] + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_cache_pruned_on_remove(library: Library): + """Removing an unlinked entry must prune the path cache.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + tracked_path = Path("tracked.txt") + (library_dir / tracked_path).touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / tracked_path).unlink() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + tracked_entry = next(e for e in engine.unlinked_entries if e.path == tracked_path) + + cache = library.get_or_build_path_cache() + assert tracked_path in cache + + # Only an explicit removal should prune the cache + engine.unlinked_entries = [tracked_entry] + engine.remove_unlinked_entries() + assert tracked_path not in cache + + # A different file at the same path afterwards should be treated as new + (library_dir / tracked_path).touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [tracked_path] + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_duplicate_case_collision_treated_as_unlinked(library: Library): + """A duplicate entry displaced by a case-insensitive collision must be treated as unlinked.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + library.is_case_sensitive_fs = False # Force a collision using a case difference + + (library_dir / "Dupe").mkdir() + (library_dir / "Dupe" / "photo.jpg").touch() + entry_a_id, entry_b_id = library.add_entries( + [ + Entry(path=Path("Dupe/photo.jpg"), fields=[]), + Entry(path=Path("Dupe/PHOTO.JPG"), fields=[]), + ] + ) + + cache = library.get_or_build_path_cache() + path_key = Path("dupe/photo.jpg") # Case-insensitive + NFD + assert library.duplicate_path_entry_ids is not None + assert len(library.duplicate_path_entry_ids) == 1 + dupe_id = library.duplicate_path_entry_ids[0] + original_id = entry_b_id if dupe_id == entry_a_id else entry_a_id + assert cache.get(path_key) == original_id + + # The dupe ID should be marked as unlinked, and the original ID should not + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + unlinked_ids = {e.id for e in engine.unlinked_entries} + assert dupe_id in unlinked_ids + assert original_id not in unlinked_ids + + # Removing the duplicate entry shouldn't remove the original from the cache + engine.unlinked_entries = [e for e in engine.unlinked_entries if e.id == dupe_id] + engine.remove_unlinked_entries() + assert cache.get(path_key) == original_id + assert library.duplicate_path_entry_ids == [] + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_moved_file(library: Library): + """A moved file (same filename + stats, different path) must auto-relink to its entry.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "moveme.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + original_id = library.get_entry_id_from_path(Path("moveme.txt")) + assert original_id >= 0 + + (library_dir / "sub").mkdir() + (library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [] + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == original_id + assert Path("sub/moveme.txt") not in {e.path for e in engine.unlinked_entries} + assert library.get_entry_id_from_path(Path("sub/moveme.txt")) == original_id + # The original path should no longer be associated with an entry + assert library.get_entry_id_from_path(Path("moveme.txt")) == -1 + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_moved_file_ambiguous(library: Library): + """Two equally-matching candidates (same filename + stats) must not auto-relink. + + NOTE: This may change in the future as capability expands. + """ + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "a").mkdir() + (library_dir / "b").mkdir() + (library_dir / "a" / "dupe.txt").touch() + (library_dir / "b" / "dupe.txt").touch() + st = (library_dir / "a" / "dupe.txt").stat() + os.utime(library_dir / "b" / "dupe.txt", (st.st_atime, st.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / "a" / "dupe.txt").unlink() + (library_dir / "b" / "dupe.txt").unlink() + (library_dir / "c").mkdir() + (library_dir / "c" / "dupe.txt").touch() + os.utime(library_dir / "c" / "dupe.txt", (st.st_atime, st.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("c/dupe.txt") in engine.new_paths + unlinked_paths = {e.path for e in engine.unlinked_entries} + assert Path("a/dupe.txt") in unlinked_paths + assert Path("b/dupe.txt") in unlinked_paths + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_moved_file_stat_mismatch(library: Library): + """A file with (same filename, different stats) must not auto-relink. + + NOTE: This is a limitation of the current auto-relinking system, NOT a design principle. + """ + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "notmoved.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / "notmoved.txt").unlink() + (library_dir / "sub").mkdir() + (library_dir / "sub" / "notmoved.txt").touch() + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("sub/notmoved.txt") in engine.new_paths + assert Path("notmoved.txt") in {e.path for e in engine.unlinked_entries} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_renamed_file(library: Library): + """A renamed file (different filename, same stats) must auto-relink to its entry.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "original_name.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + original_id = library.get_entry_id_from_path(Path("original_name.txt")) + assert original_id >= 0 + + (library_dir / "original_name.txt").rename(library_dir / "renamed.txt") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [] + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == original_id + assert Path("renamed.txt") not in {e.path for e in engine.unlinked_entries} + assert library.get_entry_id_from_path(Path("renamed.txt")) == original_id + # The original path should no longer be associated with an entry + assert library.get_entry_id_from_path(Path("original_name.txt")) == -1 + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_renamed_file_ambiguous(library: Library): + """Two equally-matching candidates (different filenames, same stats) must not auto-relink. + + NOTE: This may be automated in the future, but for now differs to the user's judgement. + """ + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "a").mkdir() + (library_dir / "b").mkdir() + (library_dir / "a" / "one.txt").touch() + (library_dir / "b" / "two.txt").touch() + st = (library_dir / "a" / "one.txt").stat() + os.utime(library_dir / "b" / "two.txt", (st.st_atime, st.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / "a" / "one.txt").unlink() + (library_dir / "b" / "two.txt").unlink() + (library_dir / "c").mkdir() + (library_dir / "c" / "renamed.txt").touch() + os.utime(library_dir / "c" / "renamed.txt", (st.st_atime, st.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("c/renamed.txt") in engine.new_paths + unlinked_paths = {e.path for e in engine.unlinked_entries} + assert Path("a/one.txt") in unlinked_paths + assert Path("b/two.txt") in unlinked_paths + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_moved_and_renamed_together(library: Library): + """A moved file and a renamed file in the same sync must each auto-relink via their own pass.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "moveme.txt").touch() + (library_dir / "rename_orig.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + moved_id = library.get_entry_id_from_path(Path("moveme.txt")) + renamed_id = library.get_entry_id_from_path(Path("rename_orig.txt")) + assert moved_id >= 0 + assert renamed_id >= 0 + + (library_dir / "sub").mkdir() + (library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt") + (library_dir / "rename_orig.txt").rename(library_dir / "rename_new.txt") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [] + assert engine.relinked_entries_count == 2 + assert {e.id for e in engine.relinked_entries} == {moved_id, renamed_id} + assert library.get_entry_id_from_path(Path("sub/moveme.txt")) == moved_id + assert library.get_entry_id_from_path(Path("rename_new.txt")) == renamed_id + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_cancel_skips_unlinked_finalization(library: Library): + """A cancelled `sync_dir()` call must not mark any entries as unlinked.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "a.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + baseline_unlinked_count = engine.unlinked_entries_count + + # Case where sync is cancelled before this scan even starts + engine.cancelled = True + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.unlinked_entries_count == baseline_unlinked_count + assert engine.new_paths == [] + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_cancel_skips_save_new_entries(library: Library): + """A cancelled `save_new_entries()` call must not save any new entries.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + baseline_count = library.entries_count + + (library_dir / "f0.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [Path("f0.txt")] + + engine.cancelled = True + list(engine.save_new_entries()) + assert library.entries_count == baseline_count + assert engine.new_paths == [Path("f0.txt")] + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_cancel_skips_sync_entry_stats(library: Library): + """A cancelled `sync_entry_stats()` call must not touch `paths_to_restat`.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "f0.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + # A second scan finds the same file again, marking it for a restat + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert len(engine.paths_to_restat) == 1 + expected = list(engine.paths_to_restat) + + engine.cancelled = True + list(engine.sync_entry_stats()) + assert engine.paths_to_restat == expected diff --git a/tests/core/library/test_unlinked_entries.py b/tests/core/library/test_unlinked_entries.py deleted file mode 100644 index 4d20590c6..000000000 --- a/tests/core/library/test_unlinked_entries.py +++ /dev/null @@ -1,38 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from pathlib import Path -from tempfile import TemporaryDirectory - -import pytest - -from tagstudio.core.library.alchemy.enums import BrowsingState -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry -from tagstudio.core.utils.types import unwrap - -CWD = Path(__file__).parent - - -# NOTE: Does this test actually work? -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_refresh_unlinked_entries(library: Library): - registry = UnlinkedRegistry(lib=library) - - # touch the file `one/two/bar.md` but in wrong location to simulate a moved file - (unwrap(library.library_dir) / "bar.md").touch() - - # no files actually exist, so it should return all entries - assert list(registry.refresh_unlinked_files()) == [0, 1] - - # neither of the library entries exist - assert len(registry.unlinked_entries) == 2 - - # iterate through two files - assert list(registry.fix_unlinked_entries()) == [0, 1] - - # `bar.md` should be relinked to new correct path - results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) - entries = library.get_entries(results.ids) - assert entries[0].path == Path("bar.md") diff --git a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 485e54a8f510792d204505ff7fba58c0b3c0b9e9..33d95a4e4e17c665a4df7ef39fa9cf97e4dc4d81 100644 GIT binary patch delta 854 zcmZ9IPfQa*6vpRCJ0)$Y3!_jdv|FHnrZu8LL?UJrNz)(@QPMPk6m3~*{4X9fnp6`L zPhvIX9pUCpNQmi)$Yp`TmU`1f2{B&0Nun3@fHU1JBpxQa-|v0zeKRk;WJ@pEUU|xQ z2q9j6T>O;q6U+opT6f5?Y5i&9eF9!i!SC#^DQJ;kI$JvnZ7#d(1u5l55~_c5+!1t( z=CUe}2c9dFV^^O|_-#eejIZCpTTIVESBIP!n@o;gyO-pjggnr#TnHNI=IumkJhjKh zQ#X>M_fwCOa-{Eacer~XyEq5uC7ZQ^$d>tbEM0LTrr22J>58Jwl68khHrH7n31kgtg1D=^H} zNNrI~<-mG2aLa#O=bR52ETLqODMv7~1&#P=gOBekfJ;;k>{n_qU4S+Y!$3)xDIQ_3 P#?J+40X4f@0FeFxY4rez delta 908 zcmZWnOHWfl6rLl!bevYWwUt+~4<4n2@)qkON*X1mkft_bnx=&N9yM0t?*hUZ->o67KL z)~u}7>*28jm*2wcQ@5tWqch=<>9S*ZVA$yzurX877vi;%iOJCT?eQWG_O5_u?b8Ze zl9hb4NzxFcQ8>-YZBkr%Dh*1S_hxxhQE9hOV$>4iFoq|lX6~bT3r^zRIw<69-Z2#! zyUH_z$f9&7#PsA0T*eop%_};0X6P@ zh8`9)=WvG|-|c`EqZ`megQjBgV2qRJV_-q?M}x*f@dg{IKkV;DM}qs^kHI+>&{N_U zC+WofI8@>JIP^1r9VI>iw&%dE3@GqL9L`c-EhS!IHxq|z%u_>&Tksllyg%PSg@H}5 zvXSb7R*GxA+2mU8DoR{}ZWadWQJsJ`8aRk6ldeNA-2yK zP0_Lq&D19q7F{E71x{}B_u^ChhbK7Byk(SJ7n53XDFaQ|nT9szJ(36c_&R)$hCb#5 TO0Ec|UTD_h*ECpPeF6C|{IK|I diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index a5ab0edbb..000b17c81 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -134,7 +134,7 @@ def test_title_update( qt_driver.main_window.menu_bar.ignore_modal_action = QAction(menu_bar) qt_driver.main_window.menu_bar.save_library_backup_action = QAction(menu_bar) qt_driver.main_window.menu_bar.close_library_action = QAction(menu_bar) - qt_driver.main_window.menu_bar.refresh_dir_action = QAction(menu_bar) + qt_driver.main_window.menu_bar.sync_library_action = QAction(menu_bar) qt_driver.main_window.menu_bar.tag_manager_action = QAction(menu_bar) qt_driver.main_window.menu_bar.color_manager_action = QAction(menu_bar) qt_driver.main_window.menu_bar.new_tag_action = QAction(menu_bar) From 07ec478184753684a59020311c07fa00c206c704 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:24:27 -0700 Subject: [PATCH 3/8] fix: don't lint st_birthtime --- src/tagstudio/core/utils/stat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/core/utils/stat.py b/src/tagstudio/core/utils/stat.py index cafd71d4e..296201593 100644 --- a/src/tagstudio/core/utils/stat.py +++ b/src/tagstudio/core/utils/stat.py @@ -19,7 +19,8 @@ def get_date_modified(path_or_stat: Path | os.stat_result) -> float: def get_date_created(path_or_stat: Path | os.stat_result) -> float: stat = _resolve(path_or_stat) if platform.system() in {"Windows", "Darwin"}: - return stat.st_birthtime + # NOTE: Accessing stat().st_birthtime causes linter checks to fail on some systems. + return stat.st_birthtime # type: ignore[attr-defined, unused-ignore] else: return stat.st_ctime From bb66ae4e2198d3bd7dec9db8a10221c4d5e9fee3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:41:25 -0700 Subject: [PATCH 4/8] docs: update library sync documentation --- .../settings_refresh_library_on_open.png | Bin 49729 -> 59096 bytes docs/libraries.md | 63 ++++++++++++++++-- docs/roadmap.md | 22 +++--- docs/stylesheets/extra.css | 1 + docs/usage.md | 22 ++---- 5 files changed, 77 insertions(+), 31 deletions(-) diff --git a/docs/assets/settings_refresh_library_on_open.png b/docs/assets/settings_refresh_library_on_open.png index 931e455ed98b4da0879d4d6806a6e88a0a179a5e..2469cb9ca1a6f8bd9e89285516ee3648ffa9eaa1 100644 GIT binary patch literal 59096 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 GPyP=pz7Mbf diff --git a/docs/libraries.md b/docs/libraries.md index c48545e6a..3dc986eec 100644 --- a/docs/libraries.md +++ b/docs/libraries.md @@ -33,13 +33,68 @@ To create or open a [library](libraries.md), go to **File -> Open/Create Library !!! info "Legacy Library Migration" If you open a library created with TagStudio **v9.4.2 or earlier** in **[v9.5.0](changelog.md#950-march-3rd-2025) or later**, you'll be walked through a migration process that converts the old `ts_library.json` save file to the new `ts_library.sqlite` format. The original JSON file is preserved and can be easily deleted from the **View -> Library Information** panel once you're satisfied with the migration. -## :material-database-refresh: Refreshing Directories +## :material-database-sync: Library Syncing + +A TagStudio library gets synced with the files found in your chosen content folders and certain metadata attributes (e.g. stats) found on those files. This is a **non-destructive, read-only** process and none of your content files are moved, modified, or deleted. Syncing is indicated by a temporary progress bar, and you can continue to use TagStudio normally while syncing occurs. + +Syncing automatically occurs when you open a library by default, and you can manually sync a library at any time by going to **File -> Sync Library** in the menubar to by pressing Ctrl+R (⌘ Command +R on macOS). If you do not wish for your library to be synced when opened, you can disable this behavior in the settings. + +
+ ![Settings -> Sync Library on Open](assets/settings_refresh_library_on_open.png) +
+ Settings -> Sync Library on Open +
+
+ +### :material-link-variant: Automatic Relinking + +Unlinked entries are file entries in your TagStudio library that have become "unlinked" from their original file on disk, likely as a result of the original file being renamed, moved, or deleted. TagStudio attempts to automatically relink any of these entries as a part of the syncing process, but there are some scenarios where automatic relinking is not possible or too ambiguous and requires a manual review. Below is a complete table of every scenario in which file entries can become unlinked, and whether or not TagStudio can auto-relink them: + +| Case | File Moved? | File Renamed? | File Modified? | Unlinked Entries | Matched Files | Auto-Relink | +| -------: | :---------: | :-----------: | :------------: | :--------------: | :-----------: | :---------------------------------: | +| **\#0** | _No_ | _No_ | **Yes** | 0 | — | :material-minus-circle:{.lg .gray} | +| **\#1** | — | — | — | 1 | 0 | :material-close-circle:{.lg .red} | +| **\#2** | **Yes** | **Yes** | **Yes** | 1 | 0 | :material-close-circle:{.lg .red} | +| **\#3** | **Yes** | **Yes** | _No_ | 1 | 1 | :material-check-circle:{.lg .green} | +| **\#4** | **Yes** | **Yes** | _No_ | 1 | 2+ | :material-close-circle:{.lg .red} | +| **\#5** | **Yes** | **Yes** | _No_ | 2+ | Any | :material-close-circle:{.lg .red} | +| **\#6** | **Yes** | _No_ | **Yes** | 1 | 1 | :material-check-circle:{.lg .green} | +| **\#7** | **Yes** | _No_ | _No_ | 1 | 1 | :material-check-circle:{.lg .green} | +| **\#8** | **Yes** | _No_ | _No_ | 1 | 2+ | :material-close-circle:{.lg .red} | +| **\#9** | **Yes** | _No_ | _No_ | 2+ | Any | :material-close-circle:{.lg .red} | +| **\#10** | _No_ | **Yes** | **Yes** | 1 | 0 | :material-close-circle:{.lg .red} | +| **\#11** | _No_ | **Yes** | _No_ | 1 | 1 | :material-check-circle:{.lg .green} | +| **\#12** | _No_ | **Yes** | _No_ | 1 | 2+ | :material-close-circle:{.lg .red} | +| **\#13** | _No_ | **Yes** | _No_ | 2+ | Any | :material-close-circle:{.lg .red} | + +#### Explanations + +- **Case \#0**: _Modifying file content alone does not create unlinked entries._ +- **Case \#1**: If the original file was deleted, no matches will be found. TagStudio leaves the decision to delete entries up to the user. +- **Case \#2**: If the original file bears no similarities to the unlinked entry anymore, it is indistinguishable from a deleted or new file. +- **Case \#3**: The file has been moved and renamed with a high degree of confidence. +- **Case \#4**: If more than one file is matched with the same stats, the case is too ambiguous. +- **Case \#5**: If two or more entries share the same stats, it's not clear which entry a matched file belongs to. +- **Case \#6**: The file has been moved and modified, but since no other file shares its filename, it is assumed to be the same file with a decent degree of confidence. +- **Case \#7**: The original file has been moved with a high degree of confidence. +- **Case \#8**: If multiple copies of the same moved file are matched in different locations, the case is ambiguous. +- **Case \#9**: _Similar to **\#5**._ If two or more entries share the same filename and stats, it's not clear which entry a matched file belongs to. +- **Case \#10**: _Same as **\#2**._ +- **Case \#11**: The file has been renamed with a high degree of confidence. +- **Case \#12**: _Same as **\#4**._ +- **Case \#13**: _Same as **\#5**._ + +Every numbered case above assumes the entry has saved file metadata attributes to help match against (added in **v9.7**), in which case the **file modification date** combined with the **file size** is used as a soft file signature to aid in scenarios such as renames or moves. If no file metadata is stored with the file entry, or if this soft file signature doesn't lead to a confident match, TagStudio falls back to matching by filename alone: a single file found with that name is automatically relinked, while zero or multiple filename matches leave the entry unlinked for manual review. -TagStudio automatically scans for new or updated files when opening a library by default. This behavior can be toggled in the settings if your library is very large and/or located on a slow drive. + +!!! warning + There's currently no way to manually specify which remaining unlinked entries should be linked with which files, only to delete the entries from the library. Manual relinking is a high priority feature for future releases. -![Settings -> Automatically Load New Files](assets/settings_refresh_library_on_open.png) + +!!! warning "Switching from a Case-Sensitive to Case-Insensitive Filesystem (i.e. Windows)" + If you switch from using TagStudio on a computer with a case-sensitive filesystem to one with a *case-insensitive* one, TagStudio will treat any entries added up to this point with the same filepath + name under case-insensitivity as duplicate entries and merge them. The automatic relinking process will also take case-insensitivity into account when relinking entries. -To manually refresh your library at any time, use **File -> Refresh Directories** from the menu or by using Ctrl+R (⌘ Command +R on macOS). + Currently, TagStudio only uses this case-insensitivity mode when running on Windows. Future versions will be more precise about this distinction, with the aim of determining the case sensitivity on a per-drive basis. ## :material-database-cog: Library Information Panel diff --git a/docs/roadmap.md b/docs/roadmap.md index 518f44dd4..b1a0db978 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -96,7 +96,7 @@ A detailed specification written for the TagStudio tag and/or library format. In - [x] Delete Old Backups **[[v9.5.4](changelog.md#954-september-1st-2025)]** - [x] Delete Legacy JSON File **[[v9.5.4](changelog.md#954-september-1st-2025)]** - [x] Translations -- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** +- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** - [ ] Improved Tag Autocomplete :material-chevron-triple-up:{ .priority-high title="High Priority" } - [ ] Tags appear as widgets in search bar _(similar to new tag search/create bar)_ :material-chevron-triple-up:{ .priority-high title="High Priority" } - [x] Unified Media Player @@ -136,7 +136,7 @@ A detailed specification written for the TagStudio tag and/or library format. In - [x] Theme - [x] Thumbnail Generation **[[v9.5.4](changelog.md#954-september-1st-2025)]** - [x] Configurable Page Size -- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** +- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** - [ ] Stored in `.TagStudio` folder :material-chevron-triple-up:{ .priority-high title="High Priority" } - [ ] Toggle File Extension Label :material-chevron-double-up:{ .priority-med title="Medium Priority" } - [ ] Toggle Duration Label :material-chevron-double-up:{ .priority-med title="Medium Priority" } @@ -149,13 +149,13 @@ A detailed specification written for the TagStudio tag and/or library format. In - [x] Per-Library Tags - [ ] Global Tags :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.8.x]** -- [ ] Multiple Root Directories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Ability to store TagStudio data folder separate from library content folder(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]** - - [ ] Detect Renames :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Detect Moves :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Detect Deletions :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Performant :material-chevron-triple-up:{ .priority-high title="High Priority" } +- [ ] Multiple Root Directories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** + - [ ] Ability to store TagStudio data folder separate from library content folder(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** +- [x] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.0]** + - [x] Detect Renames :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [x] Detect Moves :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Some way to safely handle unlinked entries presumed to be from deleted files automatically (deleted after X days?) :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [x] Performant :material-chevron-triple-up:{ .priority-high title="High Priority" } - [ ] Background File Scanning :material-chevron-triple-up:{ .priority-high title="High Priority" } - [x] Thumbnail Caching **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - [ ] Audio Waveform Caching :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** @@ -166,7 +166,7 @@ A detailed specification written for the TagStudio tag and/or library format. In File or file-like [entries](entries.md) stored in the library. - [x] File Entries **[v1.0.0]** -- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.6.x]** +- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.8.x]** - [x] Fields - [x] Text Lines - [x] Text Boxes @@ -267,7 +267,7 @@ Discrete library objects representing [attributes]( Open/Create Library** in the menu bar or use Ctrl+O (⌘ Command +O on macOS) and chose a folder with file contents you'd like to use as a TagStudio library. If a `.TagStudio` folder doesn't already exist inside the directory, TagStudio will create one and automatically scan the folder for files to include. Otherwise, the pre-existing library is opened. -### :material-database-refresh: Refreshing Directories +### :material-database-sync: Library Syncing -TagStudio automatically scans for new or updated files when opening a library by default. Manually refresh by going to **File -> Refresh Directories** in the menu or by using Ctrl+R (⌘ Command +R on macOS). +A TagStudio library gets synced with the files found in your content folders. This is a **non-destructive, read-only** process and none of your files are moved, modified, or deleted. Syncing is indicated by a temporary progress bar, and you can continue to use TagStudio normally while syncing occurs. + +### :material-link-variant: Automatic Relinking + +Files that become moved, renamed, or modified will try to be automatically relinked during syncing. If a file cannot be found or a match cannot be safely made, it will stay unlinked until the entries are manually deleted under "Fix Unlinked Entries". _Manual relinking is a high priority feature for future versions._ !!! abstract "TagStudio Libraries" @@ -115,20 +119,6 @@ Creating and adding fields to entries is extremely similar to how [tagging](#tag --- -## Relinking Moved Files - -Inevitably some of the files inside your library will be renamed, moved, or deleted. If a file has been renamed or moved, TagStudio will display the thumbnail as a red broken chain link. To relink moved files or delete these entries, select the "Manage Unlinked Entries" option under the Tools menu. Click the "Refresh" button to scan your library for unlinked entries. Once complete, you can attempt to "Search & Relink" any unlinked file entries to their respective files, or "Delete Unlinked Entries" in the event the original files have been deleted and you no longer wish to keep their entries inside your library. - - -!!! warning - There is currently no method to relink entries to files that have been renamed - only moved or deleted. This is a high priority for future releases. - - -!!! warning - If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are high priorities for future versions. - ---- - ## Launch Arguments There are a handful of launch arguments you can pass to TagStudio via the command line or a desktop shortcut. From 0e88d5eb29d94dcbe68f93a3f7d94fb638ec4240 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:42:03 -0700 Subject: [PATCH 5/8] fix(ui): fix banner not always updating label position --- src/tagstudio/qt/controllers/banner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tagstudio/qt/controllers/banner.py b/src/tagstudio/qt/controllers/banner.py index cf6ba5efa..7af625ab0 100644 --- a/src/tagstudio/qt/controllers/banner.py +++ b/src/tagstudio/qt/controllers/banner.py @@ -194,6 +194,7 @@ def _set_mode(self, mode: BannerMode): def _present(self, mode: BannerMode, button_text: str, message: str) -> None: """Applies the banner mode and any label + button text, then animates the banner open.""" self._set_mode(mode) + self.view.label.reset_width() self.view.action_button.setText(button_text) self.view.label.setText(message) self._animate_to(self.HEIGHT) @@ -212,6 +213,7 @@ def show_notice(self, message: str, action_text: str) -> None: def show_fleeting_notice(self, message: str) -> None: """Show a brief notice with no action button that dismisses itself automatically.""" self._set_mode("fleeting_notice") + self.view.label.reset_width() self.view.label.setText(message) self._animate_to(self.HEIGHT) # Deferred by the existing MIN_VISIBLE_MS guard, same as an unforced hide_banner(). From 0e7b93aa993a7c3d5136551e20725fcfd1b83a8b Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:19:47 -0700 Subject: [PATCH 6/8] fix: fix edge cases and rework tests based on docs case table --- src/tagstudio/core/library/sync.py | 227 ++++--- src/tagstudio/qt/qt_driver.py | 6 + src/tagstudio/resources/translations/en.json | 1 + tests/core/library/test_sync.py | 644 ++++++++++++++++--- 4 files changed, 713 insertions(+), 165 deletions(-) diff --git a/src/tagstudio/core/library/sync.py b/src/tagstudio/core/library/sync.py index 6172741ee..b3d1520d9 100644 --- a/src/tagstudio/core/library/sync.py +++ b/src/tagstudio/core/library/sync.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT -from collections.abc import Iterator +from collections.abc import Callable, Hashable, Iterator from dataclasses import dataclass, field from datetime import datetime as dt from pathlib import Path @@ -130,6 +130,11 @@ def sync_dir( if unlinked_ids: self.unlinked_entries = self.library.get_entries(list(unlinked_ids)) + if self.unlinked_entries: + yield -1, -1 # Signals the UI that repair work is starting + + # Any (rare) duplicate entries are merged first, then the normal relinking process + self._merge_duplicate_path_entries(case_sensitive, cache) self._auto_relink_matched_entries(case_sensitive, cache) yield count, len(self.new_paths) @@ -220,7 +225,8 @@ def _glob_for_filename(self, filename: str, case_sensitive: bool) -> list[Path]: def find_relink_candidates(self, entry: Entry) -> list[Path]: """Try to find files in the library directory matching an unlinked entry's filename. - Comparisons are made using NFD normalization and the assumed filesystem's case sensitivity. + A file already in the path cache can only be a candidate if its normalized path matches + this entry's own normalized path, such as with an NFC/NFD or case only duplicate. """ case_sensitive = self._get_case_sensitivity() target_key = norm_path(Path(entry.path.name), case_sensitive=case_sensitive) @@ -232,6 +238,15 @@ def find_relink_candidates(self, entry: Entry) -> list[Path]: else: matches = self._glob_for_filename(entry.path.name, case_sensitive) + entry_key = norm_path(entry.path, case_sensitive=case_sensitive) + cache = self.library.get_or_build_path_cache() + filtered: list[Path] = [] + for path in matches: + path_key = norm_path(path, case_sensitive=case_sensitive) + if path_key == entry_key or cache.get(path_key) is None: + filtered.append(path) + matches = filtered + logger.info("[Sync] Relink candidates", entry=entry.path.as_posix(), matches=matches) return matches @@ -253,6 +268,66 @@ def _apply_relink( return self.library.merge_entries(source, target) return self.library.update_entry_path(entry.id, new_path) + def _relink_unique_matches( + self, + paths: list[Path], + cache: dict[Path, int], + case_sensitive: bool, + key_of_entry: Callable[[Entry], Hashable], + key_of_path: Callable[[Path], Hashable | None], + log_message: str, + ) -> list[Path]: + """Relink entries to paths that share a unique key. + + Args: + paths (list[Path]): Candidate filepaths to try matching against unlinked entries. + cache (dict[Path, int]): Path cache, forwarded to `_apply_relink()`. + case_sensitive (bool): Filesystem case sensitivity, forwarded to `_apply_relink()`. + key_of_entry (Callable[[Entry], Hashable]): Computes a grouping key from an entry. + key_of_path (Callable[[Path], Hashable | None]): Computes a grouping key from a path, + or None if that path can't be evaluated by this key at all (e.g. missing stats). + log_message (str): Logged on each successful relink in this pass. + + Returns: + list[Path]: Remaining unmatched paths. + """ + if not paths or not self.unlinked_entries: + return paths # Nothing to match against + + by_key: dict[Hashable, list[Entry]] = {} # Key -> entries sharing it + for entry in self.unlinked_entries: + by_key.setdefault(key_of_entry(entry), []).append(entry) + + paths_by_key: dict[Hashable, list[Path]] = {} # Key -> candidate paths sharing it + unmatched: list[Path] = [] + for path in paths: + key = key_of_path(path) + if key is None: # Not evaluable by this key (e.g. missing stats) + unmatched.append(path) + continue + paths_by_key.setdefault(key, []).append(path) + + relinked: list[Entry] = [] + for key, candidate_paths in paths_by_key.items(): # Check each key for a unique pairing + candidates = by_key.get(key, []) + if len(candidates) != 1 or len(candidate_paths) != 1: # Ambiguous case + unmatched.extend(candidate_paths) + continue + + entry, new_path = candidates[0], candidate_paths[0] # The only pair sharing this key + if not self._apply_relink(entry, new_path, cache, case_sensitive): # DB write failed + unmatched.append(new_path) + continue + + logger.info(log_message, old_path=entry.path.as_posix(), new_path=new_path.as_posix()) + relinked.append(entry) + + for entry in relinked: + self.unlinked_entries.remove(entry) + self.relinked_entries.extend(relinked) + + return unmatched + def relink_unlinked_entries(self) -> Iterator[int]: """Attempt to fix unlinked entries by finding a single matching file in the library.""" self.manual_relink_count = 0 @@ -280,37 +355,57 @@ def relink_unlinked_entries(self) -> Iterator[int]: for entry in matched: self.unlinked_entries.remove(entry) + def _merge_duplicate_path_entries(self, case_sensitive: bool, cache: dict[Path, int]) -> None: + """Merge any entry whose stored path collides with another entry's onto that entry. + + Entries here have already a full path + filename match. + """ + duplicate_ids = set(self.library.duplicate_path_entry_ids or []) + if not duplicate_ids: + return + + merged: list[Entry] = [] + for entry in self.unlinked_entries: + if self.cancelled: + break + if entry.id in duplicate_ids and self._apply_relink( + entry, entry.path, cache, case_sensitive + ): + merged.append(entry) + + for entry in merged: + self.unlinked_entries.remove(entry) + self.relinked_entries.extend(merged) + def _auto_relink_matched_entries(self, case_sensitive: bool, cache: dict[Path, int]) -> None: """Attempt to automatically relink unlinked entries under available conditions. Auto-relink applies to: - Files with the same filename but different paths - - Handles moves, moves + changes + - Handles moves, moves + changes (Case #7) - Files with different names and/or paths but the same date_modified and file_size - - Handles moves, renames + moves + - Handles moves, renames + moves (Cases #3, #11) + - Files with only a matching filename, as a last resort when nothing else matches + - Handles moves + changes, and entries with no stored stats (Case #6) Auto-relink DOES NOT apply to: - - Renames + Moves + Changes - - Deletions - - Ambiguous (more than one) matches + - Renames + Changes (Cases #2, #10) + - Deletions (Case #1) + - Ambiguous matches (Cases #4, #5, #8, #9, #12, #13) + + *(Cases are listed in the Library documentation)* """ if not self.new_paths or not self.unlinked_entries: return library_dir = unwrap(self.library.library_dir) + self.relinked_entries = [] - # Pass 1: Filename + metadata - by_name_and_stat: dict[tuple[Path, float | None, int | None], list[Entry]] = {} - for entry in self.unlinked_entries: - name_key = norm_path(Path(entry.path.name), case_sensitive=case_sensitive) - by_name_and_stat.setdefault( - (name_key, entry.date_modified, entry.file_size), [] - ).append(entry) + def name_key(path: Path) -> Path: + return norm_path(Path(path.name), case_sensitive=case_sensitive) - relinked: list[Entry] = [] - remaining_new: list[Path] = [] + # Stat every new path once, every pass below reuses this stats_by_path: dict[Path, tuple[float | None, int | None]] = {} - for new_path in self.new_paths: try: file_stat = (library_dir / new_path).stat() @@ -320,69 +415,53 @@ def _auto_relink_matched_entries(self, case_sensitive: bool, cache: dict[Path, i path=new_path, error=e, ) - remaining_new.append(new_path) - continue - - mtime = get_date_modified(file_stat) - size = get_file_size(file_stat) - stats_by_path[new_path] = (mtime, size) - - name_key = norm_path(Path(new_path.name), case_sensitive=case_sensitive) - key = (name_key, mtime, size) - candidates = by_name_and_stat.get(key, []) - if len(candidates) != 1: - remaining_new.append(new_path) - continue - - entry = candidates[0] - if not self._apply_relink(entry, new_path, cache, case_sensitive): - remaining_new.append(new_path) continue - by_name_and_stat[key] = [] # Don't match a second new_path here - - logger.info( - "[Sync] Automatically relinked moved file", - old_path=entry.path.as_posix(), - new_path=new_path.as_posix(), - ) - relinked.append(entry) - - for entry in relinked: - self.unlinked_entries.remove(entry) - - # Pass 2: Different filename checking for same metadata - by_stat_only: dict[tuple[float | None, int | None], list[Entry]] = {} - for entry in self.unlinked_entries: - by_stat_only.setdefault((entry.date_modified, entry.file_size), []).append(entry) + stats_by_path[new_path] = (get_date_modified(file_stat), get_file_size(file_stat)) + + def name_and_stat_key(path: Path) -> tuple[Path, float | None, int | None] | None: + stat = stats_by_path.get(path) + return None if stat is None else (name_key(path), *stat) + + # Pass 1: Filename + metadata (Case #7) + remaining = self._relink_unique_matches( + self.new_paths, + cache, + case_sensitive, + key_of_entry=lambda e: (name_key(e.path), e.date_modified, e.file_size), + key_of_path=name_and_stat_key, + log_message="[Sync] Automatically relinked moved file", + ) - still_remaining: list[Path] = [] - for new_path in remaining_new: - stat_key = stats_by_path.get(new_path) - if stat_key is None: - still_remaining.append(new_path) - continue + if self.cancelled: + self.new_paths = remaining + return - candidates = by_stat_only.get(stat_key, []) - if len(candidates) != 1: - still_remaining.append(new_path) - continue + # Pass 2: Different filename checking for same metadata (Cases #3, #11) + remaining = self._relink_unique_matches( + remaining, + cache, + case_sensitive, + key_of_entry=lambda e: (e.date_modified, e.file_size), + key_of_path=stats_by_path.get, + log_message="[Sync] Automatically relinked renamed file (matched by size + mdate)", + ) - entry = candidates[0] - if not self._apply_relink(entry, new_path, cache, case_sensitive): - still_remaining.append(new_path) - continue - by_stat_only[stat_key] = [] + if self.cancelled: + self.new_paths = remaining + return - logger.info( - "[Sync] Automatically relinked renamed file (matched by size/date only)", - old_path=entry.path.as_posix(), - new_path=new_path.as_posix(), - ) - relinked.append(entry) - self.unlinked_entries.remove(entry) + # Pass 3: Filename only, for anything that wasn't matched before + # (Case #6, and entries with no stored stats) + remaining = self._relink_unique_matches( + remaining, + cache, + case_sensitive, + key_of_entry=lambda e: name_key(e.path), + key_of_path=name_key, + log_message="[Sync] Automatically relinked file by filename (no metadata found)", + ) - self.new_paths = still_remaining - self.relinked_entries = relinked + self.new_paths = remaining def remove_unlinked_entries(self) -> None: """Remove unlinked entries from the Library.""" diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index d38dfb18b..e468c2e02 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -1120,6 +1120,12 @@ def on_progress(progress: tuple[int, int]) -> None: if engine.cancelled: return searched_count, found_count = progress + if searched_count < 0: + # Scan finished, duplicate entry merging/relinking is running before the next yield + self.main_window.banner.show_progress( + Translations["library.sync.repairing"], phase="repairing" + ) + return self.main_window.banner.show_progress( Translations.format( "library.sync.scanning", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index cd8915dc6..24f183d49 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -265,6 +265,7 @@ "library.sync.relinked_banner.singular": "{count} File Automatically Relinked", "library.sync.relinked_suffix": "({count} Automatically Relinked)", "library.sync.remaining_unlinked_suffix": "({count} Still Unlinked)", + "library.sync.repairing": "Repairing Entries…", "library.sync.scanning": "Discovering Files… ({found_count} New Out of {searched_count})", "library.sync.unlinked_banner.plural": "{count} Unlinked Entries Found", "library.sync.unlinked_banner.singular": "{count} Unlinked Entry Found", diff --git a/tests/core/library/test_sync.py b/tests/core/library/test_sync.py index b9b071fe4..fc6746f9c 100644 --- a/tests/core/library/test_sync.py +++ b/tests/core/library/test_sync.py @@ -9,14 +9,17 @@ from tempfile import TemporaryDirectory import pytest +from sqlalchemy import text +from sqlalchemy.orm import Session from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME -from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.sync import LibrarySyncEngine from tagstudio.core.utils.types import unwrap +# NOTE: Case numbers are described in the Library documentation. + @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_new_files(library: Library): @@ -51,29 +54,6 @@ def test_sync_multi_byte_filenames(library: Library): assert Path("umlaute äöü.txt") in engine.new_paths -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_unlinked_entries(library: Library): - """An unlinked entry with one matching file must be found by `relink_unlinked_entries()`.""" - library_dir = unwrap(library.library_dir) - engine = LibrarySyncEngine(library=library) - - # Touch the file "bar.md" but in the wrong location, to simulate a moved file - (library_dir / "bar.md").touch() - - # Neither library entry ("foo.txt", "one/two/bar.md") exists on disk, so both are unlinked - list(engine.sync_dir(library_dir, force_internal_scanner=True)) - assert engine.unlinked_entries_count == 2 - - # Relinking bar.md should match and relink to the entry that was at "one/two/bar.md" - list(engine.relink_unlinked_entries()) - assert engine.manual_relink_count == 1 - assert engine.unlinked_entries_count == 1 - - results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) - entries = library.get_entries(results.ids) - assert entries[0].path == Path("bar.md") - - @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_nfd_nfc_false_positive(library: Library): """A file reappearing in a different Unicode form must not look like a new/duplicate entry.""" @@ -98,27 +78,6 @@ def test_sync_nfd_nfc_false_positive(library: Library): assert engine.unlinked_entries_count == 2 -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_case_sensitivity_aware_relink(library: Library): - """`find_relink_candidates()` must respect the library's case-sensitivity setting.""" - library_dir = unwrap(library.library_dir) - engine = LibrarySyncEngine(library=library) - - (library_dir / "Other").mkdir() - (library_dir / "Other" / "name.txt").touch() - library.add_entries([Entry(path=Path("Folder/Name.txt"), fields=[])]) - - list(engine.sync_dir(library_dir, force_internal_scanner=True)) - unlinked_entry = next(e for e in engine.unlinked_entries if e.path == Path("Folder/Name.txt")) - - library.is_case_sensitive_fs = True - assert engine.find_relink_candidates(unlinked_entry) == [] - - library.is_case_sensitive_fs = False - engine._filename_to_path_map = None # Rebuild the index with the new case sensitivity - assert engine.find_relink_candidates(unlinked_entry) == [Path("Other/name.txt")] - - @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_cache_pruned_on_remove(library: Library): """Removing an unlinked entry must prune the path cache.""" @@ -149,11 +108,22 @@ def test_sync_cache_pruned_on_remove(library: Library): @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_duplicate_case_collision_treated_as_unlinked(library: Library): - """A duplicate entry displaced by a case-insensitive collision must be treated as unlinked.""" +def test_sync_signals_repair_phase_when_entries_are_unlinked(library: Library): + """`sync_dir()` must yield a (-1, -1) signal before repair work when entries are unlinked.""" library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) - library.is_case_sensitive_fs = False # Force a collision using a case difference + + # The baseline entries never existed on disk, so they're always unlinked here + progress = list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert (-1, -1) in progress + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_duplicate_case_collision_merged(library: Library): + """A duplicate entry displaced by a case-insensitive collision must be merged automatically.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + library.is_case_sensitive_fs = False # Force a case-insensitive collision (library_dir / "Dupe").mkdir() (library_dir / "Dupe" / "photo.jpg").touch() @@ -172,22 +142,342 @@ def test_sync_duplicate_case_collision_treated_as_unlinked(library: Library): original_id = entry_b_id if dupe_id == entry_a_id else entry_a_id assert cache.get(path_key) == original_id - # The dupe ID should be marked as unlinked, and the original ID should not + entries_before = library.entries_count list(engine.sync_dir(library_dir, force_internal_scanner=True)) - unlinked_ids = {e.id for e in engine.unlinked_entries} - assert dupe_id in unlinked_ids - assert original_id not in unlinked_ids - - # Removing the duplicate entry shouldn't remove the original from the cache - engine.unlinked_entries = [e for e in engine.unlinked_entries if e.id == dupe_id] - engine.remove_unlinked_entries() + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == dupe_id + assert dupe_id not in {e.id for e in engine.unlinked_entries} + # A merge deletes the duplicate outright, rather than just reassigning its path + assert library.entries_count == entries_before - 1 assert cache.get(path_key) == original_id assert library.duplicate_path_entry_ids == [] +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_unlinked_entries(library: Library): + """`relink_unlinked_entries()` must relink an entry to its one matching, unclaimed file.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "sub").mkdir() + (library_dir / "sub" / "found.txt").touch() + library.add_entries([Entry(path=Path("found.txt"), fields=[])]) + entry_id = library.get_entry_id_from_path(Path("found.txt")) + assert entry_id >= 0 + # Skip sync_dir() to avoid a scan, and manually create unlinked entries to test + engine.unlinked_entries = [unwrap(library.get_entry_full(entry_id))] + + list(engine.relink_unlinked_entries()) + assert engine.manual_relink_count == 1 + assert engine.unlinked_entries == [] + assert library.get_entry_id_from_path(Path("sub/found.txt")) == entry_id + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_relink_ignores_files_already_linked_elsewhere(library: Library): + """`find_relink_candidates()` must not offer a file linked elsewhere at a different path.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "kept").mkdir() + (library_dir / "kept" / "target.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + kept_id = library.get_entry_id_from_path(Path("kept/target.txt")) + assert kept_id >= 0 + + # A different, unrelated entry that happens to share a filename with the kept file above + library.add_entries([Entry(path=Path("old/target.txt"), fields=[])]) + orphan_id = library.get_entry_id_from_path(Path("old/target.txt")) + # Skip sync_dir() to avoid a scan, and manually create unlinked entries to test + engine.unlinked_entries = [unwrap(library.get_entry_full(orphan_id))] + + list(engine.relink_unlinked_entries()) + assert engine.manual_relink_count == 0 + assert len(engine.unlinked_entries) == 1 + # The kept entry must be untouched, no merge should have occurred + assert library.get_entry_id_from_path(Path("kept/target.txt")) == kept_id + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_relink_merges_nfc_nfd_duplicate(library: Library): + """`relink_unlinked_entries()` must merge a duplicate entry differing only by Unicode form. + + PathType always normalizes to NFD, so this test uses raw SQL to simulate legacy data + from before that rule existed. + """ + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + nfc_name = unicodedata.normalize("NFC", "SKÅL.txt") + nfd_name = unicodedata.normalize("NFD", "SKÅL.txt") + assert nfc_name != nfd_name + + # The real file lands in NFD form, as most filesystems store it regardless of input form + (library_dir / nfd_name).touch() + entry_a_id, entry_b_id = library.add_entries( + [ + Entry(path=Path(nfd_name), fields=[]), + Entry(path=Path("placeholder.txt"), fields=[]), + ] + ) + with Session(library.engine) as session: + session.execute( + text("UPDATE entries SET path = :path WHERE id = :id"), + {"path": nfc_name, "id": entry_b_id}, + ) + session.commit() + library.path_cache = None # Force a rebuild to pick up the raw SQL change + + library.get_or_build_path_cache() + assert library.duplicate_path_entry_ids is not None + assert len(library.duplicate_path_entry_ids) == 1 + dupe_id = library.duplicate_path_entry_ids[0] + kept_id = entry_b_id if dupe_id == entry_a_id else entry_a_id + # Skip sync_dir() to isolate manual relink + engine.unlinked_entries = [unwrap(library.get_entry_full(dupe_id))] + + entries_before = library.entries_count + list(engine.relink_unlinked_entries()) + assert engine.manual_relink_count == 1 + assert dupe_id not in {e.id for e in engine.unlinked_entries} + # A merge deletes the duplicate outright, rather than just reassigning its path + assert library.entries_count == entries_before - 1 + assert unwrap(library.get_entry_full(kept_id)).id == kept_id + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_merges_nfc_nfd_duplicate(library: Library): + """A duplicate entry differing only by Unicode form must be merged automatically. + + PathType always normalizes to NFD, so this test uses raw SQL to simulate legacy data + from before that rule existed. + """ + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + nfc_name = unicodedata.normalize("NFC", "SKÅL.txt") + nfd_name = unicodedata.normalize("NFD", "SKÅL.txt") + assert nfc_name != nfd_name + + # The real file lands in NFD form, as most filesystems store it regardless of input form + (library_dir / nfd_name).touch() + entry_a_id, entry_b_id = library.add_entries( + [ + Entry(path=Path(nfd_name), fields=[]), + Entry(path=Path("placeholder.txt"), fields=[]), + ] + ) + with Session(library.engine) as session: + session.execute( + text("UPDATE entries SET path = :path WHERE id = :id"), + {"path": nfc_name, "id": entry_b_id}, + ) + session.commit() + library.path_cache = None # Force a rebuild to pick up the raw SQL change + + library.get_or_build_path_cache() + assert library.duplicate_path_entry_ids is not None + assert len(library.duplicate_path_entry_ids) == 1 + dupe_id = library.duplicate_path_entry_ids[0] + kept_id = entry_b_id if dupe_id == entry_a_id else entry_a_id + + entries_before = library.entries_count + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == dupe_id + assert dupe_id not in {e.id for e in engine.unlinked_entries} + assert library.entries_count == entries_before - 1 + assert unwrap(library.get_entry_full(kept_id)).id == kept_id + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_case_sensitivity_aware_relink(library: Library): + """`find_relink_candidates()` must respect the library's case-sensitivity setting.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "Other").mkdir() + (library_dir / "Other" / "name.txt").touch() + library.add_entries([Entry(path=Path("Folder/Name.txt"), fields=[])]) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + unlinked_entry = next(e for e in engine.unlinked_entries if e.path == Path("Folder/Name.txt")) + + library.is_case_sensitive_fs = True + assert engine.find_relink_candidates(unlinked_entry) == [] + + library.is_case_sensitive_fs = False + engine._filename_to_path_map = None # Rebuild the index with the new case sensitivity + assert engine.find_relink_candidates(unlinked_entry) == [Path("Other/name.txt")] + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_modified_file_stays_linked(library: Library): + """[Case #0] Modifying a file's content in place must not create an unlinked entry.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "stable.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / "stable.txt").write_text("modified content") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert Path("stable.txt") not in {e.path for e in engine.unlinked_entries} + assert engine.new_paths == [] + assert len(engine.paths_to_restat) == 1 + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_deleted_file(library: Library): + """[Case #1] A deleted file with no candidates anywhere must not auto-relink.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "gone.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / "gone.txt").unlink() + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert engine.new_paths == [] + assert Path("gone.txt") in {e.path for e in engine.unlinked_entries} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_moved_and_renamed_file_modified(library: Library): + """[Case #2] A moved, renamed, and modified file must not auto-relink.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "original_name.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / "original_name.txt").unlink() + (library_dir / "sub").mkdir() + (library_dir / "sub" / "renamed.txt").write_text("different content, different size") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("sub/renamed.txt") in engine.new_paths + assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_moved_and_renamed_file(library: Library): + """[Case #3] A single file that is both moved and renamed must still auto-relink.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "original_name.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + original_id = library.get_entry_id_from_path(Path("original_name.txt")) + assert original_id >= 0 + + (library_dir / "sub").mkdir() + (library_dir / "original_name.txt").rename(library_dir / "sub" / "renamed.txt") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [] + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == original_id + assert library.get_entry_id_from_path(Path("sub/renamed.txt")) == original_id + assert library.get_entry_id_from_path(Path("original_name.txt")) == -1 + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_single_entry_multiple_moved_and_renamed_files(library: Library): + """[Case #4] A single entry must not auto-relink to 2+ equally-matching moved+renamed files. + + NOTE: This may change in the future as capability expands. + """ + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "original_name.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + original_stat = (library_dir / "original_name.txt").stat() + + (library_dir / "original_name.txt").unlink() + (library_dir / "a").mkdir() + (library_dir / "b").mkdir() + (library_dir / "a" / "renamed1.txt").touch() + (library_dir / "b" / "renamed2.txt").touch() + os.utime(library_dir / "a" / "renamed1.txt", (original_stat.st_atime, original_stat.st_mtime)) + os.utime(library_dir / "b" / "renamed2.txt", (original_stat.st_atime, original_stat.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("a/renamed1.txt") in engine.new_paths + assert Path("b/renamed2.txt") in engine.new_paths + assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_multiple_entries_one_moved_and_renamed_file(library: Library): + """[Case #5] Two or more entries must not auto-relink to one matching moved+renamed file.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "a").mkdir() + (library_dir / "b").mkdir() + (library_dir / "a" / "one.txt").touch() + (library_dir / "b" / "two.txt").touch() + st = (library_dir / "a" / "one.txt").stat() + os.utime(library_dir / "b" / "two.txt", (st.st_atime, st.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + + (library_dir / "a" / "one.txt").unlink() + (library_dir / "b" / "two.txt").unlink() + (library_dir / "c").mkdir() + (library_dir / "c" / "renamed.txt").touch() + os.utime(library_dir / "c" / "renamed.txt", (st.st_atime, st.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("c/renamed.txt") in engine.new_paths + unlinked_paths = {e.path for e in engine.unlinked_entries} + assert Path("a/one.txt") in unlinked_paths + assert Path("b/two.txt") in unlinked_paths + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_moved_file_modified(library: Library): + """[Case #6] A moved + modified file must still auto-relink by its unique filename.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "notmoved.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + original_id = library.get_entry_id_from_path(Path("notmoved.txt")) + assert original_id >= 0 + + (library_dir / "notmoved.txt").unlink() + (library_dir / "sub").mkdir() + (library_dir / "sub" / "notmoved.txt").write_text("different content, different size") + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [] + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == original_id + assert library.get_entry_id_from_path(Path("sub/notmoved.txt")) == original_id + # The original path should no longer be associated with an entry + assert library.get_entry_id_from_path(Path("notmoved.txt")) == -1 + + @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_auto_relink_moved_file(library: Library): - """A moved file (same filename + stats, different path) must auto-relink to its entry.""" + """[Case #7] A moved file (same filename + stats, different path) must auto-relink.""" library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) @@ -211,14 +501,68 @@ def test_sync_auto_relink_moved_file(library: Library): @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_auto_relink_moved_file_ambiguous(library: Library): - """Two equally-matching candidates (same filename + stats) must not auto-relink. +def test_sync_auto_relink_moved_files_same_names(library: Library): + """[Case #7] A file candidate with same name + different stats must not block the real match.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "photo.jpg").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + original_id = library.get_entry_id_from_path(Path("photo.jpg")) + assert original_id >= 0 + original_stat = (library_dir / "photo.jpg").stat() + + (library_dir / "a").mkdir() + (library_dir / "b").mkdir() + (library_dir / "photo.jpg").rename(library_dir / "a" / "photo.jpg") + (library_dir / "b" / "photo.jpg").touch() + # Ensure a different mtime than "a/photo.jpg" + os.utime( + library_dir / "b" / "photo.jpg", (original_stat.st_atime, original_stat.st_mtime + 100) + ) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == original_id + assert library.get_entry_id_from_path(Path("a/photo.jpg")) == original_id + assert Path("b/photo.jpg") in engine.new_paths + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_single_entry_multiple_moved_files(library: Library): + """[Case #8] A single entry must not auto-relink to multiple equally-matching file candidates. NOTE: This may change in the future as capability expands. """ library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) + (library_dir / "photo.jpg").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + assert library.get_entry_id_from_path(Path("photo.jpg")) >= 0 + original_stat = (library_dir / "photo.jpg").stat() + + (library_dir / "a").mkdir() + (library_dir / "b").mkdir() + (library_dir / "photo.jpg").rename(library_dir / "a" / "photo.jpg") + (library_dir / "b" / "photo.jpg").touch() + os.utime(library_dir / "b" / "photo.jpg", (original_stat.st_atime, original_stat.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("a/photo.jpg") in engine.new_paths + assert Path("b/photo.jpg") in engine.new_paths + assert Path("photo.jpg") in {e.path for e in engine.unlinked_entries} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_multiple_entries_one_moved_file(library: Library): + """[Case #9] Two or more entries must not auto-relink to one matching file candidate.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + (library_dir / "a").mkdir() (library_dir / "b").mkdir() (library_dir / "a" / "dupe.txt").touch() @@ -244,31 +588,27 @@ def test_sync_auto_relink_moved_file_ambiguous(library: Library): @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_auto_relink_moved_file_stat_mismatch(library: Library): - """A file with (same filename, different stats) must not auto-relink. - - NOTE: This is a limitation of the current auto-relinking system, NOT a design principle. - """ +def test_sync_auto_relink_renamed_file_modified(library: Library): + """[Case #10] A renamed file with different stats must not auto-relink.""" library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) - (library_dir / "notmoved.txt").touch() + (library_dir / "original_name.txt").touch() list(engine.sync_dir(library_dir, force_internal_scanner=True)) list(engine.save_new_entries()) - (library_dir / "notmoved.txt").unlink() - (library_dir / "sub").mkdir() - (library_dir / "sub" / "notmoved.txt").touch() + (library_dir / "original_name.txt").unlink() + (library_dir / "renamed_and_modified.txt").write_text("different content, different size") list(engine.sync_dir(library_dir, force_internal_scanner=True)) assert engine.relinked_entries_count == 0 - assert Path("sub/notmoved.txt") in engine.new_paths - assert Path("notmoved.txt") in {e.path for e in engine.unlinked_entries} + assert Path("renamed_and_modified.txt") in engine.new_paths + assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries} @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_auto_relink_renamed_file(library: Library): - """A renamed file (different filename, same stats) must auto-relink to its entry.""" + """[Case #11] A renamed file (different filename, same stats) must auto-relink to its entry.""" library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) @@ -291,41 +631,59 @@ def test_sync_auto_relink_renamed_file(library: Library): @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_auto_relink_renamed_file_ambiguous(library: Library): - """Two equally-matching candidates (different filenames, same stats) must not auto-relink. +def test_sync_auto_relink_single_entry_multiple_renamed_files(library: Library): + """[Case #12] A single entry must not auto-relink to 2+ matched renamed files.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) - NOTE: This may be automated in the future, but for now differs to the user's judgement. - """ + (library_dir / "original_name.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + original_stat = (library_dir / "original_name.txt").stat() + + (library_dir / "original_name.txt").unlink() + (library_dir / "renamed1.txt").touch() + (library_dir / "renamed2.txt").touch() + os.utime(library_dir / "renamed1.txt", (original_stat.st_atime, original_stat.st_mtime)) + os.utime(library_dir / "renamed2.txt", (original_stat.st_atime, original_stat.st_mtime)) + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("renamed1.txt") in engine.new_paths + assert Path("renamed2.txt") in engine.new_paths + assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_multiple_entries_one_renamed_file(library: Library): + """[Case #13] Two or more entries must not auto-relink to one matching renamed file.""" library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) - (library_dir / "a").mkdir() - (library_dir / "b").mkdir() - (library_dir / "a" / "one.txt").touch() - (library_dir / "b" / "two.txt").touch() - st = (library_dir / "a" / "one.txt").stat() - os.utime(library_dir / "b" / "two.txt", (st.st_atime, st.st_mtime)) + (library_dir / "one.txt").touch() + (library_dir / "two.txt").touch() + st = (library_dir / "one.txt").stat() + os.utime(library_dir / "two.txt", (st.st_atime, st.st_mtime)) list(engine.sync_dir(library_dir, force_internal_scanner=True)) list(engine.save_new_entries()) - (library_dir / "a" / "one.txt").unlink() - (library_dir / "b" / "two.txt").unlink() - (library_dir / "c").mkdir() - (library_dir / "c" / "renamed.txt").touch() - os.utime(library_dir / "c" / "renamed.txt", (st.st_atime, st.st_mtime)) + (library_dir / "one.txt").unlink() + (library_dir / "two.txt").unlink() + (library_dir / "renamed.txt").touch() + os.utime(library_dir / "renamed.txt", (st.st_atime, st.st_mtime)) list(engine.sync_dir(library_dir, force_internal_scanner=True)) assert engine.relinked_entries_count == 0 - assert Path("c/renamed.txt") in engine.new_paths + assert Path("renamed.txt") in engine.new_paths unlinked_paths = {e.path for e in engine.unlinked_entries} - assert Path("a/one.txt") in unlinked_paths - assert Path("b/two.txt") in unlinked_paths + assert Path("one.txt") in unlinked_paths + assert Path("two.txt") in unlinked_paths @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_auto_relink_moved_and_renamed_together(library: Library): - """A moved file and a renamed file in the same sync must each auto-relink via their own pass.""" +def test_sync_auto_relink_separate_moved_and_renamed_files(library: Library): + """[Cases #7, #11] Two separate files, one moved and one renamed, must each auto-relink.""" library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) @@ -350,6 +708,47 @@ def test_sync_auto_relink_moved_and_renamed_together(library: Library): assert library.get_entry_id_from_path(Path("rename_new.txt")) == renamed_id +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_matches_entry_without_stored_stats_by_filename(library: Library): + """An entry with no stored stats must auto-relink to a single matching filename.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + library.add_entries([Entry(path=Path("legacy.txt"), fields=[])]) + original_id = library.get_entry_id_from_path(Path("legacy.txt")) + assert original_id >= 0 + + (library_dir / "sub").mkdir() + (library_dir / "sub" / "legacy.txt").touch() + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.new_paths == [] + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].id == original_id + assert Path("legacy.txt") not in {e.path for e in engine.unlinked_entries} + assert library.get_entry_id_from_path(Path("sub/legacy.txt")) == original_id + # The original path should no longer be associated with an entry + assert library.get_entry_id_from_path(Path("legacy.txt")) == -1 + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_auto_relink_skips_ambiguous_entry_without_stored_stats(library: Library): + """An entry with no stored stats must not auto-relink to 2+ files with the same name.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + library.add_entries([Entry(path=Path("legacy.txt"), fields=[])]) + + (library_dir / "a").mkdir() + (library_dir / "b").mkdir() + (library_dir / "a" / "legacy.txt").touch() + (library_dir / "b" / "legacy.txt").touch() + + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("a/legacy.txt") in engine.new_paths + assert Path("b/legacy.txt") in engine.new_paths + assert Path("legacy.txt") in {e.path for e in engine.unlinked_entries} + + @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_cancel_skips_unlinked_finalization(library: Library): """A cancelled `sync_dir()` call must not mark any entries as unlinked.""" @@ -368,6 +767,69 @@ def test_sync_cancel_skips_unlinked_finalization(library: Library): assert engine.new_paths == [] +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_cancel_skips_duplicate_merge(library: Library): + """Cancelling right after the scan must stop duplicate merging before it starts.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + library.is_case_sensitive_fs = False # Force a collision using a case difference + + (library_dir / "Dupe").mkdir() + (library_dir / "Dupe" / "photo.jpg").touch() + library.add_entries( + [ + Entry(path=Path("Dupe/photo.jpg"), fields=[]), + Entry(path=Path("Dupe/PHOTO.JPG"), fields=[]), + ] + ) + library.get_or_build_path_cache() + assert library.duplicate_path_entry_ids is not None + assert len(library.duplicate_path_entry_ids) == 1 + dupe_id = library.duplicate_path_entry_ids[0] + + # Cancel right as the repair phase signal comes through, before merging actually runs + generator = engine.sync_dir(library_dir, force_internal_scanner=True) + for progress in generator: + if progress == (-1, -1): + engine.cancelled = True + break + list(generator) + + assert engine.relinked_entries_count == 0 + assert dupe_id in {e.id for e in engine.unlinked_entries} + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sync_cancel_skips_remaining_relink_passes(library: Library): + """Cancelling right after the scan must let Pass 1 finish, but stop before Pass 2.""" + library_dir = unwrap(library.library_dir) + engine = LibrarySyncEngine(library=library) + + (library_dir / "moveme.txt").touch() + (library_dir / "original_name.txt").touch() + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + list(engine.save_new_entries()) + moved_id = library.get_entry_id_from_path(Path("moveme.txt")) + renamed_id = library.get_entry_id_from_path(Path("original_name.txt")) + + (library_dir / "sub").mkdir() + (library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt") # Pass 1 match + (library_dir / "original_name.txt").rename(library_dir / "renamed.txt") # Pass 2 match + + # Cancel right as the repair phase signal comes through, before relinking actually runs + generator = engine.sync_dir(library_dir, force_internal_scanner=True) + for progress in generator: + if progress == (-1, -1): + engine.cancelled = True + break + list(generator) + + relinked_ids = {e.id for e in engine.relinked_entries} + assert moved_id in relinked_ids + assert renamed_id not in relinked_ids + assert Path("renamed.txt") in engine.new_paths + + @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_cancel_skips_save_new_entries(library: Library): """A cancelled `save_new_entries()` call must not save any new entries.""" From e87b32a780bcd918ea4b0f1dea29da1732979afc Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:16:09 -0700 Subject: [PATCH 7/8] refactor: remove legacy 'search and relink' system, fold into auto-relink --- src/tagstudio/core/library/sync.py | 98 +--------------- .../qt/controllers/relink_entries_progress.py | 37 ------ src/tagstudio/qt/mixed/fix_unlinked.py | 22 ++-- src/tagstudio/resources/translations/cs.json | 1 - src/tagstudio/resources/translations/de.json | 5 +- src/tagstudio/resources/translations/el.json | 5 +- src/tagstudio/resources/translations/en.json | 7 +- src/tagstudio/resources/translations/es.json | 5 +- src/tagstudio/resources/translations/fi.json | 5 +- src/tagstudio/resources/translations/fil.json | 5 +- src/tagstudio/resources/translations/fr.json | 5 +- src/tagstudio/resources/translations/hu.json | 5 +- src/tagstudio/resources/translations/it.json | 5 +- src/tagstudio/resources/translations/ja.json | 5 +- .../resources/translations/nb_NO.json | 5 +- src/tagstudio/resources/translations/pl.json | 5 +- src/tagstudio/resources/translations/pt.json | 5 +- .../resources/translations/pt_BR.json | 5 +- src/tagstudio/resources/translations/qpv.json | 5 +- src/tagstudio/resources/translations/ru.json | 5 +- src/tagstudio/resources/translations/sv.json | 3 - src/tagstudio/resources/translations/ta.json | 3 - src/tagstudio/resources/translations/tok.json | 5 +- src/tagstudio/resources/translations/tr.json | 5 +- .../resources/translations/zh_Hans.json | 5 +- .../resources/translations/zh_Hant.json | 5 +- tests/core/library/test_sync.py | 110 ++---------------- 27 files changed, 40 insertions(+), 336 deletions(-) delete mode 100644 src/tagstudio/qt/controllers/relink_entries_progress.py diff --git a/src/tagstudio/core/library/sync.py b/src/tagstudio/core/library/sync.py index b3d1520d9..b46f356ab 100644 --- a/src/tagstudio/core/library/sync.py +++ b/src/tagstudio/core/library/sync.py @@ -9,11 +9,10 @@ from time import time import structlog -from wcmatch import glob, pathlib from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob +from tagstudio.core.library.ignore import Ignore from tagstudio.core.library.scanners import scan_paths from tagstudio.core.utils.filesystem import is_fs_case_sensitive from tagstudio.core.utils.normalization import norm_path @@ -36,14 +35,8 @@ class LibrarySyncEngine: paths_to_restat: list[tuple[int, Path]] = field(default_factory=list) unlinked_entries: list[Entry] = field(default_factory=list) relinked_entries: list[Entry] = field(default_factory=list) - manual_relink_count: int = 0 cancelled: bool = False - _scanned_paths: list[Path] = field(default_factory=list, init=False, repr=False) - _filename_to_path_map: dict[Path, list[Path]] | None = field( - default=None, init=False, repr=False - ) - @property def new_file_count(self) -> int: return len(self.new_paths) @@ -66,8 +59,6 @@ def reset(self) -> None: self.paths_to_restat = [] self.unlinked_entries = [] self.relinked_entries = [] - self._scanned_paths = [] - self._filename_to_path_map = None def _get_case_sensitivity(self) -> bool: if self.library.is_case_sensitive_fs is None: @@ -106,7 +97,6 @@ def sync_dir( if self.cancelled: break count += 1 - self._scanned_paths.append(raw_path) key = norm_path(raw_path, case_sensitive=case_sensitive) entry_id = cache.get(key) if entry_id is not None: @@ -191,65 +181,6 @@ def sync_entry_stats(self) -> Iterator[int]: index = end self.paths_to_restat = self.paths_to_restat[index:] - def _build_filename_to_path_map(self, case_sensitive: bool) -> dict[Path, list[Path]]: - index: dict[Path, list[Path]] = {} - for path in self._scanned_paths: - key = norm_path(Path(path.name), case_sensitive=case_sensitive) - index.setdefault(key, []).append(path) - return index - - def _glob_for_filename(self, filename: str, case_sensitive: bool) -> list[Path]: - """Search the library directory for files matching `filename`. - - Used only as a fallback when find_relink_candidates() is called without a prior - sync_dir() scan in this engine instance to reuse results from. - """ - library_dir = unwrap(self.library.library_dir) - ignore_patterns = ignore_to_glob(Ignore.get_patterns(library_dir)) - target_path = norm_path(Path(filename), case_sensitive=case_sensitive) - flags = PATH_GLOB_FLAGS | (0 if case_sensitive else glob.IGNORECASE) - - matches: list[Path] = [] - for path in pathlib.Path(str(library_dir)).glob( - patterns=f"***/{glob.escape(filename)}", - flags=flags, - exclude=ignore_patterns, - ): - if path.is_dir(): - continue - candidate = Path(path).relative_to(library_dir) - if norm_path(Path(candidate.name), case_sensitive=case_sensitive) == target_path: - matches.append(candidate) - return matches - - def find_relink_candidates(self, entry: Entry) -> list[Path]: - """Try to find files in the library directory matching an unlinked entry's filename. - - A file already in the path cache can only be a candidate if its normalized path matches - this entry's own normalized path, such as with an NFC/NFD or case only duplicate. - """ - case_sensitive = self._get_case_sensitivity() - target_key = norm_path(Path(entry.path.name), case_sensitive=case_sensitive) - - if self._scanned_paths: - if self._filename_to_path_map is None: - self._filename_to_path_map = self._build_filename_to_path_map(case_sensitive) - matches = list(self._filename_to_path_map.get(target_key, [])) - else: - matches = self._glob_for_filename(entry.path.name, case_sensitive) - - entry_key = norm_path(entry.path, case_sensitive=case_sensitive) - cache = self.library.get_or_build_path_cache() - filtered: list[Path] = [] - for path in matches: - path_key = norm_path(path, case_sensitive=case_sensitive) - if path_key == entry_key or cache.get(path_key) is None: - filtered.append(path) - matches = filtered - - logger.info("[Sync] Relink candidates", entry=entry.path.as_posix(), matches=matches) - return matches - def _apply_relink( self, entry: Entry, new_path: Path, cache: dict[Path, int], case_sensitive: bool ) -> bool: @@ -328,33 +259,6 @@ def _relink_unique_matches( return unmatched - def relink_unlinked_entries(self) -> Iterator[int]: - """Attempt to fix unlinked entries by finding a single matching file in the library.""" - self.manual_relink_count = 0 - case_sensitive = self._get_case_sensitivity() - cache = self.library.get_or_build_path_cache() - matched: list[Entry] = [] - - for i, entry in enumerate(self.unlinked_entries): - yield i - candidates = self.find_relink_candidates(entry) - if len(candidates) != 1: - continue - new_path = candidates[0] - if not self._apply_relink(entry, new_path, cache, case_sensitive): - continue - - self.manual_relink_count += 1 - matched.append(entry) - logger.info( - "[Sync] Relinked entry", - entry=entry.path.as_posix(), - new_path=new_path.as_posix(), - ) - - for entry in matched: - self.unlinked_entries.remove(entry) - def _merge_duplicate_path_entries(self, case_sensitive: bool, cache: dict[Path, int]) -> None: """Merge any entry whose stored path collides with another entry's onto that entry. diff --git a/src/tagstudio/qt/controllers/relink_entries_progress.py b/src/tagstudio/qt/controllers/relink_entries_progress.py deleted file mode 100644 index 0a09f7ad7..000000000 --- a/src/tagstudio/qt/controllers/relink_entries_progress.py +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from PySide6.QtCore import QObject, Signal - -from tagstudio.core.library.sync import LibrarySyncEngine -from tagstudio.i18n.translations import Translations -from tagstudio.qt.controllers.progress_bar import ProgressWidget - - -class RelinkUnlinkedEntriesProgress(QObject): - done = Signal() - - def __init__(self, tracker: LibrarySyncEngine): - super().__init__() - self.tracker = tracker - - def repair_entries(self): - def displayed_text(x): - return Translations.format( - "entries.unlinked.relink.attempting", - index=x, - unlinked_count=self.tracker.unlinked_entries_count, - fixed_count=self.tracker.manual_relink_count, - ) - - pw = ProgressWidget( - label_text="", - cancel_button_text=None, - minimum=0, - maximum=self.tracker.unlinked_entries_count, - ) - pw.setWindowTitle(Translations["entries.unlinked.relink.title"]) - pw.from_iterable_function( - self.tracker.relink_unlinked_entries, displayed_text, self.done.emit - ) diff --git a/src/tagstudio/qt/mixed/fix_unlinked.py b/src/tagstudio/qt/mixed/fix_unlinked.py index 4a9a958e0..6063cd428 100644 --- a/src/tagstudio/qt/mixed/fix_unlinked.py +++ b/src/tagstudio/qt/mixed/fix_unlinked.py @@ -14,7 +14,6 @@ from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.merge_dupe_entries_progress import MergeDuplicateEntriesProgress from tagstudio.qt.controllers.progress_bar import ProgressWidget -from tagstudio.qt.controllers.relink_entries_progress import RelinkUnlinkedEntriesProgress from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal from tagstudio.qt.views.styles.stylesheets import header @@ -40,7 +39,14 @@ def __init__(self, library: Library, driver: QtDriver): self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 6, 6, 6) - self.unlinked_desc_widget = QLabel(Translations["entries.unlinked.description"]) + self.unlinked_desc_widget = QLabel( + Translations["entries.unlinked.description"] + + "

" + + Translations["entries.unlinked.description.deleted"] + # TODO: Implement manual relinking + # + "

" + # + Translations["entries.unlinked.description.ambiguous"] + ) self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel") self.unlinked_desc_widget.setWordWrap(True) self.unlinked_desc_widget.setStyleSheet("text-align:left;") @@ -57,16 +63,6 @@ def __init__(self, library: Library, driver: QtDriver): self.refresh_unlinked_button.clicked.connect(self.refresh_unlinked) self.merge_class = MergeDuplicateEntriesProgress(self.lib, self.driver) - self.relink_class = RelinkUnlinkedEntriesProgress(self.sync_engine) - - self.search_button = QPushButton(Translations["entries.unlinked.search_and_relink"]) - self.relink_class.done.connect( - lambda: ( - self.driver.update_browsing_state(), - self._sync_ui_from_tracker(), - ) - ) - self.search_button.clicked.connect(self.relink_class.repair_entries) self.manual_button = QPushButton(Translations["entries.unlinked.relink.manual"]) self.manual_button.setHidden(True) @@ -96,7 +92,6 @@ def __init__(self, library: Library, driver: QtDriver): self.root_layout.addWidget(self.unlinked_count_label) self.root_layout.addWidget(self.unlinked_desc_widget) self.root_layout.addWidget(self.refresh_unlinked_button) - self.root_layout.addWidget(self.search_button) self.root_layout.addWidget(self.manual_button) self.root_layout.addWidget(self.remove_button) self.root_layout.addStretch(1) @@ -152,7 +147,6 @@ def update_unlinked_count(self): count: int = self.lib.unlinked_entries_count syncing = self.driver.file_scan_lock # Disabled while a sync is running - self.search_button.setDisabled(count < 1 or syncing) self.remove_button.setDisabled(count < 1 or syncing) count_text: str = Translations.format( diff --git a/src/tagstudio/resources/translations/cs.json b/src/tagstudio/resources/translations/cs.json index b90d8e42f..b5b2ba6e5 100644 --- a/src/tagstudio/resources/translations/cs.json +++ b/src/tagstudio/resources/translations/cs.json @@ -40,7 +40,6 @@ "entries.duplicates.description": "Duplicitní položky jsou definovány jako více položek, které ukazují na stejný soubor na disku. Jejich sloučením se spojí značky a metadata ze všech duplikátů do jediné konsolidované položky. Nesmí se zaměňovat s „duplicitními soubory“, což jsou duplikáty samotných vašich souborů mimo TagStudio.", "entries.mirror.confirmation": "Opravdu chcete zrcadlit následujících {count} položek?", "entries.unlinked.relink.manual": "Znovu propojit ručně", - "entries.unlinked.relink.title": "Propojuji záznamy", "entries.unlinked.scanning": "Skenuji knihovnu pro nepropojené záznamy...", "entries.unlinked.title": "Opravit nepropojené záznamy", "field.copy": "Zkopírovat políčko", diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index e0038419a..4f058bc84 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -58,14 +58,11 @@ "entries.remove.singular.confirm": "Soll dieser Eintrag von der Bibliothek entfernt werden? Es werden keine Dateien auf der Festplatte gelöscht.", "entries.running.dialog.new_entries": "Füge {total} neue Dateieinträge hinzu...", "entries.tags": "Tags", - "entries.unlinked.description": "Jeder Bibliothekseintrag ist mit einer Datei in einem Ihrer Verzeichnisse verknüpft. Wenn eine Datei, die mit einem Eintrag verknüpft ist, außerhalb von TagStudio verschoben oder gelöscht wird, gilt sie als nicht verknüpft.

Nicht verknüpfte Einträge können durch das Durchsuchen Ihrer Verzeichnisse automatisch neu verknüpft, vom Benutzer manuell neu verknüpft oder auf Wunsch gelöscht werden.", - "entries.unlinked.relink.attempting": "Versuche {index}/{unlinked_count} Einträge neu zu verknüpfen, {fixed_count} bereits erfolgreich neu verknüpft", + "entries.unlinked.description": "Jeder Bibliothekseintrag ist mit einer Datei in einem Ihrer Verzeichnisse verknüpft. Wenn eine Datei, die mit einem Eintrag verknüpft ist, außerhalb von TagStudio verschoben oder gelöscht wird, gilt sie als nicht verknüpft.", "entries.unlinked.relink.manual": "&Manuell Neuverknüpfen", - "entries.unlinked.relink.title": "Einträge werden neu verknüpft", "entries.unlinked.remove": "Entferne nicht verknüpfte Einträge", "entries.unlinked.remove_alt": "Entfer&ne nicht verknüpfte Einträge", "entries.unlinked.scanning": "Bibliothek wird nach nicht verknüpften Einträgen durchsucht...", - "entries.unlinked.search_and_relink": "&Suchen && Neuverknüpfen", "entries.unlinked.title": "Unverknüpfte Einträge reparieren", "entries.unlinked.unlinked_count": "Unverknüpfte Einträge: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/el.json b/src/tagstudio/resources/translations/el.json index fa9033f03..21e5ef0b7 100644 --- a/src/tagstudio/resources/translations/el.json +++ b/src/tagstudio/resources/translations/el.json @@ -57,14 +57,11 @@ "entries.remove.singular.confirm": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτή την εγγραφή από τη βιβλιοθήκη σας; Δεν θα διαγραφεί κανένα αρχείο από τον δίσκο.", "entries.running.dialog.new_entries": "Προσθήκη {total} νέων εγγραφών αρχείων...", "entries.tags": "Tags", - "entries.unlinked.description": "Κάθε εγγραφή της βιβλιοθήκης είναι συνδεδεμένη με ένα αρχείο σε έναν από τους καταλόγους σας. Εάν ένα αρχείο που είναι συνδεδεμένο με μια εγγραφή μετακινηθεί ή διαγραφεί εκτός του TagStudio, τότε θεωρείται αποσυνδεδεμένο.

Οι αποσυνδεδεμένες εγγραφές μπορούν να επανασυνδεθούν αυτόματα μέσω αναζήτησης στους καταλόγους σας ή να διαγραφούν, εάν το επιθυμείτε.", - "entries.unlinked.relink.attempting": "Προσπάθεια επανασύνδεσης {index}/{unlinked_count} εγγραφών, {fixed_count} επανασυνδέθηκαν επιτυχώς", + "entries.unlinked.description": "Κάθε εγγραφή της βιβλιοθήκης είναι συνδεδεμένη με ένα αρχείο σε έναν από τους καταλόγους σας. Εάν ένα αρχείο που είναι συνδεδεμένο με μια εγγραφή μετακινηθεί ή διαγραφεί εκτός του TagStudio, τότε θεωρείται αποσυνδεδεμένο.", "entries.unlinked.relink.manual": "&Χειροκίνητη επανασύνδεση", - "entries.unlinked.relink.title": "Επανασύνδεση εγγραφών", "entries.unlinked.remove": "Αφαίρεση αποσυνδεδεμένων εγγραφών", "entries.unlinked.remove_alt": "Α&φαίρεση αποσυνδεδεμένων εγγραφών", "entries.unlinked.scanning": "Σάρωση βιβλιοθήκης για αποσυνδεδεμένες εγγραφές...", - "entries.unlinked.search_and_relink": "&Αναζήτηση && Επανασύνδεση", "entries.unlinked.title": "Διόρθωση αποσυνδεδεμένων εγγραφών", "entries.unlinked.unlinked_count": "Αποσυνδεδεμένες εγγραφές: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 24f183d49..7483d97d3 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -63,15 +63,14 @@ "entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.", "entries.running.dialog.new_entries": "Adding {total} New File Entries…", "entries.tags": "Tags", - "entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.

Unlinked entries may be automatically relinked via searching your directories or deleted if desired.", - "entries.unlinked.relink.attempting": "Attempting to Relink {index}/{unlinked_count} Entries, {fixed_count} Successfully Relinked", + "entries.unlinked.description": "Unlinked entries are file entries that can no longer find their original file on disk. Most entries are automatically relinked during a library sync, however some cases require manual review.", + "entries.unlinked.description.ambiguous": "For unlinked entries that have ambiguous matches to multiple files in your library, you may manually choose how they get relinked.", + "entries.unlinked.description.deleted": "When you delete files outside of TagStudio, their associated entries become unlinked. You may manually delete any unlinked entries at your own discretion.", "entries.unlinked.relink.manual": "&Manual Relink", - "entries.unlinked.relink.title": "Relinking Entries", "entries.unlinked.remove": "Remove Unlinked Entries", "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", "entries.unlinked.review": "Manual &Review", "entries.unlinked.scanning": "Scanning Library for Unlinked Entries…", - "entries.unlinked.search_and_relink": "&Search && Relink", "entries.unlinked.title": "Fix Unlinked Entries", "entries.unlinked.unlinked_count": "Unlinked Entries: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index 694d69f11..53d6fcc55 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -61,14 +61,11 @@ "entries.remove.singular.confirm": "¿Está seguro que quiere eliminar ésta entrada de su librería? Ningún archivo en el disco será eliminado.", "entries.running.dialog.new_entries": "Añadiendo {total} nuevas entradas de archivos...", "entries.tags": "Etiquetas", - "entries.unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de tus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado.

Las entradas no vinculadas se pueden volver a vincular automáticamente mediante una búsqueda en tus directorios, el usuario puede eliminarlas si así lo desea.", - "entries.unlinked.relink.attempting": "Intentando volver a vincular {index}/{unlinked_count} Entradas, {fixed_count} Reenlazado correctamente", + "entries.unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de tus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado. ", "entries.unlinked.relink.manual": "&Reenlace manual", - "entries.unlinked.relink.title": "Volver a vincular las entradas", "entries.unlinked.remove": "Eliminar Entradas No Vinculadas", "entries.unlinked.remove_alt": "Quit&ar entradas desvinculadas", "entries.unlinked.scanning": "Buscando entradas no enlazadas en la biblioteca...", - "entries.unlinked.search_and_relink": "&Buscar && Revincular", "entries.unlinked.title": "Corregir entradas no vinculadas", "entries.unlinked.unlinked_count": "Entradas no vinculadas: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/fi.json b/src/tagstudio/resources/translations/fi.json index 2c4a2d834..d0cdde503 100644 --- a/src/tagstudio/resources/translations/fi.json +++ b/src/tagstudio/resources/translations/fi.json @@ -57,14 +57,11 @@ "entries.remove.singular.confirm": "Haluatko varmasti poistaa tämän merkinnän kirjastostasi? Levyllä olevia tiedostoja ei poisteta.", "entries.running.dialog.new_entries": "Lisätään {total} uutta tiedosto merkintää...", "entries.tags": "Tunnisteet", - "entries.unlinked.description": "Jokainen kirjastomerkintä on linkitetty tiedostoon jossakin hakemistoistasi. Jos merkintään linkitetty tiedosto siirretään tai poistetaan TagStudion ulkopuolelle, sitä pidetään linkittämättömänä.

Linkittämättömät merkinnät voidaan linkittää automaattisesti uudelleen hakemistojen haun avulla tai poistaa haluttaessa.", - "entries.unlinked.relink.attempting": "Yritetään linkittää uudelleen {index}/{unlinked_count} merkintää, {fixed_count} uudelleenlinkitys onnistui", + "entries.unlinked.description": "Jokainen kirjastomerkintä on linkitetty tiedostoon jossakin hakemistoistasi. Jos merkintään linkitetty tiedosto siirretään tai poistetaan TagStudion ulkopuolelle, sitä pidetään linkittämättömänä.", "entries.unlinked.relink.manual": "&Manual Relink", - "entries.unlinked.relink.title": "Uudelleen yhdistetään merkintöjä", "entries.unlinked.remove": "Poista linkittämättömät merkinnät", "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", "entries.unlinked.scanning": "Skannataan kirjastosta linkittämättömiä merkintöjä...", - "entries.unlinked.search_and_relink": "&Search && Relink", "entries.unlinked.title": "Korjaa linkittämättömät merkinnät", "entries.unlinked.unlinked_count": "Linkittämättömät merkinnät: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/fil.json b/src/tagstudio/resources/translations/fil.json index 99e329b84..9382badba 100644 --- a/src/tagstudio/resources/translations/fil.json +++ b/src/tagstudio/resources/translations/fil.json @@ -48,12 +48,9 @@ "entries.remove.plural.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na {count} entry?", "entries.running.dialog.new_entries": "Dinadagdag ang {total} Mga Bagong Entry ng File…", "entries.tags": "Mga Tag", - "entries.unlinked.description": "Ang bawat entry sa library ay naka-link sa isang file sa isa sa iyong mga direktoryo. Kung ang isang file na naka-link sa isang entry ay inilipat o binura sa labas ng TagStudio, ito ay isinasaalang-alang na naka-unlink.

Ang mga naka-unlink na entry ay maaring i-link muli sa pamamagitan ng paghahanap sa iyong mga direktoryo o buburahin kung ninanais.", - "entries.unlinked.relink.attempting": "Sinusubukang i-link muli ang {index}/{unlinked_count} Mga Entry, {fixed_count} Matagumpay na na-link muli", + "entries.unlinked.description": "Ang bawat entry sa library ay naka-link sa isang file sa isa sa iyong mga direktoryo. Kung ang isang file na naka-link sa isang entry ay inilipat o binura sa labas ng TagStudio, ito ay isinasaalang-alang na naka-unlink.", "entries.unlinked.relink.manual": "&Manwal na Pag-link Muli", - "entries.unlinked.relink.title": "Nili-link muli ang Mga Entry", "entries.unlinked.scanning": "Sina-scan ang Library para sa Mga Naka-unlink na Entry…", - "entries.unlinked.search_and_relink": "&Maghanap at Mag-link muli", "entries.unlinked.title": "Ayusin ang Mga Naka-unlink na Entry", "entries.unlinked.unlinked_count": "Mga Naka-unlink na Entry: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/fr.json b/src/tagstudio/resources/translations/fr.json index 6a1be3ed4..b21b28987 100644 --- a/src/tagstudio/resources/translations/fr.json +++ b/src/tagstudio/resources/translations/fr.json @@ -62,14 +62,11 @@ "entries.remove.singular.confirm": "Êtes-vous sûr de vouloir supprimer cette entrée de votre bibliothèque ? Aucun fichier sur le disque ne sera supprimé.", "entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...", "entries.tags": "Tags", - "entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié.

Les entrées non liées peuvent être automatiquement reliées via la recherche dans vos dossiers, reliées manuellement par l'utilisateur, ou supprimées si désiré.", - "entries.unlinked.relink.attempting": "Tentative de Reliage de {index}/{unlinked_count} Entrées, {fixed_count} ont été Reliées avec Succès", + "entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié. ", "entries.unlinked.relink.manual": "&Reliage Manuel", - "entries.unlinked.relink.title": "Reliage des Entrées", "entries.unlinked.remove": "Supprimer les entrées non liées", "entries.unlinked.remove_alt": "Supprim&er les entrées non liées", "entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...", - "entries.unlinked.search_and_relink": "&Rechercher && Relier", "entries.unlinked.title": "Réparation des Entrées non Liées", "entries.unlinked.unlinked_count": "Entrées non Liées : {count}", "ffmpeg.missing.status": "{ffmpeg} : {ffmpeg_status}
{ffprobe} : {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/hu.json b/src/tagstudio/resources/translations/hu.json index c7ef5f628..1d4738458 100644 --- a/src/tagstudio/resources/translations/hu.json +++ b/src/tagstudio/resources/translations/hu.json @@ -62,14 +62,11 @@ "entries.remove.singular.confirm": "Biztosan el akarja távolítani ezt az elemet a könyvtárból? A lemezen található fájl nem lesz törölve.", "entries.running.dialog.new_entries": "{total} új elem felvétele folyamatban…", "entries.tags": "Címkék", - "entries.unlinked.description": "A könyvtár minden eleme egy fájllal van összekapcsolva a számítógépen. Ha egy kapcsolt fájl a TagSudión kívül áthelyezésre vagy törésre kerül, akkor ez a kapcsolat megszakad.

Ezeket a kapcsolat nélküli elemeket a program megpróbálhatja automatikusan megkeresni, de Ön is kézileg újra összekapcsolhatja vagy törölheti őket.", - "entries.unlinked.relink.attempting": "{unlinked_count}/{index} elem újra összekapcsolásának megkísérlése; {fixed_count} elem sikeresen újra összekapcsolva", + "entries.unlinked.description": "A könyvtár minden eleme egy fájllal van összekapcsolva a számítógépen. Ha egy kapcsolt fájl a TagSudión kívül áthelyezésre vagy törésre kerül, akkor ez a kapcsolat megszakad.", "entries.unlinked.relink.manual": "Új&ra összekapcsolás kézileg", - "entries.unlinked.relink.title": "Elemek újra összekapcsolása", "entries.unlinked.remove": "Kapcsolat nélküli elemek eltávolítása", "entries.unlinked.remove_alt": "&Kapcsolat nélküli elemek eltávolítása", "entries.unlinked.scanning": "Kapcsolat nélküli elemek keresése a könyvtárban…", - "entries.unlinked.search_and_relink": "&Keresés és újra összekapcsolás", "entries.unlinked.title": "Kapcsolat nélküli elemek javítása", "entries.unlinked.unlinked_count": "Kapcsolat nélküli elemek: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/it.json b/src/tagstudio/resources/translations/it.json index f2f846d61..b8a96adf5 100644 --- a/src/tagstudio/resources/translations/it.json +++ b/src/tagstudio/resources/translations/it.json @@ -57,14 +57,11 @@ "entries.remove.singular.confirm": "Sei sicuro di voler rimuovere questa voce dalla tua biblioteca? Nessun file su disco verrà eliminato.", "entries.running.dialog.new_entries": "Aggiundendo {total} Nuove Voci di File...", "entries.tags": "Etichette", - "entries.unlinked.description": "Ogni voce della biblioteca è collegata ad un file in una delle tue cartelle. Se un file collegato ad una voce viene spostato o eliminito al di fuori di TagStudio, la voce corrispondente viene considerata scollegata.

Le voci scollegate possono essere ricollegate automaticamente attraverso la ricerca nelle tue cartelle oppure cancellate se lo si desidera.", - "entries.unlinked.relink.attempting": "Tentativo di Ricollegare {index}/{unlinked_count} Voci, {fixed_count} Ricollegate con Successo", + "entries.unlinked.description": "Ogni voce della biblioteca è collegata ad un file in una delle tue cartelle. Se un file collegato ad una voce viene spostato o eliminito al di fuori di TagStudio, la voce corrispondente viene considerata scollegata.", "entries.unlinked.relink.manual": "Ricollegamento &Manuale", - "entries.unlinked.relink.title": "Ricollegamento Voci", "entries.unlinked.remove": "Rimuovi Voci non Collegate", "entries.unlinked.remove_alt": "Rimuo&vi Voci non Collegate", "entries.unlinked.scanning": "Scansionando la Biblioteca in cerca di Voci non Collegate...", - "entries.unlinked.search_and_relink": "&Ricerca && Ricollega", "entries.unlinked.title": "Correggi Voci non Collegate", "entries.unlinked.unlinked_count": "Voci non Collegate: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/ja.json b/src/tagstudio/resources/translations/ja.json index 6fdfac1c7..8a175b891 100644 --- a/src/tagstudio/resources/translations/ja.json +++ b/src/tagstudio/resources/translations/ja.json @@ -62,14 +62,11 @@ "entries.remove.singular.confirm": "このエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。", "entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています…", "entries.tags": "タグ", - "entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。

リンク切れのエントリは、ディレクトリを検索して自動的に再リンクすることも、必要に応じて削除することもできます。", - "entries.unlinked.relink.attempting": "{unlinked_count} 件中 {index} 件のエントリを再リンク中、{fixed_count} 件を正常に再リンクしました", + "entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。", "entries.unlinked.relink.manual": "手動で再リンク(&M)", - "entries.unlinked.relink.title": "エントリの再リンク", "entries.unlinked.remove": "リンク切れのエントリを削除", "entries.unlinked.remove_alt": "リンク切れのエントリを削除(&V)", "entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています…", - "entries.unlinked.search_and_relink": "検索して再リンク(&S)", "entries.unlinked.title": "リンク切れのエントリを修正", "entries.unlinked.unlinked_count": "リンク切れのエントリ数: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/nb_NO.json b/src/tagstudio/resources/translations/nb_NO.json index 63c92ef4e..b2427aae5 100644 --- a/src/tagstudio/resources/translations/nb_NO.json +++ b/src/tagstudio/resources/translations/nb_NO.json @@ -56,12 +56,9 @@ "entries.remove.singular.confirm": "Er du sikker på at du vil fjerne denne oppføringen fra bibliotek ditt? Ingen filer på disken vil slettes.", "entries.running.dialog.new_entries": "Legger til {total} Nye Filoppføringer...", "entries.tags": "Etiketter", - "entries.unlinked.description": "Hver biblioteksoppføring er koblet til en fil i en av dine mapper. Hvis en fil koblet til en oppføring er flyttet eller slettet utenfor TagStudio, så er den sett på som frakoblet.

Frakoblede oppføringer kan bli automatisk gjenkoblet ved å søke i mappene dine eller slettet om det er ønsket.", - "entries.unlinked.relink.attempting": "Forsøker å Gjenkoble {index}/{unlinked_count} Oppføringer, {fixed_count} Klart Gjenkoblet", + "entries.unlinked.description": "Hver biblioteksoppføring er koblet til en fil i en av dine mapper. Hvis en fil koblet til en oppføring er flyttet eller slettet utenfor TagStudio, så er den sett på som frakoblet.", "entries.unlinked.relink.manual": "&Manuell Gjenkobling", - "entries.unlinked.relink.title": "Gjenkobler Oppføringer", "entries.unlinked.scanning": "Skanner bibliotek for ulenkede oppføringer …", - "entries.unlinked.search_and_relink": "&Søk && Gjenkobl", "entries.unlinked.title": "Fiks ulenkede oppføringer", "entries.unlinked.unlinked_count": "Frakoblede Oppføringer: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/pl.json b/src/tagstudio/resources/translations/pl.json index ed83ac6b7..710210a14 100644 --- a/src/tagstudio/resources/translations/pl.json +++ b/src/tagstudio/resources/translations/pl.json @@ -48,12 +48,9 @@ "entries.remove.plural.confirm": "Jesteś pewien że chcesz usunąć następujące {count} wpisy?", "entries.running.dialog.new_entries": "Dodawanie {total} nowych wpisów plików...", "entries.tags": "Tagi", - "entries.unlinked.description": "Każdy wpis w bibliotece jest połączony z plikiem w jednym z twoich katalogów. Jeśli połączony plik jest przeniesiony poza TagStudio albo usunięty to jest uważany za odłączony.

Odłączone wpisy mogą być automatycznie połączone ponownie przez szukanie twoich katalogów, ręczne ponowne łączenie przez użytkownika lub usunięte jeśli zajdzie taka potrzeba.", - "entries.unlinked.relink.attempting": "Próbowanie ponownego łączenia {index}/{unlinked_count} wpisów, {fixed_count} poprawnie połączono ponownie", + "entries.unlinked.description": "Każdy wpis w bibliotece jest połączony z plikiem w jednym z twoich katalogów. Jeśli połączony plik jest przeniesiony poza TagStudio albo usunięty to jest uważany za odłączony.", "entries.unlinked.relink.manual": "&Ręczne ponowne łączenie", - "entries.unlinked.relink.title": "Ponowne łączenie wpisów", "entries.unlinked.scanning": "Skanowanie biblioteki dla odłączonych wpisów...", - "entries.unlinked.search_and_relink": "&Wyszukaj && Zalinkuj ponownie", "entries.unlinked.title": "Napraw odłączone wpisy", "entries.unlinked.unlinked_count": "Odłączone wpisy: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/pt.json b/src/tagstudio/resources/translations/pt.json index 3de2d849c..b2b7c9497 100644 --- a/src/tagstudio/resources/translations/pt.json +++ b/src/tagstudio/resources/translations/pt.json @@ -53,13 +53,10 @@ "entries.remove.plural.confirm": "Tem certeza que deseja apagar os seguintes {count} registos ?", "entries.running.dialog.new_entries": "A Adicionar {total} Novos Registos de Ficheiros...", "entries.tags": "Tags", - "entries.unlinked.description": "Cada registo na biblioteca faz referência à um ficheiro numa das suas pastas. Se um ficheiro referenciado à uma entrada for movido ou apagado fora do TagStudio, ele é depois considerado não-referenciado.

Registos não-referenciados podem ser automaticamente referenciados por pesquisas nos seus diretórios, manualmente pelo utilizador, ou apagado se for desejado.", - "entries.unlinked.relink.attempting": "A tentar referenciar {index}/{unlinked_count} Registos, {fixed_count} Referenciados com Sucesso", + "entries.unlinked.description": "Cada registo na biblioteca faz referência à um ficheiro numa das suas pastas. Se um ficheiro referenciado à uma entrada for movido ou apagado fora do TagStudio, ele é depois considerado não-referenciado.", "entries.unlinked.relink.manual": "&Referência Manual", - "entries.unlinked.relink.title": "A Referenciar Registos", "entries.unlinked.remove_alt": "Remover Entradas sem Conexões", "entries.unlinked.scanning": "A escanear biblioteca por registos não referenciados...", - "entries.unlinked.search_and_relink": "&Pesquisar && Referenciar", "entries.unlinked.title": "Corrigir Registos Não Referenciados", "entries.unlinked.unlinked_count": "Registos Não Referenciados: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/pt_BR.json b/src/tagstudio/resources/translations/pt_BR.json index d074a9998..4bd28ea43 100644 --- a/src/tagstudio/resources/translations/pt_BR.json +++ b/src/tagstudio/resources/translations/pt_BR.json @@ -57,14 +57,11 @@ "entries.remove.singular.confirm": "Você tem certeza que deseja remover esse registro da sua bilbioteca ? Nenhum arquivo no disco será excluído.", "entries.running.dialog.new_entries": "Adicionando {total} Novos Registros de Arquivos...", "entries.tags": "Tags", - "entries.unlinked.description": "Cada registro na biblioteca faz referência à um arquivo em uma de suas pastas. Se um arquivo referenciado à uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não-referenciado.

Registros não-referenciados podem ser automaticamente referenciados por buscas nos seus diretórios, manualmente pelo usuário, ou deletado se for desejado.", - "entries.unlinked.relink.attempting": "Tentando referenciar {index}/{unlinked_count} Registros, {fixed_count} Referenciados com Sucesso", + "entries.unlinked.description": "Cada registro na biblioteca faz referência à um arquivo em uma de suas pastas. Se um arquivo referenciado à uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não-referenciado.", "entries.unlinked.relink.manual": "&Referência Manual", - "entries.unlinked.relink.title": "Referenciando Registros", "entries.unlinked.remove": "Remover Registros Não Vinculados", "entries.unlinked.remove_alt": "Remover Entradas sem Conexões", "entries.unlinked.scanning": "Escaneando bibliotecada em busca de registros não referenciados...", - "entries.unlinked.search_and_relink": "&Buscar && Referenciar", "entries.unlinked.title": "Corrigir Registros Não Referenciados", "entries.unlinked.unlinked_count": "Registros Não Referenciados: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/qpv.json b/src/tagstudio/resources/translations/qpv.json index cf26d51ac..6c8b47fdb 100644 --- a/src/tagstudio/resources/translations/qpv.json +++ b/src/tagstudio/resources/translations/qpv.json @@ -57,14 +57,11 @@ "entries.remove.singular.confirm": "Du kestetsa afto shiruzmakaban long mlafuhuomi we? Nil mlafu na shiruzmabaksu bli kestejena.", "entries.running.dialog.new_entries": "Nasii {total} neo shiruzmakaban fu mlafu ima...", "entries.tags": "Festaretol", - "entries.unlinked.description": "Tont shiruzmakaban fu mlafuhuomi tsunagajena na mlafu ine joku mlafukaban fu du. Li mlafu tsunagajena na shiruzmakaban ugokijena os kestejena ekso TagStudio, sit sore kntsunagajena.

Tsunaganaijena shiruzmakaban deki tsunaga gen na suha per mlafukaban fu du os keste li du vil.", - "entries.unlinked.relink.attempting": "Iskat ima na tsunaga gen {index}/{unlinked_count} shiruzmakaban, {fixed_count} tsunagajena gen", + "entries.unlinked.description": "Tont shiruzmakaban fu mlafuhuomi tsunagajena na mlafu ine joku mlafukaban fu du. Li mlafu tsunagajena na shiruzmakaban ugokijena os kestejena ekso TagStudio, sit sore kntsunagajena.", "entries.unlinked.relink.manual": "&Tsunaga gen mit hant", - "entries.unlinked.relink.title": "Tsunaga shiruzmakaban gen", "entries.unlinked.remove": "Keste kntsunagajena shiruzmakaban", "entries.unlinked.remove_alt": "Keste kntsunagajena shiruzmakaban (&v)", "entries.unlinked.scanning": "Taskame mlafuhuomi ima grun kntsunagajena shiruzmakaban...", - "entries.unlinked.search_and_relink": "&Suha &&Tsunaga gen", "entries.unlinked.title": "Fiks kntsunagajena shiruzmakaban", "entries.unlinked.unlinked_count": "Kntsunagajena shiruzmakaban: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/ru.json b/src/tagstudio/resources/translations/ru.json index f7b630917..c31817a3d 100644 --- a/src/tagstudio/resources/translations/ru.json +++ b/src/tagstudio/resources/translations/ru.json @@ -59,13 +59,10 @@ "entries.remove.singular.confirm": "Вы уверены, что хотите удалить эту запись? Файл на диске не будет удалён.", "entries.running.dialog.new_entries": "Добавление {total} новых записей...", "entries.tags": "Теги", - "entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".

Откреплённые записи могут быть прикреплены обратно автоматически, либо же удалены если в них нет надобности.", - "entries.unlinked.relink.attempting": "Попытка перепривязать {index}/{unlinked_count} записей, {fixed_count} привязано успешно", + "entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".", "entries.unlinked.relink.manual": "&Ручная привязка", - "entries.unlinked.relink.title": "Привязка записей", "entries.unlinked.remove": "Удалить откреплённые записи", "entries.unlinked.scanning": "Сканирование библиотеки на наличие откреплённых записей...", - "entries.unlinked.search_and_relink": "&Поиск и привязка", "entries.unlinked.title": "Исправить откреплённые записи", "entries.unlinked.unlinked_count": "Откреплённых записей: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/sv.json b/src/tagstudio/resources/translations/sv.json index ed6872813..1ce4be0d9 100644 --- a/src/tagstudio/resources/translations/sv.json +++ b/src/tagstudio/resources/translations/sv.json @@ -58,13 +58,10 @@ "entries.running.dialog.new_entries": "Lägger Till {total} Nya Filposter...", "entries.tags": "Etiketter", "entries.unlinked.description": "Varje post i biblioteket är länkad till en fil i en av dina kataloger. Om en fil länkad till en post är flyttad eller borttagen utanför TagStudio blir den olänkad. Olänkade poster kan automatiskt bli omlänkade genom att söka genom dina kataloger, manuellt omlänkade av användaren eller tas bort om så önskas.", - "entries.unlinked.relink.attempting": "Försöker att länka om {index}/{unlinked_count} Poster, {fixed_count} Lyckades Länkas Om", "entries.unlinked.relink.manual": "Länka om manuellt", - "entries.unlinked.relink.title": "Länkar om poster", "entries.unlinked.remove": "Ta Bort Olänkade Poster", "entries.unlinked.remove_alt": "&Ta Bort Olänkade Poster", "entries.unlinked.scanning": "Skannar bibliotek efter olänkade poster...", - "entries.unlinked.search_and_relink": "Sök && Länka om", "entries.unlinked.title": "Fixa olänkade poster", "entries.unlinked.unlinked_count": "Olänkade Poster: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/ta.json b/src/tagstudio/resources/translations/ta.json index a058ec494..f4730fb6d 100644 --- a/src/tagstudio/resources/translations/ta.json +++ b/src/tagstudio/resources/translations/ta.json @@ -58,13 +58,10 @@ "entries.running.dialog.new_entries": "{total} புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது ...", "entries.tags": "குறிச்சொற்கள்", "entries.unlinked.description": "ஒவ்வொரு நூலக நுழைவும் உங்கள் கோப்பகங்களில் ஒன்றில் ஒரு கோப்போடு இணைக்கப்பட்டுள்ளது. ஒரு நுழைவுடன் இணைக்கப்பட்ட ஒரு கோப்பு முகவரிச்சீட்டுஅறைக்கு வெளியே நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அது பின்னர் இணைக்கப்படாததாகக் கருதப்படுகிறது.", - "entries.unlinked.relink.attempting": "{index}/{unlinked_count} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, {fixed_count} மீண்டும் இணைக்கப்பட்டது", "entries.unlinked.relink.manual": "& கையேடு மறுபரிசீலனை", - "entries.unlinked.relink.title": "உள்ளீடுகள் மீண்டும் இணைக்கப்படுகின்றது", "entries.unlinked.remove": "இணைக்கப்படாத உள்ளீடுகளை அகற்று", "entries.unlinked.remove_alt": "இணைக்கப்படாத உள்ளீடுகளை அகற்று&விடு", "entries.unlinked.scanning": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...", - "entries.unlinked.search_and_relink": "& தேடல் && relink", "entries.unlinked.title": "இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்யவும்", "entries.unlinked.unlinked_count": "இணைக்கப்படாத உள்ளீடுகள்: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/tok.json b/src/tagstudio/resources/translations/tok.json index c49bdb7c9..725e3e32b 100644 --- a/src/tagstudio/resources/translations/tok.json +++ b/src/tagstudio/resources/translations/tok.json @@ -56,14 +56,11 @@ "entries.remove.singular.confirm": "mi weka e ijo ni. ni li pona anu seme? poki lipu pi ilo sina la lipu ala li weka.", "entries.running.dialog.new_entries": "mi pana e lipu sin {total}...", "entries.tags": "poki", - "entries.unlinked.description": "ijo ale li jo e ijo lon tomo sina. ona li tawa anu weka lon ilo TagStudio ala la, ona li jo ala e ijo lon.

ijo pi ijo lon li ken alasa lon tomo li ken kama jo e ijo lon. ante la sina ken weka e ona.", - "entries.unlinked.relink.attempting": "mi o pana e ijo lon tawa ijo {index}/{unlinked_count}. mi pana e ijo lon tawa ijo {fixed_count}", + "entries.unlinked.description": "ijo ale li jo e ijo lon tomo sina. ona li tawa anu weka lon ilo TagStudio ala la, ona li jo ala e ijo lon.", "entries.unlinked.relink.manual": "sina o pana e ijo lon tawa ijo (&M)", - "entries.unlinked.relink.title": "mi pana e ijo lon tawa ijo", "entries.unlinked.remove": "o weka e ijo pi ijo lon ala", "entries.unlinked.remove_alt": "o weka e ijo pi ijo lon ala (&V)", "entries.unlinked.scanning": "mi o alasa e ijo pi ijo lon ala...", - "entries.unlinked.search_and_relink": "o ala&sa o pana e ijo lon tawa ijo", "entries.unlinked.title": "o pona e ijo pi ijo lon ala", "entries.unlinked.unlinked_count": "ijo pi ijo lon ala: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/tr.json b/src/tagstudio/resources/translations/tr.json index f5c4b25d5..81efe5732 100644 --- a/src/tagstudio/resources/translations/tr.json +++ b/src/tagstudio/resources/translations/tr.json @@ -47,12 +47,9 @@ "entries.remove.plural.confirm": "{count} tane kayıtları silmek istediğinden emin misin?", "entries.running.dialog.new_entries": "{total} Yeni Dosya Kaydı Ekleniyor...", "entries.tags": "Etiketler", - "entries.unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopmuş olarak sayılır.

Kopmuş kayıtlar dizinlerinizde arama yapılırken otomatik olarak tekrar eşleştirilebilir, manuel olarak sizin tarafınızdan eşleştirilebilir veya isteğiniz üzere silinebilir.", - "entries.unlinked.relink.attempting": "{index}/{unlinked_count} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, {fixed_count} Başarıyla Yeniden Eşleştirildi", + "entries.unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopmuş olarak sayılır.", "entries.unlinked.relink.manual": "&Manuel Yeniden Eşleştirme", - "entries.unlinked.relink.title": "Kayıtlar Yeniden Eşleştiriliyor", "entries.unlinked.scanning": "Kütüphane, Kopmuş Kayıtlar için Taranıyor...", - "entries.unlinked.search_and_relink": "&Ara && Yeniden Eşleştir", "entries.unlinked.title": "Kopmuş Kayıtları Düzelt", "entries.unlinked.unlinked_count": "Kopmuş Kayıtlar: {count}", "field.add": "Ek Bilgi Ekle", diff --git a/src/tagstudio/resources/translations/zh_Hans.json b/src/tagstudio/resources/translations/zh_Hans.json index 689837bd5..19ad49394 100644 --- a/src/tagstudio/resources/translations/zh_Hans.json +++ b/src/tagstudio/resources/translations/zh_Hans.json @@ -56,14 +56,11 @@ "entries.remove.plural.confirm": "您确定要删除以下 {count} 个项目?", "entries.running.dialog.new_entries": "正在加入 {total} 个新文件项目...", "entries.tags": "标签", - "entries.unlinked.description": "每个仓库条目都链接到一个目录中的文件。如果链接到某个条目的文件在TagStudio之外被移动或删除,则会被视为未链接。

未链接的条目可能会通过搜索目录自动重新链接,或者根据需要删除。", - "entries.unlinked.relink.attempting": "正在尝试重新链接 {index}/{unlinked_count} 个项目, {fixed_count} 个项目成功重链", + "entries.unlinked.description": "每个仓库条目都链接到一个目录中的文件。如果链接到某个条目的文件在TagStudio之外被移动或删除,则会被视为未链接。", "entries.unlinked.relink.manual": "手动重新链接(&m)", - "entries.unlinked.relink.title": "正在重新链接项目", "entries.unlinked.remove": "删除未链接项目", "entries.unlinked.remove_alt": "删除未链接项目(&v)", "entries.unlinked.scanning": "正在扫描仓库以寻找未链接的项目...", - "entries.unlinked.search_and_relink": "搜索并重新链接(&s)", "entries.unlinked.title": "修复未链接的项目", "entries.unlinked.unlinked_count": "未链接的项目: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/zh_Hant.json b/src/tagstudio/resources/translations/zh_Hant.json index 140c3907f..ba92018ba 100644 --- a/src/tagstudio/resources/translations/zh_Hant.json +++ b/src/tagstudio/resources/translations/zh_Hant.json @@ -57,14 +57,11 @@ "entries.remove.singular.confirm": "您確定要從您的文件庫刪除這個項目嗎? 硬碟上不會有檔案被刪除。", "entries.running.dialog.new_entries": "正在加入 {total} 個新檔案項目...", "entries.tags": "標籤", - "entries.unlinked.description": "每個文件庫的項目都連接到您的其中一個檔案,如果一個已連接的檔案被刪除或移出 TagStudio,那麼這個項目會被歸類為「未連接」。

您可以透過搜尋您的檔案來讓未連接的項目自動重新連接,或者自動刪除這些未連接項目。", - "entries.unlinked.relink.attempting": "正在嘗試重新連接 {index}/{unlinked_count} 個項目,已成功重新連接 {fixed_count} 個", + "entries.unlinked.description": "每個文件庫的項目都連接到您的其中一個檔案,如果一個已連接的檔案被刪除或移出 TagStudio,那麼這個項目會被歸類為「未連接」。", "entries.unlinked.relink.manual": "手動重新連接 (&M)", - "entries.unlinked.relink.title": "正在重新連接", "entries.unlinked.remove": "刪除未連接項目", "entries.unlinked.remove_alt": "刪除未連接項目 (&V)", "entries.unlinked.scanning": "正在掃描文件庫中的未連接項目...", - "entries.unlinked.search_and_relink": "搜尋並重新連接 (&S)", "entries.unlinked.title": "修復未連接項目", "entries.unlinked.unlinked_count": "未連接項目:{count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/tests/core/library/test_sync.py b/tests/core/library/test_sync.py index fc6746f9c..04e35f103 100644 --- a/tests/core/library/test_sync.py +++ b/tests/core/library/test_sync.py @@ -153,99 +153,6 @@ def test_sync_duplicate_case_collision_merged(library: Library): assert library.duplicate_path_entry_ids == [] -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_unlinked_entries(library: Library): - """`relink_unlinked_entries()` must relink an entry to its one matching, unclaimed file.""" - library_dir = unwrap(library.library_dir) - engine = LibrarySyncEngine(library=library) - - (library_dir / "sub").mkdir() - (library_dir / "sub" / "found.txt").touch() - library.add_entries([Entry(path=Path("found.txt"), fields=[])]) - entry_id = library.get_entry_id_from_path(Path("found.txt")) - assert entry_id >= 0 - # Skip sync_dir() to avoid a scan, and manually create unlinked entries to test - engine.unlinked_entries = [unwrap(library.get_entry_full(entry_id))] - - list(engine.relink_unlinked_entries()) - assert engine.manual_relink_count == 1 - assert engine.unlinked_entries == [] - assert library.get_entry_id_from_path(Path("sub/found.txt")) == entry_id - - -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_relink_ignores_files_already_linked_elsewhere(library: Library): - """`find_relink_candidates()` must not offer a file linked elsewhere at a different path.""" - library_dir = unwrap(library.library_dir) - engine = LibrarySyncEngine(library=library) - - (library_dir / "kept").mkdir() - (library_dir / "kept" / "target.txt").touch() - list(engine.sync_dir(library_dir, force_internal_scanner=True)) - list(engine.save_new_entries()) - kept_id = library.get_entry_id_from_path(Path("kept/target.txt")) - assert kept_id >= 0 - - # A different, unrelated entry that happens to share a filename with the kept file above - library.add_entries([Entry(path=Path("old/target.txt"), fields=[])]) - orphan_id = library.get_entry_id_from_path(Path("old/target.txt")) - # Skip sync_dir() to avoid a scan, and manually create unlinked entries to test - engine.unlinked_entries = [unwrap(library.get_entry_full(orphan_id))] - - list(engine.relink_unlinked_entries()) - assert engine.manual_relink_count == 0 - assert len(engine.unlinked_entries) == 1 - # The kept entry must be untouched, no merge should have occurred - assert library.get_entry_id_from_path(Path("kept/target.txt")) == kept_id - - -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_relink_merges_nfc_nfd_duplicate(library: Library): - """`relink_unlinked_entries()` must merge a duplicate entry differing only by Unicode form. - - PathType always normalizes to NFD, so this test uses raw SQL to simulate legacy data - from before that rule existed. - """ - library_dir = unwrap(library.library_dir) - engine = LibrarySyncEngine(library=library) - - nfc_name = unicodedata.normalize("NFC", "SKÅL.txt") - nfd_name = unicodedata.normalize("NFD", "SKÅL.txt") - assert nfc_name != nfd_name - - # The real file lands in NFD form, as most filesystems store it regardless of input form - (library_dir / nfd_name).touch() - entry_a_id, entry_b_id = library.add_entries( - [ - Entry(path=Path(nfd_name), fields=[]), - Entry(path=Path("placeholder.txt"), fields=[]), - ] - ) - with Session(library.engine) as session: - session.execute( - text("UPDATE entries SET path = :path WHERE id = :id"), - {"path": nfc_name, "id": entry_b_id}, - ) - session.commit() - library.path_cache = None # Force a rebuild to pick up the raw SQL change - - library.get_or_build_path_cache() - assert library.duplicate_path_entry_ids is not None - assert len(library.duplicate_path_entry_ids) == 1 - dupe_id = library.duplicate_path_entry_ids[0] - kept_id = entry_b_id if dupe_id == entry_a_id else entry_a_id - # Skip sync_dir() to isolate manual relink - engine.unlinked_entries = [unwrap(library.get_entry_full(dupe_id))] - - entries_before = library.entries_count - list(engine.relink_unlinked_entries()) - assert engine.manual_relink_count == 1 - assert dupe_id not in {e.id for e in engine.unlinked_entries} - # A merge deletes the duplicate outright, rather than just reassigning its path - assert library.entries_count == entries_before - 1 - assert unwrap(library.get_entry_full(kept_id)).id == kept_id - - @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sync_auto_relink_merges_nfc_nfd_duplicate(library: Library): """A duplicate entry differing only by Unicode form must be merged automatically. @@ -292,8 +199,8 @@ def test_sync_auto_relink_merges_nfc_nfd_duplicate(library: Library): @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_sync_case_sensitivity_aware_relink(library: Library): - """`find_relink_candidates()` must respect the library's case-sensitivity setting.""" +def test_sync_auto_relink_respects_case_sensitivity(library: Library): + """The filename-only fallback pass must respect the library's case-sensitivity setting.""" library_dir = unwrap(library.library_dir) engine = LibrarySyncEngine(library=library) @@ -301,15 +208,16 @@ def test_sync_case_sensitivity_aware_relink(library: Library): (library_dir / "Other" / "name.txt").touch() library.add_entries([Entry(path=Path("Folder/Name.txt"), fields=[])]) - list(engine.sync_dir(library_dir, force_internal_scanner=True)) - unlinked_entry = next(e for e in engine.unlinked_entries if e.path == Path("Folder/Name.txt")) - library.is_case_sensitive_fs = True - assert engine.find_relink_candidates(unlinked_entry) == [] + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 0 + assert Path("Folder/Name.txt") in {e.path for e in engine.unlinked_entries} library.is_case_sensitive_fs = False - engine._filename_to_path_map = None # Rebuild the index with the new case sensitivity - assert engine.find_relink_candidates(unlinked_entry) == [Path("Other/name.txt")] + list(engine.sync_dir(library_dir, force_internal_scanner=True)) + assert engine.relinked_entries_count == 1 + assert engine.relinked_entries[0].path == Path("Folder/Name.txt") + assert library.get_entry_id_from_path(Path("Other/name.txt")) >= 0 @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) From 2c87ce67db994dd0e1df4290e0e66436f7f7eafc Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:48:04 -0700 Subject: [PATCH 8/8] refactor(ui): make 'fix unlined entries' panel refresh button use sync banner --- src/tagstudio/qt/mixed/fix_unlinked.py | 39 +------------------ src/tagstudio/qt/qt_driver.py | 3 ++ src/tagstudio/resources/translations/cs.json | 1 - src/tagstudio/resources/translations/de.json | 1 - src/tagstudio/resources/translations/el.json | 1 - src/tagstudio/resources/translations/en.json | 5 +-- src/tagstudio/resources/translations/es.json | 1 - src/tagstudio/resources/translations/fi.json | 1 - src/tagstudio/resources/translations/fil.json | 1 - src/tagstudio/resources/translations/fr.json | 1 - src/tagstudio/resources/translations/hu.json | 1 - src/tagstudio/resources/translations/it.json | 1 - src/tagstudio/resources/translations/ja.json | 1 - .../resources/translations/nb_NO.json | 1 - src/tagstudio/resources/translations/pl.json | 1 - src/tagstudio/resources/translations/pt.json | 1 - .../resources/translations/pt_BR.json | 1 - src/tagstudio/resources/translations/qpv.json | 1 - src/tagstudio/resources/translations/ru.json | 1 - src/tagstudio/resources/translations/sv.json | 1 - src/tagstudio/resources/translations/ta.json | 1 - src/tagstudio/resources/translations/tok.json | 1 - src/tagstudio/resources/translations/tr.json | 1 - .../resources/translations/zh_Hans.json | 1 - .../resources/translations/zh_Hant.json | 1 - 25 files changed, 6 insertions(+), 63 deletions(-) diff --git a/src/tagstudio/qt/mixed/fix_unlinked.py b/src/tagstudio/qt/mixed/fix_unlinked.py index 6063cd428..d3ebba0f6 100644 --- a/src/tagstudio/qt/mixed/fix_unlinked.py +++ b/src/tagstudio/qt/mixed/fix_unlinked.py @@ -4,24 +4,19 @@ from typing import TYPE_CHECKING, override -import structlog from PySide6 import QtCore, QtGui from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.merge_dupe_entries_progress import MergeDuplicateEntriesProgress -from tagstudio.qt.controllers.progress_bar import ProgressWidget from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal from tagstudio.qt.views.styles.stylesheets import header if TYPE_CHECKING: from tagstudio.qt.qt_driver import QtDriver -logger = structlog.get_logger(__name__) - # TODO: Split to use MVC guidelines, or completely redo. class FixUnlinkedEntriesModal(QWidget): @@ -60,7 +55,7 @@ def __init__(self, library: Library, driver: QtDriver): self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.refresh_unlinked_button = QPushButton(Translations["entries.generic.refresh_alt"]) - self.refresh_unlinked_button.clicked.connect(self.refresh_unlinked) + self.refresh_unlinked_button.clicked.connect(self.driver.sync_library_callback) self.merge_class = MergeDuplicateEntriesProgress(self.lib, self.driver) @@ -100,38 +95,6 @@ def __init__(self, library: Library, driver: QtDriver): self.update_unlinked_count() - def refresh_unlinked(self): - if self.driver.file_scan_lock: - logger.info("[FixUnlinkedEntries] Sync already in progress, ignoring refresh request") - return - self.driver.file_scan_lock = True - - pw = ProgressWidget( - cancel_button_text=None, - minimum=0, - maximum=self.lib.entries_count, - ) - pw.setWindowTitle(Translations["library.scan_library.title"]) - pw.update_label(Translations["entries.unlinked.scanning"]) - - def finish(): - self.driver.file_scan_lock = False - if ( - hasattr(self.driver, "library_info_window") - and self.driver.library_info_window.isVisible() - ): - self.driver.library_info_window.update_cleanup() - - # Uses the Library's shared path cache - pw.from_iterable_function( - lambda: self.sync_engine.sync_dir(unwrap(self.lib.library_dir)), - None, - self.set_unlinked_count, - finish, - self.update_unlinked_count, - self.remove_modal.refresh_list, - ) - def _sync_ui_from_tracker(self) -> None: """Refresh the UI from the tracker's current state, without rescanning the library.""" self.set_unlinked_count() diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index e468c2e02..bcc0907ae 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -1166,6 +1166,9 @@ def _finish_sync( self.lib.unlinked_entries_count = unlinked_count if hasattr(self, "unlinked_modal") and self.unlinked_modal.isVisible(): self.unlinked_modal.update_unlinked_count() + self.unlinked_modal.remove_modal.refresh_list() + if hasattr(self, "library_info_window") and self.library_info_window.isVisible(): + self.library_info_window.update_cleanup() if self.sync_engine.cancelled: return diff --git a/src/tagstudio/resources/translations/cs.json b/src/tagstudio/resources/translations/cs.json index b5b2ba6e5..783b17b60 100644 --- a/src/tagstudio/resources/translations/cs.json +++ b/src/tagstudio/resources/translations/cs.json @@ -40,7 +40,6 @@ "entries.duplicates.description": "Duplicitní položky jsou definovány jako více položek, které ukazují na stejný soubor na disku. Jejich sloučením se spojí značky a metadata ze všech duplikátů do jediné konsolidované položky. Nesmí se zaměňovat s „duplicitními soubory“, což jsou duplikáty samotných vašich souborů mimo TagStudio.", "entries.mirror.confirmation": "Opravdu chcete zrcadlit následujících {count} položek?", "entries.unlinked.relink.manual": "Znovu propojit ručně", - "entries.unlinked.scanning": "Skenuji knihovnu pro nepropojené záznamy...", "entries.unlinked.title": "Opravit nepropojené záznamy", "field.copy": "Zkopírovat políčko", "field.edit": "Upravit políčko", diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index 4f058bc84..af6997d6d 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -62,7 +62,6 @@ "entries.unlinked.relink.manual": "&Manuell Neuverknüpfen", "entries.unlinked.remove": "Entferne nicht verknüpfte Einträge", "entries.unlinked.remove_alt": "Entfer&ne nicht verknüpfte Einträge", - "entries.unlinked.scanning": "Bibliothek wird nach nicht verknüpften Einträgen durchsucht...", "entries.unlinked.title": "Unverknüpfte Einträge reparieren", "entries.unlinked.unlinked_count": "Unverknüpfte Einträge: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/el.json b/src/tagstudio/resources/translations/el.json index 21e5ef0b7..5016e837b 100644 --- a/src/tagstudio/resources/translations/el.json +++ b/src/tagstudio/resources/translations/el.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "&Χειροκίνητη επανασύνδεση", "entries.unlinked.remove": "Αφαίρεση αποσυνδεδεμένων εγγραφών", "entries.unlinked.remove_alt": "Α&φαίρεση αποσυνδεδεμένων εγγραφών", - "entries.unlinked.scanning": "Σάρωση βιβλιοθήκης για αποσυνδεδεμένες εγγραφές...", "entries.unlinked.title": "Διόρθωση αποσυνδεδεμένων εγγραφών", "entries.unlinked.unlinked_count": "Αποσυνδεδεμένες εγγραφές: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 7483d97d3..6344844ee 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -67,10 +67,9 @@ "entries.unlinked.description.ambiguous": "For unlinked entries that have ambiguous matches to multiple files in your library, you may manually choose how they get relinked.", "entries.unlinked.description.deleted": "When you delete files outside of TagStudio, their associated entries become unlinked. You may manually delete any unlinked entries at your own discretion.", "entries.unlinked.relink.manual": "&Manual Relink", - "entries.unlinked.remove": "Remove Unlinked Entries", - "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", + "entries.unlinked.remove": "Delete Unlinked Entries", + "entries.unlinked.remove_alt": "&Delete Unlinked Entries", "entries.unlinked.review": "Manual &Review", - "entries.unlinked.scanning": "Scanning Library for Unlinked Entries…", "entries.unlinked.title": "Fix Unlinked Entries", "entries.unlinked.unlinked_count": "Unlinked Entries: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index 53d6fcc55..cdc6b9989 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -65,7 +65,6 @@ "entries.unlinked.relink.manual": "&Reenlace manual", "entries.unlinked.remove": "Eliminar Entradas No Vinculadas", "entries.unlinked.remove_alt": "Quit&ar entradas desvinculadas", - "entries.unlinked.scanning": "Buscando entradas no enlazadas en la biblioteca...", "entries.unlinked.title": "Corregir entradas no vinculadas", "entries.unlinked.unlinked_count": "Entradas no vinculadas: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/fi.json b/src/tagstudio/resources/translations/fi.json index d0cdde503..d00e7c0b1 100644 --- a/src/tagstudio/resources/translations/fi.json +++ b/src/tagstudio/resources/translations/fi.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "&Manual Relink", "entries.unlinked.remove": "Poista linkittämättömät merkinnät", "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", - "entries.unlinked.scanning": "Skannataan kirjastosta linkittämättömiä merkintöjä...", "entries.unlinked.title": "Korjaa linkittämättömät merkinnät", "entries.unlinked.unlinked_count": "Linkittämättömät merkinnät: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/fil.json b/src/tagstudio/resources/translations/fil.json index 9382badba..68aa36d7b 100644 --- a/src/tagstudio/resources/translations/fil.json +++ b/src/tagstudio/resources/translations/fil.json @@ -50,7 +50,6 @@ "entries.tags": "Mga Tag", "entries.unlinked.description": "Ang bawat entry sa library ay naka-link sa isang file sa isa sa iyong mga direktoryo. Kung ang isang file na naka-link sa isang entry ay inilipat o binura sa labas ng TagStudio, ito ay isinasaalang-alang na naka-unlink.", "entries.unlinked.relink.manual": "&Manwal na Pag-link Muli", - "entries.unlinked.scanning": "Sina-scan ang Library para sa Mga Naka-unlink na Entry…", "entries.unlinked.title": "Ayusin ang Mga Naka-unlink na Entry", "entries.unlinked.unlinked_count": "Mga Naka-unlink na Entry: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/fr.json b/src/tagstudio/resources/translations/fr.json index b21b28987..f142a0019 100644 --- a/src/tagstudio/resources/translations/fr.json +++ b/src/tagstudio/resources/translations/fr.json @@ -66,7 +66,6 @@ "entries.unlinked.relink.manual": "&Reliage Manuel", "entries.unlinked.remove": "Supprimer les entrées non liées", "entries.unlinked.remove_alt": "Supprim&er les entrées non liées", - "entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...", "entries.unlinked.title": "Réparation des Entrées non Liées", "entries.unlinked.unlinked_count": "Entrées non Liées : {count}", "ffmpeg.missing.status": "{ffmpeg} : {ffmpeg_status}
{ffprobe} : {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/hu.json b/src/tagstudio/resources/translations/hu.json index 1d4738458..fdd8b718e 100644 --- a/src/tagstudio/resources/translations/hu.json +++ b/src/tagstudio/resources/translations/hu.json @@ -66,7 +66,6 @@ "entries.unlinked.relink.manual": "Új&ra összekapcsolás kézileg", "entries.unlinked.remove": "Kapcsolat nélküli elemek eltávolítása", "entries.unlinked.remove_alt": "&Kapcsolat nélküli elemek eltávolítása", - "entries.unlinked.scanning": "Kapcsolat nélküli elemek keresése a könyvtárban…", "entries.unlinked.title": "Kapcsolat nélküli elemek javítása", "entries.unlinked.unlinked_count": "Kapcsolat nélküli elemek: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/it.json b/src/tagstudio/resources/translations/it.json index b8a96adf5..5dfd211aa 100644 --- a/src/tagstudio/resources/translations/it.json +++ b/src/tagstudio/resources/translations/it.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "Ricollegamento &Manuale", "entries.unlinked.remove": "Rimuovi Voci non Collegate", "entries.unlinked.remove_alt": "Rimuo&vi Voci non Collegate", - "entries.unlinked.scanning": "Scansionando la Biblioteca in cerca di Voci non Collegate...", "entries.unlinked.title": "Correggi Voci non Collegate", "entries.unlinked.unlinked_count": "Voci non Collegate: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/ja.json b/src/tagstudio/resources/translations/ja.json index 8a175b891..f65d50274 100644 --- a/src/tagstudio/resources/translations/ja.json +++ b/src/tagstudio/resources/translations/ja.json @@ -66,7 +66,6 @@ "entries.unlinked.relink.manual": "手動で再リンク(&M)", "entries.unlinked.remove": "リンク切れのエントリを削除", "entries.unlinked.remove_alt": "リンク切れのエントリを削除(&V)", - "entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています…", "entries.unlinked.title": "リンク切れのエントリを修正", "entries.unlinked.unlinked_count": "リンク切れのエントリ数: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/nb_NO.json b/src/tagstudio/resources/translations/nb_NO.json index b2427aae5..6d2f306c8 100644 --- a/src/tagstudio/resources/translations/nb_NO.json +++ b/src/tagstudio/resources/translations/nb_NO.json @@ -58,7 +58,6 @@ "entries.tags": "Etiketter", "entries.unlinked.description": "Hver biblioteksoppføring er koblet til en fil i en av dine mapper. Hvis en fil koblet til en oppføring er flyttet eller slettet utenfor TagStudio, så er den sett på som frakoblet.", "entries.unlinked.relink.manual": "&Manuell Gjenkobling", - "entries.unlinked.scanning": "Skanner bibliotek for ulenkede oppføringer …", "entries.unlinked.title": "Fiks ulenkede oppføringer", "entries.unlinked.unlinked_count": "Frakoblede Oppføringer: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/pl.json b/src/tagstudio/resources/translations/pl.json index 710210a14..5d075629b 100644 --- a/src/tagstudio/resources/translations/pl.json +++ b/src/tagstudio/resources/translations/pl.json @@ -50,7 +50,6 @@ "entries.tags": "Tagi", "entries.unlinked.description": "Każdy wpis w bibliotece jest połączony z plikiem w jednym z twoich katalogów. Jeśli połączony plik jest przeniesiony poza TagStudio albo usunięty to jest uważany za odłączony.", "entries.unlinked.relink.manual": "&Ręczne ponowne łączenie", - "entries.unlinked.scanning": "Skanowanie biblioteki dla odłączonych wpisów...", "entries.unlinked.title": "Napraw odłączone wpisy", "entries.unlinked.unlinked_count": "Odłączone wpisy: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/pt.json b/src/tagstudio/resources/translations/pt.json index b2b7c9497..9a9d1e549 100644 --- a/src/tagstudio/resources/translations/pt.json +++ b/src/tagstudio/resources/translations/pt.json @@ -56,7 +56,6 @@ "entries.unlinked.description": "Cada registo na biblioteca faz referência à um ficheiro numa das suas pastas. Se um ficheiro referenciado à uma entrada for movido ou apagado fora do TagStudio, ele é depois considerado não-referenciado.", "entries.unlinked.relink.manual": "&Referência Manual", "entries.unlinked.remove_alt": "Remover Entradas sem Conexões", - "entries.unlinked.scanning": "A escanear biblioteca por registos não referenciados...", "entries.unlinked.title": "Corrigir Registos Não Referenciados", "entries.unlinked.unlinked_count": "Registos Não Referenciados: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/pt_BR.json b/src/tagstudio/resources/translations/pt_BR.json index 4bd28ea43..383e1c64b 100644 --- a/src/tagstudio/resources/translations/pt_BR.json +++ b/src/tagstudio/resources/translations/pt_BR.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "&Referência Manual", "entries.unlinked.remove": "Remover Registros Não Vinculados", "entries.unlinked.remove_alt": "Remover Entradas sem Conexões", - "entries.unlinked.scanning": "Escaneando bibliotecada em busca de registros não referenciados...", "entries.unlinked.title": "Corrigir Registros Não Referenciados", "entries.unlinked.unlinked_count": "Registros Não Referenciados: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/qpv.json b/src/tagstudio/resources/translations/qpv.json index 6c8b47fdb..209677673 100644 --- a/src/tagstudio/resources/translations/qpv.json +++ b/src/tagstudio/resources/translations/qpv.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "&Tsunaga gen mit hant", "entries.unlinked.remove": "Keste kntsunagajena shiruzmakaban", "entries.unlinked.remove_alt": "Keste kntsunagajena shiruzmakaban (&v)", - "entries.unlinked.scanning": "Taskame mlafuhuomi ima grun kntsunagajena shiruzmakaban...", "entries.unlinked.title": "Fiks kntsunagajena shiruzmakaban", "entries.unlinked.unlinked_count": "Kntsunagajena shiruzmakaban: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/ru.json b/src/tagstudio/resources/translations/ru.json index c31817a3d..0084c8215 100644 --- a/src/tagstudio/resources/translations/ru.json +++ b/src/tagstudio/resources/translations/ru.json @@ -62,7 +62,6 @@ "entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".", "entries.unlinked.relink.manual": "&Ручная привязка", "entries.unlinked.remove": "Удалить откреплённые записи", - "entries.unlinked.scanning": "Сканирование библиотеки на наличие откреплённых записей...", "entries.unlinked.title": "Исправить откреплённые записи", "entries.unlinked.unlinked_count": "Откреплённых записей: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/sv.json b/src/tagstudio/resources/translations/sv.json index 1ce4be0d9..ce69b1042 100644 --- a/src/tagstudio/resources/translations/sv.json +++ b/src/tagstudio/resources/translations/sv.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "Länka om manuellt", "entries.unlinked.remove": "Ta Bort Olänkade Poster", "entries.unlinked.remove_alt": "&Ta Bort Olänkade Poster", - "entries.unlinked.scanning": "Skannar bibliotek efter olänkade poster...", "entries.unlinked.title": "Fixa olänkade poster", "entries.unlinked.unlinked_count": "Olänkade Poster: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/ta.json b/src/tagstudio/resources/translations/ta.json index f4730fb6d..3ec97a589 100644 --- a/src/tagstudio/resources/translations/ta.json +++ b/src/tagstudio/resources/translations/ta.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "& கையேடு மறுபரிசீலனை", "entries.unlinked.remove": "இணைக்கப்படாத உள்ளீடுகளை அகற்று", "entries.unlinked.remove_alt": "இணைக்கப்படாத உள்ளீடுகளை அகற்று&விடு", - "entries.unlinked.scanning": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...", "entries.unlinked.title": "இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்யவும்", "entries.unlinked.unlinked_count": "இணைக்கப்படாத உள்ளீடுகள்: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/tok.json b/src/tagstudio/resources/translations/tok.json index 725e3e32b..f4894b7ae 100644 --- a/src/tagstudio/resources/translations/tok.json +++ b/src/tagstudio/resources/translations/tok.json @@ -60,7 +60,6 @@ "entries.unlinked.relink.manual": "sina o pana e ijo lon tawa ijo (&M)", "entries.unlinked.remove": "o weka e ijo pi ijo lon ala", "entries.unlinked.remove_alt": "o weka e ijo pi ijo lon ala (&V)", - "entries.unlinked.scanning": "mi o alasa e ijo pi ijo lon ala...", "entries.unlinked.title": "o pona e ijo pi ijo lon ala", "entries.unlinked.unlinked_count": "ijo pi ijo lon ala: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/tr.json b/src/tagstudio/resources/translations/tr.json index 81efe5732..e187b91bd 100644 --- a/src/tagstudio/resources/translations/tr.json +++ b/src/tagstudio/resources/translations/tr.json @@ -49,7 +49,6 @@ "entries.tags": "Etiketler", "entries.unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopmuş olarak sayılır.", "entries.unlinked.relink.manual": "&Manuel Yeniden Eşleştirme", - "entries.unlinked.scanning": "Kütüphane, Kopmuş Kayıtlar için Taranıyor...", "entries.unlinked.title": "Kopmuş Kayıtları Düzelt", "entries.unlinked.unlinked_count": "Kopmuş Kayıtlar: {count}", "field.add": "Ek Bilgi Ekle", diff --git a/src/tagstudio/resources/translations/zh_Hans.json b/src/tagstudio/resources/translations/zh_Hans.json index 19ad49394..0b0e9c338 100644 --- a/src/tagstudio/resources/translations/zh_Hans.json +++ b/src/tagstudio/resources/translations/zh_Hans.json @@ -60,7 +60,6 @@ "entries.unlinked.relink.manual": "手动重新链接(&m)", "entries.unlinked.remove": "删除未链接项目", "entries.unlinked.remove_alt": "删除未链接项目(&v)", - "entries.unlinked.scanning": "正在扫描仓库以寻找未链接的项目...", "entries.unlinked.title": "修复未链接的项目", "entries.unlinked.unlinked_count": "未链接的项目: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", diff --git a/src/tagstudio/resources/translations/zh_Hant.json b/src/tagstudio/resources/translations/zh_Hant.json index ba92018ba..4c6de5bca 100644 --- a/src/tagstudio/resources/translations/zh_Hant.json +++ b/src/tagstudio/resources/translations/zh_Hant.json @@ -61,7 +61,6 @@ "entries.unlinked.relink.manual": "手動重新連接 (&M)", "entries.unlinked.remove": "刪除未連接項目", "entries.unlinked.remove_alt": "刪除未連接項目 (&V)", - "entries.unlinked.scanning": "正在掃描文件庫中的未連接項目...", "entries.unlinked.title": "修復未連接項目", "entries.unlinked.unlinked_count": "未連接項目:{count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}",