Compare commits

..

3 Commits

Author SHA1 Message Date
Travis Abendshien 8610cb41af feat!: add file date metadata to db 2026-08-16 12:12:28 -07:00
Travis Abendshien 17cf87a4fa fix(docs): fix changelog header level 2026-08-16 11:57:48 -07:00
Sola-ris 3fe7922642 feat: add per-tag category display options (#1470)
* feat: add per-tag display options.

* feat: bump DB_VERSION to 301, add migration.

* tests: add tests.

* docs: document cateogry display options.

* docs: document library versions 300 and 301.

* fix: resolve ruff and pyright issues.

* fix: redo migration in the new style.

* fix: Remove unnecessary @staticmethod decorators.

* fix: Bump library version to 400.

* fix: Fix color order in colored_radio_button_style.

* fix: Commit the library produced by pytest.

* test: Add test for removing a category during tag creation.

* fix: Fix removing categories during tag creation.

* test: Add test for adding another tag after removing an existing category.

* fix: Fix adding another tag after removing an existing category.

* feat: Add separators between widgets.

* docs: Fix version in library-changes.md.

* test: Test removing a category inherited both directly and indirectly.

* fix: Fix removing a category inherited both directly and indirectly.

---------

Co-authored-by: Travis Abendshien <46939827+cyanvoxel@users.noreply.github.com>
2026-08-16 11:54:04 -07:00
26 changed files with 955 additions and 150 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ toc_depth: 2
# :material-script-text: Changelog
### 9.6.3 <small>August 15th, 2026</small>
## 9.6.3 <small>August 15th, 2026</small>
This update includes some critical library bugfixes along with a handful QoL tweaks and additions to the tag/field search bars. The [documentation](https://docs.tagstud.io/usage/#tagging) on this feature has been updated to include the new improvements.
+17
View File
@@ -201,3 +201,20 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| 95e2fe7b4449951c385e35a2e13f0c1925f1f98e | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
#### Version 300
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- |-------------------------------------------------------------------------| ------ |
| 51a9c16f50ca785d810911d2d0c83fa33eb1c0ae | [v9.6.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.2) | SQLite |
- Drops `folder` columns from the `entries` table.
- Drops the unused `folders` table.
#### Version 400
| Added in Commit | Introduced in Release | Format |
|-----------------|-----------------------| ------ |
| TBD | TBD | SQLite |
- Adds the `category_exclusion` table.
+2
View File
@@ -106,6 +106,8 @@ This means that duplicates of tags can appear on entries if the tag inherits fro
![Tag Category Example](assets/tag_categories_example.png)
If you don't want a tag to appear in one, more, or even all the applicable categories, simply uncheck the category in the "Edit Tag" panel.
### Built-In Tags and Categories
The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
@@ -14,14 +14,14 @@ JSON_FILENAME: str = "ts_library.json"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 300
DB_VERSION: int = 400
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS tag_id
UNION
SELECT tp.child_id AS tag_id
FROM tag_parents tp
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
)
SELECT * FROM ChildTags;
@@ -20,3 +20,10 @@ class TagEntry(Base):
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)
class CategoryExclusion(Base):
__tablename__ = "category_exclusions"
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
category_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
+114 -8
View File
@@ -82,7 +82,8 @@ from tagstudio.core.library.alchemy.fields import (
TextField,
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagEntry, TagParent
from tagstudio.core.library.alchemy.metadata import FileMetadata
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
from tagstudio.core.library.alchemy.models import (
Entry,
@@ -95,6 +96,7 @@ from tagstudio.core.library.alchemy.models import (
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.stat import get_date_created, get_date_modified
from tagstudio.core.utils.types import unwrap
if TYPE_CHECKING:
@@ -540,7 +542,11 @@ class Library:
return entry
def get_entry_full(
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
self,
entry_id: int,
with_fields: bool = True,
with_tags: bool = True,
with_metadata: bool = True,
) -> Entry | None:
"""Load entry and join with all joins and all tags."""
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
@@ -570,6 +576,11 @@ class Library:
)
)
if with_metadata:
entry_stmt = entry_stmt.outerjoin(Entry.file_metadata).options(
selectinload(Entry.file_metadata),
)
start_time = time.time()
entry = session.scalar(entry_stmt)
if with_tags:
@@ -764,10 +775,79 @@ class Library:
session.query(Entry).where(Entry.id.in_(sub_list)).delete()
session.commit()
def has_entry_with_path(self, path: Path) -> bool:
"""Check if an entry with this path is in the library."""
def get_entry_id_from_path(self, path: Path) -> int:
"""Attempt to return an Entry ID given a filepath, else return -1."""
with Session(self.engine) as session:
return session.query(exists().where(Entry.path == path)).scalar()
return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1
# def update_entry_file_metadata(
# self, entry_id: int, date_created: datetime | None, date_modified: datetime | None
# ):
# with Session(self.engine) as session:
# stmt = update(FileMetadata).where(
# and_(
# FileMetadata.entry_id == entry_id,
# )
# )
# if date_created:
# stmt = stmt.values(date_created=date_created)
# if date_modified:
# stmt = stmt.values(date_modified=date_modified)
# session.execute(stmt)
# session.commit()
def refresh_file_entry_stats(self, entry_id: int, path: Path | None):
"""Updates a file entry's associated stat() data."""
needs_update = False
entry = self.get_entry_full(
entry_id, with_fields=False, with_tags=False, with_metadata=True
)
if not entry:
return
if not path:
full_path = unwrap(self.library_dir) / entry.path
else:
full_path = unwrap(self.library_dir) / path
logger.info(full_path)
file_date_created = get_date_created(full_path)
file_date_modified = get_date_modified(full_path)
# Log info
if entry.date_created != file_date_created:
logger.info(f"Difference in date_created!: {entry.date_created}/{file_date_created}")
needs_update = True
else:
logger.info("No difference in date_created.")
if entry.date_modified != file_date_modified:
logger.info(f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}")
needs_update = True
else:
logger.info("No difference in date_modified")
if needs_update:
return
else:
logger.info(f"Updating entry file_metadata for {full_path}")
with Session(self.engine) as session:
stmt = update(FileMetadata).where(
and_(
FileMetadata.entry_id == entry_id,
)
)
if file_date_created:
stmt = stmt.values(date_created=file_date_created)
if file_date_modified:
stmt = stmt.values(date_modified=file_date_modified)
session.execute(stmt)
session.commit()
def get_paths(self, limit: int = -1) -> list[str]:
path_strings: list[str] = []
@@ -1085,7 +1165,7 @@ class Library:
Returns True if the action succeeded and False if the path already exists.
"""
if self.has_entry_with_path(path):
if self.get_entry_id_from_path(path) >= 0:
return False
if isinstance(entry_id, Entry):
entry_id = entry_id.id
@@ -1326,6 +1406,7 @@ class Library:
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> Tag | None:
with Session(self.engine, expire_on_commit=False) as session:
try:
@@ -1342,6 +1423,9 @@ class Library:
self.update_aliases(tag, aliases, session)
session.flush()
if exclusion_ids is not None:
self._update_category_exclusion(tag, exclusion_ids, session)
session.commit()
session.expunge(tag)
return tag
@@ -1471,6 +1555,7 @@ class Library:
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
joinedload(Tag.color),
selectinload(Tag.category_exclusions),
)
tag = session.scalar(tags_query.where(Tag.id == tag_id))
@@ -1541,7 +1626,10 @@ class Library:
statement = select(Tag).where(Tag.id.in_(all_tag_ids))
statement = statement.options(
noload(Tag.parent_tags), selectinload(Tag.aliases), joinedload(Tag.color)
noload(Tag.parent_tags),
selectinload(Tag.aliases),
selectinload(Tag.category_exclusions),
joinedload(Tag.color),
)
tags = session.scalars(statement).fetchall()
for tag in tags:
@@ -1620,9 +1708,10 @@ class Library:
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> None:
"""Edit a Tag in the Library."""
self.add_tag(tag, parent_ids, aliases)
self.add_tag(tag, parent_ids, aliases, exclusion_ids)
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
@@ -1745,6 +1834,23 @@ class Library:
)
session.add(parent_tag)
def _update_category_exclusion(
self, tag: Tag, exclusion_ids: list[int] | set[int], session: Session
):
prev_exclusions = session.scalars(
select(CategoryExclusion).where(CategoryExclusion.tag_id == tag.id)
).all()
for exclusion in prev_exclusions:
if exclusion.category_id not in exclusion_ids:
session.delete(exclusion)
else:
exclusion_ids.remove(exclusion.category_id)
for exclusion_id in exclusion_ids:
exclusion = CategoryExclusion(tag_id=tag.id, category_id=exclusion_id)
session.add(exclusion)
def get_version(self, key: str) -> int:
"""Get a version value from the DB.
@@ -0,0 +1,116 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from __future__ import annotations
from datetime import datetime as dt
from pathlib import Path
from typing import TYPE_CHECKING, Any, override
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, null
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.joins import TagParent
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.models import Entry
class FileMetadata(Base):
"""Table that includes file data and metadata obtained from os.stat() for entries."""
__tablename__ = "file_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
# NOTE: These dates are stored as floats because that's their natural form from os.stat()
# and comparisons are quicker without having to convert to/from datetime objects.
date_created: Mapped[float | None]
date_modified: Mapped[float | None]
def __init__(
self,
entry_id: int,
date_created: float | None = None,
date_modified: float | None = None,
) -> None:
super().__init__()
self.entry_id = entry_id
# # Path data
# self.path = path
# self.filename = path.name
# self.suffix = path.suffix.lstrip(".").lower()
# File metadata
self.date_created = date_created # st_birthtime on Windows and Mac, st_ctime on Linux
self.date_modified = date_modified # st_mtime
class ExifMetadata(Base):
"""Contains Exif metadata for a entries."""
__tablename__ = "exif_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
date_taken: Mapped[dt | None]
def __init__(
self,
entry_id: int,
date_taken: dt | None = None,
) -> None:
super().__init__()
self.entry_id = entry_id
self.date_taken = date_taken # Exif.Image.DateTime
class DimensionMetadata(Base):
"""Contains dimension metadata for entries (e.g. image and video files)."""
__tablename__ = "dimension_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
width: Mapped[int] = mapped_column(nullable=False)
height: Mapped[int] = mapped_column(nullable=False)
def __init__(
self,
entry_id: int,
width: int,
height: int,
) -> None:
super().__init__()
self.entry_id = entry_id
self.width = width
self.height = height
class DurationMetadata(Base):
"""Contains duration metadata for entries (e.g. audio and video files)."""
__tablename__ = "duration_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
duration: Mapped[float] = mapped_column(nullable=False)
def __init__(
self,
entry_id: int,
duration: float,
) -> None:
super().__init__()
self.entry_id = entry_id
self.duration = duration
@@ -100,6 +100,7 @@ class DBMigrations:
MigrationTo201, # changes: field tables
MigrationTo202, # changes: tag_parents
MigrationTo300, # changes: deletes folders
MigrationTo400, # changes: add category_exclusions
]
with Session(self.engine) as session:
if self.loaded_db_version > DB_VERSION:
@@ -578,3 +579,23 @@ class MigrationTo300(DBMigration):
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
class MigrationTo400(DBMigration):
version = 400
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
logger.info(fmt_log("Creating category_exclusions table..."))
session.execute(
text("""
CREATE TABLE category_exclusions (
tag_id INTEGER NOT NULL REFERENCES tags(id),
category_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (tag_id, category_id)
)
""")
)
session.flush()
+44 -19
View File
@@ -11,12 +11,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.fields import (
BaseField,
DatetimeField,
TextField,
)
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.fields import BaseField, DatetimeField, TextField
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent
from tagstudio.core.library.alchemy.metadata import FileMetadata
from tagstudio.core.utils.stat import get_date_created, get_date_modified
class Namespace(Base):
@@ -104,6 +102,12 @@ class Tag(Base):
back_populates="parent_tags",
)
disambiguation_id: Mapped[int | None]
category_exclusions: Mapped[set["Tag"]] = relationship(
secondary=CategoryExclusion.__tablename__,
primaryjoin="Tag.id == CategoryExclusion.tag_id",
secondaryjoin="Tag.id == CategoryExclusion.category_id",
back_populates="category_exclusions",
)
__table_args__ = (
ForeignKeyConstraint(
@@ -124,6 +128,10 @@ class Tag(Base):
def alias_ids(self) -> list[int]:
return [tag.id for tag in self.aliases]
@property
def exclusion_ids(self) -> list[int]:
return [tag.id for tag in self.category_exclusions]
def __init__(
self,
name: str,
@@ -137,6 +145,7 @@ class Tag(Base):
disambiguation_id: int | None = None,
is_category: bool = False,
is_hidden: bool = False,
category_exclusions: set["Tag"] | None = None,
):
self.name = name
self.aliases = aliases or set()
@@ -149,6 +158,7 @@ class Tag(Base):
self.is_category = is_category
self.is_hidden = is_hidden
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.category_exclusions = category_exclusions or set()
super().__init__()
@override
@@ -187,12 +197,12 @@ class Entry(Base):
id: Mapped[int] = mapped_column(primary_key=True)
# TODO: Possibly move to FileMetadata table if Entry is split into Entry/FileEntry (see #588)
path: Mapped[Path] = mapped_column(PathType, unique=True)
filename: Mapped[str] = mapped_column()
suffix: Mapped[str] = mapped_column()
date_created: Mapped[dt | None]
date_modified: Mapped[dt | None]
date_added: Mapped[dt | None]
date_added: Mapped[dt | None] # The date this entry was added to the library
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
@@ -205,6 +215,11 @@ class Entry(Base):
cascade="all, delete",
)
file_metadata: Mapped["FileMetadata"] = relationship(
uselist=False,
cascade="all, delete-orphan",
)
@property
def fields(self) -> list[BaseField]:
fields: list[BaseField] = []
@@ -220,28 +235,31 @@ class Entry(Base):
def is_archived(self) -> bool:
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
@property
def date_created(self) -> float | None:
return self.file_metadata.date_created if self.file_metadata else None
@property
def date_modified(self) -> float | None:
return self.file_metadata.date_modified if self.file_metadata else None
def __init__(
self,
path: Path,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_added: dt | None = None,
# date_created: float | None = None,
# date_modified: float | None = None,
path_for_file_metadata: Path | None = None,
) -> None:
super().__init__()
self.path = path
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.path = path
self.filename = path.name
self.suffix = path.suffix.lstrip(".").lower()
# The date the file associated with this entry was created.
# st_birthtime on Windows and Mac, st_ctime on Linux.
self.date_created = date_created
# The date the file associated with this entry was last modified: st_mtime.
self.date_modified = date_modified
# The date this entry was added to the library.
self.date_added = date_added
self.date_added = date_added # The date this entry was added to the library
for field in fields:
if isinstance(field, TextField):
@@ -251,6 +269,13 @@ class Entry(Base):
else:
raise ValueError(f"Invalid field type: {field}")
if path_for_file_metadata:
self.file_metadata = FileMetadata(
entry_id=self.id,
date_created=get_date_created(path_for_file_metadata),
date_modified=get_date_modified(path_for_file_metadata),
)
def has_tag(self, tag: Tag) -> bool:
return tag in self.tags
+12 -2
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
from datetime import datetime as dt
from pathlib import Path
from time import time
import platform
import structlog
from wcmatch import pathlib
@@ -37,11 +38,13 @@ class RefreshTracker:
while index < len(self.files_not_in_library):
yield index
end = min(len(self.files_not_in_library), index + batch_size)
lib_dir = unwrap(self.library.library_dir)
entries = [
Entry(
path=entry_path,
fields=[],
date_added=dt.now(),
path_for_file_metadata=(lib_dir / entry_path),
)
for entry_path in self.files_not_in_library[index:end]
]
@@ -142,8 +145,11 @@ class RefreshTracker:
dir_file_count += 1
self.library.included_files.add(f)
if not self.library.has_entry_with_path(f):
entry_id = self.library.get_entry_id_from_path(f)
if entry_id < 0:
self.files_not_in_library.append(f)
else:
self.library.refresh_file_entry_stats(entry_id, path=f)
end_time_total = time()
yield dir_file_count
@@ -187,8 +193,12 @@ class RefreshTracker:
relative_path = f.relative_to(library_dir)
if not self.library.has_entry_with_path(relative_path):
entry_id = self.library.get_entry_id_from_path(relative_path)
if entry_id < 0:
self.files_not_in_library.append(relative_path)
else:
self.library.refresh_file_entry_stats(entry_id, path=relative_path)
except ValueError:
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
+16
View File
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import platform
from pathlib import Path
def get_date_modified(path: Path) -> float:
return path.stat().st_mtime
def get_date_created(path: Path) -> float:
if platform.system() in {"Windows", "Darwin"}:
return path.stat().st_birthtime
else:
return path.stat().st_ctime
@@ -88,6 +88,7 @@ class TagBoxWidget(TagBoxWidgetView):
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.on_update.emit()
@@ -166,7 +166,10 @@ class TagSearchPanel(SearchPanel[Tag]):
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
if choose_item:
@@ -188,6 +191,7 @@ class TagSearchPanel(SearchPanel[Tag]):
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self.update_items(self.layout().search_field.text())
@@ -158,7 +158,10 @@ class TagSuggestBox(SuggestBox[Tag]):
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._on_item_chosen(tag)
@@ -174,6 +177,7 @@ class TagSuggestBox(SuggestBox[Tag]):
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._update_items(self.layout().search_field.text())
+181 -34
View File
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QFrame,
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -38,6 +39,7 @@ from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
colored_checkbox_style,
colored_radio_button_style,
get_tag_border_color,
get_tag_highlight_color,
@@ -86,9 +88,10 @@ class BuildTagPanel(ModalContent):
self.tag_color_slug: str | None
self.disambiguation_id: int | None
self.parent_ids: set[int] = set()
self.exclusion_ids: set[int] = set()
self.aliases: list[TagAlias] = []
self.setMinimumSize(300, 460)
self.setMinimumSize(300, 640)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
@@ -96,7 +99,6 @@ class BuildTagPanel(ModalContent):
# Name -----------------------------------------------------------------
self.name_widget = QWidget()
self.name_layout = QVBoxLayout(self.name_widget)
self.name_layout.setStretch(1, 1)
self.name_layout.setContentsMargins(0, 0, 0, 0)
self.name_layout.setSpacing(0)
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -111,7 +113,6 @@ class BuildTagPanel(ModalContent):
# Shorthand ------------------------------------------------------------
self.shorthand_widget = QWidget()
self.shorthand_layout = QVBoxLayout(self.shorthand_widget)
self.shorthand_layout.setStretch(1, 1)
self.shorthand_layout.setContentsMargins(0, 0, 0, 0)
self.shorthand_layout.setSpacing(0)
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -123,7 +124,6 @@ class BuildTagPanel(ModalContent):
# Aliases --------------------------------------------------------------
self.aliases_widget = QWidget()
self.aliases_layout = QVBoxLayout(self.aliases_widget)
self.aliases_layout.setStretch(1, 1)
self.aliases_layout.setContentsMargins(0, 0, 0, 0)
self.aliases_layout.setSpacing(0)
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -144,16 +144,14 @@ class BuildTagPanel(ModalContent):
# Parent Tags ----------------------------------------------------------
self.parent_tags_widget = QWidget()
self.parent_tags_widget.setMinimumHeight(128)
self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget)
self.parent_tags_layout.setStretch(1, 1)
self.parent_tags_layout.setContentsMargins(0, 0, 0, 0)
self.parent_tags_layout.setSpacing(0)
self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.disam_button_group = QButtonGroup(self)
self.disam_button_group.setExclusive(False)
self.parent_tags_title = QLabel(Translations["tag.parent_tags"])
self.parent_tags_title = QLabel(header(Translations["tag.parent_tags"], 3))
self.parent_tags_layout.addWidget(self.parent_tags_title)
self.scroll_contents = QWidget()
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
@@ -184,14 +182,40 @@ class BuildTagPanel(ModalContent):
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Categories -----------------------------------------------------------
self.category_widget = QWidget()
self.category_layout = QVBoxLayout(self.category_widget)
self.category_layout.setContentsMargins(0, 0, 0, 0)
self.category_layout.setSpacing(0)
self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.category_layout.addWidget(QLabel(header(Translations["tag.categories"], 3)))
category_subtitle = QLabel(Translations["tag.categories.subtitle"])
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.5)
category_subtitle.setGraphicsEffect(opacity_effect)
self.category_layout.addWidget(category_subtitle)
self.category_scroll_contents = QWidget()
self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents)
self.category_scroll_layout.setContentsMargins(6, 6, 6, 0)
self.category_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.category_scroll_area = QScrollArea()
self.category_scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.category_scroll_area.setWidgetResizable(True)
self.category_scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.category_scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.category_scroll_area.setWidget(self.category_scroll_contents)
self.category_layout.addWidget(self.category_scroll_area)
# Color ----------------------------------------------------------------
self.color_widget = QWidget()
self.color_layout = QVBoxLayout(self.color_widget)
self.color_layout.setStretch(1, 1)
self.color_layout.setContentsMargins(0, 0, 0, 6)
self.color_layout.setSpacing(6)
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.color_title = QLabel(Translations["tag.color"])
self.color_title = QLabel(header(Translations["tag.color"], 3))
self.color_layout.addWidget(self.color_title)
self.color_button: TagColorPreview
try:
@@ -215,7 +239,6 @@ class BuildTagPanel(ModalContent):
# Category -------------------------------------------------------------
self.cat_widget = QWidget()
self.cat_layout = QHBoxLayout(self.cat_widget)
self.cat_layout.setStretch(1, 1)
self.cat_layout.setContentsMargins(0, 0, 0, 0)
self.cat_layout.setSpacing(6)
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -229,7 +252,6 @@ class BuildTagPanel(ModalContent):
# Hidden ---------------------------------------------------------------
self.hidden_widget = QWidget()
self.hidden_layout = QHBoxLayout(self.hidden_widget)
self.hidden_layout.setStretch(1, 1)
self.hidden_layout.setContentsMargins(0, 0, 0, 0)
self.hidden_layout.setSpacing(6)
self.hidden_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -244,16 +266,33 @@ class BuildTagPanel(ModalContent):
self.root_layout.addWidget(self.name_widget)
self.root_layout.addWidget(self.shorthand_widget)
self.root_layout.addWidget(self.aliases_widget)
self.root_layout.addWidget(self.aliases_table)
self.root_layout.addWidget(self.aliases_table, stretch=1)
self.root_layout.addWidget(self.aliases_add_button)
self.root_layout.addWidget(self.parent_tags_widget)
self._add_spaced_separator()
self.root_layout.addWidget(self.parent_tags_widget, stretch=1)
self._add_spaced_separator()
self.root_layout.addWidget(self.category_widget)
self._add_spaced_separator()
self.root_layout.addWidget(self.color_widget)
self._add_spaced_separator()
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
self.root_layout.addWidget(self.cat_widget)
self.root_layout.addWidget(self.hidden_widget)
self.set_tag(tag or Tag(name=Translations["tag.new"]))
def _add_spaced_separator(self) -> None:
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setFrameShadow(QFrame.Shadow.Plain)
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.1)
sep.setGraphicsEffect(opacity_effect)
self.root_layout.addSpacing(6)
self.root_layout.addWidget(sep)
self.root_layout.addSpacing(6)
def backspace(self):
focused_widget = QApplication.focusWidget()
row = self.aliases_table.rowCount()
@@ -285,10 +324,12 @@ class BuildTagPanel(ModalContent):
def _add_parent_tag_callback(self, tag_id: int):
self.parent_ids.add(tag_id)
self.set_parent_tags()
self.set_categories(added_parent_id=tag_id)
def _remove_parent_tag_callback(self, tag_id: int):
self.parent_ids.remove(tag_id)
self.set_parent_tags()
self.set_categories(removed_parent=True)
def _create_alias_callback(self):
alias = TagAlias("", tag_id=self.tag.id)
@@ -315,6 +356,127 @@ class BuildTagPanel(ModalContent):
self.tag_color_slug = None
self.color_button.set_tag_color_group(tag_color_group)
def set_categories(self, added_parent_id: int | None = None, removed_parent: bool = False):
while self.category_scroll_layout.itemAt(0):
self.category_scroll_layout.takeAt(0).widget().deleteLater()
c = QWidget()
layout = QVBoxLayout(c)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(3)
if removed_parent:
tags_by_category: dict[Tag, set[Tag]] = {}
hierarchy = set(self._lib.get_tag_hierarchy(self.parent_ids).values())
for tag in hierarchy:
if tag.is_category:
tags_by_category[tag] = set()
for tag in hierarchy:
for parent in self._lib.get_tag_hierarchy([tag.id]).values():
if parent in tags_by_category:
if tag == parent and parent.id not in self.parent_ids:
continue
tags_by_category[parent].add(tag)
for category, tags in tags_by_category.items():
if len(tags) == 0:
continue
last_tab, next_tab, container = self._build_category_row_widget(category)
layout.addWidget(container)
self.setTabOrder(last_tab, next_tab)
else:
tag_ids = set(self.parent_ids)
if added_parent_id is not None:
tag_ids.add(added_parent_id)
for tag in self._lib.get_tag_hierarchy(tag_ids).values():
if not tag.is_category or tag == self.tag:
continue
last_tab, next_tab, container = self._build_category_row_widget(tag)
layout.addWidget(container)
self.setTabOrder(last_tab, next_tab)
self.category_scroll_layout.addWidget(c)
def _build_category_row_widget(self, category: Tag) -> tuple[QPushButton, QCheckBox, QWidget]:
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self._lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.set_categories()
def on_category_edit(category_tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self._lib, tag=category_tag)
edit_modal = Modal(
build_tag_panel,
self._lib.tag_display_name(category_tag),
"Edit Tag",
is_savable=True,
)
edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel))
edit_modal.show()
def update_category_exclusion(category_tag: Tag, checked: bool) -> None:
if checked:
self.exclusion_ids.remove(category_tag.id)
else:
self.exclusion_ids.add(category_tag.id)
# Add Tag Widget
tag_widget = TagWidget(
category,
library=self._lib,
has_edit=True,
has_remove=False,
)
tag_widget.on_edit.connect(partial(on_category_edit, category))
row.addWidget(tag_widget)
# Add Category Exclusion Tag Button
include_checkbox = QCheckBox()
include_checkbox.setFixedSize(22, 22)
include_checkbox.setToolTip(Translations["tag.categories.tooltip"])
include_checkbox.setStyleSheet(colored_checkbox_style(*self._tag_colors(category)))
if category.id not in self.exclusion_ids:
include_checkbox.setChecked(True)
include_checkbox.toggled.connect(partial(update_category_exclusion, category))
row.addWidget(include_checkbox)
return tag_widget.bg_button, include_checkbox, container
def _tag_colors(self, tag: Tag) -> tuple[QColor, QColor, QColor, QColor]:
primary_color = get_tag_primary_color(tag)
border_color = (
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
)
text_color: QColor
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_tag_text_color(primary_color, highlight_color)
return primary_color, border_color, highlight_color, text_color
def set_parent_tags(self):
while self.parent_tags_scroll_layout.itemAt(0):
self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater()
@@ -346,29 +508,12 @@ class BuildTagPanel(ModalContent):
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
# Init Colors
primary_color = get_tag_primary_color(tag)
border_color = (
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
)
text_color: QColor
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_tag_text_color(primary_color, highlight_color)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self._lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.set_parent_tags()
@@ -395,9 +540,7 @@ class BuildTagPanel(ModalContent):
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
disam_button.setFixedSize(22, 22)
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
disam_button.setStyleSheet(
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
)
disam_button.setStyleSheet(colored_radio_button_style(*self._tag_colors(tag)))
self.disam_button_group.addButton(disam_button)
if is_disambiguation:
@@ -478,6 +621,10 @@ class BuildTagPanel(ModalContent):
self.parent_ids.add(parent_id)
self.set_parent_tags()
for exclusion_id in tag.exclusion_ids:
self.exclusion_ids.add(exclusion_id)
self.set_categories()
try:
self.tag_color_namespace = tag.color_namespace
self.tag_color_slug = tag.color_slug
+1 -1
View File
@@ -185,7 +185,7 @@ class FieldContainers(QWidget):
grandparent_tags: set[Tag] = set()
for parent_tag in parent_tags:
if parent_tag in categories:
if parent_tag in categories and parent_tag.id not in tag.exclusion_ids:
categories[parent_tag].add(tag)
has_category_parent = True
grandparent_tags.update(parent_tag.parent_tags)
+1
View File
@@ -894,6 +894,7 @@ class QtDriver(DriverMixin, QObject):
panel.build_tag(),
set(panel.parent_ids),
set(panel.aliases),
set(panel.exclusion_ids),
),
self.modal.hide(),
)
@@ -118,44 +118,57 @@ def line_edit_style_main() -> str:
def checkbox_style() -> str:
"""Style used for QCheckBoxes."""
"""Style used for common QCheckBoxes."""
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_tag_border_color(primary_color)
highlight_color = get_tag_highlight_color(primary_color)
text_color: QColor = get_tag_text_color(primary_color, highlight_color)
return colored_checkbox_style(
primary_color,
get_tag_border_color(primary_color),
highlight_color,
get_tag_text_color(primary_color, highlight_color),
)
def colored_checkbox_style(
primary_color: QColor,
border_color: QColor,
highlight_color: QColor,
text_color: QColor,
) -> str:
"""Style used for QCheckBoxes."""
return f"""
QCheckBox{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: solid;
border-width: 2px;
}}
QCheckBox::indicator{{
width: 10px;
height: 10px;
border-radius: 2px;
margin: 4px;
}}
QCheckBox::indicator:checked{{
background: rgba{text_color.toTuple()};
}}
QCheckBox::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QCheckBox::focus{{
border-color: rgba{highlight_color.toTuple()};
outline: none;
}}
"""
QCheckBox{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: solid;
border-width: 2px;
}}
QCheckBox::indicator{{
width: 10px;
height: 10px;
border-radius: 2px;
margin: 4px;
}}
QCheckBox::indicator:checked{{
background: rgba{text_color.toTuple()};
}}
QCheckBox::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QCheckBox::focus{{
border-color: rgba{highlight_color.toTuple()};
outline: none;
}}
"""
def colored_radio_button_style(
primary_color: QColor,
text_color: QColor,
border_color: QColor,
highlight_color: QColor,
text_color: QColor,
) -> str:
return f"""
QRadioButton{{
@@ -386,6 +386,9 @@
"tag.add.plural": "Add Tags",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
"tag.categories": "Category Visibility",
"tag.categories.subtitle": "Inherited from Parent Tags",
"tag.categories.tooltip": "Show tag in this category",
"tag.choose_color": "Choose Tag Color",
"tag.color": "Color",
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
+19 -24
View File
@@ -31,7 +31,7 @@
"drop_import.description": "Les fichiers suivants correspondent à des chemins de fichiers déjà existant dans la bibliothèque",
"drop_import.duplicates_choice.plural": "Les chemins d'accès des {count} fichiers suivants existent déjà dans la Bibliothèque.",
"drop_import.duplicates_choice.singular": "Le fichier suivant correspond a un chemin d'accès déjà existant dans la bibliothèque.",
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers",
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers...",
"drop_import.progress.label.plural": "Importation des Nouveaux Fichiers...\n{count} Fichiers Importés.{suffix}",
"drop_import.progress.label.singular": "Importation des Nouveaux Fichiers...\n1 Fichier Importé.{suffix}",
"drop_import.progress.window_title": "Importer des Fichiers",
@@ -41,26 +41,26 @@
"edit.paste_fields": "Coller les Champs",
"edit.tag_manager": "Gérer les Tags",
"entries.duplicate.merge": "Fusion des entrées dupliquées",
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées",
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées...",
"entries.duplicate.refresh": "Rafraichir les Entrées en Doublon",
"entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les tags et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
"entries.generic.refresh_alt": "&Recharger",
"entries.generic.remove.removing": "Suppression des Entrées",
"entries.generic.remove.removing_count": "Suppression de {count} entrées",
"entries.generic.remove.removing_count": "Suppression de {count} entrées...",
"entries.ignored.description": "Les entrées de fichier sont considérées comme « ignorées » si elles ont été ajoutées à la bibliothèque avant que les règles d'ignorance de l'utilisateur (via le fichier « .ts_ignore ») aient été mises à jour pour les exclure. Les fichiers ignorés sont conservés dans la bibliothèque par défaut afin d'éviter toute perte accidentelle de données lors de la mise à jour des règles d'ignorance.",
"entries.ignored.ignored_count": "Entrées Ignorées : {count}",
"entries.ignored.remove": "Supprimer les entrées ignorées",
"entries.ignored.remove_alt": "Supprim&er les entrées ignorées",
"entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque",
"entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque...",
"entries.ignored.title": "Corriger les entrées ignorées",
"entries.mirror": "&Répliquer",
"entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes ?",
"entries.mirror.label": "Réplication de {idx}/{total} Entrées",
"entries.mirror.label": "Réplication de {idx}/{total} Entrées...",
"entries.mirror.title": "Réplication des Entrées",
"entries.mirror.window_title": "Entrées Répliqués",
"entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les <b>{count}</b> entrées suivantes ? Aucun fichier sur votre disque ne sera supprimée.",
"entries.remove.singular.confirm": "Êtes-vous sûr de vouloir supprimer cette entrée de votre bibliothèque ? Aucun fichier sur le disque ne sera supprimé.",
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier",
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...",
"entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier",
"entries.tags": "Tags",
"entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié. <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é.",
@@ -69,7 +69,7 @@
"entries.unlinked.relink.title": "Reliage des Entrées",
"entries.unlinked.remove": "Supprimer les entrées non liées",
"entries.unlinked.remove_alt": "Supprim&er les entrées non liées",
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées",
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...",
"entries.unlinked.search_and_relink": "&Rechercher && Relier",
"entries.unlinked.title": "Réparation des Entrées non Liées",
"entries.unlinked.unlinked_count": "Entrées non Liées : {count}",
@@ -161,14 +161,11 @@
"generic.skip_alt": "&Passer",
"generic.yes": "Oui",
"home.search": "Rechercher",
"home.search.how_to_exit": "(Esc pour Fermer)",
"home.search.view_limit": "Limite d'affichage :",
"home.search_entries": "Recherche",
"home.search_field_templates": "Rechercher un format de champs",
"home.search_field_templates": "Rechercher un format de champs",
"home.search_library": "Rechercher dans la Bibliothèque",
"home.search_or_create_fields": "Rechercher ou Créer des Champs…",
"home.search_or_create_tags": "Rechercher ou Créer des Tags…",
"home.search_tags": "Recherche des Tags…",
"home.search_tags": "Recherche de Tags",
"home.show_hidden_entries": "Afficher les entrées cachées",
"home.thumbnail_size": "Taille de la miniature",
"home.thumbnail_size.extra_large": "Très Grandes Miniatures",
@@ -177,8 +174,8 @@
"home.thumbnail_size.mini": "Mini Miniatures",
"home.thumbnail_size.small": "Petites Miniatures",
"ignore.open_file": "Afficher le fichier \"{ts_ignore}\" sur le Disque",
"json_migration.checking_for_parity": "Vérification de la Parité",
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL",
"json_migration.checking_for_parity": "Vérification de la Parité...",
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL...",
"json_migration.description": "<br>Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie ne sera utilisée <i>que</i> si vous cliquez sur \"Terminer la migration\". <br><br>Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"<b>(!)</b>\" à côté d'elles.<br><center><i>Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.</i></center>",
"json_migration.discrepancies_found": "Divergences Détectées dans la Bibliothèque",
"json_migration.discrepancies_found.description": "Des divergences ont été détectées entre le format d'origine et le format converti de la bibliothèque. Veuillez les examiner et choisir de poursuivre la migration ou de l'annuler.",
@@ -235,7 +232,7 @@
"library.name": "Bibliothèque",
"library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers",
"library.refresh.scanning.singular": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichier Trouvé, {found_count} Nouveaux Fichiers",
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation",
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...",
"library.refresh.title": "Rafraîchissement des Dossiers",
"library.scan_library.title": "Balayage de la Bibliothèque",
"library_info.cleanup": "Nettoyage",
@@ -257,7 +254,7 @@
"library_object.name_required": "Nom (Requis)",
"library_object.slug": "Identifiant unique",
"library_object.slug_required": "ID Slug (Requis)",
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers",
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers...",
"macros.running.dialog.title": "Exécution des Macros sur les Nouvelles Entrées",
"media_player.autoplay": "Lecture automatique",
"media_player.loop": "Lire en boucle",
@@ -287,7 +284,7 @@
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Répertoires à Tags",
"menu.select": "Sélectionner",
"menu.settings": "Paramètres",
"menu.settings": "Paramètres...",
"menu.tools": "&Outils",
"menu.tools.fix_duplicate_files": "Réparer les entrées de &fichiers en double",
"menu.tools.fix_ignored_entries": "Corriger les entrées &Ignorer",
@@ -319,8 +316,6 @@
"settings.dateformat.international": "International",
"settings.dateformat.label": "Format de Date",
"settings.dateformat.system": "Système",
"settings.edit_field_on_add": "Modifier Après avoir Ajouté un Champ",
"settings.edit_tag_on_create": "Modifier Après avoir Créé un Nouveau Tag",
"settings.filepath.label": "Visibilités du Chemin de Fichier",
"settings.filepath.option.full": "Afficher le Chemin Complet",
"settings.filepath.option.name": "Afficher Seulement le Nom du Fichier",
@@ -360,18 +355,18 @@
"sorting.direction.ascending": "Croissant",
"sorting.direction.descending": "Décroissant",
"sorting.mode.random": "Aléatoire",
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"",
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"...",
"status.deleted_file_plural": "Suppression de {count} fichiers!",
"status.deleted_file_singular": "Suppression de 1 fichier!",
"status.deleted_none": "Aucun fichiers supprimer.",
"status.deleted_partial_warning": "Seulement {count} fichier(s) on été supprimé! Vérifier si les fichiers restant ne serais pas manquant ou en cours d'utilisation.",
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"",
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque",
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque...",
"status.library_backup_success": "Bibliothèque sauvegardée au chemin: \"{path}\" ({time_span})",
"status.library_closed": "Bibliothèque fermée ({time_span})",
"status.library_closing": "Fermeture de la Bibliothèque",
"status.library_closing": "Fermeture de la Bibliothèque...",
"status.library_save_success": "Bibliothèque Sauvegardée et Fermée!",
"status.library_search_query": "Rechercher dans la Bibliothèque",
"status.library_search_query": "Rechercher dans la Bibliothèque...",
"status.library_version_expected": "Exceptée:",
"status.library_version_found": "Trouvée:",
"status.library_version_mismatch": "La version de la library ne correspond pas!",
+1 -3
View File
@@ -3,7 +3,6 @@
"about.config_path": "Konfigurációs fájl",
"about.description": "A TagStudio egy fénykép- és fájlkezelő program, mely címkék segítségével nyújt felhasználói szabadságot és rugalmasságot. A TagStudio nem használ jogvédett formátumokat, társfájlokat és nem fordítja a feje tetejére a már létező fájlrendszert.",
"about.documentation": "Dokumentáció",
"about.library_version": "Könyvtárformátum",
"about.module.found": "Telepítve",
"about.modules.title": "Nemkötelező modulok",
"about.title": "A TagStudio névjegye",
@@ -162,7 +161,7 @@
"generic.skip_alt": "&Kihagyás",
"generic.yes": "Igen",
"home.search": "Keresés",
"home.search.how_to_exit": "(Kilépés az Enter billentyűvel)",
"home.search.how_to_exit": "(Kilépés az Esc billentyűvel)",
"home.search.view_limit": "Megtekintési korlát:",
"home.search_entries": "Tételek keresése",
"home.search_field_templates": "Keresés a mezőminták között…",
@@ -330,7 +329,6 @@
"settings.global": "Globális beállítások",
"settings.hourformat.label": "24-órás idő",
"settings.infinite_scroll": "Végtelen görgetés",
"settings.keep_suggest_boxes_open": "Keresőmezők nyitva tartása elemek létrehozása után",
"settings.language": "&Nyelv",
"settings.library": "Könyvtárbeállítások",
"settings.localization": "Nyelv és formátumok",
+1 -20
View File
@@ -1,13 +1,9 @@
{
"about.app_cache_path": "Applicatie Cache Pad",
"about.config_path": "Configuratie Pad",
"about.description": "TagStudio is een applicatie om foto's en bestanden te organiseren, met een onderliggend systeem gebaseerd op tags, dat zich focust op vrijheid en flexibiliteit bieden aan de gebruiker. Geen gepatenteerde programma's of bestandsformaten, geen zeëen aan sidecarbestanden, en je bestaande bestandsstructuur wordt niet volledig overhoop gegooid.",
"about.documentation": "Documentatie",
"about.module.found": "Gevonden",
"about.modules.title": "Optionele Modules",
"about.title": "Over TagSudio",
"about.version": "Versie",
"about.version.latest": "{built_version} (Laatste Uitgave: {latest_version})",
"about.website": "Website",
"app.git": "Git Commit",
"app.pre_release": "Pre-Release",
@@ -47,7 +43,6 @@
"field.mixed_data": "Gemixte Data",
"field.paste": "Veld Plakken",
"field.remove": "Veld Weghalen",
"field_type.text": "Text",
"file.date_added": "Datum Toegevoegd",
"file.date_created": "Datum Aangemaakt",
"file.date_modified": "Datum Aangepast",
@@ -85,16 +80,14 @@
"generic.save": "Opslaan",
"generic.skip": "Overslaan",
"generic.skip_alt": "&Overslaan",
"generic.yes": "Ja",
"home.search": "Zoeken",
"home.search_tags": "Labels Zoeken",
"home.search_tags": "Labels Zoeken",
"home.thumbnail_size": "Miniatuur Grootte",
"home.thumbnail_size.extra_large": "Extra Grote Miniaturen",
"home.thumbnail_size.large": "Grote Miniaturen",
"home.thumbnail_size.medium": "Gemiddelde Minituren",
"home.thumbnail_size.mini": "Mini Miniaturen",
"home.thumbnail_size.small": "Kleine Miniaturen",
"json_migration.creating_database_tables": "SQL databasetabellen worden aangemaakt…",
"json_migration.finish_migration": "Migratie Afronden",
"json_migration.heading.aliases": "Aliassen:",
"json_migration.heading.colors": "Kleuren:",
@@ -103,16 +96,9 @@
"json_migration.heading.shorthands": "Afkortingen:",
"json_migration.migration_complete": "Migratie Afgerond!",
"json_migration.title": "Migratie Formaat Opslaan: \"{path}\"",
"library.refresh.scanning.plural": "Mappen scannen voor nieuwe bestanden...\n{searched_count} bestanden doorgezocht, {found_count} nieuwe bestanden gevonden",
"library.refresh.scanning_preparing": "Mappen scannen voor nieuwe bestanden...\nVoorbereiden...",
"library.refresh.title": "Mappen Verversen",
"library_info.stats": "Statistieken",
"library_info.stats.fields": "Velden:",
"library_info.stats.macros": "Macros:",
"library_info.stats.tags": "Labels:",
"library_object.name": "Naam",
"library_object.name_required": "Naam (vereist)",
"media_player.loop": "Herhalen",
"menu.delete_selected_files_ambiguous": "Bestand(en) verplaatsen naar {trash_term}",
"menu.delete_selected_files_plural": "Bestanden verplaatsen naar {trash_term}",
"menu.delete_selected_files_singular": "Bestand verplaatsen naar {trash_term}",
@@ -121,15 +107,10 @@
"menu.edit.manage_tags": "Labels Beheren",
"menu.edit.new_tag": "Nieuw &Label",
"menu.file": "&Bestand",
"menu.file.missing_library.title": "Missende bibliotheek",
"menu.help": "&Help",
"menu.help.about": "Over",
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Mappen naar Labels",
"menu.select": "Selecteren",
"menu.settings": "Instellingen…",
"menu.window": "Venster",
"preview.ignored": "Genegeerd",
"select.all": "Alles Selecteren",
"select.inverse": "Selectie omkeren",
"sorting.direction.ascending": "Oplopend",
Binary file not shown.
+312
View File
@@ -4,13 +4,16 @@
# pyright: reportPrivateUsage = false
from collections.abc import Callable
from typing import cast
from PySide6.QtWidgets import QCheckBox
from pytestqt.qtbot import QtBot
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagAlias
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.build_tag import BuildTagPanel, CustomTableItem
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
@@ -171,3 +174,312 @@ def test_build_tag_panel_build_tag(qtbot: QtBot, library: Library):
tag: Tag = panel.build_tag()
assert tag.name == Translations["tag.new"]
def test_build_tag_panel_show_category_from_parent(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == parent
def test_build_tag_panel_show_category_from_grandparent(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == grandparent
def test_build_tag_panel_add_category_through_parent(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child = unwrap(library.add_tag(generate_tag("child", id=124)))
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
assert __find_category_tag_widget(panel) is None
child.parent_tags.add(parent)
panel._add_parent_tag_callback(parent.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == parent
def test_build_tag_panel_add_category_through_grandparent(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
child = unwrap(library.add_tag(generate_tag("child", id=124)))
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
assert __find_category_tag_widget(panel) is None
child.parent_tags.add(parent)
panel._add_parent_tag_callback(parent.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == grandparent
def test_build_tag_panel_remove_category_through_parent(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == parent
panel._remove_parent_tag_callback(parent.id)
assert __find_category_tag_widget(panel) is None
def test_build_tag_panel_remove_category_through_grandparent(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == grandparent
panel._remove_parent_tag_callback(parent.id)
assert __find_category_tag_widget(panel) is None
def test_build_tag_panel_exclude_from_category(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
assert len(panel.exclusion_ids) == 0
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
checkbox = __find_include_checkbox(tag_widget)
assert checkbox.isChecked()
checkbox.click()
assert parent.id in panel.exclusion_ids
def test_build_tag_panel_include_in_category(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child = unwrap(
library.add_tag(
generate_tag("child", id=124, parent_tags={parent}, category_exclusions={parent})
)
)
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
assert parent.id in panel.exclusion_ids
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
checkbox = __find_include_checkbox(tag_widget)
assert not checkbox.isChecked()
checkbox.click()
assert len(panel.exclusion_ids) == 0
def test_build_tag_panel_remove_duplicate_category_retained(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
other_parent = unwrap(
library.add_tag(generate_tag("other_parent", id=124, parent_tags={grandparent}))
)
child = unwrap(
library.add_tag(generate_tag("child", id=125, parent_tags={parent, other_parent}))
)
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == grandparent
panel._remove_parent_tag_callback(parent.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == grandparent
def test_build_tag_panel_new_tag_multiple_categories(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
other_parent = unwrap(library.add_tag(generate_tag("other_parent", id=124, is_category=True)))
panel: BuildTagPanel = BuildTagPanel(library)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
panel._add_parent_tag_callback(parent.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
assert tag_widget.tag == parent
panel._add_parent_tag_callback(other_parent.id)
tag_widget = __find_category_tag_widget(panel, 1)
assert tag_widget is not None
assert tag_widget.tag == other_parent
def test_build_tag_panel_category_not_shown_for_self(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
library.add_tag(generate_tag("category", id=123, is_category=True))
panel: BuildTagPanel = BuildTagPanel(library)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
def test_build_tag_panel_remove_inherited_from_multiple_parents_during_tag_creation(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child1 = unwrap(library.add_tag(generate_tag("child1", id=124, parent_tags={parent})))
child2 = unwrap(library.add_tag(generate_tag("child2", id=125, parent_tags={parent})))
panel: BuildTagPanel = BuildTagPanel(library)
qtbot.addWidget(panel)
panel._add_parent_tag_callback(124)
panel._add_parent_tag_callback(125)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(child1.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(child2.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
def test_build_tag_panel_add_different_category_after_removing_other_category(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
category = unwrap(library.add_tag(generate_tag("category", id=123, is_category=True)))
tag = unwrap(library.add_tag(generate_tag("tag", id=124, parent_tags={category})))
other = unwrap(library.add_tag(generate_tag("other", id=125)))
panel: BuildTagPanel = BuildTagPanel(library, tag)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(category.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
panel._add_parent_tag_callback(other.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
def test_build_tag_panel_remove_category_inherited_directly_and_indirectly(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
grandchild = unwrap(
library.add_tag(generate_tag("grandchild", id=125, parent_tags={parent, child}))
)
panel: BuildTagPanel = BuildTagPanel(library, grandchild)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(parent.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(child.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None:
item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index)
while item is not None:
if isinstance(item.widget(), TagWidget):
break
item = item.widget().layout().itemAt(0)
if item is not None:
return cast(TagWidget, item.widget())
return None
def __find_include_checkbox(tag_widget: TagWidget) -> QCheckBox:
layout_item = tag_widget.parentWidget().layout().itemAt(1)
assert layout_item is not None
widget = layout_item.widget()
assert isinstance(widget, QCheckBox)
return widget
+27 -1
View File
@@ -1,8 +1,11 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from pathlib import Path
from tagstudio.core.library.alchemy.library import Library
# pyright: reportPrivateUsage=false
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
@@ -182,3 +185,26 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
assert container.title != "<h4>Tags</h4>"
case _:
pass
def test_exclude_tag_category(
qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag]
):
panel = PreviewPanel(qt_driver)
category_parent = unwrap(generate_tag("category_parent", id=123, is_category=True))
library.add_tag(category_parent)
tag = unwrap(generate_tag("tag", id=124))
library.add_tag(tag, parent_ids={category_parent.id}, exclusion_ids={category_parent.id})
entry = Entry(id=777, path=Path("test.txt"), fields=[])
library.add_entries([entry])
library.add_tags_to_entries(entry.id, tag.id)
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
assert len(panel.containers._containers) == 1
assert panel.containers._containers[0].title == "<h4>Tags</h4>"
+4 -4
View File
@@ -80,9 +80,9 @@ def test_library_add_file(library: Library):
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert not library.has_entry_with_path(entry.path)
assert not library.get_entry_id_from_path(entry.path)
assert library.add_entries([entry])
assert library.has_entry_with_path(entry.path)
assert library.get_entry_id_from_path(entry.path)
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
@@ -338,8 +338,8 @@ def test_merge_entries(library: Library):
entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id))
assert library.merge_entries(entry_a_, entry_b_)
assert not library.has_entry_with_path(Path("a"))
assert library.has_entry_with_path(Path("b"))
assert not library.get_entry_id_from_path(Path("a"))
assert library.get_entry_id_from_path(Path("b"))
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))