feat: add library cleanup screen and 'fix ignored files' window (#1070)

* feat(ui): add LibraryInfoWindow with statistics

* feat: add library cleanup screen

* fix: missing resource

* tests: add basic test for resource_manager.py

* feat: remove ignored files in bulk

* feat: open backups folder from library info window

* refactor: rename unlinked+ignored modal files

* refactor: sort en.json
This commit is contained in:
Travis Abendshien
2025-08-31 16:53:56 -07:00
committed by GitHub
parent 7a7e1cc4bd
commit 2f4b72fd4d
45 changed files with 1224 additions and 461 deletions

View File

@@ -5,6 +5,9 @@
from sqlalchemy import text
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
DB_VERSION_LEGACY_KEY: str = "DB_VERSION"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"

View File

@@ -67,6 +67,8 @@ from tagstudio.core.library.alchemy.constants import (
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DB_VERSION_LEGACY_KEY,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_QUERY,
)
from tagstudio.core.library.alchemy.db import make_tables
@@ -213,8 +215,11 @@ class Library:
folder: Folder | None
included_files: set[Path] = set()
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
def __init__(self) -> None:
self.dupe_entries_count: int = -1 # NOTE: For internal management.
self.dupe_files_count: int = -1
self.ignored_entries_count: int = -1
self.unlinked_entries_count: int = -1
def close(self):
if self.engine:
@@ -224,6 +229,11 @@ class Library:
self.folder = None
self.included_files = set()
self.dupe_entries_count = -1
self.dupe_files_count = -1
self.ignored_entries_count = -1
self.unlinked_entries_count = -1
def migrate_json_to_sqlite(self, json_lib: JsonLibrary):
"""Migrate JSON library data to the SQLite database."""
logger.info("Starting Library Conversion...")
@@ -340,10 +350,10 @@ class Library:
is_new = True
return self.open_sqlite_library(library_dir, is_new)
else:
self.storage_path = library_dir / TS_FOLDER_NAME / self.SQL_FILENAME
self.storage_path = library_dir / TS_FOLDER_NAME / SQL_FILENAME
assert isinstance(self.storage_path, Path)
if self.verify_ts_folder(library_dir) and (is_new := not self.storage_path.exists()):
json_path = library_dir / TS_FOLDER_NAME / self.JSON_FILENAME
json_path = library_dir / TS_FOLDER_NAME / JSON_FILENAME
if json_path.exists():
return LibraryStatus(
success=False,
@@ -1513,7 +1523,7 @@ class Library:
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
shutil.copy2(
self.library_dir / TS_FOLDER_NAME / self.SQL_FILENAME,
self.library_dir / TS_FOLDER_NAME / SQL_FILENAME,
target_path,
)

View File

@@ -11,7 +11,7 @@ from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.library.alchemy.fields import _FieldID
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.missing_files import logger
from tagstudio.core.utils.unlinked_registry import logger
class TagStudioCore:

View File

@@ -0,0 +1,51 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
import structlog
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.utils.types import unwrap
logger = structlog.get_logger(__name__)
@dataclass
class IgnoredRegistry:
"""State tracker for ignored entries."""
lib: Library
ignored_entries: list[Entry] = field(default_factory=list)
@property
def ignored_count(self) -> int:
return len(self.ignored_entries)
def reset(self):
self.ignored_entries.clear()
def refresh_ignored_entries(self) -> Iterator[int]:
"""Track the number of entries that would otherwise be ignored by the current rules."""
logger.info("[IgnoredRegistry] Refreshing ignored entries...")
self.ignored_entries = []
library_dir: Path = unwrap(self.lib.library_dir)
for i, entry in enumerate(self.lib.all_entries()):
if not Ignore.compiled_patterns:
# If the compiled_patterns has malfunctioned, don't consider that a false positive
yield i
elif Ignore.compiled_patterns.match(library_dir / entry.path):
self.ignored_entries.append(entry)
yield i
def remove_ignored_entries(self) -> None:
self.lib.remove_entries(list(map(lambda ignored: ignored.id, self.ignored_entries)))
self.ignored_entries = []

View File

@@ -1,3 +1,8 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import shutil
from collections.abc import Iterator
from dataclasses import dataclass, field
@@ -11,7 +16,7 @@ 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.qt.helpers.silent_popen import silent_run
from tagstudio.qt.helpers.silent_popen import silent_run # pyright: ignore
logger = structlog.get_logger(__name__)

View File

@@ -14,34 +14,37 @@ logger = structlog.get_logger()
@dataclass
class MissingRegistry:
"""State tracker for unlinked and moved files."""
class UnlinkedRegistry:
"""State tracker for unlinked entries."""
library: Library
lib: Library
files_fixed_count: int = 0
missing_file_entries: list[Entry] = field(default_factory=list)
unlinked_entries: list[Entry] = field(default_factory=list)
@property
def missing_file_entries_count(self) -> int:
return len(self.missing_file_entries)
def unlinked_entries_count(self) -> int:
return len(self.unlinked_entries)
def refresh_missing_files(self) -> Iterator[int]:
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("[refresh_missing_files] Refreshing missing files...")
logger.info("[UnlinkedRegistry] Refreshing unlinked files...")
self.missing_file_entries = []
for i, entry in enumerate(self.library.all_entries()):
full_path = unwrap(self.library.library_dir) / entry.path
self.unlinked_entries = []
for i, entry in enumerate(self.lib.all_entries()):
full_path = unwrap(self.lib.library_dir) / entry.path
if not full_path.exists() or not full_path.is_file():
self.missing_file_entries.append(entry)
self.unlinked_entries.append(entry)
yield i
def match_missing_file_entry(self, match_entry: Entry) -> list[Path]:
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.library.library_dir)
library_dir = unwrap(self.lib.library_dir)
matches: list[Path] = []
ignore_patterns = Ignore.get_patterns(library_dir)
@@ -56,26 +59,26 @@ class MissingRegistry:
new_path = Path(path).relative_to(library_dir)
matches.append(new_path)
logger.info("[MissingRegistry] Matches", matches=matches)
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.missing_file_entries):
item_matches = self.match_missing_file_entry(entry)
for i, entry in enumerate(self.unlinked_entries):
item_matches = self.match_unlinked_file_entry(entry)
if len(item_matches) == 1:
logger.info(
"[fix_unlinked_entries]",
"[UnlinkedRegistry]",
entry=entry.path.as_posix(),
item_matches=item_matches[0].as_posix(),
)
if not self.library.update_entry_path(entry.id, item_matches[0]):
if not self.lib.update_entry_path(entry.id, item_matches[0]):
try:
match = unwrap(self.library.get_entry_full_by_path(item_matches[0]))
entry_full = unwrap(self.library.get_entry_full(entry.id))
self.library.merge_entries(entry_full, match)
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
@@ -83,11 +86,8 @@ class MissingRegistry:
yield i
for entry in matched_entries:
self.missing_file_entries.remove(entry)
self.unlinked_entries.remove(entry)
def execute_deletion(self) -> None:
self.library.remove_entries(
list(map(lambda missing: missing.id, self.missing_file_entries))
)
self.missing_file_entries = []
def remove_unlinked_entries(self) -> None:
self.lib.remove_entries(list(map(lambda unlinked: unlinked.id, self.unlinked_entries)))
self.unlinked_entries = []

View File

@@ -0,0 +1,94 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import TYPE_CHECKING, override
import structlog
from PySide6 import QtGui
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.ignored_registry import IgnoredRegistry
from tagstudio.qt.modals.remove_ignored_modal import RemoveIgnoredModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.view.widgets.fix_ignored_modal_view import FixIgnoredEntriesModalView
from tagstudio.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__(library, driver)
self.tracker = IgnoredRegistry(self.lib)
self.remove_modal = RemoveIgnoredModal(self.driver, self.tracker)
self.remove_modal.done.connect(
lambda: (
self.update_ignored_count(),
self.driver.update_browsing_state(),
self.driver.library_info_window.update_cleanup(),
self.refresh_ignored(),
)
)
self.refresh_ignored_button.clicked.connect(self.refresh_ignored)
self.remove_button.clicked.connect(self.remove_modal.show)
self.done_button.clicked.connect(self.hide)
self.update_ignored_count()
def refresh_ignored(self):
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.ignored.scanning"])
def update_driver_widgets():
if (
hasattr(self.driver, "library_info_window")
and self.driver.library_info_window.isVisible()
):
self.driver.library_info_window.update_cleanup()
pw.from_iterable_function(
self.tracker.refresh_ignored_entries,
None,
self.set_ignored_count,
self.update_ignored_count,
self.remove_modal.refresh_list,
update_driver_widgets,
)
def set_ignored_count(self):
"""Sets the ignored_entries_count in the Library to the tracker's value."""
self.lib.ignored_entries_count = self.tracker.ignored_count
def update_ignored_count(self):
"""Updates the UI to reflect the Library's current ignored_entries_count."""
# Indicates that the library is new compared to the last update.
# NOTE: Make sure set_ignored_count() is called before this!
if self.tracker.ignored_count > 0 and self.lib.ignored_entries_count < 0:
self.tracker.reset()
count: int = self.lib.ignored_entries_count
self.remove_button.setDisabled(count < 1)
count_text: str = Translations.format(
"entries.ignored.ignored_count", count=count if count >= 0 else ""
)
self.ignored_count_label.setText(f"<h3>{count_text}</h3>")
@override
def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore
self.update_ignored_count()
return super().showEvent(event)

View File

@@ -1,13 +1,24 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import os
from pathlib import Path
from typing import TYPE_CHECKING, override
from warnings import catch_warnings
from typing import TYPE_CHECKING
import structlog
from humanfriendly import format_size # pyright: ignore[reportUnknownVariableType]
from PySide6 import QtGui
from tagstudio.core.constants import BACKUP_FOLDER_NAME, TS_FOLDER_NAME
from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
JSON_FILENAME,
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers import file_opener
from tagstudio.qt.translations import Translations
from tagstudio.qt.view.widgets.library_info_window_view import LibraryInfoWindowView
@@ -15,17 +26,33 @@ from tagstudio.qt.view.widgets.library_info_window_view import LibraryInfoWindow
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class LibraryInfoWindow(LibraryInfoWindowView):
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__(library, driver)
# Statistics Buttons
self.manage_tags_button.clicked.connect(
self.driver.main_window.menu_bar.tag_manager_action.trigger
)
self.manage_colors_button.clicked.connect(
self.driver.main_window.menu_bar.color_manager_action.trigger
)
# Cleanup Buttons
self.fix_unlinked_entries.clicked.connect(
self.driver.main_window.menu_bar.fix_unlinked_entries_action.trigger
)
self.fix_ignored_entries.clicked.connect(
self.driver.main_window.menu_bar.fix_ignored_entries_action.trigger
)
self.fix_dupe_files.clicked.connect(
self.driver.main_window.menu_bar.fix_dupe_files_action.trigger
)
# General Buttons
self.close_button.clicked.connect(lambda: self.close())
def update_title(self):
@@ -47,6 +74,93 @@ class LibraryInfoWindow(LibraryInfoWindowView):
self.macros_count_label.setText("<b>1</b>") # TODO: Implement macros system
def showEvent(self, event: QtGui.QShowEvent): # noqa N802
def update_cleanup(self):
# Unlinked Entries
unlinked_count: str = (
str(self.lib.unlinked_entries_count) if self.lib.unlinked_entries_count >= 0 else ""
)
self.unlinked_count_label.setText(f"<b>{unlinked_count}</b>")
# Ignored Entries
ignored_count: str = (
str(self.lib.ignored_entries_count) if self.lib.ignored_entries_count >= 0 else ""
)
self.ignored_count_label.setText(f"<b>{ignored_count}</b>")
# Duplicate Files
dupe_files_count: str = (
str(self.lib.dupe_files_count) if self.lib.dupe_files_count >= 0 else ""
)
self.dupe_files_count_label.setText(f"<b>{dupe_files_count}</b>")
# Legacy JSON Library Present
json_library_text: str = (
Translations["generic.yes"]
if self.__is_json_library_present
else Translations["generic.no"]
)
self.legacy_json_status_label.setText(f"<b>{json_library_text}</b>")
# Backups
self.backups_count_label.setText(
f"<b>{self.__backups_count}</b> ({format_size(self.__backups_size)})"
)
# Buttons
with catch_warnings(record=True):
self.view_legacy_json_file.clicked.disconnect()
self.open_backups_folder.clicked.disconnect()
if self.__is_json_library_present:
self.view_legacy_json_file.setEnabled(True)
self.view_legacy_json_file.clicked.connect(
lambda: file_opener.open_file(
unwrap(self.lib.library_dir) / TS_FOLDER_NAME / JSON_FILENAME, file_manager=True
)
)
else:
self.view_legacy_json_file.setEnabled(False)
self.open_backups_folder.clicked.connect(
lambda: file_opener.open_file(
unwrap(self.lib.library_dir) / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
)
)
def update_version(self):
version_text: str = f"<b>{self.lib.get_version(DB_VERSION_CURRENT_KEY)}</b> / {DB_VERSION}"
self.version_label.setText(
Translations.format("library_info.version", version=version_text)
)
def refresh(self):
self.update_title()
self.update_stats()
self.update_cleanup()
self.update_version()
@property
def __is_json_library_present(self):
json_path = unwrap(self.lib.library_dir) / TS_FOLDER_NAME / JSON_FILENAME
return json_path.exists()
@property
def __backups_count(self):
backups_path = unwrap(self.lib.library_dir) / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
return len(os.listdir(backups_path))
@property
def __backups_size(self):
backups_path = unwrap(self.lib.library_dir) / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
size: int = 0
for f in backups_path.glob("*"):
if not f.is_dir() and f.exists():
size += Path(f).stat().st_size
return size
@override
def showEvent(self, event: QtGui.QShowEvent): # type: ignore
self.refresh()
return super().showEvent(event)

