Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions src/tagstudio/core/library/alchemy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,23 @@
""")


DEFAULT_FIELD_TEMPLATES = (
TextFieldTemplate(name="Title"),
TextFieldTemplate(name="Author"),
TextFieldTemplate(name="Artist"),
TextFieldTemplate(name="URL"),
TextFieldTemplate(name="Description", is_multiline=True),
TextFieldTemplate(name="Notes", is_multiline=True),
TextFieldTemplate(name="Comments", is_multiline=True),
DatetimeFieldTemplate(name="Date"),
)
def default_field_templates() -> tuple[TextFieldTemplate | DatetimeFieldTemplate, ...]:
"""Build a fresh set of default field template instances.

These must be constructed anew on every call rather than shared as module-level
singletons. SQLAlchemy instances remember their persistent identity once they've been
added and flushed to a session; reusing the same instances across multiple `Library`
(and therefore multiple database engines/sessions) causes every `Library` after the
first to silently skip inserting these rows, since SQLAlchemy assumes they already
exist.
"""
return (
TextFieldTemplate(name="Title"),
TextFieldTemplate(name="Author"),
TextFieldTemplate(name="Artist"),
TextFieldTemplate(name="URL"),
TextFieldTemplate(name="Description", is_multiline=True),
TextFieldTemplate(name="Notes", is_multiline=True),
TextFieldTemplate(name="Comments", is_multiline=True),
DatetimeFieldTemplate(name="Date"),
)
4 changes: 2 additions & 2 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_QUERY,
default_field_templates,
)
from tagstudio.core.library.alchemy.db import Base as ModelBase
from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum
Expand Down Expand Up @@ -461,7 +461,7 @@ def create_sqlite_library(
session.flush()

# Add default field templates
for template in DEFAULT_FIELD_TEMPLATES:
for template in default_field_templates():
session.add(template)
session.flush()

Expand Down
4 changes: 2 additions & 2 deletions src/tagstudio/core/library/alchemy/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
default_field_templates,
)
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP, DatetimeField, TextField
from tagstudio.core.library.alchemy.joins import TagParent
Expand Down Expand Up @@ -468,7 +468,7 @@ def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):

# Add default field templates
logger.info(fmt_log("Adding default field templates..."))
for template in DEFAULT_FIELD_TEMPLATES:
for template in default_field_templates():
session.add(template)
session.flush()

Expand Down
6 changes: 5 additions & 1 deletion src/tagstudio/qt/controllers/inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._edit_field(entry.id, entry_field)

self.layout().containers.update_from_entry(self._selected[0])
elif len(self._selected) > 1:
self.layout().containers.update_from_entries(self._selected)

def _edit_field(self, entry_id: int, field: BaseField) -> None:
# TODO: A lot of this code is similar to or straight up shared with FieldContainers.
Expand Down Expand Up @@ -216,6 +218,8 @@ def _add_tag_to_selected(self, tag_id: int) -> None:
self.layout().containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self.layout().containers.update_from_entry(self._selected[0])
elif len(self._selected) > 1:
self.layout().containers.update_from_entries(self._selected)

def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
Expand Down Expand Up @@ -269,7 +273,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._current_stats = None
self.layout().file_attrs.update_multi_selection(len(selected))
self.layout().file_attrs.update_date_label()
self.layout().containers.hide_containers() # TODO: Allow for mixed editing
self.layout().containers.update_from_entries(selected)
self._set_selection_callback()

except Exception as e:
Expand Down
23 changes: 23 additions & 0 deletions src/tagstudio/qt/controllers/tag_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class TagBoxWidget(TagBoxWidgetView):
on_update = Signal()

__entries: list[int] = []
__mixed_only: bool = False

def __init__(self, title: str, driver: QtDriver):
super().__init__(title, driver)
Expand All @@ -35,6 +36,28 @@ def __init__(self, title: str, driver: QtDriver):
def set_entries(self, entries: list[int]) -> None:
self.__entries = entries

def set_mixed_only(self, value: bool) -> None:
"""If True, all tags in this widget are treated as partial-selection tags."""
self.__mixed_only = value

def set_tags(self, tags): # type: ignore[override]
"""Render tags; visually dim those that are not shared across entries."""
tags_ = list(tags)

# When mixed_only is set, all tags in this widget are considered partial.
partial_tag_ids: set[int] = set()
if not self.__mixed_only and self.__entries:
tag_ids = [t.id for t in tags_]
tag_entries = self.__driver.lib.get_tag_entries(tag_ids, self.__entries)
required = set(self.__entries)
for tag_id, entries in tag_entries.items():
if set(entries) < required:
partial_tag_ids.add(tag_id)
elif self.__mixed_only:
partial_tag_ids = {tag.id for tag in tags_}

super().set_tags(tags_, partial_tag_ids=partial_tag_ids)

@override
def _on_click(self, tag: Tag) -> None:
match self.__driver.settings.tag_click_action:
Expand Down
188 changes: 158 additions & 30 deletions src/tagstudio/qt/mixed/field_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QGraphicsOpacityEffect,
QHBoxLayout,
QMessageBox,
QScrollArea,
Expand Down Expand Up @@ -102,15 +103,100 @@ def top_entry_id(self) -> int:

def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
"""Update tags and fields from a single Entry source."""
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
self.update_from_selection(entry_id, update_badges)

entry = unwrap(self.lib.get_entry_full(entry_id))
self.cached_entries = [entry]
self.update_granular(entry.tags, entry.fields, update_badges)
def update_from_entries(self, entry_ids: list[int], update_badges: bool = True) -> None:
"""Update tags and fields from multiple Entry sources, showing shared tags."""
self.update_from_selection(entry_ids, update_badges)

def update_from_selection(self, entry_ids: int | list[int], update_badges: bool = True) -> None:
"""Update tags and fields from one or more Entry sources."""
entry_ids = [entry_ids] if isinstance(entry_ids, int) else list(entry_ids)
logger.warning("[FieldContainers] Updating Selection", entry_ids=entry_ids)

if len(entry_ids) == 1:
entries = [unwrap(self.lib.get_entry_full(entry_ids[0]))]
else:
entries = list(self.lib.get_entries_full(entry_ids))

if not entries:
self.cached_entries = []
self.hide_containers()
return

self.cached_entries = entries

if len(entries) == 1:
entry = entries[0]
self.update_granular(entry.tags, entry.fields, update_badges)
return

shared_tags = self._get_shared_tags(entries)
mixed_tags = set().union(*(entry.tags for entry in entries)) - shared_tags
shared_fields, mixed_fields = self._split_fields(entries)

next_index = self.update_granular(
shared_tags,
shared_fields,
update_badges,
hide_leftovers=False,
)

if mixed_tags or mixed_fields:
next_index = self.write_info_container(
next_index,
Translations["preview.partial_section"],
Translations["preview.partial_section_body"],
)

if mixed_tags:
categories = self.get_tag_categories(mixed_tags)
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
self.write_tag_container(next_index, tags=tags, category_tag=cat, is_mixed=True)
next_index += 1

for field in mixed_fields:
self.write_field_container(next_index, field, is_mixed=True)
next_index += 1

self.hide_unused_containers(next_index)

def _get_shared_tags(self, entries: list[Entry]) -> set[Tag]:
"""Get tags that are present in all entries."""
if not entries:
return set()

shared_tags = set(entries[0].tags)
for entry in entries[1:]:
shared_tags &= set(entry.tags)

return shared_tags

def _split_fields(self, entries: list[Entry]) -> tuple[list[BaseField], list[BaseField]]:
"""Split fields into shared and mixed groups for a multi-selection."""
all_fields_by_type: dict[tuple[str, str], list[BaseField]] = {}
for entry in entries:
for field in entry.fields:
all_fields_by_type.setdefault((field.name, field.class_name), []).append(field)

shared_fields: list[BaseField] = []
mixed_fields: list[BaseField] = []
for fields in all_fields_by_type.values():
if len(fields) == len(entries) and all(f.value == fields[0].value for f in fields):
shared_fields.append(fields[0])
else:
mixed_fields.append(fields[0])

return shared_fields, mixed_fields

def update_granular(
self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True
) -> None:
self,
entry_tags: set[Tag],
entry_fields: list[BaseField],
update_badges: bool = True,
*,
hide_leftovers: bool = True,
) -> int:
"""Individually update elements of the item preview."""
container_len: int = len(entry_fields)
container_index = 0
Expand All @@ -131,10 +217,10 @@ def update_granular(
self.write_field_container(index, field, is_mixed=False)

# Hide leftover container(s)
if len(self._containers) > container_len:
for i, c in enumerate(self._containers):
if i > (container_len - 1):
c.setHidden(True)
if hide_leftovers:
self.hide_unused_containers(container_len)

return container_len

def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None:
"""Visually add or remove a tag from the item preview without needing to query the db."""
Expand All @@ -154,6 +240,12 @@ def hide_containers(self) -> None:
for c in self._containers:
c.setHidden(True)

def hide_unused_containers(self, visible_count: int) -> None:
"""Hide containers that are no longer part of the active selection view."""
for i, container in enumerate(self._containers):
if i >= visible_count:
container.setHidden(True)

def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
"""Get a dictionary of category tags mapped to their respective tags.

