diff --git a/docs/library-changes.md b/docs/library-changes.md index d37bbedb0..1f27f7c00 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -123,3 +123,13 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | - Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted. + +#### Version 104 + +| Used From | Format | Location | +| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | ``/.TagStudio/ts_library.sqlite | + +- Adds a new `url_fields` table. +- Changes the type key of the `URL` and `SOURCE` fields to be `URL`. +- Migrates any records in the `text_fields` whose type key has the type `URL` (so, if their type is either `URL` or `SOURCE`) to the new `url_fields` table. diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 83aab71b0..7280acaf9 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -11,7 +11,7 @@ DB_VERSION_LEGACY_KEY: str = "DB_VERSION" DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 102 +DB_VERSION: int = 104 TAG_CHILDREN_QUERY = text(""" WITH RECURSIVE ChildTags AS ( diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 76f7fa124..4d00fb80d 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -151,6 +151,7 @@ def with_search_query(self, search_query: str) -> "BrowsingState": class FieldTypeEnum(enum.Enum): TEXT_LINE = "Text Line" TEXT_BOX = "Text Box" + URL = "URL" TAGS = "Tags" DATETIME = "Datetime" BOOLEAN = "Checkbox" diff --git a/src/tagstudio/core/library/alchemy/fields.py b/src/tagstudio/core/library/alchemy/fields.py index faffae079..8993b8d0f 100644 --- a/src/tagstudio/core/library/alchemy/fields.py +++ b/src/tagstudio/core/library/alchemy/fields.py @@ -88,13 +88,29 @@ def __eq__(self, value: object) -> bool: raise NotImplementedError +class UrlField(BaseField): + __tablename__ = "url_fields" + + title: Mapped[str | None] + value: Mapped[str | None] + + def __key(self) -> tuple[ValueType, str | None, str | None]: + return self.type, self.title, self.value + + @override + def __eq__(self, value: object) -> bool: + if isinstance(value, UrlField): + return self.__key() == value.__key() + raise NotImplementedError + + class DatetimeField(BaseField): __tablename__ = "datetime_fields" value: Mapped[str | None] def __key(self): - return (self.type, self.value) + return self.type, self.value @override def __eq__(self, value: object) -> bool: @@ -117,7 +133,7 @@ class FieldID(Enum): TITLE = DefaultField(id=0, name="Title", type=FieldTypeEnum.TEXT_LINE, is_default=True) AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE) ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE) - URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE) + URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.URL) DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_BOX) NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX) COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE) @@ -132,7 +148,7 @@ class FieldID(Enum): COMIC = DefaultField(id=18, name="Comic", type=FieldTypeEnum.TEXT_LINE) SERIES = DefaultField(id=19, name="Series", type=FieldTypeEnum.TEXT_LINE) MANGA = DefaultField(id=20, name="Manga", type=FieldTypeEnum.TEXT_LINE) - SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.TEXT_LINE) + SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.URL) DATE_UPLOADED = DefaultField(id=22, name="Date Uploaded", type=FieldTypeEnum.DATETIME) DATE_RELEASED = DefaultField(id=23, name="Date Released", type=FieldTypeEnum.DATETIME) VOLUME = DefaultField(id=24, name="Volume", type=FieldTypeEnum.TEXT_LINE) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index a25231e95..09751b387 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -89,6 +89,7 @@ DatetimeField, FieldID, TextField, + UrlField, ) from tagstudio.core.library.alchemy.joins import TagEntry, TagParent from tagstudio.core.library.alchemy.models import ( @@ -551,6 +552,9 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: self.__apply_db100_parent_repairs(session) if loaded_db_version < 102: self.__apply_db102_repairs(session) + if loaded_db_version < 104: + self.__apply_db104_value_type_migration(session) + self.__apply_db104_url_migration(session) # Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist self.migrate_sql_to_ts_ignore(library_dir) @@ -698,6 +702,66 @@ def __apply_db102_repairs(self, session: Session): session.commit() logger.info("[Library][Migration] Verified TagParent table data") + def __apply_db104_value_type_migration(self, session: Session): + """Changes the type of the URL field types to URL.""" + try: + with session: + stmt = ( + update(ValueType) + .filter(ValueType.key.in_([FieldID.URL.name, FieldID.SOURCE.name])) + .values( + type=FieldTypeEnum.URL.name, + ) + ) + + session.execute(stmt) + session.commit() + logger.info("[Library][Migration] Changed the type of the URL field types to URL!") + except Exception as e: + logger.error( + "[Library][Migration] Could not change the type of the URL field types to URL!", + error=e, + ) + session.rollback() + + def __apply_db104_url_migration(self, session: Session): + """Moves all URL text fields to the new URL field table.""" + try: + with session: + # Get all URL fields from the text fields table + source_records = ( + session.query(TextField) + .join(ValueType) + .filter(ValueType.type == FieldTypeEnum.URL.name) + .with_for_update() + .all() + ) + + destination_records = [] + for source_record in source_records: + destination_record = UrlField( + title=None, + value=source_record.value, + type_key=source_record.type_key, + entry_id=source_record.entry_id, + position=source_record.position, + ) + destination_records.append(destination_record) + + for record in source_records: + session.delete(record) + + session.add_all(destination_records) + session.commit() + + logger.info("[Library][Migration] Migrated URL fields to the url_fields table") + except Exception as e: + logger.error( + "[Library][Migration] Could not migrate URL fields to the url_fields table!", + error=e, + ) + session.rollback() + def migrate_sql_to_ts_ignore(self, library_dir: Path): # Do not continue if existing '.ts_ignore' file is found if Path(library_dir / TS_FOLDER_NAME / IGNORE_NAME).exists(): @@ -762,9 +826,11 @@ def get_entry_full( if with_fields: entry_stmt = ( entry_stmt.outerjoin(Entry.text_fields) + .outerjoin(Entry.url_fields) .outerjoin(Entry.datetime_fields) .options( selectinload(Entry.text_fields), + selectinload(Entry.url_fields), selectinload(Entry.datetime_fields), ) ) @@ -811,11 +877,13 @@ def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]: statement = select(Entry).where(Entry.id.in_(set(entry_ids))) statement = ( statement.outerjoin(Entry.text_fields) + .outerjoin(Entry.url_fields) .outerjoin(Entry.datetime_fields) .outerjoin(Entry.tags) ) statement = statement.options( selectinload(Entry.text_fields), + selectinload(Entry.url_fields), selectinload(Entry.datetime_fields), selectinload(Entry.tags).options( selectinload(Tag.aliases), @@ -839,8 +907,13 @@ def get_entry_full_by_path(self, path: Path) -> Entry | None: stmt = select(Entry).where(Entry.path == path) stmt = ( stmt.outerjoin(Entry.text_fields) + .outerjoin(Entry.url_fields) .outerjoin(Entry.datetime_fields) - .options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields)) + .options( + selectinload(Entry.text_fields), + selectinload(Entry.url_fields), + selectinload(Entry.datetime_fields), + ) ) stmt = ( stmt.outerjoin(Entry.tags) @@ -885,11 +958,13 @@ def all_entries(self, with_joins: bool = False) -> Iterator[Entry]: # load Entry with all joins and all tags stmt = ( stmt.outerjoin(Entry.text_fields) + .outerjoin(Entry.url_fields) .outerjoin(Entry.datetime_fields) .outerjoin(Entry.tags) ) stmt = stmt.options( contains_eager(Entry.text_fields), + contains_eager(Entry.url_fields), contains_eager(Entry.datetime_fields), contains_eager(Entry.tags), ) @@ -1224,12 +1299,7 @@ def remove_entry_field( # recalculate the remaining positions # self.update_field_position(type(field), field.type, entry_ids) - def update_entry_field( - self, - entry_ids: list[int] | int, - field: BaseField, - content: str | datetime, - ): + def update_entry_field(self, entry_ids: list[int] | int, field: BaseField, **kwargs): if isinstance(entry_ids, int): entry_ids = [entry_ids] @@ -1245,7 +1315,7 @@ def update_entry_field( FieldClass.entry_id.in_(entry_ids), ) ) - .values(value=content) + .values(**kwargs) ) session.execute(update_stmt) @@ -1268,14 +1338,14 @@ def add_field_to_entry( *, field: ValueType | None = None, field_id: FieldID | str | None = None, - value: str | datetime | None = None, + **kwargs, ) -> bool: logger.info( "[Library][add_field_to_entry]", entry_id=entry_id, field_type=field, field_id=field_id, - value=value, + **kwargs, ) # supply only instance or ID, not both assert bool(field) != (field_id is not None) @@ -1285,20 +1355,16 @@ def add_field_to_entry( field_id = field_id.name field = self.get_value_type(unwrap(field_id)) - field_model: TextField | DatetimeField - if field.type in (FieldTypeEnum.TEXT_LINE, FieldTypeEnum.TEXT_BOX): - field_model = TextField( - type_key=field.key, - value=value or "", - ) - - elif field.type == FieldTypeEnum.DATETIME: - field_model = DatetimeField( - type_key=field.key, - value=value, - ) - else: - raise NotImplementedError(f"field type not implemented: {field.type}") + field_model: TextField | UrlField | DatetimeField + match field.type: + case FieldTypeEnum.TEXT_LINE | FieldTypeEnum.TEXT_BOX: + field_model = TextField(type_key=field.key, **kwargs) + case FieldTypeEnum.URL: + field_model = UrlField(type_key=field.key, **kwargs) + case FieldTypeEnum.DATETIME: + field_model = DatetimeField(type_key=field.key, **kwargs) + case _: + raise NotImplementedError(f"field type not implemented: {field.type}") with Session(self.engine) as session: try: diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 7cebf62a3..02c974462 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -18,6 +18,7 @@ BooleanField, DatetimeField, TextField, + UrlField, ) from tagstudio.core.library.alchemy.joins import TagParent @@ -211,6 +212,10 @@ class Entry(Base): back_populates="entry", cascade="all, delete", ) + url_fields: Mapped[list[UrlField]] = relationship( + back_populates="entry", + cascade="all, delete", + ) datetime_fields: Mapped[list[DatetimeField]] = relationship( back_populates="entry", cascade="all, delete", @@ -220,6 +225,7 @@ class Entry(Base): def fields(self) -> list[BaseField]: fields: list[BaseField] = [] fields.extend(self.text_fields) + fields.extend(self.url_fields) fields.extend(self.datetime_fields) fields = sorted(fields, key=lambda field: field.type.position) return fields @@ -260,6 +266,8 @@ def __init__( for field in fields: if isinstance(field, TextField): self.text_fields.append(field) + elif isinstance(field, UrlField): + self.url_fields.append(field) elif isinstance(field, DatetimeField): self.datetime_fields.append(field) else: @@ -295,6 +303,7 @@ class ValueType(Base): # add relations to other tables text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type") + url_fields: Mapped[list[UrlField]] = relationship("UrlField", back_populates="type") datetime_fields: Mapped[list[DatetimeField]] = relationship( "DatetimeField", back_populates="type" ) @@ -305,6 +314,7 @@ def as_field(self) -> BaseField: FieldClass = { # noqa: N806 FieldTypeEnum.TEXT_LINE: TextField, FieldTypeEnum.TEXT_BOX: TextField, + FieldTypeEnum.URL: UrlField, FieldTypeEnum.DATETIME: DatetimeField, FieldTypeEnum.BOOLEAN: BooleanField, } diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 7f17dbf29..ad953fced 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -29,6 +29,7 @@ BaseField, DatetimeField, TextField, + UrlField, ) from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag @@ -37,9 +38,11 @@ from tagstudio.qt.mixed.datetime_picker import DatetimePicker from tagstudio.qt.mixed.field_widget import FieldContainer from tagstudio.qt.mixed.text_field import TextWidget +from tagstudio.qt.mixed.url_widget import UrlWidget from tagstudio.qt.translations import Translations from tagstudio.qt.views.edit_text_box_modal import EditTextBox from tagstudio.qt.views.edit_text_line_modal import EditTextLine +from tagstudio.qt.views.edit_url_modal import EditUrl from tagstudio.qt.views.panel_modal import PanelModal if typing.TYPE_CHECKING: @@ -282,8 +285,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): title=title, window_title=f"Edit {field.type.type.value}", save_callback=( - lambda content: ( - self.update_field(field, content), # type: ignore + lambda data: ( + self.update_field(field, value=data), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -321,8 +324,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): title=title, window_title=f"Edit {field.type.name}", save_callback=( - lambda content: ( - self.update_field(field, content), # type: ignore + lambda data: ( + self.update_field(field, value=data), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -338,6 +341,51 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) + elif field.type.type == FieldTypeEnum.URL: + logger.info("[FieldContainers][write_container] URL Line Field", field=field) + + container.set_title(field.type.name) + container.set_inline(False) + + # Normalize line endings in any text content. + if not is_mixed: + assert isinstance(field, UrlField) + url_title: str | None = field.title + url_value: str = field.value or "" + else: + url_title = "" + url_value = "Mixed Data" + + title = f"{field.type.name} ({field.type.type.value})" + inner_widget = UrlWidget(title, url_title, url_value) + container.set_inner_widget(inner_widget) + if not is_mixed: + modal = PanelModal( + EditUrl(url_title, url_value), + title=title, + window_title=f"Edit {field.type.type.value}", + save_callback=( + lambda data: ( + self.update_field(field, title=data[0], value=data[1]), + self.update_from_entry(self.cached_entries[0].id), + ) + ), + ) + if "pytest" in sys.modules: + # for better testability + container.modal = modal # pyright: ignore[reportAttributeAccessIssue] + + container.set_edit_callback(modal.show) + container.set_remove_callback( + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.type.value), + callback=lambda: ( + self.remove_field(field), + self.update_from_entry(self.cached_entries[0].id), + ), + ) + ) + elif field.type.type == FieldTypeEnum.DATETIME: logger.info("[FieldContainers][write_container] Datetime Field", field=field) if not is_mixed: @@ -361,8 +409,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): DatetimePicker(self.driver, field.value or dt.now()), title=f"Edit {field.type.name}", save_callback=( - lambda content: ( - self.update_field(field, content), # type: ignore + lambda data: ( + self.update_field(field, value=data), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -464,11 +512,11 @@ def remove_field(self, field: BaseField): entry_ids = [e.id for e in self.cached_entries] self.lib.remove_entry_field(field, entry_ids) - def update_field(self, field: BaseField, content: str) -> None: + def update_field(self, field: BaseField, **kwargs) -> None: """Update a field in all selected Entries, given a field object.""" assert isinstance( field, - TextField | DatetimeField, + TextField | DatetimeField | UrlField, ), f"instance: {type(field)}" entry_ids = [e.id for e in self.cached_entries] @@ -477,7 +525,7 @@ def update_field(self, field: BaseField, content: str) -> None: self.lib.update_entry_field( entry_ids, field, - content, + **kwargs, ) def remove_message_box(self, prompt: str, callback: Callable) -> None: diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/mixed/text_field.py index d8052ce96..2949247fc 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/mixed/text_field.py @@ -3,9 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import re - -from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel from tagstudio.qt.mixed.field_widget import FieldWidget @@ -21,25 +18,8 @@ def __init__(self, title, text: str) -> None: self.text_label = QLabel() self.text_label.setStyleSheet("font-size: 12px") self.text_label.setWordWrap(True) - self.text_label.setTextFormat(Qt.TextFormat.MarkdownText) - self.text_label.setOpenExternalLinks(True) - self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.base_layout.addWidget(self.text_label) self.set_text(text) def set_text(self, text: str): - text = linkify(text) self.text_label.setText(text) - - -# Regex from https://stackoverflow.com/a/6041965 -def linkify(text: str): - url_pattern = ( - r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-*]*[\w@?^=%&\/~+#-*])" - ) - return re.sub( - url_pattern, - lambda url: f'{url.group(0)}', - text, - flags=re.IGNORECASE, - ) diff --git a/src/tagstudio/qt/mixed/url_widget.py b/src/tagstudio/qt/mixed/url_widget.py new file mode 100644 index 000000000..52f5ab0ef --- /dev/null +++ b/src/tagstudio/qt/mixed/url_widget.py @@ -0,0 +1,29 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import structlog +from PySide6.QtCore import Qt + +from tagstudio.qt.mixed.text_field import TextWidget + +logger = structlog.get_logger(__name__) + + +def to_anchor(url_title: str | None, url_value: str) -> str: + if url_title is None: + url_title = url_value + + return f'{url_title}' + + +class UrlWidget(TextWidget): + def __init__(self, title, url_title: str | None, url_value: str) -> None: + super().__init__(title, to_anchor(url_title, url_value)) + self.setObjectName("urlLine") + self.text_label.setTextFormat(Qt.TextFormat.MarkdownText) + self.text_label.setOpenExternalLinks(True) + self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + self.set_url(url_title, url_value) + + def set_url(self, url_title: str | None, url_value: str) -> None: + self.set_text(to_anchor(url_title, url_value)) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 8d7edde30..b488da013 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -1209,7 +1209,16 @@ def paste_fields_action_callback(self): if field.type_key == e.type_key and field.value == e.value: exists = True if not exists: - self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value) + match field.type.type: + case FieldTypeEnum.URL: + self.lib.add_field_to_entry( + id, field_id=field.type_key, title=field.title, value=field.value + ) + case _: + self.lib.add_field_to_entry( + id, field_id=field.type_key, value=field.value + ) + self.lib.add_tags_to_entries(id, self.copy_buffer["tags"]) if len(self.selected) > 1: if TAG_ARCHIVED in self.copy_buffer["tags"]: diff --git a/src/tagstudio/qt/views/edit_text_box_modal.py b/src/tagstudio/qt/views/edit_text_box_modal.py index 3c3bce32b..d6e4c9fc9 100644 --- a/src/tagstudio/qt/views/edit_text_box_modal.py +++ b/src/tagstudio/qt/views/edit_text_box_modal.py @@ -19,7 +19,7 @@ def __init__(self, text): self.text_edit.setPlainText(text) self.root_layout.addWidget(self.text_edit) - def get_content(self) -> str: + def get_content(self): return self.text_edit.toPlainText() def reset(self): diff --git a/src/tagstudio/qt/views/edit_text_line_modal.py b/src/tagstudio/qt/views/edit_text_line_modal.py index 5ee933ccd..5e059789a 100644 --- a/src/tagstudio/qt/views/edit_text_line_modal.py +++ b/src/tagstudio/qt/views/edit_text_line_modal.py @@ -9,21 +9,24 @@ class EditTextLine(PanelWidget): - def __init__(self, text): + def __init__(self, text: str | None): super().__init__() self.setMinimumWidth(480) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) self.text = text self.text_edit = QLineEdit() - self.text_edit.setText(text) + self.text_edit.setText(text or "") self.root_layout.addWidget(self.text_edit) - def get_content(self) -> str: - return self.text_edit.text() + def get_content(self) -> str | None: + text: str | None = self.text_edit.text() + if text == "": + text = None + return text def reset(self): - self.text_edit.setText(self.text) + self.text_edit.setText(self.text or "") def add_callback(self, callback: Callable, event: str = "returnPressed"): if event == "returnPressed": diff --git a/src/tagstudio/qt/views/edit_url_modal.py b/src/tagstudio/qt/views/edit_url_modal.py new file mode 100644 index 000000000..e0506686d --- /dev/null +++ b/src/tagstudio/qt/views/edit_url_modal.py @@ -0,0 +1,68 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from collections.abc import Callable + +from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget + +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelWidget + + +class EditUrl(PanelWidget): + def __init__(self, url_title: str | None, url_value: str | None): + super().__init__() + self.url_title = url_title + self.url_value = url_value + + self.setMinimumWidth(480) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + + # Edit title + self.edit_title_widget = QWidget() + self.edit_title_layout = QHBoxLayout(self.edit_title_widget) + + self.edit_title_label = QLabel(Translations["field.url.edit_title"]) + self.edit_title_input = QLineEdit() + self.edit_title_input.setText(self.url_title or "") + + self.edit_title_layout.addWidget(self.edit_title_label) + self.edit_title_layout.addWidget(self.edit_title_input) + + # Edit URL + self.edit_url_widget = QWidget() + self.edit_url_layout = QHBoxLayout(self.edit_url_widget) + + self.edit_url_label = QLabel(Translations["field.url.edit_url"]) + self.edit_url_input = QLineEdit() + self.edit_url_input.setText(self.url_value or "") + + self.edit_url_layout.addWidget(self.edit_url_label) + self.edit_url_layout.addWidget(self.edit_url_input) + + self.root_layout.addWidget(self.edit_title_widget) + self.root_layout.addWidget(self.edit_url_widget) + + def get_content(self): + # Ensure that blank values preserve being None + url_title: str | None = self.edit_title_input.text() + if url_title == "": + url_title = None + + url_value: str | None = self.edit_url_input.text() + if url_value == "": + url_value = None + + return url_title, url_value + + def reset(self): + self.edit_title_input.setText(self.url_title or "") + self.edit_url_input.setText(self.url_value or "") + + def add_callback(self, callback: Callable, event: str = "returnPressed"): + if event == "returnPressed": + self.edit_title_input.returnPressed.connect(callback) + self.edit_url_input.returnPressed.connect(callback) + else: + raise ValueError(f"unknown event type: {event}") diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py index 241e57f73..2fe271f3f 100755 --- a/src/tagstudio/qt/views/panel_modal.py +++ b/src/tagstudio/qt/views/panel_modal.py @@ -4,7 +4,7 @@ from collections.abc import Callable -from typing import override +from typing import Any, override import structlog from PySide6 import QtCore, QtGui @@ -27,7 +27,7 @@ def __init__( title: str = "", window_title: str | None = None, done_callback: Callable[[], None] | None = None, - save_callback: Callable[[str], None] | None = None, + save_callback: Callable[..., None | tuple[None, ...]] | None = None, has_save: bool = False, ): # [Done] @@ -120,8 +120,8 @@ class PanelWidget(QWidget): def __init__(self): super().__init__() - def get_content(self) -> str: - return "" + def get_content(self) -> tuple[Any, ...] | Any: + pass def reset(self) -> None: pass diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index edda02311..2153afc91 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -74,6 +74,8 @@ "field.copy": "Copy Field", "field.edit": "Edit Field", "field.paste": "Paste Field", + "field.url.edit_title": "Edit Title", + "field.url.edit_url": "Edit URL", "file.date_added": "Date Added", "file.date_created": "Date Created", "file.date_modified": "Date Modified", diff --git a/tests/test_library.py b/tests/test_library.py index 447344512..c4d44873f 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -235,7 +235,7 @@ def test_update_entry_field(library: Library, entry_full: Entry): library.update_entry_field( entry_full.id, title_field, - "new value", + value="new value", ) entry = next(library.all_entries(with_joins=True)) @@ -254,12 +254,12 @@ def test_update_entry_with_multiple_identical_fields(library: Library, entry_ful library.update_entry_field( entry_full.id, title_field, - "new value", + value="new value", ) # Then only one should be updated entry = next(library.all_entries(with_joins=True)) - assert entry.text_fields[0].value == "" + assert entry.text_fields[0].value is None assert entry.text_fields[1].value == "new value"