View File

@@ -80,6 +80,7 @@ class MainMenuBar(QMenuBar):
tools_menu: QMenu
fix_unlinked_entries_action: QAction
fix_ignored_entries_action: QAction
fix_dupe_files_action: QAction
clear_thumb_cache_action: QAction
@@ -349,6 +350,13 @@ class MainMenuBar(QMenuBar):
self.fix_unlinked_entries_action.setEnabled(False)
self.tools_menu.addAction(self.fix_unlinked_entries_action)
# Fix Ignored Entries
self.fix_ignored_entries_action = QAction(
Translations["menu.tools.fix_ignored_entries"], self
)
self.fix_ignored_entries_action.setEnabled(False)
self.tools_menu.addAction(self.fix_ignored_entries_action)
# Fix Duplicate Files
self.fix_dupe_files_action = QAction(Translations["menu.tools.fix_duplicate_files"], self)
self.fix_dupe_files_action.setEnabled(False)
@@ -510,11 +518,8 @@ class MainWindow(QMainWindow):
self.central_layout.setObjectName("central_layout")
self.setup_search_bar()
self.setup_extra_input_bar()
self.setup_content(driver)
self.setCentralWidget(self.central_widget)
def setup_search_bar(self):
@@ -618,7 +623,6 @@ class MainWindow(QMainWindow):
self.content_splitter.setHandleWidth(12)
self.setup_entry_list(driver)
self.setup_preview_panel(driver)
self.content_splitter.setStretchFactor(0, 1)

View File

@@ -18,7 +18,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.dupe_files import DupeRegistry
from tagstudio.qt.modals.mirror_entities import MirrorEntriesModal
from tagstudio.qt.modals.mirror_entries_modal import MirrorEntriesModal
from tagstudio.qt.translations import Translations
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
# TODO: Break up into MVC classes, similar to fix_ignored_modal
class FixDupeFilesModal(QWidget):
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()

View File