Expand Down Expand Up @@ -245,6 +337,15 @@ def add_tags_to_selected(self, tag_ids: int | list[int]) -> None:
)
self.driver.add_tags_to_selected_callback(tag_ids)

def set_container_partial(self, container: FieldContainer, is_partial: bool) -> None:
"""Apply a visual partial-selection treatment to a container."""
if is_partial:
effect = QGraphicsOpacityEffect(container)
effect.setOpacity(0.7)
container.setGraphicsEffect(effect)
else:
container.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]

def update_text_field_callback(
self, field: TextField, entry_id: int, content: dict[str, str | bool]
) -> None:
Expand Down Expand Up @@ -374,6 +475,12 @@ def write_unknown_container():
else:
container = self._containers[index]

self.set_container_partial(container, is_mixed)
# Reset any callbacks left over from this container's previous contents, since not
# every branch below re-assigns them (e.g. mixed fields are not editable/removable).
container.set_edit_callback()
container.set_remove_callback()

# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
title = f"{field.name} ({Translations[field_name_key]})"
Expand All @@ -388,6 +495,25 @@ def write_unknown_container():

container.setHidden(False)

def write_info_container(self, index: int, title: str, text: str) -> int:
"""Render a non-interactive informational container."""
logger.info("[FieldContainers][write_info_container]", index=index)
if len(self._containers) < (index + 1):
container = FieldContainer()
self._containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self._containers[index]

