mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-09-02 09:09:01 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8610cb41af | |||
| 17cf87a4fa | |||
| 3fe7922642 |
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -106,6 +106,8 @@ This means that duplicates of tags can appear on entries if the tag inherits fro
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}\"?",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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,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.
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user