@@ -10,10 +10,10 @@ 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.missing_files import MissingRegistry
from tagstudio.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
from tagstudio.core.utils.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.modals.merge_dupe_entries import MergeDuplicateEntries
from tagstudio.qt.modals.relink_unlinked import RelinkUnlinkedEntries
from tagstudio.qt.modals.relink_entries_modal import RelinkUnlinkedEntries
from tagstudio.qt.modals.remove_unlinked_modal import RemoveUnlinkedEntriesModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
@@ -22,15 +22,16 @@ if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
# TODO: Break up into MVC classes, similar to fix_ignored_modal
class FixUnlinkedEntriesModal(QWidget):
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
self.tracker = MissingRegistry(library=self.lib)
self.tracker = UnlinkedRegistry(lib=self.lib)
self.missing_count = -1
self.unlinked_count = -1
self.dupe_count = -1
self.setWindowTitle(Translations["entries.unlinked.title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
@@ -43,18 +44,16 @@ class FixUnlinkedEntriesModal(QWidget):
self.unlinked_desc_widget.setWordWrap(True)
self.unlinked_desc_widget.setStyleSheet("text-align:left;")
self.missing_count_label = QLabel()
self.missing_count_label.setObjectName("missingCountLabel")
self.missing_count_label.setStyleSheet("font-weight:bold;font-size:14px;")
self.missing_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.unlinked_count_label = QLabel()
self.unlinked_count_label.setObjectName("unlinkedCountLabel")
self.unlinked_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.dupe_count_label = QLabel()
self.dupe_count_label.setObjectName("dupeCountLabel")
self.dupe_count_label.setStyleSheet("font-weight:bold;font-size:14px;")
self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.refresh_unlinked_button = QPushButton(Translations["entries.unlinked.refresh_all"])
self.refresh_unlinked_button.clicked.connect(self.refresh_missing_files)
self.refresh_unlinked_button = QPushButton(Translations["entries.generic.refresh_alt"])
self.refresh_unlinked_button.clicked.connect(self.refresh_unlinked)
self.merge_class = MergeDuplicateEntries(self.lib, self.driver)
self.relink_class = RelinkUnlinkedEntries(self.tracker)
@@ -64,7 +63,7 @@ class FixUnlinkedEntriesModal(QWidget):
# refresh the grid
lambda: (
self.driver.update_browsing_state(),
self.refresh_missing_files(),
self.refresh_unlinked(),
)
)
self.search_button.clicked.connect(self.relink_class.repair_entries)
@@ -72,16 +71,17 @@ class FixUnlinkedEntriesModal(QWidget):
self.manual_button = QPushButton(Translations["entries.unlinked.relink.manual"])
self.manual_button.setHidden(True)
self.delete_button = QPushButton(Translations["entries.unlinked.delete_alt"])
self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker)
self.delete_modal.done.connect(
self.remove_button = QPushButton(Translations["entries.unlinked.remove_alt"])
self.remove_modal = RemoveUnlinkedEntriesModal(self.driver, self.tracker)
self.remove_modal.done.connect(
lambda: (
self.set_missing_count(),
self.set_unlinked_count(),
# refresh the grid
self.driver.update_browsing_state(),
self.refresh_unlinked(),
)
)
self.delete_button.clicked.connect(self.delete_modal.show)
self.remove_button.clicked.connect(self.remove_modal.show)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
@@ -93,19 +93,19 @@ class FixUnlinkedEntriesModal(QWidget):
self.done_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.done_button)
self.root_layout.addWidget(self.missing_count_label)
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.delete_button)
self.root_layout.addWidget(self.remove_button)
self.root_layout.addStretch(1)
self.root_layout.addStretch(2)
self.root_layout.addWidget(self.button_container)
self.set_missing_count(self.missing_count)
self.update_unlinked_count()
def refresh_missing_files(self):
def refresh_unlinked(self):
pw = ProgressWidget(
cancel_button_text=None,
minimum=0,
@@ -114,30 +114,47 @@ class FixUnlinkedEntriesModal(QWidget):
pw.setWindowTitle(Translations["library.scan_library.title"])
pw.update_label(Translations["entries.unlinked.scanning"])
def update_driver_widgets():
if (
hasattr(self.driver, "library_info_window")
and self.driver.library_info_window.isVisible()
):
self.driver.library_info_window.update_cleanup()
pw.from_iterable_function(
self.tracker.refresh_missing_files,
self.tracker.refresh_unlinked_files,
None,
self.set_missing_count,
self.delete_modal.refresh_list,
self.set_unlinked_count,
self.update_unlinked_count,
self.remove_modal.refresh_list,
update_driver_widgets,
)
def set_missing_count(self, count: int | None = None):
if count is not None:
self.missing_count = count
else:
self.missing_count = self.tracker.missing_file_entries_count
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
if self.missing_count < 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
self.missing_count_label.setText(Translations["entries.unlinked.missing_count.none"])
else:
# disable buttons if there are no files to fix
self.search_button.setDisabled(self.missing_count == 0)
self.delete_button.setDisabled(self.missing_count == 0)
self.missing_count_label.setText(
Translations.format("entries.unlinked.missing_count.some", count=self.missing_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
self.search_button.setDisabled(count < 1)
self.remove_button.setDisabled(count < 1)
count_text: str = Translations.format(
"entries.unlinked.unlinked_count", count=count if count >= 0 else ""
)
self.unlinked_count_label.setText(f"<h3>{count_text}</h3>")
@override
def showEvent(self, event: QtGui.QShowEvent) -> None:
self.update_unlinked_count()
return super().showEvent(event)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802

View File

@@ -5,7 +5,7 @@
from PySide6.QtCore import QObject, Signal
from tagstudio.core.utils.missing_files import MissingRegistry
from tagstudio.core.utils.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
@@ -13,7 +13,7 @@ from tagstudio.qt.widgets.progress import ProgressWidget
class RelinkUnlinkedEntries(QObject):
done = Signal()
def __init__(self, tracker: MissingRegistry):
def __init__(self, tracker: UnlinkedRegistry):
super().__init__()
self.tracker = tracker
@@ -21,8 +21,8 @@ class RelinkUnlinkedEntries(QObject):
def displayed_text(x):
return Translations.format(
"entries.unlinked.relink.attempting",
idx=x,
missing_count=self.tracker.missing_file_entries_count,
index=x,
unlinked_count=self.tracker.unlinked_entries_count,
fixed_count=self.tracker.files_fixed_count,
)
@@ -30,8 +30,7 @@ class RelinkUnlinkedEntries(QObject):
label_text="",
cancel_button_text=None,
minimum=0,
maximum=self.tracker.missing_file_entries_count,
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)

View File

@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.utils.missing_files import MissingRegistry
from tagstudio.core.utils.ignored_registry import IgnoredRegistry
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
@@ -27,14 +27,14 @@ if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
class DeleteUnlinkedEntriesModal(QWidget):
class RemoveIgnoredModal(QWidget):
done = Signal()
def __init__(self, driver: "QtDriver", tracker: MissingRegistry):
def __init__(self, driver: "QtDriver", tracker: IgnoredRegistry):
super().__init__()
self.driver = driver
self.tracker = tracker
self.setWindowTitle(Translations["entries.unlinked.delete"])
self.setWindowTitle(Translations["entries.ignored.remove"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
@@ -42,8 +42,8 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.desc_widget = QLabel(
Translations.format(
"entries.unlinked.delete.confirm",
count=self.tracker.missing_file_entries_count,
"entries.remove.plural.confirm",
count=self.tracker.ignored_count,
)
)
self.desc_widget.setObjectName("descriptionLabel")
@@ -64,7 +64,7 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.delete_button = QPushButton(Translations["generic.delete_alt"])
self.delete_button = QPushButton(Translations["generic.remove_alt"])
self.delete_button.clicked.connect(self.hide)
self.delete_button.clicked.connect(lambda: self.delete_entries())
self.button_layout.addWidget(self.delete_button)
@@ -75,13 +75,11 @@ class DeleteUnlinkedEntriesModal(QWidget):
def refresh_list(self):
self.desc_widget.setText(
Translations.format(
"entries.unlinked.delete.confirm", count=self.tracker.missing_file_entries_count
)
Translations.format("entries.remove.plural.confirm", count=self.tracker.ignored_count)
)
self.model.clear()
for i in self.tracker.missing_file_entries:
for i in self.tracker.ignored_entries:
item = QStandardItem(str(i.path))
item.setEditable(False)
self.model.appendRow(item)
@@ -92,11 +90,15 @@ class DeleteUnlinkedEntriesModal(QWidget):
minimum=0,
maximum=0,
)
pw.setWindowTitle(Translations["entries.unlinked.delete.deleting"])
pw.update_label(Translations["entries.unlinked.delete.deleting"])
pw.setWindowTitle(Translations["entries.generic.remove.removing"])
pw.update_label(
Translations.format(
"entries.generic.remove.removing_count", count=self.tracker.ignored_count
)
)
pw.show()
r = CustomRunnable(self.tracker.execute_deletion)
r = CustomRunnable(self.tracker.remove_ignored_entries)
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (

View File

@@ -0,0 +1,119 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
from tagstudio.core.utils.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
class RemoveUnlinkedEntriesModal(QWidget):
done = Signal()
def __init__(self, driver: "QtDriver", tracker: UnlinkedRegistry):
super().__init__()
self.driver = driver
self.tracker = tracker
self.setWindowTitle(Translations["entries.unlinked.remove"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.desc_widget = QLabel(
Translations.format(
"entries.remove.plural.confirm",
count=self.tracker.unlinked_entries_count,
)
)
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_view = QListView()
self.model = QStandardItemModel()
self.list_view.setModel(self.model)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.cancel_button = QPushButton(Translations["generic.cancel_alt"])
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.delete_button = QPushButton(Translations["generic.remove_alt"])
self.delete_button.clicked.connect(self.hide)
self.delete_button.clicked.connect(lambda: self.remove_entries())
self.button_layout.addWidget(self.delete_button)
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.list_view)
self.root_layout.addWidget(self.button_container)
def refresh_list(self):
self.desc_widget.setText(
Translations.format(
"entries.remove.plural.confirm", count=self.tracker.unlinked_entries_count
)
)
self.model.clear()
for i in self.tracker.unlinked_entries:
item = QStandardItem(str(i.path))
item.setEditable(False)
self.model.appendRow(item)
def remove_entries(self):
pw = ProgressWidget(
cancel_button_text=None,
minimum=0,
maximum=0,
)
pw.setWindowTitle(Translations["entries.generic.remove.removing"])
pw.update_label(
Translations.format(
"entries.generic.remove.removing_count", count=self.tracker.unlinked_entries_count
)
)
pw.show()
r = CustomRunnable(self.tracker.remove_unlinked_entries)
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.done.emit(),
)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -1,138 +1,146 @@
{
"splash_classic": {
"path": "qt/images/splash/classic.png",
"mode": "qpixmap"
},
"splash_goo_gears": {
"path": "qt/images/splash/goo_gears.png",
"mode": "qpixmap"
},
"icon": {
"path": "icon.png",
"mode": "pil"
},
"play_icon": {
"path": "qt/images/play.svg",
"mode": "rb"
},
"pause_icon": {
"path": "qt/images/pause.svg",
"mode": "rb"
},
"volume_icon": {
"path": "qt/images/volume.svg",
"mode": "rb"
},
"volume_mute_icon": {
"path": "qt/images/volume_mute.svg",
"mode": "rb"
},
"broken_link_icon": {
"path": "qt/images/broken_link_icon.png",
"mode": "pil"
},
"ignored": {
"path": "qt/images/ignored_128.png",
"mode": "pil"
},
"adobe_illustrator": {
"path": "qt/images/file_icons/adobe_illustrator.png",
"mode": "pil"
},
"adobe_photoshop": {
"path": "qt/images/file_icons/adobe_photoshop.png",
"mode": "pil"
},
"affinity_photo": {
"path": "qt/images/file_icons/affinity_photo.png",
"mode": "pil"
},
"archive": {
"path": "qt/images/file_icons/archive.png",
"mode": "pil"
},
"audio": {
"path": "qt/images/file_icons/audio.png",
"mode": "pil"
},
"blender": {
"path": "qt/images/file_icons/blender.png",
"mode": "pil"
},
"database": {
"path": "qt/images/file_icons/database.png",
"mode": "pil"
},
"document": {
"path": "qt/images/file_icons/document.png",
"mode": "pil"
},
"ebook": {
"path": "qt/images/file_icons/ebook.png",
"mode": "pil"
},
"file_generic": {
"path": "qt/images/file_icons/file_generic.png",
"mode": "pil"
},
"font": {
"path": "qt/images/file_icons/font.png",
"mode": "pil"
},
"image": {
"path": "qt/images/file_icons/image.png",
"mode": "pil"
},
"image_vector": {
"path": "qt/images/file_icons/image_vector.png",
"mode": "pil"
},
"material": {
"path": "qt/images/file_icons/material.png",
"mode": "pil"
},
"model": {
"path": "qt/images/file_icons/model.png",
"mode": "pil"
},
"presentation": {
"path": "qt/images/file_icons/presentation.png",
"mode": "pil"
},
"program": {
"path": "qt/images/file_icons/program.png",
"mode": "pil"
},
"shader": {
"path": "qt/images/file_icons/shader.png",
"mode": "pil"
},
"shortcut": {
"path": "qt/images/file_icons/shortcut.png",
"mode": "pil"
},
"spreadsheet": {
"path": "qt/images/file_icons/spreadsheet.png",
"mode": "pil"
},
"text": {
"path": "qt/images/file_icons/text.png",
"mode": "pil"
},
"video": {
"path": "qt/images/file_icons/video.png",
"mode": "pil"
},
"thumb_loading": {
"path": "qt/images/thumb_loading.png",
"mode": "pil"
},
"bxs-left-arrow": {
"path": "qt/images/bxs-left-arrow.png",
"mode": "pil"
},
"bxs-right-arrow": {
"path": "qt/images/bxs-right-arrow.png",
"mode": "pil"
}
"splash_classic": {
"path": "qt/images/splash/classic.png",
"mode": "qpixmap"
},
"splash_goo_gears": {
"path": "qt/images/splash/goo_gears.png",
"mode": "qpixmap"
},
"icon": {
"path": "icon.png",
"mode": "pil"
},
"play_icon": {
"path": "qt/images/play.svg",
"mode": "rb"
},
"pause_icon": {
"path": "qt/images/pause.svg",
"mode": "rb"
},
"volume_icon": {
"path": "qt/images/volume.svg",
"mode": "rb"
},
"volume_mute_icon": {
"path": "qt/images/volume_mute.svg",
"mode": "rb"
},
"broken_link_icon": {
"path": "qt/images/broken_link_icon.png",
"mode": "pil"
},
"ignored": {
"path": "qt/images/ignored_128.png",
"mode": "pil"
},
"adobe_illustrator": {
"path": "qt/images/file_icons/adobe_illustrator.png",
"mode": "pil"
},
"adobe_photoshop": {
"path": "qt/images/file_icons/adobe_photoshop.png",
"mode": "pil"
},
"affinity_photo": {
"path": "qt/images/file_icons/affinity_photo.png",
"mode": "pil"
},
"archive": {
"path": "qt/images/file_icons/archive.png",
"mode": "pil"
},
"audio": {
"path": "qt/images/file_icons/audio.png",
"mode": "pil"
},
"database": {
"path": "qt/images/file_icons/database.png",
"mode": "pil"
},
"document": {
"path": "qt/images/file_icons/document.png",
"mode": "pil"
},
"ebook": {
"path": "qt/images/file_icons/ebook.png",
"mode": "pil"
},
"file_generic": {
"path": "qt/images/file_icons/file_generic.png",
"mode": "pil"
},
"font": {
"path": "qt/images/file_icons/font.png",
"mode": "pil"
},
"image": {
"path": "qt/images/file_icons/image.png",
"mode": "pil"
},
"image_vector": {
"path": "qt/images/file_icons/image_vector.png",
"mode": "pil"
},
"material": {
"path": "qt/images/file_icons/material.png",
"mode": "pil"
},
"model": {
"path": "qt/images/file_icons/model.png",
"mode": "pil"
},
"presentation": {
"path": "qt/images/file_icons/presentation.png",
"mode": "pil"
},
"program": {
"path": "qt/images/file_icons/program.png",
"mode": "pil"
},
"shader": {
"path": "qt/images/file_icons/shader.png",
"mode": "pil"
},
"shortcut": {
"path": "qt/images/file_icons/shortcut.png",
"mode": "pil"
},
"spreadsheet": {
"path": "qt/images/file_icons/spreadsheet.png",
"mode": "pil"
},
"text": {
"path": "qt/images/file_icons/text.png",
"mode": "pil"
},
"video": {
"path": "qt/images/file_icons/video.png",
"mode": "pil"
},
"thumb_loading": {
"path": "qt/images/thumb_loading.png",
"mode": "pil"
},
"bxs-left-arrow": {
"path": "qt/images/bxs-left-arrow.png",
"mode": "pil"
},
"bxs-right-arrow": {
"path": "qt/images/bxs-right-arrow.png",
"mode": "pil"
},
"unlinked_stat": {
"path": "qt/images/unlinked_stat.png",
"mode": "pil"
},
"ignored_stat": {
"path": "qt/images/ignored_stat.png",
"mode": "pil"
},
"dupe_file_stat": {
"path": "qt/images/dupe_file_stat.png",
"mode": "pil"
}
}

View File

@@ -45,7 +45,6 @@ from PySide6.QtWidgets import (
QScrollArea,
)
# this import has side-effect of import PySide resources
import tagstudio.qt.resources_rc # noqa: F401
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
@@ -69,6 +68,9 @@ from tagstudio.core.utils.refresh_dir import RefreshDirTracker
from tagstudio.core.utils.types import unwrap
from tagstudio.core.utils.web import strip_web_protocol
from tagstudio.qt.cache_manager import CacheManager
# this import has side-effect of import PySide resources
from tagstudio.qt.controller.fix_ignored_modal_controller import FixIgnoredEntriesModal
from tagstudio.qt.controller.widgets.ignore_modal_controller import IgnoreModal
from tagstudio.qt.controller.widgets.library_info_window_controller import LibraryInfoWindow
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
@@ -180,6 +182,7 @@ class QtDriver(DriverMixin, QObject):
folders_modal: FoldersToTagsModal
about_modal: AboutModal
unlinked_modal: FixUnlinkedEntriesModal
ignored_modal: FixIgnoredEntriesModal
dupe_modal: FixDupeFilesModal
library_info_window: LibraryInfoWindow
@@ -503,6 +506,15 @@ class QtDriver(DriverMixin, QObject):
create_fix_unlinked_entries_modal
)
def create_ignored_entries_modal():
if not hasattr(self, "ignored_modal"):
self.ignored_modal = FixIgnoredEntriesModal(self.lib, self)
self.ignored_modal.show()
self.main_window.menu_bar.fix_ignored_entries_action.triggered.connect(
create_ignored_entries_modal
)
def create_dupe_files_modal():
if not hasattr(self, "dupe_modal"):
self.dupe_modal = FixDupeFilesModal(self.lib, self)
@@ -753,6 +765,7 @@ class QtDriver(DriverMixin, QObject):
self.main_window.menu_bar.ignore_modal_action.setEnabled(False)
self.main_window.menu_bar.new_tag_action.setEnabled(False)
self.main_window.menu_bar.fix_unlinked_entries_action.setEnabled(False)
self.main_window.menu_bar.fix_ignored_entries_action.setEnabled(False)
self.main_window.menu_bar.fix_dupe_files_action.setEnabled(False)
self.main_window.menu_bar.clear_thumb_cache_action.setEnabled(False)
self.main_window.menu_bar.folders_to_tags_action.setEnabled(False)
@@ -1747,6 +1760,7 @@ class QtDriver(DriverMixin, QObject):
self.main_window.menu_bar.ignore_modal_action.setEnabled(True)
self.main_window.menu_bar.new_tag_action.setEnabled(True)
self.main_window.menu_bar.fix_unlinked_entries_action.setEnabled(True)
self.main_window.menu_bar.fix_ignored_entries_action.setEnabled(True)
self.main_window.menu_bar.fix_dupe_files_action.setEnabled(True)
self.main_window.menu_bar.clear_thumb_cache_action.setEnabled(True)
self.main_window.menu_bar.folders_to_tags_action.setEnabled(True)

View File

@@ -0,0 +1,67 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import TYPE_CHECKING, override
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.qt.translations import Translations
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
class FixIgnoredEntriesModalView(QWidget):
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
self.setWindowTitle(Translations["entries.ignored.title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.ignored_desc_widget = QLabel(Translations["entries.ignored.description"])
self.ignored_desc_widget.setObjectName("ignoredDescriptionLabel")
self.ignored_desc_widget.setWordWrap(True)
self.ignored_desc_widget.setStyleSheet("text-align:left;")
self.ignored_count_label = QLabel()
self.ignored_count_label.setObjectName("ignoredCountLabel")
self.ignored_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.refresh_ignored_button = QPushButton(Translations["entries.generic.refresh_alt"])
self.remove_button = QPushButton(Translations["entries.ignored.remove_alt"])
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.done_button = QPushButton(Translations["generic.done_alt"])
self.done_button.setDefault(True)
self.button_layout.addWidget(self.done_button)
self.root_layout.addWidget(self.ignored_count_label)
self.root_layout.addWidget(self.ignored_desc_widget)
self.root_layout.addWidget(self.refresh_ignored_button)
self.root_layout.addWidget(self.remove_button)
self.root_layout.addStretch(1)
self.root_layout.addStretch(2)
self.root_layout.addWidget(self.button_container)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.done_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -3,10 +3,15 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
from typing import TYPE_CHECKING
from PIL import Image, ImageQt
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
QFrame,
QGraphicsOpacityEffect,
QGridLayout,
QHBoxLayout,
QLabel,
@@ -17,11 +22,13 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.platform_strings import open_file_str
from tagstudio.qt.translations import Translations
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.ts_qt import QtDriver
@@ -32,11 +39,12 @@ class LibraryInfoWindowView(QWidget):
self.driver = driver
self.setWindowTitle("Library Information")
self.setMinimumSize(400, 300)
self.setMinimumSize(800, 480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
row_height: int = 22
icon_margin: int = 4
cell_alignment: Qt.AlignmentFlag = (
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
@@ -49,11 +57,12 @@ class LibraryInfoWindowView(QWidget):
self.body_widget = QWidget()
self.body_layout = QHBoxLayout(self.body_widget)
self.body_layout.setContentsMargins(0, 0, 0, 0)
self.body_layout.setSpacing(0)
self.body_layout.setSpacing(6)
# Statistics -----------------------------------------------------------
self.stats_widget = QWidget()
self.stats_layout = QVBoxLayout(self.stats_widget)
self.stats_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.stats_layout.setContentsMargins(0, 0, 0, 0)
self.stats_layout.setSpacing(12)
@@ -67,16 +76,17 @@ class LibraryInfoWindowView(QWidget):
self.stats_grid_layout.setColumnMinimumWidth(1, 12)
self.stats_grid_layout.setColumnMinimumWidth(3, 12)
self.entries_row: int = 0
self.tags_row: int = 1
self.fields_row: int = 2
self.namespaces_row: int = 3
self.colors_row: int = 4
self.macros_row: int = 5
self.stats_entries_row: int = 0
self.stats_tags_row: int = 1
self.stats_fields_row: int = 2
self.stats_namespaces_row: int = 3
self.stats_colors_row: int = 4
self.stats_macros_row: int = 5
self.labels_col: int = 0
self.values_col: int = 2
self.buttons_col: int = 4
# NOTE: Alternating rows for visual padding
self.stats_labels_col: int = 0
self.stats_values_col: int = 2
self.stats_buttons_col: int = 4
self.entries_label: QLabel = QLabel(Translations["library_info.stats.entries"])
self.entries_label.setAlignment(cell_alignment)
@@ -91,21 +101,43 @@ class LibraryInfoWindowView(QWidget):
self.macros_label: QLabel = QLabel(Translations["library_info.stats.macros"])
self.macros_label.setAlignment(cell_alignment)
self.stats_grid_layout.addWidget(self.entries_label, self.entries_row, self.labels_col)
self.stats_grid_layout.addWidget(self.tags_label, self.tags_row, self.labels_col)
self.stats_grid_layout.addWidget(self.fields_label, self.fields_row, self.labels_col)
self.stats_grid_layout.addWidget(
self.namespaces_label, self.namespaces_row, self.labels_col
self.entries_label,
self.stats_entries_row,
self.stats_labels_col,
)
self.stats_grid_layout.addWidget(
self.tags_label,
self.stats_tags_row,
self.stats_labels_col,
)
self.stats_grid_layout.addWidget(
self.fields_label,
self.stats_fields_row,
self.stats_labels_col,
)
self.stats_grid_layout.addWidget(
self.namespaces_label,
self.stats_namespaces_row,
self.stats_labels_col,
)
self.stats_grid_layout.addWidget(
self.colors_label,
self.stats_colors_row,
self.stats_labels_col,
)
self.stats_grid_layout.addWidget(
self.macros_label,
self.stats_macros_row,
self.stats_labels_col,
)
self.stats_grid_layout.addWidget(self.colors_label, self.colors_row, self.labels_col)
self.stats_grid_layout.addWidget(self.macros_label, self.macros_row, self.labels_col)
self.stats_grid_layout.setRowMinimumHeight(self.entries_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.tags_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.fields_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.namespaces_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.colors_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.macros_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.stats_entries_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.stats_tags_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.stats_fields_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.stats_namespaces_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.stats_colors_row, row_height)
self.stats_grid_layout.setRowMinimumHeight(self.stats_macros_row, row_height)
self.entry_count_label: QLabel = QLabel()
self.entry_count_label.setAlignment(cell_alignment)
@@ -120,21 +152,49 @@ class LibraryInfoWindowView(QWidget):
self.macros_count_label: QLabel = QLabel()
self.macros_count_label.setAlignment(cell_alignment)
self.stats_grid_layout.addWidget(self.entry_count_label, self.entries_row, self.values_col)
self.stats_grid_layout.addWidget(self.tag_count_label, self.tags_row, self.values_col)
self.stats_grid_layout.addWidget(self.field_count_label, self.fields_row, self.values_col)
self.stats_grid_layout.addWidget(
self.namespaces_count_label, self.namespaces_row, self.values_col
self.entry_count_label,
self.stats_entries_row,
self.stats_values_col,
)
self.stats_grid_layout.addWidget(
self.tag_count_label,
self.stats_tags_row,
self.stats_values_col,
)
self.stats_grid_layout.addWidget(
self.field_count_label,
self.stats_fields_row,
self.stats_values_col,
)
self.stats_grid_layout.addWidget(
self.namespaces_count_label,
self.stats_namespaces_row,
self.stats_values_col,
)
self.stats_grid_layout.addWidget(
self.color_count_label,
self.stats_colors_row,
self.stats_values_col,
)
self.stats_grid_layout.addWidget(
self.macros_count_label,
self.stats_macros_row,
self.stats_values_col,
)
self.stats_grid_layout.addWidget(self.color_count_label, self.colors_row, self.values_col)
self.stats_grid_layout.addWidget(self.macros_count_label, self.macros_row, self.values_col)
self.manage_tags_button = QPushButton(Translations["edit.tag_manager"])
self.manage_colors_button = QPushButton(Translations["color_manager.title"])
self.stats_grid_layout.addWidget(self.manage_tags_button, self.tags_row, self.buttons_col)
self.stats_grid_layout.addWidget(
self.manage_colors_button, self.colors_row, self.buttons_col
self.manage_tags_button,
self.stats_tags_row,
self.stats_buttons_col,
)
self.stats_grid_layout.addWidget(
self.manage_colors_button,
self.stats_colors_row,
self.stats_buttons_col,
)
self.stats_layout.addWidget(self.stats_label)
@@ -148,7 +208,246 @@ class LibraryInfoWindowView(QWidget):
QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
)
# Buttons
# Vertical Separator
self.vertical_sep = QFrame()
self.vertical_sep.setFrameShape(QFrame.Shape.VLine)
self.vertical_sep.setFrameShadow(QFrame.Shadow.Plain)
opacity_effect_vert_sep = QGraphicsOpacityEffect(self)
opacity_effect_vert_sep.setOpacity(0.1)
self.vertical_sep.setGraphicsEffect(opacity_effect_vert_sep)
self.body_layout.addWidget(self.vertical_sep)
# Cleanup --------------------------------------------------------------
self.cleanup_widget = QWidget()
self.cleanup_layout = QVBoxLayout(self.cleanup_widget)
self.cleanup_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.cleanup_layout.setContentsMargins(0, 0, 0, 0)
self.cleanup_layout.setSpacing(12)
self.cleanup_label = QLabel(f"<h3>{Translations['library_info.cleanup']}</h3>")
self.cleanup_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.cleanup_grid: QWidget = QWidget()
self.cleanup_grid_layout: QGridLayout = QGridLayout(self.cleanup_grid)
self.cleanup_grid_layout.setContentsMargins(0, 0, 0, 0)
self.cleanup_grid_layout.setSpacing(0)
self.cleanup_grid_layout.setColumnMinimumWidth(1, 12)
self.cleanup_grid_layout.setColumnMinimumWidth(3, 6)
self.cleanup_grid_layout.setColumnMinimumWidth(5, 6)
self.cleanup_layout.addWidget(self.cleanup_label)
self.cleanup_layout.addWidget(self.cleanup_grid)
self.cleanup_unlinked_row: int = 0
self.cleanup_ignored_row: int = 1
self.cleanup_dupe_files_row: int = 2
self.cleanup_section_break_row: int = 3
self.cleanup_legacy_json_row: int = 4
self.cleanup_backups_row: int = 5
# NOTE: Alternating rows for visual padding
self.cleanup_labels_col: int = 0
self.cleanup_values_col: int = 2
self.cleanup_icons_col: int = 4
self.cleanup_buttons_col: int = 6
# Horizontal Separator
self.horizontal_sep = QFrame()
self.horizontal_sep.setFrameShape(QFrame.Shape.HLine)
self.horizontal_sep.setFrameShadow(QFrame.Shadow.Plain)
self.horizontal_sep.setFixedHeight(row_height)
opacity_effect_hor_sep = QGraphicsOpacityEffect(self)
opacity_effect_hor_sep.setOpacity(0.1)
self.horizontal_sep.setGraphicsEffect(opacity_effect_hor_sep)
self.cleanup_grid_layout.addWidget(
self.horizontal_sep,
self.cleanup_section_break_row,
self.cleanup_labels_col,
1,
7,
Qt.AlignmentFlag.AlignVCenter,
)
self.unlinked_icon = QLabel()
unlinked_image: Image.Image = self.driver.rm.get("unlinked_stat") # pyright: ignore[reportAssignmentType]
unlinked_pixmap = QPixmap.fromImage(ImageQt.ImageQt(unlinked_image))
unlinked_pixmap.setDevicePixelRatio(self.devicePixelRatio())
unlinked_pixmap = unlinked_pixmap.scaledToWidth(
math.floor((row_height - icon_margin) * self.devicePixelRatio()),
Qt.TransformationMode.SmoothTransformation,
)
self.unlinked_icon.setPixmap(unlinked_pixmap)
self.ignored_icon = QLabel()
ignored_image: Image.Image = self.driver.rm.get("ignored_stat") # pyright: ignore[reportAssignmentType]
ignored_pixmap = QPixmap.fromImage(ImageQt.ImageQt(ignored_image))
ignored_pixmap.setDevicePixelRatio(self.devicePixelRatio())
ignored_pixmap = ignored_pixmap.scaledToWidth(
math.floor((row_height - icon_margin) * self.devicePixelRatio()),
Qt.TransformationMode.SmoothTransformation,
)
self.ignored_icon.setPixmap(ignored_pixmap)
self.dupe_file_icon = QLabel()
dupe_file_image: Image.Image = self.driver.rm.get("dupe_file_stat") # pyright: ignore[reportAssignmentType]
dupe_file_pixmap = QPixmap.fromImage(
ImageQt.ImageQt(theme_fg_overlay(dupe_file_image, use_alpha=False))
)
dupe_file_pixmap.setDevicePixelRatio(self.devicePixelRatio())
dupe_file_pixmap = dupe_file_pixmap.scaledToWidth(
math.floor((row_height - icon_margin) * self.devicePixelRatio()),
Qt.TransformationMode.SmoothTransformation,
)
self.dupe_file_icon.setPixmap(dupe_file_pixmap)
self.cleanup_grid_layout.addWidget(
self.unlinked_icon,
self.cleanup_unlinked_row,
self.cleanup_icons_col,
)
self.cleanup_grid_layout.addWidget(
self.ignored_icon,
self.cleanup_ignored_row,
self.cleanup_icons_col,
)
self.cleanup_grid_layout.addWidget(
self.dupe_file_icon,
self.cleanup_dupe_files_row,
self.cleanup_icons_col,
)
self.unlinked_label: QLabel = QLabel(Translations["library_info.cleanup.unlinked"])
self.unlinked_label.setAlignment(cell_alignment)
self.ignored_label: QLabel = QLabel(Translations["library_info.cleanup.ignored"])
self.ignored_label.setAlignment(cell_alignment)
self.dupe_files_label: QLabel = QLabel(Translations["library_info.cleanup.dupe_files"])
self.dupe_files_label.setAlignment(cell_alignment)
self.legacy_json_label: QLabel = QLabel(Translations["library_info.cleanup.legacy_json"])
self.legacy_json_label.setAlignment(cell_alignment)
self.backups_label: QLabel = QLabel(Translations["library_info.cleanup.backups"])
self.backups_label.setAlignment(cell_alignment)
self.cleanup_grid_layout.addWidget(
self.unlinked_label,
self.cleanup_unlinked_row,
self.cleanup_labels_col,
)
self.cleanup_grid_layout.addWidget(
self.ignored_label,
self.cleanup_ignored_row,
self.cleanup_labels_col,
)
self.cleanup_grid_layout.addWidget(
self.dupe_files_label,
self.cleanup_dupe_files_row,
self.cleanup_labels_col,
)
self.cleanup_grid_layout.addWidget(
self.legacy_json_label,
self.cleanup_legacy_json_row,
self.cleanup_labels_col,
)
self.cleanup_grid_layout.addWidget(
self.backups_label,
self.cleanup_backups_row,
self.cleanup_labels_col,
)
self.cleanup_grid_layout.setRowMinimumHeight(self.cleanup_unlinked_row, row_height)
self.cleanup_grid_layout.setRowMinimumHeight(self.cleanup_ignored_row, row_height)
self.cleanup_grid_layout.setRowMinimumHeight(self.cleanup_dupe_files_row, row_height)
self.cleanup_grid_layout.setRowMinimumHeight(self.cleanup_legacy_json_row, row_height)
self.cleanup_grid_layout.setRowMinimumHeight(self.cleanup_backups_row, row_height)
self.unlinked_count_label: QLabel = QLabel()
self.unlinked_count_label.setAlignment(cell_alignment)
self.ignored_count_label: QLabel = QLabel()
self.ignored_count_label.setAlignment(cell_alignment)
self.dupe_files_count_label: QLabel = QLabel()
self.dupe_files_count_label.setAlignment(cell_alignment)
self.legacy_json_status_label: QLabel = QLabel()
self.legacy_json_status_label.setAlignment(cell_alignment)
self.backups_count_label: QLabel = QLabel()
self.backups_count_label.setAlignment(cell_alignment)
self.cleanup_grid_layout.addWidget(
self.unlinked_count_label,
self.cleanup_unlinked_row,
self.cleanup_values_col,
)
self.cleanup_grid_layout.addWidget(
self.ignored_count_label,
self.cleanup_ignored_row,
self.cleanup_values_col,
)
self.cleanup_grid_layout.addWidget(
self.dupe_files_count_label,
self.cleanup_dupe_files_row,
self.cleanup_values_col,
)
self.cleanup_grid_layout.addWidget(
self.legacy_json_status_label,
self.cleanup_legacy_json_row,
self.cleanup_values_col,
)
self.cleanup_grid_layout.addWidget(
self.backups_count_label,
self.cleanup_backups_row,
self.cleanup_values_col,
)
self.fix_unlinked_entries = QPushButton(Translations["menu.tools.fix_unlinked_entries"])
self.fix_ignored_entries = QPushButton(Translations["menu.tools.fix_ignored_entries"])
self.fix_dupe_files = QPushButton(Translations["menu.tools.fix_duplicate_files"])
self.view_legacy_json_file = QPushButton(open_file_str())
self.open_backups_folder = QPushButton(Translations["menu.file.open_backups_folder"])
self.cleanup_grid_layout.addWidget(
self.fix_unlinked_entries,
self.cleanup_unlinked_row,
self.cleanup_buttons_col,
)
self.cleanup_grid_layout.addWidget(
self.fix_ignored_entries,
self.cleanup_ignored_row,
self.cleanup_buttons_col,
)
self.cleanup_grid_layout.addWidget(
self.fix_dupe_files,
self.cleanup_dupe_files_row,
self.cleanup_buttons_col,
)
self.cleanup_grid_layout.addWidget(
self.view_legacy_json_file,
self.cleanup_legacy_json_row,
self.cleanup_buttons_col,
)
self.cleanup_grid_layout.addWidget(
self.open_backups_folder,
self.cleanup_backups_row,
self.cleanup_buttons_col,
)
self.body_layout.addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
)
self.body_layout.addWidget(self.cleanup_widget)
self.body_layout.addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
)
# Details --------------------------------------------------------------
self.details_container = QWidget()
self.details_layout = QHBoxLayout(self.details_container)
self.details_layout.setContentsMargins(6, 0, 6, 0)
opacity_effect_details = QGraphicsOpacityEffect(self)
opacity_effect_details.setOpacity(0.5)
self.version_label = QLabel()
self.version_label.setGraphicsEffect(opacity_effect_details)
self.details_layout.addWidget(self.version_label)
# Buttons --------------------------------------------------------------
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
@@ -162,4 +461,5 @@ class LibraryInfoWindowView(QWidget):
self.root_layout.addWidget(self.body_widget)
self.root_layout.addStretch(1)
self.root_layout.addStretch(2)
self.root_layout.addWidget(self.details_container)
self.root_layout.addWidget(self.button_container)

View File

@@ -59,6 +59,7 @@ from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories, MediaType
from tagstudio.core.palette import UI_COLORS, ColorType, UiColor, get_ui_color
from tagstudio.core.utils.encoding import detect_char_encoding
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers.blender_thumbnailer import blend_thumb
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.helpers.file_tester import is_readable_video
@@ -1436,7 +1437,9 @@ class ThumbRenderer(QObject):
if (
image
and Ignore.compiled_patterns
and Ignore.compiled_patterns.match(filepath.relative_to(self.lib.library_dir))
and Ignore.compiled_patterns.match(
filepath.relative_to(unwrap(self.lib.library_dir))
)
):
image = render_ignored((adj_size, adj_size), pixel_ratio, image)
except TypeError:

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -48,16 +48,11 @@
"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.delete": "Unverknüpfte Einträge löschen",
"entries.unlinked.delete.confirm": "Sind Sie sicher, dass Sie die folgenden {count} Einträge löschen wollen?",
"entries.unlinked.delete.deleting": "Einträge werden gelöscht",
"entries.unlinked.delete.deleting_count": "Lösche {idx}/{count} unverknüpfte Einträge",
"entries.unlinked.delete_alt": "Unverknüpfte Einträge &löschen",
"entries.remove.plural.confirm": "Sind Sie sicher, dass Sie die folgenden {count} Einträge löschen wollen?",
"entries.generic.remove.removing": "Einträge werden gelöscht",
"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.<br><br>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.missing_count.none": "Unverknüpfte Einträge: -",
"entries.unlinked.missing_count.some": "Unverknüpfte Einträge: {count}",
"entries.unlinked.refresh_all": "Alle aktualisie&ren",
"entries.unlinked.relink.attempting": "Versuche {idx}/{missing_count} Einträge wieder zu verknüpfen, {fixed_count} bereits erfolgreich wieder verknüpft",
"entries.unlinked.unlinked_count": "Unverknüpfte Einträge: {count}",
"entries.unlinked.relink.attempting": "Versuche {index}/{unlinked_count} Einträge wieder zu verknüpfen, {fixed_count} bereits erfolgreich wieder verknüpft",
"entries.unlinked.relink.manual": "&Manuell Neuverknüpfen",
"entries.unlinked.relink.title": "Einträge werden neuverknüpft",
"entries.unlinked.scanning": "Bibliothek wird nach nicht verknüpften Einträgen durchsucht...",

View File

@@ -40,29 +40,35 @@
"entries.duplicate.merge": "Merge Duplicate Entries",
"entries.duplicate.refresh": "Refresh Duplicate Entries",
"entries.duplicates.description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.",
"entries.generic.refresh_alt": "&Refresh",
"entries.generic.remove.removing_count": "Removing {count} Entries...",
"entries.generic.remove.removing": "Removing Entries",
"entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.",
"entries.ignored.ignored_count": "Ignored Entries: {count}",
"entries.ignored.remove_alt": "Remo&ve Ignored Entries",
"entries.ignored.remove": "Remove Ignored Entries",
"entries.ignored.scanning": "Scanning Library for Ignored Entries...",
"entries.ignored.title": "Fix Ignored Entries",
"entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?",
"entries.mirror.label": "Mirroring {idx}/{total} Entries...",
"entries.mirror.title": "Mirroring Entries",
"entries.mirror.window_title": "Mirror Entries",
"entries.mirror": "&Mirror",
"entries.remove.plural.confirm": "Are you sure you want to remove these <b>{count}</b> 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.delete_alt": "De&lete Unlinked Entries",
"entries.unlinked.delete.confirm": "Are you sure you want to delete the following {count} entries?",
"entries.unlinked.delete.deleting_count": "Deleting {idx}/{count} Unlinked Entries",
"entries.unlinked.delete.deleting": "Deleting Entries",
"entries.unlinked.delete": "Delete Unlinked Entries",
"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.<br><br>Unlinked entries may be automatically relinked via searching your directories or deleted if desired.",
"entries.unlinked.missing_count.none": "Unlinked Entries: N/A",
"entries.unlinked.missing_count.some": "Unlinked Entries: {count}",
"entries.unlinked.refresh_all": "&Refresh All",
"entries.unlinked.relink.attempting": "Attempting to Relink {idx}/{missing_count} Entries, {fixed_count} Successfully Relinked",
"entries.unlinked.relink.attempting": "Attempting to Relink {index}/{unlinked_count} Entries, {fixed_count} Successfully Relinked",
"entries.unlinked.relink.manual": "&Manual Relink",
"entries.unlinked.relink.title": "Relinking Entries",
"entries.unlinked.remove_alt": "Remo&ve Unlinked Entries",
"entries.unlinked.remove": "Remove Unlinked Entries",
"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.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field.copy": "Copy Field",
@@ -71,7 +77,6 @@
"file.date_added": "Date Added",
"file.date_created": "Date Created",
"file.date_modified": "Date Modified",
"file.path": "File Path",
"file.dimensions": "Dimensions",
"file.duplicates.description": "TagStudio supports importing DupeGuru results to manage duplicate files.",
"file.duplicates.dupeguru.advice": "After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's \"Fix Unlinked Entries\" feature in the Tools menu in order to delete the unlinked Entries.",
@@ -91,6 +96,7 @@
"file.open_location.generic": "Show file in file explorer",
"file.open_location.mac": "Reveal in Finder",
"file.open_location.windows": "Show in File Explorer",
"file.path": "File Path",
"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.",
@@ -115,17 +121,21 @@
"generic.missing": "Missing",
"generic.navigation.back": "Back",
"generic.navigation.next": "Next",
"generic.no": "No",
"generic.none": "None",
"generic.overwrite_alt": "&Overwrite",
"generic.overwrite": "Overwrite",
"generic.paste": "Paste",
"generic.recent_libraries": "Recent Libraries",
"generic.remove_alt": "&Remove",
"generic.remove": "Remove",
"generic.rename_alt": "&Rename",
"generic.rename": "Rename",
"generic.reset": "Reset",
"generic.save": "Save",
"generic.skip_alt": "&Skip",
"generic.skip": "Skip",
"generic.yes": "Yes",
"home.search_entries": "Search Entries",
"home.search_library": "Search Library",
"home.search_tags": "Search Tags",
@@ -162,6 +172,12 @@
"json_migration.title.old_lib": "<h2>v9.4 Library</h2>",
"json_migration.title": "Save Format Migration: \"{path}\"",
"landing.open_create_library": "Open/Create Library {shortcut}",
"library_info.cleanup.backups": "Library Backups:",
"library_info.cleanup.dupe_files": "Duplicate Files:",
"library_info.cleanup.ignored": "Ignored Entries:",
"library_info.cleanup.legacy_json": "Leftover Legacy Library:",
"library_info.cleanup.unlinked": "Unlinked Entries:",
"library_info.cleanup": "Cleanup",
"library_info.stats.colors": "Tag Colors:",
"library_info.stats.entries": "Entries:",
"library_info.stats.fields": "Fields:",
@@ -170,6 +186,7 @@
"library_info.stats.tags": "Tags:",
"library_info.stats": "Statistics",
"library_info.title": "Library '{library_dir}'",
"library_info.version": "Library Format Version: {version}",
"library_object.name_required": "Name (Required)",
"library_object.name": "Name",
"library_object.slug_required": "ID Slug (Required)",
@@ -201,6 +218,7 @@
"menu.file.missing_library.message": "The location of the library \"{library}\" cannot be found.",
"menu.file.missing_library.title": "Missing Library",
"menu.file.new_library": "New Library",
"menu.file.open_backups_folder": "Open Backups Folder",
"menu.file.open_create_library": "&Open/Create Library",
"menu.file.open_library": "Open Library",
"menu.file.open_recent_library": "Open Recent",
@@ -214,7 +232,8 @@
"menu.macros": "&Macros",
"menu.select": "Select",
"menu.settings": "Settings...",
"menu.tools.fix_duplicate_files": "Fix Duplicate &Files",
"menu.tools.fix_duplicate_files": "Fix &Duplicate Files",
"menu.tools.fix_ignored_entries": "Fix &Ignored Entries",
"menu.tools.fix_unlinked_entries": "Fix &Unlinked Entries",
"menu.tools": "&Tools",
"menu.view.decrease_thumbnail_size": "Decrease Thumbnail Size",
@@ -236,34 +255,34 @@
"select.clear": "Clear Selection",
"select.inverse": "Invert Selection",
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
"settings.dateformat.english": "English",
"settings.dateformat.international": "International",
"settings.dateformat.label": "Date Format",
"settings.dateformat.system": "System",
"settings.filepath.label": "Filepath Visibility",
"settings.filepath.option.full": "Show Full Paths",
"settings.filepath.option.name": "Show Filenames Only",
"settings.filepath.option.relative": "Show Relative Paths",
"settings.generate_thumbs": "Thumbnail Generation",
"settings.global": "Global Settings",
"settings.hourformat.label": "24-Hour Time",
"settings.language": "Language",
"settings.library": "Library Settings",
"settings.open_library_on_start": "Open Library on Start",
"settings.generate_thumbs": "Thumbnail Generation",
"settings.page_size": "Page Size",
"settings.restart_required": "Please restart TagStudio for changes to take effect.",
"settings.show_filenames_in_grid": "Show Filenames in Grid",
"settings.show_recent_libraries": "Show Recent Libraries",
"settings.tag_click_action.label": "Tag Click Action",
"settings.tag_click_action.add_to_search": "Add Tag to Search",
"settings.tag_click_action.label": "Tag Click Action",
"settings.tag_click_action.open_edit": "Edit Tag",
"settings.tag_click_action.set_search": "Search for Tag",
"settings.theme.dark": "Dark",
"settings.theme.label": "Theme:",
"settings.theme.light": "Light",
"settings.theme.system": "System",
"settings.dateformat.label": "Date Format",
"settings.dateformat.system": "System",
"settings.dateformat.english": "English",
"settings.dateformat.international": "International",
"settings.hourformat.label": "24-Hour Time",
"settings.zeropadding.label": "Date Zero-Padding",
"settings.title": "Settings",
"settings.zeropadding.label": "Date Zero-Padding",
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"sorting.mode.random": "Random",

View File

@@ -48,16 +48,11 @@
"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.delete": "Eliminar entradas no vinculadas",
"entries.unlinked.delete.confirm": "¿Está seguro de que desea eliminar las siguientes {count} entradas?",
"entries.unlinked.delete.deleting": "Eliminando entradas",
"entries.unlinked.delete.deleting_count": "Eliminando {idx}/{count} entradas no vinculadas",
"entries.unlinked.delete_alt": "Eliminar entradas no vinculadas",
"entries.remove.plural.confirm": "¿Está seguro de que desea eliminar las siguientes {count} entradas?",
"entries.generic.remove.removing": "Eliminando entradas",
"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. <br><br>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.missing_count.none": "Entradas no vinculadas: N/A",
"entries.unlinked.missing_count.some": "Entradas no vinculadas: {count}",
"entries.unlinked.refresh_all": "&Recargar todo",
"entries.unlinked.relink.attempting": "Intentando volver a vincular {idx}/{missing_count} Entradas, {fixed_count} Reenlazado correctamente",
"entries.unlinked.unlinked_count": "Entradas no vinculadas: {count}",
"entries.unlinked.relink.attempting": "Intentando volver a vincular {index}/{unlinked_count} Entradas, {fixed_count} Reenlazado correctamente",
"entries.unlinked.relink.manual": "&Reenlace manual",
"entries.unlinked.relink.title": "Volver a vincular las entradas",
"entries.unlinked.scanning": "Buscando entradas no enlazadas en la biblioteca...",

View File

@@ -48,16 +48,11 @@
"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.delete": "Burahin ang Mga Hindi Naka-link na Entry",
"entries.unlinked.delete.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na {count} entry?",
"entries.unlinked.delete.deleting": "Binubura ang Mga Entry",
"entries.unlinked.delete.deleting_count": "Binubura ang {idx}/{count} (mga) Naka-unlink na Entry",
"entries.unlinked.delete_alt": "Burahin ang Mga Naka-unlink na Entry",
"entries.remove.plural.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na {count} entry?",
"entries.generic.remove.removing": "Binubura ang Mga Entry",
"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.<br><br>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.missing_count.none": "Mga Naka-unlink na Entry: N/A",
"entries.unlinked.missing_count.some": "Mga Naka-unlink na Entry: {count}",
"entries.unlinked.refresh_all": "&I-refresh Lahat",
"entries.unlinked.relink.attempting": "Sinusubukang i-link muli ang {idx}/{missing_count} Mga Entry, {fixed_count} Matagumpay na na-link muli",
"entries.unlinked.unlinked_count": "Mga Naka-unlink na Entry: {count}",
"entries.unlinked.relink.attempting": "Sinusubukang i-link muli ang {index}/{unlinked_count} Mga Entry, {fixed_count} Matagumpay na na-link muli",
"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…",

View File

@@ -48,16 +48,11 @@
"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.delete": "Supprimer les Entrées non Liées",
"entries.unlinked.delete.confirm": "Êtes-vous sûr de vouloir supprimer les {count} entrées suivantes?",
"entries.unlinked.delete.deleting": "Suppression des Entrées",
"entries.unlinked.delete.deleting_count": "Suppression des Entrées non Liées {idx}/{count}",
"entries.unlinked.delete_alt": "Supprimer les Entrées non liées",
"entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les {count} entrées suivantes?",
"entries.generic.remove.removing": "Suppression des Entrées",
"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é. <br><br>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.missing_count.none": "Entrées non Liées : N/A",
"entries.unlinked.missing_count.some": "Entrées non Liées : {count}",
"entries.unlinked.refresh_all": "&Tout Rafraîchir",
"entries.unlinked.relink.attempting": "Tentative de Reliage de {idx}/{missing_count} Entrées, {fixed_count} ont été Reliées avec Succès",
"entries.unlinked.unlinked_count": "Entrées non Liées : {count}",
"entries.unlinked.relink.attempting": "Tentative de Reliage de {index}/{unlinked_count} Entrées, {fixed_count} ont été Reliées avec Succès",
"entries.unlinked.relink.manual": "&Reliage Manuel",
"entries.unlinked.relink.title": "Reliage des Entrées",
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...",

View File

@@ -48,16 +48,11 @@
"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.delete": "Kapcsolat nélküli elemek törlése",
"entries.unlinked.delete.confirm": "Biztosan törölni akarja az alábbi {count} elemet?",
"entries.unlinked.delete.deleting": "Elemek törlése",
"entries.unlinked.delete.deleting_count": "{count}/{idx}. kapcsolat nélküli elem törlése folyamatban…",
"entries.unlinked.delete_alt": "&Kapcsolat nélküli elemek törlése",
"entries.remove.plural.confirm": "Biztosan törölni akarja az alábbi {count} elemet?",
"entries.generic.remove.removing": "Elemek törlése",
"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.<br><br>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.missing_count.none": "Kapcsolat nélküli elemek: 0",
"entries.unlinked.missing_count.some": "Kapcsolat nélküli elemek: {count}",
"entries.unlinked.refresh_all": "&Az összes frissítése",
"entries.unlinked.relink.attempting": "{missing_count}/{idx} elem újra összekapcsolásának megkísérlése; {fixed_count} elem sikeresen újra összekapcsolva",
"entries.unlinked.unlinked_count": "Kapcsolat nélküli elemek: {count}",
"entries.unlinked.relink.attempting": "{unlinked_count}/{index} elem újra összekapcsolásának megkísérlése; {fixed_count} elem sikeresen újra összekapcsolva",
"entries.unlinked.relink.manual": "Új&ra összekapcsolás kézileg",
"entries.unlinked.relink.title": "Elemek újra összekapcsolása",
"entries.unlinked.scanning": "Kapcsolat nélküli elemek keresése a könyvtárban…",

View File

@@ -48,16 +48,11 @@
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています...",
"entries.running.dialog.title": "新しいファイルエントリを追加",
"entries.tags": "タグ",
"entries.unlinked.delete": "未リンクのエントリを削除",
"entries.unlinked.delete.confirm": "以下の {count} 件のエントリ削除してもよろしいですか?",
"entries.unlinked.delete.deleting": "エントリの削除",
"entries.unlinked.delete.deleting_count": "{count} 件中 {idx} 件の未リンクのエントリを削除しています",
"entries.unlinked.delete_alt": "未リンクのエントリを削除(&L)",
"entries.remove.plural.confirm": "以下の {count} 件のエントリを削除してもよろしいですか?",
"entries.generic.remove.removing": "エントリ削除",
"entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルがTagStudio以外で移動または削除された場合、そのエントリは未リンクとして扱われます。<br><br>未リンクのエントリは、ディレクトリを検索して自動的に再リンクすることも、必要に応じて削除することもできます。",
"entries.unlinked.missing_count.none": "未リンクのエントリを削除",
"entries.unlinked.missing_count.some": "未リンクのエントリ数: {count}",
"entries.unlinked.refresh_all": "すべて再読み込み(&R)",
"entries.unlinked.relink.attempting": "{missing_count} 件中 {idx} 件の項目を再リンク中、{fixed_count} 件を正常に再リンクしました",
"entries.unlinked.unlinked_count": "未リンクのエントリ数: {count}",
"entries.unlinked.relink.attempting": "{unlinked_count} 件中 {index} 件の項目を再リンク中、{fixed_count} 件を正常に再リンクしました",
"entries.unlinked.relink.manual": "手動で再リンク(&M)",
"entries.unlinked.relink.title": "エントリの再リンク",
"entries.unlinked.scanning": "未リンクのエントリをライブラリ内でスキャンしています...",

View File

@@ -48,16 +48,11 @@
"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.delete": "Slett Frakoblede Oppføringer",
"entries.unlinked.delete.confirm": "Er du sikker på at di voø slette følgende {count} oppføringer?",
"entries.unlinked.delete.deleting": "Sletter Oppføringer",
"entries.unlinked.delete.deleting_count": "Sletter {idx}/{count} ulenkede oppføringer",
"entries.unlinked.delete_alt": "&Slett Frakoblede Oppføringer",
"entries.remove.plural.confirm": "Er du sikker på at di voø slette følgende {count} oppføringer?",
"entries.generic.remove.removing": "Sletter Oppføringer",
"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.<br><br>Frakoblede oppføringer kan bli automatisk gjenkoblet ved å søke i mappene dine eller slettet om det er ønsket.",
"entries.unlinked.missing_count.none": "Frakoblede Oppføringer: N/A",
"entries.unlinked.missing_count.some": "Frakoblede Oppføringer: {count}",
"entries.unlinked.refresh_all": "Gjenoppfrisk alle",
"entries.unlinked.relink.attempting": "Forsøker å Gjenkoble {idx}/{missing_count} Oppføringer, {fixed_count} Klart Gjenkoblet",
"entries.unlinked.unlinked_count": "Frakoblede Oppføringer: {count}",
"entries.unlinked.relink.attempting": "Forsøker å Gjenkoble {index}/{unlinked_count} Oppføringer, {fixed_count} Klart Gjenkoblet",
"entries.unlinked.relink.manual": "&Manuell Gjenkobling",
"entries.unlinked.relink.title": "Gjenkobler Oppføringer",
"entries.unlinked.scanning": "Skanner bibliotek for ulenkede oppføringer …",

View File

@@ -48,16 +48,11 @@
"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.delete": "Usuń odłączone wpisy",
"entries.unlinked.delete.confirm": "Jesteś pewien że chcesz usunąć następujące {count} wpisy?",
"entries.unlinked.delete.deleting": "Usuwanie wpisów",
"entries.unlinked.delete.deleting_count": "Usuwanie {idx}/{count} odłączonych wpisów",
"entries.unlinked.delete_alt": "Usuń odłączone wpisy",
"entries.remove.plural.confirm": "Jesteś pewien że chcesz usunąć następujące {count} wpisy?",
"entries.generic.remove.removing": "Usuwanie wpisów",
"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.<br><br>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.missing_count.none": "Odłączone wpisy: brak",
"entries.unlinked.missing_count.some": "Odłączone wpisy: {count}",
"entries.unlinked.refresh_all": "&Odśwież wszystko",
"entries.unlinked.relink.attempting": "Próbowanie ponownego łączenia {idx}/{missing_count} wpisów, {fixed_count} poprawnie połączono ponownie",
"entries.unlinked.unlinked_count": "Odłączone wpisy: {count}",
"entries.unlinked.relink.attempting": "Próbowanie ponownego łączenia {index}/{unlinked_count} wpisów, {fixed_count} poprawnie połączono ponownie",
"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...",

View File

@@ -47,16 +47,11 @@
"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.delete": "Apagar Registos não Referenciados",
"entries.unlinked.delete.confirm": "Tem certeza que deseja apagar os seguintes {count} registos ?",
"entries.unlinked.delete.deleting": "A Apagar Registos",
"entries.unlinked.delete.deleting_count": "A Apagar {idx}/{count} Registos Não Ligadas",
"entries.unlinked.delete_alt": "De&letar Registos Não Ligados",
"entries.remove.plural.confirm": "Tem certeza que deseja apagar os seguintes {count} registos ?",
"entries.generic.remove.removing": "A Apagar Registos",
"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.<br><br>Registos não-referenciados podem ser automaticamente referenciados por pesquisas nos seus diretórios, manualmente pelo utilizador, ou apagado se for desejado.",
"entries.unlinked.missing_count.none": "Registos Não Referenciados: N/A",
"entries.unlinked.missing_count.some": "Registos não referenciados: {count}",
"entries.unlinked.refresh_all": "&Atualizar Tudo",
"entries.unlinked.relink.attempting": "A tentar referenciar {idx}/{missing_count} Registos, {fixed_count} Referenciados com Sucesso",
"entries.unlinked.unlinked_count": "Registos Não Referenciados: {count}",
"entries.unlinked.relink.attempting": "A tentar referenciar {index}/{unlinked_count} Registos, {fixed_count} Referenciados com Sucesso",
"entries.unlinked.relink.manual": "&Referência Manual",
"entries.unlinked.relink.title": "A Referenciar Registos",
"entries.unlinked.scanning": "A escanear biblioteca por registos não referenciados...",

View File

@@ -45,16 +45,11 @@
"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.delete": "Deletar Registros não Referenciados",
"entries.unlinked.delete.confirm": "Tem certeza que deseja deletar os seguintes {count} Registros ?",
"entries.unlinked.delete.deleting": "Deletando Registros",
"entries.unlinked.delete.deleting_count": "Deletando {idx}/{count} Registros Não Linkadas",
"entries.unlinked.delete_alt": "De&letar Registros Não Linkados",
"entries.remove.plural.confirm": "Tem certeza que deseja deletar os seguintes {count} Registros ?",
"entries.generic.remove.removing": "Deletando Registros",
"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.<br><br>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.missing_count.none": "Registros Não Referenciados: N/A",
"entries.unlinked.missing_count.some": "Registros não referenciados: {count}",
"entries.unlinked.refresh_all": "&Atualizar Tudo",
"entries.unlinked.relink.attempting": "Tentando referenciar {idx}/{missing_count} Registros, {fixed_count} Referenciados com Sucesso",
"entries.unlinked.unlinked_count": "Registros Não Referenciados: {count}",
"entries.unlinked.relink.attempting": "Tentando referenciar {index}/{unlinked_count} Registros, {fixed_count} Referenciados com Sucesso",
"entries.unlinked.relink.manual": "&Referência Manual",
"entries.unlinked.relink.title": "Referenciando Registros",
"entries.unlinked.scanning": "Escaneando bibliotecada em busca de registros não referenciados...",

View File

@@ -48,16 +48,11 @@
"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.delete": "Keste tsunaganaijena shiruzmakaban",
"entries.unlinked.delete.confirm": "Du kestetsa afto {count} shiruzmakaban?",
"entries.unlinked.delete.deleting": "Keste shiruzmakaban ima",
"entries.unlinked.delete.deleting_count": "Keste {idx}/{count} tsunaganaijena shiruzmakaban ima",
"entries.unlinked.delete_alt": "Ke&ste tsunaganaijena shiruzmakaban",
"entries.remove.plural.confirm": "Du kestetsa afto {count} shiruzmakaban?",
"entries.generic.remove.removing": "Keste shiruzmakaban ima",
"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 tsunaganaijena.<br><br>Tsunaganaijena shiruzmakaban deki tsunaga gen na suha per mlafukaban fu du os keste li du vil.",
"entries.unlinked.missing_count.none": "Tsunaganaijena shiruzmakaban: Jamnai",
"entries.unlinked.missing_count.some": "Tsunaganaijena shiruzmakaban: {count}",
"entries.unlinked.refresh_all": "&Gotova al gen",
"entries.unlinked.relink.attempting": "Iskat ima na tsunaga gen {idx}/{missing_count} shiruzmakaban, {fixed_count} tsunagajena gen",
"entries.unlinked.unlinked_count": "Tsunaganaijena shiruzmakaban: {count}",
"entries.unlinked.relink.attempting": "Iskat ima na tsunaga gen {index}/{unlinked_count} shiruzmakaban, {fixed_count} tsunagajena gen",
"entries.unlinked.relink.manual": "&Tsunaga gen mit hant",
"entries.unlinked.relink.title": "Tsunaga shiruzmakaban gen",
"entries.unlinked.scanning": "Suha mlafuhuomi ima per tsunaganaijena shiruzmakaban...",

View File

@@ -48,16 +48,11 @@
"entries.running.dialog.new_entries": "Добавление {total} новых записей...",
"entries.running.dialog.title": "Добавление новых записей",
"entries.tags": "Теги",
"entries.unlinked.delete": "Удалить откреплённые записи",
"entries.unlinked.delete.confirm": "Вы уверены, что хотите удалить {count} записей?",
"entries.unlinked.delete.deleting": "Удаление записей",
"entries.unlinked.delete.deleting_count": "Удаление {idx}/{count} откреплённых записей",
"entries.unlinked.delete_alt": "&Удалить откреплённые записи",
"entries.remove.plural.confirm": "Вы уверены, что хотите удалить {count} записей?",
"entries.generic.remove.removing": "Удаление записей",
"entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".<br><br>Откреплённые записи могут быть прикреплены обратно автоматически, либо же удалены если в них нет надобности.",
"entries.unlinked.missing_count.none": "Откреплённых записей: нет",
"entries.unlinked.missing_count.some": "Откреплённых записей: {count}",
"entries.unlinked.refresh_all": "&Обновить Всё",
"entries.unlinked.relink.attempting": "Попытка перепривязать {idx}/{missing_count} записей, {fixed_count} привязано успешно",
"entries.unlinked.unlinked_count": "Откреплённых записей: {count}",
"entries.unlinked.relink.attempting": "Попытка перепривязать {index}/{unlinked_count} записей, {fixed_count} привязано успешно",
"entries.unlinked.relink.manual": "&Ручная привязка",
"entries.unlinked.relink.title": "Привязка записей",
"entries.unlinked.scanning": "Сканирование библиотеки на наличие откреплённых записей...",

View File

@@ -26,13 +26,9 @@
"entries.mirror.title": "Speglar Poster",
"entries.mirror.window_title": "Spegla Poster",
"entries.tags": "Etiketter",
"entries.unlinked.delete": "Radera olänkade poster",
"entries.unlinked.delete.confirm": "Är du säker att du vill radera följande {count} poster?",
"entries.unlinked.delete.deleting": "Raderar poster",
"entries.unlinked.delete.deleting_count": "Raderar {idx}/{count} olänkade poster",
"entries.unlinked.delete_alt": "Radera olänkade poster",
"entries.remove.plural.confirm": "Är du säker att du vill radera följande {count} poster?",
"entries.generic.remove.removing": "Raderar poster",
"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.refresh_all": "Uppdatera alla",
"entries.unlinked.relink.manual": "Länka om manuellt",
"entries.unlinked.relink.title": "Länkar om poster",
"entries.unlinked.scanning": "Skannar bibliotek efter olänkade poster...",

View File

@@ -48,16 +48,11 @@
"entries.running.dialog.new_entries": "{total} புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது ...",
"entries.running.dialog.title": "புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது",
"entries.tags": "குறிச்சொற்கள்",
"entries.unlinked.delete": "இணைக்கப்படாத உள்ளீடுகளை நீக்கு",
"entries.unlinked.delete.confirm": "பின்வரும் உள்ளீடுகளை நிச்சயமாக நீக்க விரும்புகிறீர்களா {count}?",
"entries.unlinked.delete.deleting": "உள்ளீடுகள் நீக்கப்படுகிறது",
"entries.unlinked.delete.deleting_count": "{idx}/{count} இணைக்கப்படாத உள்ளீடுகள் நீக்கப்படுகிறது",
"entries.unlinked.delete_alt": "டி & லெட் இணைக்கப்படாத உள்ளீடுகள்",
"entries.remove.plural.confirm": "பின்வரும் உள்ளீடுகளை நிச்சயமாக நீக்க விரும்புகிறீர்களா {count}?",
"entries.generic.remove.removing": "உள்ளீடுகள் நீக்கப்புகிறது",
"entries.unlinked.description": "ஒவ்வொரு நூலக நுழைவும் உங்கள் கோப்பகங்களில் ஒன்றில் ஒரு கோப்போடு இணைக்கப்பட்டுள்ளது. ஒரு நுழைவுடன் இணைக்கப்பட்ட ஒரு கோப்பு டாக்ச்டுடியோவுக்கு வெளியே நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அது பின்னர் இணைக்கப்படாததாகக் கருதப்படுகிறது.",
"entries.unlinked.missing_count.none": "இணைக்கப்படாத உள்ளீடுகள்: இதற்கில்லை",
"entries.unlinked.missing_count.some": "இணைக்கப்படாத உள்ளீடுகள்: {count}",
"entries.unlinked.refresh_all": "& அனைத்தையும் புதுப்பிக்கவும்",
"entries.unlinked.relink.attempting": "{idx}/{missing_count} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, {fixed_count} மீண்டும் இணைக்கப்பட்டது",
"entries.unlinked.unlinked_count": "இணைக்கப்படாத உள்ளீடுகள்: {count}",
"entries.unlinked.relink.attempting": "{index}/{unlinked_count} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, {fixed_count} மீண்டும் இணைக்கப்பட்டது",
"entries.unlinked.relink.manual": "& கையேடு மறுபரிசீலனை",
"entries.unlinked.relink.title": "உள்ளீடுகள் மீண்டும் இணைக்கப்படுகின்றது",
"entries.unlinked.scanning": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...",

View File

@@ -48,16 +48,11 @@
"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.delete": "o weka e ijo pi ijo lon ala",
"entries.unlinked.delete.confirm": "mi weka e ijo {count}. ni li pona anu seme?",
"entries.unlinked.delete.deleting": "mi weka e ijo",
"entries.unlinked.delete.deleting_count": "mi weka e ijo {idx}/{count} pi ijo lon ala",
"entries.unlinked.delete_alt": "o weka e ijo pi &linja ala",
"entries.remove.plural.confirm": "mi weka e ijo {count}. ni li pona anu seme?",
"entries.generic.remove.removing": "mi weka e ijo",
"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.<br><br>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.missing_count.none": "ijo pi ijo lon ala: N/A",
"entries.unlinked.missing_count.some": "ijo pi ijo lon ala: {count}",
"entries.unlinked.refresh_all": "o lukin sin e ale (&R)",
"entries.unlinked.relink.attempting": "mi o pana e ijo lon tawa ijo {idx}/{missing_count}. mi pana e ijo lon tawa ijo {fixed_count}",
"entries.unlinked.unlinked_count": "ijo pi ijo lon ala: {count}",
"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.relink.manual": "sina o pana e ijo lon tawa ijo (&M)",
"entries.unlinked.relink.title": "mi pana e ijo lon tawa ijo",
"entries.unlinked.scanning": "mi o alasa e ijo pi ijo lon ala...",

View File

@@ -47,16 +47,11 @@
"entries.running.dialog.new_entries": "{total} Yeni Dosya Kaydı Ekleniyor...",
"entries.running.dialog.title": "Yeni Dosya Kayıtları Ekleniyor",
"entries.tags": "Etiketler",
"entries.unlinked.delete": "Kopmuş Kayıtları Sil",
"entries.unlinked.delete.confirm": "{count} tane kayıtları silmek istediğinden emin misin?",
"entries.unlinked.delete.deleting": "Kayıtlar Siliniyor",
"entries.unlinked.delete.deleting_count": "{idx}/{count} Kopmuş Kayıt Siliniyor",
"entries.unlinked.delete_alt": "Kopmuş Kayıtları S&il",
"entries.remove.plural.confirm": "{count} tane kayıtları silmek istediğinden emin misin?",
"entries.generic.remove.removing": "Kayıtlar Siliniyor",
"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.<br><br>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.missing_count.none": "Kopmuş Kayıtlar: Yok",
"entries.unlinked.missing_count.some": "Kopmuş Kayıtlar: {count}",
"entries.unlinked.refresh_all": "&Tümünü Yenile",
"entries.unlinked.relink.attempting": "{idx}/{missing_count} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, {fixed_count} Başarıyla Yeniden Eşleştirildi",
"entries.unlinked.unlinked_count": "Kopmuş Kayıtlar: {count}",
"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.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...",

View File

@@ -48,16 +48,11 @@
"entries.running.dialog.new_entries": "正在加入 {total} 个新文件项目...",
"entries.running.dialog.title": "正在加入新文件项目",
"entries.tags": "标签",
"entries.unlinked.delete": "删除未链接的项目",
"entries.unlinked.delete.confirm": "您确定要删除以下 {count} 个项目?",
"entries.unlinked.delete.deleting": "正在删除项目",
"entries.unlinked.delete.deleting_count": "正在删除 {idx}/{count} 个未链接的项目",
"entries.unlinked.delete_alt": "删除未链接的项目(&l)",
"entries.remove.plural.confirm": "您确定要删除以下 {count} 个项目?",
"entries.generic.remove.removing": "正在删除项目",
"entries.unlinked.description": "每个仓库条目都链接到一个目录中的文件。如果链接到某个条目的文件在TagStudio之外被移动或删除则会被视为未链接。<br><br>未链接的条目可能会通过搜索目录自动重新链接,或者根据需要删除。",
"entries.unlinked.missing_count.none": "未链接的项目: ",
"entries.unlinked.missing_count.some": "未链接的项目: {count}",
"entries.unlinked.refresh_all": "全部刷新(&r)",
"entries.unlinked.relink.attempting": "正在尝试重新链接 {idx}/{missing_count} 个项目, {fixed_count} 个项目成功重链",
"entries.unlinked.unlinked_count": "未链接的项目: {count}",
"entries.unlinked.relink.attempting": "正在尝试重新链接 {index}/{unlinked_count} 个项目, {fixed_count} 个项目成功重链",
"entries.unlinked.relink.manual": "手动重新链接(&m)",
"entries.unlinked.relink.title": "正在重新链接项目",
"entries.unlinked.scanning": "正在扫描仓库以寻找未链接的项目...",

View File

@@ -48,16 +48,11 @@
"entries.running.dialog.new_entries": "正在加入 {total} 個新檔案項目...",
"entries.running.dialog.title": "正在加入新檔案項目",
"entries.tags": "標籤",
"entries.unlinked.delete_alt": "刪除未連接項目",
"entries.unlinked.delete.confirm": "您確定要刪除 {count} 個項目嗎?",
"entries.unlinked.delete.deleting_count": "正在刪除 {idx}/{count} 個未連接項目",
"entries.unlinked.delete.deleting": "正在刪除項目",
"entries.unlinked.delete": "刪除未連接項目",
"entries.remove.plural.confirm": "您確定要刪除 {count} 個項目嗎?",
"entries.generic.remove.removing": "正在刪除項目",
"entries.unlinked.description": "每個文件庫的項目都連接到您的其中一個檔案,如果一個已連接的檔案被刪除或移出 TagStudio那麼這個項目會被歸類為「未連接」。<br><br>您可以透過搜尋您的檔案來讓未連接的項目自動重新連接,或者自動刪除這些未連接項目。",
"entries.unlinked.missing_count.none": "未連接項目:",
"entries.unlinked.missing_count.some": "未連接項目:{count}",
"entries.unlinked.refresh_all": "重新整理全部 (&R)",
"entries.unlinked.relink.attempting": "正在嘗試重新連接 {idx}/{missing_count} 個項目,已成功重新連接 {fixed_count} 個",
"entries.unlinked.unlinked_count": "未連接項目:{count}",
"entries.unlinked.relink.attempting": "正在嘗試重新連接 {index}/{unlinked_count} 個項目,已成功重新連接 {fixed_count}",
"entries.unlinked.relink.manual": "手動重新連接 (&M)",
"entries.unlinked.relink.title": "正在重新連接",
"entries.unlinked.scanning": "正在掃描文件庫中的未連接項目...",

View File

@@ -9,8 +9,8 @@ import pytest
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.missing_files import MissingRegistry
from tagstudio.core.utils.types import unwrap
from tagstudio.core.utils.unlinked_registry import UnlinkedRegistry
CWD = Path(__file__).parent
@@ -18,16 +18,16 @@ CWD = Path(__file__).parent
# NOTE: Does this test actually work?
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_refresh_missing_files(library: Library):
registry = MissingRegistry(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_missing_files()) == [0, 1]
assert list(registry.refresh_unlinked_files()) == [0, 1]
# neither of the library entries exist
assert len(registry.missing_file_entries) == 2
assert len(registry.unlinked_entries) == 2
# iterate through two files
assert list(registry.fix_unlinked_entries()) == [0, 1]

View File

@@ -0,0 +1,19 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import structlog
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.resource_manager import ResourceManager
logger = structlog.get_logger()
def test_get():
rm = ResourceManager()
for res in rm._map: # pyright: ignore[reportPrivateUsage]
assert rm.get(res), f"Could not get resource '{res}'"
assert unwrap(rm.get_path(res)).exists(), f"Filepath for resource '{res}' does not exist"

View File

@@ -9,6 +9,9 @@ from pathlib import Path
import pytest
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.library.alchemy.constants import (
SQL_FILENAME,
)
from tagstudio.core.library.alchemy.library import Library
CWD = Path(__file__)
@@ -36,8 +39,8 @@ def test_library_migrations(path: str):
temp_path_ts = temp_path / TS_FOLDER_NAME
temp_path_ts.mkdir(exist_ok=True)
shutil.copy(
original_path / TS_FOLDER_NAME / Library.SQL_FILENAME,
temp_path / TS_FOLDER_NAME / Library.SQL_FILENAME,
original_path / TS_FOLDER_NAME / SQL_FILENAME,
temp_path / TS_FOLDER_NAME / SQL_FILENAME,
)
try: