mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-08-21 11:32:28 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8610cb41af | |||
| 17cf87a4fa | |||
| 3fe7922642 | |||
| 555dae50d4 | |||
| e6d67c26dd |
+3
-3
@@ -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,8 +17,6 @@ This update includes some critical library bugfixes along with a handful QoL twe
|
||||
|
||||
- feat(ui): show library format version in "About" window by @CyanVoxel in 102cfdf4a2e12635f4b6e47259aa23ea95787421
|
||||
|
||||
### Changed
|
||||
|
||||
#### Tag and Field Bars
|
||||
|
||||
- feat(ui): keep tag/field search bars open by default by @CyanVoxel in #1472
|
||||
@@ -26,6 +24,8 @@ This update includes some critical library bugfixes along with a handful QoL twe
|
||||
- feat(ui): use tab and shift+tab to navigate tag/field search bars by @CyanVoxel in #1474
|
||||
- feat(ui): add hint icons to tag/field search bars by @CyanVoxel in #1475
|
||||
|
||||
### Changed
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- refactor: almost final migrations cleanup by @Computerdores in #1456
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.6.3"
|
||||
version = "9.6.4"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
|
||||
@@ -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}\"?",
|
||||
|
||||
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