self.set_container_partial(container, is_partial=False)
container.set_title(title)
container.set_inner_widget(TextContainerWidget(title, text))
container.set_copy_callback()
container.set_edit_callback()
container.set_remove_callback()
container.setHidden(False)
return index + 1

def write_tag_container(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
) -> None:
Expand All @@ -408,32 +534,34 @@ def write_tag_container(
else:
container = self._containers[index]

self.set_container_partial(container, is_mixed)
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)

if not is_mixed:
inner_widget = container.get_inner_widget()

if isinstance(inner_widget, TagBoxWidget):
with catch_warnings(record=True):
inner_widget.on_update.disconnect()
inner_widget = container.get_inner_widget()

else:
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
container.set_inner_widget(inner_widget)
inner_widget.set_entries([e.id for e in self.cached_entries])
inner_widget.set_tags(tags)

inner_widget.on_update.connect(
lambda: (
self.update_from_entry(self.cached_entries[0].id, update_badges=True),
self.on_tags_update.emit(),
)
)
if isinstance(inner_widget, TagBoxWidget):
with catch_warnings(record=True):
inner_widget.on_update.disconnect()
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
container.set_inner_widget(inner_widget)

# For mixed tag containers, mark the widget so it can gray out all tags.
inner_widget.set_mixed_only(is_mixed)

inner_widget.set_entries([e.id for e in self.cached_entries])
inner_widget.set_tags(tags)

def update_callback():
if len(self.cached_entries) == 1:
self.update_from_entry(self.cached_entries[0].id, update_badges=True)
else:
entry_ids = [e.id for e in self.cached_entries]
self.update_from_entries(entry_ids, update_badges=True)
self.on_tags_update.emit()

inner_widget.on_update.connect(update_callback)

container.set_edit_callback()
container.set_remove_callback()
container.setHidden(False)
Expand Down
Loading